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
This commit is contained in:
@@ -1,85 +1,205 @@
|
||||
<script setup lang="ts">
|
||||
import type { Agent, ConversationEntry } from '../../types/agent'
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import type { Agent, TranscriptSession, TranscriptMessage } from '../../types/agent'
|
||||
|
||||
defineProps<{
|
||||
agent: Agent
|
||||
}>()
|
||||
|
||||
const mockEntries: ConversationEntry[] = [
|
||||
{
|
||||
id: '1',
|
||||
role: 'user',
|
||||
content: 'Revisa el módulo de autenticación y encuentra los errores de validación de tokens.',
|
||||
timestamp: '14:32',
|
||||
method: 'voice'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
role: 'agent',
|
||||
content: 'Encontré 3 issues en el validador de tokens JWT: expiración no verificada en refresh, falta sanitización del header Authorization, y el middleware no maneja tokens revocados.',
|
||||
timestamp: '14:33',
|
||||
method: 'text'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
role: 'user',
|
||||
content: 'Corrige los tres problemas y agrega tests unitarios para cada caso.',
|
||||
timestamp: '14:35',
|
||||
method: 'text'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
role: 'agent',
|
||||
content: 'Corregidos los 3 issues. Se agregaron 8 tests unitarios cubriendo cada caso: token expirado, header malformado, token revocado, y sus variantes edge-case. Todos los tests pasan.',
|
||||
timestamp: '14:38',
|
||||
method: 'text'
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
role: 'user',
|
||||
content: 'Ahora implementa un sistema de rate limiting para la API de login con un máximo de 5 intentos por minuto.',
|
||||
timestamp: '15:01',
|
||||
method: 'voice'
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
role: 'agent',
|
||||
content: 'Rate limiter implementado usando sliding window en Redis. Configurado a 5 intentos/minuto por IP. Retorna HTTP 429 con header Retry-After cuando se excede el límite.',
|
||||
timestamp: '15:04',
|
||||
method: 'text'
|
||||
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">{{ mockEntries.length }}</span>
|
||||
<span class="history-count">{{ messages.length }}</span>
|
||||
</div>
|
||||
<div class="history-list">
|
||||
|
||||
<!-- 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="entry in mockEntries"
|
||||
:key="entry.id"
|
||||
v-for="msg in messages"
|
||||
:key="msg.uuid"
|
||||
class="history-entry"
|
||||
:class="entry.role"
|
||||
:class="msg.role === 'assistant' ? 'agent' : 'user'"
|
||||
>
|
||||
<div class="entry-meta">
|
||||
<span class="role-badge" :class="entry.role">
|
||||
{{ entry.role === 'user' ? 'Tú' : agent.uiConfig?.shortLabel || 'AG' }}
|
||||
<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>
|
||||
<span class="entry-time">{{ entry.timestamp }}</span>
|
||||
<!-- Mic icon for voice entries -->
|
||||
<svg v-if="entry.method === 'voice'" class="method-icon" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
|
||||
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
|
||||
<line x1="12" y1="19" x2="12" y2="23"/>
|
||||
<line x1="8" y1="23" x2="16" y2="23"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="entry-content">{{ entry.content }}</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>
|
||||
|
||||
@@ -115,8 +235,53 @@ const mockEntries: ConversationEntry[] = [
|
||||
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: 300px;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -168,8 +333,13 @@ const mockEntries: ConversationEntry[] = [
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.method-icon {
|
||||
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 {
|
||||
@@ -178,8 +348,57 @@ const mockEntries: ConversationEntry[] = [
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user