Upload 17 files
Browse files- .env.example +9 -0
- .gitignore +8 -0
- README.md +20 -10
- index.html +25 -0
- metadata.json +5 -0
- package-lock.json +0 -0
- package.json +34 -0
- public/manifest.json +17 -0
- public/sw.js +12 -0
- src/App.tsx +439 -0
- src/index.css +106 -0
- src/lib/anonymizer.ts +155 -0
- src/lib/gemini.ts +143 -0
- src/main.tsx +10 -0
- src/types.ts +8 -0
- tsconfig.json +26 -0
- vite.config.ts +24 -0
.env.example
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# GEMINI_API_KEY: Required for Gemini AI API calls.
|
| 2 |
+
# AI Studio automatically injects this at runtime from user secrets.
|
| 3 |
+
# Users configure this via the Secrets panel in the AI Studio UI.
|
| 4 |
+
GEMINI_API_KEY="MY_GEMINI_API_KEY"
|
| 5 |
+
|
| 6 |
+
# APP_URL: The URL where this applet is hosted.
|
| 7 |
+
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
|
| 8 |
+
# Used for self-referential links, OAuth callbacks, and API endpoints.
|
| 9 |
+
APP_URL="MY_APP_URL"
|
.gitignore
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules/
|
| 2 |
+
build/
|
| 3 |
+
dist/
|
| 4 |
+
coverage/
|
| 5 |
+
.DS_Store
|
| 6 |
+
*.log
|
| 7 |
+
.env*
|
| 8 |
+
!.env.example
|
README.md
CHANGED
|
@@ -1,10 +1,20 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<div align="center">
|
| 2 |
+
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
|
| 3 |
+
</div>
|
| 4 |
+
|
| 5 |
+
# Run and deploy your AI Studio app
|
| 6 |
+
|
| 7 |
+
This contains everything you need to run your app locally.
|
| 8 |
+
|
| 9 |
+
View your app in AI Studio: https://ai.studio/apps/c2791183-64b9-4b25-b6ea-133555b8c3ec
|
| 10 |
+
|
| 11 |
+
## Run Locally
|
| 12 |
+
|
| 13 |
+
**Prerequisites:** Node.js
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
1. Install dependencies:
|
| 17 |
+
`npm install`
|
| 18 |
+
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
| 19 |
+
3. Run the app:
|
| 20 |
+
`npm run dev`
|
index.html
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>XLabs & Tratamientos</title>
|
| 7 |
+
<link rel="manifest" href="/manifest.json" />
|
| 8 |
+
<meta name="theme-color" content="#2563eb" />
|
| 9 |
+
<link rel="apple-touch-icon" href="https://cdn-icons-png.flaticon.com/512/2966/2966327.png" />
|
| 10 |
+
</head>
|
| 11 |
+
<body>
|
| 12 |
+
<div id="root"></div>
|
| 13 |
+
<script type="module" src="/src/main.tsx"></script>
|
| 14 |
+
<script>
|
| 15 |
+
if ('serviceWorker' in navigator) {
|
| 16 |
+
window.addEventListener('load', () => {
|
| 17 |
+
navigator.serviceWorker.register('/sw.js')
|
| 18 |
+
.then(reg => console.log('SW registered'))
|
| 19 |
+
.catch(err => console.log('SW error', err));
|
| 20 |
+
});
|
| 21 |
+
}
|
| 22 |
+
</script>
|
| 23 |
+
</body>
|
| 24 |
+
</html>
|
| 25 |
+
|
metadata.json
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "Ordenador Clínico IA",
|
| 3 |
+
"description": "Una aplicación para organizar resultados de laboratorio y tratamientos médicos de forma anónima y segura utilizando IA.",
|
| 4 |
+
"requestFramePermissions": []
|
| 5 |
+
}
|
package-lock.json
ADDED
|
File without changes
|
package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "react-example",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "0.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite --port=3000 --host=0.0.0.0",
|
| 8 |
+
"build": "vite build",
|
| 9 |
+
"preview": "vite preview",
|
| 10 |
+
"clean": "rm -rf dist",
|
| 11 |
+
"lint": "tsc --noEmit"
|
| 12 |
+
},
|
| 13 |
+
"dependencies": {
|
| 14 |
+
"@google/genai": "^1.29.0",
|
| 15 |
+
"@tailwindcss/vite": "^4.1.14",
|
| 16 |
+
"@vitejs/plugin-react": "^5.0.4",
|
| 17 |
+
"lucide-react": "^0.546.0",
|
| 18 |
+
"react": "^19.0.0",
|
| 19 |
+
"react-dom": "^19.0.0",
|
| 20 |
+
"vite": "^6.2.0",
|
| 21 |
+
"express": "^4.21.2",
|
| 22 |
+
"dotenv": "^17.2.3",
|
| 23 |
+
"motion": "^12.23.24"
|
| 24 |
+
},
|
| 25 |
+
"devDependencies": {
|
| 26 |
+
"@types/node": "^22.14.0",
|
| 27 |
+
"autoprefixer": "^10.4.21",
|
| 28 |
+
"tailwindcss": "^4.1.14",
|
| 29 |
+
"tsx": "^4.21.0",
|
| 30 |
+
"typescript": "~5.8.2",
|
| 31 |
+
"vite": "^6.2.0",
|
| 32 |
+
"@types/express": "^4.17.21"
|
| 33 |
+
}
|
| 34 |
+
}
|
public/manifest.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "XLabs & Tratamientos",
|
| 3 |
+
"short_name": "XLabs",
|
| 4 |
+
"description": "Herramienta médica para procesar analíticas y tratamientos.",
|
| 5 |
+
"start_url": "/",
|
| 6 |
+
"display": "standalone",
|
| 7 |
+
"background_color": "#ffffff",
|
| 8 |
+
"theme_color": "#2563eb",
|
| 9 |
+
"icons": [
|
| 10 |
+
{
|
| 11 |
+
"src": "https://cdn-icons-png.flaticon.com/512/2966/2966327.png",
|
| 12 |
+
"sizes": "512x512",
|
| 13 |
+
"type": "image/png",
|
| 14 |
+
"purpose": "any maskable"
|
| 15 |
+
}
|
| 16 |
+
]
|
| 17 |
+
}
|
public/sw.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
self.addEventListener('install', (e) => {
|
| 2 |
+
self.skipWaiting();
|
| 3 |
+
});
|
| 4 |
+
|
| 5 |
+
self.addEventListener('activate', (e) => {
|
| 6 |
+
e.waitUntil(clients.claim());
|
| 7 |
+
});
|
| 8 |
+
|
| 9 |
+
self.addEventListener('fetch', (e) => {
|
| 10 |
+
// Simple pass-through for PWA requirement
|
| 11 |
+
e.respondWith(fetch(e.request));
|
| 12 |
+
});
|
src/App.tsx
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect, useRef } from 'react';
|
| 2 |
+
import { User, Mode } from './types';
|
| 3 |
+
import { processLabsWithGemini, processTreatmentsWithGemini } from './lib/gemini';
|
| 4 |
+
|
| 5 |
+
const STORAGE_KEY = "ordenador_clinico_usuarios_v3";
|
| 6 |
+
|
| 7 |
+
function simpleHash(text: string) {
|
| 8 |
+
let hash = 0;
|
| 9 |
+
for (let i = 0; i < text.length; i++) {
|
| 10 |
+
hash = (hash << 5) - hash + text.charCodeAt(i);
|
| 11 |
+
hash |= 0;
|
| 12 |
+
}
|
| 13 |
+
return String(hash);
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
function xorEncrypt(text: string, key: string) {
|
| 17 |
+
let result = "";
|
| 18 |
+
for (let i = 0; i < text.length; i++) {
|
| 19 |
+
const charCode = text.charCodeAt(i) ^ key.charCodeAt(i % key.length);
|
| 20 |
+
result += String.fromCharCode(charCode);
|
| 21 |
+
}
|
| 22 |
+
return btoa(result);
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
function xorDecrypt(base64Text: string, key: string) {
|
| 26 |
+
try {
|
| 27 |
+
const text = atob(base64Text);
|
| 28 |
+
let result = "";
|
| 29 |
+
for (let i = 0; i < text.length; i++) {
|
| 30 |
+
const charCode = text.charCodeAt(i) ^ key.charCodeAt(i % key.length);
|
| 31 |
+
result += String.fromCharCode(charCode);
|
| 32 |
+
}
|
| 33 |
+
return result;
|
| 34 |
+
} catch {
|
| 35 |
+
return null;
|
| 36 |
+
}
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
export default function App() {
|
| 40 |
+
const [users, setUsers] = useState<User[]>([]);
|
| 41 |
+
const [selectedUserIndex, setSelectedUserIndex] = useState<number | "">("");
|
| 42 |
+
const [mode, setMode] = useState<Mode>("labs");
|
| 43 |
+
const [inputText, setInputText] = useState("");
|
| 44 |
+
const [outputText, setOutputText] = useState("");
|
| 45 |
+
const [status, setStatus] = useState({ message: "Listo para pegar texto.", isError: false });
|
| 46 |
+
const [isModalOpen, setIsModalOpen] = useState(false);
|
| 47 |
+
const [isProcessing, setIsProcessing] = useState(false);
|
| 48 |
+
|
| 49 |
+
const [newUserName, setNewUserName] = useState("");
|
| 50 |
+
const [newUserApiKey, setNewUserApiKey] = useState("");
|
| 51 |
+
const [newUserPin, setNewUserPin] = useState("");
|
| 52 |
+
|
| 53 |
+
const [unlockedUserIndex, setUnlockedUserIndex] = useState<number | null>(null);
|
| 54 |
+
const [unlockedApiKey, setUnlockedApiKey] = useState<string | null>(null);
|
| 55 |
+
|
| 56 |
+
const [isPinModalOpen, setIsPinModalOpen] = useState(false);
|
| 57 |
+
const [pinInput, setPinInput] = useState("");
|
| 58 |
+
|
| 59 |
+
useEffect(() => {
|
| 60 |
+
const raw = localStorage.getItem(STORAGE_KEY);
|
| 61 |
+
if (raw) {
|
| 62 |
+
try {
|
| 63 |
+
const parsed = JSON.parse(raw);
|
| 64 |
+
if (Array.isArray(parsed)) {
|
| 65 |
+
setUsers(parsed);
|
| 66 |
+
}
|
| 67 |
+
} catch (e) {
|
| 68 |
+
console.error("Error loading users", e);
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
}, []);
|
| 72 |
+
|
| 73 |
+
const saveUsers = (newUsers: User[]) => {
|
| 74 |
+
setUsers(newUsers);
|
| 75 |
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(newUsers));
|
| 76 |
+
};
|
| 77 |
+
|
| 78 |
+
const handleSaveUser = () => {
|
| 79 |
+
if (!newUserName || !newUserApiKey || !newUserPin) {
|
| 80 |
+
updateStatus("Debes escribir nombre, API key y PIN.", true);
|
| 81 |
+
return;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
if (newUserPin.length < 4) {
|
| 85 |
+
updateStatus("El PIN debe tener al menos 4 caracteres.", true);
|
| 86 |
+
return;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
const encryptedApiKey = xorEncrypt(newUserApiKey, newUserPin);
|
| 90 |
+
const pinHash = simpleHash(newUserPin);
|
| 91 |
+
|
| 92 |
+
const existingIndex = users.findIndex(u => u.name.toLowerCase() === newUserName.toLowerCase());
|
| 93 |
+
const updatedUsers = [...users];
|
| 94 |
+
|
| 95 |
+
if (existingIndex >= 0) {
|
| 96 |
+
updatedUsers[existingIndex] = { name: newUserName, encryptedApiKey, pinHash };
|
| 97 |
+
} else {
|
| 98 |
+
updatedUsers.push({ name: newUserName, encryptedApiKey, pinHash });
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
saveUsers(updatedUsers);
|
| 102 |
+
setIsModalOpen(false);
|
| 103 |
+
setNewUserName("");
|
| 104 |
+
setNewUserApiKey("");
|
| 105 |
+
setNewUserPin("");
|
| 106 |
+
updateStatus(`Usuario "${newUserName}" guardado correctamente.`);
|
| 107 |
+
};
|
| 108 |
+
|
| 109 |
+
const updateStatus = (message: string, isError = false) => {
|
| 110 |
+
setStatus({ message, isError });
|
| 111 |
+
};
|
| 112 |
+
|
| 113 |
+
const executeProcessing = async (apiKey: string, userName: string) => {
|
| 114 |
+
try {
|
| 115 |
+
setIsProcessing(true);
|
| 116 |
+
updateStatus(`Procesando con Gemini para ${userName}...`);
|
| 117 |
+
|
| 118 |
+
let result = "";
|
| 119 |
+
if (mode === "treatments") {
|
| 120 |
+
result = await processTreatmentsWithGemini(apiKey, inputText);
|
| 121 |
+
} else {
|
| 122 |
+
result = await processLabsWithGemini(apiKey, inputText);
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
setOutputText(result);
|
| 126 |
+
updateStatus(`Procesamiento completado para ${userName}.`);
|
| 127 |
+
} catch (error: any) {
|
| 128 |
+
console.error(error);
|
| 129 |
+
updateStatus(error.message || "Error procesando con Gemini. Revisa la API key o la consola.", true);
|
| 130 |
+
} finally {
|
| 131 |
+
setIsProcessing(false);
|
| 132 |
+
}
|
| 133 |
+
};
|
| 134 |
+
|
| 135 |
+
const handleProcess = async () => {
|
| 136 |
+
if (selectedUserIndex === "") {
|
| 137 |
+
updateStatus("Selecciona un médico antes de procesar.", true);
|
| 138 |
+
return;
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
if (!inputText.trim()) {
|
| 142 |
+
updateStatus("Pega un texto antes de procesar.", true);
|
| 143 |
+
return;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
if (unlockedUserIndex === selectedUserIndex && unlockedApiKey) {
|
| 147 |
+
executeProcessing(unlockedApiKey, users[selectedUserIndex as number].name);
|
| 148 |
+
} else {
|
| 149 |
+
setPinInput("");
|
| 150 |
+
setIsPinModalOpen(true);
|
| 151 |
+
}
|
| 152 |
+
};
|
| 153 |
+
|
| 154 |
+
const handlePinConfirm = () => {
|
| 155 |
+
if (selectedUserIndex === "") return;
|
| 156 |
+
|
| 157 |
+
const currentUser = users[selectedUserIndex as number];
|
| 158 |
+
if (simpleHash(pinInput) !== currentUser.pinHash) {
|
| 159 |
+
updateStatus("PIN incorrecto.", true);
|
| 160 |
+
return;
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
const apiKey = xorDecrypt(currentUser.encryptedApiKey, pinInput);
|
| 164 |
+
if (!apiKey) {
|
| 165 |
+
updateStatus("No se pudo desbloquear la API key.", true);
|
| 166 |
+
return;
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
setUnlockedUserIndex(selectedUserIndex as number);
|
| 170 |
+
setUnlockedApiKey(apiKey);
|
| 171 |
+
setIsPinModalOpen(false);
|
| 172 |
+
executeProcessing(apiKey, currentUser.name);
|
| 173 |
+
};
|
| 174 |
+
|
| 175 |
+
const handleCopy = async () => {
|
| 176 |
+
if (!outputText.trim()) {
|
| 177 |
+
updateStatus("No hay resultado para copiar.", true);
|
| 178 |
+
return;
|
| 179 |
+
}
|
| 180 |
+
try {
|
| 181 |
+
await navigator.clipboard.writeText(outputText);
|
| 182 |
+
updateStatus("Resultado copiado al portapapeles.");
|
| 183 |
+
} catch {
|
| 184 |
+
updateStatus("No se pudo copiar el resultado.", true);
|
| 185 |
+
}
|
| 186 |
+
};
|
| 187 |
+
|
| 188 |
+
const handleLock = () => {
|
| 189 |
+
setUnlockedUserIndex(null);
|
| 190 |
+
setUnlockedApiKey(null);
|
| 191 |
+
updateStatus("Usuario bloqueado.");
|
| 192 |
+
};
|
| 193 |
+
|
| 194 |
+
return (
|
| 195 |
+
<div className="app-container">
|
| 196 |
+
<header className="app-header">
|
| 197 |
+
<div>
|
| 198 |
+
<h1 className="text-3xl font-bold text-slate-900">Ordenador Clínico IA</h1>
|
| 199 |
+
<p className="subtitle">Analíticas y tratamientos con anonimización local</p>
|
| 200 |
+
</div>
|
| 201 |
+
|
| 202 |
+
<div className="user-bar">
|
| 203 |
+
<label htmlFor="userSelect">Usuario</label>
|
| 204 |
+
<select
|
| 205 |
+
id="userSelect"
|
| 206 |
+
value={selectedUserIndex}
|
| 207 |
+
onChange={(e) => {
|
| 208 |
+
setSelectedUserIndex(e.target.value === "" ? "" : Number(e.target.value));
|
| 209 |
+
setUnlockedUserIndex(null);
|
| 210 |
+
setUnlockedApiKey(null);
|
| 211 |
+
}}
|
| 212 |
+
className="bg-white border border-slate-200 rounded-lg px-3 py-2"
|
| 213 |
+
>
|
| 214 |
+
<option value="">Seleccionar médico</option>
|
| 215 |
+
{users.map((user, index) => (
|
| 216 |
+
<option key={index} value={index}>{user.name}</option>
|
| 217 |
+
))}
|
| 218 |
+
</select>
|
| 219 |
+
|
| 220 |
+
<button
|
| 221 |
+
onClick={() => setIsModalOpen(true)}
|
| 222 |
+
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition-colors"
|
| 223 |
+
>
|
| 224 |
+
Configurar usuarios
|
| 225 |
+
</button>
|
| 226 |
+
<button
|
| 227 |
+
onClick={handleLock}
|
| 228 |
+
className="bg-slate-600 hover:bg-slate-700 text-white px-4 py-2 rounded-lg transition-colors"
|
| 229 |
+
>
|
| 230 |
+
Bloquear
|
| 231 |
+
</button>
|
| 232 |
+
</div>
|
| 233 |
+
</header>
|
| 234 |
+
|
| 235 |
+
<main className="main-content">
|
| 236 |
+
<section className="controls-panel">
|
| 237 |
+
<div className="mode-selector flex items-center gap-4 mb-4">
|
| 238 |
+
<span className="font-medium">Modo:</span>
|
| 239 |
+
|
| 240 |
+
<label className="radio-option flex items-center gap-2 cursor-pointer">
|
| 241 |
+
<input
|
| 242 |
+
type="radio"
|
| 243 |
+
name="mode"
|
| 244 |
+
value="labs"
|
| 245 |
+
checked={mode === "labs"}
|
| 246 |
+
onChange={() => setMode("labs")}
|
| 247 |
+
className="w-4 h-4 text-blue-600"
|
| 248 |
+
/>
|
| 249 |
+
<span>Analíticas</span>
|
| 250 |
+
</label>
|
| 251 |
+
|
| 252 |
+
<label className="radio-option flex items-center gap-2 cursor-pointer">
|
| 253 |
+
<input
|
| 254 |
+
type="radio"
|
| 255 |
+
name="mode"
|
| 256 |
+
value="treatments"
|
| 257 |
+
checked={mode === "treatments"}
|
| 258 |
+
onChange={() => setMode("treatments")}
|
| 259 |
+
className="w-4 h-4 text-blue-600"
|
| 260 |
+
/>
|
| 261 |
+
<span>Tratamientos</span>
|
| 262 |
+
</label>
|
| 263 |
+
</div>
|
| 264 |
+
|
| 265 |
+
<div className="actions-row flex gap-3 mb-4">
|
| 266 |
+
<button
|
| 267 |
+
onClick={handleProcess}
|
| 268 |
+
disabled={isProcessing}
|
| 269 |
+
className={`px-6 py-2 rounded-lg font-medium transition-colors ${isProcessing ? 'bg-slate-400 cursor-not-allowed' : 'bg-blue-600 hover:bg-blue-700 text-white'}`}
|
| 270 |
+
>
|
| 271 |
+
{isProcessing ? 'Procesando...' : 'Procesar'}
|
| 272 |
+
</button>
|
| 273 |
+
<button
|
| 274 |
+
onClick={() => {
|
| 275 |
+
setInputText("");
|
| 276 |
+
setOutputText("");
|
| 277 |
+
updateStatus("Campos limpiados.");
|
| 278 |
+
}}
|
| 279 |
+
className="bg-slate-200 hover:bg-slate-300 text-slate-700 px-4 py-2 rounded-lg transition-colors"
|
| 280 |
+
>
|
| 281 |
+
Limpiar
|
| 282 |
+
</button>
|
| 283 |
+
<button
|
| 284 |
+
onClick={handleCopy}
|
| 285 |
+
className="bg-slate-200 hover:bg-slate-300 text-slate-700 px-4 py-2 rounded-lg transition-colors"
|
| 286 |
+
>
|
| 287 |
+
Copiar resultado
|
| 288 |
+
</button>
|
| 289 |
+
</div>
|
| 290 |
+
|
| 291 |
+
<div
|
| 292 |
+
className={`status-message ${status.isError ? 'bg-red-50 text-red-800' : 'bg-emerald-50 text-emerald-800'}`}
|
| 293 |
+
>
|
| 294 |
+
{status.message}
|
| 295 |
+
</div>
|
| 296 |
+
</section>
|
| 297 |
+
|
| 298 |
+
<section className="workspace grid grid-cols-1 md:grid-cols-2 gap-5">
|
| 299 |
+
<div className="panel flex flex-col h-[520px]">
|
| 300 |
+
<div className="panel-header p-4 pb-0">
|
| 301 |
+
<h2 className="text-lg font-semibold">Texto de entrada</h2>
|
| 302 |
+
</div>
|
| 303 |
+
<textarea
|
| 304 |
+
value={inputText}
|
| 305 |
+
onChange={(e) => setInputText(e.target.value)}
|
| 306 |
+
placeholder="Pega aquí la analítica, medicación o informe..."
|
| 307 |
+
className="flex-1 m-4 p-4 border border-slate-200 rounded-xl resize-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 outline-none bg-slate-50/50"
|
| 308 |
+
spellCheck="false"
|
| 309 |
+
/>
|
| 310 |
+
</div>
|
| 311 |
+
|
| 312 |
+
<div className="panel flex flex-col h-[520px]">
|
| 313 |
+
<div className="panel-header p-4 pb-0">
|
| 314 |
+
<h2 className="text-lg font-semibold">Resultado</h2>
|
| 315 |
+
</div>
|
| 316 |
+
<textarea
|
| 317 |
+
value={outputText}
|
| 318 |
+
readOnly
|
| 319 |
+
placeholder="Aquí aparecerá el resultado ordenado..."
|
| 320 |
+
className="flex-1 m-4 p-4 border border-slate-200 rounded-xl resize-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 outline-none bg-slate-50/50"
|
| 321 |
+
spellCheck="false"
|
| 322 |
+
/>
|
| 323 |
+
</div>
|
| 324 |
+
</section>
|
| 325 |
+
</main>
|
| 326 |
+
|
| 327 |
+
{isModalOpen && (
|
| 328 |
+
<div className="modal" onClick={() => setIsModalOpen(false)}>
|
| 329 |
+
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
| 330 |
+
<div className="modal-header flex justify-between items-center p-5 border-b border-slate-100">
|
| 331 |
+
<h2 className="text-xl font-bold">Configuración de usuarios</h2>
|
| 332 |
+
<button onClick={() => setIsModalOpen(false)} className="text-2xl text-slate-400 hover:text-slate-600">✕</button>
|
| 333 |
+
</div>
|
| 334 |
+
|
| 335 |
+
<div className="modal-body p-6">
|
| 336 |
+
<div className="form-group flex flex-col gap-2 mb-4">
|
| 337 |
+
<label className="text-sm text-slate-500">Nombre del médico</label>
|
| 338 |
+
<input
|
| 339 |
+
type="text"
|
| 340 |
+
value={newUserName}
|
| 341 |
+
onChange={(e) => setNewUserName(e.target.value)}
|
| 342 |
+
placeholder="Ej: Doctor Arnal"
|
| 343 |
+
className="border border-slate-200 rounded-lg px-3 py-2 outline-none focus:border-blue-500"
|
| 344 |
+
/>
|
| 345 |
+
</div>
|
| 346 |
+
|
| 347 |
+
<div className="form-group flex flex-col gap-2 mb-4">
|
| 348 |
+
<label className="text-sm text-slate-500">API Key de Gemini</label>
|
| 349 |
+
<input
|
| 350 |
+
type="password"
|
| 351 |
+
value={newUserApiKey}
|
| 352 |
+
onChange={(e) => setNewUserApiKey(e.target.value)}
|
| 353 |
+
placeholder="Pega aquí la API key"
|
| 354 |
+
className="border border-slate-200 rounded-lg px-3 py-2 outline-none focus:border-blue-500"
|
| 355 |
+
/>
|
| 356 |
+
</div>
|
| 357 |
+
|
| 358 |
+
<div className="form-group flex flex-col gap-2 mb-6">
|
| 359 |
+
<label className="text-sm text-slate-500">PIN del usuario</label>
|
| 360 |
+
<input
|
| 361 |
+
type="password"
|
| 362 |
+
value={newUserPin}
|
| 363 |
+
onChange={(e) => setNewUserPin(e.target.value)}
|
| 364 |
+
placeholder="Mínimo 4 caracteres"
|
| 365 |
+
className="border border-slate-200 rounded-lg px-3 py-2 outline-none focus:border-blue-500"
|
| 366 |
+
/>
|
| 367 |
+
</div>
|
| 368 |
+
|
| 369 |
+
<div className="modal-actions mb-6">
|
| 370 |
+
<button
|
| 371 |
+
onClick={handleSaveUser}
|
| 372 |
+
className="w-full bg-blue-600 hover:bg-blue-700 text-white py-2 rounded-lg font-medium transition-colors"
|
| 373 |
+
>
|
| 374 |
+
Guardar usuario
|
| 375 |
+
</button>
|
| 376 |
+
</div>
|
| 377 |
+
|
| 378 |
+
<hr className="border-slate-100 mb-6" />
|
| 379 |
+
|
| 380 |
+
<div>
|
| 381 |
+
<h3 className="font-semibold mb-4">Usuarios guardados</h3>
|
| 382 |
+
<ul className="users-list space-y-3">
|
| 383 |
+
{users.map((user, index) => (
|
| 384 |
+
<li key={index} className="p-3 bg-slate-50 border border-slate-200 rounded-xl">
|
| 385 |
+
<strong className="block text-slate-900">{user.name}</strong>
|
| 386 |
+
<small className="text-slate-500">
|
| 387 |
+
API guardada: {user.encryptedApiKey ? "Sí" : "No"} | PIN: {user.pinHash ? "Sí" : "No"}
|
| 388 |
+
</small>
|
| 389 |
+
</li>
|
| 390 |
+
))}
|
| 391 |
+
{users.length === 0 && (
|
| 392 |
+
<p className="text-slate-400 text-sm italic">No hay usuarios configurados.</p>
|
| 393 |
+
)}
|
| 394 |
+
</ul>
|
| 395 |
+
</div>
|
| 396 |
+
</div>
|
| 397 |
+
</div>
|
| 398 |
+
</div>
|
| 399 |
+
)}
|
| 400 |
+
|
| 401 |
+
{isPinModalOpen && (
|
| 402 |
+
<div className="modal" onClick={() => setIsPinModalOpen(false)}>
|
| 403 |
+
<div className="modal-content max-w-sm" onClick={(e) => e.stopPropagation()}>
|
| 404 |
+
<div className="modal-header flex justify-between items-center p-5 border-b border-slate-100">
|
| 405 |
+
<h2 className="text-xl font-bold">Desbloquear Usuario</h2>
|
| 406 |
+
<button onClick={() => setIsPinModalOpen(false)} className="text-2xl text-slate-400 hover:text-slate-600">✕</button>
|
| 407 |
+
</div>
|
| 408 |
+
|
| 409 |
+
<div className="modal-body p-6">
|
| 410 |
+
<p className="text-slate-600 mb-4">
|
| 411 |
+
Introduce el PIN de <strong>{selectedUserIndex !== "" ? users[selectedUserIndex as number].name : ""}</strong> para procesar.
|
| 412 |
+
</p>
|
| 413 |
+
<div className="form-group flex flex-col gap-2 mb-6">
|
| 414 |
+
<input
|
| 415 |
+
type="password"
|
| 416 |
+
value={pinInput}
|
| 417 |
+
onChange={(e) => setPinInput(e.target.value)}
|
| 418 |
+
onKeyDown={(e) => e.key === 'Enter' && handlePinConfirm()}
|
| 419 |
+
placeholder="PIN de seguridad"
|
| 420 |
+
autoFocus
|
| 421 |
+
className="border border-slate-200 rounded-lg px-3 py-2 outline-none focus:border-blue-500 text-center text-2xl tracking-widest"
|
| 422 |
+
/>
|
| 423 |
+
</div>
|
| 424 |
+
|
| 425 |
+
<div className="modal-actions">
|
| 426 |
+
<button
|
| 427 |
+
onClick={handlePinConfirm}
|
| 428 |
+
className="w-full bg-blue-600 hover:bg-blue-700 text-white py-2 rounded-lg font-medium transition-colors"
|
| 429 |
+
>
|
| 430 |
+
Confirmar y Procesar
|
| 431 |
+
</button>
|
| 432 |
+
</div>
|
| 433 |
+
</div>
|
| 434 |
+
</div>
|
| 435 |
+
</div>
|
| 436 |
+
)}
|
| 437 |
+
</div>
|
| 438 |
+
);
|
| 439 |
+
}
|
src/index.css
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import "tailwindcss";
|
| 2 |
+
|
| 3 |
+
@theme {
|
| 4 |
+
--radius-xl: 16px;
|
| 5 |
+
--radius-2xl: 18px;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
:root {
|
| 9 |
+
--bg: #f3f6fb;
|
| 10 |
+
--panel: #ffffff;
|
| 11 |
+
--border: #d7deea;
|
| 12 |
+
--text: #1e293b;
|
| 13 |
+
--muted: #64748b;
|
| 14 |
+
--primary: #2563eb;
|
| 15 |
+
--primary-hover: #1d4ed8;
|
| 16 |
+
--success-bg: #ecfdf5;
|
| 17 |
+
--success-text: #065f46;
|
| 18 |
+
--shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
|
| 19 |
+
--radius: 16px;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
body {
|
| 23 |
+
margin: 0;
|
| 24 |
+
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
| 25 |
+
background: var(--bg);
|
| 26 |
+
color: var(--text);
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
.app-container {
|
| 30 |
+
max-width: 1400px;
|
| 31 |
+
margin: 0 auto;
|
| 32 |
+
padding: 20px;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
.app-header {
|
| 36 |
+
display: flex;
|
| 37 |
+
justify-content: space-between;
|
| 38 |
+
align-items: flex-start;
|
| 39 |
+
gap: 20px;
|
| 40 |
+
margin-bottom: 20px;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
.user-bar {
|
| 44 |
+
display: flex;
|
| 45 |
+
align-items: center;
|
| 46 |
+
gap: 10px;
|
| 47 |
+
background: var(--panel);
|
| 48 |
+
border: 1px solid var(--border);
|
| 49 |
+
border-radius: 12px;
|
| 50 |
+
padding: 12px;
|
| 51 |
+
box-shadow: var(--shadow);
|
| 52 |
+
flex-wrap: wrap;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
.status-message {
|
| 56 |
+
padding: 10px 12px;
|
| 57 |
+
border-radius: 10px;
|
| 58 |
+
background: var(--success-bg);
|
| 59 |
+
color: var(--success-text);
|
| 60 |
+
font-size: 14px;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
.panel {
|
| 64 |
+
background: var(--panel);
|
| 65 |
+
border: 1px solid var(--border);
|
| 66 |
+
border-radius: var(--radius);
|
| 67 |
+
box-shadow: var(--shadow);
|
| 68 |
+
display: flex;
|
| 69 |
+
flex-direction: column;
|
| 70 |
+
min-height: 520px;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
.modal {
|
| 74 |
+
position: fixed;
|
| 75 |
+
inset: 0;
|
| 76 |
+
background: rgba(15, 23, 42, 0.45);
|
| 77 |
+
display: flex;
|
| 78 |
+
align-items: center;
|
| 79 |
+
justify-content: center;
|
| 80 |
+
padding: 20px;
|
| 81 |
+
z-index: 50;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
.modal-content {
|
| 85 |
+
width: 100%;
|
| 86 |
+
max-width: 560px;
|
| 87 |
+
background: #fff;
|
| 88 |
+
border-radius: 18px;
|
| 89 |
+
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.25);
|
| 90 |
+
overflow: hidden;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
.users-list li {
|
| 94 |
+
padding: 12px;
|
| 95 |
+
border: 1px solid var(--border);
|
| 96 |
+
border-radius: 12px;
|
| 97 |
+
margin-bottom: 10px;
|
| 98 |
+
background: #f8fafc;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
@media (max-width: 980px) {
|
| 102 |
+
.app-header {
|
| 103 |
+
flex-direction: column;
|
| 104 |
+
align-items: stretch;
|
| 105 |
+
}
|
| 106 |
+
}
|
src/lib/anonymizer.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
export function normalizeInputText(rawText: string): string {
|
| 3 |
+
return String(rawText || "")
|
| 4 |
+
.replace(/\r/g, "\n")
|
| 5 |
+
.replace(/\bblanco\b/gi, " ")
|
| 6 |
+
.replace(/\bletras blanco\b/gi, " ")
|
| 7 |
+
.replace(/\bblancobl\b/gi, " ")
|
| 8 |
+
.replace(/[ \t]+/g, " ")
|
| 9 |
+
.replace(/\n{2,}/g, "\n")
|
| 10 |
+
.trim();
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export function removeAdministrativePatterns(text: string): string {
|
| 14 |
+
const patternsToRemove = [
|
| 15 |
+
/Nombre:.*$/gim,
|
| 16 |
+
/Número:.*$/gim,
|
| 17 |
+
/Historia:.*$/gim,
|
| 18 |
+
/T\.\s*Sanitaria:.*$/gim,
|
| 19 |
+
/Solicitante:.*$/gim,
|
| 20 |
+
/Servicio:.*$/gim,
|
| 21 |
+
/Destino[s]?:.*$/gim,
|
| 22 |
+
/Destinos:.*$/gim,
|
| 23 |
+
/Centro:.*$/gim,
|
| 24 |
+
/Sexo:.*$/gim,
|
| 25 |
+
/Edad:.*$/gim,
|
| 26 |
+
/Habitación:.*$/gim,
|
| 27 |
+
/Cama:.*$/gim,
|
| 28 |
+
/Recep\.?Muestra.*$/gim,
|
| 29 |
+
/Fch\.?Informe.*$/gim,
|
| 30 |
+
/Fecha de análisis:.*$/gim,
|
| 31 |
+
/Resultados validados por:.*$/gim,
|
| 32 |
+
/Tipo de Muestra:.*$/gim,
|
| 33 |
+
/Tipo de informe:.*$/gim,
|
| 34 |
+
/Página\s*\d+\/\d+/gim,
|
| 35 |
+
/Hospital Ernest Lluch.*$/gim,
|
| 36 |
+
/H\.\s*ERNEST LLUCH.*$/gim,
|
| 37 |
+
/INFORME DE RESULTADOS.*$/gim,
|
| 38 |
+
/Calatayud,.*$/gim,
|
| 39 |
+
/Tecnica:.*$/gim,
|
| 40 |
+
/T[eé]cnica:.*$/gim,
|
| 41 |
+
/_{5,}/g
|
| 42 |
+
];
|
| 43 |
+
|
| 44 |
+
let cleaned = text;
|
| 45 |
+
patternsToRemove.forEach((rx) => {
|
| 46 |
+
cleaned = cleaned.replace(rx, "");
|
| 47 |
+
});
|
| 48 |
+
|
| 49 |
+
return cleaned;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
export function removeDirectIdentifiers(text: string): string {
|
| 53 |
+
return text
|
| 54 |
+
.replace(/\b[A-Z]{2}\d{9,}[A-Z]?\b/g, " ")
|
| 55 |
+
.replace(/\b\d{6,}\b/g, " ")
|
| 56 |
+
.replace(/\b\d{1,2}\/\d{1,2}\/\d{2,4}\b/g, " ")
|
| 57 |
+
.replace(/\b\d{1,2}:\d{2}:\d{2}\b/g, " ")
|
| 58 |
+
.replace(/\b[A-Z]{2,}-[A-Z]{2,}\b/g, " ")
|
| 59 |
+
.replace(/[A-ZÁÉÍÓÚÑ]{2,},\s*[A-ZÁÉÍÓÚÑ\s]{2,}/g, " ");
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
export function keepUsefulLabLines(lines: string[]): string[] {
|
| 63 |
+
const allowedSectionPatterns = [
|
| 64 |
+
/^BIOQUIMICA$/i,
|
| 65 |
+
/^BIOQUIMICA GENERAL$/i,
|
| 66 |
+
/^HEMATOLOGIA$/i,
|
| 67 |
+
/^HEMATIMETRIA$/i,
|
| 68 |
+
/^HEMOSTASIA$/i,
|
| 69 |
+
/^GASOMETRIA$/i,
|
| 70 |
+
/^ORINA$/i,
|
| 71 |
+
/^COAGULACION$/i,
|
| 72 |
+
/^OTRAS PRUEBAS:?$/i
|
| 73 |
+
];
|
| 74 |
+
|
| 75 |
+
const obviousNoisePatterns = [
|
| 76 |
+
/^Magnitud Resultado Unidades Intervalo de Referencia Biológico$/i,
|
| 77 |
+
/^Tipo de informe:.*$/i,
|
| 78 |
+
/^Resultados validados por:.*$/i,
|
| 79 |
+
/^Tipo de Muestra:.*$/i,
|
| 80 |
+
/^\.+$/,
|
| 81 |
+
/^-$/,
|
| 82 |
+
/^Hospital .*$/i,
|
| 83 |
+
/^INFORME DE RESULTADOS$/i,
|
| 84 |
+
/^Nombre:.*$/i,
|
| 85 |
+
/^Historia:.*$/i,
|
| 86 |
+
/^Sexo:.*$/i,
|
| 87 |
+
/^Edad:.*$/i,
|
| 88 |
+
/^Recep\.Muestra.*$/i,
|
| 89 |
+
/^Fch\.Informe.*$/i
|
| 90 |
+
];
|
| 91 |
+
|
| 92 |
+
return lines.filter((line) => {
|
| 93 |
+
if (!line || line.length < 2) return false;
|
| 94 |
+
if (obviousNoisePatterns.some((rx) => rx.test(line))) return false;
|
| 95 |
+
if (allowedSectionPatterns.some((rx) => rx.test(line))) return true;
|
| 96 |
+
|
| 97 |
+
const hasPend = /\bPEND\b/i.test(line);
|
| 98 |
+
const hasValue = /\b\d+[.,]?\d*\b/.test(line);
|
| 99 |
+
const hasRange = /\b\d+[.,]?\d*\s*-\s*\d+[.,]?\d*\b/.test(line);
|
| 100 |
+
const hasUnits =
|
| 101 |
+
/\b(mg\/dL|g\/dL|mmol\/L|mEq\/L|mil\/mm3|mill\/mm3|fl|pg|%|u\/L|ui\/L|ng\/mL|mL\/min\/1\.73m\^?2|mL\/min|seg|s|mill|mil|pg|fl)\b/i.test(
|
| 102 |
+
line
|
| 103 |
+
);
|
| 104 |
+
|
| 105 |
+
// If it has a value and units, or a value and a range, or is a section header, keep it.
|
| 106 |
+
// Also keep lines that look like parameters if they are reasonably long.
|
| 107 |
+
const looksLikeParameter = /^[A-ZÁÉÍÓÚÑ][A-ZÁÉÍÓÚÑ0-9\s\.\-\(\)\/]{3,}$/i.test(line);
|
| 108 |
+
|
| 109 |
+
return hasPend || hasRange || hasUnits || hasValue || looksLikeParameter;
|
| 110 |
+
});
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
export function finalizeCleanLines(text: string): string[] {
|
| 114 |
+
return text
|
| 115 |
+
.split("\n")
|
| 116 |
+
.map((line) => line.trim())
|
| 117 |
+
.map((line) => line.replace(/[ \t]+/g, " ").trim())
|
| 118 |
+
.filter(Boolean);
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
export function anonymizeLabsText(rawText: string): string {
|
| 122 |
+
let text = normalizeInputText(rawText);
|
| 123 |
+
text = removeAdministrativePatterns(text);
|
| 124 |
+
text = removeDirectIdentifiers(text);
|
| 125 |
+
|
| 126 |
+
const cleanedLines = finalizeCleanLines(text);
|
| 127 |
+
const usefulLines = keepUsefulLabLines(cleanedLines);
|
| 128 |
+
|
| 129 |
+
const result = usefulLines.join("\n").trim();
|
| 130 |
+
console.log("Anonymized Labs Text length:", result.length);
|
| 131 |
+
return result;
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
export function anonymizeTreatmentsText(rawText: string): string {
|
| 135 |
+
let text = normalizeInputText(rawText);
|
| 136 |
+
text = removeAdministrativePatterns(text);
|
| 137 |
+
text = removeDirectIdentifiers(text);
|
| 138 |
+
|
| 139 |
+
const cleanedLines = finalizeCleanLines(text).filter((line) => {
|
| 140 |
+
const hasDose =
|
| 141 |
+
/\b\d+[.,]?\d*\s?(mg|mcg|g|ui|ml|ug)\b/i.test(line);
|
| 142 |
+
const hasFrequency =
|
| 143 |
+
/(cada\s+\d+\s*h|cada\s+\d+\s*horas|al d[ií]a|si precisa|por la noche|por la ma[ñn]ana|semanal|q\d+h)/i.test(
|
| 144 |
+
line
|
| 145 |
+
);
|
| 146 |
+
const looksLikeDrug =
|
| 147 |
+
/^[A-ZÁÉÍÓÚÑa-záéíóúñ][A-ZÁÉÍÓÚÑa-záéíóúñ0-9\s\/\.\-]+$/i.test(line);
|
| 148 |
+
|
| 149 |
+
return looksLikeDrug || hasDose || hasFrequency;
|
| 150 |
+
});
|
| 151 |
+
|
| 152 |
+
const result = cleanedLines.join("\n").trim();
|
| 153 |
+
console.log("Anonymized Treatments Text length:", result.length);
|
| 154 |
+
return result;
|
| 155 |
+
}
|
src/lib/gemini.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import { GoogleGenAI, ThinkingLevel } from "@google/genai";
|
| 3 |
+
import { anonymizeLabsText, anonymizeTreatmentsText } from "./anonymizer";
|
| 4 |
+
|
| 5 |
+
const GEMINI_MODEL = "gemini-2.0-flash";
|
| 6 |
+
|
| 7 |
+
const XLABS_PROMPT = `Eres un asistente para ordenar resultados de laboratorio en formato XLabs, únicamente a partir del texto que el usuario pega. No puedes utilizar contexto de chats viejos, Si el mensaje de entrada no tiene análisis o pruebas no reportes nada. Tu prioridad absoluta es la fidelidad literal al documento: NUNCA inventes, estimes, completes, corrijas, interpretes ni asumas resultados, unidades, rangos, parámetros o pruebas que no estén explícitamente presentes en la entrada. Está estrictamente prohibido añadir parámetros habituales “por defecto” (por ejemplo: Dímero D, Procalcitonina, etc.) si no aparecen literalmente en el texto proporcionado.
|
| 8 |
+
|
| 9 |
+
Formato XLabs (categorías en este orden, cada una en una sola línea continua y solo si contiene datos válidos):
|
| 10 |
+
Hematología
|
| 11 |
+
Coagulación
|
| 12 |
+
Bioquímica
|
| 13 |
+
Gasometría
|
| 14 |
+
Orina
|
| 15 |
+
Otras pruebas:
|
| 16 |
+
|
| 17 |
+
Salida siempre en español y con este patrón exacto:
|
| 18 |
+
Categoría: Parámetro Resultado unidades | Parámetro Resultado unidades | ...
|
| 19 |
+
|
| 20 |
+
Reglas estrictas anti-invención:
|
| 21 |
+
- Solo incluye parámetros que aparezcan de forma explícita y con resultado y unidades visibles en la entrada.
|
| 22 |
+
- Si falta el resultado o la unidad, NO lo incluyas.
|
| 23 |
+
- No conviertas unidades, no cambies notaciones (ej.: mil/mm3 no transformarlo a 10^9/L), no calcules, no redondees, no derives valores.
|
| 24 |
+
- No normalices nombres si no son inequívocos.
|
| 25 |
+
- No agregues pruebas relacionadas aunque sean clínicamente habituales.
|
| 26 |
+
- Si un parámetro aparece como “PEND” o sin resultado numérico válido, omítelo.
|
| 27 |
+
- Si la entrada no contiene ningún dato válido para una categoría, no muestres esa categoría.
|
| 28 |
+
- Si no hay ningún parámetro extraíble en todo el texto, responde exactamente: “Sin datos de laboratorio extraíbles.”
|
| 29 |
+
|
| 30 |
+
Detección de valores alterados:
|
| 31 |
+
- Solo marca con un asterisco al final (*) cuando exista un rango de referencia explícito para ese mismo parámetro en la entrada y el valor esté fuera de ese rango (ejemplo: 135 mg/dL*).
|
| 32 |
+
- NO uses doble asterisco (**).
|
| 33 |
+
- No marques nada si no hay rango explícito.
|
| 34 |
+
- No infieras alteración por símbolos externos si el rango no está presente.
|
| 35 |
+
|
| 36 |
+
Reglas específicas por sección:
|
| 37 |
+
Hematología:
|
| 38 |
+
- Incluir solo: Hematíes, Hemoglobina, Hematocrito; luego VCM y HCM.
|
| 39 |
+
- Elementos celulares: Leucocitos Totales, luego mostrar primero el porcentaje y entre paréntesis el valor absoluto si ambos aparecen explícitamente en la entrada de cada elemento.
|
| 40 |
+
- No incluir Eosinófilos ni Basófilos si están dentro de rango y el rango está explícito.
|
| 41 |
+
- Incluir Plaquetas; no incluir VPM ni otros índices plaquetarios.
|
| 42 |
+
|
| 43 |
+
Coagulación:
|
| 44 |
+
- Resumir exclusivamente usando: INR, PT y PTT.
|
| 45 |
+
- Mapear únicamente desde: “TIEMPO DE PROTROMBINA”, “INR-TP” y “T. TROMBOPLASTINA PARCIAL ACTIV.” si aparecen explícitamente.
|
| 46 |
+
- No incluir fibrinógeno ni ratios adicionales aquí; solo irán en “Otras pruebas” si corresponde.
|
| 47 |
+
|
| 48 |
+
Otras pruebas:
|
| 49 |
+
- Incluir solo parámetros explícitos que no encajen en las categorías anteriores. Recuerda en la gasometría incluir el ph si está disponible.
|
| 50 |
+
|
| 51 |
+
Estilo:
|
| 52 |
+
- Sin introducciones, sin explicaciones, sin comentarios.
|
| 53 |
+
- Solo el bloque formateado.
|
| 54 |
+
- Gramática correcta y capitalización adecuada.
|
| 55 |
+
|
| 56 |
+
Si se introducen datos de un estudio paraclínico como una ecografía TAC.. ETC... repórtalo con el mismo formato. Por ejemplo "Ecografía abdominal : Esteatosis hepática | Colecistectomía"
|
| 57 |
+
|
| 58 |
+
Cada mensaje corresponde a un paciente distinto; nunca mezclar información entre mensajes.`;
|
| 59 |
+
|
| 60 |
+
const TREATMENTS_PROMPT = `Eres "Tratamiento Fácil", un asistente especializado en reformatear y organizar listas de medicamentos de manera muy específica.
|
| 61 |
+
|
| 62 |
+
Tu tarea es convertir descripciones detalladas de medicamentos en formatos sumamente concisos y fieles al texto original.
|
| 63 |
+
|
| 64 |
+
Ejemplos:
|
| 65 |
+
- "Amlodipino 5mg - 30 comprimidos: 1 comprimido cada 24 horas" -> "Amlodipino 5mg: 1 al día"
|
| 66 |
+
- "Enantyum 25mg - 20 cápsulas duras: 1 cápsula cada 8 horas" -> "Enantyum 25mg: 1 cada 8h"
|
| 67 |
+
|
| 68 |
+
Reglas estrictas:
|
| 69 |
+
- Salida siempre en español.
|
| 70 |
+
- Sin introducciones, sin explicaciones, sin comentarios, sin consejos médicos.
|
| 71 |
+
- Presenta solo dosis y frecuencia de forma directa.
|
| 72 |
+
- Elimina detalles innecesarios como número de comprimidos, envase, forma farmacéutica o texto accesorio si no aportan a la pauta.
|
| 73 |
+
- Mantén únicamente lo explícito en la entrada.
|
| 74 |
+
- No inventes, no completes, no interpretes y no corrijas.
|
| 75 |
+
- Ordena alfabéticamente por nombre del fármaco.
|
| 76 |
+
- Devuelve el resultado en una sola línea.
|
| 77 |
+
- Separa cada fármaco con una barra vertical: |
|
| 78 |
+
- Formato exacto: Fármaco dosis: pauta
|
| 79 |
+
- La primera letra de cada fármaco en mayúscula y el resto normal.
|
| 80 |
+
- Si no hay tratamientos válidos extraíbles, responde exactamente: "Sin tratamientos extraíbles."`;
|
| 81 |
+
|
| 82 |
+
async function callGemini(apiKey: string, systemPrompt: string, userText: string) {
|
| 83 |
+
console.log("Calling Gemini API...");
|
| 84 |
+
try {
|
| 85 |
+
const ai = new GoogleGenAI({ apiKey });
|
| 86 |
+
|
| 87 |
+
// Create a promise that rejects after 60 seconds
|
| 88 |
+
const timeoutPromise = new Promise((_, reject) => {
|
| 89 |
+
setTimeout(() => reject(new Error("La solicitud a la IA ha tardado demasiado (tiempo de espera agotado). Esto puede deberse a una conexión lenta o a que el texto es muy extenso.")), 60000);
|
| 90 |
+
});
|
| 91 |
+
|
| 92 |
+
const apiCallPromise = ai.models.generateContent({
|
| 93 |
+
model: GEMINI_MODEL,
|
| 94 |
+
contents: [{
|
| 95 |
+
parts: [{
|
| 96 |
+
text: `${systemPrompt}\n\n---INICIO_TEXTO---\n${userText}\n---FIN_TEXTO---`
|
| 97 |
+
}]
|
| 98 |
+
}],
|
| 99 |
+
config: {
|
| 100 |
+
temperature: 0.1,
|
| 101 |
+
}
|
| 102 |
+
});
|
| 103 |
+
|
| 104 |
+
const response = await Promise.race([apiCallPromise, timeoutPromise]) as any;
|
| 105 |
+
console.log("Gemini API response received.");
|
| 106 |
+
|
| 107 |
+
const text = response.text;
|
| 108 |
+
|
| 109 |
+
if (!text) {
|
| 110 |
+
return "El modelo no devolvió ningún resultado. Por favor, intenta con un texto más claro o revisa tu API Key.";
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
return text;
|
| 114 |
+
} catch (error: any) {
|
| 115 |
+
console.error("Gemini API Error:", error);
|
| 116 |
+
if (error.message?.includes("API_KEY_INVALID")) {
|
| 117 |
+
throw new Error("La API Key proporcionada no es válida.");
|
| 118 |
+
}
|
| 119 |
+
if (error.message?.includes("quota")) {
|
| 120 |
+
throw new Error("Se ha superado la cuota de la API Key.");
|
| 121 |
+
}
|
| 122 |
+
if (error.message?.includes("Failed to fetch") || error.message?.includes("NetworkError") || error.message?.includes("Load failed")) {
|
| 123 |
+
throw new Error("Error de red: Tu navegador o un bloqueador de publicidad (AdBlock, uBlock) está impidiendo la conexión inmediata con la IA. Por favor, desactívalo para esta web.");
|
| 124 |
+
}
|
| 125 |
+
throw new Error(`Error de comunicación con la IA: ${error.message || "Error desconocido"}`);
|
| 126 |
+
}
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
export async function processLabsWithGemini(apiKey: string, rawText: string) {
|
| 130 |
+
const sanitized = anonymizeLabsText(rawText);
|
| 131 |
+
if (!sanitized) {
|
| 132 |
+
return "Sin datos de laboratorio extraíbles.";
|
| 133 |
+
}
|
| 134 |
+
return await callGemini(apiKey, XLABS_PROMPT, sanitized);
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
export async function processTreatmentsWithGemini(apiKey: string, rawText: string) {
|
| 138 |
+
const sanitized = anonymizeTreatmentsText(rawText);
|
| 139 |
+
if (!sanitized) {
|
| 140 |
+
return "Sin tratamientos extraíbles.";
|
| 141 |
+
}
|
| 142 |
+
return await callGemini(apiKey, TREATMENTS_PROMPT, sanitized);
|
| 143 |
+
}
|
src/main.tsx
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {StrictMode} from 'react';
|
| 2 |
+
import {createRoot} from 'react-dom/client';
|
| 3 |
+
import App from './App.tsx';
|
| 4 |
+
import './index.css';
|
| 5 |
+
|
| 6 |
+
createRoot(document.getElementById('root')!).render(
|
| 7 |
+
<StrictMode>
|
| 8 |
+
<App />
|
| 9 |
+
</StrictMode>,
|
| 10 |
+
);
|
src/types.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
export interface User {
|
| 3 |
+
name: string;
|
| 4 |
+
encryptedApiKey: string;
|
| 5 |
+
pinHash: string;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
export type Mode = "labs" | "treatments";
|
tsconfig.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"target": "ES2022",
|
| 4 |
+
"experimentalDecorators": true,
|
| 5 |
+
"useDefineForClassFields": false,
|
| 6 |
+
"module": "ESNext",
|
| 7 |
+
"lib": [
|
| 8 |
+
"ES2022",
|
| 9 |
+
"DOM",
|
| 10 |
+
"DOM.Iterable"
|
| 11 |
+
],
|
| 12 |
+
"skipLibCheck": true,
|
| 13 |
+
"moduleResolution": "bundler",
|
| 14 |
+
"isolatedModules": true,
|
| 15 |
+
"moduleDetection": "force",
|
| 16 |
+
"allowJs": true,
|
| 17 |
+
"jsx": "react-jsx",
|
| 18 |
+
"paths": {
|
| 19 |
+
"@/*": [
|
| 20 |
+
"./*"
|
| 21 |
+
]
|
| 22 |
+
},
|
| 23 |
+
"allowImportingTsExtensions": true,
|
| 24 |
+
"noEmit": true
|
| 25 |
+
}
|
| 26 |
+
}
|
vite.config.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import tailwindcss from '@tailwindcss/vite';
|
| 2 |
+
import react from '@vitejs/plugin-react';
|
| 3 |
+
import path from 'path';
|
| 4 |
+
import {defineConfig, loadEnv} from 'vite';
|
| 5 |
+
|
| 6 |
+
export default defineConfig(({mode}) => {
|
| 7 |
+
const env = loadEnv(mode, '.', '');
|
| 8 |
+
return {
|
| 9 |
+
plugins: [react(), tailwindcss()],
|
| 10 |
+
define: {
|
| 11 |
+
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY),
|
| 12 |
+
},
|
| 13 |
+
resolve: {
|
| 14 |
+
alias: {
|
| 15 |
+
'@': path.resolve(__dirname, '.'),
|
| 16 |
+
},
|
| 17 |
+
},
|
| 18 |
+
server: {
|
| 19 |
+
// HMR is disabled in AI Studio via DISABLE_HMR env var.
|
| 20 |
+
// Do not modifyâfile watching is disabled to prevent flickering during agent edits.
|
| 21 |
+
hmr: process.env.DISABLE_HMR !== 'true',
|
| 22 |
+
},
|
| 23 |
+
};
|
| 24 |
+
});
|