- 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
405 lines
9.3 KiB
Vue
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">·</span>
|
|
<span>{{ formatDuration(stats.duration) }}</span>
|
|
<span class="stats-sep">·</span>
|
|
<span>{{ formatTokens(stats.totalInput + stats.totalOutput) }}</span>
|
|
<span class="stats-sep">·</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>
|