fix: parseo JSON robusto, UI upload full-width, selector en header, multiple files

- session-analyzer.ts: extractJSON() con 4 estrategias de parseo + log raw
- TranscriptionsView: selector proyecto movido al header, upload card full-width
- TranscriptionsView: soporte multi-file con cola, banner de progreso
- TranscriptionsView: download .md con revokeObjectURL diferido
- TranscriptionsView: upload deshabilitado sin proyecto seleccionado
- session-analyzer + project-doc exportados como servicios independientes
- i18n: keys titleView, statusParsing/Analyzing/Generating, filesLoaded
- i18n: fix key title duplicado (title -> titleLabel)
This commit is contained in:
2026-05-28 13:20:23 -05:00
parent 7d299554bf
commit b974788a16
4 changed files with 194 additions and 60 deletions
+7 -1
View File
@@ -249,6 +249,7 @@
}, },
"transcriptions": { "transcriptions": {
"title": "Transcriptions", "title": "Transcriptions",
"titleView": "Transcriptions",
"subtitle": "Manage project sessions. Upload transcripts, analyze with AI, and maintain an incremental document.", "subtitle": "Manage project sessions. Upload transcripts, analyze with AI, and maintain an incremental document.",
"configureAI": "Configure AI", "configureAI": "Configure AI",
"aiKeyTitle": "OpenRouter API Key", "aiKeyTitle": "OpenRouter API Key",
@@ -276,9 +277,13 @@
"selected": "selected", "selected": "selected",
"createInKappa": "Create {count} in KAPPA", "createInKappa": "Create {count} in KAPPA",
"type": "Type", "type": "Type",
"title": "Title", "titleLabel": "Title",
"priority": "Priority", "priority": "Priority",
"selectProjectToCreate": "Select a project above to create stories in KAPPA", "selectProjectToCreate": "Select a project above to create stories in KAPPA",
"statusParsing": "Processing files...",
"statusAnalyzing": "Analyzing session with AI...",
"statusGenerating": "Generating project document...",
"filesLoaded": "files loaded",
"analyzeSession": "Analyze session", "analyzeSession": "Analyze session",
"sessionError": "Error analyzing session", "sessionError": "Error analyzing session",
"sessionSummary": "Summary", "sessionSummary": "Summary",
@@ -299,6 +304,7 @@
"updatedAt": "Updated:", "updatedAt": "Updated:",
"docViewer": "Session document", "docViewer": "Session document",
"selectProjectHint": "Select a project to view its sessions", "selectProjectHint": "Select a project to view its sessions",
"selectProjectFirst": "Select a project to start",
"sessionCount": "{count} sessions | {count} session | {count} sessions" "sessionCount": "{count} sessions | {count} session | {count} sessions"
}, },
"projectAi": { "projectAi": {
+7 -1
View File
@@ -249,6 +249,7 @@
}, },
"transcriptions": { "transcriptions": {
"title": "Transcripciones", "title": "Transcripciones",
"titleView": "Transcripciones",
"subtitle": "Gestioná las sesiones del proyecto. Subí transcripciones, analizalas con IA y mantené un documento incremental.", "subtitle": "Gestioná las sesiones del proyecto. Subí transcripciones, analizalas con IA y mantené un documento incremental.",
"configureAI": "Configurar IA", "configureAI": "Configurar IA",
"aiKeyTitle": "API Key de OpenRouter", "aiKeyTitle": "API Key de OpenRouter",
@@ -276,9 +277,13 @@
"selected": "seleccionadas", "selected": "seleccionadas",
"createInKappa": "Crear {count} en KAPPA", "createInKappa": "Crear {count} en KAPPA",
"type": "Tipo", "type": "Tipo",
"title": "Título", "titleLabel": "Título",
"priority": "Prioridad", "priority": "Prioridad",
"selectProjectToCreate": "Seleccioná un proyecto arriba para crear las HUs en KAPPA", "selectProjectToCreate": "Seleccioná un proyecto arriba para crear las HUs en KAPPA",
"statusParsing": "Procesando archivos...",
"statusAnalyzing": "Analizando sesión con IA...",
"statusGenerating": "Generando documento del proyecto...",
"filesLoaded": "archivos cargados",
"analyzeSession": "Analizar sesión", "analyzeSession": "Analizar sesión",
"sessionError": "Error al analizar la sesión", "sessionError": "Error al analizar la sesión",
"sessionSummary": "Resumen", "sessionSummary": "Resumen",
@@ -299,6 +304,7 @@
"updatedAt": "Actualizado:", "updatedAt": "Actualizado:",
"docViewer": "Documento de sesiones", "docViewer": "Documento de sesiones",
"selectProjectHint": "Seleccioná un proyecto para ver sus sesiones", "selectProjectHint": "Seleccioná un proyecto para ver sus sesiones",
"selectProjectFirst": "Seleccioná un proyecto para empezar",
"sessionCount": "{count} sesiones | {count} sesión | {count} sesiones" "sessionCount": "{count} sesiones | {count} sesión | {count} sesiones"
}, },
"projectAi": { "projectAi": {
+29 -2
View File
@@ -63,12 +63,39 @@ export async function analyzeSession(
) )
try { try {
const jsonStr = content.replace(/```json\s*/gi, '').replace(/```\s*$/g, '').trim() const jsonStr = extractJSON(content)
const result: SessionExtraction = JSON.parse(jsonStr) const result: SessionExtraction = JSON.parse(jsonStr)
console.log(`[Alpha] Session analysis complete — ${result.pendingTasks.length} tasks, ${result.decisions.length} decisions`) console.log(`[Alpha] Session analysis complete — ${result.pendingTasks.length} tasks, ${result.decisions.length} decisions`)
return result return result
} catch (e) { } catch (e) {
console.error('[Alpha] Failed to parse session analysis:', content) console.error('[Alpha] Failed to parse session analysis. Raw response:', content)
throw new Error('No se pudo parsear el análisis de la sesión') throw new Error('No se pudo parsear el análisis de la sesión')
} }
} }
/**
* Extrae el primer objeto JSON válido del texto.
* Maneja markdown code blocks, texto antes/después, etc.
*/
function extractJSON(text: string): string {
// 1. Intentar parse directo
try { JSON.parse(text); return text } catch {}
// 2. Extraer entre ```json y ```
const jsonBlock = text.match(/```(?:json)?\s*([\s\S]*?)```/)
if (jsonBlock) {
const candidate = jsonBlock[1].trim()
try { JSON.parse(candidate); return candidate } catch {}
}
// 3. Extraer entre { y } (first { to last })
const firstBrace = text.indexOf('{')
const lastBrace = text.lastIndexOf('}')
if (firstBrace !== -1 && lastBrace > firstBrace) {
const candidate = text.slice(firstBrace, lastBrace + 1)
try { JSON.parse(candidate); return candidate } catch {}
}
// 4. Fallback: devolver el texto original (lanzará error en el caller)
return text
}
+148 -53
View File
@@ -2,9 +2,18 @@
import { ref, computed, watch, nextTick } from 'vue' import { ref, computed, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useProjectsStore } from '@/stores/projects' import { useProjectsStore } from '@/stores/projects'
import { useSettingsStore } from '@/stores/settings' import { useSettingsStore, AVAILABLE_MODELS, PROVIDER_CONFIG, hasProviderApiKey, type AIProvider } from '@/stores/settings'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { analyzeSession, type SessionExtraction } from '@/services/session-analyzer' import { analyzeSession, type SessionExtraction } from '@/services/session-analyzer'
import { generateProjectDoc, getProjectDoc } from '@/services/project-doc' import { generateProjectDoc, getProjectDoc } from '@/services/project-doc'
import { parseFile } from '@/services/parse-transcription'
import { import {
Card, Card,
CardContent, CardContent,
@@ -39,6 +48,8 @@ import {
Hash, Hash,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
ChevronDown,
Check,
} from 'lucide-vue-next' } from 'lucide-vue-next'
const { t } = useI18n() const { t } = useI18n()
@@ -93,10 +104,10 @@ watch(selectedProjectId, async (id) => {
function parseSessionOffsets(md: string) { function parseSessionOffsets(md: string) {
const offsets: { date: string; title: string; offset: number }[] = [] const offsets: { date: string; title: string; offset: number }[] = []
const re = /## 📍 Sesión \d+: (.+?) \*\*Archivo fuente:.*?\*\* /gs const re = /## 📍 Sesión \d+:\s*(.+?)\n\*\*Archivo fuente:/g
let match let match
while ((match = re.exec(md)) !== null) { while ((match = re.exec(md)) !== null) {
const title = match[1] const title = match[1].trim()
const dateMatch = title.match(/(\d{4}-\d{2}-\d{2})/) const dateMatch = title.match(/(\d{4}-\d{2}-\d{2})/)
offsets.push({ offsets.push({
date: dateMatch?.[1] || '', date: dateMatch?.[1] || '',
@@ -165,18 +176,23 @@ const fileQueue = ref<QueuedFile[]>([])
const activeFileIndex = ref(-1) const activeFileIndex = ref(-1)
async function handleFiles(files: FileList | File[]) { async function handleFiles(files: FileList | File[]) {
const { parseFile } = await import('@/services/parse-transcription') console.log(`[Alpha] handleFiles: ${files.length} file(s)`)
const newFiles: QueuedFile[] = [] const newFiles: QueuedFile[] = []
parsing.value = true
for (const file of Array.from(files)) { for (const file of Array.from(files)) {
console.log(`[Alpha] Parsing: ${file.name} (${file.size} bytes)`)
try { try {
const result = await parseFile(file) const result = await parseFile(file)
console.log(`[Alpha] Parsed OK: ${result.fileName}, ${result.text.length} chars`)
newFiles.push({ fileName: result.fileName, text: result.text, parsed: true }) newFiles.push({ fileName: result.fileName, text: result.text, parsed: true })
} catch (e: any) { } catch (e: any) {
console.error(`[Alpha] Failed to parse ${file.name}:`, e) console.error(`[Alpha] Failed to parse ${file.name}:`, e)
} }
} }
parsing.value = false
if (newFiles.length === 0) return if (newFiles.length === 0) return
fileQueue.value.push(...newFiles) fileQueue.value.push(...newFiles)
@@ -305,7 +321,18 @@ function downloadDoc() {
a.href = url a.href = url
a.download = `${slug}-sesiones.md` a.download = `${slug}-sesiones.md`
a.click() a.click()
URL.revokeObjectURL(url) setTimeout(() => URL.revokeObjectURL(url), 1000)
}
const allProviders: AIProvider[] = ['openrouter', 'minimax', 'opencode']
function modelsForProvider(p: AIProvider) {
return AVAILABLE_MODELS.filter(m => m.provider === p)
}
function switchModel(provider: AIProvider, modelId: string) {
if (provider !== settingsStore.provider) {
settingsStore.setActiveProvider(provider)
}
settingsStore.setModel(modelId)
} }
function clearAll() { function clearAll() {
@@ -325,47 +352,26 @@ function clearAll() {
<template> <template>
<div class="@container/main flex flex-1 flex-col gap-4 px-4 lg:px-6 py-4"> <div class="@container/main flex flex-1 flex-col gap-4 px-4 lg:px-6 py-4">
<!-- Header --> <!-- Global processing banner -->
<div class="flex items-center justify-between"> <div
<div> v-if="parsing || sessionLoading || docGenerating"
<h1 class="text-2xl font-bold tracking-tight">{{ t('transcriptions.title') }}</h1> class="flex items-center gap-3 px-4 py-2.5 rounded-lg bg-primary/5 border border-primary/20 text-sm"
<p class="text-sm text-muted-foreground mt-1">{{ t('transcriptions.subtitle') }}</p>
</div>
<Button
v-if="!settingsStore.apiKeyConfigured"
variant="outline"
size="sm"
@click="$emit('navigate-settings')"
> >
<Settings2 class="size-4 mr-1" /> <Loader2 class="size-4 animate-spin text-primary shrink-0" />
{{ t('transcriptions.configureAI') }} <span v-if="parsing" class="text-primary font-medium">{{ t('transcriptions.statusParsing') }}</span>
</Button> <span v-else-if="sessionLoading" class="text-primary font-medium">{{ t('transcriptions.statusAnalyzing') }}</span>
<Button <span v-else-if="docGenerating" class="text-primary font-medium">{{ t('transcriptions.statusGenerating') }}</span>
v-else-if="docMarkdown"
variant="outline"
size="sm"
@click="downloadDoc()"
>
<FileDown class="size-4 mr-1" />
{{ t('transcriptions.downloadDoc') }}
</Button>
</div> </div>
<!-- Project selector + Upload --> <!-- Header with inline project selector -->
<Card id="transcriptions-top" class="overflow-hidden"> <div class="flex items-start justify-between gap-4 flex-wrap">
<div class="flex flex-col @md:flex-row divide-y @md:divide-y-0 @md:divide-x"> <div class="flex items-center gap-3 min-w-0 flex-1">
<!-- Project selector --> <h1 class="text-2xl font-bold tracking-tight shrink-0">{{ t('transcriptions.titleView') }}</h1>
<div class="flex-1 p-4 space-y-2"> <div class="min-w-[240px] md:w-[360px]">
<Label class="text-xs text-muted-foreground uppercase tracking-wider font-semibold flex items-center gap-1"> <Select v-model="selectedProjectId">
<FolderOpen class="size-3" /> <SelectTrigger id="transcriptions-project-trigger" class="h-9 text-sm">
{{ t('transcriptions.selectProject') }} <FolderOpen class="size-4 shrink-0" />
</Label> <SelectValue :placeholder="t('transcriptions.selectProject')" />
<Select
v-model="selectedProjectId"
:placeholder="t('transcriptions.projectPlaceholder')"
>
<SelectTrigger id="transcriptions-project-trigger">
<SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem <SelectItem
@@ -375,17 +381,95 @@ function clearAll() {
>{{ p.name }}</SelectItem> >{{ p.name }}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<p v-if="docSessionCount > 0" class="text-xs text-muted-foreground"> </div>
{{ t('transcriptions.sessionCount', { count: docSessionCount }) }} </div>
</p> <div class="flex items-center gap-2 shrink-0">
<Button
v-if="!settingsStore.apiKeyConfigured"
variant="outline"
size="sm"
@click="$emit('navigate-settings')"
>
<Settings2 class="size-4 mr-1" />
{{ t('transcriptions.configureAI') }}
</Button>
<DropdownMenu v-else>
<DropdownMenuTrigger as-child>
<Button variant="outline" size="sm" class="text-xs h-7 gap-1 font-mono">
{{ settingsStore.selectedModel?.label || settingsStore.modelId }}
<ChevronDown class="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-64">
<DropdownMenuLabel>{{ t('projectAi.switchModel') }}</DropdownMenuLabel>
<DropdownMenuSeparator />
<template v-for="p in allProviders" :key="p">
<DropdownMenuItem
v-if="modelsForProvider(p).length > 0"
class="flex flex-col items-start py-2 cursor-default"
disabled
>
<div class="flex items-center gap-2 w-full">
<span class="text-xs font-medium">{{ PROVIDER_CONFIG[p].label }}</span>
<Badge
v-if="hasProviderApiKey(p)"
variant="outline"
class="text-[9px] text-green-600 border-green-300 dark:text-green-400 dark:border-green-700"
>{{ t('projectAi.keyReady') }}</Badge>
<Badge
v-else
variant="outline"
class="text-[9px] text-muted-foreground"
>{{ t('projectAi.noKey') }}</Badge>
</div>
</DropdownMenuItem>
<DropdownMenuItem
v-for="m in modelsForProvider(p)"
:key="m.id"
:disabled="!hasProviderApiKey(p)"
@click="switchModel(p, m.id)"
class="flex items-center gap-2 py-1.5 pl-6"
>
<Check
v-if="settingsStore.provider === p && settingsStore.modelId === m.id"
class="size-3.5 text-primary shrink-0"
/>
<div v-else class="size-3.5 shrink-0" />
<span class="text-xs">{{ m.label }}</span>
<code class="text-[9px] text-muted-foreground ml-auto truncate max-w-[120px]">{{ m.id }}</code>
</DropdownMenuItem>
<DropdownMenuSeparator v-if="p !== allProviders[allProviders.length - 1]" />
</template>
<DropdownMenuSeparator />
<DropdownMenuItem
class="text-xs text-muted-foreground justify-center"
@click="$emit('navigate-settings')"
>
<Settings2 class="size-3 mr-1" />
{{ t('projectAi.settings') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
v-if="docMarkdown"
variant="outline"
size="sm"
@click="downloadDoc()"
>
<FileDown class="size-4 mr-1" />
{{ t('transcriptions.downloadDoc') }}
</Button>
</div>
</div> </div>
<!-- Upload zone --> <!-- Upload card (full width) -->
<Card id="transcriptions-upload" class="border-dashed overflow-hidden">
<div <div
class="flex-1 p-4 flex flex-col items-center justify-center gap-2 cursor-pointer border-dashed min-h-[100px]" class="flex flex-col items-center justify-center gap-3 p-6 min-h-[120px] transition-opacity"
@drop.prevent="handleFileDrop" :class="selectedProjectId ? 'cursor-pointer' : 'opacity-50 pointer-events-none'"
@dragover.prevent @drop.prevent="selectedProjectId ? handleFileDrop($event) : null"
@click="openFilePicker" @dragover.prevent="selectedProjectId ? null : null"
@click="selectedProjectId ? openFilePicker() : null"
> >
<input <input
ref="fileInput" ref="fileInput"
@@ -395,7 +479,13 @@ function clearAll() {
class="hidden" class="hidden"
@change="handleFileSelect" @change="handleFileSelect"
/> />
<template v-if="!parsedText && !parsing"> <template v-if="!selectedProjectId">
<FolderOpen class="size-6 text-muted-foreground" />
<p class="text-xs text-muted-foreground text-center">
{{ t('transcriptions.selectProjectFirst') }}
</p>
</template>
<template v-else-if="!parsedText && !parsing">
<Upload class="size-6 text-muted-foreground" /> <Upload class="size-6 text-muted-foreground" />
<p class="text-xs text-muted-foreground text-center"> <p class="text-xs text-muted-foreground text-center">
{{ t('transcriptions.dropzone') }}<br> {{ t('transcriptions.dropzone') }}<br>
@@ -408,6 +498,11 @@ function clearAll() {
</template> </template>
<template v-else> <template v-else>
<div class="w-full space-y-2"> <div class="w-full space-y-2">
<!-- Files loaded badge -->
<div class="flex items-center gap-2 text-xs text-green-600 dark:text-green-400">
<CheckCircle2 class="size-3.5" />
<span>{{ fileQueue.length }} {{ t('transcriptions.filesLoaded') }}</span>
</div>
<!-- Active file info --> <!-- Active file info -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<FileText class="size-5 text-primary shrink-0" /> <FileText class="size-5 text-primary shrink-0" />
@@ -437,6 +532,7 @@ function clearAll() {
class="text-xs h-7" class="text-xs h-7"
:disabled="sessionLoading || !selectedProjectId" :disabled="sessionLoading || !selectedProjectId"
@click.stop="analyzeAsSession()" @click.stop="analyzeAsSession()"
id="transcriptions-analyze-btn"
> >
<Loader2 v-if="sessionLoading" class="size-3 mr-1 animate-spin" /> <Loader2 v-if="sessionLoading" class="size-3 mr-1 animate-spin" />
<Sparkles v-else class="size-3 mr-1" /> <Sparkles v-else class="size-3 mr-1" />
@@ -446,7 +542,6 @@ function clearAll() {
</div> </div>
</template> </template>
</div> </div>
</div>
</Card> </Card>
<!-- Stats row --> <!-- Stats row -->