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:
2026-02-15 20:05:27 -06:00
parent 68edc01d44
commit f3ac7986ec
10 changed files with 2246 additions and 97 deletions

View File

@@ -5,6 +5,7 @@ const props = defineProps<{
placeholder?: string
recording?: boolean
historyActive?: boolean
settingsActive?: boolean
autofocus?: boolean
}>()
@@ -12,6 +13,7 @@ const emit = defineEmits<{
submit: [text: string]
mic: []
'toggle-history': []
'toggle-settings': []
}>()
const inputText = ref('')
@@ -82,6 +84,17 @@ defineExpose({ focus })
<polyline points="12 6 12 12 16 14"/>
</svg>
</button>
<button
class="ci-btn ci-settings"
:class="{ active: settingsActive }"
title="Voice settings"
@click="emit('toggle-settings')"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</button>
</div>
</template>
@@ -148,7 +161,8 @@ defineExpose({ focus })
color: rgba(139, 92, 246, 0.9);
}
.ci-history.active {
.ci-history.active,
.ci-settings.active {
background: rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.7);
}

View File

@@ -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">&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>
@@ -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>

View File

@@ -0,0 +1,328 @@
<script setup lang="ts">
import type { VoiceCapture } from '../../composables/useVoiceCapture'
const props = defineProps<{
voice: VoiceCapture
}>()
const emit = defineEmits<{
close: []
}>()
function handleMicSelect(e: Event) {
const target = e.target as HTMLSelectElement
props.voice.selectMicrophone(target.value)
}
</script>
<template>
<div class="input-settings">
<div class="is-header">
<span class="is-title">Voice Settings</span>
<button class="is-close" @click="emit('close')">
<svg width="10" height="10" viewBox="0 0 10 10">
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
</div>
<!-- Voice Mode Toggle -->
<div class="is-section">
<label class="is-label">Mode</label>
<div class="is-mode-row">
<button
class="is-mode-btn"
:class="{ active: voice.voiceMode.value === 'webspeech' }"
:disabled="voice.isRecording.value"
@click="voice.voiceMode.value !== 'webspeech' && voice.toggleWhisperMode()"
>
<span class="is-mode-icon">Web</span>
<span class="is-mode-label">Speech API</span>
</button>
<button
class="is-mode-btn"
:class="{
active: voice.voiceMode.value === 'whisper',
loading: voice.whisperStatus.value === 'loading'
}"
:disabled="voice.isRecording.value"
@click="voice.voiceMode.value !== 'whisper' && voice.toggleWhisperMode()"
>
<span class="is-mode-icon">GPU</span>
<span class="is-mode-label">Whisper</span>
</button>
</div>
<div class="is-status">
<span
class="is-status-dot"
:class="{
ready: voice.whisperStatus.value === 'ready',
loading: voice.whisperStatus.value === 'loading',
offline: voice.whisperStatus.value === 'offline'
}"
></span>
<span class="is-status-text">
{{ voice.voiceMode.value === 'whisper'
? (voice.whisperStatus.value === 'ready' ? 'Whisper ready' : voice.whisperStatus.value === 'loading' ? 'Starting...' : 'Offline')
: 'Web Speech API'
}}
</span>
</div>
</div>
<!-- Microphone Selection -->
<div class="is-section">
<label class="is-label">Microphone</label>
<select
class="is-select"
:value="voice.selectedDeviceId.value"
@change="handleMicSelect"
>
<option
v-for="device in voice.audioDevices.value"
:key="device.deviceId"
:value="device.deviceId"
>
{{ device.label || `Microphone ${voice.audioDevices.value.indexOf(device) + 1}` }}
</option>
</select>
</div>
<!-- Debug: Last Audio Playback -->
<div class="is-section">
<label class="is-label">Debug</label>
<button
class="is-debug-btn"
:class="{ playing: voice.isPlayingAudio.value }"
:disabled="!voice.lastAudioUrl.value"
@click="voice.playLastAudio()"
>
<svg v-if="!voice.isPlayingAudio.value" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none">
<rect x="6" y="4" width="4" height="16" rx="1"/>
<rect x="14" y="4" width="4" height="16" rx="1"/>
</svg>
<span>{{ voice.isPlayingAudio.value ? 'Stop' : 'Play last audio' }}</span>
</button>
</div>
</div>
</template>
<style scoped>
.input-settings {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
padding: 12px;
margin-top: 8px;
animation: settings-in 0.2s ease-out;
}
.is-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.is-title {
font-size: 11px;
font-weight: 600;
color: rgba(255, 255, 255, 0.5);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.is-close {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.3);
cursor: pointer;
border-radius: 4px;
transition: all 0.15s;
}
.is-close:hover {
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.7);
}
.is-section {
margin-bottom: 10px;
}
.is-section:last-child {
margin-bottom: 0;
}
.is-label {
display: block;
font-size: 11px;
font-weight: 500;
color: rgba(255, 255, 255, 0.4);
margin-bottom: 6px;
}
.is-mode-row {
display: flex;
gap: 6px;
}
.is-mode-btn {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 8px 6px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
cursor: pointer;
transition: all 0.15s;
color: rgba(255, 255, 255, 0.4);
}
.is-mode-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.7);
}
.is-mode-btn.active {
background: rgba(139, 92, 246, 0.15);
border-color: rgba(139, 92, 246, 0.3);
color: rgba(139, 92, 246, 0.9);
}
.is-mode-btn.loading {
border-color: rgba(251, 191, 36, 0.3);
color: rgba(251, 191, 36, 0.8);
}
.is-mode-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.is-mode-icon {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.5px;
}
.is-mode-label {
font-size: 10px;
font-weight: 500;
}
.is-status {
display: flex;
align-items: center;
gap: 6px;
margin-top: 6px;
}
.is-status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
}
.is-status-dot.ready {
background: #22c55e;
box-shadow: 0 0 6px rgba(34, 197, 94, 0.4);
}
.is-status-dot.loading {
background: #fbbf24;
animation: status-pulse 1s ease-in-out infinite;
}
.is-status-dot.offline {
background: rgba(255, 255, 255, 0.2);
}
.is-status-text {
font-size: 11px;
color: rgba(255, 255, 255, 0.4);
}
.is-select {
width: 100%;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
padding: 7px 10px;
font-size: 12px;
color: rgba(255, 255, 255, 0.8);
outline: none;
cursor: pointer;
font-family: system-ui, sans-serif;
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='rgba(255,255,255,0.3)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
padding-right: 28px;
}
.is-select:focus {
border-color: rgba(255, 255, 255, 0.2);
}
.is-select option {
background: #1a1025;
color: rgba(255, 255, 255, 0.85);
}
.is-debug-btn {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 7px 10px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
color: rgba(255, 255, 255, 0.4);
font-size: 12px;
font-family: system-ui, sans-serif;
cursor: pointer;
transition: all 0.15s;
}
.is-debug-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.7);
}
.is-debug-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.is-debug-btn.playing {
background: rgba(59, 130, 246, 0.12);
border-color: rgba(59, 130, 246, 0.25);
color: rgba(59, 130, 246, 0.9);
}
@keyframes settings-in {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes status-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
</style>

View File

@@ -1,8 +1,11 @@
<script setup lang="ts">
import { ref, reactive, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
import type { Agent } from '../../types/agent'
import { useVoiceCapture } from '../../composables/useVoiceCapture'
import { useCanvasStore } from '../../stores/canvas'
import ChatInput from './ChatInput.vue'
import TranscriptCard from './TranscriptCard.vue'
import InputSettings from './InputSettings.vue'
import ConversationHistory from './ConversationHistory.vue'
interface ChatMessage {
@@ -35,11 +38,17 @@ const emit = defineEmits<{
submit: [text: string]
}>()
const canvasStore = useCanvasStore()
const voice = useVoiceCapture({
onNotification: (msg, type, duration) => canvasStore.showNotification(msg, type, duration)
})
const contentEl = ref<HTMLDivElement | null>(null)
const chatInputEl = ref<InstanceType<typeof ChatInput> | null>(null)
const isRecording = ref(false)
const showTranscript = ref(false)
const showHistory = ref(false)
const showSettings = ref(false)
const messages = reactive<ChatMessage[]>([])
const panelStyle = computed(() => {
@@ -60,7 +69,7 @@ const panelStyle = computed(() => {
})
const hasContent = computed(() =>
messages.length > 0 || showTranscript.value || showHistory.value
messages.length > 0 || showTranscript.value || showHistory.value || showSettings.value
)
async function scrollToBottom() {
@@ -100,6 +109,8 @@ function handleTranscriptDone(text: string) {
isRecording.value = false
showTranscript.value = false
if (!text.trim()) return
messages.push({ id: ++idCounter, role: 'user', content: text, status: 'sent' })
const agentMsg: ChatMessage = { id: ++idCounter, role: 'agent', content: '', status: 'thinking' }
@@ -108,6 +119,10 @@ function handleTranscriptDone(text: string) {
pushAgentResponse(agentMsg)
}
function toggleSettings() {
showSettings.value = !showSettings.value
}
function toggleHistory() {
showHistory.value = !showHistory.value
if (showHistory.value) scrollToBottom()
@@ -122,14 +137,22 @@ watch(() => props.visible, async (v) => {
isRecording.value = false
showTranscript.value = false
showHistory.value = false
showSettings.value = false
messages.length = 0
idCounter = 0
await voice.init()
await nextTick()
if (props.startRecording) {
handleMic()
} else {
chatInputEl.value?.focus()
}
} else {
// Cleanup when panel closes
if (voice.isRecording.value) {
voice.stopRecording()
}
voice.cleanup()
}
})
@@ -142,6 +165,7 @@ onMounted(() => {
onBeforeUnmount(() => {
document.removeEventListener('keydown', handleKeydown)
if (thinkTimer) clearTimeout(thinkTimer)
voice.cleanup()
})
</script>
@@ -187,7 +211,8 @@ onBeforeUnmount(() => {
</template>
</div>
<TranscriptCard v-if="showTranscript" @done="handleTranscriptDone" />
<TranscriptCard v-if="showTranscript" :voice="voice" @done="handleTranscriptDone" />
<InputSettings v-if="showSettings" :voice="voice" @close="showSettings = false" />
<ConversationHistory v-if="showHistory" :agent="agent" />
</div>
@@ -197,10 +222,12 @@ onBeforeUnmount(() => {
:placeholder="`Mensaje a ${agent.uiConfig?.label || agent.name}...`"
:recording="isRecording"
:history-active="showHistory"
:settings-active="showSettings"
:autofocus="visible"
@submit="handleSubmit"
@mic="handleMic"
@toggle-history="toggleHistory"
@toggle-settings="toggleSettings"
/>
</div>
</div>

View File

@@ -1,52 +1,62 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { onMounted, onBeforeUnmount } from 'vue'
import type { VoiceCapture } from '../../composables/useVoiceCapture'
const props = withDefaults(defineProps<{
typeSpeed?: number
}>(), {
typeSpeed: 30
})
const props = defineProps<{
voice: VoiceCapture
}>()
const emit = defineEmits<{
done: [text: string]
}>()
const PLACEHOLDER_TEXT = 'Necesito que revises el componente de autenticación en el módulo de usuarios. ' +
'Hay un problema con la validación de tokens JWT cuando el usuario tiene sesiones múltiples activas. ' +
'El token se invalida correctamente en el servidor pero el cliente sigue usando el token anterior ' +
'hasta que expira naturalmente. Quiero que implementes una verificación en tiempo real usando WebSocket ' +
'para notificar al cliente cuando su token ha sido revocado desde otra sesión.'
const displayedText = ref('')
let intervalId: number | null = null
let charIndex = 0
function handleStop() {
props.voice.stopRecording()
// Brief delay for final Whisper result
const delay = props.voice.voiceMode.value === 'whisper' ? 800 : 200
setTimeout(() => {
emit('done', props.voice.transcript.value.trim())
}, delay)
}
onMounted(() => {
intervalId = window.setInterval(() => {
if (charIndex < PLACEHOLDER_TEXT.length) {
displayedText.value += PLACEHOLDER_TEXT[charIndex]
charIndex++
} else {
if (intervalId) clearInterval(intervalId)
intervalId = null
emit('done', displayedText.value)
}
}, props.typeSpeed)
props.voice.clearTranscript()
props.voice.startRecording()
})
onBeforeUnmount(() => {
if (intervalId) clearInterval(intervalId)
if (props.voice.isRecording.value) {
props.voice.stopRecording()
}
})
</script>
<template>
<div class="transcript-card">
<div class="transcript-header">
<span class="rec-dot"></span>
<span class="rec-label">Transcribiendo...</span>
<div class="transcript-header-left">
<span class="rec-dot"></span>
<span class="rec-label">Transcribiendo...</span>
<span class="mode-badge" :class="voice.voiceMode.value">
{{ voice.voiceMode.value === 'whisper' ? 'GPU' : 'Web' }}
</span>
</div>
<button class="stop-btn" @click="handleStop" title="Stop recording">
<svg width="10" height="10" viewBox="0 0 10 10">
<rect x="1" y="1" width="8" height="8" rx="1" fill="currentColor"/>
</svg>
</button>
</div>
<div class="transcript-body">
<span class="transcript-text">{{ displayedText }}</span>
<!-- Error state -->
<div v-if="voice.error.value" class="transcript-error">
{{ voice.error.value }}
</div>
<!-- Transcript body -->
<div v-else class="transcript-body">
<span class="transcript-text">{{ voice.animatedTranscript.value }}</span>
<span v-if="voice.interimTranscript.value" class="interim-text">{{ voice.interimTranscript.value }}</span>
<span class="blink-cursor">|</span>
</div>
</div>
@@ -64,10 +74,16 @@ onBeforeUnmount(() => {
.transcript-header {
display: flex;
align-items: center;
gap: 8px;
justify-content: space-between;
margin-bottom: 8px;
}
.transcript-header-left {
display: flex;
align-items: center;
gap: 8px;
}
.rec-dot {
width: 8px;
height: 8px;
@@ -85,16 +101,67 @@ onBeforeUnmount(() => {
letter-spacing: 0.5px;
}
.mode-badge {
font-size: 9px;
font-weight: 700;
padding: 2px 5px;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.mode-badge.whisper {
background: rgba(139, 92, 246, 0.2);
color: rgba(139, 92, 246, 0.9);
border: 1px solid rgba(139, 92, 246, 0.3);
}
.mode-badge.webspeech {
background: rgba(59, 130, 246, 0.2);
color: rgba(59, 130, 246, 0.9);
border: 1px solid rgba(59, 130, 246, 0.3);
}
.stop-btn {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(239, 68, 68, 0.15);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 6px;
color: #ef4444;
cursor: pointer;
transition: all 0.15s;
}
.stop-btn:hover {
background: rgba(239, 68, 68, 0.25);
}
.transcript-body {
font-size: 13px;
line-height: 1.6;
color: rgba(255, 255, 255, 0.85);
min-height: 20px;
}
.transcript-text {
white-space: pre-wrap;
}
.interim-text {
color: rgba(255, 255, 255, 0.4);
font-style: italic;
}
.transcript-error {
font-size: 12px;
color: rgba(239, 68, 68, 0.8);
padding: 4px 0;
}
.blink-cursor {
color: rgba(255, 255, 255, 0.7);
animation: cursor-blink 0.8s step-end infinite;