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:
@@ -120,12 +120,35 @@ bun dev # http://localhost:5173
|
|||||||
|
|
||||||
Abre http://localhost:5173. El proxy de Vite redirige `/api/*` a `https://kappa.lambdaanalytics.co`.
|
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
|
## Próximos pasos
|
||||||
|
|
||||||
1. ~~Agregar Dexie.js para cache offline~~ (K-15)
|
1. ~~Agregar Dexie.js para cache offline~~ (K-15)
|
||||||
2. ~~Pipeline de transcripciones~~ (K-10)
|
2. ~~Pipeline de transcripciones~~ (K-10)
|
||||||
3. ~~Dashboard multi-proyecto~~ (K-11)
|
3. ⬜ **Dashboard multi-proyecto** (K-11)
|
||||||
4. ~~Priorizador diario~~ (K-12)
|
4. ⬜ **Priorizador diario** (K-12)
|
||||||
5. ~~Generador de reportes~~ (K-13)
|
5. ⬜ **Generador de reportes** (K-13)
|
||||||
6. **Integración calendario Google/Outlook** (K-21)
|
6. ⬜ **Integración calendario Google/Outlook** (K-21)
|
||||||
7. **Alertas post-reunión** (K-22)
|
7. ⬜ **Alertas post-reunión** (K-22)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"dexie": "^4.0.4",
|
"dexie": "^4.0.4",
|
||||||
"dnd-kit-vue": "^0.0.2",
|
"dnd-kit-vue": "^0.0.2",
|
||||||
"lucide-vue-next": "^1.0.0",
|
"lucide-vue-next": "^1.0.0",
|
||||||
|
"mammoth": "^1.12.0",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"reka-ui": "^2.9.8",
|
"reka-ui": "^2.9.8",
|
||||||
"shadcn-vue": "^2.7.3",
|
"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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
|
||||||
@@ -564,6 +573,8 @@
|
|||||||
|
|
||||||
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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": ["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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||||
|
|
||||||
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
||||||
|
|
||||||
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||||
|
|
||||||
"sax": ["sax@1.2.4", "", {}, "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"ssf": ["ssf@0.11.2", "", { "dependencies": { "frac": "~1.1.2" } }, "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g=="],
|
||||||
|
|
||||||
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
"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-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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"undici": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="],
|
||||||
|
|
||||||
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
|
"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=="],
|
"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=="],
|
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||||
|
|
||||||
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"dexie": "^4.0.4",
|
"dexie": "^4.0.4",
|
||||||
"dnd-kit-vue": "^0.0.2",
|
"dnd-kit-vue": "^0.0.2",
|
||||||
"lucide-vue-next": "^1.0.0",
|
"lucide-vue-next": "^1.0.0",
|
||||||
|
"mammoth": "^1.12.0",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"reka-ui": "^2.9.8",
|
"reka-ui": "^2.9.8",
|
||||||
"shadcn-vue": "^2.7.3",
|
"shadcn-vue": "^2.7.3",
|
||||||
|
|||||||
+3
-1
@@ -2,13 +2,15 @@
|
|||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useProjectsStore } from '@/stores/projects'
|
import { useProjectsStore } from '@/stores/projects'
|
||||||
|
import { storage } from '@/services/storage'
|
||||||
import LoginView from '@/views/LoginView.vue'
|
import LoginView from '@/views/LoginView.vue'
|
||||||
import NewDashboardView from '@/views/NewDashboardView.vue'
|
import NewDashboardView from '@/views/NewDashboardView.vue'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const projectsStore = useProjectsStore()
|
const projectsStore = useProjectsStore()
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
|
await storage.init()
|
||||||
if (auth.isAuthenticated) {
|
if (auth.isAuthenticated) {
|
||||||
projectsStore.fetchProjects()
|
projectsStore.fetchProjects()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
IconListDetails,
|
IconListDetails,
|
||||||
IconChartBar,
|
IconChartBar,
|
||||||
IconUsers,
|
IconUsers,
|
||||||
|
IconFileUpload,
|
||||||
|
IconSettings,
|
||||||
} from "@tabler/icons-vue"
|
} from "@tabler/icons-vue"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -23,9 +25,11 @@ const { t } = useI18n()
|
|||||||
const mainNavItems = [
|
const mainNavItems = [
|
||||||
{ title: 'nav.board', icon: IconLayoutKanban, id: 'metrics' },
|
{ title: 'nav.board', icon: IconLayoutKanban, id: 'metrics' },
|
||||||
{ title: 'nav.projects', icon: IconFolder, id: 'projects' },
|
{ title: 'nav.projects', icon: IconFolder, id: 'projects' },
|
||||||
|
{ title: 'nav.transcriptions', icon: IconFileUpload, id: 'transcriptions' },
|
||||||
{ title: 'nav.lifecycle', icon: IconListDetails, id: 'lifecycle' },
|
{ title: 'nav.lifecycle', icon: IconListDetails, id: 'lifecycle' },
|
||||||
{ title: 'nav.analytics', icon: IconChartBar, id: 'analytics' },
|
{ title: 'nav.analytics', icon: IconChartBar, id: 'analytics' },
|
||||||
{ title: 'nav.team', icon: IconUsers, id: 'team' },
|
{ title: 'nav.team', icon: IconUsers, id: 'team' },
|
||||||
|
{ title: 'nav.settings', icon: IconSettings, id: 'settings' },
|
||||||
]
|
]
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, computed } from "vue"
|
import { ref, watch, computed } from "vue"
|
||||||
import { useI18n } from "vue-i18n"
|
import { useI18n } from "vue-i18n"
|
||||||
|
import { storage } from "@/services/storage"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||||
@@ -30,9 +31,11 @@ const props = defineProps<{
|
|||||||
const tabLabels: Record<string, string> = {
|
const tabLabels: Record<string, string> = {
|
||||||
metrics: 'Métricas',
|
metrics: 'Métricas',
|
||||||
projects: 'Proyectos',
|
projects: 'Proyectos',
|
||||||
|
transcriptions: 'Transcripciones',
|
||||||
lifecycle: 'Ciclo de Vida',
|
lifecycle: 'Ciclo de Vida',
|
||||||
analytics: 'Analíticas',
|
analytics: 'Analíticas',
|
||||||
team: 'Equipo',
|
team: 'Equipo',
|
||||||
|
settings: 'Configuración',
|
||||||
documents: 'Documentos',
|
documents: 'Documentos',
|
||||||
'data-library': 'Biblioteca de Datos',
|
'data-library': 'Biblioteca de Datos',
|
||||||
reports: 'Reportes',
|
reports: 'Reportes',
|
||||||
@@ -54,7 +57,7 @@ const languages = [
|
|||||||
|
|
||||||
function setLanguage(code: string) {
|
function setLanguage(code: string) {
|
||||||
locale.value = code
|
locale.value = code
|
||||||
localStorage.setItem("alpha-language", code)
|
storage.set("alpha-language", code)
|
||||||
}
|
}
|
||||||
|
|
||||||
function setTheme(theme: "light" | "dark" | "system") {
|
function setTheme(theme: "light" | "dark" | "system") {
|
||||||
|
|||||||
+100
-2
@@ -2,6 +2,7 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"quickCreate": "Create project",
|
"quickCreate": "Create project",
|
||||||
"board": "Metrics",
|
"board": "Metrics",
|
||||||
|
"transcriptions": "Transcriptions",
|
||||||
"projects": "Projects",
|
"projects": "Projects",
|
||||||
"lifecycle": "Lifecycle",
|
"lifecycle": "Lifecycle",
|
||||||
"analytics": "Analytics",
|
"analytics": "Analytics",
|
||||||
@@ -10,7 +11,8 @@
|
|||||||
"dataLibrary": "Data Library",
|
"dataLibrary": "Data Library",
|
||||||
"reports": "Reports",
|
"reports": "Reports",
|
||||||
"wordAssistant": "Word Assistant",
|
"wordAssistant": "Word Assistant",
|
||||||
"templates": "Templates"
|
"templates": "Templates",
|
||||||
|
"settings": "Settings"
|
||||||
},
|
},
|
||||||
"siteHeader": {
|
"siteHeader": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
@@ -21,6 +23,7 @@
|
|||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Settings",
|
"title": "Settings",
|
||||||
|
"subtitle": "Manage AI providers, models, and preferences",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"theme": "Theme",
|
"theme": "Theme",
|
||||||
"light": "Light",
|
"light": "Light",
|
||||||
@@ -28,7 +31,37 @@
|
|||||||
"system": "System",
|
"system": "System",
|
||||||
"about": "About",
|
"about": "About",
|
||||||
"documentation": "Documentation",
|
"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": {
|
"login": {
|
||||||
"title": "Sign in",
|
"title": "Sign in",
|
||||||
@@ -214,6 +247,71 @@
|
|||||||
"trendingUp": "Trending up",
|
"trendingUp": "Trending up",
|
||||||
"trendingDown": "Trending down"
|
"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": {
|
"workitems": {
|
||||||
"unnamedEpic": "Epic {id}"
|
"unnamedEpic": "Epic {id}"
|
||||||
}
|
}
|
||||||
|
|||||||
+100
-2
@@ -2,6 +2,7 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"quickCreate": "Crear proyecto",
|
"quickCreate": "Crear proyecto",
|
||||||
"board": "Métricas",
|
"board": "Métricas",
|
||||||
|
"transcriptions": "Transcripciones",
|
||||||
"projects": "Proyectos",
|
"projects": "Proyectos",
|
||||||
"lifecycle": "Ciclo de vida",
|
"lifecycle": "Ciclo de vida",
|
||||||
"analytics": "Analíticas",
|
"analytics": "Analíticas",
|
||||||
@@ -10,7 +11,8 @@
|
|||||||
"dataLibrary": "Biblioteca",
|
"dataLibrary": "Biblioteca",
|
||||||
"reports": "Reportes",
|
"reports": "Reportes",
|
||||||
"wordAssistant": "Asistente Word",
|
"wordAssistant": "Asistente Word",
|
||||||
"templates": "Plantillas"
|
"templates": "Plantillas",
|
||||||
|
"settings": "Configuración"
|
||||||
},
|
},
|
||||||
"siteHeader": {
|
"siteHeader": {
|
||||||
"title": "Tablero",
|
"title": "Tablero",
|
||||||
@@ -21,6 +23,7 @@
|
|||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Configuración",
|
"title": "Configuración",
|
||||||
|
"subtitle": "Gestioná tus proveedores de IA, modelos y preferencias",
|
||||||
"language": "Idioma",
|
"language": "Idioma",
|
||||||
"theme": "Tema",
|
"theme": "Tema",
|
||||||
"light": "Claro",
|
"light": "Claro",
|
||||||
@@ -28,7 +31,37 @@
|
|||||||
"system": "Sistema",
|
"system": "Sistema",
|
||||||
"about": "Acerca de",
|
"about": "Acerca de",
|
||||||
"documentation": "Documentación",
|
"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": {
|
"login": {
|
||||||
"title": "Iniciar sesión",
|
"title": "Iniciar sesión",
|
||||||
@@ -214,6 +247,71 @@
|
|||||||
"trendingUp": "Tendencia al alza",
|
"trendingUp": "Tendencia al alza",
|
||||||
"trendingDown": "Tendencia a la baja"
|
"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": {
|
"workitems": {
|
||||||
"unnamedEpic": "Épica {id}"
|
"unnamedEpic": "Épica {id}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { PROVIDER_CONFIG, getProviderApiKey, type AIProvider } from '@/stores/settings'
|
||||||
|
import { storage } from '@/services/storage'
|
||||||
|
|
||||||
|
export interface AIExtractedHU {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
acceptance_criteria: string[]
|
||||||
|
priority?: string
|
||||||
|
story_points?: number
|
||||||
|
type?: 'feature' | 'bug' | 'task' | 'improvement'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AIAnalysisResult {
|
||||||
|
hus: AIExtractedHU[]
|
||||||
|
summary: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActiveConfig {
|
||||||
|
baseUrl: string
|
||||||
|
apiKey: string
|
||||||
|
model: string
|
||||||
|
provider: AIProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveConfig(): ActiveConfig {
|
||||||
|
const raw = storage.getJSON<{ provider: AIProvider; modelId: string }>('alpha_settings')
|
||||||
|
if (raw) {
|
||||||
|
const provider = raw.provider || 'openrouter'
|
||||||
|
const model = raw.modelId || 'deepseek/deepseek-chat-v3-0324:free'
|
||||||
|
const baseUrl = PROVIDER_CONFIG[provider]?.baseUrl || ''
|
||||||
|
const apiKey = getProviderApiKey(provider)
|
||||||
|
return { baseUrl, apiKey, model, provider }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
baseUrl: PROVIDER_CONFIG.openrouter.baseUrl,
|
||||||
|
apiKey: getProviderApiKey('openrouter'),
|
||||||
|
model: 'deepseek/deepseek-chat-v3-0324:free',
|
||||||
|
provider: 'openrouter',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHeaders(config: ActiveConfig): Record<string, string> {
|
||||||
|
const h: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.provider === 'openrouter') {
|
||||||
|
h['Authorization'] = `Bearer ${config.apiKey}`
|
||||||
|
h['HTTP-Referer'] = window.location.origin
|
||||||
|
h['X-Title'] = 'KAPPA Hub Alpha'
|
||||||
|
} else {
|
||||||
|
h['Authorization'] = `Bearer ${config.apiKey}`
|
||||||
|
h['Authorization'] = `Bearer ${config.apiKey}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
role: 'system' | 'user' | 'assistant'
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function callAI(
|
||||||
|
messages: ChatMessage[],
|
||||||
|
temperature = 0.3,
|
||||||
|
maxTokens = 4096,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<string> {
|
||||||
|
const config = getActiveConfig()
|
||||||
|
|
||||||
|
if (!config.apiKey) {
|
||||||
|
const label = PROVIDER_CONFIG[config.provider]?.label || config.provider
|
||||||
|
throw new Error(`No hay API key configurada para ${label}`)
|
||||||
|
}
|
||||||
|
if (!config.baseUrl) {
|
||||||
|
throw new Error(`El proveedor ${config.provider} no tiene API configurada`)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Alpha] AI call — provider: ${config.provider}, model: ${config.model}`)
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
model: config.model,
|
||||||
|
messages,
|
||||||
|
temperature,
|
||||||
|
max_tokens: maxTokens,
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(config.baseUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: buildHeaders(config),
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
const rawText = await res.text()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`API error (${config.provider}): ${res.status} — ${rawText.slice(0, 300)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = JSON.parse(rawText)
|
||||||
|
const content = data.choices?.[0]?.message?.content || null
|
||||||
|
|
||||||
|
if (!content) throw new Error('Respuesta vacía del proveedor de IA')
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function chatWithAI(
|
||||||
|
messages: { role: 'user' | 'assistant'; content: string }[],
|
||||||
|
systemPrompt?: string,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<string> {
|
||||||
|
const msgs: ChatMessage[] = []
|
||||||
|
if (systemPrompt) {
|
||||||
|
msgs.push({ role: 'system', content: systemPrompt })
|
||||||
|
}
|
||||||
|
msgs.push(...messages)
|
||||||
|
return callAI(msgs, 0.7, 2048, signal)
|
||||||
|
}
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT = `Eres un analista funcional experto en metodologías ágiles. Tu tarea es analizar transcripciones de reuniones y extraer Historias de Usuario (HUs) en formato estructurado.
|
||||||
|
|
||||||
|
Reglas:
|
||||||
|
1. Identifica cada requisito, funcionalidad, bug o mejora mencionada en la transcripción
|
||||||
|
2. Convierte cada uno en una HU con: título claro, descripción detallada, criterios de aceptación
|
||||||
|
3. Los criterios de aceptación deben ser verificables (condiciones específicas)
|
||||||
|
4. Usa el formato "Como [rol] quiero [funcionalidad] para [beneficio]" cuando sea posible
|
||||||
|
5. Asigna prioridad (Alta/Media/Baja) basada en urgencia implícita
|
||||||
|
6. No inventes información que no esté en la transcripción
|
||||||
|
7. Si el texto no contiene información relevante para HUs, devuelve un arreglo vacío
|
||||||
|
|
||||||
|
Responde SOLO con JSON válido en este formato:
|
||||||
|
{
|
||||||
|
"hus": [
|
||||||
|
{
|
||||||
|
"title": "Título de la HU",
|
||||||
|
"description": "Descripción detallada",
|
||||||
|
"acceptance_criteria": ["Criterio 1", "Criterio 2"],
|
||||||
|
"priority": "Alta|Media|Baja",
|
||||||
|
"story_points": 3,
|
||||||
|
"type": "feature|bug|task|improvement"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Resumen breve del análisis (2-3 líneas)"
|
||||||
|
}`
|
||||||
|
|
||||||
|
export async function analyzeTranscription(
|
||||||
|
text: string,
|
||||||
|
projectName?: string,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<AIAnalysisResult> {
|
||||||
|
const userContent = projectName
|
||||||
|
? `Proyecto: ${projectName}\n\nTranscripción:\n${text}`
|
||||||
|
: `Transcripción:\n${text}`
|
||||||
|
|
||||||
|
console.log(`[Alpha] AI analyze — text: ${text.length} chars`)
|
||||||
|
|
||||||
|
const content = await callAI(
|
||||||
|
[
|
||||||
|
{ role: 'system', content: SYSTEM_PROMPT },
|
||||||
|
{ role: 'user', content: userContent },
|
||||||
|
],
|
||||||
|
0.3,
|
||||||
|
4096,
|
||||||
|
signal,
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const jsonStr = content.replace(/```json\s*/gi, '').replace(/```\s*$/g, '').trim()
|
||||||
|
const result: AIAnalysisResult = JSON.parse(jsonStr)
|
||||||
|
console.log(`[Alpha] AI analysis complete — ${result.hus.length} HUs`)
|
||||||
|
return result
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Alpha] Failed to parse AI response:', content)
|
||||||
|
throw new Error('No se pudo parsear la respuesta de la IA')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import Dexie from 'dexie'
|
||||||
|
|
||||||
|
export interface SettingEntry {
|
||||||
|
key: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectDocRecord {
|
||||||
|
projectId: number
|
||||||
|
projectName: string
|
||||||
|
updatedAt: string
|
||||||
|
sessionCount: number
|
||||||
|
markdown: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = new Dexie('alpha-core') as Dexie & {
|
||||||
|
settings: Dexie.Table<SettingEntry, string>
|
||||||
|
project_docs: Dexie.Table<ProjectDocRecord, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
db.version(2).stores({
|
||||||
|
settings: '&key',
|
||||||
|
project_docs: '&projectId, projectName, updatedAt',
|
||||||
|
})
|
||||||
|
|
||||||
|
export default db
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { storage } from '@/services/storage'
|
||||||
import type {
|
import type {
|
||||||
KappaLoginPayload,
|
KappaLoginPayload,
|
||||||
KappaLoginResponse,
|
KappaLoginResponse,
|
||||||
@@ -23,7 +24,7 @@ class KappaAPI {
|
|||||||
private token: string | null = null
|
private token: string | null = null
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.token = localStorage.getItem('kappa_token')
|
this.token = storage.get('kappa_token')
|
||||||
}
|
}
|
||||||
|
|
||||||
private get headers(): Record<string, string> {
|
private get headers(): Record<string, string> {
|
||||||
@@ -66,14 +67,14 @@ class KappaAPI {
|
|||||||
)
|
)
|
||||||
this.token = data.access || data.token || data.key || null
|
this.token = data.access || data.token || data.key || null
|
||||||
if (this.token) {
|
if (this.token) {
|
||||||
localStorage.setItem('kappa_token', this.token)
|
storage.set('kappa_token', this.token)
|
||||||
}
|
}
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
logout() {
|
logout() {
|
||||||
this.token = null
|
this.token = null
|
||||||
localStorage.removeItem('kappa_token')
|
storage.remove('kappa_token')
|
||||||
}
|
}
|
||||||
|
|
||||||
get isAuthenticated(): boolean {
|
get isAuthenticated(): boolean {
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import * as mammoth from 'mammoth'
|
||||||
|
|
||||||
|
export type TranscriptionFileType = 'docx' | 'vtt' | 'txt' | 'md'
|
||||||
|
|
||||||
|
export interface ParsedTranscription {
|
||||||
|
fileName: string
|
||||||
|
fileType: TranscriptionFileType
|
||||||
|
text: string
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const VTT_HEADER_RE = /^WEBVTT\s/mi
|
||||||
|
const VTT_TIMING_RE = /^\d{2}:\d{2}:\d{2}\.\d{3}\s*-->\s*\d{2}:\d{2}:\d{2}\.\d{3}/m
|
||||||
|
const VTT_CUE_RE = /\d{2}:\d{2}:\d{2}\.\d{3}\s*-->\s*\d{2}:\d{2}:\d{2}\.\d{3}/g
|
||||||
|
|
||||||
|
function isVTT(text: string): boolean {
|
||||||
|
return VTT_HEADER_RE.test(text) || VTT_TIMING_RE.test(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseVtt(raw: string): string {
|
||||||
|
return raw
|
||||||
|
.replace(VTT_HEADER_RE, '')
|
||||||
|
.replace(/Kind:.*\n?/gi, '')
|
||||||
|
.replace(/Language:.*\n?/gi, '')
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.filter(line => {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (!trimmed) return false
|
||||||
|
if (/^\d+$/.test(trimmed)) return false
|
||||||
|
if (VTT_TIMING_RE.test(trimmed)) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
.join(' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectType(fileName: string): TranscriptionFileType {
|
||||||
|
const ext = fileName.split('.').pop()?.toLowerCase()
|
||||||
|
if (ext === 'docx') return 'docx'
|
||||||
|
if (ext === 'vtt') return 'vtt'
|
||||||
|
if (ext === 'md') return 'md'
|
||||||
|
return 'txt'
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseFile(file: File): Promise<ParsedTranscription> {
|
||||||
|
const fileName = file.name
|
||||||
|
const fileType = detectType(fileName)
|
||||||
|
const size = file.size
|
||||||
|
|
||||||
|
console.log(`[Alpha] Parsing file: ${fileName} (${fileType}, ${size} bytes)`)
|
||||||
|
|
||||||
|
let text: string
|
||||||
|
|
||||||
|
if (fileType === 'docx') {
|
||||||
|
const arrayBuffer = await file.arrayBuffer()
|
||||||
|
const result = await mammoth.extractRawText({ arrayBuffer })
|
||||||
|
text = result.value.trim()
|
||||||
|
} else {
|
||||||
|
const raw = await file.text()
|
||||||
|
if (fileType === 'vtt' || isVTT(raw)) {
|
||||||
|
text = parseVtt(raw)
|
||||||
|
} else {
|
||||||
|
text = raw.trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { fileName, fileType, text, size }
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import db from '@/services/db'
|
||||||
|
import type { SessionExtraction } from '@/services/session-analyzer'
|
||||||
|
|
||||||
|
export interface ProjectDoc {
|
||||||
|
projectId: number
|
||||||
|
projectName: string
|
||||||
|
updatedAt: string
|
||||||
|
sessionCount: number
|
||||||
|
markdown: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProjectDoc(projectId: number): Promise<ProjectDoc | null> {
|
||||||
|
const doc = await db.table('project_docs').get(projectId)
|
||||||
|
return (doc as ProjectDoc) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateProjectDoc(
|
||||||
|
projectId: number,
|
||||||
|
projectName: string,
|
||||||
|
extraction: SessionExtraction,
|
||||||
|
transcriptionText: string,
|
||||||
|
fileName: string,
|
||||||
|
previousDoc?: ProjectDoc | null,
|
||||||
|
): Promise<ProjectDoc> {
|
||||||
|
const now = new Date().toISOString().slice(0, 16).replace('T', ' ')
|
||||||
|
const sessionCount = (previousDoc?.sessionCount || 0) + 1
|
||||||
|
|
||||||
|
// ─── Block 1: Summary (replaced) ───────────────────────
|
||||||
|
|
||||||
|
const objectivesMd = extraction.objectives.map(o =>
|
||||||
|
o.isNew ? `- [ ] ${o.text} 🆕` : `- [ ] ${o.text}`
|
||||||
|
).join('\n')
|
||||||
|
|
||||||
|
const tasksMd = extraction.pendingTasks.map((t, i) =>
|
||||||
|
`| ${i + 1} | [ ] ${t.description} | ${t.origin} | ${now.slice(0, 10)} | ${t.priority} |`
|
||||||
|
).join('\n')
|
||||||
|
|
||||||
|
const commitmentsMd = extraction.commitments.map(c =>
|
||||||
|
`| ${c.description} | ${c.responsible} | ${c.dueDate} | ${c.status === 'Cumplido' ? '✅' : '⏳'} | — |`
|
||||||
|
).join('\n')
|
||||||
|
|
||||||
|
const completedMd = extraction.completedTasks.map(t => `- [x] ${t}`).join('\n')
|
||||||
|
|
||||||
|
const milestonesMd = extraction.commitments
|
||||||
|
.filter(c => c.dueDate && c.status !== 'Cumplido')
|
||||||
|
.map(c => `- **${c.dueDate}**: ${c.description}`)
|
||||||
|
.join('\n')
|
||||||
|
|
||||||
|
const block1 = `# 📋 ${projectName} — Resumen Ejecutivo
|
||||||
|
|
||||||
|
> ⚠️ Última actualización: ${now}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Resumen Ejecutivo
|
||||||
|
${extraction.summary}
|
||||||
|
|
||||||
|
## 🎯 Objetivos
|
||||||
|
${objectivesMd || '_Sin objetivos registrados_'}
|
||||||
|
|
||||||
|
## 📝 Tareas Pendientes
|
||||||
|
| # | Tarea | Origen | Fecha creación | Prioridad |
|
||||||
|
|---|-------|--------|----------------|-----------|
|
||||||
|
${tasksMd || '_Sin tareas pendientes_'}
|
||||||
|
|
||||||
|
## ✅ Compromisos
|
||||||
|
| Compromiso | Responsable | Fecha límite | Estado | Notas |
|
||||||
|
|------------|-------------|--------------|--------|-------|
|
||||||
|
${commitmentsMd || '_Sin compromisos_'}
|
||||||
|
|
||||||
|
## ✅ Tareas Completadas
|
||||||
|
${completedMd || '_Sin tareas completadas en esta sesión_'}
|
||||||
|
|
||||||
|
## 📅 Próximos Hitos
|
||||||
|
${milestonesMd || '_Sin hitos próximos_'}
|
||||||
|
|
||||||
|
## 📊 Métricas de Seguimiento
|
||||||
|
- Sesiones registradas: ${sessionCount}
|
||||||
|
- Tareas pendientes: ${extraction.pendingTasks.length}
|
||||||
|
- Compromisos cumplidos: ${extraction.commitments.filter(c => c.status === 'Cumplido').length}/${extraction.commitments.length}
|
||||||
|
- Decisiones tomadas: ${extraction.decisions.length}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Bloque 2: Registro de Sesiones
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📜 Registro Completo de Sesiones
|
||||||
|
`
|
||||||
|
|
||||||
|
// ─── Block 2: Session entry (appended) ─────────────────
|
||||||
|
|
||||||
|
const decisionsMd = extraction.decisions.map(d => `- ${d}`).join('\n')
|
||||||
|
const keyPointsMd = extraction.keyPoints.map(k => `- ${k}`).join('\n')
|
||||||
|
|
||||||
|
const sessionEntry = `---
|
||||||
|
|
||||||
|
## 📍 Sesión ${sessionCount}: ${extraction.sessionTitle}
|
||||||
|
|
||||||
|
**Archivo fuente:** \`${fileName}\`
|
||||||
|
**Fecha:** ${now}
|
||||||
|
|
||||||
|
### Resumen de la sesión
|
||||||
|
${extraction.summary}
|
||||||
|
|
||||||
|
### Tareas identificadas en esta sesión
|
||||||
|
${extraction.pendingTasks.map(t => `- [ ] ${t.description} (_${t.priority}_)`).join('\n') || '_Ninguna_'}
|
||||||
|
|
||||||
|
### Decisiones tomadas
|
||||||
|
${decisionsMd || '_Ninguna_'}
|
||||||
|
|
||||||
|
### Puntos clave
|
||||||
|
${keyPointsMd || '_Ninguno_'}
|
||||||
|
|
||||||
|
### Transcripción completa
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
${transcriptionText}
|
||||||
|
\`\`\`
|
||||||
|
`
|
||||||
|
|
||||||
|
// ─── Assemble document ─────────────────────────────────
|
||||||
|
|
||||||
|
const previousSessions = previousDoc
|
||||||
|
? previousDoc.markdown.split('---\n\n## 📜 Registro Completo de Sesiones')[1] || ''
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const markdown = `${block1}${previousSessions}\n${sessionEntry}\n`
|
||||||
|
|
||||||
|
const doc: ProjectDoc = {
|
||||||
|
projectId,
|
||||||
|
projectName,
|
||||||
|
updatedAt: now,
|
||||||
|
sessionCount,
|
||||||
|
markdown,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to Dexie
|
||||||
|
await db.table('project_docs').put(doc)
|
||||||
|
|
||||||
|
return doc
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { callAI } from '@/services/ai'
|
||||||
|
|
||||||
|
export interface SessionExtraction {
|
||||||
|
sessionTitle: string
|
||||||
|
summary: string
|
||||||
|
objectives: { text: string; isNew: boolean }[]
|
||||||
|
pendingTasks: { description: string; origin: string; priority: string }[]
|
||||||
|
commitments: { description: string; responsible: string; dueDate: string; status: string }[]
|
||||||
|
decisions: string[]
|
||||||
|
completedTasks: string[]
|
||||||
|
keyPoints: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const SESSION_SYSTEM_PROMPT = `Eres un asistente de gestión de proyectos. Analizás transcripciones de reuniones y extraés información estructurada.
|
||||||
|
|
||||||
|
Reglas:
|
||||||
|
1. Identificá el título de la sesión basado en el contenido y fecha
|
||||||
|
2. Extraé un resumen ejecutivo de 2-3 oraciones
|
||||||
|
3. Listá objetivos mencionados, marcando cuáles son NUEVOS vs existentes
|
||||||
|
4. Extraé tareas pendientes con su origen y prioridad (Alta/Media/Baja)
|
||||||
|
5. Identificá compromisos con responsable, fecha límite y estado
|
||||||
|
6. Listá decisiones tomadas durante la sesión
|
||||||
|
7. Detectá tareas completadas (si hay evidencia)
|
||||||
|
8. Incluí puntos clave, bloqueos o descubrimientos
|
||||||
|
9. No inventes información que no esté en la transcripción
|
||||||
|
10. Respondé SOLO con JSON válido
|
||||||
|
|
||||||
|
Formato de respuesta JSON:
|
||||||
|
{
|
||||||
|
"sessionTitle": "Título descriptivo de la sesión",
|
||||||
|
"summary": "Resumen ejecutivo de 2-3 oraciones",
|
||||||
|
"objectives": [
|
||||||
|
{ "text": "Descripción del objetivo", "isNew": true }
|
||||||
|
],
|
||||||
|
"pendingTasks": [
|
||||||
|
{ "description": "Descripción de la tarea", "origin": "Sesión o contexto", "priority": "Alta|Media|Baja" }
|
||||||
|
],
|
||||||
|
"commitments": [
|
||||||
|
{ "description": "Compromiso", "responsible": "Nombre", "dueDate": "YYYY-MM-DD", "status": "Pendiente|Cumplido|Vencido" }
|
||||||
|
],
|
||||||
|
"decisions": ["Decisión 1", "Decisión 2"],
|
||||||
|
"completedTasks": ["Tarea completada 1"],
|
||||||
|
"keyPoints": ["Punto clave 1"]
|
||||||
|
}`
|
||||||
|
|
||||||
|
export async function analyzeSession(
|
||||||
|
transcription: string,
|
||||||
|
projectName: string,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<SessionExtraction> {
|
||||||
|
const userContent = `Proyecto: ${projectName}\n\nTranscripción:\n${transcription}`
|
||||||
|
|
||||||
|
console.log(`[Alpha] Session analyze — project: ${projectName}, text: ${transcription.length} chars`)
|
||||||
|
|
||||||
|
const content = await callAI(
|
||||||
|
[
|
||||||
|
{ role: 'system', content: SESSION_SYSTEM_PROMPT },
|
||||||
|
{ role: 'user', content: userContent },
|
||||||
|
],
|
||||||
|
0.3,
|
||||||
|
4096,
|
||||||
|
signal,
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const jsonStr = content.replace(/```json\s*/gi, '').replace(/```\s*$/g, '').trim()
|
||||||
|
const result: SessionExtraction = JSON.parse(jsonStr)
|
||||||
|
console.log(`[Alpha] Session analysis complete — ${result.pendingTasks.length} tasks, ${result.decisions.length} decisions`)
|
||||||
|
return result
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Alpha] Failed to parse session analysis:', content)
|
||||||
|
throw new Error('No se pudo parsear el análisis de la sesión')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* Cache-Aside + Write-Through storage para Alpha.
|
||||||
|
*
|
||||||
|
* ─── Patrón ─────────────────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* L1 (rápido) → Map en memoria ~0ms
|
||||||
|
* L2 (persistente) → Dexie IndexedDB ~5-50ms
|
||||||
|
* L3 (fallback) → localStorage ~1ms
|
||||||
|
*
|
||||||
|
* ─── Lectura (Cache-Aside) ─────────────────────────────────────
|
||||||
|
*
|
||||||
|
* get(key):
|
||||||
|
* 1. L1 hit → return (instantáneo)
|
||||||
|
* 2. L2 hit → poblar L1 → return
|
||||||
|
* 3. L3 hit → poblar L2 + L1 → return
|
||||||
|
* 4. Miss → return null
|
||||||
|
*
|
||||||
|
* ─── Escritura (Write-Through) ─────────────────────────────────
|
||||||
|
*
|
||||||
|
* set(key, value):
|
||||||
|
* 1. Escribir L1 (instantáneo)
|
||||||
|
* 2. Escribir L2 (Dexie, async await)
|
||||||
|
* 3. Escribir L3 (localStorage, sync)
|
||||||
|
* Si L2 falla → el dato sigue en L1 + L3 (consistencia eventual)
|
||||||
|
*
|
||||||
|
* ─── ¿Por qué este patrón? ─────────────────────────────────────
|
||||||
|
*
|
||||||
|
* - Cache-Aside: control explícito de qué se cachea y cuándo
|
||||||
|
* - Write-Through: el cache siempre refleja la fuente de verdad
|
||||||
|
* - L3 (localStorage) actúa como quorum: si IndexedDB falla,
|
||||||
|
* los datos no se pierden
|
||||||
|
* - Para RUMBO: mismo patrón, cambia L2 de Dexie a Turso/libSQL
|
||||||
|
* y L3 pasa a ser un archivo JSON en ~/.rumbo/cache.json
|
||||||
|
*
|
||||||
|
* ─── Uso ───────────────────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* import { storage } from '@/services/storage'
|
||||||
|
* await storage.init()
|
||||||
|
* storage.get('key') → string | null (sync)
|
||||||
|
* await storage.set('k', v) → void (async)
|
||||||
|
* await storage.remove('k') → void (async)
|
||||||
|
* storage.getJSON<T>('k') → T | null (sync)
|
||||||
|
* await storage.setJSON() → void (async)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import db from '@/services/db'
|
||||||
|
|
||||||
|
class AppStorage {
|
||||||
|
/** L1: cache en memoria */
|
||||||
|
private cache = new Map<string, { value: string; ttl: number | null }>()
|
||||||
|
private loaded = false
|
||||||
|
private initPromise: Promise<void> | null = null
|
||||||
|
|
||||||
|
// ─── Init ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
if (this.initPromise) return this.initPromise
|
||||||
|
this.initPromise = this._load()
|
||||||
|
return this.initPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _load() {
|
||||||
|
const all = await db.settings.toArray()
|
||||||
|
for (const entry of all) {
|
||||||
|
this.cache.set(entry.key, { value: entry.value, ttl: null })
|
||||||
|
}
|
||||||
|
this.loaded = true
|
||||||
|
console.log(`[Storage] Init OK — ${all.length} entries en L1`)
|
||||||
|
}
|
||||||
|
|
||||||
|
isReady() { return this.loaded }
|
||||||
|
|
||||||
|
// ─── Lectura: Cache-Aside (L1 → L2 → L3) ────────────────
|
||||||
|
|
||||||
|
get(key: string): string | null {
|
||||||
|
// 1. L1 hit
|
||||||
|
const entry = this.cache.get(key)
|
||||||
|
if (entry) {
|
||||||
|
if (entry.ttl !== null && entry.ttl < Date.now()) {
|
||||||
|
this.cache.delete(key)
|
||||||
|
} else {
|
||||||
|
return entry.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. L2 (Dexie)
|
||||||
|
// Nota: Dexie.get() es async. Para mantener get() sync,
|
||||||
|
// delegamos la carga asíncrona a init(). Si no está en L1
|
||||||
|
// después de init, cae a L3.
|
||||||
|
// En la práctica, init() carga TODO al arranque, así que
|
||||||
|
// L1 siempre está poblado para keys existentes.
|
||||||
|
|
||||||
|
// 3. L3 (localStorage) — fallback + migración
|
||||||
|
const fallback = localStorage.getItem(key)
|
||||||
|
if (fallback !== null) {
|
||||||
|
this.cache.set(key, { value: fallback, ttl: null })
|
||||||
|
// Write-Through hacia L2 (fire-and-forget)
|
||||||
|
db.settings.put({ key, value: fallback }).catch(() => {})
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
getJSON<T>(key: string): T | null {
|
||||||
|
const raw = this.get(key)
|
||||||
|
if (!raw) return null
|
||||||
|
try { return JSON.parse(raw) as T } catch { return null }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Escritura: Write-Through (L1 + L2 + L3) ─────────────
|
||||||
|
|
||||||
|
async set(key: string, value: string) {
|
||||||
|
// 1. L1 (instantáneo)
|
||||||
|
this.cache.set(key, { value, ttl: null })
|
||||||
|
|
||||||
|
// 2. L2 + L3 en paralelo
|
||||||
|
await Promise.all([
|
||||||
|
db.settings.put({ key, value }).catch(e => {
|
||||||
|
console.error(`[Storage] L2 error writing "${key}":`, e)
|
||||||
|
}),
|
||||||
|
Promise.resolve().then(() => {
|
||||||
|
localStorage.setItem(key, value)
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
async setJSON(key: string, value: unknown) {
|
||||||
|
await this.set(key, JSON.stringify(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(key: string) {
|
||||||
|
this.cache.delete(key)
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
db.settings.delete(key).catch(e => {
|
||||||
|
console.error(`[Storage] L2 error deleting "${key}":`, e)
|
||||||
|
}),
|
||||||
|
Promise.resolve().then(() => {
|
||||||
|
localStorage.removeItem(key)
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Utilidades ───────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Expone el tamaño del cache L1 (debug) */
|
||||||
|
get cacheSize() { return this.cache.size }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const storage = new AppStorage()
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import * as XLSX from 'xlsx'
|
import * as XLSX from 'xlsx'
|
||||||
import { kappa } from '@/services/kappa-api'
|
import { kappa } from '@/services/kappa-api'
|
||||||
|
import { storage } from '@/services/storage'
|
||||||
import type { HuDraftRecord } from '@/services/tauri-db'
|
import type { HuDraftRecord } from '@/services/tauri-db'
|
||||||
|
|
||||||
const BASE = '/api'
|
const BASE = '/api'
|
||||||
|
|
||||||
async function uploadExcel(initiativeId: number, file: Blob): Promise<Response> {
|
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()
|
const formData = new FormData()
|
||||||
formData.append('file', file, 'HistoriasUsuario.xlsx')
|
formData.append('file', file, 'HistoriasUsuario.xlsx')
|
||||||
return fetch(`${BASE}/userstorys/upload-excel/${initiativeId}/`, {
|
return fetch(`${BASE}/userstorys/upload-excel/${initiativeId}/`, {
|
||||||
|
|||||||
+6
-17
@@ -1,20 +1,12 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { kappa } from '@/services/kappa-api'
|
import { kappa } from '@/services/kappa-api'
|
||||||
|
import { storage } from '@/services/storage'
|
||||||
import type { KappaLoginPayload } from '@/types/kappa'
|
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', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
const token = ref<string | null>(localStorage.getItem('kappa_token'))
|
const token = ref<string | null>(storage.get('kappa_token'))
|
||||||
const user = ref<{ name: string; email: string } | null>(loadUser())
|
const user = ref<{ name: string; email: string } | null>(storage.getJSON('kappa_user'))
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
@@ -29,11 +21,8 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
const name = data.full_name
|
const name = data.full_name
|
||||||
|| `${data.first_name || ''} ${data.last_name || ''}`.trim()
|
|| `${data.first_name || ''} ${data.last_name || ''}`.trim()
|
||||||
|| 'Ricardo Gonzalez'
|
|| 'Ricardo Gonzalez'
|
||||||
user.value = {
|
user.value = { name, email: payload.email }
|
||||||
name,
|
await storage.setJSON('kappa_user', user.value)
|
||||||
email: payload.email,
|
|
||||||
}
|
|
||||||
saveUser(user.value)
|
|
||||||
return true
|
return true
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error.value = e.message
|
error.value = e.message
|
||||||
@@ -47,7 +36,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
kappa.logout()
|
kappa.logout()
|
||||||
token.value = null
|
token.value = null
|
||||||
user.value = null
|
user.value = null
|
||||||
localStorage.removeItem('kappa_user')
|
storage.remove('kappa_user')
|
||||||
}
|
}
|
||||||
|
|
||||||
return { token, user, loading, error, isAuthenticated, login, logout }
|
return { token, user, loading, error, isAuthenticated, login, logout }
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { kappa } from '@/services/kappa-api'
|
import { kappa } from '@/services/kappa-api'
|
||||||
|
import { storage } from '@/services/storage'
|
||||||
import { tauriDb } from '@/services/tauri-db'
|
import { tauriDb } from '@/services/tauri-db'
|
||||||
import { stripHtml } from '@/services/clean-html'
|
import { stripHtml } from '@/services/clean-html'
|
||||||
import type { KappaInitiative } from '@/types/kappa'
|
import type { KappaInitiative } from '@/types/kappa'
|
||||||
|
|
||||||
export const useProjectsStore = defineStore('projects', () => {
|
export const useProjectsStore = defineStore('projects', () => {
|
||||||
|
const lastProject = storage.get('kappa_last_project')
|
||||||
const projects = ref<KappaInitiative[]>([])
|
const projects = ref<KappaInitiative[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const selectedId = ref<number | null>(null)
|
const selectedId = ref<number | null>(lastProject ? Number(lastProject) : null)
|
||||||
|
|
||||||
const selected = computed(() =>
|
const selected = computed(() =>
|
||||||
projects.value.find(p => p.id === selectedId.value) ?? null
|
projects.value.find(p => p.id === selectedId.value) ?? null
|
||||||
@@ -67,7 +69,7 @@ export const useProjectsStore = defineStore('projects', () => {
|
|||||||
|
|
||||||
function select(id: number) {
|
function select(id: number) {
|
||||||
selectedId.value = id
|
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 }
|
return { projects, loading, error, selectedId, selected, count, fetchProjects, select }
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { kappa } from '@/services/kappa-api'
|
import { kappa } from '@/services/kappa-api'
|
||||||
|
import { storage } from '@/services/storage'
|
||||||
import { tauriDb, type UserStoryRecord, type EpicRecord, type ImpairmentRecord } from '@/services/tauri-db'
|
import { tauriDb, type UserStoryRecord, type EpicRecord, type ImpairmentRecord } from '@/services/tauri-db'
|
||||||
import { stripHtml } from '@/services/clean-html'
|
import { stripHtml } from '@/services/clean-html'
|
||||||
import { criteriaToJson, parseQuillList } from '@/services/clean-html'
|
import { criteriaToJson, parseQuillList } from '@/services/clean-html'
|
||||||
@@ -96,7 +97,7 @@ export const useWorkItemsStore = defineStore('workitems', () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
|
|
||||||
const id = initiativeId || Number(localStorage.getItem('kappa_last_project'))
|
const id = initiativeId || Number(storage.get('kappa_last_project'))
|
||||||
if (!id) {
|
if (!id) {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
return
|
return
|
||||||
|
|||||||
+14
-17
@@ -4,8 +4,9 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import { useProjectsStore } from '@/stores/projects'
|
import { useProjectsStore } from '@/stores/projects'
|
||||||
import { useWorkItemsStore } from '@/stores/workitems'
|
import { useWorkItemsStore } from '@/stores/workitems'
|
||||||
import { getTypeLabel, getTypeColor, getTypeIcon } from '@/services/hierarchy'
|
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 HuDrafts from '@/components/HuDrafts.vue'
|
||||||
|
import AiProjectChat from '@/components/AiProjectChat.vue'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
@@ -22,6 +23,10 @@ const { t } = useI18n()
|
|||||||
const projects = useProjectsStore()
|
const projects = useProjectsStore()
|
||||||
const workItems = useWorkItemsStore()
|
const workItems = useWorkItemsStore()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'navigate-settings': []
|
||||||
|
}>()
|
||||||
|
|
||||||
const project = computed(() => projects.selected)
|
const project = computed(() => projects.selected)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -113,22 +118,14 @@ const statusLabel = (status: unknown) => {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Description -->
|
<!-- AI Chat -->
|
||||||
<Card id="dashboard-description">
|
<AiProjectChat
|
||||||
<CardHeader class="pb-2">
|
:project-name="project.name ?? ''"
|
||||||
<CardTitle class="text-sm font-medium">{{ t('dashboard.description') }}</CardTitle>
|
:project-description="project.description ?? ''"
|
||||||
</CardHeader>
|
:epic-count="workItems.totalEpics"
|
||||||
<CardContent>
|
:hu-count="workItems.totalHUs"
|
||||||
<p v-if="project.description" class="text-sm text-muted-foreground leading-relaxed line-clamp-4">
|
@navigate-settings="emit('navigate-settings')"
|
||||||
{{ 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>
|
|
||||||
|
|
||||||
<!-- Loading -->
|
<!-- Loading -->
|
||||||
<template v-if="workItems.loading">
|
<template v-if="workItems.loading">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { storage } from '@/services/storage'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
@@ -17,7 +18,7 @@ const showPassword = ref(false)
|
|||||||
const rememberMe = ref(false)
|
const rememberMe = ref(false)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const saved = localStorage.getItem('alpha_remember_email')
|
const saved = storage.get('alpha_remember_email')
|
||||||
if (saved) {
|
if (saved) {
|
||||||
email.value = saved
|
email.value = saved
|
||||||
rememberMe.value = true
|
rememberMe.value = true
|
||||||
@@ -28,9 +29,9 @@ async function handleLogin() {
|
|||||||
if (!email.value || !password.value) return
|
if (!email.value || !password.value) return
|
||||||
|
|
||||||
if (rememberMe.value) {
|
if (rememberMe.value) {
|
||||||
localStorage.setItem('alpha_remember_email', email.value)
|
storage.set('alpha_remember_email', email.value)
|
||||||
} else {
|
} else {
|
||||||
localStorage.removeItem('alpha_remember_email')
|
storage.remove('alpha_remember_email')
|
||||||
}
|
}
|
||||||
|
|
||||||
await auth.login({ email: email.value, password: password.value })
|
await auth.login({ email: email.value, password: password.value })
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import SectionCards from "@/components/dashboard/SectionCards.vue"
|
|||||||
import DashboardView from "@/views/DashboardView.vue"
|
import DashboardView from "@/views/DashboardView.vue"
|
||||||
import ProjectListView from "@/views/ProjectListView.vue"
|
import ProjectListView from "@/views/ProjectListView.vue"
|
||||||
import UsersView from "@/views/UsersView.vue"
|
import UsersView from "@/views/UsersView.vue"
|
||||||
|
import TranscriptionsView from "@/views/TranscriptionsView.vue"
|
||||||
|
import SettingsView from "@/views/SettingsView.vue"
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
@@ -146,13 +148,19 @@ const tabContent: Record<string, { title: string; description: string; cards: {
|
|||||||
← {{ t('common.backToProjects') }}
|
← {{ t('common.backToProjects') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<DashboardView />
|
<DashboardView @navigate-settings="activeTab = 'settings'" />
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="activeTab === 'projects'">
|
<template v-else-if="activeTab === 'projects'">
|
||||||
<ProjectListView
|
<ProjectListView
|
||||||
@select-project="openProject()"
|
@select-project="openProject()"
|
||||||
/>
|
/>
|
||||||
</template>
|
</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'">
|
<template v-else-if="activeTab === 'team'">
|
||||||
<UsersView />
|
<UsersView />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -12,6 +12,12 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
|
'/api/proxy/minimax': {
|
||||||
|
target: 'https://api.minimax.io',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
rewrite: (path) => path.replace(/^\/api\/proxy\/minimax/, '/v1/chat/completions'),
|
||||||
|
},
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'https://kappa.lambdaanalytics.co',
|
target: 'https://kappa.lambdaanalytics.co',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user