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:
@@ -249,6 +249,7 @@
|
||||
},
|
||||
"transcriptions": {
|
||||
"title": "Transcriptions",
|
||||
"titleView": "Transcriptions",
|
||||
"subtitle": "Manage project sessions. Upload transcripts, analyze with AI, and maintain an incremental document.",
|
||||
"configureAI": "Configure AI",
|
||||
"aiKeyTitle": "OpenRouter API Key",
|
||||
@@ -276,9 +277,13 @@
|
||||
"selected": "selected",
|
||||
"createInKappa": "Create {count} in KAPPA",
|
||||
"type": "Type",
|
||||
"title": "Title",
|
||||
"titleLabel": "Title",
|
||||
"priority": "Priority",
|
||||
"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",
|
||||
"sessionError": "Error analyzing session",
|
||||
"sessionSummary": "Summary",
|
||||
@@ -299,6 +304,7 @@
|
||||
"updatedAt": "Updated:",
|
||||
"docViewer": "Session document",
|
||||
"selectProjectHint": "Select a project to view its sessions",
|
||||
"selectProjectFirst": "Select a project to start",
|
||||
"sessionCount": "{count} sessions | {count} session | {count} sessions"
|
||||
},
|
||||
"projectAi": {
|
||||
|
||||
@@ -249,6 +249,7 @@
|
||||
},
|
||||
"transcriptions": {
|
||||
"title": "Transcripciones",
|
||||
"titleView": "Transcripciones",
|
||||
"subtitle": "Gestioná las sesiones del proyecto. Subí transcripciones, analizalas con IA y mantené un documento incremental.",
|
||||
"configureAI": "Configurar IA",
|
||||
"aiKeyTitle": "API Key de OpenRouter",
|
||||
@@ -276,9 +277,13 @@
|
||||
"selected": "seleccionadas",
|
||||
"createInKappa": "Crear {count} en KAPPA",
|
||||
"type": "Tipo",
|
||||
"title": "Título",
|
||||
"titleLabel": "Título",
|
||||
"priority": "Prioridad",
|
||||
"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",
|
||||
"sessionError": "Error al analizar la sesión",
|
||||
"sessionSummary": "Resumen",
|
||||
@@ -299,6 +304,7 @@
|
||||
"updatedAt": "Actualizado:",
|
||||
"docViewer": "Documento de sesiones",
|
||||
"selectProjectHint": "Seleccioná un proyecto para ver sus sesiones",
|
||||
"selectProjectFirst": "Seleccioná un proyecto para empezar",
|
||||
"sessionCount": "{count} sesiones | {count} sesión | {count} sesiones"
|
||||
},
|
||||
"projectAi": {
|
||||
|
||||
@@ -63,12 +63,39 @@ export async function analyzeSession(
|
||||
)
|
||||
|
||||
try {
|
||||
const jsonStr = content.replace(/```json\s*/gi, '').replace(/```\s*$/g, '').trim()
|
||||
const jsonStr = extractJSON(content)
|
||||
const result: SessionExtraction = JSON.parse(jsonStr)
|
||||
console.log(`[Alpha] Session analysis complete — ${result.pendingTasks.length} tasks, ${result.decisions.length} decisions`)
|
||||
return result
|
||||
} 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')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
@@ -2,9 +2,18 @@
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
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 { generateProjectDoc, getProjectDoc } from '@/services/project-doc'
|
||||
import { parseFile } from '@/services/parse-transcription'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -39,6 +48,8 @@ import {
|
||||
Hash,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Check,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -93,10 +104,10 @@ watch(selectedProjectId, async (id) => {
|
||||
|
||||
function parseSessionOffsets(md: string) {
|
||||
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
|
||||
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})/)
|
||||
offsets.push({
|
||||
date: dateMatch?.[1] || '',
|
||||
@@ -165,18 +176,23 @@ const fileQueue = ref<QueuedFile[]>([])
|
||||
const activeFileIndex = ref(-1)
|
||||
|
||||
async function handleFiles(files: FileList | File[]) {
|
||||
const { parseFile } = await import('@/services/parse-transcription')
|
||||
console.log(`[Alpha] handleFiles: ${files.length} file(s)`)
|
||||
const newFiles: QueuedFile[] = []
|
||||
parsing.value = true
|
||||
|
||||
for (const file of Array.from(files)) {
|
||||
console.log(`[Alpha] Parsing: ${file.name} (${file.size} bytes)`)
|
||||
try {
|
||||
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 })
|
||||
} catch (e: any) {
|
||||
console.error(`[Alpha] Failed to parse ${file.name}:`, e)
|
||||
}
|
||||
}
|
||||
|
||||
parsing.value = false
|
||||
|
||||
if (newFiles.length === 0) return
|
||||
|
||||
fileQueue.value.push(...newFiles)
|
||||
@@ -305,7 +321,18 @@ function downloadDoc() {
|
||||
a.href = url
|
||||
a.download = `${slug}-sesiones.md`
|
||||
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() {
|
||||
@@ -325,47 +352,26 @@ function clearAll() {
|
||||
|
||||
<template>
|
||||
<div class="@container/main flex flex-1 flex-col gap-4 px-4 lg:px-6 py-4">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold tracking-tight">{{ t('transcriptions.title') }}</h1>
|
||||
<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" />
|
||||
{{ t('transcriptions.configureAI') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="docMarkdown"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="downloadDoc()"
|
||||
>
|
||||
<FileDown class="size-4 mr-1" />
|
||||
{{ t('transcriptions.downloadDoc') }}
|
||||
</Button>
|
||||
<!-- Global processing banner -->
|
||||
<div
|
||||
v-if="parsing || sessionLoading || docGenerating"
|
||||
class="flex items-center gap-3 px-4 py-2.5 rounded-lg bg-primary/5 border border-primary/20 text-sm"
|
||||
>
|
||||
<Loader2 class="size-4 animate-spin text-primary shrink-0" />
|
||||
<span v-if="parsing" class="text-primary font-medium">{{ t('transcriptions.statusParsing') }}</span>
|
||||
<span v-else-if="sessionLoading" class="text-primary font-medium">{{ t('transcriptions.statusAnalyzing') }}</span>
|
||||
<span v-else-if="docGenerating" class="text-primary font-medium">{{ t('transcriptions.statusGenerating') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Project selector + Upload -->
|
||||
<Card id="transcriptions-top" class="overflow-hidden">
|
||||
<div class="flex flex-col @md:flex-row divide-y @md:divide-y-0 @md:divide-x">
|
||||
<!-- Project selector -->
|
||||
<div class="flex-1 p-4 space-y-2">
|
||||
<Label class="text-xs text-muted-foreground uppercase tracking-wider font-semibold flex items-center gap-1">
|
||||
<FolderOpen class="size-3" />
|
||||
{{ t('transcriptions.selectProject') }}
|
||||
</Label>
|
||||
<Select
|
||||
v-model="selectedProjectId"
|
||||
:placeholder="t('transcriptions.projectPlaceholder')"
|
||||
>
|
||||
<SelectTrigger id="transcriptions-project-trigger">
|
||||
<SelectValue />
|
||||
<!-- Header with inline project selector -->
|
||||
<div class="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div class="flex items-center gap-3 min-w-0 flex-1">
|
||||
<h1 class="text-2xl font-bold tracking-tight shrink-0">{{ t('transcriptions.titleView') }}</h1>
|
||||
<div class="min-w-[240px] md:w-[360px]">
|
||||
<Select v-model="selectedProjectId">
|
||||
<SelectTrigger id="transcriptions-project-trigger" class="h-9 text-sm">
|
||||
<FolderOpen class="size-4 shrink-0" />
|
||||
<SelectValue :placeholder="t('transcriptions.selectProject')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
@@ -375,18 +381,96 @@ function clearAll() {
|
||||
>{{ p.name }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p v-if="docSessionCount > 0" class="text-xs text-muted-foreground">
|
||||
{{ t('transcriptions.sessionCount', { count: docSessionCount }) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Upload zone -->
|
||||
<div
|
||||
class="flex-1 p-4 flex flex-col items-center justify-center gap-2 cursor-pointer border-dashed min-h-[100px]"
|
||||
@drop.prevent="handleFileDrop"
|
||||
@dragover.prevent
|
||||
@click="openFilePicker"
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<!-- Upload card (full width) -->
|
||||
<Card id="transcriptions-upload" class="border-dashed overflow-hidden">
|
||||
<div
|
||||
class="flex flex-col items-center justify-center gap-3 p-6 min-h-[120px] transition-opacity"
|
||||
:class="selectedProjectId ? 'cursor-pointer' : 'opacity-50 pointer-events-none'"
|
||||
@drop.prevent="selectedProjectId ? handleFileDrop($event) : null"
|
||||
@dragover.prevent="selectedProjectId ? null : null"
|
||||
@click="selectedProjectId ? openFilePicker() : null"
|
||||
>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
@@ -395,7 +479,13 @@ function clearAll() {
|
||||
class="hidden"
|
||||
@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" />
|
||||
<p class="text-xs text-muted-foreground text-center">
|
||||
{{ t('transcriptions.dropzone') }}<br>
|
||||
@@ -408,6 +498,11 @@ function clearAll() {
|
||||
</template>
|
||||
<template v-else>
|
||||
<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 -->
|
||||
<div class="flex items-center gap-2">
|
||||
<FileText class="size-5 text-primary shrink-0" />
|
||||
@@ -437,6 +532,7 @@ function clearAll() {
|
||||
class="text-xs h-7"
|
||||
:disabled="sessionLoading || !selectedProjectId"
|
||||
@click.stop="analyzeAsSession()"
|
||||
id="transcriptions-analyze-btn"
|
||||
>
|
||||
<Loader2 v-if="sessionLoading" class="size-3 mr-1 animate-spin" />
|
||||
<Sparkles v-else class="size-3 mr-1" />
|
||||
@@ -446,7 +542,6 @@ function clearAll() {
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Stats row -->
|
||||
|
||||
Reference in New Issue
Block a user