K-10 pipeline transcripciones + settings IA + cache-aside + session doc
Nuevos modulos: - services/ai.ts: cliente IA provider-agnostico (OpenRouter, MiniMax) - services/db.ts: Dexie core con tabla settings + project_docs - services/storage.ts: Cache-Aside + Write-Through (L1 Map → L2 Dexie → L3 localStorage) - services/parse-transcription.ts: parser .docx/.vtt/.txt/.md - services/session-analyzer.ts: extraccion IA de sesiones (resumen, tareas, decisiones) - services/project-doc.ts: documento maestro MD (Bloque 1 resumen + Bloque 2 sesiones) - stores/settings.ts: proveedores IA, modelos, API keys separadas por provider - stores/transcriptions.ts: pipeline upload → analyze → create HU en KAPPA - views/SettingsView.vue: configuracion IA (OpenRouter, MiniMax, OpenCode bridge) - views/TranscriptionsView.vue: subida multiple + analisis sesion + visor MD + calendario - components/AiProjectChat.vue: chat contextual por proyecto con selector de modelo Cambios en existentes: - stores/auth.ts, kappa-api.ts, upload-hu.ts: migrados a storage service (Dexie + localStorage) - stores/projects.ts, workitems.ts: kappa_last_project via storage - DashboardView.vue: descripcion reemplazada por AiProjectChat - NewDashboardView.vue: tabs transcriptions + settings + navigate-settings events - NavMain.vue: items Transcripciones + Configuracion - SiteHeader.vue: labels tabs + language via storage - LoginView.vue: remember_email via storage - i18n: +80 keys español/ingles - vite.config.ts: proxy CORS para MiniMax - package.json: +mammoth.js
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
import { PROVIDER_CONFIG, getProviderApiKey, type AIProvider } from '@/stores/settings'
|
||||
import { storage } from '@/services/storage'
|
||||
|
||||
export interface AIExtractedHU {
|
||||
title: string
|
||||
description: string
|
||||
acceptance_criteria: string[]
|
||||
priority?: string
|
||||
story_points?: number
|
||||
type?: 'feature' | 'bug' | 'task' | 'improvement'
|
||||
}
|
||||
|
||||
export interface AIAnalysisResult {
|
||||
hus: AIExtractedHU[]
|
||||
summary: string
|
||||
}
|
||||
|
||||
interface ActiveConfig {
|
||||
baseUrl: string
|
||||
apiKey: string
|
||||
model: string
|
||||
provider: AIProvider
|
||||
}
|
||||
|
||||
function getActiveConfig(): ActiveConfig {
|
||||
const raw = storage.getJSON<{ provider: AIProvider; modelId: string }>('alpha_settings')
|
||||
if (raw) {
|
||||
const provider = raw.provider || 'openrouter'
|
||||
const model = raw.modelId || 'deepseek/deepseek-chat-v3-0324:free'
|
||||
const baseUrl = PROVIDER_CONFIG[provider]?.baseUrl || ''
|
||||
const apiKey = getProviderApiKey(provider)
|
||||
return { baseUrl, apiKey, model, provider }
|
||||
}
|
||||
return {
|
||||
baseUrl: PROVIDER_CONFIG.openrouter.baseUrl,
|
||||
apiKey: getProviderApiKey('openrouter'),
|
||||
model: 'deepseek/deepseek-chat-v3-0324:free',
|
||||
provider: 'openrouter',
|
||||
}
|
||||
}
|
||||
|
||||
function buildHeaders(config: ActiveConfig): Record<string, string> {
|
||||
const h: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
if (config.provider === 'openrouter') {
|
||||
h['Authorization'] = `Bearer ${config.apiKey}`
|
||||
h['HTTP-Referer'] = window.location.origin
|
||||
h['X-Title'] = 'KAPPA Hub Alpha'
|
||||
} else {
|
||||
h['Authorization'] = `Bearer ${config.apiKey}`
|
||||
h['Authorization'] = `Bearer ${config.apiKey}`
|
||||
}
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'system' | 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
export async function callAI(
|
||||
messages: ChatMessage[],
|
||||
temperature = 0.3,
|
||||
maxTokens = 4096,
|
||||
signal?: AbortSignal
|
||||
): Promise<string> {
|
||||
const config = getActiveConfig()
|
||||
|
||||
if (!config.apiKey) {
|
||||
const label = PROVIDER_CONFIG[config.provider]?.label || config.provider
|
||||
throw new Error(`No hay API key configurada para ${label}`)
|
||||
}
|
||||
if (!config.baseUrl) {
|
||||
throw new Error(`El proveedor ${config.provider} no tiene API configurada`)
|
||||
}
|
||||
|
||||
console.log(`[Alpha] AI call — provider: ${config.provider}, model: ${config.model}`)
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
model: config.model,
|
||||
messages,
|
||||
temperature,
|
||||
max_tokens: maxTokens,
|
||||
}
|
||||
|
||||
const res = await fetch(config.baseUrl, {
|
||||
method: 'POST',
|
||||
headers: buildHeaders(config),
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
})
|
||||
|
||||
const rawText = await res.text()
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`API error (${config.provider}): ${res.status} — ${rawText.slice(0, 300)}`)
|
||||
}
|
||||
|
||||
const data = JSON.parse(rawText)
|
||||
const content = data.choices?.[0]?.message?.content || null
|
||||
|
||||
if (!content) throw new Error('Respuesta vacía del proveedor de IA')
|
||||
return content
|
||||
}
|
||||
|
||||
export async function chatWithAI(
|
||||
messages: { role: 'user' | 'assistant'; content: string }[],
|
||||
systemPrompt?: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<string> {
|
||||
const msgs: ChatMessage[] = []
|
||||
if (systemPrompt) {
|
||||
msgs.push({ role: 'system', content: systemPrompt })
|
||||
}
|
||||
msgs.push(...messages)
|
||||
return callAI(msgs, 0.7, 2048, signal)
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT = `Eres un analista funcional experto en metodologías ágiles. Tu tarea es analizar transcripciones de reuniones y extraer Historias de Usuario (HUs) en formato estructurado.
|
||||
|
||||
Reglas:
|
||||
1. Identifica cada requisito, funcionalidad, bug o mejora mencionada en la transcripción
|
||||
2. Convierte cada uno en una HU con: título claro, descripción detallada, criterios de aceptación
|
||||
3. Los criterios de aceptación deben ser verificables (condiciones específicas)
|
||||
4. Usa el formato "Como [rol] quiero [funcionalidad] para [beneficio]" cuando sea posible
|
||||
5. Asigna prioridad (Alta/Media/Baja) basada en urgencia implícita
|
||||
6. No inventes información que no esté en la transcripción
|
||||
7. Si el texto no contiene información relevante para HUs, devuelve un arreglo vacío
|
||||
|
||||
Responde SOLO con JSON válido en este formato:
|
||||
{
|
||||
"hus": [
|
||||
{
|
||||
"title": "Título de la HU",
|
||||
"description": "Descripción detallada",
|
||||
"acceptance_criteria": ["Criterio 1", "Criterio 2"],
|
||||
"priority": "Alta|Media|Baja",
|
||||
"story_points": 3,
|
||||
"type": "feature|bug|task|improvement"
|
||||
}
|
||||
],
|
||||
"summary": "Resumen breve del análisis (2-3 líneas)"
|
||||
}`
|
||||
|
||||
export async function analyzeTranscription(
|
||||
text: string,
|
||||
projectName?: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<AIAnalysisResult> {
|
||||
const userContent = projectName
|
||||
? `Proyecto: ${projectName}\n\nTranscripción:\n${text}`
|
||||
: `Transcripción:\n${text}`
|
||||
|
||||
console.log(`[Alpha] AI analyze — text: ${text.length} chars`)
|
||||
|
||||
const content = await callAI(
|
||||
[
|
||||
{ role: 'system', content: SYSTEM_PROMPT },
|
||||
{ role: 'user', content: userContent },
|
||||
],
|
||||
0.3,
|
||||
4096,
|
||||
signal,
|
||||
)
|
||||
|
||||
try {
|
||||
const jsonStr = content.replace(/```json\s*/gi, '').replace(/```\s*$/g, '').trim()
|
||||
const result: AIAnalysisResult = JSON.parse(jsonStr)
|
||||
console.log(`[Alpha] AI analysis complete — ${result.hus.length} HUs`)
|
||||
return result
|
||||
} catch (e) {
|
||||
console.error('[Alpha] Failed to parse AI response:', content)
|
||||
throw new Error('No se pudo parsear la respuesta de la IA')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import Dexie from 'dexie'
|
||||
|
||||
export interface SettingEntry {
|
||||
key: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface ProjectDocRecord {
|
||||
projectId: number
|
||||
projectName: string
|
||||
updatedAt: string
|
||||
sessionCount: number
|
||||
markdown: string
|
||||
}
|
||||
|
||||
const db = new Dexie('alpha-core') as Dexie & {
|
||||
settings: Dexie.Table<SettingEntry, string>
|
||||
project_docs: Dexie.Table<ProjectDocRecord, number>
|
||||
}
|
||||
|
||||
db.version(2).stores({
|
||||
settings: '&key',
|
||||
project_docs: '&projectId, projectName, updatedAt',
|
||||
})
|
||||
|
||||
export default db
|
||||
@@ -1,3 +1,4 @@
|
||||
import { storage } from '@/services/storage'
|
||||
import type {
|
||||
KappaLoginPayload,
|
||||
KappaLoginResponse,
|
||||
@@ -23,7 +24,7 @@ class KappaAPI {
|
||||
private token: string | null = null
|
||||
|
||||
constructor() {
|
||||
this.token = localStorage.getItem('kappa_token')
|
||||
this.token = storage.get('kappa_token')
|
||||
}
|
||||
|
||||
private get headers(): Record<string, string> {
|
||||
@@ -66,14 +67,14 @@ class KappaAPI {
|
||||
)
|
||||
this.token = data.access || data.token || data.key || null
|
||||
if (this.token) {
|
||||
localStorage.setItem('kappa_token', this.token)
|
||||
storage.set('kappa_token', this.token)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.token = null
|
||||
localStorage.removeItem('kappa_token')
|
||||
storage.remove('kappa_token')
|
||||
}
|
||||
|
||||
get isAuthenticated(): boolean {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import * as mammoth from 'mammoth'
|
||||
|
||||
export type TranscriptionFileType = 'docx' | 'vtt' | 'txt' | 'md'
|
||||
|
||||
export interface ParsedTranscription {
|
||||
fileName: string
|
||||
fileType: TranscriptionFileType
|
||||
text: string
|
||||
size: number
|
||||
}
|
||||
|
||||
const VTT_HEADER_RE = /^WEBVTT\s/mi
|
||||
const VTT_TIMING_RE = /^\d{2}:\d{2}:\d{2}\.\d{3}\s*-->\s*\d{2}:\d{2}:\d{2}\.\d{3}/m
|
||||
const VTT_CUE_RE = /\d{2}:\d{2}:\d{2}\.\d{3}\s*-->\s*\d{2}:\d{2}:\d{2}\.\d{3}/g
|
||||
|
||||
function isVTT(text: string): boolean {
|
||||
return VTT_HEADER_RE.test(text) || VTT_TIMING_RE.test(text)
|
||||
}
|
||||
|
||||
function parseVtt(raw: string): string {
|
||||
return raw
|
||||
.replace(VTT_HEADER_RE, '')
|
||||
.replace(/Kind:.*\n?/gi, '')
|
||||
.replace(/Language:.*\n?/gi, '')
|
||||
.split(/\r?\n/)
|
||||
.filter(line => {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) return false
|
||||
if (/^\d+$/.test(trimmed)) return false
|
||||
if (VTT_TIMING_RE.test(trimmed)) return false
|
||||
return true
|
||||
})
|
||||
.join(' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function detectType(fileName: string): TranscriptionFileType {
|
||||
const ext = fileName.split('.').pop()?.toLowerCase()
|
||||
if (ext === 'docx') return 'docx'
|
||||
if (ext === 'vtt') return 'vtt'
|
||||
if (ext === 'md') return 'md'
|
||||
return 'txt'
|
||||
}
|
||||
|
||||
export async function parseFile(file: File): Promise<ParsedTranscription> {
|
||||
const fileName = file.name
|
||||
const fileType = detectType(fileName)
|
||||
const size = file.size
|
||||
|
||||
console.log(`[Alpha] Parsing file: ${fileName} (${fileType}, ${size} bytes)`)
|
||||
|
||||
let text: string
|
||||
|
||||
if (fileType === 'docx') {
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const result = await mammoth.extractRawText({ arrayBuffer })
|
||||
text = result.value.trim()
|
||||
} else {
|
||||
const raw = await file.text()
|
||||
if (fileType === 'vtt' || isVTT(raw)) {
|
||||
text = parseVtt(raw)
|
||||
} else {
|
||||
text = raw.trim()
|
||||
}
|
||||
}
|
||||
|
||||
return { fileName, fileType, text, size }
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import db from '@/services/db'
|
||||
import type { SessionExtraction } from '@/services/session-analyzer'
|
||||
|
||||
export interface ProjectDoc {
|
||||
projectId: number
|
||||
projectName: string
|
||||
updatedAt: string
|
||||
sessionCount: number
|
||||
markdown: string
|
||||
}
|
||||
|
||||
export async function getProjectDoc(projectId: number): Promise<ProjectDoc | null> {
|
||||
const doc = await db.table('project_docs').get(projectId)
|
||||
return (doc as ProjectDoc) || null
|
||||
}
|
||||
|
||||
export async function generateProjectDoc(
|
||||
projectId: number,
|
||||
projectName: string,
|
||||
extraction: SessionExtraction,
|
||||
transcriptionText: string,
|
||||
fileName: string,
|
||||
previousDoc?: ProjectDoc | null,
|
||||
): Promise<ProjectDoc> {
|
||||
const now = new Date().toISOString().slice(0, 16).replace('T', ' ')
|
||||
const sessionCount = (previousDoc?.sessionCount || 0) + 1
|
||||
|
||||
// ─── Block 1: Summary (replaced) ───────────────────────
|
||||
|
||||
const objectivesMd = extraction.objectives.map(o =>
|
||||
o.isNew ? `- [ ] ${o.text} 🆕` : `- [ ] ${o.text}`
|
||||
).join('\n')
|
||||
|
||||
const tasksMd = extraction.pendingTasks.map((t, i) =>
|
||||
`| ${i + 1} | [ ] ${t.description} | ${t.origin} | ${now.slice(0, 10)} | ${t.priority} |`
|
||||
).join('\n')
|
||||
|
||||
const commitmentsMd = extraction.commitments.map(c =>
|
||||
`| ${c.description} | ${c.responsible} | ${c.dueDate} | ${c.status === 'Cumplido' ? '✅' : '⏳'} | — |`
|
||||
).join('\n')
|
||||
|
||||
const completedMd = extraction.completedTasks.map(t => `- [x] ${t}`).join('\n')
|
||||
|
||||
const milestonesMd = extraction.commitments
|
||||
.filter(c => c.dueDate && c.status !== 'Cumplido')
|
||||
.map(c => `- **${c.dueDate}**: ${c.description}`)
|
||||
.join('\n')
|
||||
|
||||
const block1 = `# 📋 ${projectName} — Resumen Ejecutivo
|
||||
|
||||
> ⚠️ Última actualización: ${now}
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Resumen Ejecutivo
|
||||
${extraction.summary}
|
||||
|
||||
## 🎯 Objetivos
|
||||
${objectivesMd || '_Sin objetivos registrados_'}
|
||||
|
||||
## 📝 Tareas Pendientes
|
||||
| # | Tarea | Origen | Fecha creación | Prioridad |
|
||||
|---|-------|--------|----------------|-----------|
|
||||
${tasksMd || '_Sin tareas pendientes_'}
|
||||
|
||||
## ✅ Compromisos
|
||||
| Compromiso | Responsable | Fecha límite | Estado | Notas |
|
||||
|------------|-------------|--------------|--------|-------|
|
||||
${commitmentsMd || '_Sin compromisos_'}
|
||||
|
||||
## ✅ Tareas Completadas
|
||||
${completedMd || '_Sin tareas completadas en esta sesión_'}
|
||||
|
||||
## 📅 Próximos Hitos
|
||||
${milestonesMd || '_Sin hitos próximos_'}
|
||||
|
||||
## 📊 Métricas de Seguimiento
|
||||
- Sesiones registradas: ${sessionCount}
|
||||
- Tareas pendientes: ${extraction.pendingTasks.length}
|
||||
- Compromisos cumplidos: ${extraction.commitments.filter(c => c.status === 'Cumplido').length}/${extraction.commitments.length}
|
||||
- Decisiones tomadas: ${extraction.decisions.length}
|
||||
|
||||
---
|
||||
|
||||
### Bloque 2: Registro de Sesiones
|
||||
|
||||
---
|
||||
|
||||
## 📜 Registro Completo de Sesiones
|
||||
`
|
||||
|
||||
// ─── Block 2: Session entry (appended) ─────────────────
|
||||
|
||||
const decisionsMd = extraction.decisions.map(d => `- ${d}`).join('\n')
|
||||
const keyPointsMd = extraction.keyPoints.map(k => `- ${k}`).join('\n')
|
||||
|
||||
const sessionEntry = `---
|
||||
|
||||
## 📍 Sesión ${sessionCount}: ${extraction.sessionTitle}
|
||||
|
||||
**Archivo fuente:** \`${fileName}\`
|
||||
**Fecha:** ${now}
|
||||
|
||||
### Resumen de la sesión
|
||||
${extraction.summary}
|
||||
|
||||
### Tareas identificadas en esta sesión
|
||||
${extraction.pendingTasks.map(t => `- [ ] ${t.description} (_${t.priority}_)`).join('\n') || '_Ninguna_'}
|
||||
|
||||
### Decisiones tomadas
|
||||
${decisionsMd || '_Ninguna_'}
|
||||
|
||||
### Puntos clave
|
||||
${keyPointsMd || '_Ninguno_'}
|
||||
|
||||
### Transcripción completa
|
||||
|
||||
\`\`\`
|
||||
${transcriptionText}
|
||||
\`\`\`
|
||||
`
|
||||
|
||||
// ─── Assemble document ─────────────────────────────────
|
||||
|
||||
const previousSessions = previousDoc
|
||||
? previousDoc.markdown.split('---\n\n## 📜 Registro Completo de Sesiones')[1] || ''
|
||||
: ''
|
||||
|
||||
const markdown = `${block1}${previousSessions}\n${sessionEntry}\n`
|
||||
|
||||
const doc: ProjectDoc = {
|
||||
projectId,
|
||||
projectName,
|
||||
updatedAt: now,
|
||||
sessionCount,
|
||||
markdown,
|
||||
}
|
||||
|
||||
// Save to Dexie
|
||||
await db.table('project_docs').put(doc)
|
||||
|
||||
return doc
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { callAI } from '@/services/ai'
|
||||
|
||||
export interface SessionExtraction {
|
||||
sessionTitle: string
|
||||
summary: string
|
||||
objectives: { text: string; isNew: boolean }[]
|
||||
pendingTasks: { description: string; origin: string; priority: string }[]
|
||||
commitments: { description: string; responsible: string; dueDate: string; status: string }[]
|
||||
decisions: string[]
|
||||
completedTasks: string[]
|
||||
keyPoints: string[]
|
||||
}
|
||||
|
||||
const SESSION_SYSTEM_PROMPT = `Eres un asistente de gestión de proyectos. Analizás transcripciones de reuniones y extraés información estructurada.
|
||||
|
||||
Reglas:
|
||||
1. Identificá el título de la sesión basado en el contenido y fecha
|
||||
2. Extraé un resumen ejecutivo de 2-3 oraciones
|
||||
3. Listá objetivos mencionados, marcando cuáles son NUEVOS vs existentes
|
||||
4. Extraé tareas pendientes con su origen y prioridad (Alta/Media/Baja)
|
||||
5. Identificá compromisos con responsable, fecha límite y estado
|
||||
6. Listá decisiones tomadas durante la sesión
|
||||
7. Detectá tareas completadas (si hay evidencia)
|
||||
8. Incluí puntos clave, bloqueos o descubrimientos
|
||||
9. No inventes información que no esté en la transcripción
|
||||
10. Respondé SOLO con JSON válido
|
||||
|
||||
Formato de respuesta JSON:
|
||||
{
|
||||
"sessionTitle": "Título descriptivo de la sesión",
|
||||
"summary": "Resumen ejecutivo de 2-3 oraciones",
|
||||
"objectives": [
|
||||
{ "text": "Descripción del objetivo", "isNew": true }
|
||||
],
|
||||
"pendingTasks": [
|
||||
{ "description": "Descripción de la tarea", "origin": "Sesión o contexto", "priority": "Alta|Media|Baja" }
|
||||
],
|
||||
"commitments": [
|
||||
{ "description": "Compromiso", "responsible": "Nombre", "dueDate": "YYYY-MM-DD", "status": "Pendiente|Cumplido|Vencido" }
|
||||
],
|
||||
"decisions": ["Decisión 1", "Decisión 2"],
|
||||
"completedTasks": ["Tarea completada 1"],
|
||||
"keyPoints": ["Punto clave 1"]
|
||||
}`
|
||||
|
||||
export async function analyzeSession(
|
||||
transcription: string,
|
||||
projectName: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<SessionExtraction> {
|
||||
const userContent = `Proyecto: ${projectName}\n\nTranscripción:\n${transcription}`
|
||||
|
||||
console.log(`[Alpha] Session analyze — project: ${projectName}, text: ${transcription.length} chars`)
|
||||
|
||||
const content = await callAI(
|
||||
[
|
||||
{ role: 'system', content: SESSION_SYSTEM_PROMPT },
|
||||
{ role: 'user', content: userContent },
|
||||
],
|
||||
0.3,
|
||||
4096,
|
||||
signal,
|
||||
)
|
||||
|
||||
try {
|
||||
const jsonStr = content.replace(/```json\s*/gi, '').replace(/```\s*$/g, '').trim()
|
||||
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)
|
||||
throw new Error('No se pudo parsear el análisis de la sesión')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Cache-Aside + Write-Through storage para Alpha.
|
||||
*
|
||||
* ─── Patrón ─────────────────────────────────────────────────────
|
||||
*
|
||||
* L1 (rápido) → Map en memoria ~0ms
|
||||
* L2 (persistente) → Dexie IndexedDB ~5-50ms
|
||||
* L3 (fallback) → localStorage ~1ms
|
||||
*
|
||||
* ─── Lectura (Cache-Aside) ─────────────────────────────────────
|
||||
*
|
||||
* get(key):
|
||||
* 1. L1 hit → return (instantáneo)
|
||||
* 2. L2 hit → poblar L1 → return
|
||||
* 3. L3 hit → poblar L2 + L1 → return
|
||||
* 4. Miss → return null
|
||||
*
|
||||
* ─── Escritura (Write-Through) ─────────────────────────────────
|
||||
*
|
||||
* set(key, value):
|
||||
* 1. Escribir L1 (instantáneo)
|
||||
* 2. Escribir L2 (Dexie, async await)
|
||||
* 3. Escribir L3 (localStorage, sync)
|
||||
* Si L2 falla → el dato sigue en L1 + L3 (consistencia eventual)
|
||||
*
|
||||
* ─── ¿Por qué este patrón? ─────────────────────────────────────
|
||||
*
|
||||
* - Cache-Aside: control explícito de qué se cachea y cuándo
|
||||
* - Write-Through: el cache siempre refleja la fuente de verdad
|
||||
* - L3 (localStorage) actúa como quorum: si IndexedDB falla,
|
||||
* los datos no se pierden
|
||||
* - Para RUMBO: mismo patrón, cambia L2 de Dexie a Turso/libSQL
|
||||
* y L3 pasa a ser un archivo JSON en ~/.rumbo/cache.json
|
||||
*
|
||||
* ─── Uso ───────────────────────────────────────────────────────
|
||||
*
|
||||
* import { storage } from '@/services/storage'
|
||||
* await storage.init()
|
||||
* storage.get('key') → string | null (sync)
|
||||
* await storage.set('k', v) → void (async)
|
||||
* await storage.remove('k') → void (async)
|
||||
* storage.getJSON<T>('k') → T | null (sync)
|
||||
* await storage.setJSON() → void (async)
|
||||
*/
|
||||
|
||||
import db from '@/services/db'
|
||||
|
||||
class AppStorage {
|
||||
/** L1: cache en memoria */
|
||||
private cache = new Map<string, { value: string; ttl: number | null }>()
|
||||
private loaded = false
|
||||
private initPromise: Promise<void> | null = null
|
||||
|
||||
// ─── Init ──────────────────────────────────────────────────
|
||||
|
||||
async init() {
|
||||
if (this.initPromise) return this.initPromise
|
||||
this.initPromise = this._load()
|
||||
return this.initPromise
|
||||
}
|
||||
|
||||
private async _load() {
|
||||
const all = await db.settings.toArray()
|
||||
for (const entry of all) {
|
||||
this.cache.set(entry.key, { value: entry.value, ttl: null })
|
||||
}
|
||||
this.loaded = true
|
||||
console.log(`[Storage] Init OK — ${all.length} entries en L1`)
|
||||
}
|
||||
|
||||
isReady() { return this.loaded }
|
||||
|
||||
// ─── Lectura: Cache-Aside (L1 → L2 → L3) ────────────────
|
||||
|
||||
get(key: string): string | null {
|
||||
// 1. L1 hit
|
||||
const entry = this.cache.get(key)
|
||||
if (entry) {
|
||||
if (entry.ttl !== null && entry.ttl < Date.now()) {
|
||||
this.cache.delete(key)
|
||||
} else {
|
||||
return entry.value
|
||||
}
|
||||
}
|
||||
|
||||
// 2. L2 (Dexie)
|
||||
// Nota: Dexie.get() es async. Para mantener get() sync,
|
||||
// delegamos la carga asíncrona a init(). Si no está en L1
|
||||
// después de init, cae a L3.
|
||||
// En la práctica, init() carga TODO al arranque, así que
|
||||
// L1 siempre está poblado para keys existentes.
|
||||
|
||||
// 3. L3 (localStorage) — fallback + migración
|
||||
const fallback = localStorage.getItem(key)
|
||||
if (fallback !== null) {
|
||||
this.cache.set(key, { value: fallback, ttl: null })
|
||||
// Write-Through hacia L2 (fire-and-forget)
|
||||
db.settings.put({ key, value: fallback }).catch(() => {})
|
||||
return fallback
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
getJSON<T>(key: string): T | null {
|
||||
const raw = this.get(key)
|
||||
if (!raw) return null
|
||||
try { return JSON.parse(raw) as T } catch { return null }
|
||||
}
|
||||
|
||||
// ─── Escritura: Write-Through (L1 + L2 + L3) ─────────────
|
||||
|
||||
async set(key: string, value: string) {
|
||||
// 1. L1 (instantáneo)
|
||||
this.cache.set(key, { value, ttl: null })
|
||||
|
||||
// 2. L2 + L3 en paralelo
|
||||
await Promise.all([
|
||||
db.settings.put({ key, value }).catch(e => {
|
||||
console.error(`[Storage] L2 error writing "${key}":`, e)
|
||||
}),
|
||||
Promise.resolve().then(() => {
|
||||
localStorage.setItem(key, value)
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
async setJSON(key: string, value: unknown) {
|
||||
await this.set(key, JSON.stringify(value))
|
||||
}
|
||||
|
||||
async remove(key: string) {
|
||||
this.cache.delete(key)
|
||||
|
||||
await Promise.all([
|
||||
db.settings.delete(key).catch(e => {
|
||||
console.error(`[Storage] L2 error deleting "${key}":`, e)
|
||||
}),
|
||||
Promise.resolve().then(() => {
|
||||
localStorage.removeItem(key)
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
// ─── Utilidades ───────────────────────────────────────────
|
||||
|
||||
/** Expone el tamaño del cache L1 (debug) */
|
||||
get cacheSize() { return this.cache.size }
|
||||
}
|
||||
|
||||
export const storage = new AppStorage()
|
||||
@@ -1,11 +1,12 @@
|
||||
import * as XLSX from 'xlsx'
|
||||
import { kappa } from '@/services/kappa-api'
|
||||
import { storage } from '@/services/storage'
|
||||
import type { HuDraftRecord } from '@/services/tauri-db'
|
||||
|
||||
const BASE = '/api'
|
||||
|
||||
async function uploadExcel(initiativeId: number, file: Blob): Promise<Response> {
|
||||
const token = localStorage.getItem('kappa_token')
|
||||
const token = storage.get('kappa_token')
|
||||
const formData = new FormData()
|
||||
formData.append('file', file, 'HistoriasUsuario.xlsx')
|
||||
return fetch(`${BASE}/userstorys/upload-excel/${initiativeId}/`, {
|
||||
|
||||
Reference in New Issue
Block a user