Files
agent-ui/frontend/src/components/agent/ConversationHistory.vue
josedario87 f3ac7986ec feat: Add transcript engine API and connect ConversationHistory to real data
- Add transcript-engine service that parses Claude Code JSONL transcripts
  with session listing, message extraction, token/stats analysis, and caching
- Add transcript REST routes (sessions list, latest, by session ID, section filtering)
- Rewrite ConversationHistory to fetch from /api/transcript/* instead of mock data
- Add session pills for switching between conversation sessions
- Add stats bar footer with model, duration, tokens, and tool count
- Add TranscriptSession/TranscriptMessage types, ChatInput, InputSettings,
  PromptBar updates, TranscriptCard, and useVoiceCapture composable
2026-02-15 20:05:43 -06:00

405 lines
9.3 KiB
Vue

<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import type { Agent, TranscriptSession, TranscriptMessage } from '../../types/agent'
defineProps<{
agent: Agent
}>()
const sessions = ref<TranscriptSession[]>([])
const activeSessionId = ref<string | null>(null)
const messages = ref<TranscriptMessage[]>([])
const stats = ref<{
model: string
duration: number
toolCallCount: number
totalInput: number
totalOutput: number
} | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
// ── Helpers ──
function formatSessionTime(iso: string): string {
if (!iso) return '--:--'
const d = new Date(iso)
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })
}
function formatDuration(ms: number): string {
if (!ms || ms <= 0) return '0s'
const totalSec = Math.floor(ms / 1000)
const m = Math.floor(totalSec / 60)
const s = totalSec % 60
if (m === 0) return `${s}s`
return `${m}m ${s}s`
}
function formatTokens(n: number): string {
if (!n || n <= 0) return '0'
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`
return String(n)
}
function shortModel(model: string): string {
if (!model) return '?'
if (model.includes('opus')) return 'opus-4'
if (model.includes('sonnet')) return 'sonnet-4'
if (model.includes('haiku')) return 'haiku-4'
// Fallback: last segment
const parts = model.split('-')
return parts.slice(-2).join('-')
}
function truncateContent(text: string, max = 200): string {
if (!text || text.length <= max) return text || ''
return text.slice(0, max) + '...'
}
// ── Fetch helpers ──
function getBaseUrl(sessionId: string): string {
const firstSession = sessions.value[0]
if (firstSession && sessionId === firstSession.id) {
return '/api/transcript/latest'
}
return `/api/transcript/${sessionId}`
}
async function fetchSessions() {
try {
const res = await fetch('/api/transcript/sessions')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
sessions.value = await res.json()
} catch (e: any) {
error.value = `Error cargando sesiones: ${e.message}`
}
}
async function fetchSessionData(sessionId: string) {
loading.value = true
error.value = null
try {
const base = getBaseUrl(sessionId)
const [msgRes, statsRes, tokensRes] = await Promise.all([
fetch(`${base}?section=messages`),
fetch(`${base}?section=stats`),
fetch(`${base}?section=tokens`)
])
if (!msgRes.ok || !statsRes.ok || !tokensRes.ok) {
throw new Error('Error fetching session data')
}
const [msgData, statsData, tokensData] = await Promise.all([
msgRes.json(),
statsRes.json(),
tokensRes.json()
])
messages.value = msgData.messages || []
stats.value = {
model: statsData.model || '',
duration: statsData.duration || 0,
toolCallCount: statsData.stats?.toolCallCount || 0,
totalInput: tokensData.tokens?.totalInput || 0,
totalOutput: tokensData.tokens?.totalOutput || 0
}
} catch (e: any) {
error.value = `Error cargando datos: ${e.message}`
messages.value = []
stats.value = null
} finally {
loading.value = false
}
}
// ── Lifecycle ──
watch(activeSessionId, (id) => {
if (id) fetchSessionData(id)
})
onMounted(async () => {
await fetchSessions()
if (sessions.value.length > 0) {
activeSessionId.value = sessions.value[0].id
}
})
</script>
<template>
<div class="conversation-history">
<!-- Header -->
<div class="history-header">
<span class="history-title">Historial</span>
<span class="history-count">{{ messages.length }}</span>
</div>
<!-- Session Pills -->
<div v-if="sessions.length > 1" class="session-pills">
<button
v-for="session in sessions"
:key="session.id"
class="session-pill"
:class="{ active: session.id === activeSessionId }"
@click="activeSessionId = session.id"
>
{{ formatSessionTime(session.startTime) }}
<span class="pill-count">({{ session.messageCount }})</span>
</button>
</div>
<!-- Loading -->
<div v-if="loading" class="history-loading">
<span class="loading-dots">...</span>
</div>
<!-- Error -->
<div v-else-if="error" class="history-error">{{ error }}</div>
<!-- Empty -->
<div v-else-if="messages.length === 0 && !loading" class="history-empty">
Sin mensajes en esta sesión
</div>
<!-- Message List -->
<div v-else class="history-list">
<div
v-for="msg in messages"
:key="msg.uuid"
class="history-entry"
:class="msg.role === 'assistant' ? 'agent' : 'user'"
>
<div class="entry-meta">
<span
class="role-badge"
:class="msg.role === 'assistant' ? 'agent' : 'user'"
>
{{ msg.role === 'user' ? 'Tú' : agent.uiConfig?.shortLabel || 'AG' }}
</span>
<span class="entry-time">{{ formatSessionTime(msg.timestamp) }}</span>
<span v-if="msg.toolCalls && msg.toolCalls.length > 0" class="tool-badge">
{{ msg.toolCalls.length }} tools
</span>
</div>
<div class="entry-content">{{ truncateContent(msg.content) }}</div>
</div>
</div>
<!-- Stats Bar -->
<div v-if="stats && !loading && messages.length > 0" class="stats-bar">
<span>{{ shortModel(stats.model) }}</span>
<span class="stats-sep">&middot;</span>
<span>{{ formatDuration(stats.duration) }}</span>
<span class="stats-sep">&middot;</span>
<span>{{ formatTokens(stats.totalInput + stats.totalOutput) }}</span>
<span class="stats-sep">&middot;</span>
<span>{{ stats.toolCallCount }} tools</span>
</div>
</div>
</template>
<style scoped>
.conversation-history {
margin-top: 8px;
animation: slide-in 0.2s ease-out;
}
.history-header {
display: flex;
align-items: center;
gap: 8px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
margin-bottom: 8px;
}
.history-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: rgba(255, 255, 255, 0.4);
}
.history-count {
font-size: 10px;
font-weight: 700;
color: rgba(255, 255, 255, 0.3);
background: rgba(255, 255, 255, 0.06);
padding: 1px 6px;
border-radius: 8px;
}
/* Session Pills */
.session-pills {
display: flex;
gap: 6px;
overflow-x: auto;
padding-bottom: 8px;
margin-bottom: 8px;
scrollbar-width: none;
}
.session-pills::-webkit-scrollbar {
display: none;
}
.session-pill {
flex-shrink: 0;
font-size: 10px;
font-weight: 600;
padding: 3px 8px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
}
.session-pill:hover {
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.7);
}
.session-pill.active {
background: rgba(139, 92, 246, 0.15);
border-color: rgba(139, 92, 246, 0.3);
color: rgba(139, 92, 246, 0.9);
}
.pill-count {
opacity: 0.6;
margin-left: 2px;
}
/* Message List */
.history-list {
max-height: 220px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.1) transparent;
}
.history-entry {
padding: 8px 10px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.04);
}
.history-entry.agent {
background: rgba(139, 92, 246, 0.06);
border-color: rgba(139, 92, 246, 0.1);
}
.entry-meta {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.role-badge {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.3px;
padding: 1px 6px;
border-radius: 4px;
}
.role-badge.user {
color: rgba(59, 130, 246, 0.9);
background: rgba(59, 130, 246, 0.12);
}
.role-badge.agent {
color: rgba(139, 92, 246, 0.9);
background: rgba(139, 92, 246, 0.12);
}
.entry-time {
font-size: 10px;
color: rgba(255, 255, 255, 0.25);
}
.tool-badge {
font-size: 9px;
font-weight: 600;
padding: 1px 5px;
border-radius: 4px;
color: rgba(245, 158, 11, 0.9);
background: rgba(245, 158, 11, 0.12);
}
.entry-content {
font-size: 12px;
line-height: 1.5;
color: rgba(255, 255, 255, 0.7);
}
/* Stats Bar */
.stats-bar {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
font-family: monospace;
font-size: 10px;
color: rgba(255, 255, 255, 0.35);
}
.stats-sep {
opacity: 0.4;
}
/* States */
.history-loading {
padding: 16px 0;
text-align: center;
color: rgba(255, 255, 255, 0.3);
font-size: 12px;
}
.loading-dots {
animation: pulse 1s ease-in-out infinite;
}
.history-error {
padding: 12px 0;
text-align: center;
color: rgba(239, 68, 68, 0.7);
font-size: 11px;
}
.history-empty {
padding: 16px 0;
text-align: center;
color: rgba(255, 255, 255, 0.25);
font-size: 11px;
font-style: italic;
}
@keyframes slide-in {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes pulse {
0%, 100% { opacity: 0.3; }
50% { opacity: 1; }
}
</style>