155 lines
7.6 KiB
Vue
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>
|