aarnal80 commited on
Commit
2a40140
·
verified ·
1 Parent(s): e316153

Upload 17 files

Browse files
Files changed (17) hide show
  1. .env.example +9 -0
  2. .gitignore +8 -0
  3. README.md +20 -10
  4. index.html +25 -0
  5. metadata.json +5 -0
  6. package-lock.json +0 -0
  7. package.json +34 -0
  8. public/manifest.json +17 -0
  9. public/sw.js +12 -0
  10. src/App.tsx +439 -0
  11. src/index.css +106 -0
  12. src/lib/anonymizer.ts +155 -0
  13. src/lib/gemini.ts +143 -0
  14. src/main.tsx +10 -0
  15. src/types.ts +8 -0
  16. tsconfig.json +26 -0
  17. 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
- title: HellApp
3
- emoji: 🐨
4
- colorFrom: indigo
5
- colorTo: green
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
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
+ });