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:
@@ -5,6 +5,7 @@ const props = defineProps<{
|
|||||||
placeholder?: string
|
placeholder?: string
|
||||||
recording?: boolean
|
recording?: boolean
|
||||||
historyActive?: boolean
|
historyActive?: boolean
|
||||||
|
settingsActive?: boolean
|
||||||
autofocus?: boolean
|
autofocus?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ const emit = defineEmits<{
|
|||||||
submit: [text: string]
|
submit: [text: string]
|
||||||
mic: []
|
mic: []
|
||||||
'toggle-history': []
|
'toggle-history': []
|
||||||
|
'toggle-settings': []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const inputText = ref('')
|
const inputText = ref('')
|
||||||
@@ -82,6 +84,17 @@ defineExpose({ focus })
|
|||||||
<polyline points="12 6 12 12 16 14"/>
|
<polyline points="12 6 12 12 16 14"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -148,7 +161,8 @@ defineExpose({ focus })
|
|||||||
color: rgba(139, 92, 246, 0.9);
|
color: rgba(139, 92, 246, 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ci-history.active {
|
.ci-history.active,
|
||||||
|
.ci-settings.active {
|
||||||
background: rgba(255, 255, 255, 0.12);
|
background: rgba(255, 255, 255, 0.12);
|
||||||
color: rgba(255, 255, 255, 0.7);
|
color: rgba(255, 255, 255, 0.7);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,85 +1,205 @@
|
|||||||
<script setup lang="ts">
|
<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<{
|
defineProps<{
|
||||||
agent: Agent
|
agent: Agent
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const mockEntries: ConversationEntry[] = [
|
const sessions = ref<TranscriptSession[]>([])
|
||||||
{
|
const activeSessionId = ref<string | null>(null)
|
||||||
id: '1',
|
const messages = ref<TranscriptMessage[]>([])
|
||||||
role: 'user',
|
const stats = ref<{
|
||||||
content: 'Revisa el módulo de autenticación y encuentra los errores de validación de tokens.',
|
model: string
|
||||||
timestamp: '14:32',
|
duration: number
|
||||||
method: 'voice'
|
toolCallCount: number
|
||||||
},
|
totalInput: number
|
||||||
{
|
totalOutput: number
|
||||||
id: '2',
|
} | null>(null)
|
||||||
role: 'agent',
|
const loading = ref(false)
|
||||||
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.',
|
const error = ref<string | null>(null)
|
||||||
timestamp: '14:33',
|
|
||||||
method: 'text'
|
// ── Helpers ──
|
||||||
},
|
|
||||||
{
|
function formatSessionTime(iso: string): string {
|
||||||
id: '3',
|
if (!iso) return '--:--'
|
||||||
role: 'user',
|
const d = new Date(iso)
|
||||||
content: 'Corrige los tres problemas y agrega tests unitarios para cada caso.',
|
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||||
timestamp: '14:35',
|
}
|
||||||
method: 'text'
|
|
||||||
},
|
function formatDuration(ms: number): string {
|
||||||
{
|
if (!ms || ms <= 0) return '0s'
|
||||||
id: '4',
|
const totalSec = Math.floor(ms / 1000)
|
||||||
role: 'agent',
|
const m = Math.floor(totalSec / 60)
|
||||||
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.',
|
const s = totalSec % 60
|
||||||
timestamp: '14:38',
|
if (m === 0) return `${s}s`
|
||||||
method: 'text'
|
return `${m}m ${s}s`
|
||||||
},
|
}
|
||||||
{
|
|
||||||
id: '5',
|
function formatTokens(n: number): string {
|
||||||
role: 'user',
|
if (!n || n <= 0) return '0'
|
||||||
content: 'Ahora implementa un sistema de rate limiting para la API de login con un máximo de 5 intentos por minuto.',
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
||||||
timestamp: '15:01',
|
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`
|
||||||
method: 'voice'
|
return String(n)
|
||||||
},
|
}
|
||||||
{
|
|
||||||
id: '6',
|
function shortModel(model: string): string {
|
||||||
role: 'agent',
|
if (!model) return '?'
|
||||||
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.',
|
if (model.includes('opus')) return 'opus-4'
|
||||||
timestamp: '15:04',
|
if (model.includes('sonnet')) return 'sonnet-4'
|
||||||
method: 'text'
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="conversation-history">
|
<div class="conversation-history">
|
||||||
|
<!-- Header -->
|
||||||
<div class="history-header">
|
<div class="history-header">
|
||||||
<span class="history-title">Historial</span>
|
<span class="history-title">Historial</span>
|
||||||
<span class="history-count">{{ mockEntries.length }}</span>
|
<span class="history-count">{{ messages.length }}</span>
|
||||||
</div>
|
</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
|
<div
|
||||||
v-for="entry in mockEntries"
|
v-for="msg in messages"
|
||||||
:key="entry.id"
|
:key="msg.uuid"
|
||||||
class="history-entry"
|
class="history-entry"
|
||||||
:class="entry.role"
|
:class="msg.role === 'assistant' ? 'agent' : 'user'"
|
||||||
>
|
>
|
||||||
<div class="entry-meta">
|
<div class="entry-meta">
|
||||||
<span class="role-badge" :class="entry.role">
|
<span
|
||||||
{{ entry.role === 'user' ? 'Tú' : agent.uiConfig?.shortLabel || 'AG' }}
|
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>
|
||||||
<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>
|
||||||
<div class="entry-content">{{ entry.content }}</div>
|
<div class="entry-content">{{ truncateContent(msg.content) }}</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -115,8 +235,53 @@ const mockEntries: ConversationEntry[] = [
|
|||||||
border-radius: 8px;
|
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 {
|
.history-list {
|
||||||
max-height: 300px;
|
max-height: 220px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -168,8 +333,13 @@ const mockEntries: ConversationEntry[] = [
|
|||||||
color: rgba(255, 255, 255, 0.25);
|
color: rgba(255, 255, 255, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.method-icon {
|
.tool-badge {
|
||||||
color: rgba(255, 255, 255, 0.25);
|
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 {
|
.entry-content {
|
||||||
@@ -178,8 +348,57 @@ const mockEntries: ConversationEntry[] = [
|
|||||||
color: rgba(255, 255, 255, 0.7);
|
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 {
|
@keyframes slide-in {
|
||||||
from { opacity: 0; transform: translateY(-8px); }
|
from { opacity: 0; transform: translateY(-8px); }
|
||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 0.3; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
328
frontend/src/components/agent/InputSettings.vue
Normal file
328
frontend/src/components/agent/InputSettings.vue
Normal 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>
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
|
import { ref, reactive, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import type { Agent } from '../../types/agent'
|
import type { Agent } from '../../types/agent'
|
||||||
|
import { useVoiceCapture } from '../../composables/useVoiceCapture'
|
||||||
|
import { useCanvasStore } from '../../stores/canvas'
|
||||||
import ChatInput from './ChatInput.vue'
|
import ChatInput from './ChatInput.vue'
|
||||||
import TranscriptCard from './TranscriptCard.vue'
|
import TranscriptCard from './TranscriptCard.vue'
|
||||||
|
import InputSettings from './InputSettings.vue'
|
||||||
import ConversationHistory from './ConversationHistory.vue'
|
import ConversationHistory from './ConversationHistory.vue'
|
||||||
|
|
||||||
interface ChatMessage {
|
interface ChatMessage {
|
||||||
@@ -35,11 +38,17 @@ const emit = defineEmits<{
|
|||||||
submit: [text: string]
|
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 contentEl = ref<HTMLDivElement | null>(null)
|
||||||
const chatInputEl = ref<InstanceType<typeof ChatInput> | null>(null)
|
const chatInputEl = ref<InstanceType<typeof ChatInput> | null>(null)
|
||||||
const isRecording = ref(false)
|
const isRecording = ref(false)
|
||||||
const showTranscript = ref(false)
|
const showTranscript = ref(false)
|
||||||
const showHistory = ref(false)
|
const showHistory = ref(false)
|
||||||
|
const showSettings = ref(false)
|
||||||
const messages = reactive<ChatMessage[]>([])
|
const messages = reactive<ChatMessage[]>([])
|
||||||
|
|
||||||
const panelStyle = computed(() => {
|
const panelStyle = computed(() => {
|
||||||
@@ -60,7 +69,7 @@ const panelStyle = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const hasContent = computed(() =>
|
const hasContent = computed(() =>
|
||||||
messages.length > 0 || showTranscript.value || showHistory.value
|
messages.length > 0 || showTranscript.value || showHistory.value || showSettings.value
|
||||||
)
|
)
|
||||||
|
|
||||||
async function scrollToBottom() {
|
async function scrollToBottom() {
|
||||||
@@ -100,6 +109,8 @@ function handleTranscriptDone(text: string) {
|
|||||||
isRecording.value = false
|
isRecording.value = false
|
||||||
showTranscript.value = false
|
showTranscript.value = false
|
||||||
|
|
||||||
|
if (!text.trim()) return
|
||||||
|
|
||||||
messages.push({ id: ++idCounter, role: 'user', content: text, status: 'sent' })
|
messages.push({ id: ++idCounter, role: 'user', content: text, status: 'sent' })
|
||||||
|
|
||||||
const agentMsg: ChatMessage = { id: ++idCounter, role: 'agent', content: '', status: 'thinking' }
|
const agentMsg: ChatMessage = { id: ++idCounter, role: 'agent', content: '', status: 'thinking' }
|
||||||
@@ -108,6 +119,10 @@ function handleTranscriptDone(text: string) {
|
|||||||
pushAgentResponse(agentMsg)
|
pushAgentResponse(agentMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleSettings() {
|
||||||
|
showSettings.value = !showSettings.value
|
||||||
|
}
|
||||||
|
|
||||||
function toggleHistory() {
|
function toggleHistory() {
|
||||||
showHistory.value = !showHistory.value
|
showHistory.value = !showHistory.value
|
||||||
if (showHistory.value) scrollToBottom()
|
if (showHistory.value) scrollToBottom()
|
||||||
@@ -122,14 +137,22 @@ watch(() => props.visible, async (v) => {
|
|||||||
isRecording.value = false
|
isRecording.value = false
|
||||||
showTranscript.value = false
|
showTranscript.value = false
|
||||||
showHistory.value = false
|
showHistory.value = false
|
||||||
|
showSettings.value = false
|
||||||
messages.length = 0
|
messages.length = 0
|
||||||
idCounter = 0
|
idCounter = 0
|
||||||
|
await voice.init()
|
||||||
await nextTick()
|
await nextTick()
|
||||||
if (props.startRecording) {
|
if (props.startRecording) {
|
||||||
handleMic()
|
handleMic()
|
||||||
} else {
|
} else {
|
||||||
chatInputEl.value?.focus()
|
chatInputEl.value?.focus()
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Cleanup when panel closes
|
||||||
|
if (voice.isRecording.value) {
|
||||||
|
voice.stopRecording()
|
||||||
|
}
|
||||||
|
voice.cleanup()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -142,6 +165,7 @@ onMounted(() => {
|
|||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
document.removeEventListener('keydown', handleKeydown)
|
document.removeEventListener('keydown', handleKeydown)
|
||||||
if (thinkTimer) clearTimeout(thinkTimer)
|
if (thinkTimer) clearTimeout(thinkTimer)
|
||||||
|
voice.cleanup()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -187,7 +211,8 @@ onBeforeUnmount(() => {
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</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" />
|
<ConversationHistory v-if="showHistory" :agent="agent" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -197,10 +222,12 @@ onBeforeUnmount(() => {
|
|||||||
:placeholder="`Mensaje a ${agent.uiConfig?.label || agent.name}...`"
|
:placeholder="`Mensaje a ${agent.uiConfig?.label || agent.name}...`"
|
||||||
:recording="isRecording"
|
:recording="isRecording"
|
||||||
:history-active="showHistory"
|
:history-active="showHistory"
|
||||||
|
:settings-active="showSettings"
|
||||||
:autofocus="visible"
|
:autofocus="visible"
|
||||||
@submit="handleSubmit"
|
@submit="handleSubmit"
|
||||||
@mic="handleMic"
|
@mic="handleMic"
|
||||||
@toggle-history="toggleHistory"
|
@toggle-history="toggleHistory"
|
||||||
|
@toggle-settings="toggleSettings"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,52 +1,62 @@
|
|||||||
<script setup lang="ts">
|
<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<{
|
const props = defineProps<{
|
||||||
typeSpeed?: number
|
voice: VoiceCapture
|
||||||
}>(), {
|
}>()
|
||||||
typeSpeed: 30
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
done: [text: string]
|
done: [text: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const PLACEHOLDER_TEXT = 'Necesito que revises el componente de autenticación en el módulo de usuarios. ' +
|
function handleStop() {
|
||||||
'Hay un problema con la validación de tokens JWT cuando el usuario tiene sesiones múltiples activas. ' +
|
props.voice.stopRecording()
|
||||||
'El token se invalida correctamente en el servidor pero el cliente sigue usando el token anterior ' +
|
// Brief delay for final Whisper result
|
||||||
'hasta que expira naturalmente. Quiero que implementes una verificación en tiempo real usando WebSocket ' +
|
const delay = props.voice.voiceMode.value === 'whisper' ? 800 : 200
|
||||||
'para notificar al cliente cuando su token ha sido revocado desde otra sesión.'
|
setTimeout(() => {
|
||||||
|
emit('done', props.voice.transcript.value.trim())
|
||||||
const displayedText = ref('')
|
}, delay)
|
||||||
let intervalId: number | null = null
|
}
|
||||||
let charIndex = 0
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
intervalId = window.setInterval(() => {
|
props.voice.clearTranscript()
|
||||||
if (charIndex < PLACEHOLDER_TEXT.length) {
|
props.voice.startRecording()
|
||||||
displayedText.value += PLACEHOLDER_TEXT[charIndex]
|
|
||||||
charIndex++
|
|
||||||
} else {
|
|
||||||
if (intervalId) clearInterval(intervalId)
|
|
||||||
intervalId = null
|
|
||||||
emit('done', displayedText.value)
|
|
||||||
}
|
|
||||||
}, props.typeSpeed)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (intervalId) clearInterval(intervalId)
|
if (props.voice.isRecording.value) {
|
||||||
|
props.voice.stopRecording()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="transcript-card">
|
<div class="transcript-card">
|
||||||
<div class="transcript-header">
|
<div class="transcript-header">
|
||||||
<span class="rec-dot"></span>
|
<div class="transcript-header-left">
|
||||||
<span class="rec-label">Transcribiendo...</span>
|
<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>
|
||||||
<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>
|
<span class="blink-cursor">|</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -64,10 +74,16 @@ onBeforeUnmount(() => {
|
|||||||
.transcript-header {
|
.transcript-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
justify-content: space-between;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.transcript-header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.rec-dot {
|
.rec-dot {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
@@ -85,16 +101,67 @@ onBeforeUnmount(() => {
|
|||||||
letter-spacing: 0.5px;
|
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 {
|
.transcript-body {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: rgba(255, 255, 255, 0.85);
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
min-height: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.transcript-text {
|
.transcript-text {
|
||||||
white-space: pre-wrap;
|
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 {
|
.blink-cursor {
|
||||||
color: rgba(255, 255, 255, 0.7);
|
color: rgba(255, 255, 255, 0.7);
|
||||||
animation: cursor-blink 0.8s step-end infinite;
|
animation: cursor-blink 0.8s step-end infinite;
|
||||||
|
|||||||
806
frontend/src/composables/useVoiceCapture.ts
Normal file
806
frontend/src/composables/useVoiceCapture.ts
Normal file
@@ -0,0 +1,806 @@
|
|||||||
|
import { ref, watch, type Ref } from 'vue'
|
||||||
|
import { endpoints } from '../config/endpoints'
|
||||||
|
|
||||||
|
// Web Speech API types (not in default TS lib)
|
||||||
|
interface SpeechRecognitionEvent extends Event {
|
||||||
|
resultIndex: number
|
||||||
|
results: SpeechRecognitionResultList
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpeechRecognitionErrorEvent extends Event {
|
||||||
|
error: string
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpeechRecognition extends EventTarget {
|
||||||
|
continuous: boolean
|
||||||
|
interimResults: boolean
|
||||||
|
lang: string
|
||||||
|
onresult: ((event: SpeechRecognitionEvent) => void) | null
|
||||||
|
onerror: ((event: SpeechRecognitionErrorEvent) => void) | null
|
||||||
|
onend: (() => void) | null
|
||||||
|
start(): void
|
||||||
|
stop(): void
|
||||||
|
abort(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VoiceMode = 'webspeech' | 'whisper'
|
||||||
|
export type WhisperStatus = 'offline' | 'loading' | 'ready'
|
||||||
|
|
||||||
|
export interface VoiceCapture {
|
||||||
|
// State
|
||||||
|
isRecording: Ref<boolean>
|
||||||
|
transcript: Ref<string>
|
||||||
|
interimTranscript: Ref<string>
|
||||||
|
animatedTranscript: Ref<string>
|
||||||
|
error: Ref<string>
|
||||||
|
voiceMode: Ref<VoiceMode>
|
||||||
|
whisperStatus: Ref<WhisperStatus>
|
||||||
|
audioDevices: Ref<MediaDeviceInfo[]>
|
||||||
|
selectedDeviceId: Ref<string>
|
||||||
|
isAndroid: Ref<boolean>
|
||||||
|
lastAudioUrl: Ref<string>
|
||||||
|
isPlayingAudio: Ref<boolean>
|
||||||
|
// Actions
|
||||||
|
startRecording: () => void
|
||||||
|
stopRecording: () => void
|
||||||
|
toggleWhisperMode: () => Promise<void>
|
||||||
|
checkWhisperStatus: () => Promise<any>
|
||||||
|
loadAudioDevices: () => Promise<void>
|
||||||
|
selectMicrophone: (deviceId: string) => void
|
||||||
|
playLastAudio: () => void
|
||||||
|
init: () => Promise<void>
|
||||||
|
cleanup: () => void
|
||||||
|
clearTranscript: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVoiceCapture(options?: {
|
||||||
|
onNotification?: (message: string, type: 'info' | 'success' | 'error', duration?: number) => void
|
||||||
|
}): VoiceCapture {
|
||||||
|
const notify = options?.onNotification || (() => {})
|
||||||
|
|
||||||
|
// ====== State ======
|
||||||
|
const isRecording = ref(false)
|
||||||
|
const transcript = ref('')
|
||||||
|
const interimTranscript = ref('')
|
||||||
|
const animatedTranscript = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
const voiceMode = ref<VoiceMode>('webspeech')
|
||||||
|
const whisperStatus = ref<WhisperStatus>('offline')
|
||||||
|
const audioDevices = ref<MediaDeviceInfo[]>([])
|
||||||
|
const selectedDeviceId = ref('')
|
||||||
|
const isAndroid = ref(false)
|
||||||
|
|
||||||
|
// Audio debug & save
|
||||||
|
const lastAudioUrl = ref('')
|
||||||
|
const isPlayingAudio = ref(false)
|
||||||
|
|
||||||
|
// ====== Internal state ======
|
||||||
|
let recognition: SpeechRecognition | null = null
|
||||||
|
let lastProcessedResult = ''
|
||||||
|
|
||||||
|
// Typing animation
|
||||||
|
let typingTimeout: number | null = null
|
||||||
|
let lastAnimatedLength = 0
|
||||||
|
|
||||||
|
// Whisper
|
||||||
|
const WHISPER_WS_URL = endpoints.whisper
|
||||||
|
let whisperSocket: WebSocket | null = null
|
||||||
|
let mediaRecorder: MediaRecorder | null = null
|
||||||
|
let audioChunks: Blob[] = []
|
||||||
|
let chunkInterval: number | null = null
|
||||||
|
const CHUNK_INTERVAL_MS = 3000
|
||||||
|
let mediaStream: MediaStream | null = null
|
||||||
|
let supportedMimeType = 'audio/webm;codecs=opus'
|
||||||
|
|
||||||
|
// Audio playback debug
|
||||||
|
let audioElement: HTMLAudioElement | null = null
|
||||||
|
let recordingStartTime = 0
|
||||||
|
|
||||||
|
// ====== Mobile / Audio Format ======
|
||||||
|
|
||||||
|
function checkMobile() {
|
||||||
|
const ua = navigator.userAgent
|
||||||
|
isAndroid.value = /Android/i.test(ua)
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectAudioFormat(): string {
|
||||||
|
const formats = [
|
||||||
|
'audio/webm;codecs=opus',
|
||||||
|
'audio/webm',
|
||||||
|
'audio/mp4',
|
||||||
|
'audio/mp4;codecs=mp4a.40.2',
|
||||||
|
'audio/aac',
|
||||||
|
'audio/ogg;codecs=opus',
|
||||||
|
'audio/wav'
|
||||||
|
]
|
||||||
|
for (const format of formats) {
|
||||||
|
if (MediaRecorder.isTypeSupported(format)) {
|
||||||
|
console.log(`[VoiceCapture] Using audio format: ${format}`)
|
||||||
|
return format
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.warn('[VoiceCapture] No preferred format supported, using default')
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== Web Speech API ======
|
||||||
|
|
||||||
|
function initRecognition(): SpeechRecognition | null {
|
||||||
|
const SpeechRecognitionCtor = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition
|
||||||
|
if (!SpeechRecognitionCtor) {
|
||||||
|
error.value = 'Speech recognition not supported in this browser'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const rec: SpeechRecognition = new SpeechRecognitionCtor()
|
||||||
|
rec.continuous = !isAndroid.value
|
||||||
|
rec.interimResults = true
|
||||||
|
rec.lang = 'es-419'
|
||||||
|
|
||||||
|
if (isAndroid.value) {
|
||||||
|
console.log('[VoiceCapture] Android detected - using non-continuous mode')
|
||||||
|
}
|
||||||
|
|
||||||
|
rec.onresult = (event: SpeechRecognitionEvent) => {
|
||||||
|
let interim = ''
|
||||||
|
let final = ''
|
||||||
|
|
||||||
|
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||||
|
const result = event.results[i]
|
||||||
|
if (!result || !result[0]) continue
|
||||||
|
if (result.isFinal) {
|
||||||
|
final += result[0].transcript + ' '
|
||||||
|
} else {
|
||||||
|
interim += result[0].transcript
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (final) {
|
||||||
|
const trimmedFinal = final.trim()
|
||||||
|
if (isAndroid.value && lastProcessedResult && trimmedFinal.startsWith(lastProcessedResult.trim())) {
|
||||||
|
const newPart = trimmedFinal.slice(lastProcessedResult.trim().length).trim()
|
||||||
|
if (newPart) {
|
||||||
|
transcript.value += newPart + ' '
|
||||||
|
lastProcessedResult = trimmedFinal
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
transcript.value += final
|
||||||
|
lastProcessedResult = trimmedFinal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
interimTranscript.value = interim
|
||||||
|
}
|
||||||
|
|
||||||
|
rec.onerror = (event: SpeechRecognitionErrorEvent) => {
|
||||||
|
// no-speech and aborted are transient — don't kill the session
|
||||||
|
if (event.error === 'no-speech' || event.error === 'aborted') {
|
||||||
|
console.log('[VoiceCapture] Transient error:', event.error, '(will auto-restart)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.error('[VoiceCapture] Recognition error:', event.error)
|
||||||
|
if (event.error === 'not-allowed') {
|
||||||
|
error.value = 'Microphone access denied'
|
||||||
|
} else {
|
||||||
|
error.value = `Error: ${event.error}`
|
||||||
|
}
|
||||||
|
isRecording.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
rec.onend = () => {
|
||||||
|
if (isRecording.value && voiceMode.value === 'webspeech') {
|
||||||
|
if (isAndroid.value) {
|
||||||
|
isRecording.value = false
|
||||||
|
console.log('[VoiceCapture] Android session ended - tap mic to continue')
|
||||||
|
} else {
|
||||||
|
rec.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== Whisper Functions ======
|
||||||
|
|
||||||
|
async function checkWhisperStatusFn(updateLoading = true): Promise<any> {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/whisper/status')
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.enabled) {
|
||||||
|
voiceMode.value = 'whisper'
|
||||||
|
}
|
||||||
|
if (data.running) {
|
||||||
|
whisperStatus.value = 'ready'
|
||||||
|
} else if (updateLoading && (data.starting || false)) {
|
||||||
|
whisperStatus.value = 'loading'
|
||||||
|
} else if (!data.running) {
|
||||||
|
if (voiceMode.value === 'whisper' && !data.starting) {
|
||||||
|
whisperStatus.value = 'offline'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
} catch {
|
||||||
|
voiceMode.value = 'webspeech'
|
||||||
|
whisperStatus.value = 'offline'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollWhisperStatus(): Promise<void> {
|
||||||
|
const maxAttempts = 60
|
||||||
|
let attempts = 0
|
||||||
|
|
||||||
|
while (attempts < maxAttempts) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||||
|
attempts++
|
||||||
|
|
||||||
|
try {
|
||||||
|
const status = await checkWhisperStatusFn(false)
|
||||||
|
if (!status) continue
|
||||||
|
|
||||||
|
if (status.starting) {
|
||||||
|
console.log(`[VoiceCapture] Still starting... (${attempts * 2}s)`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.running && status.enabled) {
|
||||||
|
console.log('[VoiceCapture] Server ready!')
|
||||||
|
notify('Whisper GPU ready!', 'success')
|
||||||
|
connectWhisperSocket()
|
||||||
|
whisperStatus.value = 'ready'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[VoiceCapture] Server failed to start')
|
||||||
|
notify('Whisper server failed to start', 'error')
|
||||||
|
whisperStatus.value = 'offline'
|
||||||
|
return
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[VoiceCapture] Polling error:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notify('Whisper server timeout', 'error')
|
||||||
|
whisperStatus.value = 'offline'
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectWhisperSocket() {
|
||||||
|
if (whisperStatus.value !== 'ready') {
|
||||||
|
console.log('[VoiceCapture] Whisper not ready, skipping connection')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (whisperSocket?.readyState === WebSocket.OPEN) return
|
||||||
|
|
||||||
|
console.log('[VoiceCapture] Connecting to Whisper at:', WHISPER_WS_URL)
|
||||||
|
whisperSocket = new WebSocket(WHISPER_WS_URL)
|
||||||
|
|
||||||
|
const connectionTimeout = setTimeout(() => {
|
||||||
|
if (whisperSocket && whisperSocket.readyState !== WebSocket.OPEN) {
|
||||||
|
console.error('[VoiceCapture] Whisper connection timeout (10s)')
|
||||||
|
whisperSocket.close()
|
||||||
|
whisperStatus.value = 'offline'
|
||||||
|
}
|
||||||
|
}, 10000)
|
||||||
|
|
||||||
|
whisperSocket.onopen = () => {
|
||||||
|
clearTimeout(connectionTimeout)
|
||||||
|
console.log('[VoiceCapture] Whisper WebSocket connected')
|
||||||
|
whisperStatus.value = 'ready'
|
||||||
|
}
|
||||||
|
|
||||||
|
whisperSocket.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(event.data)
|
||||||
|
|
||||||
|
if (msg.type === 'ready') {
|
||||||
|
console.log('[VoiceCapture] Whisper ready:', msg.model, msg.device)
|
||||||
|
whisperStatus.value = 'ready'
|
||||||
|
} else if (msg.type === 'transcription') {
|
||||||
|
if (msg.success && msg.text) {
|
||||||
|
const fullText = msg.text.trim()
|
||||||
|
if (msg.partial) {
|
||||||
|
transcript.value = fullText + ' '
|
||||||
|
interimTranscript.value = ''
|
||||||
|
} else {
|
||||||
|
transcript.value = fullText + ' '
|
||||||
|
interimTranscript.value = ''
|
||||||
|
console.log(`[VoiceCapture] WHISPER-GPU (${msg.model}/${msg.device}):`, fullText)
|
||||||
|
}
|
||||||
|
} else if (msg.error) {
|
||||||
|
error.value = msg.error
|
||||||
|
console.error('[VoiceCapture] Whisper error:', msg.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[VoiceCapture] Whisper message error:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
whisperSocket.onclose = () => {
|
||||||
|
console.log('[VoiceCapture] Whisper WebSocket closed')
|
||||||
|
whisperStatus.value = 'offline'
|
||||||
|
}
|
||||||
|
|
||||||
|
whisperSocket.onerror = (e) => {
|
||||||
|
console.error('[VoiceCapture] Whisper WebSocket error:', e)
|
||||||
|
whisperStatus.value = 'offline'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnectWhisperSocket() {
|
||||||
|
if (whisperSocket) {
|
||||||
|
whisperSocket.close()
|
||||||
|
whisperSocket = null
|
||||||
|
}
|
||||||
|
whisperStatus.value = 'offline'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startWhisperRecording() {
|
||||||
|
if (!whisperSocket || whisperSocket.readyState !== WebSocket.OPEN) {
|
||||||
|
console.warn('[VoiceCapture] Whisper socket not connected, attempting to connect...')
|
||||||
|
connectWhisperSocket()
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500))
|
||||||
|
|
||||||
|
if (!whisperSocket || whisperSocket.readyState !== WebSocket.OPEN) {
|
||||||
|
error.value = 'Whisper server not connected'
|
||||||
|
notify('Whisper not connected. Try toggling GPU mode.', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const audioConstraints: MediaTrackConstraints = {
|
||||||
|
echoCancellation: true,
|
||||||
|
noiseSuppression: true,
|
||||||
|
autoGainControl: true,
|
||||||
|
...(selectedDeviceId.value ? { deviceId: { exact: selectedDeviceId.value } } : {})
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: audioConstraints })
|
||||||
|
|
||||||
|
const recorderOptions: MediaRecorderOptions = {}
|
||||||
|
if (supportedMimeType) {
|
||||||
|
recorderOptions.mimeType = supportedMimeType
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaRecorder = new MediaRecorder(mediaStream, recorderOptions)
|
||||||
|
console.log(`[VoiceCapture] MediaRecorder using: ${mediaRecorder.mimeType}`)
|
||||||
|
|
||||||
|
audioChunks = []
|
||||||
|
|
||||||
|
mediaRecorder.ondataavailable = (event) => {
|
||||||
|
if (event.data.size > 0) {
|
||||||
|
audioChunks.push(event.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
audioChunks = []
|
||||||
|
mediaRecorder.start(100)
|
||||||
|
isRecording.value = true
|
||||||
|
recordingStartTime = Date.now()
|
||||||
|
console.log(`[VoiceCapture] Whisper recording started`)
|
||||||
|
|
||||||
|
// Permission granted via user gesture — reload devices with labels
|
||||||
|
loadAudioDevices(true)
|
||||||
|
|
||||||
|
chunkInterval = window.setInterval(() => {
|
||||||
|
if (audioChunks.length > 0 && whisperSocket?.readyState === WebSocket.OPEN) {
|
||||||
|
sendAudioChunk(false)
|
||||||
|
}
|
||||||
|
}, CHUNK_INTERVAL_MS)
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = `Microphone error: ${e.message}`
|
||||||
|
console.error('[VoiceCapture] Microphone error:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendAudioChunk(isFinal: boolean) {
|
||||||
|
if (audioChunks.length === 0) return
|
||||||
|
|
||||||
|
const mimeType = mediaRecorder?.mimeType || supportedMimeType || 'audio/webm'
|
||||||
|
const audioBlob = new Blob(audioChunks, { type: mimeType })
|
||||||
|
|
||||||
|
if (audioBlob.size < 5000) {
|
||||||
|
if (isFinal) audioChunks = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFinal) {
|
||||||
|
audioChunks = []
|
||||||
|
saveAudioForPlayback(audioBlob)
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onloadend = () => {
|
||||||
|
const base64 = (reader.result as string).split(',')[1]
|
||||||
|
if (whisperSocket?.readyState === WebSocket.OPEN) {
|
||||||
|
whisperSocket.send(JSON.stringify({
|
||||||
|
type: 'transcribe',
|
||||||
|
audio: base64,
|
||||||
|
language: 'es',
|
||||||
|
partial: !isFinal
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(audioBlob)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopWhisperRecording() {
|
||||||
|
if (chunkInterval) {
|
||||||
|
clearInterval(chunkInterval)
|
||||||
|
chunkInterval = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audioChunks.length > 0) {
|
||||||
|
sendAudioChunk(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
||||||
|
mediaRecorder.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mediaStream) {
|
||||||
|
mediaStream.getTracks().forEach(track => track.stop())
|
||||||
|
mediaStream = null
|
||||||
|
}
|
||||||
|
|
||||||
|
isRecording.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== Audio Save & Debug Playback ======
|
||||||
|
|
||||||
|
function currentMicName(): string {
|
||||||
|
if (!selectedDeviceId.value) return 'Default'
|
||||||
|
const device = audioDevices.value.find(d => d.deviceId === selectedDeviceId.value)
|
||||||
|
return device?.label || 'Microphone'
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveAudioForPlayback(blob: Blob) {
|
||||||
|
if (lastAudioUrl.value) {
|
||||||
|
URL.revokeObjectURL(lastAudioUrl.value)
|
||||||
|
}
|
||||||
|
lastAudioUrl.value = URL.createObjectURL(blob)
|
||||||
|
saveRecordingToBackend(blob)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRecordingToBackend(blob: Blob) {
|
||||||
|
try {
|
||||||
|
const duration_ms = Date.now() - recordingStartTime
|
||||||
|
const reader = new FileReader()
|
||||||
|
|
||||||
|
reader.onloadend = async () => {
|
||||||
|
const base64 = (reader.result as string).split(',')[1]
|
||||||
|
const response = await fetch('/api/recordings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
audio: base64,
|
||||||
|
transcription: transcript.value.trim(),
|
||||||
|
microphone: currentMicName(),
|
||||||
|
duration_ms
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const data = await response.json()
|
||||||
|
if (data.success) {
|
||||||
|
console.log(`[VoiceCapture] Recording saved: ${data.filename} (${(data.size / 1024).toFixed(1)} KB)`)
|
||||||
|
} else {
|
||||||
|
console.error('[VoiceCapture] Failed to save recording:', data.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reader.readAsDataURL(blob)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[VoiceCapture] Error saving recording:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function playLastAudio() {
|
||||||
|
if (!lastAudioUrl.value) return
|
||||||
|
|
||||||
|
if (isPlayingAudio.value && audioElement) {
|
||||||
|
audioElement.pause()
|
||||||
|
audioElement.currentTime = 0
|
||||||
|
isPlayingAudio.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
audioElement = new Audio(lastAudioUrl.value)
|
||||||
|
audioElement.onplay = () => { isPlayingAudio.value = true }
|
||||||
|
audioElement.onended = () => { isPlayingAudio.value = false }
|
||||||
|
audioElement.onpause = () => { isPlayingAudio.value = false }
|
||||||
|
audioElement.play().catch(e => {
|
||||||
|
console.error('[VoiceCapture] Failed to play audio:', e)
|
||||||
|
isPlayingAudio.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== Parallel Audio Capture (for Web Speech mode) ======
|
||||||
|
|
||||||
|
async function startAudioCapture() {
|
||||||
|
try {
|
||||||
|
const audioConstraints: MediaTrackConstraints = {
|
||||||
|
echoCancellation: true,
|
||||||
|
noiseSuppression: true,
|
||||||
|
autoGainControl: true,
|
||||||
|
...(selectedDeviceId.value ? { deviceId: { exact: selectedDeviceId.value } } : {})
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: audioConstraints })
|
||||||
|
|
||||||
|
const recorderOptions: MediaRecorderOptions = {}
|
||||||
|
if (supportedMimeType) {
|
||||||
|
recorderOptions.mimeType = supportedMimeType
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaRecorder = new MediaRecorder(mediaStream, recorderOptions)
|
||||||
|
audioChunks = []
|
||||||
|
|
||||||
|
mediaRecorder.ondataavailable = (event) => {
|
||||||
|
if (event.data.size > 0) {
|
||||||
|
audioChunks.push(event.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaRecorder.start(100)
|
||||||
|
recordingStartTime = Date.now()
|
||||||
|
console.log(`[VoiceCapture] Audio capture started (${mediaRecorder.mimeType})`)
|
||||||
|
|
||||||
|
// Permission is now granted via user gesture — reload devices with labels
|
||||||
|
loadAudioDevices(true)
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('[VoiceCapture] Audio capture error:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopAudioCapture() {
|
||||||
|
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
||||||
|
mediaRecorder.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build final blob and save
|
||||||
|
if (audioChunks.length > 0) {
|
||||||
|
const mimeType = mediaRecorder?.mimeType || supportedMimeType || 'audio/webm'
|
||||||
|
const audioBlob = new Blob(audioChunks, { type: mimeType })
|
||||||
|
audioChunks = []
|
||||||
|
if (audioBlob.size > 1000) {
|
||||||
|
saveAudioForPlayback(audioBlob)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mediaStream) {
|
||||||
|
mediaStream.getTracks().forEach(track => track.stop())
|
||||||
|
mediaStream = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== Public Recording API ======
|
||||||
|
|
||||||
|
function startRecording() {
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
if (voiceMode.value === 'whisper' && whisperStatus.value === 'ready') {
|
||||||
|
startWhisperRecording()
|
||||||
|
} else {
|
||||||
|
if (!recognition) {
|
||||||
|
recognition = initRecognition()
|
||||||
|
}
|
||||||
|
if (recognition) {
|
||||||
|
try {
|
||||||
|
recognition.start()
|
||||||
|
isRecording.value = true
|
||||||
|
// Capture raw audio in parallel for save/debug playback
|
||||||
|
startAudioCapture()
|
||||||
|
if (isAndroid.value) {
|
||||||
|
notify('Android: Tap mic again to continue recording', 'info', 3000)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[VoiceCapture] Failed to start:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRecording() {
|
||||||
|
if (voiceMode.value === 'whisper') {
|
||||||
|
stopWhisperRecording()
|
||||||
|
} else {
|
||||||
|
if (recognition) {
|
||||||
|
recognition.stop()
|
||||||
|
}
|
||||||
|
stopAudioCapture()
|
||||||
|
isRecording.value = false
|
||||||
|
}
|
||||||
|
interimTranscript.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleWhisperMode() {
|
||||||
|
if (whisperStatus.value === 'loading') return
|
||||||
|
|
||||||
|
whisperStatus.value = 'loading'
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
if (voiceMode.value !== 'whisper') {
|
||||||
|
notify('Starting Whisper GPU server...', 'info', 10000)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/whisper/toggle', { method: 'POST' })
|
||||||
|
const data = await res.json()
|
||||||
|
|
||||||
|
if (data.starting) {
|
||||||
|
console.log('[VoiceCapture] Server starting, polling...')
|
||||||
|
voiceMode.value = 'whisper'
|
||||||
|
await pollWhisperStatus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.enabled) {
|
||||||
|
voiceMode.value = 'whisper'
|
||||||
|
whisperStatus.value = data.running ? 'ready' : 'offline'
|
||||||
|
if (data.running) {
|
||||||
|
notify('Whisper GPU ready!', 'success')
|
||||||
|
connectWhisperSocket()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
voiceMode.value = 'webspeech'
|
||||||
|
whisperStatus.value = 'offline'
|
||||||
|
notify('Using Web Speech API', 'info')
|
||||||
|
disconnectWhisperSocket()
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = 'Failed to toggle Whisper'
|
||||||
|
notify('Error starting Whisper server', 'error')
|
||||||
|
console.error('[VoiceCapture] Whisper toggle error:', e)
|
||||||
|
whisperStatus.value = 'offline'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== Microphone ======
|
||||||
|
|
||||||
|
async function loadAudioDevices(skipPermissionRequest = false) {
|
||||||
|
try {
|
||||||
|
if (!skipPermissionRequest) {
|
||||||
|
// Request permission to get device labels
|
||||||
|
const tempStream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||||
|
tempStream.getTracks().forEach(track => track.stop())
|
||||||
|
}
|
||||||
|
|
||||||
|
const devices = await navigator.mediaDevices.enumerateDevices()
|
||||||
|
audioDevices.value = devices.filter(d => d.kind === 'audioinput')
|
||||||
|
|
||||||
|
if (!selectedDeviceId.value && audioDevices.value.length > 0) {
|
||||||
|
selectedDeviceId.value = audioDevices.value[0]?.deviceId || ''
|
||||||
|
}
|
||||||
|
console.log(`[VoiceCapture] Found ${audioDevices.value.length} audio devices`)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[VoiceCapture] Failed to enumerate devices:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectMicrophone(deviceId: string) {
|
||||||
|
selectedDeviceId.value = deviceId
|
||||||
|
if (isRecording.value) {
|
||||||
|
stopRecording()
|
||||||
|
setTimeout(() => startRecording(), 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== Typing Animation ======
|
||||||
|
|
||||||
|
function animateTyping(targetText: string) {
|
||||||
|
if (typingTimeout) {
|
||||||
|
clearTimeout(typingTimeout)
|
||||||
|
typingTimeout = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetText.length < animatedTranscript.value.length) {
|
||||||
|
animatedTranscript.value = targetText
|
||||||
|
lastAnimatedLength = targetText.length
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const startIndex = lastAnimatedLength
|
||||||
|
|
||||||
|
function typeNext(index: number) {
|
||||||
|
if (index <= targetText.length) {
|
||||||
|
animatedTranscript.value = targetText.substring(0, index)
|
||||||
|
lastAnimatedLength = index
|
||||||
|
if (index < targetText.length) {
|
||||||
|
const delay = 15 + Math.random() * 10
|
||||||
|
typingTimeout = window.setTimeout(() => typeNext(index + 1), delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
typeNext(startIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(transcript, (newVal) => {
|
||||||
|
animateTyping(newVal)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ====== Transcript ======
|
||||||
|
|
||||||
|
function clearTranscript() {
|
||||||
|
transcript.value = ''
|
||||||
|
interimTranscript.value = ''
|
||||||
|
animatedTranscript.value = ''
|
||||||
|
lastAnimatedLength = 0
|
||||||
|
lastProcessedResult = ''
|
||||||
|
if (typingTimeout) {
|
||||||
|
clearTimeout(typingTimeout)
|
||||||
|
typingTimeout = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== Lifecycle ======
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
recognition = initRecognition()
|
||||||
|
checkMobile()
|
||||||
|
supportedMimeType = detectAudioFormat()
|
||||||
|
// Only enumerate without getUserMedia — no user gesture here
|
||||||
|
// Devices will get full labels after first recording (user gesture)
|
||||||
|
await loadAudioDevices(true)
|
||||||
|
|
||||||
|
const status = await checkWhisperStatusFn()
|
||||||
|
if (status?.starting) {
|
||||||
|
console.log('[VoiceCapture] Server is starting, resuming polling...')
|
||||||
|
pollWhisperStatus()
|
||||||
|
} else if (voiceMode.value === 'whisper' && whisperStatus.value === 'ready') {
|
||||||
|
connectWhisperSocket()
|
||||||
|
} else if (voiceMode.value === 'whisper' && whisperStatus.value !== 'ready') {
|
||||||
|
console.log('[VoiceCapture] Whisper was enabled but server not running, disabling')
|
||||||
|
voiceMode.value = 'webspeech'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
stopRecording()
|
||||||
|
recognition = null
|
||||||
|
disconnectWhisperSocket()
|
||||||
|
if (chunkInterval) clearInterval(chunkInterval)
|
||||||
|
if (typingTimeout) clearTimeout(typingTimeout)
|
||||||
|
if (mediaStream) {
|
||||||
|
mediaStream.getTracks().forEach(track => track.stop())
|
||||||
|
mediaStream = null
|
||||||
|
}
|
||||||
|
if (audioElement) {
|
||||||
|
audioElement.pause()
|
||||||
|
audioElement = null
|
||||||
|
}
|
||||||
|
if (lastAudioUrl.value) {
|
||||||
|
URL.revokeObjectURL(lastAudioUrl.value)
|
||||||
|
lastAudioUrl.value = ''
|
||||||
|
}
|
||||||
|
isPlayingAudio.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
isRecording,
|
||||||
|
transcript,
|
||||||
|
interimTranscript,
|
||||||
|
animatedTranscript,
|
||||||
|
error,
|
||||||
|
voiceMode,
|
||||||
|
whisperStatus,
|
||||||
|
audioDevices,
|
||||||
|
selectedDeviceId,
|
||||||
|
isAndroid,
|
||||||
|
lastAudioUrl,
|
||||||
|
isPlayingAudio,
|
||||||
|
// Actions
|
||||||
|
startRecording,
|
||||||
|
stopRecording,
|
||||||
|
toggleWhisperMode,
|
||||||
|
checkWhisperStatus: checkWhisperStatusFn,
|
||||||
|
loadAudioDevices,
|
||||||
|
selectMicrophone,
|
||||||
|
playLastAudio,
|
||||||
|
init,
|
||||||
|
cleanup,
|
||||||
|
clearTranscript
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,3 +46,21 @@ export interface ConversationEntry {
|
|||||||
timestamp: string
|
timestamp: string
|
||||||
method: 'text' | 'voice'
|
method: 'text' | 'voice'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TranscriptSession {
|
||||||
|
id: string
|
||||||
|
startTime: string
|
||||||
|
messageCount: number
|
||||||
|
model: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TranscriptMessage {
|
||||||
|
uuid: string
|
||||||
|
role: 'user' | 'assistant'
|
||||||
|
content: string
|
||||||
|
timestamp: string
|
||||||
|
isMeta: boolean
|
||||||
|
tokens?: { input: number; output: number }
|
||||||
|
toolCalls?: string[]
|
||||||
|
hasThinking: boolean
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
handleAgentsPlugins, handleAgentsMcpJson,
|
handleAgentsPlugins, handleAgentsMcpJson,
|
||||||
handleAgentsConfigPermissions, handleAgentsConfigHooks, handleAgentsConfigMcp
|
handleAgentsConfigPermissions, handleAgentsConfigHooks, handleAgentsConfigMcp
|
||||||
} from './agents'
|
} from './agents'
|
||||||
|
import { handleTranscript, handleTranscriptSessions } from './transcript'
|
||||||
|
|
||||||
export async function handleRequest(req: Request): Promise<Response> {
|
export async function handleRequest(req: Request): Promise<Response> {
|
||||||
const url = new URL(req.url)
|
const url = new URL(req.url)
|
||||||
@@ -277,6 +278,20 @@ export async function handleRequest(req: Request): Promise<Response> {
|
|||||||
return handleGitFile(url)
|
return handleGitFile(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Transcript
|
||||||
|
if (path === '/api/transcript/sessions' && req.method === 'GET') {
|
||||||
|
return handleTranscriptSessions()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/transcript/latest' && req.method === 'GET') {
|
||||||
|
return handleTranscript(req, url, 'latest')
|
||||||
|
}
|
||||||
|
|
||||||
|
const transcriptMatch = path.match(/^\/api\/transcript\/([a-f0-9-]+)$/)
|
||||||
|
if (transcriptMatch && req.method === 'GET') {
|
||||||
|
return handleTranscript(req, url, transcriptMatch[1])
|
||||||
|
}
|
||||||
|
|
||||||
// Agents
|
// Agents
|
||||||
if (path === '/api/agents' && req.method === 'GET') {
|
if (path === '/api/agents' && req.method === 'GET') {
|
||||||
return handleAgents(req)
|
return handleAgents(req)
|
||||||
|
|||||||
81
server/routes/transcript.ts
Normal file
81
server/routes/transcript.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { jsonResponse, errorResponse } from '../utils/cors'
|
||||||
|
import { getTranscriptAnalysis, listSessions } from '../services/transcript-engine'
|
||||||
|
import type { TranscriptAnalysis } from '../services/transcript-engine'
|
||||||
|
|
||||||
|
export function handleTranscriptSessions(): Response {
|
||||||
|
const sessions = listSessions()
|
||||||
|
return jsonResponse(sessions)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleTranscript(req: Request, url: URL, sessionId: string): Response {
|
||||||
|
if (req.method !== 'GET') return errorResponse('Method not allowed', 405)
|
||||||
|
|
||||||
|
const resolvedId = sessionId === 'latest' ? undefined : sessionId
|
||||||
|
const analysis = getTranscriptAnalysis(resolvedId)
|
||||||
|
|
||||||
|
if (!analysis) {
|
||||||
|
return errorResponse('Transcript not found', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section filtering
|
||||||
|
const section = url.searchParams.get('section')
|
||||||
|
if (section) {
|
||||||
|
return handleSection(analysis, section)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full response (exclude thinking by default)
|
||||||
|
const includeThinking = url.searchParams.get('includeThinking') === 'true'
|
||||||
|
if (!includeThinking) {
|
||||||
|
return jsonResponse({
|
||||||
|
...analysis,
|
||||||
|
messages: analysis.messages.filter(m => !m.isMeta)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
...analysis,
|
||||||
|
messages: analysis.messages.filter(m => !m.isMeta)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSection(analysis: TranscriptAnalysis, section: string): Response {
|
||||||
|
switch (section) {
|
||||||
|
case 'messages':
|
||||||
|
return jsonResponse({
|
||||||
|
sessionId: analysis.sessionId,
|
||||||
|
messages: analysis.messages.filter(m => !m.isMeta)
|
||||||
|
})
|
||||||
|
case 'tokens':
|
||||||
|
return jsonResponse({
|
||||||
|
sessionId: analysis.sessionId,
|
||||||
|
tokens: analysis.tokens
|
||||||
|
})
|
||||||
|
case 'tools':
|
||||||
|
return jsonResponse({
|
||||||
|
sessionId: analysis.sessionId,
|
||||||
|
tools: analysis.tools
|
||||||
|
})
|
||||||
|
case 'stats':
|
||||||
|
return jsonResponse({
|
||||||
|
sessionId: analysis.sessionId,
|
||||||
|
stats: analysis.stats,
|
||||||
|
model: analysis.model,
|
||||||
|
version: analysis.version,
|
||||||
|
duration: analysis.duration,
|
||||||
|
startTime: analysis.startTime,
|
||||||
|
endTime: analysis.endTime
|
||||||
|
})
|
||||||
|
case 'files':
|
||||||
|
return jsonResponse({
|
||||||
|
sessionId: analysis.sessionId,
|
||||||
|
filesModified: analysis.filesModified
|
||||||
|
})
|
||||||
|
case 'subagents':
|
||||||
|
return jsonResponse({
|
||||||
|
sessionId: analysis.sessionId,
|
||||||
|
subagents: analysis.subagents
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
return errorResponse(`Unknown section: ${section}. Valid: messages, tokens, tools, stats, files, subagents`, 400)
|
||||||
|
}
|
||||||
|
}
|
||||||
574
server/services/transcript-engine.ts
Normal file
574
server/services/transcript-engine.ts
Normal file
@@ -0,0 +1,574 @@
|
|||||||
|
// Transcript Engine - Parses Claude Code JSONL transcripts
|
||||||
|
// Module-level state pattern (like terminal.ts, torch-handler.ts)
|
||||||
|
|
||||||
|
import { existsSync, readFileSync, statSync, readdirSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { homedir } from 'os'
|
||||||
|
import { WORKING_DIR } from '../config'
|
||||||
|
|
||||||
|
// ── Types ──
|
||||||
|
|
||||||
|
export interface TranscriptAnalysis {
|
||||||
|
sessionId: string
|
||||||
|
model: string
|
||||||
|
version: string
|
||||||
|
gitBranch: string
|
||||||
|
cwd: string
|
||||||
|
startTime: string
|
||||||
|
endTime: string
|
||||||
|
duration: number
|
||||||
|
messages: TranscriptMessage[]
|
||||||
|
tokens: {
|
||||||
|
totalInput: number
|
||||||
|
totalOutput: number
|
||||||
|
totalCacheRead: number
|
||||||
|
totalCacheCreation: number
|
||||||
|
byTurn: TurnTokens[]
|
||||||
|
}
|
||||||
|
tools: {
|
||||||
|
summary: Record<string, number>
|
||||||
|
calls: ToolCall[]
|
||||||
|
}
|
||||||
|
filesModified: string[]
|
||||||
|
subagents: SubagentInfo[]
|
||||||
|
summaries: string[]
|
||||||
|
stats: {
|
||||||
|
messageCount: number
|
||||||
|
userMessageCount: number
|
||||||
|
assistantMessageCount: number
|
||||||
|
toolCallCount: number
|
||||||
|
thinkingBlocks: number
|
||||||
|
errors: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TranscriptMessage {
|
||||||
|
uuid: string
|
||||||
|
role: 'user' | 'assistant'
|
||||||
|
content: string
|
||||||
|
timestamp: string
|
||||||
|
isMeta: boolean
|
||||||
|
tokens?: { input: number; output: number }
|
||||||
|
toolCalls?: string[]
|
||||||
|
hasThinking: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolCall {
|
||||||
|
name: string
|
||||||
|
input: unknown
|
||||||
|
output?: string
|
||||||
|
timestamp: string
|
||||||
|
isError: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TurnTokens {
|
||||||
|
turnIndex: number
|
||||||
|
input: number
|
||||||
|
output: number
|
||||||
|
cacheRead: number
|
||||||
|
cacheCreation: number
|
||||||
|
model: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubagentInfo {
|
||||||
|
agentId: string
|
||||||
|
prompt: string
|
||||||
|
timestamp: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionInfo {
|
||||||
|
id: string
|
||||||
|
startTime: string
|
||||||
|
messageCount: number
|
||||||
|
model: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Module-level cache ──
|
||||||
|
|
||||||
|
const cache = new Map<string, {
|
||||||
|
analysis: TranscriptAnalysis
|
||||||
|
lastModified: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// ── Project hash ──
|
||||||
|
|
||||||
|
function getProjectHash(): string {
|
||||||
|
// C:\Users\jodar\agent-ui → C--Users-jodar-agent-ui
|
||||||
|
return WORKING_DIR.replace(/[\\/]/g, '-').replace(/:/g, '-')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProjectDir(): string {
|
||||||
|
return join(homedir(), '.claude', 'projects', getProjectHash())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Path resolution ──
|
||||||
|
|
||||||
|
function resolveTranscriptPath(sessionId?: string): string | null {
|
||||||
|
const projectDir = getProjectDir()
|
||||||
|
if (!existsSync(projectDir)) return null
|
||||||
|
|
||||||
|
if (sessionId && sessionId !== 'latest') {
|
||||||
|
const filePath = join(projectDir, `${sessionId}.jsonl`)
|
||||||
|
return existsSync(filePath) ? filePath : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find most recent by mtime
|
||||||
|
try {
|
||||||
|
const files = readdirSync(projectDir)
|
||||||
|
.filter(f => f.endsWith('.jsonl'))
|
||||||
|
.map(f => {
|
||||||
|
const fullPath = join(projectDir, f)
|
||||||
|
return { name: f, path: fullPath, mtime: statSync(fullPath).mtimeMs }
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.mtime - a.mtime)
|
||||||
|
|
||||||
|
return files.length > 0 ? files[0].path : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionIdFromPath(filePath: string): string {
|
||||||
|
const basename = filePath.split(/[\\/]/).pop() || ''
|
||||||
|
return basename.replace('.jsonl', '')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──
|
||||||
|
|
||||||
|
function truncate(str: string, maxLen: number): string {
|
||||||
|
if (!str || str.length <= maxLen) return str || ''
|
||||||
|
return str.slice(0, maxLen) + '...'
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractText(content: any): string {
|
||||||
|
if (typeof content === 'string') {
|
||||||
|
return content.replace(/<[^>]+>/g, '').trim()
|
||||||
|
}
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
return content
|
||||||
|
.filter((c: any) => c.type === 'text')
|
||||||
|
.map((c: any) => c.text || '')
|
||||||
|
.join('\n')
|
||||||
|
.replace(/<[^>]+>/g, '')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── JSONL parsing ──
|
||||||
|
|
||||||
|
interface ParsedLine {
|
||||||
|
type: string
|
||||||
|
data: any
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTranscriptFile(filePath: string): ParsedLine[] {
|
||||||
|
const content = readFileSync(filePath, 'utf8')
|
||||||
|
const rawLines = content.trim().split('\n')
|
||||||
|
const lines: ParsedLine[] = []
|
||||||
|
|
||||||
|
for (const line of rawLines) {
|
||||||
|
if (!line.trim()) continue
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(line)
|
||||||
|
lines.push({ type: obj.type, data: obj })
|
||||||
|
} catch {
|
||||||
|
// Skip unparseable lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Build analysis from parsed lines ──
|
||||||
|
|
||||||
|
function buildAnalysis(lines: ParsedLine[], fileSessionId: string): TranscriptAnalysis {
|
||||||
|
let sessionId = fileSessionId
|
||||||
|
let model = ''
|
||||||
|
let version = ''
|
||||||
|
let gitBranch = ''
|
||||||
|
let cwd = ''
|
||||||
|
let startTime = ''
|
||||||
|
let endTime = ''
|
||||||
|
|
||||||
|
const messages: TranscriptMessage[] = []
|
||||||
|
const toolCalls: ToolCall[] = []
|
||||||
|
const filesModified = new Set<string>()
|
||||||
|
const subagents: SubagentInfo[] = []
|
||||||
|
const summaries: string[] = []
|
||||||
|
const turnTokens: TurnTokens[] = []
|
||||||
|
|
||||||
|
let totalInput = 0
|
||||||
|
let totalOutput = 0
|
||||||
|
let totalCacheRead = 0
|
||||||
|
let totalCacheCreation = 0
|
||||||
|
let thinkingBlocks = 0
|
||||||
|
let errors = 0
|
||||||
|
|
||||||
|
// Track assistant message chunks by message.id (streaming chunks share the same id)
|
||||||
|
const assistantChunks = new Map<string, {
|
||||||
|
uuid: string
|
||||||
|
timestamp: string
|
||||||
|
model: string
|
||||||
|
textParts: string[]
|
||||||
|
toolNames: string[]
|
||||||
|
hasThinking: boolean
|
||||||
|
usage: any
|
||||||
|
pendingToolCalls: { name: string; input: any; id: string; timestamp: string }[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// Track tool results by tool_use_id
|
||||||
|
const toolResults = new Map<string, { content: string; isError: boolean }>()
|
||||||
|
|
||||||
|
// ── First pass: collect all data ──
|
||||||
|
for (const { type, data } of lines) {
|
||||||
|
// Extract metadata from first message that has it
|
||||||
|
if (data.sessionId && !sessionId) sessionId = data.sessionId
|
||||||
|
if (data.version && !version) version = data.version
|
||||||
|
if (data.gitBranch && !gitBranch) gitBranch = data.gitBranch
|
||||||
|
if (data.cwd && !cwd) cwd = data.cwd
|
||||||
|
|
||||||
|
// Track time bounds
|
||||||
|
if (data.timestamp) {
|
||||||
|
if (!startTime || data.timestamp < startTime) startTime = data.timestamp
|
||||||
|
if (!endTime || data.timestamp > endTime) endTime = data.timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'user': {
|
||||||
|
const msg = data.message
|
||||||
|
if (!msg) break
|
||||||
|
|
||||||
|
// Collect tool results (user messages contain tool_result blocks)
|
||||||
|
if (Array.isArray(msg.content)) {
|
||||||
|
for (const block of msg.content) {
|
||||||
|
if (block.type === 'tool_result') {
|
||||||
|
const resultText = typeof block.content === 'string'
|
||||||
|
? block.content
|
||||||
|
: Array.isArray(block.content)
|
||||||
|
? block.content.map((c: any) => c.text || '').join('\n')
|
||||||
|
: ''
|
||||||
|
toolResults.set(block.tool_use_id, {
|
||||||
|
content: truncate(resultText, 300),
|
||||||
|
isError: !!block.is_error
|
||||||
|
})
|
||||||
|
if (block.is_error) errors++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add as user message (skip meta, skip tool-result-only messages)
|
||||||
|
const isMeta = !!data.isMeta
|
||||||
|
const text = extractText(msg.content)
|
||||||
|
const hasToolResult = Array.isArray(msg.content) &&
|
||||||
|
msg.content.some((c: any) => c.type === 'tool_result')
|
||||||
|
|
||||||
|
if (text && !hasToolResult) {
|
||||||
|
messages.push({
|
||||||
|
uuid: data.uuid || '',
|
||||||
|
role: 'user',
|
||||||
|
content: text,
|
||||||
|
timestamp: data.timestamp || '',
|
||||||
|
isMeta,
|
||||||
|
hasThinking: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'assistant': {
|
||||||
|
const msg = data.message
|
||||||
|
if (!msg || msg.role !== 'assistant') break
|
||||||
|
|
||||||
|
const msgId = msg.id || data.uuid
|
||||||
|
if (!model && msg.model) model = msg.model
|
||||||
|
|
||||||
|
let chunk = assistantChunks.get(msgId)
|
||||||
|
if (!chunk) {
|
||||||
|
chunk = {
|
||||||
|
uuid: data.uuid || '',
|
||||||
|
timestamp: data.timestamp || '',
|
||||||
|
model: msg.model || '',
|
||||||
|
textParts: [],
|
||||||
|
toolNames: [],
|
||||||
|
hasThinking: false,
|
||||||
|
usage: null,
|
||||||
|
pendingToolCalls: []
|
||||||
|
}
|
||||||
|
assistantChunks.set(msgId, chunk)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Take latest usage (streaming chunks repeat usage, last is most accurate)
|
||||||
|
if (msg.usage) chunk.usage = msg.usage
|
||||||
|
|
||||||
|
// Process content blocks (each JSONL line typically has one block)
|
||||||
|
if (Array.isArray(msg.content)) {
|
||||||
|
for (const block of msg.content) {
|
||||||
|
if (block.type === 'text' && block.text?.trim()) {
|
||||||
|
chunk.textParts.push(block.text)
|
||||||
|
} else if (block.type === 'thinking') {
|
||||||
|
chunk.hasThinking = true
|
||||||
|
thinkingBlocks++
|
||||||
|
} else if (block.type === 'tool_use') {
|
||||||
|
chunk.toolNames.push(block.name)
|
||||||
|
chunk.pendingToolCalls.push({
|
||||||
|
name: block.name,
|
||||||
|
input: block.input,
|
||||||
|
id: block.id,
|
||||||
|
timestamp: data.timestamp || ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'progress': {
|
||||||
|
if (data.data?.type === 'agent_progress' && data.data.agentId) {
|
||||||
|
const existing = subagents.find(s => s.agentId === data.data.agentId)
|
||||||
|
if (!existing) {
|
||||||
|
subagents.push({
|
||||||
|
agentId: data.data.agentId,
|
||||||
|
prompt: truncate(data.data.prompt || '', 200),
|
||||||
|
timestamp: data.timestamp || ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'file-history-snapshot': {
|
||||||
|
const backups = data.snapshot?.trackedFileBackups
|
||||||
|
if (backups && typeof backups === 'object') {
|
||||||
|
for (const filePath of Object.keys(backups)) {
|
||||||
|
filesModified.add(filePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'summary': {
|
||||||
|
const summaryText = data.summary || data.message?.content
|
||||||
|
if (summaryText) {
|
||||||
|
summaries.push(truncate(
|
||||||
|
typeof summaryText === 'string' ? summaryText : JSON.stringify(summaryText),
|
||||||
|
1000
|
||||||
|
))
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Second pass: assemble assistant messages and finalize tool calls ──
|
||||||
|
let turnIndex = 0
|
||||||
|
for (const [, chunk] of assistantChunks) {
|
||||||
|
const text = chunk.textParts.join('\n').trim()
|
||||||
|
|
||||||
|
if (text || chunk.toolNames.length > 0) {
|
||||||
|
const msgTokens = chunk.usage
|
||||||
|
? { input: chunk.usage.input_tokens || 0, output: chunk.usage.output_tokens || 0 }
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
messages.push({
|
||||||
|
uuid: chunk.uuid,
|
||||||
|
role: 'assistant',
|
||||||
|
content: text || `[Tool calls: ${chunk.toolNames.join(', ')}]`,
|
||||||
|
timestamp: chunk.timestamp,
|
||||||
|
isMeta: false,
|
||||||
|
tokens: msgTokens,
|
||||||
|
toolCalls: chunk.toolNames.length > 0 ? chunk.toolNames : undefined,
|
||||||
|
hasThinking: chunk.hasThinking
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finalize tool calls with results
|
||||||
|
for (const tc of chunk.pendingToolCalls) {
|
||||||
|
const result = toolResults.get(tc.id)
|
||||||
|
const inputStr = typeof tc.input === 'string' ? tc.input : JSON.stringify(tc.input)
|
||||||
|
toolCalls.push({
|
||||||
|
name: tc.name,
|
||||||
|
input: truncate(inputStr, 500),
|
||||||
|
output: result?.content,
|
||||||
|
timestamp: tc.timestamp,
|
||||||
|
isError: result?.isError || false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token tracking per turn
|
||||||
|
if (chunk.usage) {
|
||||||
|
const u = chunk.usage
|
||||||
|
const input = u.input_tokens || 0
|
||||||
|
const output = u.output_tokens || 0
|
||||||
|
const cacheRead = u.cache_read_input_tokens || 0
|
||||||
|
const cacheCreation = u.cache_creation_input_tokens || 0
|
||||||
|
|
||||||
|
totalInput += input
|
||||||
|
totalOutput += output
|
||||||
|
totalCacheRead += cacheRead
|
||||||
|
totalCacheCreation += cacheCreation
|
||||||
|
|
||||||
|
turnTokens.push({
|
||||||
|
turnIndex: turnIndex++,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
cacheRead,
|
||||||
|
cacheCreation,
|
||||||
|
model: chunk.model
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort messages chronologically
|
||||||
|
messages.sort((a, b) => a.timestamp.localeCompare(b.timestamp))
|
||||||
|
|
||||||
|
// Build tool summary
|
||||||
|
const toolSummary: Record<string, number> = {}
|
||||||
|
for (const tc of toolCalls) {
|
||||||
|
toolSummary[tc.name] = (toolSummary[tc.name] || 0) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract files from Edit/Write tool calls
|
||||||
|
for (const tc of toolCalls) {
|
||||||
|
if (['Edit', 'Write', 'NotebookEdit'].includes(tc.name) && tc.input) {
|
||||||
|
try {
|
||||||
|
const input = typeof tc.input === 'string' ? JSON.parse(tc.input) : tc.input
|
||||||
|
if (input.file_path) filesModified.add(input.file_path)
|
||||||
|
if (input.notebook_path) filesModified.add(input.notebook_path)
|
||||||
|
} catch { /* skip */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = startTime && endTime
|
||||||
|
? new Date(endTime).getTime() - new Date(startTime).getTime()
|
||||||
|
: 0
|
||||||
|
|
||||||
|
const userMsgCount = messages.filter(m => m.role === 'user').length
|
||||||
|
const assistantMsgCount = messages.filter(m => m.role === 'assistant').length
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
model,
|
||||||
|
version,
|
||||||
|
gitBranch,
|
||||||
|
cwd,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
duration,
|
||||||
|
messages,
|
||||||
|
tokens: {
|
||||||
|
totalInput,
|
||||||
|
totalOutput,
|
||||||
|
totalCacheRead,
|
||||||
|
totalCacheCreation,
|
||||||
|
byTurn: turnTokens
|
||||||
|
},
|
||||||
|
tools: {
|
||||||
|
summary: toolSummary,
|
||||||
|
calls: toolCalls
|
||||||
|
},
|
||||||
|
filesModified: [...filesModified],
|
||||||
|
subagents,
|
||||||
|
summaries,
|
||||||
|
stats: {
|
||||||
|
messageCount: messages.length,
|
||||||
|
userMessageCount: userMsgCount,
|
||||||
|
assistantMessageCount: assistantMsgCount,
|
||||||
|
toolCallCount: toolCalls.length,
|
||||||
|
thinkingBlocks,
|
||||||
|
errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Exported API ──
|
||||||
|
|
||||||
|
export function getTranscriptAnalysis(sessionId?: string): TranscriptAnalysis | null {
|
||||||
|
const filePath = resolveTranscriptPath(sessionId)
|
||||||
|
if (!filePath) return null
|
||||||
|
|
||||||
|
const sid = sessionIdFromPath(filePath)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stat = statSync(filePath)
|
||||||
|
const mtime = stat.mtimeMs
|
||||||
|
|
||||||
|
// Return cached if file hasn't changed
|
||||||
|
const cached = cache.get(sid)
|
||||||
|
if (cached && cached.lastModified === mtime) {
|
||||||
|
return cached.analysis
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full parse
|
||||||
|
const lines = parseTranscriptFile(filePath)
|
||||||
|
const analysis = buildAnalysis(lines, sid)
|
||||||
|
|
||||||
|
cache.set(sid, { analysis, lastModified: mtime })
|
||||||
|
return analysis
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[transcript-engine] Error parsing transcript:', e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listSessions(): SessionInfo[] {
|
||||||
|
const projectDir = getProjectDir()
|
||||||
|
if (!existsSync(projectDir)) return []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const files = readdirSync(projectDir)
|
||||||
|
.filter(f => f.endsWith('.jsonl'))
|
||||||
|
.map(f => {
|
||||||
|
const fullPath = join(projectDir, f)
|
||||||
|
const stat = statSync(fullPath)
|
||||||
|
return { name: f, path: fullPath, mtime: stat.mtimeMs }
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.mtime - a.mtime)
|
||||||
|
|
||||||
|
return files.map(f => {
|
||||||
|
const sid = f.name.replace('.jsonl', '')
|
||||||
|
|
||||||
|
// Try cache first for quick metadata
|
||||||
|
const cached = cache.get(sid)
|
||||||
|
if (cached && cached.lastModified === f.mtime) {
|
||||||
|
return {
|
||||||
|
id: sid,
|
||||||
|
startTime: cached.analysis.startTime,
|
||||||
|
messageCount: cached.analysis.stats.messageCount,
|
||||||
|
model: cached.analysis.model
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quick scan: read first few lines for metadata without full parse
|
||||||
|
try {
|
||||||
|
const content = readFileSync(f.path, 'utf8')
|
||||||
|
const firstLines = content.split('\n').slice(0, 20)
|
||||||
|
let startTime = ''
|
||||||
|
let model = ''
|
||||||
|
let lineCount = content.split('\n').filter(l => l.trim()).length
|
||||||
|
|
||||||
|
for (const line of firstLines) {
|
||||||
|
if (!line.trim()) continue
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(line)
|
||||||
|
if (obj.timestamp && !startTime) startTime = obj.timestamp
|
||||||
|
if (obj.type === 'assistant' && obj.message?.model && !model) {
|
||||||
|
model = obj.message.model
|
||||||
|
}
|
||||||
|
} catch { /* skip */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: sid,
|
||||||
|
startTime,
|
||||||
|
messageCount: lineCount,
|
||||||
|
model
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return { id: sid, startTime: '', messageCount: 0, model: '' }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user