hermes/frontend/src/components/SystemMessage.vue
Nico ccee249618 v0.6.42: Hermes chat UI — Vue3/TS/Vite, audio STT/TTS, sidebar rail, MCP event loop
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 19:35:10 +02:00

155 lines
7.6 KiB
Vue

<template>
<div class="message-hud">
<!-- Boundary Headline: Agent name (Header) -->
<div v-if="msg.type === 'headline' && msg.headlineKind !== 'new-session' && msg.position !== 'footer'" class="headline-container headline-header">
<div class="headline-line"></div>
<div class="headline-text">{{ msg.content }}</div>
<div class="headline-line"></div>
</div>
<!-- Boundary Headline: Agent name (Footer) -->
<div v-else-if="msg.type === 'headline' && msg.headlineKind !== 'new-session' && msg.position === 'footer'" class="headline-footer-wrapper">
<div class="headline-container headline-footer">
<div class="headline-line"></div>
<div class="headline-text">{{ msg.content }}</div>
<div class="headline-line"></div>
</div>
</div>
<!-- Boundary Headline: New session (subheadline below agent name) -->
<div v-else-if="msg.type === 'headline' && msg.headlineKind === 'new-session'" class="headline-new-session">
<span class="new-session-text">{{ msg.content }}</span>
</div>
<!-- Grouped System Messages -->
<div v-else-if="msg.role === 'system_group' || msg.messages" class="system-group">
<div class="system-group-header" @click="isCollapsed = !isCollapsed">
<Cog6ToothIcon class="system-group-icon-svg" />
<span class="system-group-summary">{{ getSummary() }}</span>
<ChevronDownIcon class="chevron" :class="{ open: !isCollapsed }" />
</div>
<div v-if="!isCollapsed" class="system-group-content" ref="groupContentEl">
<template v-for="(subMsg, idx) in msg.messages" :key="idx">
<div class="system-item">
<template v-if="isSqlResult(subMsg.content)">
<div class="sql-table-wrap">
<table class="sql-table">
<thead>
<tr>
<th v-for="(col, ci) in parseSqlTable(subMsg.content).headers" :key="ci">{{ col }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, ri) in parseSqlTable(subMsg.content).rows" :key="ri">
<td v-for="(cell, ci) in row" :key="ci">{{ cell }}</td>
</tr>
</tbody>
</table>
</div>
</template>
<template v-else>
<div class="system-content raw-text" :title="subMsg.content">{{ subMsg.content }}</div>
</template>
</div>
</template>
</div>
</div>
<!-- Fallback Single System Message -->
<div v-else class="system-group">
<div class="system-group-header">
<InformationCircleIcon class="system-group-icon-svg" />
<span class="system-group-summary">{{ msg.content }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onUpdated, nextTick } from 'vue';
import { Cog6ToothIcon, ChevronDownIcon, InformationCircleIcon } from '@heroicons/vue/20/solid';
const props = defineProps<{ msg: any }>();
const isCollapsed = ref(false);
const groupContentEl = ref<HTMLElement | null>(null);
onUpdated(() => {
nextTick(() => {
if (groupContentEl.value) {
groupContentEl.value.scrollTop = groupContentEl.value.scrollHeight;
}
});
});
function isSqlResult(content: string): boolean {
if (!content) return false;
// Tool results start with "→ " — check after stripping that prefix
const raw = content.startsWith('→ ') ? content.slice(2) : content;
const lines = raw.split('\n').filter(l => l.trim());
if (lines.length < 2) return false;
// At least 2 lines with tabs = sql result
return lines.filter(l => l.includes('\t')).length >= 2;
}
function parseSqlTable(content: string): { headers: string[], rows: string[][] } {
const raw = content.startsWith('→ ') ? content.slice(2) : content;
const lines = raw.split('\n').filter(l => l.trim());
const [headerLine, ...dataLines] = lines;
const headers = headerLine.split('\t');
const rows = dataLines
.filter(l => !l.startsWith('… ['))
.map(l => l.split('\t'));
return { headers, rows };
}
function getSummary() {
const msgs = props.msg.messages;
if (!msgs?.length) return 'Event';
const toolNames: string[] = [];
for (const m of msgs) {
const raw = (m.content || '') as string;
const match = raw.match(/^[^\w]*(\w+)/u);
if (match) {
const name = match[1];
if (!['true', 'false', 'null', 'done', 'ok'].includes(name.toLowerCase())) {
if (!toolNames.includes(name)) toolNames.push(name);
}
}
}
const callCount = msgs.length;
const label = toolNames.length ? toolNames.join(' · ') : 'Event';
return callCount === 1 ? label : `${label} · ${callCount}`;
}
</script>
<style scoped>
.message-hud { display: contents; }
.system-group { margin: -0.75rem 0; border-radius: 8px; background: var(--bg-dim); display: inline-block; max-width: 80%; align-self: flex-start; }
.system-group-header { padding: 0.4rem 0.8rem; display: flex; align-items: center; gap: 0.5rem; cursor: pointer; user-select: none; }
.system-group-header:hover { opacity: 0.8; }
.system-group-icon-svg { width: 13px; height: 13px; color: var(--text-dim); opacity: 0.5; flex-shrink: 0; }
.system-group-summary { flex: 1; font-weight: 500; color: var(--text-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.system-group-content { padding: 0.25rem; display: flex; flex-direction: column; gap: 0.15rem; background: rgba(0, 0, 0, 0.1); max-height: 320px; overflow-y: auto; }
.system-item { padding: 0.15rem 0.5rem; border-radius: 4px; }
.headline-container { display: flex; align-items: center; width: 100%; }
.headline-container.headline-header { margin: 0.6rem 0 0.35rem; opacity: 1; }
.headline-container.headline-footer { margin: 0; opacity: 0.45; }
.headline-footer-wrapper { margin: 0; display: flex; flex-direction: column; align-items: center; }
.headline-text { padding: 0 0.75rem; font-weight: 700; color: var(--text-dim); white-space: nowrap; }
.headline-line { flex: 1; height: 1px; background: var(--border); }
.headline-container.headline-header .headline-line:first-child { max-width: 12px; background: var(--accent); }
.headline-container.headline-header .headline-text { color: var(--text); opacity: 0.7; }
.headline-new-session { display: flex; justify-content: center; margin: -0.5rem 0 0.75rem; opacity: 0.45; }
.new-session-text { font-weight: 500; color: var(--text-dim); }
.system-content { color: var(--text-dim); }
.system-content.raw-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; line-height: 1.4; }
.sql-table-wrap { overflow-x: auto; max-width: 600px; }
.sql-table { border-collapse: collapse; color: var(--text); white-space: nowrap; }
.sql-table th { background: var(--surface); color: var(--accent); font-weight: 600; padding: 3px 10px; text-align: left; border-bottom: 1px solid var(--border); }
.sql-table td { padding: 2px 10px; border-bottom: 1px solid var(--border); opacity: 0.85; }
.sql-table tr:last-child td { border-bottom: none; }
.sql-table tr:hover td { background: var(--bg-dim); opacity: 1; }
.chevron { width: 14px; height: 14px; color: var(--text-dim); opacity: 0.6; flex-shrink: 0; transition: transform 0.2s ease; transform: rotate(-90deg); }
.chevron.open { transform: rotate(0deg); }
</style>