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:
2026-05-28 12:42:30 -05:00
parent 96ed01d922
commit 7d299554bf
28 changed files with 2426 additions and 56 deletions
+28 -5
View File
@@ -120,12 +120,35 @@ bun dev # http://localhost:5173
Abre http://localhost:5173. El proxy de Vite redirige `/api/*` a `https://kappa.lambdaanalytics.co`.
## Pipeline de transcripciones (K-10) ✅
Nueva vista **Transcripciones** en la barra lateral (icono upload).
**Flujo:**
1. Configurar API key de OpenRouter (DeepSeek) — se guarda en localStorage
2. Seleccionar proyecto destino desde el dropdown
3. Arrastrar o seleccionar archivo (.docx, .vtt, .txt, .md)
4. El archivo se parsea localmente (mammoth.js para docx, parseo manual para VTT)
5. Click "Analizar con IA" → se envía a OpenRouter DeepSeek
6. La IA devuelve HUs estructuradas (título, descripción, criterios de aceptación, prioridad, tipo)
7. Revisar, seleccionar/deseleccionar, eliminar HUs
8. Click "Crear en KAPPA" → se crean vía API
9. Las HUs creadas se marcan en verde y se refresca el store de workitems
**Archivos nuevos:**
- `src/services/ai.ts` — Cliente OpenRouter DeepSeek
- `src/services/parse-transcription.ts` — Parseo de .docx/.vtt/.txt/.md
- `src/stores/transcriptions.ts` — Pinia store del pipeline
- `src/views/TranscriptionsView.vue` — Vista completa con upload, preview, análisis, resultados
**Modelo usado:** `deepseek/deepseek-chat-v3-0324:free` (free tier de OpenRouter)
## Próximos pasos
1. ~~Agregar Dexie.js para cache offline~~ (K-15)
2. ~~Pipeline de transcripciones~~ (K-10)
3. ~~Dashboard multi-proyecto~~ (K-11)
4. ~~Priorizador diario~~ (K-12)
5. ~~Generador de reportes~~ (K-13)
6. **Integración calendario Google/Outlook** (K-21)
7. **Alertas post-reunión** (K-22)
3. **Dashboard multi-proyecto** (K-11)
4. **Priorizador diario** (K-12)
5. **Generador de reportes** (K-13)
6. **Integración calendario Google/Outlook** (K-21)
7. **Alertas post-reunión** (K-22)
+47
View File
@@ -19,6 +19,7 @@
"dexie": "^4.0.4",
"dnd-kit-vue": "^0.0.2",
"lucide-vue-next": "^1.0.0",
"mammoth": "^1.12.0",
"pinia": "^2.1.7",
"reka-ui": "^2.9.8",
"shadcn-vue": "^2.7.3",
@@ -456,6 +457,8 @@
"@vueuse/shared": ["@vueuse/shared@14.3.0", "", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg=="],
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="],
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
@@ -482,6 +485,8 @@
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
"ast-types": ["ast-types-x@1.18.0", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-ZtfIlyTCmnAXPCQo4mSDtFsHL7L3q0sJfpVYPmy5uYPjs+fynzOuc1Cg6yQ9fF6h61RjEWtOlRFwV1Kc80Qs6A=="],
@@ -494,8 +499,12 @@
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.32", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg=="],
"bluebird": ["bluebird@3.4.7", "", {}, "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA=="],
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
@@ -564,6 +573,8 @@
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
"crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="],
@@ -612,6 +623,8 @@
"diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
"dingbat-to-unicode": ["dingbat-to-unicode@1.0.1", "", {}, "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w=="],
"dnd-kit-vue": ["dnd-kit-vue@0.0.2", "", { "dependencies": { "@dnd-kit/abstract": "^0.1.19", "@dnd-kit/dom": "^0.1.19", "@dnd-kit/state": "^0.1.19", "@vueuse/core": "^13.4.0", "reka-ui": "^2.3.1" }, "peerDependencies": { "vue": "^3.3.0" } }, "sha512-2ZQfqTulZI7vqFiYscV7VMQRXSEryjanlaCY5BvkDf5i+whEAvOKSckyBa6SK8LCPaF5f/IIcUhfh6TnbaWq3A=="],
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
@@ -624,6 +637,8 @@
"dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="],
"duck": ["duck@0.1.12", "", { "dependencies": { "underscore": "^1.13.1" } }, "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"eciesjs": ["eciesjs@0.4.18", "", { "dependencies": { "@ecies/ciphers": "^0.2.5", "@noble/ciphers": "^1.3.0", "@noble/curves": "^1.9.7", "@noble/hashes": "^1.8.0" } }, "sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ=="],
@@ -780,6 +795,8 @@
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="],
@@ -818,6 +835,8 @@
"is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
"isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
"jackspeak": ["jackspeak@4.2.3", "", { "dependencies": { "@isaacs/cliui": "^9.0.0" } }, "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg=="],
@@ -842,12 +861,16 @@
"jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="],
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
@@ -882,6 +905,8 @@
"log-symbols": ["log-symbols@7.0.1", "", { "dependencies": { "is-unicode-supported": "^2.0.0", "yoctocolors": "^2.1.1" } }, "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg=="],
"lop": ["lop@0.4.2", "", { "dependencies": { "duck": "^0.1.12", "option": "~0.2.1", "underscore": "^1.13.1" } }, "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lucide-vue-next": ["lucide-vue-next@1.0.0", "", { "peerDependencies": { "vue": ">=3.0.1" } }, "sha512-V6SPvx1IHTj/UY+FrIYWV5faISsPSb8BnWSFDxAtezWKvWc9ZZ40PDrdu1/Qb5vg4lHWr1hs1BAMGVGm6V1Xdg=="],
@@ -890,6 +915,8 @@
"make-asynchronous": ["make-asynchronous@1.1.0", "", { "dependencies": { "p-event": "^6.0.0", "type-fest": "^4.6.0", "web-worker": "^1.5.0" } }, "sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg=="],
"mammoth": ["mammoth@1.12.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.6", "argparse": "~1.0.3", "base64-js": "^1.5.1", "bluebird": "~3.4.0", "dingbat-to-unicode": "^1.0.1", "jszip": "^3.7.1", "lop": "^0.4.2", "path-is-absolute": "^1.0.0", "underscore": "^1.13.1", "xmlbuilder": "^10.0.0" }, "bin": { "mammoth": "bin/mammoth" } }, "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
@@ -956,6 +983,8 @@
"open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
"option": ["option@0.2.4", "", {}, "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"ora": ["ora@9.4.0", "", { "dependencies": { "chalk": "^5.6.2", "cli-cursor": "^5.0.0", "cli-spinners": "^3.2.0", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.1.0", "log-symbols": "^7.0.1", "stdin-discarder": "^0.3.2", "string-width": "^8.1.0" } }, "sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ=="],
@@ -970,6 +999,8 @@
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
@@ -1014,6 +1045,8 @@
"prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="],
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
"prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="],
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
@@ -1030,6 +1063,8 @@
"rc9": ["rc9@3.0.1", "", { "dependencies": { "defu": "^6.1.6", "destr": "^2.0.5" } }, "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ=="],
"readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
"readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
"recast-x": ["recast-x@1.0.5", "", { "dependencies": { "ast-types": "npm:ast-types-x@1.18.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-CkfWKhQiYsMQYaWUkHdERXUxT2jJLBoa5y7zFv3dUAE7Ly5oU/0hsqrENyEfrCL03pDsQYbnoz17Cbagx/c2OA=="],
@@ -1054,6 +1089,8 @@
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"sax": ["sax@1.2.4", "", {}, "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw=="],
@@ -1064,6 +1101,8 @@
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
"setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="],
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"shadcn-vue": ["shadcn-vue@2.7.3", "", { "dependencies": { "@dotenvx/dotenvx": "^1.51.1", "@modelcontextprotocol/sdk": "^1.24.3", "@unovue/detypes": "^0.8.5", "@vue/compiler-sfc": "^3.5", "c12": "^3.3.2", "commander": "^14.0.2", "consola": "^3.4.2", "dedent": "^1.7.0", "deepmerge": "^4.3.1", "diff": "^8.0.2", "fs-extra": "^11.3.2", "fuzzysort": "^3.1.0", "get-tsconfig": "^4.13.0", "giget": "^3.2.0", "magic-string": "^0.30.21", "nypm": "^0.6.2", "ofetch": "^1.5.1", "open": "^10.2.0", "ora": "^9.0.0", "pathe": "^2.0.3", "postcss": "^8.5.10", "postcss-selector-parser": "^7.1.1", "prompts": "^2.4.2", "reka-ui": "^2.9.2", "semver": "^7.7.3", "stringify-object": "^6.0.0", "tailwindcss": "^4.1.17", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "ts-morph": "^27.0.2", "undici": "^7.16.0", "validate-npm-package-name": "^5.0.1", "vue-metamorph": "3.3.4", "zod": "^3.25.76", "zod-to-json-schema": "^3.25.0" }, "bin": { "shadcn-vue": "dist/index.js" } }, "sha512-bYfn9RbjG98++IjvCEhVhvva64mjDAGrtE4QNSuy6jjtE/XpI3syRJaBYmrvPBI93W27dLGbCTr8p7gnvjkQvQ=="],
@@ -1094,6 +1133,8 @@
"source-map-resolve": ["source-map-resolve@0.6.0", "", { "dependencies": { "atob": "^2.1.2", "decode-uri-component": "^0.2.0" } }, "sha512-KXBr9d/fO/bWo97NXsPIAW1bFSBOuCnjbNTBMO7N59hsv5i9yzRDfcYwwt0l04+VqnKC+EwzvJZIP/qkuMgR/w=="],
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
"ssf": ["ssf@0.11.2", "", { "dependencies": { "frac": "~1.1.2" } }, "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
@@ -1102,6 +1143,8 @@
"string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="],
"string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"stringify-object": ["stringify-object@6.0.0", "", { "dependencies": { "get-own-enumerable-keys": "^1.0.0", "is-identifier": "^1.0.1", "is-obj": "^3.0.0", "is-regexp": "^3.1.0" } }, "sha512-6f94vIED6vmJJfh3lyVsVWxCYSfI5uM+16ntED/Ql37XIyV6kj0mRAAiTeMMc/QLYIaizC3bUprQ8pQnDDrKfA=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
@@ -1148,6 +1191,8 @@
"ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="],
"underscore": ["underscore@1.13.8", "", {}, "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ=="],
"undici": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="],
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
@@ -1198,6 +1243,8 @@
"xlsx": ["xlsx@0.18.5", "", { "dependencies": { "adler-32": "~1.3.0", "cfb": "~1.2.1", "codepage": "~1.15.0", "crc-32": "~1.2.1", "ssf": "~0.11.2", "wmf": "~1.0.1", "word": "~0.3.0" }, "bin": { "xlsx": "bin/xlsx.njs" } }, "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ=="],
"xmlbuilder": ["xmlbuilder@10.1.1", "", {}, "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
+1
View File
@@ -25,6 +25,7 @@
"dexie": "^4.0.4",
"dnd-kit-vue": "^0.0.2",
"lucide-vue-next": "^1.0.0",
"mammoth": "^1.12.0",
"pinia": "^2.1.7",
"reka-ui": "^2.9.8",
"shadcn-vue": "^2.7.3",
+3 -1
View File
@@ -2,13 +2,15 @@
import { ref, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useProjectsStore } from '@/stores/projects'
import { storage } from '@/services/storage'
import LoginView from '@/views/LoginView.vue'
import NewDashboardView from '@/views/NewDashboardView.vue'
const auth = useAuthStore()
const projectsStore = useProjectsStore()
onMounted(() => {
onMounted(async () => {
await storage.init()
if (auth.isAuthenticated) {
projectsStore.fetchProjects()
}
+228
View File
@@ -0,0 +1,228 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { chatWithAI } from '@/services/ai'
import { useSettingsStore, AVAILABLE_MODELS, PROVIDER_CONFIG, hasProviderApiKey, type AIProvider } from '@/stores/settings'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Send, Loader2, AlertCircle, Brain, Settings2, Check, ChevronDown } from 'lucide-vue-next'
const { t } = useI18n()
const settings = useSettingsStore()
const props = defineProps({
projectName: { type: String, required: true },
projectDescription: { type: String, default: '' },
epicCount: { type: Number, required: true },
huCount: { type: Number, required: true },
})
const emit = defineEmits<{
'navigate-settings': []
}>()
const prompt = ref('')
const response = ref('')
const loading = ref(false)
const error = ref<string | null>(null)
const configuredProviders = computed(() => {
const providers: AIProvider[] = ['openrouter', 'minimax', 'opencode']
return providers.filter(p => hasProviderApiKey(p))
})
const allProviders = computed(() => {
const providers: AIProvider[] = ['openrouter', 'minimax', 'opencode']
return providers
})
function modelsForProvider(p: AIProvider) {
return AVAILABLE_MODELS.filter(m => m.provider === p)
}
function switchModel(provider: AIProvider, modelId: string) {
if (provider !== settings.provider) {
settings.setActiveProvider(provider)
}
settings.setModel(modelId)
}
function buildSystemPrompt(): string {
return `Eres un asistente de proyectos ágil integrado en ${props.projectName}.
Tu rol es ayudar al usuario a pensar, planificar y organizar el trabajo del proyecto.
Contexto del proyecto:
- Nombre: ${props.projectName}
- Descripción: ${props.projectDescription || 'Sin descripción'}
- Épicas: ${props.epicCount}
- HUs: ${props.huCount}
Reglas:
1. Respondé en el mismo idioma del usuario (español por defecto)
2. Sé conciso y práctico
3. Si el usuario pide crear algo que requiere acción en KAPPA, explicá que debe usar la sección de Transcripciones
4. Podés ayudar a redactar HUs, criterios de aceptación, priorizar tareas, sugerir enfoques
5. No inventes información que no esté en el contexto del proyecto`
}
async function sendPrompt() {
const text = prompt.value.trim()
if (!text || loading.value) return
loading.value = true
error.value = null
response.value = ''
prompt.value = ''
try {
const msgs = [{ role: 'user' as const, content: text }]
const result = await chatWithAI(msgs, buildSystemPrompt())
response.value = result
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
}
</script>
<template>
<Card id="project-ai-chat">
<CardHeader class="pb-3 flex flex-row items-center justify-between">
<CardTitle class="text-sm font-medium flex items-center gap-2">
<Brain class="size-4" />
{{ t('projectAi.title') }}
</CardTitle>
<div class="flex items-center gap-2">
<Button
v-if="!settings.apiKeyConfigured"
variant="ghost"
size="sm"
class="text-xs h-7"
@click="emit('navigate-settings')"
>
<Settings2 class="size-3 mr-1" />
{{ t('projectAi.configure') }}
</Button>
<DropdownMenu v-else>
<DropdownMenuTrigger as-child>
<Button variant="outline" size="sm" class="text-xs h-7 gap-1 font-mono">
{{ settings.selectedModel?.label || settings.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="settings.provider === p && settings.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>
</div>
</CardHeader>
<CardContent class="space-y-3">
<div
v-if="response || loading || error"
id="project-ai-response"
class="min-h-[80px] p-3 rounded-lg bg-muted/30 text-sm leading-relaxed whitespace-pre-wrap"
>
<div v-if="loading" class="flex items-center gap-2 text-muted-foreground">
<Loader2 class="size-4 animate-spin" />
<span>{{ t('projectAi.thinking') }}</span>
</div>
<p v-else-if="error" class="text-destructive text-xs flex items-start gap-2">
<AlertCircle class="size-4 mt-0.5 shrink-0" />
{{ error }}
</p>
<p v-else>{{ response }}</p>
</div>
<div
v-if="!settings.apiKeyConfigured"
class="text-xs text-muted-foreground p-3 rounded-lg border border-dashed text-center"
>
{{ t('projectAi.noKey') }}
<button
class="text-primary hover:underline cursor-pointer"
@click="emit('navigate-settings')"
>{{ t('projectAi.configureLink') }}</button>.
</div>
<div class="flex gap-2">
<textarea
v-model="prompt"
:placeholder="t('projectAi.placeholder')"
class="flex-1 min-h-[40px] max-h-[120px] text-sm rounded-lg border bg-transparent px-3 py-2 resize-none"
rows="1"
@keydown.enter.ctrl="sendPrompt"
@keydown.enter.meta="sendPrompt"
/>
<Button
size="icon"
class="size-10 shrink-0 self-end"
:disabled="!prompt.trim() || loading || !settings.apiKeyConfigured"
@click="sendPrompt"
id="project-ai-send-btn"
>
<Loader2 v-if="loading" class="size-4 animate-spin" />
<Send v-else class="size-4" />
</Button>
</div>
<p class="text-[10px] text-muted-foreground text-right">
Ctrl+Enter
</p>
</CardContent>
</Card>
</template>
+4
View File
@@ -8,6 +8,8 @@ import {
IconListDetails,
IconChartBar,
IconUsers,
IconFileUpload,
IconSettings,
} from "@tabler/icons-vue"
import {
@@ -23,9 +25,11 @@ const { t } = useI18n()
const mainNavItems = [
{ title: 'nav.board', icon: IconLayoutKanban, id: 'metrics' },
{ title: 'nav.projects', icon: IconFolder, id: 'projects' },
{ title: 'nav.transcriptions', icon: IconFileUpload, id: 'transcriptions' },
{ title: 'nav.lifecycle', icon: IconListDetails, id: 'lifecycle' },
{ title: 'nav.analytics', icon: IconChartBar, id: 'analytics' },
{ title: 'nav.team', icon: IconUsers, id: 'team' },
{ title: 'nav.settings', icon: IconSettings, id: 'settings' },
]
defineProps<{
+4 -1
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref, watch, computed } from "vue"
import { useI18n } from "vue-i18n"
import { storage } from "@/services/storage"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
@@ -30,9 +31,11 @@ const props = defineProps<{
const tabLabels: Record<string, string> = {
metrics: 'Métricas',
projects: 'Proyectos',
transcriptions: 'Transcripciones',
lifecycle: 'Ciclo de Vida',
analytics: 'Analíticas',
team: 'Equipo',
settings: 'Configuración',
documents: 'Documentos',
'data-library': 'Biblioteca de Datos',
reports: 'Reportes',
@@ -54,7 +57,7 @@ const languages = [
function setLanguage(code: string) {
locale.value = code
localStorage.setItem("alpha-language", code)
storage.set("alpha-language", code)
}
function setTheme(theme: "light" | "dark" | "system") {
+100 -2
View File
@@ -2,6 +2,7 @@
"nav": {
"quickCreate": "Create project",
"board": "Metrics",
"transcriptions": "Transcriptions",
"projects": "Projects",
"lifecycle": "Lifecycle",
"analytics": "Analytics",
@@ -10,7 +11,8 @@
"dataLibrary": "Data Library",
"reports": "Reports",
"wordAssistant": "Word Assistant",
"templates": "Templates"
"templates": "Templates",
"settings": "Settings"
},
"siteHeader": {
"title": "Dashboard",
@@ -21,6 +23,7 @@
},
"settings": {
"title": "Settings",
"subtitle": "Manage AI providers, models, and preferences",
"language": "Language",
"theme": "Theme",
"light": "Light",
@@ -28,7 +31,37 @@
"system": "System",
"about": "About",
"documentation": "Documentation",
"logout": "Log out"
"logout": "Log out",
"aiProvider": "AI Provider",
"aiProviderDesc": "Choose which AI engine to use for transcript analysis and story generation",
"recommended": "Recommended",
"bridge": "Bridge",
"openrouterDesc": "Access 200+ models with a single API key. Free and paid tiers available.",
"minimaxDesc": "High-performance Chinese models (MiniMax Text-01). Use your Token Plan API key from platform.minimax.io.",
"opencodeDesc": "Inherit model configuration from OpenCode (reads auth.json)",
"keyHelp": "You need an API key for this provider.",
"minimaxKeyHelp": "You need a User Token (not sk-... API key) from User Center → Interface Key. The token starts with 'eyJ...' or is a long string:",
"minimaxGroupId": "Group ID (optional)",
"minimaxGroupIdPlaceholder": "mg-...",
"minimaxGroupIdHelp": "Required if using a Group API Key. Find it at: platform.minimaxi.com → Group management.",
"apiKey": "API Key",
"apiKeyPlaceholder": "sk-or-v1-...",
"keyConfigured": "API key configured",
"saveKey": "Save",
"removeKey": "Remove",
"keySaved": "API key saved successfully",
"opencodeInfoTitle": "OpenCode Integration",
"opencodeInfoDesc": "Alpha can read API keys you've already configured in OpenCode from:",
"opencodeInfoFuture": "In the future, this will happen automatically when Alpha runs on Tauri (direct filesystem access). For now, configure OpenRouter manually.",
"opencodeFallback": "In the meantime, use",
"model": "AI Model",
"modelDesc": "Select which model to use for transcript analysis",
"freeModels": "Free",
"cheapModels": "Cheap",
"premiumModels": "Premium",
"currentModel": "Current model",
"account": "Account",
"loggedInAs": "Logged in as"
},
"login": {
"title": "Sign in",
@@ -214,6 +247,71 @@
"trendingUp": "Trending up",
"trendingDown": "Trending down"
},
"transcriptions": {
"title": "Transcriptions",
"subtitle": "Manage project sessions. Upload transcripts, analyze with AI, and maintain an incremental document.",
"configureAI": "Configure AI",
"aiKeyTitle": "OpenRouter API Key",
"aiKeyDesc": "You need an OpenRouter API key to analyze transcripts with DeepSeek.",
"apiKeyLabel": "OpenRouter API Key",
"apiKeyPlaceholder": "sk-or-v1-...",
"saveKey": "Save",
"selectProject": "Target project",
"projectPlaceholder": "Select a project...",
"dropzone": "Drop a file here",
"dropzoneFormats": "DOCX, VTT, TXT or MD",
"selectFile": "Select file",
"parsing": "Processing file...",
"addMore": "Add files",
"changeFile": "Change file",
"analyze": "Analyze with AI",
"chars": "characters",
"analyzingTitle": "Analyzing transcript...",
"analyzingDesc": "DeepSeek model is extracting user stories. This may take a few seconds.",
"error": "Error",
"tryAgain": "Try again",
"clear": "Clear all",
"summary": "Analysis summary",
"husFound": "{count} stories found",
"selected": "selected",
"createInKappa": "Create {count} in KAPPA",
"type": "Type",
"title": "Title",
"priority": "Priority",
"selectProjectToCreate": "Select a project above to create stories in KAPPA",
"analyzeSession": "Analyze session",
"sessionError": "Error analyzing session",
"sessionSummary": "Summary",
"sessionObjectives": "Objectives",
"sessionDecisions": "Decisions",
"sessionTasks": "Pending tasks",
"sessionCommitments": "Commitments",
"sessionKeyPoints": "Key points",
"generateDoc": "Update document",
"docUpdated": "Document updated",
"downloadDoc": "Download .md",
"docSaved": "Document saved to local database.",
"sessionCountTitle": "Sessions",
"sessionsRecorded": "sessions recorded",
"sessionDates": "Session calendar",
"noSessions": "No sessions recorded",
"sessionsLabel": "sessions",
"updatedAt": "Updated:",
"docViewer": "Session document",
"selectProjectHint": "Select a project to view its sessions",
"sessionCount": "{count} sessions | {count} session | {count} sessions"
},
"projectAi": {
"title": "Project AI Assistant",
"configure": "Configure",
"noKey": "No API key",
"keyReady": "Ready",
"configureLink": "Set up an AI provider",
"placeholder": "Ask something about the project...",
"thinking": "Thinking...",
"switchModel": "Switch model",
"settings": "Settings..."
},
"workitems": {
"unnamedEpic": "Epic {id}"
}
+100 -2
View File
@@ -2,6 +2,7 @@
"nav": {
"quickCreate": "Crear proyecto",
"board": "Métricas",
"transcriptions": "Transcripciones",
"projects": "Proyectos",
"lifecycle": "Ciclo de vida",
"analytics": "Analíticas",
@@ -10,7 +11,8 @@
"dataLibrary": "Biblioteca",
"reports": "Reportes",
"wordAssistant": "Asistente Word",
"templates": "Plantillas"
"templates": "Plantillas",
"settings": "Configuración"
},
"siteHeader": {
"title": "Tablero",
@@ -21,6 +23,7 @@
},
"settings": {
"title": "Configuración",
"subtitle": "Gestioná tus proveedores de IA, modelos y preferencias",
"language": "Idioma",
"theme": "Tema",
"light": "Claro",
@@ -28,7 +31,37 @@
"system": "Sistema",
"about": "Acerca de",
"documentation": "Documentación",
"logout": "Cerrar sesión"
"logout": "Cerrar sesión",
"aiProvider": "Proveedor de IA",
"aiProviderDesc": "Elegí qué motor de IA usar para analizar transcripciones y generar HUs",
"recommended": "Recomendado",
"bridge": "Puente",
"openrouterDesc": "Accedé a 200+ modelos con una sola API key. Gratuito y de pago.",
"minimaxDesc": "Modelos chinos de alto rendimiento (M2.7, M2.5, M2.1). Usá tu API Key del plan Token desde platform.minimax.io.",
"opencodeDesc": "Heredá la configuración de modelos desde OpenCode (lectura de auth.json)",
"keyHelp": "Necesitás una API key de este proveedor.",
"minimaxKeyHelp": "Necesitás un User Token (no API Key sk-...) desde User Center → Interface Key. El token empieza con 'eyJ...' o es un string largo:",
"minimaxGroupId": "Group ID (opcional)",
"minimaxGroupIdPlaceholder": "mg-...",
"minimaxGroupIdHelp": "Obligatorio si usás una Group API Key. Encontrás el Group ID en tu panel de MiniMax → Gestión de grupos.",
"apiKey": "API Key",
"apiKeyPlaceholder": "sk-or-v1-...",
"keyConfigured": "API key configurada",
"saveKey": "Guardar",
"removeKey": "Eliminar",
"keySaved": "API key guardada correctamente",
"opencodeInfoTitle": "Integración con OpenCode",
"opencodeInfoDesc": "Alpha puede leer las API keys que ya configuraste en OpenCode desde:",
"opencodeInfoFuture": "En el futuro, esto se hará automáticamente cuando Alpha corra en Tauri (acceso directo al sistema de archivos). Por ahora, configurá OpenRouter manualmente.",
"opencodeFallback": "Mientras tanto, usá",
"model": "Modelo de IA",
"modelDesc": "Seleccioná qué modelo usar para el análisis de transcripciones",
"freeModels": "Gratuitos",
"cheapModels": "Económicos",
"premiumModels": "Premium",
"currentModel": "Modelo actual",
"account": "Cuenta",
"loggedInAs": "Conectado como"
},
"login": {
"title": "Iniciar sesión",
@@ -214,6 +247,71 @@
"trendingUp": "Tendencia al alza",
"trendingDown": "Tendencia a la baja"
},
"transcriptions": {
"title": "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",
"aiKeyDesc": "Necesitás una API key de OpenRouter para analizar transcripciones con DeepSeek.",
"apiKeyLabel": "OpenRouter API Key",
"apiKeyPlaceholder": "sk-or-v1-...",
"saveKey": "Guardar",
"selectProject": "Proyecto destino",
"projectPlaceholder": "Seleccioná un proyecto...",
"dropzone": "Arrastrá un archivo acá",
"dropzoneFormats": "DOCX, VTT, TXT o MD",
"selectFile": "Seleccionar archivo",
"parsing": "Procesando archivo...",
"addMore": "Agregar archivos",
"changeFile": "Cambiar archivo",
"analyze": "Analizar con IA",
"chars": "caracteres",
"analyzingTitle": "Analizando transcripción...",
"analyzingDesc": "El modelo DeepSeek está extrayendo las HUs. Esto puede tomar unos segundos.",
"error": "Error",
"tryAgain": "Intentar de nuevo",
"clear": "Limpiar todo",
"summary": "Resumen del análisis",
"husFound": "{count} HUs encontradas",
"selected": "seleccionadas",
"createInKappa": "Crear {count} en KAPPA",
"type": "Tipo",
"title": "Título",
"priority": "Prioridad",
"selectProjectToCreate": "Seleccioná un proyecto arriba para crear las HUs en KAPPA",
"analyzeSession": "Analizar sesión",
"sessionError": "Error al analizar la sesión",
"sessionSummary": "Resumen",
"sessionObjectives": "Objetivos",
"sessionDecisions": "Decisiones",
"sessionTasks": "Tareas pendientes",
"sessionCommitments": "Compromisos",
"sessionKeyPoints": "Puntos clave",
"generateDoc": "Actualizar documento",
"docUpdated": "Documento actualizado",
"downloadDoc": "Descargar .md",
"docSaved": "Documento guardado en la base de datos local.",
"sessionCountTitle": "Sesiones",
"sessionsRecorded": "sesiones registradas",
"sessionDates": "Calendario de sesiones",
"noSessions": "Sin sesiones registradas",
"sessionsLabel": "sesiones",
"updatedAt": "Actualizado:",
"docViewer": "Documento de sesiones",
"selectProjectHint": "Seleccioná un proyecto para ver sus sesiones",
"sessionCount": "{count} sesiones | {count} sesión | {count} sesiones"
},
"projectAi": {
"title": "Asistente IA del proyecto",
"configure": "Configurar",
"noKey": "Sin API key",
"keyReady": "Lista",
"configureLink": "Configurá un proveedor de IA",
"placeholder": "Preguntale algo sobre el proyecto...",
"thinking": "Pensando...",
"switchModel": "Cambiar modelo",
"settings": "Configuración..."
},
"workitems": {
"unnamedEpic": "Épica {id}"
}
+178
View File
@@ -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')
}
}
+26
View File
@@ -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
+4 -3
View File
@@ -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 {
+69
View File
@@ -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 }
}
+143
View File
@@ -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
}
+74
View File
@@ -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')
}
}
+151
View File
@@ -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()
+2 -1
View File
@@ -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}/`, {
+6 -17
View File
@@ -1,20 +1,12 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { kappa } from '@/services/kappa-api'
import { storage } from '@/services/storage'
import type { KappaLoginPayload } from '@/types/kappa'
function loadUser(): { name: string; email: string } | null {
const raw = localStorage.getItem('kappa_user')
return raw ? JSON.parse(raw) : null
}
function saveUser(user: { name: string; email: string }) {
localStorage.setItem('kappa_user', JSON.stringify(user))
}
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(localStorage.getItem('kappa_token'))
const user = ref<{ name: string; email: string } | null>(loadUser())
const token = ref<string | null>(storage.get('kappa_token'))
const user = ref<{ name: string; email: string } | null>(storage.getJSON('kappa_user'))
const loading = ref(false)
const error = ref<string | null>(null)
@@ -29,11 +21,8 @@ export const useAuthStore = defineStore('auth', () => {
const name = data.full_name
|| `${data.first_name || ''} ${data.last_name || ''}`.trim()
|| 'Ricardo Gonzalez'
user.value = {
name,
email: payload.email,
}
saveUser(user.value)
user.value = { name, email: payload.email }
await storage.setJSON('kappa_user', user.value)
return true
} catch (e: any) {
error.value = e.message
@@ -47,7 +36,7 @@ export const useAuthStore = defineStore('auth', () => {
kappa.logout()
token.value = null
user.value = null
localStorage.removeItem('kappa_user')
storage.remove('kappa_user')
}
return { token, user, loading, error, isAuthenticated, login, logout }
+4 -2
View File
@@ -1,15 +1,17 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { kappa } from '@/services/kappa-api'
import { storage } from '@/services/storage'
import { tauriDb } from '@/services/tauri-db'
import { stripHtml } from '@/services/clean-html'
import type { KappaInitiative } from '@/types/kappa'
export const useProjectsStore = defineStore('projects', () => {
const lastProject = storage.get('kappa_last_project')
const projects = ref<KappaInitiative[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const selectedId = ref<number | null>(null)
const selectedId = ref<number | null>(lastProject ? Number(lastProject) : null)
const selected = computed(() =>
projects.value.find(p => p.id === selectedId.value) ?? null
@@ -67,7 +69,7 @@ export const useProjectsStore = defineStore('projects', () => {
function select(id: number) {
selectedId.value = id
localStorage.setItem('kappa_last_project', String(id))
storage.set('kappa_last_project', String(id))
}
return { projects, loading, error, selectedId, selected, count, fetchProjects, select }
+200
View File
@@ -0,0 +1,200 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { storage } from '@/services/storage'
export type AIProvider = 'openrouter' | 'minimax' | 'opencode'
export type ModelTier = 'free' | 'cheap' | 'premium'
export interface AIModel {
id: string
label: string
provider: AIProvider
tier: ModelTier
}
export const AVAILABLE_MODELS: AIModel[] = [
{ id: 'deepseek/deepseek-chat-v3-0324:free', label: 'DeepSeek V3 (free)', provider: 'openrouter', tier: 'free' },
{ id: 'deepseek/deepseek-r1:free', label: 'DeepSeek R1 (free)', provider: 'openrouter', tier: 'free' },
{ id: 'openai/gpt-4o-mini', label: 'GPT-4o Mini', provider: 'openrouter', tier: 'cheap' },
{ id: 'google/gemini-2.0-flash-001', label: 'Gemini 2.0 Flash', provider: 'openrouter', tier: 'cheap' },
{ id: 'openai/gpt-4o', label: 'GPT-4o', provider: 'openrouter', tier: 'premium' },
{ id: 'anthropic/claude-sonnet-4', label: 'Claude Sonnet 4', provider: 'openrouter', tier: 'premium' },
{ id: 'meta-llama/llama-4-maverick', label: 'Llama 4 Maverick', provider: 'openrouter', tier: 'free' },
{ id: 'MiniMax-M2.7', label: 'MiniMax M2.7', provider: 'minimax', tier: 'free' },
{ id: 'MiniMax-M2.7-highspeed', label: 'MiniMax M2.7 HighSpeed', provider: 'minimax', tier: 'free' },
{ id: 'MiniMax-M2.5', label: 'MiniMax M2.5', provider: 'minimax', tier: 'free' },
{ id: 'MiniMax-M2.5-highspeed', label: 'MiniMax M2.5 HighSpeed', provider: 'minimax', tier: 'free' },
{ id: 'MiniMax-M2.1', label: 'MiniMax M2.1', provider: 'minimax', tier: 'free' },
{ id: 'MiniMax-M2.1-highspeed', label: 'MiniMax M2.1 HighSpeed', provider: 'minimax', tier: 'free' },
{ id: 'MiniMax-M2', label: 'MiniMax M2', provider: 'minimax', tier: 'free' },
]
export interface ProviderConfig {
label: string
descKey: string
badgeKey?: string
apiKeyHelp: string
apiKeyLink?: string
baseUrl: string
}
export const PROVIDER_CONFIG: Record<AIProvider, ProviderConfig> = {
openrouter: {
label: 'OpenRouter',
descKey: 'settings.openrouterDesc',
badgeKey: 'settings.recommended',
apiKeyHelp: 'settings.keyHelp',
apiKeyLink: 'https://openrouter.ai/keys',
baseUrl: 'https://openrouter.ai/api/v1/chat/completions',
},
minimax: {
label: 'MiniMax',
descKey: 'settings.minimaxDesc',
apiKeyHelp: 'settings.minimaxKeyHelp',
apiKeyLink: 'https://platform.minimax.io/user-center/basic-information/interface-key',
baseUrl: '/api/proxy/minimax',
},
opencode: {
label: 'OpenCode',
descKey: 'settings.opencodeDesc',
badgeKey: 'settings.bridge',
apiKeyHelp: 'settings.opencodeInfoDesc',
baseUrl: '',
},
}
const SETTINGS_KEY = 'alpha_settings'
const API_KEY_PREFIX = 'ai_key_'
export function getProviderApiKey(provider: AIProvider): string {
return storage.get(`${API_KEY_PREFIX}${provider}`) || ''
}
export function setProviderApiKey(provider: AIProvider, key: string) {
storage.set(`${API_KEY_PREFIX}${provider}`, key)
}
export function hasProviderApiKey(provider: AIProvider): boolean {
return !!getProviderApiKey(provider)
}
export function removeProviderApiKey(provider: AIProvider) {
storage.remove(`${API_KEY_PREFIX}${provider}`)
}
// ─── MiniMax group_id ──────────────────────────────────────
export function getMinimaxGroupId(): string {
return storage.get('minimax_group_id') || ''
}
export function setMinimaxGroupId(id: string) {
storage.set('minimax_group_id', id)
}
export const useSettingsStore = defineStore('settings', () => {
const saved = storage.getJSON<{ provider: AIProvider; modelId: string }>(SETTINGS_KEY) || {
provider: 'openrouter' as AIProvider,
modelId: 'deepseek/deepseek-chat-v3-0324:free',
}
const provider = ref<AIProvider>(saved.provider)
let initialModel = saved.modelId
let migrated = false
// Migración: modelos viejos eliminados del catálogo → M2.7
const OLD_MINIMAX_MODELS = ['minimax-text-01', 'abab5.5s', 'abab6.5s', 'abab6.5g']
if (provider.value === 'minimax' && OLD_MINIMAX_MODELS.includes(initialModel)) {
initialModel = 'MiniMax-M2.7'
migrated = true
}
// Si el modelo guardado ya no existe, migrar al primero del provider
if (!AVAILABLE_MODELS.find(m => m.id === initialModel)) {
const fallback = AVAILABLE_MODELS.find(m => m.provider === provider.value)
if (fallback) { initialModel = fallback.id; migrated = true }
}
const modelId = ref<string>(initialModel)
if (migrated) {
storage.setJSON(SETTINGS_KEY, { provider: provider.value, modelId: initialModel })
}
const keyVersion = ref(0)
const minimaxGroupId = ref<string>(storage.get('minimax_group_id') || '')
const selectedModel = computed(() =>
AVAILABLE_MODELS.find(m => m.id === modelId.value) ?? null
)
const activeProviderConfig = computed(() => PROVIDER_CONFIG[provider.value])
const apiKeyConfigured = computed(() => {
keyVersion.value
return hasProviderApiKey(provider.value)
})
const freeModels = computed(() =>
AVAILABLE_MODELS.filter(m => m.provider === provider.value && m.tier === 'free')
)
const cheapModels = computed(() =>
AVAILABLE_MODELS.filter(m => m.provider === provider.value && m.tier === 'cheap')
)
const premiumModels = computed(() =>
AVAILABLE_MODELS.filter(m => m.provider === provider.value && m.tier === 'premium')
)
const allModelsForProvider = computed(() =>
AVAILABLE_MODELS.filter(m => m.provider === provider.value)
)
function setActiveProvider(p: AIProvider) {
provider.value = p
keyVersion.value++
const models = AVAILABLE_MODELS.filter(m => m.provider === p)
if (models.length > 0 && !models.find(m => m.id === modelId.value)) {
modelId.value = models[0].id
}
persist()
}
function setModel(id: string) {
modelId.value = id
persist()
}
function saveApiKey(key: string) {
setProviderApiKey(provider.value, key)
keyVersion.value++
}
function removeKey() {
removeProviderApiKey(provider.value)
keyVersion.value++
}
function saveMinimaxGroupId(id: string) {
minimaxGroupId.value = id
setMinimaxGroupId(id)
}
function persist() {
storage.setJSON(SETTINGS_KEY, {
provider: provider.value,
modelId: modelId.value,
})
}
return {
provider,
modelId,
minimaxGroupId,
selectedModel,
activeProviderConfig,
apiKeyConfigured,
freeModels,
cheapModels,
premiumModels,
allModelsForProvider,
setActiveProvider,
setModel,
saveApiKey,
removeKey,
saveMinimaxGroupId,
}
})
+160
View File
@@ -0,0 +1,160 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { parseFile, type ParsedTranscription } from '@/services/parse-transcription'
import { analyzeTranscription, type AIExtractedHU, type AIAnalysisResult } from '@/services/ai'
import { kappa } from '@/services/kappa-api'
import { useWorkItemsStore } from '@/stores/workitems'
import { useProjectsStore } from '@/stores/projects'
export type PipelineStep = 'idle' | 'uploading' | 'parsing' | 'analyzing' | 'creating' | 'done' | 'error'
export interface DraftHU extends AIExtractedHU {
_selected: boolean
_created: boolean
_error?: string
}
export const useTranscriptionsStore = defineStore('transcriptions', () => {
const step = ref<PipelineStep>('idle')
const error = ref<string | null>(null)
const parsed = ref<ParsedTranscription | null>(null)
const draftHUs = ref<DraftHU[]>([])
const summary = ref<string>('')
const selectedProjectId = ref<number | null>(null)
const husCount = computed(() => draftHUs.value.length)
const selectedHUs = computed(() => draftHUs.value.filter(h => h._selected))
const hasDrafts = computed(() => draftHUs.value.length > 0)
async function uploadAndParse(file: File) {
step.value = 'parsing'
error.value = null
parsed.value = null
draftHUs.value = []
summary.value = ''
try {
const result = await parseFile(file)
parsed.value = result
step.value = 'idle'
console.log(`[Alpha] File parsed: ${result.fileName}, ${result.text.length} chars`)
} catch (e: any) {
step.value = 'error'
error.value = e.message
console.error('[Alpha] Parse error:', e)
}
}
async function runAnalysis(signal?: AbortSignal) {
if (!parsed.value) {
error.value = 'No hay transcripción para analizar'
return
}
step.value = 'analyzing'
error.value = null
const store = useProjectsStore()
const project = selectedProjectId.value
? store.projects.find(p => p.id === selectedProjectId.value)
: null
try {
const result: AIAnalysisResult = await analyzeTranscription(
parsed.value.text,
project?.name,
signal
)
draftHUs.value = result.hus.map(hu => ({
...hu,
_selected: true,
_created: false,
}))
summary.value = result.summary
step.value = 'idle'
} catch (e: any) {
step.value = 'error'
error.value = e.message
console.error('[Alpha] AI analysis error:', e)
}
}
async function createSelectedInKAPPA(): Promise<{ ok: number; fail: number }> {
const projectId = selectedProjectId.value
if (!projectId) {
error.value = 'Seleccioná un proyecto'
return { ok: 0, fail: 0 }
}
const toCreate = draftHUs.value.filter(h => h._selected && !h._created)
if (toCreate.length === 0) return { ok: 0, fail: 0 }
step.value = 'creating'
error.value = null
let ok = 0
let fail = 0
for (const hu of toCreate) {
try {
const result = await kappa.createUserStory({
title: hu.title,
description: hu.description + (hu.acceptance_criteria.length > 0
? `\n\n**Criterios de aceptación:**\n${hu.acceptance_criteria.map(c => `- ${c}`).join('\n')}`
: ''),
initiative: projectId,
priority: hu.priority || 'Media',
story_points: hu.story_points,
status: 'todo',
})
hu._created = true
hu._selected = false
ok++
await useWorkItemsStore().fetchWorkItems(projectId)
} catch (e: any) {
hu._error = e.message
fail++
console.error(`[Alpha] Failed to create HU "${hu.title}":`, e)
}
}
step.value = 'idle'
return { ok, fail }
}
function toggleHU(index: number) {
if (draftHUs.value[index]) {
draftHUs.value[index]._selected = !draftHUs.value[index]._selected
}
}
function removeHU(index: number) {
draftHUs.value.splice(index, 1)
}
function clearAll() {
parsed.value = null
draftHUs.value = []
summary.value = ''
step.value = 'idle'
error.value = null
}
return {
step,
error,
parsed,
draftHUs,
summary,
selectedProjectId,
husCount,
selectedHUs,
hasDrafts,
uploadAndParse,
runAnalysis,
createSelectedInKAPPA,
toggleHU,
removeHU,
clearAll,
}
})
+2 -1
View File
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { kappa } from '@/services/kappa-api'
import { storage } from '@/services/storage'
import { tauriDb, type UserStoryRecord, type EpicRecord, type ImpairmentRecord } from '@/services/tauri-db'
import { stripHtml } from '@/services/clean-html'
import { criteriaToJson, parseQuillList } from '@/services/clean-html'
@@ -96,7 +97,7 @@ export const useWorkItemsStore = defineStore('workitems', () => {
loading.value = true
error.value = null
const id = initiativeId || Number(localStorage.getItem('kappa_last_project'))
const id = initiativeId || Number(storage.get('kappa_last_project'))
if (!id) {
loading.value = false
return
+14 -17
View File
@@ -4,8 +4,9 @@ import { useI18n } from 'vue-i18n'
import { useProjectsStore } from '@/stores/projects'
import { useWorkItemsStore } from '@/stores/workitems'
import { getTypeLabel, getTypeColor, getTypeIcon } from '@/services/hierarchy'
import { Activity, FileText, Layers, Clock, Info, AlertTriangle, Plus } from 'lucide-vue-next'
import { Activity, FileText, Layers, Clock, Info, AlertTriangle, Plus, Brain } from 'lucide-vue-next'
import HuDrafts from '@/components/HuDrafts.vue'
import AiProjectChat from '@/components/AiProjectChat.vue'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
@@ -22,6 +23,10 @@ const { t } = useI18n()
const projects = useProjectsStore()
const workItems = useWorkItemsStore()
const emit = defineEmits<{
'navigate-settings': []
}>()
const project = computed(() => projects.selected)
watch(
@@ -113,22 +118,14 @@ const statusLabel = (status: unknown) => {
</Card>
</div>
<!-- Description -->
<Card id="dashboard-description">
<CardHeader class="pb-2">
<CardTitle class="text-sm font-medium">{{ t('dashboard.description') }}</CardTitle>
</CardHeader>
<CardContent>
<p v-if="project.description" class="text-sm text-muted-foreground leading-relaxed line-clamp-4">
{{ project.description }}
</p>
<p v-else class="text-sm text-muted-foreground/50 italic">{{ t('dashboard.noDescription') }}</p>
<div v-if="project.start_date || project.end_date" class="flex items-center gap-4 mt-3 text-xs text-muted-foreground">
<span v-if="project.start_date">📅 {{ project.start_date }}</span>
<span v-if="project.end_date"> {{ project.end_date }}</span>
</div>
</CardContent>
</Card>
<!-- AI Chat -->
<AiProjectChat
:project-name="project.name ?? ''"
:project-description="project.description ?? ''"
:epic-count="workItems.totalEpics"
:hu-count="workItems.totalHUs"
@navigate-settings="emit('navigate-settings')"
/>
<!-- Loading -->
<template v-if="workItems.loading">
+4 -3
View File
@@ -2,6 +2,7 @@
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
import { storage } from '@/services/storage'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/checkbox'
@@ -17,7 +18,7 @@ const showPassword = ref(false)
const rememberMe = ref(false)
onMounted(() => {
const saved = localStorage.getItem('alpha_remember_email')
const saved = storage.get('alpha_remember_email')
if (saved) {
email.value = saved
rememberMe.value = true
@@ -28,9 +29,9 @@ async function handleLogin() {
if (!email.value || !password.value) return
if (rememberMe.value) {
localStorage.setItem('alpha_remember_email', email.value)
storage.set('alpha_remember_email', email.value)
} else {
localStorage.removeItem('alpha_remember_email')
storage.remove('alpha_remember_email')
}
await auth.login({ email: email.value, password: password.value })
+9 -1
View File
@@ -8,6 +8,8 @@ import SectionCards from "@/components/dashboard/SectionCards.vue"
import DashboardView from "@/views/DashboardView.vue"
import ProjectListView from "@/views/ProjectListView.vue"
import UsersView from "@/views/UsersView.vue"
import TranscriptionsView from "@/views/TranscriptionsView.vue"
import SettingsView from "@/views/SettingsView.vue"
const { t } = useI18n()
@@ -146,13 +148,19 @@ const tabContent: Record<string, { title: string; description: string; cards: {
{{ t('common.backToProjects') }}
</button>
</div>
<DashboardView />
<DashboardView @navigate-settings="activeTab = 'settings'" />
</template>
<template v-else-if="activeTab === 'projects'">
<ProjectListView
@select-project="openProject()"
/>
</template>
<template v-else-if="activeTab === 'transcriptions'">
<TranscriptionsView @navigate-settings="activeTab = 'settings'" />
</template>
<template v-else-if="activeTab === 'settings'">
<SettingsView />
</template>
<template v-else-if="activeTab === 'team'">
<UsersView />
</template>
+270
View File
@@ -0,0 +1,270 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSettingsStore, PROVIDER_CONFIG, type AIProvider } from '@/stores/settings'
import { useAuthStore } from '@/stores/auth'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import {
Brain,
Key,
CheckCircle2,
XCircle,
ExternalLink,
Info,
LogOut,
Sparkles,
ChevronRight,
} from 'lucide-vue-next'
const { t } = useI18n()
const settings = useSettingsStore()
const auth = useAuthStore()
const newKey = ref('')
const keySaved = ref(false)
const providers: AIProvider[] = ['openrouter', 'minimax', 'opencode']
function handleSaveKey() {
if (newKey.value.trim()) {
settings.saveApiKey(newKey.value.trim())
newKey.value = ''
keySaved.value = true
setTimeout(() => { keySaved.value = false }, 3000)
}
}
function handleRemoveKey() {
settings.removeKey()
keySaved.value = false
}
const tierColors: Record<string, string> = {
free: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
cheap: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
premium: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400',
}
</script>
<template>
<div class="@container/main flex flex-1 flex-col gap-4 px-4 lg:px-6 py-4 max-w-3xl">
<div>
<h1 class="text-2xl font-bold tracking-tight">{{ t('settings.title') }}</h1>
<p class="text-sm text-muted-foreground mt-1">{{ t('settings.subtitle') }}</p>
</div>
<!-- AI Provider Configuration -->
<Card id="settings-ai-provider">
<CardHeader>
<CardTitle class="text-sm font-medium flex items-center gap-2">
<Brain class="size-4" />
{{ t('settings.aiProvider') }}
</CardTitle>
<CardDescription class="text-xs">{{ t('settings.aiProviderDesc') }}</CardDescription>
</CardHeader>
<CardContent class="space-y-3">
<div
v-for="p in providers"
:key="p"
:id="`settings-provider-${p}`"
class="flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors"
:class="settings.provider === p ? 'border-primary bg-primary/5' : 'hover:bg-muted/50'"
@click="settings.setActiveProvider(p)"
>
<div class="flex size-4 mt-0.5 shrink-0">
<div
class="size-4 rounded-full border-2 flex items-center justify-center"
:class="settings.provider === p ? 'border-primary' : 'border-muted-foreground'"
>
<div v-if="settings.provider === p" class="size-2 rounded-full bg-primary" />
</div>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-sm font-medium">{{ PROVIDER_CONFIG[p].label }}</span>
<Badge
v-if="PROVIDER_CONFIG[p].badgeKey"
variant="outline"
class="text-[10px]"
>{{ t(PROVIDER_CONFIG[p].badgeKey!) }}</Badge>
<Badge
v-if="p === 'minimax'"
variant="secondary"
class="text-[10px]"
>Nuevo</Badge>
</div>
<p class="text-xs text-muted-foreground mt-0.5">{{ t(PROVIDER_CONFIG[p].descKey) }}</p>
</div>
<ChevronRight
class="size-4 shrink-0 mt-1"
:class="settings.provider === p ? 'text-primary' : 'text-muted-foreground'"
/>
</div>
<Separator />
<!-- API Key (for direct providers) -->
<div v-if="settings.provider !== 'opencode'" id="settings-api-key" class="space-y-3">
<Label class="text-sm font-medium">{{ PROVIDER_CONFIG[settings.provider].label }} API Key</Label>
<div v-if="settings.apiKeyConfigured" class="flex items-center gap-2 text-sm">
<CheckCircle2 class="size-4 text-green-500 shrink-0" />
<span class="text-green-600 dark:text-green-400">{{ t('settings.keyConfigured') }}</span>
<Button variant="ghost" size="sm" class="text-xs h-7 ml-auto" @click="handleRemoveKey">
<XCircle class="size-3 mr-1" />
{{ t('settings.removeKey') }}
</Button>
</div>
<div v-else class="flex gap-2">
<Input
v-model="newKey"
type="password"
:placeholder="t('settings.apiKeyPlaceholder')"
class="flex-1 font-mono text-xs"
/>
<Button @click="handleSaveKey" :disabled="!newKey.trim()">
{{ t('settings.saveKey') }}
</Button>
</div>
<p v-if="keySaved" class="text-xs text-green-600 dark:text-green-400 flex items-center gap-1">
<CheckCircle2 class="size-3" />
{{ t('settings.keySaved') }}
</p>
<div class="text-xs text-muted-foreground space-y-1">
<p>{{ t(PROVIDER_CONFIG[settings.provider].apiKeyHelp) }}</p>
<a
v-if="PROVIDER_CONFIG[settings.provider].apiKeyLink"
:href="PROVIDER_CONFIG[settings.provider].apiKeyLink"
target="_blank"
class="inline-flex items-center gap-1 text-primary hover:underline"
>
{{ PROVIDER_CONFIG[settings.provider].apiKeyLink }}
<ExternalLink class="size-3" />
</a>
</div>
</div>
<!-- MiniMax Group ID -->
<div v-if="settings.provider === 'minimax'" id="settings-minimax-group" class="space-y-2 pt-2">
<Label class="text-sm font-medium">{{ t('settings.minimaxGroupId') }}</Label>
<div class="flex gap-2">
<Input
v-model="settings.minimaxGroupId"
:placeholder="t('settings.minimaxGroupIdPlaceholder')"
class="flex-1 font-mono text-xs"
@update:model-value="settings.saveMinimaxGroupId(String($event))"
/>
</div>
<div class="text-xs text-muted-foreground space-y-1">
<p>{{ t('settings.minimaxGroupIdHelp') }}</p>
<a
href="https://platform.minimaxi.com/user-center/basic-information/interface-key"
target="_blank"
class="inline-flex items-center gap-1 text-primary hover:underline"
>
platform.minimaxi.com
<ExternalLink class="size-3" />
</a>
</div>
</div>
<!-- OpenCode info -->
<div v-if="settings.provider === 'opencode'" id="settings-opencode-info" class="space-y-2 text-sm">
<div class="flex items-start gap-2 p-3 rounded-lg bg-muted/50">
<Info class="size-4 text-muted-foreground mt-0.5 shrink-0" />
<div class="space-y-1">
<p class="font-medium">{{ t('settings.opencodeInfoTitle') }}</p>
<p class="text-xs text-muted-foreground">{{ t('settings.opencodeInfoDesc') }}</p>
<code class="block text-xs bg-muted px-2 py-1 rounded mt-1">~/.local/share/opencode/auth.json</code>
<p class="text-xs text-muted-foreground mt-1">{{ t('settings.opencodeInfoFuture') }}</p>
</div>
</div>
</div>
</CardContent>
</Card>
<!-- Model Selection (only for direct providers with models) -->
<Card
v-if="settings.allModelsForProvider.length > 0"
id="settings-model-select"
>
<CardHeader>
<CardTitle class="text-sm font-medium flex items-center gap-2">
<Sparkles class="size-4" />
{{ t('settings.model') }}
</CardTitle>
<CardDescription class="text-xs">{{ t('settings.modelDesc') }}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<template v-for="(models, tier) in { free: settings.freeModels, cheap: settings.cheapModels, premium: settings.premiumModels }" :key="tier">
<div v-if="models.length > 0">
<Label class="text-xs text-muted-foreground uppercase tracking-wider font-semibold mb-2 block">
{{ t(`settings.${tier}Models`) }}
</Label>
<div class="grid gap-2">
<div
v-for="m in models"
:key="m.id"
class="flex items-center gap-3 p-2 rounded-lg border cursor-pointer transition-colors"
:class="settings.modelId === m.id ? 'border-primary bg-primary/5' : 'hover:bg-muted/50'"
@click="settings.setModel(m.id)"
>
<div class="flex size-3 shrink-0">
<div
class="size-3 rounded-full border-2"
:class="settings.modelId === m.id ? 'border-primary bg-primary' : 'border-muted-foreground'"
/>
</div>
<div class="flex-1 min-w-0">
<span class="text-sm">{{ m.label }}</span>
<code class="block text-[10px] text-muted-foreground font-mono truncate">{{ m.id }}</code>
</div>
<Badge variant="outline" :class="tierColors[tier]" class="text-[10px] capitalize">{{ tier }}</Badge>
</div>
</div>
</div>
</template>
<div v-if="settings.selectedModel" class="flex items-center gap-2 p-2 rounded-lg bg-muted/30 text-xs text-muted-foreground">
<CheckCircle2 class="size-3 text-green-500 shrink-0" />
{{ t('settings.currentModel') }}: <code class="font-mono text-foreground">{{ settings.modelId }}</code>
</div>
</CardContent>
</Card>
<!-- Account -->
<Card id="settings-account">
<CardHeader>
<CardTitle class="text-sm font-medium flex items-center gap-2">
<Key class="size-4" />
{{ t('settings.account') }}
</CardTitle>
</CardHeader>
<CardContent class="space-y-3">
<div class="text-sm">
<span class="text-muted-foreground">{{ t('settings.loggedInAs') }}:</span>
<span class="font-medium ml-1">{{ auth.user?.name || '—' }}</span>
<code class="ml-2 text-xs text-muted-foreground">{{ auth.user?.email || '' }}</code>
</div>
<Button variant="outline" size="sm" @click="auth.logout()">
<LogOut class="size-3 mr-1" />
{{ t('settings.logout') }}
</Button>
</CardContent>
</Card>
</div>
</template>
+589
View File
@@ -0,0 +1,589 @@
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useProjectsStore } from '@/stores/projects'
import { useSettingsStore } from '@/stores/settings'
import { analyzeSession, type SessionExtraction } from '@/services/session-analyzer'
import { generateProjectDoc, getProjectDoc } from '@/services/project-doc'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Upload,
FileText,
Sparkles,
CheckCircle2,
Loader2,
AlertCircle,
Settings2,
Brain,
FolderOpen,
FileDown,
ListChecks,
Target,
GitCommit,
Calendar,
Hash,
ChevronLeft,
ChevronRight,
} from 'lucide-vue-next'
const { t } = useI18n()
const projectsStore = useProjectsStore()
const settingsStore = useSettingsStore()
const fileInput = ref<HTMLInputElement | null>(null)
// ─── Upload state ────────────────────────────────────────
const parsedText = ref('')
const parsedFileName = ref('')
const parsing = ref(false)
const uploadError = ref<string | null>(null)
// ─── Session analysis state ──────────────────────────────
const sessionLoading = ref(false)
const sessionResult = ref<SessionExtraction | null>(null)
const sessionError = ref<string | null>(null)
const docGenerating = ref(false)
const docGenerated = ref(false)
// ─── Project document state ──────────────────────────────
const docMarkdown = ref('')
const docSessionCount = ref(0)
const docUpdatedAt = ref('')
const sessionOffsets = ref<{ date: string; title: string; offset: number }[]>([])
const viewerRef = ref<HTMLTextAreaElement | null>(null)
// ─── Calendar state ──────────────────────────────────────
const calMonth = ref(new Date().getMonth())
const calYear = ref(new Date().getFullYear())
const emit = defineEmits<{ 'navigate-settings': [] }>()
const projects = computed(() => projectsStore.projects)
const selectedProjectId = ref<number | null>(null)
const selectedProject = computed(() =>
projects.value.find(p => p.id === selectedProjectId.value) ?? null
)
// ─── Load doc when project changes ──────────────────────
watch(selectedProjectId, async (id) => {
if (!id) return
clearAll()
const doc = await getProjectDoc(id)
if (doc) {
docMarkdown.value = doc.markdown
docSessionCount.value = doc.sessionCount
docUpdatedAt.value = doc.updatedAt
parseSessionOffsets(doc.markdown)
}
}, { immediate: false })
function parseSessionOffsets(md: string) {
const offsets: { date: string; title: string; offset: number }[] = []
const re = /## 📍 Sesión \d+: (.+?) \*\*Archivo fuente:.*?\*\* /gs
let match
while ((match = re.exec(md)) !== null) {
const title = match[1]
const dateMatch = title.match(/(\d{4}-\d{2}-\d{2})/)
offsets.push({
date: dateMatch?.[1] || '',
title,
offset: match.index,
})
}
sessionOffsets.value = offsets
}
function scrollToSession(offset: number) {
if (viewerRef.value) {
const textarea = viewerRef.value
const md = docMarkdown.value
// Calculate line number from offset
const lineNum = md.slice(0, offset).split('\n').length
const lineHeight = 20 // approximate
textarea.scrollTop = (lineNum - 5) * lineHeight
textarea.focus()
}
}
function sessionDates(): string[] {
return sessionOffsets.value.map(s => s.date).filter(Boolean)
}
// ─── Calendar helpers ────────────────────────────────────
const monthNames = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre']
function daysInMonth(m: number, y: number) {
return new Date(y, m + 1, 0).getDate()
}
function firstDayOfMonth(m: number, y: number) {
return new Date(y, m, 1).getDay()
}
function isSessionDate(d: number) {
const ds = sessionDates()
const dateStr = `${calYear.value}-${String(calMonth.value + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`
return ds.includes(dateStr)
}
function getSessionForDate(d: number) {
const dateStr = `${calYear.value}-${String(calMonth.value + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`
return sessionOffsets.value.find(s => s.date === dateStr)
}
function prevMonth() {
if (calMonth.value === 0) { calMonth.value = 11; calYear.value-- }
else calMonth.value--
}
function nextMonth() {
if (calMonth.value === 11) { calMonth.value = 0; calYear.value++ }
else calMonth.value++
}
// ─── Uploaded files queue ───────────────────────────────
interface QueuedFile {
fileName: string
text: string
parsed: boolean
}
const fileQueue = ref<QueuedFile[]>([])
const activeFileIndex = ref(-1)
async function handleFiles(files: FileList | File[]) {
const { parseFile } = await import('@/services/parse-transcription')
const newFiles: QueuedFile[] = []
for (const file of Array.from(files)) {
try {
const result = await parseFile(file)
newFiles.push({ fileName: result.fileName, text: result.text, parsed: true })
} catch (e: any) {
console.error(`[Alpha] Failed to parse ${file.name}:`, e)
}
}
if (newFiles.length === 0) return
fileQueue.value.push(...newFiles)
if (activeFileIndex.value === -1) {
activeFileIndex.value = 0
loadActiveFile()
}
}
function loadActiveFile() {
if (activeFileIndex.value < 0 || activeFileIndex.value >= fileQueue.value.length) {
clearActiveFile()
return
}
const f = fileQueue.value[activeFileIndex.value]
parsedText.value = f.text
parsedFileName.value = f.fileName
sessionResult.value = null
docGenerated.value = false
}
function clearActiveFile() {
parsedText.value = ''
parsedFileName.value = ''
sessionResult.value = null
docGenerated.value = false
activeFileIndex.value = -1
}
function removeFromQueue(idx: number) {
fileQueue.value.splice(idx, 1)
if (idx === activeFileIndex.value) {
if (fileQueue.value.length > 0) {
activeFileIndex.value = Math.min(activeFileIndex.value, fileQueue.value.length - 1)
loadActiveFile()
} else {
clearActiveFile()
}
} else if (idx < activeFileIndex.value) {
activeFileIndex.value--
}
}
function selectFile(idx: number) {
activeFileIndex.value = idx
loadActiveFile()
}
function handleFileDrop(e: DragEvent) {
const files = e.dataTransfer?.files
if (files && files.length > 0) handleFiles(files)
}
function handleFileSelect(e: Event) {
const files = (e.target as HTMLInputElement).files
if (files && files.length > 0) handleFiles(files)
// Reset input so same file can be re-selected
;(e.target as HTMLInputElement).value = ''
}
function openFilePicker() {
fileInput.value?.click()
}
// ─── Session analysis ────────────────────────────────────
async function analyzeAsSession() {
if (!parsedText.value || !selectedProjectId.value) return
sessionLoading.value = true
sessionError.value = null
sessionResult.value = null
try {
const result = await analyzeSession(
parsedText.value,
selectedProject.value?.name || '',
)
sessionResult.value = result
} catch (e: any) {
sessionError.value = e.message
} finally {
sessionLoading.value = false
}
}
async function generateDoc() {
if (!sessionResult.value || !selectedProjectId.value || !parsedText.value) return
docGenerating.value = true
const prevDoc = await getProjectDoc(selectedProjectId.value)
try {
const doc = await generateProjectDoc(
selectedProjectId.value,
selectedProject.value?.name || '',
sessionResult.value,
parsedText.value,
parsedFileName.value,
prevDoc,
)
docMarkdown.value = doc.markdown
docSessionCount.value = doc.sessionCount
docUpdatedAt.value = doc.updatedAt
parseSessionOffsets(doc.markdown)
docGenerated.value = true
parsedText.value = ''
parsedFileName.value = ''
sessionResult.value = null
} catch (e: any) {
sessionError.value = e.message
} finally {
docGenerating.value = false
}
}
function downloadDoc() {
if (!docMarkdown.value) return
const name = selectedProject.value?.name || `proyecto-${selectedProjectId.value}`
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
const blob = new Blob([docMarkdown.value], { type: 'text/markdown' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${slug}-sesiones.md`
a.click()
URL.revokeObjectURL(url)
}
function clearAll() {
parsedText.value = ''
parsedFileName.value = ''
fileQueue.value = []
activeFileIndex.value = -1
parsing.value = false
uploadError.value = null
sessionResult.value = null
sessionError.value = null
sessionLoading.value = false
docGenerated.value = false
docGenerating.value = false
}
</script>
<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>
</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 />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="p in projects"
:key="p.id"
:value="p.id"
>{{ 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"
>
<input
ref="fileInput"
type="file"
accept=".docx,.vtt,.txt,.md"
multiple
class="hidden"
@change="handleFileSelect"
/>
<template v-if="!parsedText && !parsing">
<Upload class="size-6 text-muted-foreground" />
<p class="text-xs text-muted-foreground text-center">
{{ t('transcriptions.dropzone') }}<br>
<span class="text-[10px]">{{ t('transcriptions.dropzoneFormats') }}</span>
</p>
</template>
<template v-else-if="parsing">
<Loader2 class="size-6 animate-spin text-muted-foreground" />
<p class="text-xs text-muted-foreground">{{ t('transcriptions.parsing') }}</p>
</template>
<template v-else>
<div class="w-full space-y-2">
<!-- Active file info -->
<div class="flex items-center gap-2">
<FileText class="size-5 text-primary shrink-0" />
<span class="text-sm font-medium truncate max-w-[200px]">{{ parsedFileName }}</span>
<Badge variant="outline" class="text-[10px] shrink-0">{{ (parsedText.length).toLocaleString() }} {{ t('transcriptions.chars') }}</Badge>
</div>
<!-- File queue -->
<div v-if="fileQueue.length > 1" class="flex flex-wrap gap-1">
<button
v-for="(f, i) in fileQueue"
:key="i"
class="text-[10px] px-2 py-0.5 rounded-full border transition-colors"
:class="i === activeFileIndex ? 'bg-primary text-primary-foreground border-primary' : 'hover:bg-muted'"
@click.stop="selectFile(i)"
>
{{ i + 1 }}. {{ f.fileName.slice(0, 20) }}{{ f.fileName.length > 20 ? '…' : '' }}
<span class="ml-1 cursor-pointer hover:text-destructive" @click.stop="removeFromQueue(i)">×</span>
</button>
</div>
<!-- Actions -->
<div class="flex gap-1 flex-wrap">
<Button size="sm" @click.stop="openFilePicker" variant="outline" class="text-xs h-7">
{{ t('transcriptions.addMore') }}
</Button>
<Button
size="sm"
class="text-xs h-7"
:disabled="sessionLoading || !selectedProjectId"
@click.stop="analyzeAsSession()"
>
<Loader2 v-if="sessionLoading" class="size-3 mr-1 animate-spin" />
<Sparkles v-else class="size-3 mr-1" />
{{ t('transcriptions.analyzeSession') }}
</Button>
</div>
</div>
</template>
</div>
</div>
</Card>
<!-- Stats row -->
<div v-if="selectedProjectId" class="grid gap-3 @md:grid-cols-2">
<!-- Session count -->
<Card id="transcriptions-stats-sessions">
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">{{ t('transcriptions.sessionCountTitle') }}</CardTitle>
<Hash class="size-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">{{ docSessionCount }}</div>
<p class="text-xs text-muted-foreground">{{ t('transcriptions.sessionsRecorded') }}</p>
</CardContent>
</Card>
<!-- Mini calendar -->
<Card id="transcriptions-calendar">
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium flex items-center gap-1">
<Calendar class="size-4" />
{{ t('transcriptions.sessionDates') }}
</CardTitle>
<div class="flex items-center gap-1">
<Button variant="ghost" size="icon" class="size-6" @click="prevMonth">
<ChevronLeft class="size-3" />
</Button>
<span class="text-xs font-medium w-20 text-center">{{ monthNames[calMonth] }} {{ calYear }}</span>
<Button variant="ghost" size="icon" class="size-6" @click="nextMonth">
<ChevronRight class="size-3" />
</Button>
</div>
</CardHeader>
<CardContent class="pt-0">
<div v-if="sessionOffsets.length === 0" class="text-xs text-muted-foreground text-center py-2">
{{ t('transcriptions.noSessions') }}
</div>
<template v-else>
<div class="grid grid-cols-7 gap-0 text-center text-[10px] mb-1">
<span v-for="d in ['Dom','Lun','Mar','Mié','Jue','Vie','Sáb']" :key="d" class="text-muted-foreground py-0.5">{{ d }}</span>
</div>
<div class="grid grid-cols-7 gap-0 text-center text-xs">
<span v-for="_ in firstDayOfMonth(calMonth, calYear)" :key="'e'+_" />
<button
v-for="d in daysInMonth(calMonth, calYear)"
:key="d"
class="py-0.5 rounded hover:bg-muted transition-colors relative"
:class="isSessionDate(d) ? 'bg-primary/10 text-primary font-bold' : 'text-muted-foreground'"
@click="() => { const s = getSessionForDate(d); if (s) scrollToSession(s.offset) }"
:title="getSessionForDate(d)?.title || ''"
>
{{ d }}
<span v-if="isSessionDate(d)" class="absolute -bottom-0.5 left-1/2 -translate-x-1/2 size-1 rounded-full bg-primary" />
</button>
</div>
</template>
</CardContent>
</Card>
</div>
<!-- Session analysis error -->
<Card v-if="sessionError" class="border-destructive/50">
<CardHeader class="pb-2">
<CardTitle class="text-sm font-medium flex items-center gap-2 text-destructive">
<AlertCircle class="size-4" />
{{ t('transcriptions.sessionError') }}
</CardTitle>
</CardHeader>
<CardContent>
<p class="text-sm">{{ sessionError }}</p>
</CardContent>
</Card>
<!-- Session extraction results -->
<Card v-if="sessionResult" id="transcriptions-session-result">
<CardHeader class="pb-3 flex flex-row items-center justify-between">
<CardTitle class="text-sm font-medium flex items-center gap-2">
<ListChecks class="size-4" />
{{ sessionResult.sessionTitle }}
</CardTitle>
<Button size="sm" @click="generateDoc()" :disabled="docGenerating">
<Loader2 v-if="docGenerating" class="size-4 mr-1 animate-spin" />
<CheckCircle2 v-else class="size-4 mr-1" />
{{ t('transcriptions.generateDoc') }}
</Button>
</CardHeader>
<CardContent class="space-y-3 text-sm">
<p class="text-muted-foreground">{{ sessionResult.summary }}</p>
<div class="grid gap-3 @md:grid-cols-2">
<div v-if="sessionResult.objectives.length > 0">
<p class="text-xs text-muted-foreground font-semibold mb-1">{{ t('transcriptions.sessionObjectives') }}</p>
<ul class="space-y-0.5">
<li v-for="o in sessionResult.objectives" :key="o.text" class="flex items-start gap-1">
<span v-if="o.isNew" class="text-green-500 text-xs">🆕</span>
<span>{{ o.text }}</span>
</li>
</ul>
</div>
<div v-if="sessionResult.decisions.length > 0">
<p class="text-xs text-muted-foreground font-semibold mb-1">{{ t('transcriptions.sessionDecisions') }}</p>
<ul class="space-y-0.5">
<li v-for="d in sessionResult.decisions" :key="d" class="flex items-start gap-1"><span class="text-muted-foreground"></span> {{ d }}</li>
</ul>
</div>
</div>
</CardContent>
</Card>
<!-- Markdown viewer -->
<Card v-if="docMarkdown" id="transcriptions-viewer">
<CardHeader class="pb-2 flex flex-row items-center justify-between">
<CardTitle class="text-sm font-medium flex items-center gap-2">
<FileText class="size-4" />
{{ selectedProject?.name || '' }} {{ t('transcriptions.docViewer') }}
</CardTitle>
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<span>{{ docSessionCount }} {{ t('transcriptions.sessionsLabel') }}</span>
<span>·</span>
<span>{{ t('transcriptions.updatedAt') }} {{ docUpdatedAt }}</span>
</div>
</CardHeader>
<CardContent>
<textarea
ref="viewerRef"
:value="docMarkdown"
class="w-full min-h-[400px] max-h-[600px] text-xs font-mono leading-relaxed rounded-lg border bg-muted/30 px-4 py-3 resize-y"
readonly
/>
</CardContent>
</Card>
<!-- Empty state -->
<div v-if="!selectedProjectId" class="flex flex-1 items-center justify-center py-12">
<div class="text-center space-y-2">
<FolderOpen class="size-10 text-muted-foreground mx-auto" />
<p class="text-sm text-muted-foreground">{{ t('transcriptions.selectProjectHint') }}</p>
</div>
</div>
</div>
</template>
+6
View File
@@ -12,6 +12,12 @@ export default defineConfig({
},
server: {
proxy: {
'/api/proxy/minimax': {
target: 'https://api.minimax.io',
changeOrigin: true,
secure: false,
rewrite: (path) => path.replace(/^\/api\/proxy\/minimax/, '/v1/chat/completions'),
},
'/api': {
target: 'https://kappa.lambdaanalytics.co',
changeOrigin: true,