github-actions[bot] commited on
Commit
4dc72d3
·
0 Parent(s):

Deploy MediBot from ac379a12

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env.example +26 -0
  2. .gitignore +6 -0
  3. Dockerfile +56 -0
  4. Makefile +195 -0
  5. README.md +95 -0
  6. app/admin/page.tsx +327 -0
  7. app/api/admin/config/route.ts +121 -0
  8. app/api/admin/fetch-models/route.ts +423 -0
  9. app/api/admin/llm-health/route.ts +127 -0
  10. app/api/admin/reset-password/route.ts +62 -0
  11. app/api/admin/stats/route.ts +46 -0
  12. app/api/admin/test-connection/route.ts +243 -0
  13. app/api/admin/users/route.ts +78 -0
  14. app/api/auth/delete-account/route.ts +80 -0
  15. app/api/auth/forgot-password/route.ts +44 -0
  16. app/api/auth/login/route.ts +62 -0
  17. app/api/auth/logout/route.ts +14 -0
  18. app/api/auth/me/route.ts +32 -0
  19. app/api/auth/register/route.ts +68 -0
  20. app/api/auth/resend-verification/route.ts +28 -0
  21. app/api/auth/reset-password/route.ts +63 -0
  22. app/api/auth/verify-email/route.ts +58 -0
  23. app/api/chat-history/route.ts +95 -0
  24. app/api/chat/route.ts +173 -0
  25. app/api/geo/route.ts +142 -0
  26. app/api/health-data/route.ts +113 -0
  27. app/api/health-data/sync/route.ts +75 -0
  28. app/api/health/route.ts +10 -0
  29. app/api/models/route.ts +14 -0
  30. app/api/nearby/route.ts +94 -0
  31. app/api/og/route.tsx +207 -0
  32. app/api/rag/route.ts +30 -0
  33. app/api/scan/route.ts +80 -0
  34. app/api/sessions/route.ts +70 -0
  35. app/api/triage/route.ts +29 -0
  36. app/globals.css +378 -0
  37. app/icon.svg +10 -0
  38. app/layout.tsx +78 -0
  39. app/manifest.ts +43 -0
  40. app/page.tsx +2 -0
  41. app/robots.ts +23 -0
  42. app/sitemap.ts +27 -0
  43. app/stats/page.tsx +245 -0
  44. app/symptoms/[slug]/page.tsx +237 -0
  45. app/symptoms/page.tsx +86 -0
  46. components/MedOSApp.tsx +560 -0
  47. components/ThemeProvider.tsx +76 -0
  48. components/ThemeToggle.tsx +41 -0
  49. components/WelcomeScreen.tsx +177 -0
  50. components/chat/AppDrawer.tsx +234 -0
.env.example ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ============================================================
2
+ # MedOS HuggingFace Space — full backend configuration
3
+ # ============================================================
4
+
5
+ # --- LLM providers ---
6
+ OLLABRIDGE_URL=https://ruslanmv-ollabridge.hf.space
7
+ OLLABRIDGE_API_KEY=sk-ollabridge-your-key-here
8
+ HF_TOKEN=hf_your-token-here
9
+ DEFAULT_MODEL=free-best
10
+
11
+ # --- Database (SQLite, persistent on HF Spaces /data/) ---
12
+ DB_PATH=/data/medos.db
13
+
14
+ # --- CORS (comma-separated Vercel frontend origins) ---
15
+ ALLOWED_ORIGINS=https://your-vercel-app.vercel.app,http://localhost:3000
16
+
17
+ # --- Medicine Scanner proxy ---
18
+ # Token with "Make calls to Inference Providers" permission.
19
+ # Used server-side only — never exposed to browser.
20
+ # Create at: https://huggingface.co/settings/tokens/new?ownUserPermissions=inference.serverless.write&tokenType=fineGrained
21
+ HF_TOKEN_INFERENCE=hf_your-inference-token-here
22
+ SCANNER_URL=https://ruslanmv-medicine-scanner.hf.space
23
+
24
+ # --- Application ---
25
+ NODE_ENV=production
26
+ PORT=7860
.gitignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ node_modules/
2
+ .next/
3
+ out/
4
+ .env
5
+ .env.local
6
+ *.tsbuildinfo
Dockerfile ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ============================================================
2
+ # MedOS HuggingFace Space — Production Dockerfile
3
+ #
4
+ # Enterprise architecture:
5
+ # web/ = frontend source of truth
6
+ # 9-HuggingFace-Global/ = backend + synced frontend
7
+ #
8
+ # Before deploying, run: bash scripts/sync-frontend.sh
9
+ # This copies web/ frontend, rewrites API paths, then you push.
10
+ # ============================================================
11
+
12
+ # Stage 1: Install dependencies
13
+ FROM node:18-alpine AS deps
14
+ WORKDIR /app
15
+ RUN apk add --no-cache python3 make g++
16
+ COPY package.json ./
17
+ RUN npm install --legacy-peer-deps && npm cache clean --force
18
+
19
+ # Stage 2: Build
20
+ FROM node:18-alpine AS builder
21
+ WORKDIR /app
22
+ COPY --from=deps /app/node_modules ./node_modules
23
+ COPY . .
24
+
25
+ ENV NEXT_TELEMETRY_DISABLED=1
26
+ ENV NODE_ENV=production
27
+
28
+ RUN npm run build
29
+
30
+ # Stage 3: Production runner
31
+ FROM node:18-alpine AS runner
32
+ WORKDIR /app
33
+
34
+ ENV NODE_ENV=production
35
+ ENV NEXT_TELEMETRY_DISABLED=1
36
+ ENV PORT=7860
37
+ ENV HOSTNAME=0.0.0.0
38
+
39
+ RUN addgroup --system --gid 1001 nodejs && \
40
+ adduser --system --uid 1001 nextjs
41
+
42
+ COPY --from=builder /app/public ./public
43
+ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
44
+ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
45
+ COPY --from=builder --chown=nextjs:nodejs /app/data ./data
46
+
47
+ RUN mkdir -p /data && chown nextjs:nodejs /data
48
+ ENV DB_PATH=/data/medos.db
49
+
50
+ USER nextjs
51
+ EXPOSE 7860
52
+
53
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=90s --retries=3 \
54
+ CMD wget --no-verbose --tries=1 --spider http://localhost:7860/api/health || exit 1
55
+
56
+ CMD ["node", "server.js"]
Makefile ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # =============================================================================
2
+ # MedOS Global - Makefile
3
+ # Free AI Medical Assistant for Hugging Face Spaces
4
+ # =============================================================================
5
+
6
+ SHELL := /bin/bash
7
+ .DEFAULT_GOAL := help
8
+
9
+ # Directories
10
+ APP_DIR := 9-HuggingFace-Global
11
+ NODE_MODULES := $(APP_DIR)/node_modules
12
+
13
+ # Colors
14
+ GREEN := \033[0;32m
15
+ YELLOW := \033[0;33m
16
+ RED := \033[0;31m
17
+ NC := \033[0m
18
+
19
+ # =============================================================================
20
+ # Help
21
+ # =============================================================================
22
+
23
+ .PHONY: help
24
+ help: ## Show this help message
25
+ @echo ""
26
+ @echo " MedOS Global - Development Commands"
27
+ @echo " ===================================="
28
+ @echo ""
29
+ @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \
30
+ awk 'BEGIN {FS = ":.*?## "}; {printf " $(GREEN)%-20s$(NC) %s\n", $$1, $$2}'
31
+ @echo ""
32
+
33
+ # =============================================================================
34
+ # Installation
35
+ # =============================================================================
36
+
37
+ .PHONY: install
38
+ install: ## Install all dependencies
39
+ @echo "$(GREEN)Installing dependencies...$(NC)"
40
+ cd $(APP_DIR) && npm install
41
+ @echo "$(GREEN)Dependencies installed successfully.$(NC)"
42
+
43
+ .PHONY: install-ci
44
+ install-ci: ## Install dependencies for CI (clean, reproducible)
45
+ @echo "$(GREEN)Installing dependencies (CI mode)...$(NC)"
46
+ cd $(APP_DIR) && npm ci
47
+ @echo "$(GREEN)CI dependencies installed.$(NC)"
48
+
49
+ .PHONY: clean
50
+ clean: ## Remove node_modules and build artifacts
51
+ @echo "$(YELLOW)Cleaning build artifacts...$(NC)"
52
+ rm -rf $(APP_DIR)/node_modules
53
+ rm -rf $(APP_DIR)/.next
54
+ rm -rf $(APP_DIR)/out
55
+ @echo "$(GREEN)Clean complete.$(NC)"
56
+
57
+ # =============================================================================
58
+ # Quality Checks
59
+ # =============================================================================
60
+
61
+ .PHONY: lint
62
+ lint: ## Run ESLint
63
+ @echo "$(GREEN)Running linter...$(NC)"
64
+ cd $(APP_DIR) && npx next lint
65
+
66
+ .PHONY: type-check
67
+ type-check: ## Run TypeScript type checking
68
+ @echo "$(GREEN)Running type checker...$(NC)"
69
+ cd $(APP_DIR) && npx tsc --noEmit
70
+
71
+ .PHONY: format-check
72
+ format-check: ## Check code formatting
73
+ @echo "$(GREEN)Checking code formatting...$(NC)"
74
+ cd $(APP_DIR) && npx prettier --check "**/*.{ts,tsx,js,json,css}" 2>/dev/null || echo "Prettier not configured - skipping"
75
+
76
+ # =============================================================================
77
+ # Testing
78
+ # =============================================================================
79
+
80
+ .PHONY: test
81
+ test: test-unit test-structure test-providers test-i18n test-safety ## Run all tests
82
+ @echo "$(GREEN)All tests passed!$(NC)"
83
+
84
+ .PHONY: test-unit
85
+ test-unit: ## Run unit tests
86
+ @echo "$(GREEN)Running unit tests...$(NC)"
87
+ cd $(APP_DIR) && node --experimental-vm-modules ../tests/9-hf-global/run-tests.js
88
+
89
+ .PHONY: test-structure
90
+ test-structure: ## Verify folder structure and required files exist
91
+ @echo "$(GREEN)Verifying folder structure...$(NC)"
92
+ @test -f $(APP_DIR)/Dockerfile || (echo "$(RED)FAIL: Dockerfile missing$(NC)" && exit 1)
93
+ @test -f $(APP_DIR)/package.json || (echo "$(RED)FAIL: package.json missing$(NC)" && exit 1)
94
+ @test -f $(APP_DIR)/next.config.js || (echo "$(RED)FAIL: next.config.js missing$(NC)" && exit 1)
95
+ @test -f $(APP_DIR)/tsconfig.json || (echo "$(RED)FAIL: tsconfig.json missing$(NC)" && exit 1)
96
+ @test -f $(APP_DIR)/tailwind.config.ts || (echo "$(RED)FAIL: tailwind.config.ts missing$(NC)" && exit 1)
97
+ @test -f $(APP_DIR)/README.md || (echo "$(RED)FAIL: README.md missing$(NC)" && exit 1)
98
+ @test -f $(APP_DIR)/app/layout.tsx || (echo "$(RED)FAIL: app/layout.tsx missing$(NC)" && exit 1)
99
+ @test -f $(APP_DIR)/app/page.tsx || (echo "$(RED)FAIL: app/page.tsx missing$(NC)" && exit 1)
100
+ @test -f $(APP_DIR)/app/globals.css || (echo "$(RED)FAIL: app/globals.css missing$(NC)" && exit 1)
101
+ @test -f $(APP_DIR)/app/api/chat/route.ts || (echo "$(RED)FAIL: chat API route missing$(NC)" && exit 1)
102
+ @test -f $(APP_DIR)/app/api/triage/route.ts || (echo "$(RED)FAIL: triage API route missing$(NC)" && exit 1)
103
+ @test -f $(APP_DIR)/app/api/health/route.ts || (echo "$(RED)FAIL: health API route missing$(NC)" && exit 1)
104
+ @test -f $(APP_DIR)/public/manifest.json || (echo "$(RED)FAIL: manifest.json missing$(NC)" && exit 1)
105
+ @test -f $(APP_DIR)/public/sw.js || (echo "$(RED)FAIL: sw.js missing$(NC)" && exit 1)
106
+ @test -f $(APP_DIR)/Dockerfile || (echo "$(RED)FAIL: Dockerfile missing$(NC)" && exit 1)
107
+ @echo "$(GREEN) Structure check passed (15/15 files verified).$(NC)"
108
+
109
+ .PHONY: test-providers
110
+ test-providers: ## Test provider modules exist and export correctly
111
+ @echo "$(GREEN)Verifying provider modules...$(NC)"
112
+ @test -f $(APP_DIR)/lib/providers/index.ts || (echo "$(RED)FAIL: providers/index.ts missing$(NC)" && exit 1)
113
+ @test -f $(APP_DIR)/lib/providers/ollabridge.ts || (echo "$(RED)FAIL: ollabridge.ts missing$(NC)" && exit 1)
114
+ @test -f $(APP_DIR)/lib/providers/huggingface-direct.ts || (echo "$(RED)FAIL: huggingface-direct.ts missing$(NC)" && exit 1)
115
+ @test -f $(APP_DIR)/lib/providers/cached-faq.ts || (echo "$(RED)FAIL: cached-faq.ts missing$(NC)" && exit 1)
116
+ @test -f $(APP_DIR)/lib/providers/ollabridge-models.ts || (echo "$(RED)FAIL: ollabridge-models.ts missing$(NC)" && exit 1)
117
+ @echo "$(GREEN) Provider modules verified (5/5).$(NC)"
118
+
119
+ .PHONY: test-i18n
120
+ test-i18n: ## Verify i18n translations exist (synced or in web/ source)
121
+ @echo "$(GREEN)Verifying i18n...$(NC)"
122
+ @if test -f $(APP_DIR)/lib/i18n.ts; then \
123
+ echo "$(GREEN) Found synced lib/i18n.ts$(NC)"; \
124
+ elif test -f web/lib/i18n.ts; then \
125
+ echo "$(GREEN) Found web/lib/i18n.ts (source of truth)$(NC)"; \
126
+ else \
127
+ echo "$(RED)FAIL: i18n.ts not found in lib/ or web/lib/$(NC)" && exit 1; \
128
+ fi
129
+
130
+ .PHONY: test-safety
131
+ test-safety: ## Verify safety modules exist
132
+ @echo "$(GREEN)Verifying safety modules...$(NC)"
133
+ @test -f $(APP_DIR)/lib/safety/triage.ts || (echo "$(RED)FAIL: triage.ts missing$(NC)" && exit 1)
134
+ @test -f $(APP_DIR)/lib/safety/emergency-numbers.ts || (echo "$(RED)FAIL: emergency-numbers.ts missing$(NC)" && exit 1)
135
+ @test -f $(APP_DIR)/lib/safety/disclaimer.ts || (echo "$(RED)FAIL: disclaimer.ts missing$(NC)" && exit 1)
136
+ @echo "$(GREEN) Safety modules verified (3/3).$(NC)"
137
+
138
+ # =============================================================================
139
+ # Build
140
+ # =============================================================================
141
+
142
+ .PHONY: build
143
+ build: ## Build the Next.js application
144
+ @echo "$(GREEN)Building application...$(NC)"
145
+ cd $(APP_DIR) && npm run build
146
+ @echo "$(GREEN)Build complete.$(NC)"
147
+
148
+ .PHONY: docker-build
149
+ docker-build: ## Build Docker image
150
+ @echo "$(GREEN)Building Docker image...$(NC)"
151
+ cd $(APP_DIR) && docker build -t medos-global .
152
+ @echo "$(GREEN)Docker image built successfully.$(NC)"
153
+
154
+ .PHONY: docker-run
155
+ docker-run: ## Run Docker container locally
156
+ @echo "$(GREEN)Starting MedOS on http://localhost:7860...$(NC)"
157
+ cd $(APP_DIR) && docker run -p 7860:7860 \
158
+ -e OLLABRIDGE_URL=$${OLLABRIDGE_URL:-https://ruslanmv-ollabridge-cloud.hf.space} \
159
+ -e HF_TOKEN=$${HF_TOKEN:-} \
160
+ medos-global
161
+
162
+ # =============================================================================
163
+ # Development
164
+ # =============================================================================
165
+
166
+ .PHONY: dev
167
+ dev: ## Start development server on port 7860
168
+ @echo "$(GREEN)Starting dev server on http://localhost:7860...$(NC)"
169
+ cd $(APP_DIR) && npm run dev
170
+
171
+ .PHONY: start
172
+ start: ## Start production server on port 7860
173
+ cd $(APP_DIR) && npm run start
174
+
175
+ # =============================================================================
176
+ # Deployment
177
+ # =============================================================================
178
+
179
+ .PHONY: deploy-hf
180
+ deploy-hf: ## Deploy to Hugging Face Spaces (requires HF_TOKEN)
181
+ @test -n "$(HF_TOKEN)" || (echo "$(RED)ERROR: HF_TOKEN is required. Set it with: make deploy-hf HF_TOKEN=hf_...$(NC)" && exit 1)
182
+ @echo "$(GREEN)Deploying to Hugging Face Spaces...$(NC)"
183
+ @bash scripts/deploy-hf.sh $(HF_TOKEN)
184
+
185
+ # =============================================================================
186
+ # CI Pipeline (combines all checks)
187
+ # =============================================================================
188
+
189
+ .PHONY: ci
190
+ ci: install-ci test build ## Full CI pipeline: install, test, build
191
+ @echo "$(GREEN)CI pipeline passed successfully!$(NC)"
192
+
193
+ .PHONY: check
194
+ check: test-structure test-providers test-i18n test-safety ## Quick check (no install needed)
195
+ @echo "$(GREEN)All structural checks passed!$(NC)"
README.md ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: "MediBot: Free AI Medical Assistant · 20 languages"
3
+ emoji: "\U0001F3E5"
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: true
9
+ license: apache-2.0
10
+ short_description: "Free AI medical chatbot. 20 languages. No sign-up."
11
+ tags:
12
+ - medical
13
+ - healthcare
14
+ - chatbot
15
+ - medical-ai
16
+ - health-assistant
17
+ - symptom-checker
18
+ - telemedicine
19
+ - who-guidelines
20
+ - cdc
21
+ - multilingual
22
+ - i18n
23
+ - rag
24
+ - llama-3.3
25
+ - llama-3.3-70b
26
+ - mixtral
27
+ - groq
28
+ - huggingface-inference
29
+ - pwa
30
+ - offline-first
31
+ - free
32
+ - no-signup
33
+ - privacy-first
34
+ - worldwide
35
+ - nextjs
36
+ - docker
37
+ models:
38
+ - meta-llama/Llama-3.3-70B-Instruct
39
+ - meta-llama/Meta-Llama-3-8B-Instruct
40
+ - mistralai/Mixtral-8x7B-Instruct-v0.1
41
+ - Qwen/Qwen2.5-72B-Instruct
42
+ - deepseek-ai/DeepSeek-V3
43
+ - ruslanmv/Medical-Llama3-8B
44
+ - google/gemma-2-9b-it
45
+ datasets:
46
+ - ruslanmv/ai-medical-chatbot
47
+ ---
48
+
49
+ # MediBot — free AI medical assistant, worldwide
50
+
51
+ > **Tell MediBot what's bothering you. In your language. Instantly. For free.**
52
+ > No sign-up. No paywall. No data retention. Aligned with WHO · CDC · NHS guidelines.
53
+
54
+ [![Try MediBot](https://img.shields.io/badge/Try_MediBot-Free_on_HuggingFace-blue?style=for-the-badge&logo=huggingface)](https://huggingface.co/spaces/ruslanmv/MediBot)
55
+ [![Languages](https://img.shields.io/badge/languages-20-14B8A6?style=for-the-badge)](#)
56
+ [![Free](https://img.shields.io/badge/price-free_forever-22C55E?style=for-the-badge)](#)
57
+ [![No sign-up](https://img.shields.io/badge/account-not_required-3B82F6?style=for-the-badge)](#)
58
+
59
+ ## Why MediBot
60
+
61
+ - **Free forever.** No API key, no sign-up, no paywall, no ads.
62
+ - **20 languages, auto-detected.** English, Español, Français, Português, Deutsch, Italiano, العربية, हिन्दी, Kiswahili, 中文, 日本語, 한국어, Русский, Türkçe, Tiếng Việt, ไทย, বাংলা, اردو, Polski, Nederlands.
63
+ - **Worldwide.** IP-based country detection picks your local emergency number (190+ countries) and adapts the answer to your region (°C/°F, metric/imperial, local guidance).
64
+ - **Best free LLM on HuggingFace.** Powered by **Llama 3.3 70B via HF Inference Providers (Groq)** — fastest high-quality free tier available — with an automatic fallback chain across Cerebras, SambaNova, Together, and Mixtral.
65
+ - **Grounded on WHO, CDC, NHS, NIH, ICD-11, BNF, EMA.** A structured system prompt aligns every answer with authoritative guidance.
66
+ - **Red-flag triage.** Built-in symptom patterns detect cardiac, neurological, respiratory, obstetric, pediatric, and mental-health emergencies in every supported language — and immediately escalate to the local emergency number.
67
+ - **Installable PWA.** Add to your phone's home screen and use it like a native app. Offline-capable with a cached FAQ fallback.
68
+ - **Shareable.** Every AI answer gets a Share button that generates a clean deep link with a branded OG card preview — perfect for WhatsApp, Twitter, and Telegram.
69
+ - **Private & anonymous.** Zero accounts. Zero server-side conversation storage. No IPs logged. Anonymous session counter only.
70
+ - **Open source.** Fully transparent. [github.com/ruslanmv/ai-medical-chatbot](https://github.com/ruslanmv/ai-medical-chatbot)
71
+
72
+ ## How it works
73
+
74
+ 1. You type (or speak) a health question
75
+ 2. MedOS checks for emergency red flags first
76
+ 3. It searches a medical knowledge base for relevant context
77
+ 4. Your question + context go to **Llama 3.3 70B** (via Groq, free)
78
+ 5. You get a structured answer: Summary, Possible causes, Self-care, When to see a doctor
79
+
80
+ If the main model is busy, MedOS automatically tries other free models until one responds.
81
+
82
+ ## Built with
83
+
84
+ | Layer | Technology |
85
+ |---|---|
86
+ | Frontend | Next.js 14, React, Tailwind CSS |
87
+ | AI Model | Llama 3.3 70B Instruct (via HuggingFace Inference + Groq) |
88
+ | Fallbacks | Mixtral 8x7B, OllaBridge, cached FAQ |
89
+ | Knowledge | Medical RAG from [ruslanmv/ai-medical-chatbot](https://github.com/ruslanmv/ai-medical-chatbot) dataset |
90
+ | Gateway | [OllaBridge-Cloud](https://github.com/ruslanmv/ollabridge) |
91
+ | Hosting | HuggingFace Spaces (Docker) |
92
+
93
+ ## License
94
+
95
+ Apache 2.0 — free to use, modify, and distribute.
app/admin/page.tsx ADDED
@@ -0,0 +1,327 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useEffect, useState, useCallback } from 'react';
4
+ import {
5
+ Users,
6
+ Activity,
7
+ Database,
8
+ MessageCircle,
9
+ Shield,
10
+ Search,
11
+ Trash2,
12
+ ChevronLeft,
13
+ ChevronRight,
14
+ RefreshCw,
15
+ LogIn,
16
+ Lock,
17
+ } from 'lucide-react';
18
+
19
+ interface Stats {
20
+ totalUsers: number;
21
+ verifiedUsers: number;
22
+ adminUsers: number;
23
+ totalHealthData: number;
24
+ totalChats: number;
25
+ activeSessions: number;
26
+ healthBreakdown: Array<{ type: string; count: number }>;
27
+ registrations: Array<{ day: string; count: number }>;
28
+ }
29
+
30
+ interface UserRow {
31
+ id: string;
32
+ email: string;
33
+ displayName: string | null;
34
+ emailVerified: boolean;
35
+ isAdmin: boolean;
36
+ createdAt: string;
37
+ healthDataCount: number;
38
+ chatHistoryCount: number;
39
+ }
40
+
41
+ /**
42
+ * Admin dashboard — accessible ONLY at /admin on the HuggingFace Space.
43
+ * Not linked from the public UI. Requires admin login.
44
+ */
45
+ export default function AdminPage() {
46
+ const [token, setToken] = useState('');
47
+ const [loggedIn, setLoggedIn] = useState(false);
48
+ const [email, setEmail] = useState('');
49
+ const [password, setPassword] = useState('');
50
+ const [loginError, setLoginError] = useState('');
51
+ const [stats, setStats] = useState<Stats | null>(null);
52
+ const [users, setUsers] = useState<UserRow[]>([]);
53
+ const [total, setTotal] = useState(0);
54
+ const [page, setPage] = useState(1);
55
+ const [search, setSearch] = useState('');
56
+ const [loading, setLoading] = useState(false);
57
+
58
+ const headers = useCallback(
59
+ () => ({ Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }),
60
+ [token],
61
+ );
62
+
63
+ const fetchStats = useCallback(async () => {
64
+ const res = await fetch('/api/admin/stats', { headers: headers() });
65
+ if (res.ok) setStats(await res.json());
66
+ else if (res.status === 403) { setLoggedIn(false); setToken(''); }
67
+ }, [headers]);
68
+
69
+ const fetchUsers = useCallback(async () => {
70
+ setLoading(true);
71
+ const qs = new URLSearchParams({ page: String(page), limit: '20' });
72
+ if (search) qs.set('search', search);
73
+ const res = await fetch(`/api/admin/users?${qs}`, { headers: headers() });
74
+ if (res.ok) {
75
+ const data = await res.json();
76
+ setUsers(data.users);
77
+ setTotal(data.total);
78
+ }
79
+ setLoading(false);
80
+ }, [headers, page, search]);
81
+
82
+ useEffect(() => {
83
+ if (!loggedIn) return;
84
+ fetchStats();
85
+ fetchUsers();
86
+ }, [loggedIn, fetchStats, fetchUsers]);
87
+
88
+ const handleLogin = async () => {
89
+ setLoginError('');
90
+ const res = await fetch('/api/auth/login', {
91
+ method: 'POST',
92
+ headers: { 'Content-Type': 'application/json' },
93
+ body: JSON.stringify({ email, password }),
94
+ });
95
+ const data = await res.json();
96
+ if (!res.ok) { setLoginError(data.error || 'Login failed'); return; }
97
+ // Verify this user is actually an admin.
98
+ const meRes = await fetch('/api/auth/me', {
99
+ headers: { Authorization: `Bearer ${data.token}` },
100
+ });
101
+ const me = await meRes.json();
102
+ if (!me.user) { setLoginError('Auth failed'); return; }
103
+ // Check admin flag by trying the admin API.
104
+ const adminCheck = await fetch('/api/admin/stats', {
105
+ headers: { Authorization: `Bearer ${data.token}` },
106
+ });
107
+ if (adminCheck.status === 403) { setLoginError('Not an admin account'); return; }
108
+ setToken(data.token);
109
+ setLoggedIn(true);
110
+ };
111
+
112
+ const handleDeleteUser = async (userId: string, userEmail: string) => {
113
+ if (!confirm(`Delete user ${userEmail} and ALL their data?`)) return;
114
+ await fetch(`/api/admin/users?id=${userId}`, { method: 'DELETE', headers: headers() });
115
+ fetchUsers();
116
+ fetchStats();
117
+ };
118
+
119
+ // Login screen
120
+ if (!loggedIn) {
121
+ return (
122
+ <div className="min-h-screen bg-slate-950 flex items-center justify-center p-4">
123
+ <div className="w-full max-w-sm">
124
+ <div className="text-center mb-8">
125
+ <div className="w-14 h-14 mx-auto mb-4 rounded-2xl bg-gradient-to-br from-red-500 to-orange-500 flex items-center justify-center">
126
+ <Lock size={24} className="text-white" />
127
+ </div>
128
+ <h1 className="text-2xl font-bold text-slate-100">Admin Panel</h1>
129
+ <p className="text-sm text-slate-400 mt-1">MedOS server administration</p>
130
+ </div>
131
+ {loginError && (
132
+ <div className="mb-4 p-3 rounded-xl bg-red-950/50 border border-red-700/50 text-sm text-red-300">
133
+ {loginError}
134
+ </div>
135
+ )}
136
+ <div className="space-y-3">
137
+ <input
138
+ type="email"
139
+ value={email}
140
+ onChange={(e) => setEmail(e.target.value)}
141
+ placeholder="Admin email"
142
+ className="w-full bg-slate-900 border border-slate-700 rounded-xl px-4 py-3 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-red-500/50"
143
+ />
144
+ <input
145
+ type="password"
146
+ value={password}
147
+ onChange={(e) => setPassword(e.target.value)}
148
+ placeholder="Password"
149
+ className="w-full bg-slate-900 border border-slate-700 rounded-xl px-4 py-3 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-red-500/50"
150
+ onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
151
+ />
152
+ <button
153
+ onClick={handleLogin}
154
+ className="w-full py-3 bg-gradient-to-br from-red-500 to-orange-500 text-white rounded-xl font-bold text-sm hover:brightness-110 transition-all flex items-center justify-center gap-2"
155
+ >
156
+ <LogIn size={16} />
157
+ Sign in as Admin
158
+ </button>
159
+ </div>
160
+ </div>
161
+ </div>
162
+ );
163
+ }
164
+
165
+ const totalPages = Math.ceil(total / 20);
166
+
167
+ return (
168
+ <div className="min-h-screen bg-slate-950 text-slate-100">
169
+ <header className="border-b border-slate-800 px-6 py-4 flex items-center justify-between">
170
+ <div className="flex items-center gap-3">
171
+ <div className="w-8 h-8 rounded-lg bg-gradient-to-br from-red-500 to-orange-500 flex items-center justify-center">
172
+ <Shield size={16} className="text-white" />
173
+ </div>
174
+ <h1 className="font-bold text-lg">MedOS Admin</h1>
175
+ </div>
176
+ <button
177
+ onClick={() => { fetchStats(); fetchUsers(); }}
178
+ className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-slate-800 text-slate-300 text-xs font-semibold hover:bg-slate-700"
179
+ >
180
+ <RefreshCw size={12} /> Refresh
181
+ </button>
182
+ </header>
183
+
184
+ <main className="max-w-6xl mx-auto px-4 sm:px-6 py-8">
185
+ {/* Stats grid */}
186
+ {stats && (
187
+ <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3 mb-8">
188
+ <Stat icon={Users} label="Total users" value={stats.totalUsers} />
189
+ <Stat icon={Shield} label="Verified" value={stats.verifiedUsers} />
190
+ <Stat icon={Shield} label="Admins" value={stats.adminUsers} color="text-red-400" />
191
+ <Stat icon={Database} label="Health records" value={stats.totalHealthData} />
192
+ <Stat icon={MessageCircle} label="Conversations" value={stats.totalChats} />
193
+ <Stat icon={Activity} label="Active sessions" value={stats.activeSessions} />
194
+ </div>
195
+ )}
196
+
197
+ {/* Health data breakdown */}
198
+ {stats && stats.healthBreakdown.length > 0 && (
199
+ <div className="mb-8 p-4 rounded-2xl bg-slate-900 border border-slate-800">
200
+ <h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 mb-3">Health data by type</h3>
201
+ <div className="flex flex-wrap gap-2">
202
+ {stats.healthBreakdown.map((b) => (
203
+ <span key={b.type} className="px-3 py-1.5 rounded-full bg-slate-800 text-sm font-medium text-slate-200">
204
+ {b.type}: <strong>{b.count}</strong>
205
+ </span>
206
+ ))}
207
+ </div>
208
+ </div>
209
+ )}
210
+
211
+ {/* User management */}
212
+ <div className="rounded-2xl bg-slate-900 border border-slate-800 overflow-hidden">
213
+ <div className="p-4 border-b border-slate-800 flex items-center gap-3">
214
+ <h2 className="font-bold">Users ({total})</h2>
215
+ <div className="flex-1 relative ml-4">
216
+ <Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
217
+ <input
218
+ value={search}
219
+ onChange={(e) => { setSearch(e.target.value); setPage(1); }}
220
+ placeholder="Search by email or name..."
221
+ className="w-full bg-slate-800 border border-slate-700 rounded-lg pl-9 pr-4 py-2 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-red-500/50"
222
+ />
223
+ </div>
224
+ </div>
225
+
226
+ <div className="overflow-x-auto">
227
+ <table className="w-full text-sm">
228
+ <thead>
229
+ <tr className="text-xs text-slate-400 uppercase tracking-wider border-b border-slate-800">
230
+ <th className="text-left px-4 py-3">User</th>
231
+ <th className="text-center px-4 py-3">Verified</th>
232
+ <th className="text-center px-4 py-3">Role</th>
233
+ <th className="text-center px-4 py-3">Health</th>
234
+ <th className="text-center px-4 py-3">Chats</th>
235
+ <th className="text-left px-4 py-3">Joined</th>
236
+ <th className="text-right px-4 py-3">Actions</th>
237
+ </tr>
238
+ </thead>
239
+ <tbody>
240
+ {users.map((u) => (
241
+ <tr key={u.id} className="border-b border-slate-800/50 hover:bg-slate-800/30">
242
+ <td className="px-4 py-3">
243
+ <div className="font-medium text-slate-100">{u.displayName || '—'}</div>
244
+ <div className="text-xs text-slate-400">{u.email}</div>
245
+ </td>
246
+ <td className="px-4 py-3 text-center">
247
+ <span className={`text-xs font-bold ${u.emailVerified ? 'text-emerald-400' : 'text-slate-500'}`}>
248
+ {u.emailVerified ? 'Yes' : 'No'}
249
+ </span>
250
+ </td>
251
+ <td className="px-4 py-3 text-center">
252
+ {u.isAdmin ? (
253
+ <span className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-red-500/20 text-red-300 border border-red-500/30">
254
+ ADMIN
255
+ </span>
256
+ ) : (
257
+ <span className="text-xs text-slate-500">User</span>
258
+ )}
259
+ </td>
260
+ <td className="px-4 py-3 text-center text-slate-300">{u.healthDataCount}</td>
261
+ <td className="px-4 py-3 text-center text-slate-300">{u.chatHistoryCount}</td>
262
+ <td className="px-4 py-3 text-slate-400 text-xs">
263
+ {new Date(u.createdAt).toLocaleDateString()}
264
+ </td>
265
+ <td className="px-4 py-3 text-right">
266
+ {!u.isAdmin && (
267
+ <button
268
+ onClick={() => handleDeleteUser(u.id, u.email)}
269
+ className="p-1.5 rounded-lg text-slate-500 hover:text-red-400 hover:bg-red-500/10 transition-colors"
270
+ title="Delete user"
271
+ >
272
+ <Trash2 size={14} />
273
+ </button>
274
+ )}
275
+ </td>
276
+ </tr>
277
+ ))}
278
+ {users.length === 0 && (
279
+ <tr>
280
+ <td colSpan={7} className="px-4 py-8 text-center text-slate-500">
281
+ {loading ? 'Loading...' : 'No users found'}
282
+ </td>
283
+ </tr>
284
+ )}
285
+ </tbody>
286
+ </table>
287
+ </div>
288
+
289
+ {/* Pagination */}
290
+ {totalPages > 1 && (
291
+ <div className="flex items-center justify-between px-4 py-3 border-t border-slate-800">
292
+ <span className="text-xs text-slate-400">
293
+ Page {page} of {totalPages}
294
+ </span>
295
+ <div className="flex gap-1">
296
+ <button
297
+ onClick={() => setPage(Math.max(1, page - 1))}
298
+ disabled={page <= 1}
299
+ className="p-1.5 rounded-lg bg-slate-800 text-slate-300 disabled:opacity-30"
300
+ >
301
+ <ChevronLeft size={14} />
302
+ </button>
303
+ <button
304
+ onClick={() => setPage(Math.min(totalPages, page + 1))}
305
+ disabled={page >= totalPages}
306
+ className="p-1.5 rounded-lg bg-slate-800 text-slate-300 disabled:opacity-30"
307
+ >
308
+ <ChevronRight size={14} />
309
+ </button>
310
+ </div>
311
+ </div>
312
+ )}
313
+ </div>
314
+ </main>
315
+ </div>
316
+ );
317
+ }
318
+
319
+ function Stat({ icon: Icon, label, value, color }: { icon: any; label: string; value: number; color?: string }) {
320
+ return (
321
+ <div className="p-4 rounded-xl bg-slate-900 border border-slate-800">
322
+ <Icon size={16} className={color || 'text-slate-400'} />
323
+ <div className="text-2xl font-black mt-2">{value.toLocaleString()}</div>
324
+ <div className="text-[11px] text-slate-500 font-semibold">{label}</div>
325
+ </div>
326
+ );
327
+ }
app/api/admin/config/route.ts ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { requireAdmin } from '@/lib/auth-middleware';
3
+ import { loadConfig, saveConfig, type ServerConfig } from '@/lib/server-config';
4
+
5
+ /**
6
+ * Admin configuration management.
7
+ *
8
+ * GET /api/admin/config — returns current server configuration (redacted secrets).
9
+ * PUT /api/admin/config — updates server configuration (persisted to config file).
10
+ *
11
+ * Configuration is persisted to a JSON file on disk so it survives restarts.
12
+ * Environment variables take precedence over the config file on first boot.
13
+ *
14
+ * The storage/merge logic lives in @/lib/server-config so other admin routes
15
+ * (like /api/admin/fetch-models) can read the same source of truth.
16
+ */
17
+
18
+ /** Redact sensitive fields for GET responses. */
19
+ function redact(config: ServerConfig) {
20
+ const hasSecret = (v: string) => !!(v && v.length > 0);
21
+ const mask = (v: string) => (hasSecret(v) ? '••••••••' : '');
22
+ return {
23
+ smtp: {
24
+ host: config.smtp.host,
25
+ port: config.smtp.port,
26
+ user: config.smtp.user,
27
+ pass: mask(config.smtp.pass),
28
+ fromEmail: config.smtp.fromEmail,
29
+ recoveryEmail: config.smtp.recoveryEmail,
30
+ configured: !!(config.smtp.host && config.smtp.user && config.smtp.pass),
31
+ },
32
+ llm: {
33
+ defaultPreset: config.llm.defaultPreset,
34
+ ollamaUrl: config.llm.ollamaUrl,
35
+ hfDefaultModel: config.llm.hfDefaultModel,
36
+ hfToken: mask(config.llm.hfToken),
37
+ ollabridgeUrl: config.llm.ollabridgeUrl,
38
+ ollabridgeApiKey: mask(config.llm.ollabridgeApiKey),
39
+ openaiApiKey: mask(config.llm.openaiApiKey),
40
+ anthropicApiKey: mask(config.llm.anthropicApiKey),
41
+ groqApiKey: mask(config.llm.groqApiKey),
42
+ watsonxApiKey: mask(config.llm.watsonxApiKey),
43
+ watsonxProjectId: config.llm.watsonxProjectId,
44
+ watsonxUrl: config.llm.watsonxUrl,
45
+ // Computed status flags — derived server-side so UI can show chips.
46
+ ollabridgeConfigured: !!config.llm.ollabridgeUrl,
47
+ hfConfigured: hasSecret(config.llm.hfToken),
48
+ openaiConfigured: hasSecret(config.llm.openaiApiKey),
49
+ anthropicConfigured: hasSecret(config.llm.anthropicApiKey),
50
+ groqConfigured: hasSecret(config.llm.groqApiKey),
51
+ watsonxConfigured: hasSecret(config.llm.watsonxApiKey) && !!config.llm.watsonxProjectId,
52
+ },
53
+ app: config.app,
54
+ };
55
+ }
56
+
57
+ export async function GET(req: Request) {
58
+ const admin = requireAdmin(req);
59
+ if (!admin) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
60
+
61
+ const config = loadConfig();
62
+ return NextResponse.json(redact(config));
63
+ }
64
+
65
+ export async function PUT(req: Request) {
66
+ const admin = requireAdmin(req);
67
+ if (!admin) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
68
+
69
+ try {
70
+ const body = await req.json();
71
+ const current = loadConfig();
72
+
73
+ // Merge incoming changes (only update provided fields).
74
+ if (body.smtp) {
75
+ if (body.smtp.host !== undefined) current.smtp.host = body.smtp.host;
76
+ if (body.smtp.port !== undefined) current.smtp.port = parseInt(body.smtp.port, 10);
77
+ if (body.smtp.user !== undefined) current.smtp.user = body.smtp.user;
78
+ // Only update password if it's not the redacted placeholder.
79
+ if (body.smtp.pass !== undefined && body.smtp.pass !== '••••••••') {
80
+ current.smtp.pass = body.smtp.pass;
81
+ }
82
+ if (body.smtp.fromEmail !== undefined) current.smtp.fromEmail = body.smtp.fromEmail;
83
+ if (body.smtp.recoveryEmail !== undefined) current.smtp.recoveryEmail = body.smtp.recoveryEmail;
84
+ }
85
+
86
+ if (body.llm) {
87
+ // Non-secret fields — assign directly.
88
+ if (body.llm.defaultPreset !== undefined) current.llm.defaultPreset = body.llm.defaultPreset;
89
+ if (body.llm.ollamaUrl !== undefined) current.llm.ollamaUrl = body.llm.ollamaUrl;
90
+ if (body.llm.hfDefaultModel !== undefined) current.llm.hfDefaultModel = body.llm.hfDefaultModel;
91
+ if (body.llm.ollabridgeUrl !== undefined) current.llm.ollabridgeUrl = body.llm.ollabridgeUrl;
92
+ if (body.llm.watsonxProjectId !== undefined) current.llm.watsonxProjectId = body.llm.watsonxProjectId;
93
+ if (body.llm.watsonxUrl !== undefined) current.llm.watsonxUrl = body.llm.watsonxUrl;
94
+
95
+ // Secret fields — skip if value is the redacted placeholder.
96
+ const setSecret = (field: keyof ServerConfig['llm'], value: any) => {
97
+ if (value !== undefined && value !== '••••••••') {
98
+ (current.llm as any)[field] = value;
99
+ }
100
+ };
101
+ setSecret('hfToken', body.llm.hfToken);
102
+ setSecret('ollabridgeApiKey', body.llm.ollabridgeApiKey);
103
+ setSecret('openaiApiKey', body.llm.openaiApiKey);
104
+ setSecret('anthropicApiKey', body.llm.anthropicApiKey);
105
+ setSecret('groqApiKey', body.llm.groqApiKey);
106
+ setSecret('watsonxApiKey', body.llm.watsonxApiKey);
107
+ }
108
+
109
+ if (body.app) {
110
+ if (body.app.appUrl !== undefined) current.app.appUrl = body.app.appUrl;
111
+ if (body.app.allowedOrigins !== undefined) current.app.allowedOrigins = body.app.allowedOrigins;
112
+ }
113
+
114
+ saveConfig(current);
115
+
116
+ return NextResponse.json({ success: true, config: redact(current) });
117
+ } catch (error: any) {
118
+ console.error('[Admin Config]', error?.message);
119
+ return NextResponse.json({ error: error?.message || 'Failed to update config' }, { status: 500 });
120
+ }
121
+ }
app/api/admin/fetch-models/route.ts ADDED
@@ -0,0 +1,423 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { requireAdmin } from '@/lib/auth-middleware';
3
+ import { loadConfig } from '@/lib/server-config';
4
+
5
+ /**
6
+ * GET /api/admin/fetch-models — Aggregate available models from every
7
+ * configured provider into one list for the admin model picker.
8
+ *
9
+ * Queries, in parallel:
10
+ * - OllaBridge Cloud /v1/models (OpenAI-compatible)
11
+ * - HuggingFace Inference /v1/models (via router.huggingface.co)
12
+ * - Groq /openai/v1/models (free/cheap tier)
13
+ * - OpenAI /v1/models (paid enterprise)
14
+ * - Anthropic /v1/models (paid enterprise)
15
+ * - IBM WatsonX /ml/v1/foundation_model_specs (paid enterprise)
16
+ *
17
+ * Each provider block returns:
18
+ * { provider, configured, ok, error?, models: [{id, name, ownedBy, context?}] }
19
+ *
20
+ * Providers that aren't configured still appear in the response so the UI
21
+ * can show them as "not configured" with a link to set them up. This keeps
22
+ * the client-side model picker uniform across providers.
23
+ *
24
+ * Admin-only endpoint.
25
+ */
26
+
27
+ export const runtime = 'nodejs';
28
+ export const dynamic = 'force-dynamic';
29
+
30
+ interface ModelInfo {
31
+ id: string;
32
+ name: string;
33
+ ownedBy?: string;
34
+ context?: number;
35
+ pricing?: 'free' | 'paid' | 'cheap' | 'local';
36
+ }
37
+
38
+ interface ProviderBlock {
39
+ provider: string;
40
+ label: string;
41
+ configured: boolean;
42
+ ok: boolean;
43
+ error?: string;
44
+ pricing: 'free' | 'paid' | 'cheap' | 'local';
45
+ models: ModelInfo[];
46
+ }
47
+
48
+ /** Default 10s timeout for any provider discovery call. */
49
+ function withTimeout(ms = 10000) {
50
+ return AbortSignal.timeout(ms);
51
+ }
52
+
53
+ async function safeJson(res: Response): Promise<any> {
54
+ try {
55
+ return await res.json();
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ // ---- Provider fetchers ---------------------------------------------------
62
+
63
+ async function fetchOllaBridge(
64
+ url: string,
65
+ apiKey: string,
66
+ ): Promise<ProviderBlock> {
67
+ const block: ProviderBlock = {
68
+ provider: 'ollabridge',
69
+ label: 'OllaBridge Cloud',
70
+ configured: !!url,
71
+ ok: false,
72
+ pricing: 'free',
73
+ models: [],
74
+ };
75
+ if (!url) {
76
+ block.error = 'Not configured — set OllaBridge URL in Server tab';
77
+ return block;
78
+ }
79
+ try {
80
+ const cleanBase = url.replace(/\/+$/, '');
81
+ const res = await fetch(`${cleanBase}/v1/models`, {
82
+ headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
83
+ signal: withTimeout(),
84
+ });
85
+ if (!res.ok) {
86
+ block.error = `HTTP ${res.status}`;
87
+ return block;
88
+ }
89
+ const data = await safeJson(res);
90
+ const list = Array.isArray(data?.data) ? data.data : [];
91
+ block.models = list.map((m: any) => ({
92
+ id: String(m.id ?? 'unknown'),
93
+ name: String(m.id ?? 'unknown'),
94
+ ownedBy: m.owned_by || 'ollabridge',
95
+ pricing:
96
+ String(m.id ?? '').startsWith('free-')
97
+ ? 'free'
98
+ : String(m.id ?? '').startsWith('cheap-')
99
+ ? 'cheap'
100
+ : 'local',
101
+ }));
102
+ block.ok = true;
103
+ } catch (e: any) {
104
+ block.error = e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed';
105
+ }
106
+ return block;
107
+ }
108
+
109
+ async function fetchHuggingFace(token: string): Promise<ProviderBlock> {
110
+ const block: ProviderBlock = {
111
+ provider: 'huggingface',
112
+ label: 'HuggingFace Inference',
113
+ configured: !!token,
114
+ ok: false,
115
+ pricing: 'free',
116
+ models: [],
117
+ };
118
+ if (!token) {
119
+ block.error = 'Not configured — set HF token in Server tab';
120
+ // Still provide the curated fallback chain as suggestions so users can
121
+ // pick a model even before the token is set.
122
+ block.models = CURATED_HF_MODELS.map((id) => ({
123
+ id,
124
+ name: id.split('/').pop() || id,
125
+ ownedBy: id.split('/')[0],
126
+ pricing: 'free',
127
+ }));
128
+ return block;
129
+ }
130
+ try {
131
+ const res = await fetch('https://router.huggingface.co/v1/models', {
132
+ headers: { Authorization: `Bearer ${token}` },
133
+ signal: withTimeout(),
134
+ });
135
+ if (!res.ok) {
136
+ block.error = `HTTP ${res.status}`;
137
+ // Still return curated list so the UI has something to show.
138
+ block.models = CURATED_HF_MODELS.map((id) => ({
139
+ id,
140
+ name: id.split('/').pop() || id,
141
+ ownedBy: id.split('/')[0],
142
+ pricing: 'free',
143
+ }));
144
+ return block;
145
+ }
146
+ const data = await safeJson(res);
147
+ const list = Array.isArray(data?.data) ? data.data : [];
148
+ block.models = list
149
+ .filter((m: any) => typeof m?.id === 'string')
150
+ .map((m: any) => ({
151
+ id: String(m.id),
152
+ name: String(m.id).split('/').pop() || String(m.id),
153
+ ownedBy: m.owned_by || String(m.id).split('/')[0],
154
+ pricing: 'free' as const,
155
+ }));
156
+ block.ok = true;
157
+ } catch (e: any) {
158
+ block.error = e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed';
159
+ block.models = CURATED_HF_MODELS.map((id) => ({
160
+ id,
161
+ name: id.split('/').pop() || id,
162
+ ownedBy: id.split('/')[0],
163
+ pricing: 'free',
164
+ }));
165
+ }
166
+ return block;
167
+ }
168
+
169
+ /** Verified-working free HF models (from lib/providers/huggingface-direct.ts). */
170
+ const CURATED_HF_MODELS = [
171
+ 'meta-llama/Llama-3.3-70B-Instruct',
172
+ 'Qwen/Qwen2.5-72B-Instruct',
173
+ 'Qwen/Qwen3-235B-A22B',
174
+ 'google/gemma-3-27b-it',
175
+ 'meta-llama/Llama-3.1-70B-Instruct',
176
+ 'deepseek-ai/DeepSeek-V3-0324',
177
+ ];
178
+
179
+ async function fetchGroq(apiKey: string): Promise<ProviderBlock> {
180
+ const block: ProviderBlock = {
181
+ provider: 'groq',
182
+ label: 'Groq (Free tier)',
183
+ configured: !!apiKey,
184
+ ok: false,
185
+ pricing: 'free',
186
+ models: [],
187
+ };
188
+ if (!apiKey) {
189
+ block.error = 'Not configured — add Groq API key in Server tab';
190
+ return block;
191
+ }
192
+ try {
193
+ const res = await fetch('https://api.groq.com/openai/v1/models', {
194
+ headers: { Authorization: `Bearer ${apiKey}` },
195
+ signal: withTimeout(),
196
+ });
197
+ if (!res.ok) {
198
+ block.error = `HTTP ${res.status}`;
199
+ return block;
200
+ }
201
+ const data = await safeJson(res);
202
+ const list = Array.isArray(data?.data) ? data.data : [];
203
+ block.models = list.map((m: any) => ({
204
+ id: String(m.id ?? 'unknown'),
205
+ name: String(m.id ?? 'unknown'),
206
+ ownedBy: m.owned_by || 'groq',
207
+ context: typeof m.context_window === 'number' ? m.context_window : undefined,
208
+ pricing: 'free',
209
+ }));
210
+ block.ok = true;
211
+ } catch (e: any) {
212
+ block.error = e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed';
213
+ }
214
+ return block;
215
+ }
216
+
217
+ async function fetchOpenAI(apiKey: string): Promise<ProviderBlock> {
218
+ const block: ProviderBlock = {
219
+ provider: 'openai',
220
+ label: 'OpenAI (Paid)',
221
+ configured: !!apiKey,
222
+ ok: false,
223
+ pricing: 'paid',
224
+ models: [],
225
+ };
226
+ if (!apiKey) {
227
+ block.error = 'Not configured — add OpenAI API key in Server tab';
228
+ return block;
229
+ }
230
+ try {
231
+ const res = await fetch('https://api.openai.com/v1/models', {
232
+ headers: { Authorization: `Bearer ${apiKey}` },
233
+ signal: withTimeout(),
234
+ });
235
+ if (!res.ok) {
236
+ block.error = `HTTP ${res.status}`;
237
+ return block;
238
+ }
239
+ const data = await safeJson(res);
240
+ const list = Array.isArray(data?.data) ? data.data : [];
241
+ // Filter to chat-capable GPT models — the full list is noisy.
242
+ block.models = list
243
+ .filter((m: any) => {
244
+ const id = String(m?.id || '');
245
+ return /^(gpt-|o1-|o3-|chatgpt)/i.test(id);
246
+ })
247
+ .map((m: any) => ({
248
+ id: String(m.id),
249
+ name: String(m.id),
250
+ ownedBy: m.owned_by || 'openai',
251
+ pricing: 'paid' as const,
252
+ }));
253
+ block.ok = true;
254
+ } catch (e: any) {
255
+ block.error = e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed';
256
+ }
257
+ return block;
258
+ }
259
+
260
+ async function fetchAnthropic(apiKey: string): Promise<ProviderBlock> {
261
+ const block: ProviderBlock = {
262
+ provider: 'anthropic',
263
+ label: 'Anthropic Claude (Paid)',
264
+ configured: !!apiKey,
265
+ ok: false,
266
+ pricing: 'paid',
267
+ models: [],
268
+ };
269
+ if (!apiKey) {
270
+ block.error = 'Not configured — add Anthropic API key in Server tab';
271
+ // Provide curated list as placeholder.
272
+ block.models = CURATED_ANTHROPIC_MODELS;
273
+ return block;
274
+ }
275
+ try {
276
+ const res = await fetch('https://api.anthropic.com/v1/models', {
277
+ headers: {
278
+ 'x-api-key': apiKey,
279
+ 'anthropic-version': '2023-06-01',
280
+ },
281
+ signal: withTimeout(),
282
+ });
283
+ if (!res.ok) {
284
+ block.error = `HTTP ${res.status}`;
285
+ block.models = CURATED_ANTHROPIC_MODELS;
286
+ return block;
287
+ }
288
+ const data = await safeJson(res);
289
+ const list = Array.isArray(data?.data) ? data.data : [];
290
+ block.models = list.map((m: any) => ({
291
+ id: String(m.id ?? 'unknown'),
292
+ name: String(m.display_name || m.id || 'unknown'),
293
+ ownedBy: 'anthropic',
294
+ pricing: 'paid' as const,
295
+ }));
296
+ block.ok = true;
297
+ } catch (e: any) {
298
+ block.error = e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed';
299
+ block.models = CURATED_ANTHROPIC_MODELS;
300
+ }
301
+ return block;
302
+ }
303
+
304
+ /** Fallback list so the UI can show Claude options even without a key. */
305
+ const CURATED_ANTHROPIC_MODELS: ModelInfo[] = [
306
+ { id: 'claude-opus-4-6', name: 'Claude Opus 4.6', ownedBy: 'anthropic', pricing: 'paid' },
307
+ { id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6', ownedBy: 'anthropic', pricing: 'paid' },
308
+ { id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5', ownedBy: 'anthropic', pricing: 'paid' },
309
+ ];
310
+
311
+ async function fetchWatsonx(
312
+ apiKey: string,
313
+ projectId: string,
314
+ baseUrl: string,
315
+ ): Promise<ProviderBlock> {
316
+ const block: ProviderBlock = {
317
+ provider: 'watsonx',
318
+ label: 'IBM WatsonX (Paid)',
319
+ configured: !!(apiKey && projectId),
320
+ ok: false,
321
+ pricing: 'paid',
322
+ models: [],
323
+ };
324
+ if (!apiKey || !projectId) {
325
+ block.error = 'Not configured — add WatsonX API key and project ID in Server tab';
326
+ block.models = CURATED_WATSONX_MODELS;
327
+ return block;
328
+ }
329
+ try {
330
+ // WatsonX requires exchanging the API key for an IAM bearer token first.
331
+ const iamRes = await fetch('https://iam.cloud.ibm.com/identity/token', {
332
+ method: 'POST',
333
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
334
+ body: new URLSearchParams({
335
+ grant_type: 'urn:ibm:params:oauth:grant-type:apikey',
336
+ apikey: apiKey,
337
+ }),
338
+ signal: withTimeout(),
339
+ });
340
+ if (!iamRes.ok) {
341
+ block.error = `IAM token failed: HTTP ${iamRes.status}`;
342
+ block.models = CURATED_WATSONX_MODELS;
343
+ return block;
344
+ }
345
+ const iamData = await safeJson(iamRes);
346
+ const bearer = iamData?.access_token;
347
+ if (!bearer) {
348
+ block.error = 'IAM response missing access_token';
349
+ block.models = CURATED_WATSONX_MODELS;
350
+ return block;
351
+ }
352
+
353
+ // Discover available foundation models.
354
+ const cleanBase = baseUrl.replace(/\/+$/, '');
355
+ const modelsRes = await fetch(
356
+ `${cleanBase}/ml/v1/foundation_model_specs?version=2024-05-01&limit=200`,
357
+ {
358
+ headers: { Authorization: `Bearer ${bearer}` },
359
+ signal: withTimeout(),
360
+ },
361
+ );
362
+ if (!modelsRes.ok) {
363
+ block.error = `HTTP ${modelsRes.status}`;
364
+ block.models = CURATED_WATSONX_MODELS;
365
+ return block;
366
+ }
367
+ const data = await safeJson(modelsRes);
368
+ const list = Array.isArray(data?.resources) ? data.resources : [];
369
+ block.models = list.map((m: any) => ({
370
+ id: String(m.model_id ?? 'unknown'),
371
+ name: String(m.label || m.model_id || 'unknown'),
372
+ ownedBy: m.provider || 'ibm',
373
+ pricing: 'paid' as const,
374
+ }));
375
+ block.ok = true;
376
+ } catch (e: any) {
377
+ block.error = e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed';
378
+ block.models = CURATED_WATSONX_MODELS;
379
+ }
380
+ return block;
381
+ }
382
+
383
+ /** Common WatsonX foundation models as a fallback list. */
384
+ const CURATED_WATSONX_MODELS: ModelInfo[] = [
385
+ { id: 'ibm/granite-3-8b-instruct', name: 'Granite 3 8B Instruct', ownedBy: 'ibm', pricing: 'paid' },
386
+ { id: 'meta-llama/llama-3-3-70b-instruct', name: 'Llama 3.3 70B', ownedBy: 'meta', pricing: 'paid' },
387
+ { id: 'mistralai/mistral-large', name: 'Mistral Large', ownedBy: 'mistralai', pricing: 'paid' },
388
+ ];
389
+
390
+ // ---- Route handler -------------------------------------------------------
391
+
392
+ export async function GET(req: Request) {
393
+ const admin = requireAdmin(req);
394
+ if (!admin) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
395
+
396
+ const config = loadConfig();
397
+ const { llm } = config;
398
+
399
+ // Run every discovery call in parallel so the slowest provider sets the
400
+ // total latency floor, not the sum of all calls.
401
+ const [ollabridge, huggingface, groq, openai, anthropic, watsonx] = await Promise.all([
402
+ fetchOllaBridge(llm.ollabridgeUrl, llm.ollabridgeApiKey),
403
+ fetchHuggingFace(llm.hfToken),
404
+ fetchGroq(llm.groqApiKey),
405
+ fetchOpenAI(llm.openaiApiKey),
406
+ fetchAnthropic(llm.anthropicApiKey),
407
+ fetchWatsonx(llm.watsonxApiKey, llm.watsonxProjectId, llm.watsonxUrl),
408
+ ]);
409
+
410
+ const providers = [ollabridge, huggingface, groq, openai, anthropic, watsonx];
411
+ const totalModels = providers.reduce((sum, p) => sum + p.models.length, 0);
412
+ const okCount = providers.filter((p) => p.ok).length;
413
+
414
+ return NextResponse.json({
415
+ providers,
416
+ summary: {
417
+ providers: providers.length,
418
+ providersOk: okCount,
419
+ totalModels,
420
+ fetchedAt: new Date().toISOString(),
421
+ },
422
+ });
423
+ }
app/api/admin/llm-health/route.ts ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { requireAdmin } from '@/lib/auth-middleware';
3
+ import { loadConfig } from '@/lib/server-config';
4
+
5
+ /**
6
+ * GET /api/admin/llm-health — Test all LLM providers and models.
7
+ *
8
+ * Sends a minimal "Say OK" prompt to each model in the fallback chain
9
+ * and reports which ones are alive, their latency, and any errors.
10
+ * Admin-only endpoint.
11
+ *
12
+ * Token resolution order:
13
+ * 1. admin config file (set via /api/admin/config PUT)
14
+ * 2. HF_TOKEN environment variable
15
+ * This way an admin can fix a misconfigured Space without redeploying.
16
+ */
17
+
18
+ const HF_BASE_URL = 'https://router.huggingface.co/v1';
19
+
20
+ /** All models to test — matches the presets fallback chain. */
21
+ const MODELS_TO_TEST = [
22
+ 'meta-llama/Llama-3.3-70B-Instruct:sambanova',
23
+ 'meta-llama/Llama-3.3-70B-Instruct:together',
24
+ 'meta-llama/Llama-3.3-70B-Instruct',
25
+ 'Qwen/Qwen2.5-72B-Instruct',
26
+ 'Qwen/Qwen3-235B-A22B',
27
+ 'google/gemma-3-27b-it',
28
+ 'meta-llama/Llama-3.1-70B-Instruct',
29
+ 'Qwen/Qwen3-32B',
30
+ 'deepseek-ai/DeepSeek-V3-0324',
31
+ 'deepseek-ai/DeepSeek-R1',
32
+ 'Qwen/Qwen3-30B-A3B',
33
+ 'Qwen/Qwen2.5-Coder-32B-Instruct',
34
+ ];
35
+
36
+ async function testModel(model: string, token: string): Promise<{
37
+ model: string;
38
+ status: 'ok' | 'error';
39
+ latencyMs: number;
40
+ response?: string;
41
+ error?: string;
42
+ httpStatus?: number;
43
+ }> {
44
+ const start = Date.now();
45
+ try {
46
+ const res = await fetch(`${HF_BASE_URL}/chat/completions`, {
47
+ method: 'POST',
48
+ headers: {
49
+ Authorization: `Bearer ${token}`,
50
+ 'Content-Type': 'application/json',
51
+ },
52
+ body: JSON.stringify({
53
+ model,
54
+ messages: [{ role: 'user', content: 'Say OK' }],
55
+ max_tokens: 5,
56
+ temperature: 0.1,
57
+ stream: false,
58
+ }),
59
+ signal: AbortSignal.timeout(15000),
60
+ });
61
+
62
+ const latencyMs = Date.now() - start;
63
+
64
+ if (res.ok) {
65
+ const data = await res.json();
66
+ const content = data?.choices?.[0]?.message?.content?.trim() || '';
67
+ return { model, status: 'ok', latencyMs, response: content.slice(0, 30) };
68
+ } else {
69
+ const text = await res.text().catch(() => '');
70
+ const errorMsg = text.slice(0, 100);
71
+ return { model, status: 'error', latencyMs, error: errorMsg, httpStatus: res.status };
72
+ }
73
+ } catch (e: any) {
74
+ return {
75
+ model,
76
+ status: 'error',
77
+ latencyMs: Date.now() - start,
78
+ error: e?.name === 'TimeoutError' ? 'Timeout (15s)' : (e?.message || 'Unknown error').slice(0, 100),
79
+ };
80
+ }
81
+ }
82
+
83
+ export async function GET(req: Request) {
84
+ const admin = requireAdmin(req);
85
+ if (!admin) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
86
+
87
+ // Prefer admin-configured token, fall back to env var.
88
+ const config = loadConfig();
89
+ const token = config.llm.hfToken || process.env.HF_TOKEN || '';
90
+
91
+ if (!token) {
92
+ // Still return a well-formed response so the UI can render an empty-state
93
+ // Provider Status panel with a helpful error banner.
94
+ return NextResponse.json({
95
+ error: 'HF_TOKEN not configured — set it in Admin → Server → HuggingFace.',
96
+ models: MODELS_TO_TEST.map((model) => ({
97
+ model,
98
+ status: 'error' as const,
99
+ latencyMs: 0,
100
+ error: 'No HF token configured',
101
+ })),
102
+ summary: {
103
+ total: MODELS_TO_TEST.length,
104
+ ok: 0,
105
+ error: MODELS_TO_TEST.length,
106
+ testedAt: new Date().toISOString(),
107
+ },
108
+ });
109
+ }
110
+
111
+ // Test all models in parallel for speed.
112
+ const results = await Promise.all(
113
+ MODELS_TO_TEST.map((model) => testModel(model, token))
114
+ );
115
+
116
+ const ok = results.filter((r) => r.status === 'ok').length;
117
+
118
+ return NextResponse.json({
119
+ models: results,
120
+ summary: {
121
+ total: results.length,
122
+ ok,
123
+ error: results.length - ok,
124
+ testedAt: new Date().toISOString(),
125
+ },
126
+ });
127
+ }
app/api/admin/reset-password/route.ts ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { z } from 'zod';
3
+ import bcrypt from 'bcryptjs';
4
+ import { getDb } from '@/lib/db';
5
+ import { requireAdmin } from '@/lib/auth-middleware';
6
+
7
+ const Schema = z.object({
8
+ userId: z.string().min(1),
9
+ newPassword: z.string().min(6, 'Password must be at least 6 characters'),
10
+ });
11
+
12
+ /**
13
+ * POST /api/admin/reset-password — Admin-initiated password reset.
14
+ *
15
+ * Allows admins to manually reset a user's password. This is the
16
+ * industry-standard approach for user management: the admin sets a
17
+ * temporary password and instructs the user to change it on login.
18
+ *
19
+ * Security measures:
20
+ * - Requires admin authentication
21
+ * - Passwords are bcrypt-hashed
22
+ * - All existing sessions for the user are invalidated
23
+ * - Cannot reset your own password (use profile instead)
24
+ */
25
+ export async function POST(req: Request) {
26
+ const admin = requireAdmin(req);
27
+ if (!admin) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
28
+
29
+ try {
30
+ const body = await req.json();
31
+ const { userId, newPassword } = Schema.parse(body);
32
+
33
+ const db = getDb();
34
+
35
+ // Verify user exists.
36
+ const user = db.prepare('SELECT id, email FROM users WHERE id = ?').get(userId) as any;
37
+ if (!user) {
38
+ return NextResponse.json({ error: 'User not found' }, { status: 404 });
39
+ }
40
+
41
+ // Hash new password.
42
+ const hash = bcrypt.hashSync(newPassword, 10);
43
+
44
+ // Update password and invalidate all sessions.
45
+ const tx = db.transaction(() => {
46
+ db.prepare('UPDATE users SET password = ?, updated_at = datetime(\'now\') WHERE id = ?').run(hash, userId);
47
+ db.prepare('DELETE FROM sessions WHERE user_id = ?').run(userId);
48
+ });
49
+ tx();
50
+
51
+ return NextResponse.json({
52
+ success: true,
53
+ message: `Password reset for ${user.email}. All sessions invalidated.`,
54
+ });
55
+ } catch (error: any) {
56
+ if (error instanceof z.ZodError) {
57
+ return NextResponse.json({ error: error.errors[0]?.message || 'Invalid input' }, { status: 400 });
58
+ }
59
+ console.error('[Admin Reset Password]', error?.message);
60
+ return NextResponse.json({ error: 'Failed to reset password' }, { status: 500 });
61
+ }
62
+ }
app/api/admin/stats/route.ts ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { getDb } from '@/lib/db';
3
+ import { requireAdmin } from '@/lib/auth-middleware';
4
+
5
+ /**
6
+ * GET /api/admin/stats — aggregate platform statistics (admin only).
7
+ */
8
+ export async function GET(req: Request) {
9
+ const admin = requireAdmin(req);
10
+ if (!admin) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
11
+
12
+ const db = getDb();
13
+
14
+ const totalUsers = (db.prepare('SELECT COUNT(*) as c FROM users').get() as any).c;
15
+ const verifiedUsers = (db.prepare('SELECT COUNT(*) as c FROM users WHERE email_verified = 1').get() as any).c;
16
+ const adminUsers = (db.prepare('SELECT COUNT(*) as c FROM users WHERE is_admin = 1').get() as any).c;
17
+ const totalHealthData = (db.prepare('SELECT COUNT(*) as c FROM health_data').get() as any).c;
18
+ const totalChats = (db.prepare('SELECT COUNT(*) as c FROM chat_history').get() as any).c;
19
+ const activeSessions = (db.prepare("SELECT COUNT(*) as c FROM sessions WHERE expires_at > datetime('now')").get() as any).c;
20
+
21
+ // Health data breakdown by type.
22
+ const healthBreakdown = db
23
+ .prepare('SELECT type, COUNT(*) as count FROM health_data GROUP BY type ORDER BY count DESC')
24
+ .all() as any[];
25
+
26
+ // Registrations over time (last 30 days).
27
+ const registrations = db
28
+ .prepare(
29
+ `SELECT date(created_at) as day, COUNT(*) as count
30
+ FROM users
31
+ WHERE created_at > datetime('now', '-30 days')
32
+ GROUP BY day ORDER BY day`,
33
+ )
34
+ .all() as any[];
35
+
36
+ return NextResponse.json({
37
+ totalUsers,
38
+ verifiedUsers,
39
+ adminUsers,
40
+ totalHealthData,
41
+ totalChats,
42
+ activeSessions,
43
+ healthBreakdown,
44
+ registrations,
45
+ });
46
+ }
app/api/admin/test-connection/route.ts ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { requireAdmin } from '@/lib/auth-middleware';
3
+ import { loadConfig } from '@/lib/server-config';
4
+
5
+ /**
6
+ * POST /api/admin/test-connection — Test connectivity to a named provider.
7
+ *
8
+ * Body: { provider: "ollabridge" | "huggingface" | "openai" | "anthropic"
9
+ * | "groq" | "watsonx" }
10
+ *
11
+ * Response:
12
+ * { ok: boolean, provider, latencyMs, status?, error?, details? }
13
+ *
14
+ * Used by the "Test Connection" button on each provider card. Mirrors the
15
+ * `ollabridge pair` CLI check — validates that credentials are good and
16
+ * that the provider's /v1/models (or equivalent) endpoint responds.
17
+ *
18
+ * Admin-only.
19
+ */
20
+
21
+ export const runtime = 'nodejs';
22
+ export const dynamic = 'force-dynamic';
23
+
24
+ type Provider = 'ollabridge' | 'huggingface' | 'openai' | 'anthropic' | 'groq' | 'watsonx';
25
+
26
+ interface TestResult {
27
+ ok: boolean;
28
+ provider: Provider;
29
+ latencyMs: number;
30
+ status?: number;
31
+ error?: string;
32
+ details?: string;
33
+ }
34
+
35
+ async function testOllaBridge(url: string, apiKey: string): Promise<Omit<TestResult, 'provider'>> {
36
+ const start = Date.now();
37
+ if (!url) {
38
+ return { ok: false, latencyMs: 0, error: 'URL not configured' };
39
+ }
40
+ try {
41
+ const cleanBase = url.replace(/\/+$/, '');
42
+ const res = await fetch(`${cleanBase}/v1/models`, {
43
+ headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
44
+ signal: AbortSignal.timeout(10000),
45
+ });
46
+ const latencyMs = Date.now() - start;
47
+ if (!res.ok) {
48
+ return { ok: false, latencyMs, status: res.status, error: `HTTP ${res.status}` };
49
+ }
50
+ const data = await res.json().catch(() => null);
51
+ const count = Array.isArray(data?.data) ? data.data.length : 0;
52
+ return {
53
+ ok: true,
54
+ latencyMs,
55
+ status: res.status,
56
+ details: `${count} model${count === 1 ? '' : 's'} available`,
57
+ };
58
+ } catch (e: any) {
59
+ return {
60
+ ok: false,
61
+ latencyMs: Date.now() - start,
62
+ error: e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed',
63
+ };
64
+ }
65
+ }
66
+
67
+ async function testHuggingFace(token: string): Promise<Omit<TestResult, 'provider'>> {
68
+ const start = Date.now();
69
+ if (!token) return { ok: false, latencyMs: 0, error: 'HF token not configured' };
70
+ try {
71
+ // whoami-v2 validates that the token has API access; it's cheaper
72
+ // than hitting the router and gives a clear permission error.
73
+ const res = await fetch('https://huggingface.co/api/whoami-v2', {
74
+ headers: { Authorization: `Bearer ${token}` },
75
+ signal: AbortSignal.timeout(10000),
76
+ });
77
+ const latencyMs = Date.now() - start;
78
+ if (!res.ok) {
79
+ return { ok: false, latencyMs, status: res.status, error: `HTTP ${res.status}` };
80
+ }
81
+ const data = await res.json().catch(() => null);
82
+ return {
83
+ ok: true,
84
+ latencyMs,
85
+ status: res.status,
86
+ details: data?.name ? `Authenticated as ${data.name}` : 'Token valid',
87
+ };
88
+ } catch (e: any) {
89
+ return {
90
+ ok: false,
91
+ latencyMs: Date.now() - start,
92
+ error: e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed',
93
+ };
94
+ }
95
+ }
96
+
97
+ async function testOpenAI(apiKey: string): Promise<Omit<TestResult, 'provider'>> {
98
+ const start = Date.now();
99
+ if (!apiKey) return { ok: false, latencyMs: 0, error: 'OpenAI API key not configured' };
100
+ try {
101
+ const res = await fetch('https://api.openai.com/v1/models', {
102
+ headers: { Authorization: `Bearer ${apiKey}` },
103
+ signal: AbortSignal.timeout(10000),
104
+ });
105
+ const latencyMs = Date.now() - start;
106
+ if (!res.ok) return { ok: false, latencyMs, status: res.status, error: `HTTP ${res.status}` };
107
+ const data = await res.json().catch(() => null);
108
+ const count = Array.isArray(data?.data) ? data.data.length : 0;
109
+ return { ok: true, latencyMs, status: res.status, details: `${count} models visible` };
110
+ } catch (e: any) {
111
+ return {
112
+ ok: false,
113
+ latencyMs: Date.now() - start,
114
+ error: e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed',
115
+ };
116
+ }
117
+ }
118
+
119
+ async function testAnthropic(apiKey: string): Promise<Omit<TestResult, 'provider'>> {
120
+ const start = Date.now();
121
+ if (!apiKey) return { ok: false, latencyMs: 0, error: 'Anthropic API key not configured' };
122
+ try {
123
+ const res = await fetch('https://api.anthropic.com/v1/models', {
124
+ headers: {
125
+ 'x-api-key': apiKey,
126
+ 'anthropic-version': '2023-06-01',
127
+ },
128
+ signal: AbortSignal.timeout(10000),
129
+ });
130
+ const latencyMs = Date.now() - start;
131
+ if (!res.ok) return { ok: false, latencyMs, status: res.status, error: `HTTP ${res.status}` };
132
+ const data = await res.json().catch(() => null);
133
+ const count = Array.isArray(data?.data) ? data.data.length : 0;
134
+ return { ok: true, latencyMs, status: res.status, details: `${count} models visible` };
135
+ } catch (e: any) {
136
+ return {
137
+ ok: false,
138
+ latencyMs: Date.now() - start,
139
+ error: e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed',
140
+ };
141
+ }
142
+ }
143
+
144
+ async function testGroq(apiKey: string): Promise<Omit<TestResult, 'provider'>> {
145
+ const start = Date.now();
146
+ if (!apiKey) return { ok: false, latencyMs: 0, error: 'Groq API key not configured' };
147
+ try {
148
+ const res = await fetch('https://api.groq.com/openai/v1/models', {
149
+ headers: { Authorization: `Bearer ${apiKey}` },
150
+ signal: AbortSignal.timeout(10000),
151
+ });
152
+ const latencyMs = Date.now() - start;
153
+ if (!res.ok) return { ok: false, latencyMs, status: res.status, error: `HTTP ${res.status}` };
154
+ const data = await res.json().catch(() => null);
155
+ const count = Array.isArray(data?.data) ? data.data.length : 0;
156
+ return { ok: true, latencyMs, status: res.status, details: `${count} models visible` };
157
+ } catch (e: any) {
158
+ return {
159
+ ok: false,
160
+ latencyMs: Date.now() - start,
161
+ error: e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed',
162
+ };
163
+ }
164
+ }
165
+
166
+ async function testWatsonx(
167
+ apiKey: string,
168
+ projectId: string,
169
+ _baseUrl: string,
170
+ ): Promise<Omit<TestResult, 'provider'>> {
171
+ const start = Date.now();
172
+ if (!apiKey || !projectId) {
173
+ return { ok: false, latencyMs: 0, error: 'Missing API key or project ID' };
174
+ }
175
+ try {
176
+ const res = await fetch('https://iam.cloud.ibm.com/identity/token', {
177
+ method: 'POST',
178
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
179
+ body: new URLSearchParams({
180
+ grant_type: 'urn:ibm:params:oauth:grant-type:apikey',
181
+ apikey: apiKey,
182
+ }),
183
+ signal: AbortSignal.timeout(10000),
184
+ });
185
+ const latencyMs = Date.now() - start;
186
+ if (!res.ok) return { ok: false, latencyMs, status: res.status, error: `IAM HTTP ${res.status}` };
187
+ const data = await res.json().catch(() => null);
188
+ if (!data?.access_token) return { ok: false, latencyMs, error: 'No access_token in IAM response' };
189
+ return { ok: true, latencyMs, status: 200, details: 'IAM token valid' };
190
+ } catch (e: any) {
191
+ return {
192
+ ok: false,
193
+ latencyMs: Date.now() - start,
194
+ error: e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed',
195
+ };
196
+ }
197
+ }
198
+
199
+ export async function POST(req: Request) {
200
+ const admin = requireAdmin(req);
201
+ if (!admin) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
202
+
203
+ let body: any;
204
+ try {
205
+ body = await req.json();
206
+ } catch {
207
+ return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
208
+ }
209
+
210
+ const provider = body?.provider as Provider;
211
+ if (!provider) {
212
+ return NextResponse.json({ error: 'Missing provider field' }, { status: 400 });
213
+ }
214
+
215
+ const config = loadConfig();
216
+ const { llm } = config;
217
+
218
+ let result: Omit<TestResult, 'provider'>;
219
+ switch (provider) {
220
+ case 'ollabridge':
221
+ result = await testOllaBridge(llm.ollabridgeUrl, llm.ollabridgeApiKey);
222
+ break;
223
+ case 'huggingface':
224
+ result = await testHuggingFace(llm.hfToken);
225
+ break;
226
+ case 'openai':
227
+ result = await testOpenAI(llm.openaiApiKey);
228
+ break;
229
+ case 'anthropic':
230
+ result = await testAnthropic(llm.anthropicApiKey);
231
+ break;
232
+ case 'groq':
233
+ result = await testGroq(llm.groqApiKey);
234
+ break;
235
+ case 'watsonx':
236
+ result = await testWatsonx(llm.watsonxApiKey, llm.watsonxProjectId, llm.watsonxUrl);
237
+ break;
238
+ default:
239
+ return NextResponse.json({ error: `Unknown provider: ${provider}` }, { status: 400 });
240
+ }
241
+
242
+ return NextResponse.json({ provider, ...result });
243
+ }
app/api/admin/users/route.ts ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { getDb } from '@/lib/db';
3
+ import { requireAdmin } from '@/lib/auth-middleware';
4
+
5
+ /**
6
+ * GET /api/admin/users — list all registered users (admin only).
7
+ * Query params: ?page=1&limit=50&search=term
8
+ */
9
+ export async function GET(req: Request) {
10
+ const admin = requireAdmin(req);
11
+ if (!admin) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
12
+
13
+ const url = new URL(req.url);
14
+ const page = Math.max(1, parseInt(url.searchParams.get('page') || '1', 10));
15
+ const limit = Math.min(100, Math.max(1, parseInt(url.searchParams.get('limit') || '50', 10)));
16
+ const search = url.searchParams.get('search')?.trim();
17
+ const offset = (page - 1) * limit;
18
+
19
+ const db = getDb();
20
+
21
+ const where = search ? "WHERE email LIKE ? OR display_name LIKE ?" : "";
22
+ const params = search ? [`%${search}%`, `%${search}%`] : [];
23
+
24
+ const total = (db.prepare(`SELECT COUNT(*) as c FROM users ${where}`).get(...params) as any).c;
25
+
26
+ const rows = db
27
+ .prepare(
28
+ `SELECT id, email, display_name, email_verified, is_admin, created_at
29
+ FROM users ${where}
30
+ ORDER BY created_at DESC LIMIT ? OFFSET ?`,
31
+ )
32
+ .all(...params, limit, offset) as any[];
33
+
34
+ // Health data count per user.
35
+ const users = rows.map((r) => {
36
+ const healthCount = (
37
+ db.prepare('SELECT COUNT(*) as c FROM health_data WHERE user_id = ?').get(r.id) as any
38
+ ).c;
39
+ const chatCount = (
40
+ db.prepare('SELECT COUNT(*) as c FROM chat_history WHERE user_id = ?').get(r.id) as any
41
+ ).c;
42
+ return {
43
+ id: r.id,
44
+ email: r.email,
45
+ displayName: r.display_name,
46
+ emailVerified: !!r.email_verified,
47
+ isAdmin: !!r.is_admin,
48
+ createdAt: r.created_at,
49
+ healthDataCount: healthCount,
50
+ chatHistoryCount: chatCount,
51
+ };
52
+ });
53
+
54
+ return NextResponse.json({ users, total, page, limit });
55
+ }
56
+
57
+ /**
58
+ * DELETE /api/admin/users?id=<userId> — delete a user (admin only).
59
+ * CASCADE deletes all their health data, chat history, and sessions.
60
+ */
61
+ export async function DELETE(req: Request) {
62
+ const admin = requireAdmin(req);
63
+ if (!admin) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
64
+
65
+ const url = new URL(req.url);
66
+ const userId = url.searchParams.get('id');
67
+ if (!userId) return NextResponse.json({ error: 'Missing user id' }, { status: 400 });
68
+
69
+ // Prevent deleting yourself.
70
+ if (userId === admin.id) {
71
+ return NextResponse.json({ error: 'Cannot delete your own admin account' }, { status: 400 });
72
+ }
73
+
74
+ const db = getDb();
75
+ db.prepare('DELETE FROM users WHERE id = ?').run(userId);
76
+
77
+ return NextResponse.json({ success: true });
78
+ }
app/api/auth/delete-account/route.ts ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { z } from 'zod';
3
+ import { getDb } from '@/lib/db';
4
+ import { requireAdmin } from '@/lib/auth-middleware';
5
+
6
+ const Schema = z.object({
7
+ userId: z.string().min(1),
8
+ confirmEmail: z.string().email(),
9
+ });
10
+
11
+ /**
12
+ * POST /api/auth/delete-account — ADMIN-ONLY account deletion.
13
+ *
14
+ * Only admins can delete accounts. This prevents:
15
+ * - Hackers with stolen tokens from destroying user data
16
+ * - Automated scripts mass-deleting accounts
17
+ * - Accidental self-deletion
18
+ *
19
+ * Requires both userId AND confirmEmail to match (double verification).
20
+ * Uses CASCADE deletes via foreign keys — one operation wipes everything.
21
+ *
22
+ * For GDPR: users REQUEST deletion via support/admin panel.
23
+ * Admin reviews and executes. This is the industry standard for
24
+ * healthcare apps (MyChart, Epic, Cerner all require admin action).
25
+ */
26
+ export async function POST(req: Request) {
27
+ // ADMIN ONLY — reject all non-admin requests
28
+ const admin = requireAdmin(req);
29
+ if (!admin) {
30
+ return NextResponse.json(
31
+ { error: 'Admin access required. Users must request account deletion through the admin.' },
32
+ { status: 403 },
33
+ );
34
+ }
35
+
36
+ try {
37
+ const body = await req.json();
38
+ const { userId, confirmEmail } = Schema.parse(body);
39
+
40
+ const db = getDb();
41
+
42
+ // Verify user exists and email matches (double check)
43
+ const user = db.prepare('SELECT id, email, is_admin FROM users WHERE id = ?').get(userId) as any;
44
+ if (!user) {
45
+ return NextResponse.json({ error: 'User not found' }, { status: 404 });
46
+ }
47
+
48
+ if (user.email.toLowerCase() !== confirmEmail.toLowerCase()) {
49
+ return NextResponse.json(
50
+ { error: 'Email confirmation does not match. Deletion aborted.' },
51
+ { status: 400 },
52
+ );
53
+ }
54
+
55
+ // Prevent deleting admin accounts (safety net)
56
+ if (user.is_admin) {
57
+ return NextResponse.json(
58
+ { error: 'Cannot delete admin accounts via this endpoint.' },
59
+ { status: 403 },
60
+ );
61
+ }
62
+
63
+ // CASCADE deletes handle: sessions, health_data, chat_history
64
+ db.prepare('DELETE FROM users WHERE id = ?').run(userId);
65
+
66
+ console.log(`[Account Deletion] Admin ${admin.email} deleted user ${user.email} (${userId})`);
67
+
68
+ return NextResponse.json({
69
+ success: true,
70
+ message: `Account ${user.email} and all associated data permanently deleted.`,
71
+ deletedBy: admin.email,
72
+ });
73
+ } catch (error: any) {
74
+ if (error instanceof z.ZodError) {
75
+ return NextResponse.json({ error: 'Invalid request. Provide userId and confirmEmail.' }, { status: 400 });
76
+ }
77
+ console.error('[Delete Account]', error?.message);
78
+ return NextResponse.json({ error: 'Failed to delete account' }, { status: 500 });
79
+ }
80
+ }
app/api/auth/forgot-password/route.ts ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { z } from 'zod';
3
+ import { getDb, genVerificationCode, resetExpiry } from '@/lib/db';
4
+ import { sendPasswordResetEmail } from '@/lib/email';
5
+
6
+ const Schema = z.object({
7
+ email: z.string().email(),
8
+ });
9
+
10
+ /**
11
+ * POST /api/auth/forgot-password — sends a reset code to the user's email.
12
+ *
13
+ * Always returns 200 even if the email doesn't exist (prevents email enumeration).
14
+ */
15
+ export async function POST(req: Request) {
16
+ try {
17
+ const body = await req.json();
18
+ const { email } = Schema.parse(body);
19
+
20
+ const db = getDb();
21
+ const user = db.prepare('SELECT id, email FROM users WHERE email = ?').get(email.toLowerCase()) as any;
22
+
23
+ if (user) {
24
+ const code = genVerificationCode();
25
+ db.prepare(
26
+ `UPDATE users SET reset_token = ?, reset_expires = ?, updated_at = datetime('now')
27
+ WHERE id = ?`,
28
+ ).run(code, resetExpiry(), user.id);
29
+
30
+ await sendPasswordResetEmail(user.email, code);
31
+ }
32
+
33
+ // Always return success to prevent email enumeration.
34
+ return NextResponse.json({
35
+ message: 'If that email is registered, a reset code has been sent.',
36
+ });
37
+ } catch (error: any) {
38
+ if (error instanceof z.ZodError) {
39
+ return NextResponse.json({ error: 'Invalid email' }, { status: 400 });
40
+ }
41
+ console.error('[Auth ForgotPassword]', error?.message);
42
+ return NextResponse.json({ error: 'Request failed' }, { status: 500 });
43
+ }
44
+ }
app/api/auth/login/route.ts ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { z } from 'zod';
3
+ import bcrypt from 'bcryptjs';
4
+ import { getDb, genToken, sessionExpiry, pruneExpiredSessions } from '@/lib/db';
5
+ import { checkRateLimit, getClientIp } from '@/lib/rate-limit';
6
+
7
+ const Schema = z.object({
8
+ email: z.string().email(),
9
+ password: z.string().min(1),
10
+ });
11
+
12
+ export async function POST(req: Request) {
13
+ // Rate limit: 10 login attempts per minute per IP
14
+ const ip = getClientIp(req);
15
+ const rl = checkRateLimit(`login:${ip}`, 10, 60_000);
16
+ if (!rl.allowed) {
17
+ return NextResponse.json(
18
+ { error: 'Too many login attempts. Please wait a moment.' },
19
+ { status: 429, headers: { 'Retry-After': String(Math.ceil(rl.retryAfterMs / 1000)) } },
20
+ );
21
+ }
22
+
23
+ try {
24
+ const body = await req.json();
25
+ const { email, password } = Schema.parse(body);
26
+
27
+ const db = getDb();
28
+ pruneExpiredSessions();
29
+
30
+ const user = db
31
+ .prepare('SELECT id, email, password, display_name, email_verified, is_admin FROM users WHERE email = ?')
32
+ .get(email.toLowerCase()) as any;
33
+
34
+ if (!user || !bcrypt.compareSync(password, user.password)) {
35
+ return NextResponse.json({ error: 'Invalid email or password' }, { status: 401 });
36
+ }
37
+
38
+ const token = genToken();
39
+ db.prepare('INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)').run(
40
+ token,
41
+ user.id,
42
+ sessionExpiry(),
43
+ );
44
+
45
+ return NextResponse.json({
46
+ user: {
47
+ id: user.id,
48
+ email: user.email,
49
+ displayName: user.display_name,
50
+ emailVerified: !!user.email_verified,
51
+ isAdmin: !!user.is_admin,
52
+ },
53
+ token,
54
+ });
55
+ } catch (error: any) {
56
+ if (error instanceof z.ZodError) {
57
+ return NextResponse.json({ error: 'Invalid input' }, { status: 400 });
58
+ }
59
+ console.error('[Auth Login]', error?.message);
60
+ return NextResponse.json({ error: 'Login failed' }, { status: 500 });
61
+ }
62
+ }
app/api/auth/logout/route.ts ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { getDb } from '@/lib/db';
3
+
4
+ export async function POST(req: Request) {
5
+ const h = req.headers.get('authorization');
6
+ const token = h && h.startsWith('Bearer ') ? h.slice(7).trim() : null;
7
+
8
+ if (token) {
9
+ const db = getDb();
10
+ db.prepare('DELETE FROM sessions WHERE token = ?').run(token);
11
+ }
12
+
13
+ return NextResponse.json({ success: true });
14
+ }
app/api/auth/me/route.ts ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { getDb, pruneExpiredSessions } from '@/lib/db';
3
+
4
+ export async function GET(req: Request) {
5
+ const h = req.headers.get('authorization');
6
+ const token = h && h.startsWith('Bearer ') ? h.slice(7).trim() : null;
7
+ if (!token) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
8
+
9
+ const db = getDb();
10
+ pruneExpiredSessions();
11
+
12
+ const row = db
13
+ .prepare(
14
+ `SELECT u.id, u.email, u.display_name, u.email_verified, u.is_admin, u.created_at
15
+ FROM sessions s JOIN users u ON u.id = s.user_id
16
+ WHERE s.token = ? AND s.expires_at > datetime('now')`,
17
+ )
18
+ .get(token) as any;
19
+
20
+ if (!row) return NextResponse.json({ error: 'Session expired' }, { status: 401 });
21
+
22
+ return NextResponse.json({
23
+ user: {
24
+ id: row.id,
25
+ email: row.email,
26
+ displayName: row.display_name,
27
+ emailVerified: !!row.email_verified,
28
+ isAdmin: !!row.is_admin,
29
+ createdAt: row.created_at,
30
+ },
31
+ });
32
+ }
app/api/auth/register/route.ts ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { z } from 'zod';
3
+ import bcrypt from 'bcryptjs';
4
+ import { getDb, genId, genToken, genVerificationCode, codeExpiry, sessionExpiry } from '@/lib/db';
5
+ import { sendVerificationEmail } from '@/lib/email';
6
+ import { checkRateLimit, getClientIp } from '@/lib/rate-limit';
7
+
8
+ const Schema = z.object({
9
+ email: z.string().email().max(255),
10
+ password: z.string().min(6).max(128),
11
+ displayName: z.string().max(50).optional(),
12
+ });
13
+
14
+ export async function POST(req: Request) {
15
+ // Rate limit: 5 registrations per minute per IP
16
+ const ip = getClientIp(req);
17
+ const rl = checkRateLimit(`register:${ip}`, 5, 60_000);
18
+ if (!rl.allowed) {
19
+ return NextResponse.json(
20
+ { error: 'Too many registration attempts. Please wait.' },
21
+ { status: 429, headers: { 'Retry-After': String(Math.ceil(rl.retryAfterMs / 1000)) } },
22
+ );
23
+ }
24
+
25
+ try {
26
+ const body = await req.json();
27
+ const { email, password, displayName } = Schema.parse(body);
28
+
29
+ const db = getDb();
30
+
31
+ const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
32
+ if (existing) {
33
+ return NextResponse.json({ error: 'An account with this email already exists' }, { status: 409 });
34
+ }
35
+
36
+ const id = genId();
37
+ const hash = bcrypt.hashSync(password, 10);
38
+ const code = genVerificationCode();
39
+ const expires = codeExpiry();
40
+
41
+ db.prepare(
42
+ `INSERT INTO users (id, email, password, display_name, verification_code, verification_expires)
43
+ VALUES (?, ?, ?, ?, ?, ?)`,
44
+ ).run(id, email.toLowerCase(), hash, displayName || null, code, expires);
45
+
46
+ // Auto-login
47
+ const token = genToken();
48
+ db.prepare('INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)').run(token, id, sessionExpiry());
49
+
50
+ // Send verification email (best-effort, don't block registration)
51
+ sendVerificationEmail(email, code).catch(() => {});
52
+
53
+ return NextResponse.json(
54
+ {
55
+ user: { id, email: email.toLowerCase(), displayName, emailVerified: false },
56
+ token,
57
+ message: 'Account created. Check your email for a verification code.',
58
+ },
59
+ { status: 201 },
60
+ );
61
+ } catch (error: any) {
62
+ if (error instanceof z.ZodError) {
63
+ return NextResponse.json({ error: 'Invalid input', details: error.errors }, { status: 400 });
64
+ }
65
+ console.error('[Auth Register]', error?.message);
66
+ return NextResponse.json({ error: 'Registration failed' }, { status: 500 });
67
+ }
68
+ }
app/api/auth/resend-verification/route.ts ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { getDb, genVerificationCode, codeExpiry } from '@/lib/db';
3
+ import { authenticateRequest } from '@/lib/auth-middleware';
4
+ import { sendVerificationEmail } from '@/lib/email';
5
+
6
+ /**
7
+ * POST /api/auth/resend-verification — resend the 6-digit verification code.
8
+ */
9
+ export async function POST(req: Request) {
10
+ const user = authenticateRequest(req);
11
+ if (!user) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
12
+
13
+ const db = getDb();
14
+ const row = db.prepare('SELECT email, email_verified FROM users WHERE id = ?').get(user.id) as any;
15
+
16
+ if (!row) return NextResponse.json({ error: 'User not found' }, { status: 404 });
17
+ if (row.email_verified) return NextResponse.json({ message: 'Email already verified' });
18
+
19
+ const code = genVerificationCode();
20
+ db.prepare(
21
+ `UPDATE users SET verification_code = ?, verification_expires = ?, updated_at = datetime('now')
22
+ WHERE id = ?`,
23
+ ).run(code, codeExpiry(), user.id);
24
+
25
+ await sendVerificationEmail(row.email, code);
26
+
27
+ return NextResponse.json({ message: 'Verification code sent' });
28
+ }
app/api/auth/reset-password/route.ts ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { z } from 'zod';
3
+ import bcrypt from 'bcryptjs';
4
+ import { getDb, genToken, sessionExpiry } from '@/lib/db';
5
+
6
+ const Schema = z.object({
7
+ email: z.string().email(),
8
+ code: z.string().length(6),
9
+ newPassword: z.string().min(6).max(128),
10
+ });
11
+
12
+ /**
13
+ * POST /api/auth/reset-password — reset password with the 6-digit code.
14
+ * On success, auto-logs the user in and returns a session token.
15
+ */
16
+ export async function POST(req: Request) {
17
+ try {
18
+ const body = await req.json();
19
+ const { email, code, newPassword } = Schema.parse(body);
20
+
21
+ const db = getDb();
22
+ const user = db
23
+ .prepare('SELECT id, reset_token, reset_expires FROM users WHERE email = ?')
24
+ .get(email.toLowerCase()) as any;
25
+
26
+ if (
27
+ !user ||
28
+ user.reset_token !== code ||
29
+ !user.reset_expires ||
30
+ new Date(user.reset_expires) < new Date()
31
+ ) {
32
+ return NextResponse.json({ error: 'Invalid or expired reset code' }, { status: 400 });
33
+ }
34
+
35
+ const hash = bcrypt.hashSync(newPassword, 10);
36
+ db.prepare(
37
+ `UPDATE users SET password = ?, reset_token = NULL, reset_expires = NULL, updated_at = datetime('now')
38
+ WHERE id = ?`,
39
+ ).run(hash, user.id);
40
+
41
+ // Invalidate all existing sessions for this user (security best practice).
42
+ db.prepare('DELETE FROM sessions WHERE user_id = ?').run(user.id);
43
+
44
+ // Auto-login with new session.
45
+ const token = genToken();
46
+ db.prepare('INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)').run(
47
+ token,
48
+ user.id,
49
+ sessionExpiry(),
50
+ );
51
+
52
+ return NextResponse.json({
53
+ message: 'Password reset successfully',
54
+ token,
55
+ });
56
+ } catch (error: any) {
57
+ if (error instanceof z.ZodError) {
58
+ return NextResponse.json({ error: 'Invalid input', details: error.errors }, { status: 400 });
59
+ }
60
+ console.error('[Auth ResetPassword]', error?.message);
61
+ return NextResponse.json({ error: 'Reset failed' }, { status: 500 });
62
+ }
63
+ }
app/api/auth/verify-email/route.ts ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { z } from 'zod';
3
+ import { getDb } from '@/lib/db';
4
+ import { authenticateRequest } from '@/lib/auth-middleware';
5
+ import { sendWelcomeEmail } from '@/lib/email';
6
+
7
+ const Schema = z.object({
8
+ code: z.string().length(6),
9
+ });
10
+
11
+ /**
12
+ * POST /api/auth/verify-email — verify email with 6-digit code.
13
+ * Requires auth (the user must be logged in to verify their own email).
14
+ */
15
+ export async function POST(req: Request) {
16
+ const user = authenticateRequest(req);
17
+ if (!user) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
18
+
19
+ try {
20
+ const body = await req.json();
21
+ const { code } = Schema.parse(body);
22
+
23
+ const db = getDb();
24
+ const row = db
25
+ .prepare(
26
+ `SELECT verification_code, verification_expires, email, email_verified
27
+ FROM users WHERE id = ?`,
28
+ )
29
+ .get(user.id) as any;
30
+
31
+ if (!row) return NextResponse.json({ error: 'User not found' }, { status: 404 });
32
+ if (row.email_verified) return NextResponse.json({ message: 'Email already verified' });
33
+
34
+ if (
35
+ row.verification_code !== code ||
36
+ !row.verification_expires ||
37
+ new Date(row.verification_expires) < new Date()
38
+ ) {
39
+ return NextResponse.json({ error: 'Invalid or expired code' }, { status: 400 });
40
+ }
41
+
42
+ db.prepare(
43
+ `UPDATE users SET email_verified = 1, verification_code = NULL, verification_expires = NULL, updated_at = datetime('now')
44
+ WHERE id = ?`,
45
+ ).run(user.id);
46
+
47
+ // Send welcome email
48
+ sendWelcomeEmail(row.email).catch(() => {});
49
+
50
+ return NextResponse.json({ message: 'Email verified successfully', emailVerified: true });
51
+ } catch (error: any) {
52
+ if (error instanceof z.ZodError) {
53
+ return NextResponse.json({ error: 'Invalid code format' }, { status: 400 });
54
+ }
55
+ console.error('[Auth Verify]', error?.message);
56
+ return NextResponse.json({ error: 'Verification failed' }, { status: 500 });
57
+ }
58
+ }
app/api/chat-history/route.ts ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { z } from 'zod';
3
+ import { getDb, genId } from '@/lib/db';
4
+ import { authenticateRequest } from '@/lib/auth-middleware';
5
+
6
+ /**
7
+ * GET /api/chat-history → list conversations (newest first, max 100)
8
+ * POST /api/chat-history → save a conversation
9
+ * DELETE /api/chat-history?id=<id> → delete one conversation
10
+ */
11
+
12
+ export async function GET(req: Request) {
13
+ const user = authenticateRequest(req);
14
+ if (!user) {
15
+ return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
16
+ }
17
+
18
+ const db = getDb();
19
+ const rows = db
20
+ .prepare(
21
+ 'SELECT id, preview, topic, created_at FROM chat_history WHERE user_id = ? ORDER BY created_at DESC LIMIT 100',
22
+ )
23
+ .all(user.id) as any[];
24
+
25
+ return NextResponse.json({
26
+ conversations: rows.map((r) => ({
27
+ id: r.id,
28
+ preview: r.preview,
29
+ topic: r.topic,
30
+ createdAt: r.created_at,
31
+ })),
32
+ });
33
+ }
34
+
35
+ const SaveSchema = z.object({
36
+ preview: z.string().max(200),
37
+ messages: z.array(
38
+ z.object({
39
+ role: z.enum(['user', 'assistant', 'system']),
40
+ content: z.string(),
41
+ }),
42
+ ),
43
+ topic: z.string().max(50).optional(),
44
+ });
45
+
46
+ export async function POST(req: Request) {
47
+ const user = authenticateRequest(req);
48
+ if (!user) {
49
+ return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
50
+ }
51
+
52
+ try {
53
+ const body = await req.json();
54
+ const { preview, messages, topic } = SaveSchema.parse(body);
55
+
56
+ const db = getDb();
57
+ const id = genId();
58
+
59
+ db.prepare(
60
+ 'INSERT INTO chat_history (id, user_id, preview, messages, topic) VALUES (?, ?, ?, ?, ?)',
61
+ ).run(id, user.id, preview, JSON.stringify(messages), topic || null);
62
+
63
+ return NextResponse.json({ id }, { status: 201 });
64
+ } catch (error: any) {
65
+ if (error instanceof z.ZodError) {
66
+ return NextResponse.json(
67
+ { error: 'Invalid input', details: error.errors },
68
+ { status: 400 },
69
+ );
70
+ }
71
+ console.error('[Chat History POST]', error?.message);
72
+ return NextResponse.json({ error: 'Save failed' }, { status: 500 });
73
+ }
74
+ }
75
+
76
+ export async function DELETE(req: Request) {
77
+ const user = authenticateRequest(req);
78
+ if (!user) {
79
+ return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
80
+ }
81
+
82
+ const url = new URL(req.url);
83
+ const id = url.searchParams.get('id');
84
+ if (!id) {
85
+ return NextResponse.json({ error: 'Missing id' }, { status: 400 });
86
+ }
87
+
88
+ const db = getDb();
89
+ db.prepare('DELETE FROM chat_history WHERE id = ? AND user_id = ?').run(
90
+ id,
91
+ user.id,
92
+ );
93
+
94
+ return NextResponse.json({ success: true });
95
+ }
app/api/chat/route.ts ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest } from 'next/server';
2
+ import { z } from 'zod';
3
+ import { streamWithFallback, type ChatMessage } from '@/lib/providers';
4
+ import { triageMessage } from '@/lib/safety/triage';
5
+ import { getEmergencyInfo } from '@/lib/safety/emergency-numbers';
6
+ import { buildRAGContext } from '@/lib/rag/medical-kb';
7
+ import { buildMedicalSystemPrompt } from '@/lib/medical-knowledge';
8
+
9
+ const RequestSchema = z.object({
10
+ messages: z.array(
11
+ z.object({
12
+ role: z.enum(['system', 'user', 'assistant']),
13
+ content: z.string(),
14
+ })
15
+ ),
16
+ model: z.string().optional().default('qwen2.5:1.5b'),
17
+ language: z.string().optional().default('en'),
18
+ countryCode: z.string().optional().default('US'),
19
+ });
20
+
21
+ export async function POST(request: NextRequest) {
22
+ const routeStartedAt = Date.now();
23
+ try {
24
+ const body = await request.json();
25
+ const { messages, model, language, countryCode } = RequestSchema.parse(body);
26
+
27
+ // Single-line JSON payload so the HF Space logs API (SSE) can be grepped
28
+ // with a simple prefix match. Every stage below tags itself `[Chat]`.
29
+ console.log(
30
+ `[Chat] route.enter ${JSON.stringify({
31
+ turns: messages.length,
32
+ model,
33
+ language,
34
+ countryCode,
35
+ userAgent: request.headers.get('user-agent')?.slice(0, 80) || null,
36
+ })}`,
37
+ );
38
+
39
+ // Step 1: Emergency triage on the latest user message
40
+ const lastUserMessage = messages.filter((m) => m.role === 'user').pop();
41
+ if (lastUserMessage) {
42
+ const triage = triageMessage(lastUserMessage.content);
43
+ console.log(
44
+ `[Chat] route.triage ${JSON.stringify({
45
+ isEmergency: triage.isEmergency,
46
+ userChars: lastUserMessage.content.length,
47
+ })}`,
48
+ );
49
+
50
+ if (triage.isEmergency) {
51
+ const emergencyInfo = getEmergencyInfo(countryCode);
52
+ const emergencyResponse = [
53
+ `**EMERGENCY DETECTED**\n\n`,
54
+ `${triage.guidance}\n\n`,
55
+ `**Call emergency services NOW:**\n`,
56
+ `- Emergency: **${emergencyInfo.emergency}** (${emergencyInfo.country})\n`,
57
+ `- Ambulance: **${emergencyInfo.ambulance}**\n`,
58
+ emergencyInfo.crisisHotline
59
+ ? `- Crisis Hotline: **${emergencyInfo.crisisHotline}**\n`
60
+ : '',
61
+ `\nDo not delay. Every minute matters.`,
62
+ ].join('');
63
+
64
+ const encoder = new TextEncoder();
65
+ const stream = new ReadableStream({
66
+ start(controller) {
67
+ const data = JSON.stringify({
68
+ choices: [{ delta: { content: emergencyResponse } }],
69
+ provider: 'triage',
70
+ model: 'emergency-detection',
71
+ isEmergency: true,
72
+ });
73
+ controller.enqueue(encoder.encode(`data: ${data}\n\n`));
74
+ controller.enqueue(encoder.encode('data: [DONE]\n\n'));
75
+ controller.close();
76
+ },
77
+ });
78
+
79
+ return new Response(stream, {
80
+ headers: {
81
+ 'Content-Type': 'text/event-stream',
82
+ 'Cache-Control': 'no-cache',
83
+ Connection: 'keep-alive',
84
+ },
85
+ });
86
+ }
87
+ }
88
+
89
+ // Step 2: Build RAG context from the medical knowledge base.
90
+ const ragStart = Date.now();
91
+ const ragContext = lastUserMessage
92
+ ? buildRAGContext(lastUserMessage.content)
93
+ : '';
94
+ console.log(
95
+ `[Chat] route.rag ${JSON.stringify({
96
+ chars: ragContext.length,
97
+ latencyMs: Date.now() - ragStart,
98
+ })}`,
99
+ );
100
+
101
+ // Step 3: Build a structured, locale-aware system prompt that grounds
102
+ // the model in WHO/CDC/NHS guidance and pins the response language,
103
+ // country, emergency number, and measurement system. This replaces
104
+ // the old inline "respond in X language" instruction.
105
+ const emergencyInfo = getEmergencyInfo(countryCode);
106
+ const systemPrompt = buildMedicalSystemPrompt({
107
+ country: countryCode,
108
+ language,
109
+ emergencyNumber: emergencyInfo.emergency,
110
+ });
111
+
112
+ // Step 4: Assemble the final message list: system prompt first, then
113
+ // the conversation history, with the last user turn augmented by the
114
+ // retrieved RAG context (kept inline so the model treats it as recent
115
+ // reference material rather than a background instruction).
116
+ const priorMessages = messages.slice(0, -1);
117
+ const finalUserContent = [
118
+ lastUserMessage?.content || '',
119
+ ragContext
120
+ ? `\n\n[Reference material retrieved from the medical knowledge base — use if relevant]\n${ragContext}`
121
+ : '',
122
+ ].join('');
123
+
124
+ const augmentedMessages: ChatMessage[] = [
125
+ { role: 'system' as const, content: systemPrompt },
126
+ ...priorMessages,
127
+ { role: 'user' as const, content: finalUserContent },
128
+ ];
129
+
130
+ // Step 4: Stream response via the provider fallback chain.
131
+ console.log(
132
+ `[Chat] route.provider.dispatch ${JSON.stringify({
133
+ systemPromptChars: systemPrompt.length,
134
+ totalMessages: augmentedMessages.length,
135
+ preparedInMs: Date.now() - routeStartedAt,
136
+ })}`,
137
+ );
138
+ const stream = await streamWithFallback(augmentedMessages, model);
139
+ console.log(
140
+ `[Chat] route.stream.opened ${JSON.stringify({
141
+ totalMs: Date.now() - routeStartedAt,
142
+ })}`,
143
+ );
144
+
145
+ return new Response(stream, {
146
+ headers: {
147
+ 'Content-Type': 'text/event-stream',
148
+ 'Cache-Control': 'no-cache',
149
+ Connection: 'keep-alive',
150
+ },
151
+ });
152
+ } catch (error) {
153
+ console.error(
154
+ `[Chat] route.error ${JSON.stringify({
155
+ totalMs: Date.now() - routeStartedAt,
156
+ name: (error as any)?.name,
157
+ message: String((error as any)?.message || error).slice(0, 200),
158
+ })}`,
159
+ );
160
+
161
+ if (error instanceof z.ZodError) {
162
+ return new Response(
163
+ JSON.stringify({ error: 'Invalid request', details: error.errors }),
164
+ { status: 400, headers: { 'Content-Type': 'application/json' } }
165
+ );
166
+ }
167
+
168
+ return new Response(
169
+ JSON.stringify({ error: 'Internal server error' }),
170
+ { status: 500, headers: { 'Content-Type': 'application/json' } }
171
+ );
172
+ }
173
+ }
app/api/geo/route.ts ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { getEmergencyInfo } from '@/lib/safety/emergency-numbers';
3
+
4
+ export const runtime = 'nodejs';
5
+ export const dynamic = 'force-dynamic';
6
+
7
+ /**
8
+ * IP-based country + language + emergency number detection.
9
+ *
10
+ * Privacy posture:
11
+ * - Platform geo headers are read first (zero external calls, zero PII).
12
+ * - If nothing is present we fall back to ipapi.co (free, no key), but
13
+ * ONLY for public IPs — RFC1918, loopback, and link-local are never
14
+ * sent outbound.
15
+ * - The client IP is never logged or returned.
16
+ */
17
+
18
+ const GEO_HEADERS = [
19
+ 'x-vercel-ip-country',
20
+ 'cf-ipcountry',
21
+ 'x-nf-country',
22
+ 'cloudfront-viewer-country',
23
+ 'x-appengine-country',
24
+ 'fly-client-ip-country',
25
+ 'x-forwarded-country',
26
+ ] as const;
27
+
28
+ // Country → best-effort primary language out of the ones MedOS ships.
29
+ // Kept local to this file so we don't bloat lib/i18n for a single use.
30
+ const COUNTRY_TO_LANGUAGE: Record<string, string> = {
31
+ US: 'en', GB: 'en', CA: 'en', AU: 'en', NZ: 'en', IE: 'en', ZA: 'en',
32
+ NG: 'en', KE: 'en', GH: 'en', UG: 'en', SG: 'en', MY: 'en', IN: 'en',
33
+ PK: 'en', BD: 'en', LK: 'en', PH: 'en',
34
+ ES: 'es', MX: 'es', AR: 'es', CO: 'es', CL: 'es', PE: 'es', VE: 'es',
35
+ EC: 'es', GT: 'es', CU: 'es', BO: 'es', DO: 'es', HN: 'es', PY: 'es',
36
+ SV: 'es', NI: 'es', CR: 'es', PA: 'es', UY: 'es', PR: 'es',
37
+ BR: 'pt', PT: 'pt', AO: 'pt', MZ: 'pt',
38
+ FR: 'fr', BE: 'fr', LU: 'fr', MC: 'fr', SN: 'fr', CI: 'fr', CM: 'fr',
39
+ CD: 'fr', HT: 'fr', DZ: 'fr', TN: 'fr', MA: 'ar',
40
+ DE: 'de', AT: 'de', CH: 'de', LI: 'de',
41
+ IT: 'it', SM: 'it', VA: 'it',
42
+ NL: 'nl', SR: 'nl',
43
+ PL: 'pl',
44
+ RU: 'ru', BY: 'ru', KZ: 'ru', KG: 'ru',
45
+ TR: 'tr',
46
+ SA: 'ar', AE: 'ar', EG: 'ar', JO: 'ar', IQ: 'ar', SY: 'ar', LB: 'ar',
47
+ YE: 'ar', LY: 'ar', OM: 'ar', QA: 'ar', KW: 'ar', BH: 'ar', SD: 'ar',
48
+ PS: 'ar',
49
+ CN: 'zh', TW: 'zh', HK: 'zh',
50
+ JP: 'ja',
51
+ KR: 'ko',
52
+ TH: 'th',
53
+ VN: 'vi',
54
+ TZ: 'sw',
55
+ };
56
+
57
+ function pickHeaderCountry(req: Request): string | null {
58
+ for (const h of GEO_HEADERS) {
59
+ const v = req.headers.get(h);
60
+ if (v && v.length >= 2 && v.toUpperCase() !== 'XX') {
61
+ return v.toUpperCase().slice(0, 2);
62
+ }
63
+ }
64
+ return null;
65
+ }
66
+
67
+ function extractClientIp(req: Request): string | null {
68
+ const xff = req.headers.get('x-forwarded-for');
69
+ if (xff) {
70
+ const first = xff.split(',')[0]?.trim();
71
+ if (first) return first;
72
+ }
73
+ return req.headers.get('x-real-ip');
74
+ }
75
+
76
+ function isPrivateIp(ip: string): boolean {
77
+ if (!ip) return true;
78
+ if (ip === '::1' || ip === '127.0.0.1' || ip.startsWith('fe80:')) return true;
79
+ const m = ip.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
80
+ if (!m) return false;
81
+ const a = parseInt(m[1], 10);
82
+ const b = parseInt(m[2], 10);
83
+ if (a === 10) return true;
84
+ if (a === 127) return true;
85
+ if (a === 169 && b === 254) return true;
86
+ if (a === 172 && b >= 16 && b <= 31) return true;
87
+ if (a === 192 && b === 168) return true;
88
+ return false;
89
+ }
90
+
91
+ async function lookupIpapi(ip: string): Promise<string | null> {
92
+ try {
93
+ const controller = new AbortController();
94
+ const timeout = setTimeout(() => controller.abort(), 1500);
95
+ const res = await fetch(`https://ipapi.co/${encodeURIComponent(ip)}/country/`, {
96
+ signal: controller.signal,
97
+ headers: { 'User-Agent': 'MedOS-Geo/1.0' },
98
+ });
99
+ clearTimeout(timeout);
100
+ if (!res.ok) return null;
101
+ const text = (await res.text()).trim().toUpperCase();
102
+ if (/^[A-Z]{2}$/.test(text)) return text;
103
+ return null;
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
108
+
109
+ export async function GET(req: Request): Promise<Response> {
110
+ let country = pickHeaderCountry(req);
111
+ let source: 'header' | 'ipapi' | 'default' = country ? 'header' : 'default';
112
+
113
+ if (!country) {
114
+ const ip = extractClientIp(req);
115
+ if (ip && !isPrivateIp(ip)) {
116
+ const looked = await lookupIpapi(ip);
117
+ if (looked) {
118
+ country = looked;
119
+ source = 'ipapi';
120
+ }
121
+ }
122
+ }
123
+
124
+ const finalCountry = country || 'US';
125
+ const info = getEmergencyInfo(finalCountry);
126
+ const language = COUNTRY_TO_LANGUAGE[finalCountry] ?? 'en';
127
+
128
+ return NextResponse.json(
129
+ {
130
+ country: finalCountry,
131
+ language,
132
+ emergencyNumber: info.emergency,
133
+ source,
134
+ },
135
+ {
136
+ headers: {
137
+ 'Cache-Control': 'private, max-age=3600',
138
+ 'X-Robots-Tag': 'noindex',
139
+ },
140
+ },
141
+ );
142
+ }
app/api/health-data/route.ts ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { z } from 'zod';
3
+ import { getDb, genId } from '@/lib/db';
4
+ import { authenticateRequest } from '@/lib/auth-middleware';
5
+
6
+ /**
7
+ * GET /api/health-data → fetch all health data for the user
8
+ * GET /api/health-data?type=vital → filter by type
9
+ * POST /api/health-data/sync → bulk sync from client localStorage
10
+ */
11
+ export async function GET(req: Request) {
12
+ const user = authenticateRequest(req);
13
+ if (!user) {
14
+ return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
15
+ }
16
+
17
+ const db = getDb();
18
+ const url = new URL(req.url);
19
+ const type = url.searchParams.get('type');
20
+
21
+ const rows = type
22
+ ? db
23
+ .prepare('SELECT * FROM health_data WHERE user_id = ? AND type = ? ORDER BY updated_at DESC')
24
+ .all(user.id, type)
25
+ : db
26
+ .prepare('SELECT * FROM health_data WHERE user_id = ? ORDER BY updated_at DESC')
27
+ .all(user.id);
28
+
29
+ // Parse the JSON `data` field back into objects.
30
+ const items = (rows as any[]).map((r) => ({
31
+ id: r.id,
32
+ type: r.type,
33
+ data: JSON.parse(r.data),
34
+ createdAt: r.created_at,
35
+ updatedAt: r.updated_at,
36
+ }));
37
+
38
+ return NextResponse.json({ items });
39
+ }
40
+
41
+ /**
42
+ * POST /api/health-data — upsert a single health-data record.
43
+ */
44
+ const UpsertSchema = z.object({
45
+ id: z.string().optional(),
46
+ type: z.enum([
47
+ 'medication',
48
+ 'medication_log',
49
+ 'appointment',
50
+ 'vital',
51
+ 'record',
52
+ 'conversation',
53
+ ]),
54
+ data: z.record(z.any()),
55
+ });
56
+
57
+ export async function POST(req: Request) {
58
+ const user = authenticateRequest(req);
59
+ if (!user) {
60
+ return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
61
+ }
62
+
63
+ try {
64
+ const body = await req.json();
65
+ const { id, type, data } = UpsertSchema.parse(body);
66
+
67
+ const db = getDb();
68
+ const itemId = id || genId();
69
+ const json = JSON.stringify(data);
70
+
71
+ // Upsert: insert or replace. SQLite's ON CONFLICT handles this cleanly.
72
+ db.prepare(
73
+ `INSERT INTO health_data (id, user_id, type, data, updated_at)
74
+ VALUES (?, ?, ?, ?, datetime('now'))
75
+ ON CONFLICT(id) DO UPDATE SET data = excluded.data, updated_at = datetime('now')`,
76
+ ).run(itemId, user.id, type, json);
77
+
78
+ return NextResponse.json({ id: itemId, type }, { status: 201 });
79
+ } catch (error: any) {
80
+ if (error instanceof z.ZodError) {
81
+ return NextResponse.json(
82
+ { error: 'Invalid input', details: error.errors },
83
+ { status: 400 },
84
+ );
85
+ }
86
+ console.error('[Health Data POST]', error?.message);
87
+ return NextResponse.json({ error: 'Save failed' }, { status: 500 });
88
+ }
89
+ }
90
+
91
+ /**
92
+ * DELETE /api/health-data?id=<id> — delete one record.
93
+ */
94
+ export async function DELETE(req: Request) {
95
+ const user = authenticateRequest(req);
96
+ if (!user) {
97
+ return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
98
+ }
99
+
100
+ const url = new URL(req.url);
101
+ const id = url.searchParams.get('id');
102
+ if (!id) {
103
+ return NextResponse.json({ error: 'Missing id' }, { status: 400 });
104
+ }
105
+
106
+ const db = getDb();
107
+ db.prepare('DELETE FROM health_data WHERE id = ? AND user_id = ?').run(
108
+ id,
109
+ user.id,
110
+ );
111
+
112
+ return NextResponse.json({ success: true });
113
+ }
app/api/health-data/sync/route.ts ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { z } from 'zod';
3
+ import { getDb, genId } from '@/lib/db';
4
+ import { authenticateRequest } from '@/lib/auth-middleware';
5
+
6
+ /**
7
+ * POST /api/health-data/sync — bulk sync from client localStorage.
8
+ *
9
+ * The client sends its entire localStorage health dataset (medications,
10
+ * appointments, vitals, records, medication_logs, conversations). The
11
+ * server upserts each item. This runs on:
12
+ * - First login (migrates existing guest data to the account)
13
+ * - Periodic background sync while logged in
14
+ *
15
+ * Idempotent: calling it twice with the same data is safe.
16
+ */
17
+
18
+ const ItemSchema = z.object({
19
+ id: z.string(),
20
+ type: z.enum([
21
+ 'medication',
22
+ 'medication_log',
23
+ 'appointment',
24
+ 'vital',
25
+ 'record',
26
+ 'conversation',
27
+ ]),
28
+ data: z.record(z.any()),
29
+ });
30
+
31
+ const SyncSchema = z.object({
32
+ items: z.array(ItemSchema).max(5000),
33
+ });
34
+
35
+ export async function POST(req: Request) {
36
+ const user = authenticateRequest(req);
37
+ if (!user) {
38
+ return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
39
+ }
40
+
41
+ try {
42
+ const body = await req.json();
43
+ const { items } = SyncSchema.parse(body);
44
+
45
+ const db = getDb();
46
+
47
+ const upsert = db.prepare(
48
+ `INSERT INTO health_data (id, user_id, type, data, updated_at)
49
+ VALUES (?, ?, ?, ?, datetime('now'))
50
+ ON CONFLICT(id) DO UPDATE SET data = excluded.data, updated_at = datetime('now')`,
51
+ );
52
+
53
+ // Run as a single transaction for speed (1000+ items in <50ms).
54
+ const tx = db.transaction(() => {
55
+ for (const item of items) {
56
+ upsert.run(item.id, user.id, item.type, JSON.stringify(item.data));
57
+ }
58
+ });
59
+ tx();
60
+
61
+ return NextResponse.json({
62
+ synced: items.length,
63
+ message: `${items.length} items synced`,
64
+ });
65
+ } catch (error: any) {
66
+ if (error instanceof z.ZodError) {
67
+ return NextResponse.json(
68
+ { error: 'Invalid input', details: error.errors },
69
+ { status: 400 },
70
+ );
71
+ }
72
+ console.error('[Health Data Sync]', error?.message);
73
+ return NextResponse.json({ error: 'Sync failed' }, { status: 500 });
74
+ }
75
+ }
app/api/health/route.ts ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+
3
+ export async function GET() {
4
+ return NextResponse.json({
5
+ status: 'healthy',
6
+ service: 'medos-global',
7
+ timestamp: new Date().toISOString(),
8
+ version: '1.0.0',
9
+ });
10
+ }
app/api/models/route.ts ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { fetchAvailableModels } from '@/lib/providers/ollabridge-models';
3
+
4
+ export async function GET() {
5
+ try {
6
+ const models = await fetchAvailableModels();
7
+ return NextResponse.json({ models });
8
+ } catch {
9
+ return NextResponse.json(
10
+ { models: [], error: 'Failed to fetch models' },
11
+ { status: 200 } // Return 200 with empty array — non-critical endpoint
12
+ );
13
+ }
14
+ }
app/api/nearby/route.ts ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+
3
+ /**
4
+ * POST /api/nearby — Proxy to MetaEngine Nearby Finder.
5
+ * GET /api/nearby — Health check.
6
+ *
7
+ * Calls the Gradio API endpoint (2-step: submit → fetch result).
8
+ * Handles sleeping Spaces, timeouts, and Overpass errors gracefully.
9
+ */
10
+
11
+ const NEARBY_URL =
12
+ process.env.NEARBY_URL || 'https://ruslanmv-metaengine-nearby.hf.space';
13
+
14
+ export const runtime = 'nodejs';
15
+ export const dynamic = 'force-dynamic';
16
+
17
+ export async function POST(req: Request) {
18
+ try {
19
+ const body = await req.json();
20
+ const { lat, lon, radius_m = 3000, entity_type = 'all', limit = 25 } = body;
21
+
22
+ // Step 1: Submit to Gradio API
23
+ const submitRes = await fetch(`${NEARBY_URL}/gradio_api/call/search_ui`, {
24
+ method: 'POST',
25
+ headers: { 'Content-Type': 'application/json' },
26
+ body: JSON.stringify({
27
+ data: [String(lat), String(lon), radius_m, entity_type, limit],
28
+ }),
29
+ signal: AbortSignal.timeout(30000),
30
+ });
31
+
32
+ if (!submitRes.ok) {
33
+ const ct = submitRes.headers.get('content-type') || '';
34
+ if (!ct.includes('json')) {
35
+ return NextResponse.json(
36
+ { error: 'Nearby finder is waking up. Please try again in a moment.', count: 0, results: [] },
37
+ { status: 503 },
38
+ );
39
+ }
40
+ return NextResponse.json({ error: 'Search submission failed', count: 0, results: [] }, { status: 502 });
41
+ }
42
+
43
+ const { event_id } = await submitRes.json();
44
+ if (!event_id) {
45
+ return NextResponse.json({ error: 'No event ID received', count: 0, results: [] }, { status: 502 });
46
+ }
47
+
48
+ // Step 2: Fetch result via SSE
49
+ const resultRes = await fetch(
50
+ `${NEARBY_URL}/gradio_api/call/search_ui/${event_id}`,
51
+ { signal: AbortSignal.timeout(30000) },
52
+ );
53
+
54
+ const text = await resultRes.text();
55
+
56
+ // Parse SSE data line: "data: [summary, table, json_string]"
57
+ const dataLine = text.split('\n').find((l: string) => l.startsWith('data: '));
58
+ if (!dataLine) {
59
+ return NextResponse.json({ error: 'Empty response from search', count: 0, results: [] });
60
+ }
61
+
62
+ const gradioData = JSON.parse(dataLine.slice(6));
63
+ // gradioData = [summary_text, table_array, json_string]
64
+ const jsonStr = gradioData?.[2];
65
+ if (!jsonStr) {
66
+ return NextResponse.json({ error: 'No results', count: 0, results: [] });
67
+ }
68
+
69
+ try {
70
+ const parsed = JSON.parse(jsonStr);
71
+ return NextResponse.json(parsed);
72
+ } catch {
73
+ // If the json_str is an error message, return it
74
+ return NextResponse.json({ error: jsonStr, count: 0, results: [] });
75
+ }
76
+ } catch (error: any) {
77
+ console.error('[Nearby Proxy]', error?.name, error?.message?.slice(0, 100));
78
+ const msg =
79
+ error?.name === 'TimeoutError' || error?.name === 'AbortError'
80
+ ? 'Search timed out. The service may be starting up — please try again.'
81
+ : 'Nearby finder unavailable. Please try again.';
82
+ return NextResponse.json({ error: msg, count: 0, results: [] }, { status: 502 });
83
+ }
84
+ }
85
+
86
+ export async function GET() {
87
+ try {
88
+ const res = await fetch(NEARBY_URL, { signal: AbortSignal.timeout(8000) });
89
+ if (res.ok) return NextResponse.json({ status: 'ok' });
90
+ return NextResponse.json({ status: 'waking' }, { status: 503 });
91
+ } catch {
92
+ return NextResponse.json({ status: 'sleeping' }, { status: 503 });
93
+ }
94
+ }
app/api/og/route.tsx ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ImageResponse } from 'next/og';
2
+
3
+ export const runtime = 'edge';
4
+
5
+ /**
6
+ * Dynamic Open Graph image endpoint.
7
+ *
8
+ * Every share on Twitter / WhatsApp / LinkedIn / Telegram / iMessage
9
+ * renders a branded 1200x630 card. The query becomes the card title so
10
+ * a link like `https://ruslanmv-medibot.hf.space/?q=chest+pain` previews
11
+ * as a premium, unique image instead of the default favicon blob.
12
+ *
13
+ * Usage from the client: `/api/og?q=<question>&lang=<code>`
14
+ * The endpoint also handles missing parameters gracefully (returns a
15
+ * default brand card).
16
+ */
17
+ export async function GET(req: Request): Promise<Response> {
18
+ try {
19
+ const { searchParams } = new URL(req.url);
20
+ const rawQuery = (searchParams.get('q') || '').trim();
21
+ const lang = (searchParams.get('lang') || 'en').slice(0, 5);
22
+
23
+ // Hard-limit title length so long queries don't overflow.
24
+ const title =
25
+ rawQuery.length > 120 ? rawQuery.slice(0, 117) + '…' : rawQuery;
26
+
27
+ const subtitle = title
28
+ ? 'Ask MedOS — free, private, in your language'
29
+ : 'Free AI medical assistant — 20 languages, no sign-up';
30
+
31
+ const headline = title || 'Tell me what\'s bothering you.';
32
+
33
+ return new ImageResponse(
34
+ (
35
+ <div
36
+ style={{
37
+ width: '100%',
38
+ height: '100%',
39
+ display: 'flex',
40
+ flexDirection: 'column',
41
+ padding: '72px',
42
+ background:
43
+ 'radial-gradient(1200px 800px at 10% -10%, rgba(59,130,246,0.35), transparent 60%),' +
44
+ 'radial-gradient(1000px 600px at 110% 10%, rgba(20,184,166,0.30), transparent 60%),' +
45
+ 'linear-gradient(180deg, #0B1220 0%, #0E1627 100%)',
46
+ color: '#F8FAFC',
47
+ fontFamily: 'sans-serif',
48
+ position: 'relative',
49
+ }}
50
+ >
51
+ {/* Top bar: brand mark + language chip */}
52
+ <div
53
+ style={{
54
+ display: 'flex',
55
+ alignItems: 'center',
56
+ justifyContent: 'space-between',
57
+ marginBottom: '48px',
58
+ }}
59
+ >
60
+ <div style={{ display: 'flex', alignItems: 'center', gap: '18px' }}>
61
+ <div
62
+ style={{
63
+ width: '72px',
64
+ height: '72px',
65
+ borderRadius: '22px',
66
+ background: 'linear-gradient(135deg, #3B82F6 0%, #14B8A6 100%)',
67
+ display: 'flex',
68
+ alignItems: 'center',
69
+ justifyContent: 'center',
70
+ fontSize: '44px',
71
+ boxShadow: '0 20px 60px -10px rgba(59,130,246,0.65)',
72
+ }}
73
+ >
74
+
75
+ </div>
76
+ <div style={{ display: 'flex', flexDirection: 'column' }}>
77
+ <div
78
+ style={{
79
+ fontSize: '40px',
80
+ fontWeight: 800,
81
+ letterSpacing: '-0.02em',
82
+ lineHeight: 1,
83
+ }}
84
+ >
85
+ MedOS
86
+ </div>
87
+ <div
88
+ style={{
89
+ fontSize: '16px',
90
+ color: '#14B8A6',
91
+ fontWeight: 700,
92
+ textTransform: 'uppercase',
93
+ letterSpacing: '0.18em',
94
+ marginTop: '6px',
95
+ }}
96
+ >
97
+ Worldwide medical AI
98
+ </div>
99
+ </div>
100
+ </div>
101
+
102
+ <div
103
+ style={{
104
+ display: 'flex',
105
+ alignItems: 'center',
106
+ gap: '10px',
107
+ padding: '10px 18px',
108
+ borderRadius: '9999px',
109
+ background: 'rgba(255,255,255,0.08)',
110
+ border: '1px solid rgba(255,255,255,0.18)',
111
+ fontSize: '18px',
112
+ color: '#CBD5E1',
113
+ fontWeight: 600,
114
+ }}
115
+ >
116
+ <span
117
+ style={{
118
+ width: '8px',
119
+ height: '8px',
120
+ borderRadius: '9999px',
121
+ background: '#22C55E',
122
+ }}
123
+ />
124
+ {lang.toUpperCase()} · FREE · NO SIGN-UP
125
+ </div>
126
+ </div>
127
+
128
+ {/* Main content */}
129
+ <div
130
+ style={{
131
+ display: 'flex',
132
+ flexDirection: 'column',
133
+ flex: 1,
134
+ justifyContent: 'center',
135
+ }}
136
+ >
137
+ {title && (
138
+ <div
139
+ style={{
140
+ fontSize: '22px',
141
+ fontWeight: 700,
142
+ color: '#14B8A6',
143
+ textTransform: 'uppercase',
144
+ letterSpacing: '0.18em',
145
+ marginBottom: '22px',
146
+ }}
147
+ >
148
+ Ask MedOS
149
+ </div>
150
+ )}
151
+ <div
152
+ style={{
153
+ fontSize: title ? '68px' : '84px',
154
+ fontWeight: 800,
155
+ lineHeight: 1.1,
156
+ letterSpacing: '-0.025em',
157
+ color: '#F8FAFC',
158
+ maxWidth: '1000px',
159
+ }}
160
+ >
161
+ {title ? `"${headline}"` : headline}
162
+ </div>
163
+ <div
164
+ style={{
165
+ fontSize: '26px',
166
+ color: '#94A3B8',
167
+ marginTop: '28px',
168
+ fontWeight: 500,
169
+ }}
170
+ >
171
+ {subtitle}
172
+ </div>
173
+ </div>
174
+
175
+ {/* Footer: trust strip */}
176
+ <div
177
+ style={{
178
+ display: 'flex',
179
+ alignItems: 'center',
180
+ gap: '28px',
181
+ fontSize: '18px',
182
+ color: '#94A3B8',
183
+ fontWeight: 600,
184
+ borderTop: '1px solid rgba(255,255,255,0.1)',
185
+ paddingTop: '28px',
186
+ }}
187
+ >
188
+ <span style={{ color: '#14B8A6', fontWeight: 700 }}>
189
+ ✓ Aligned with WHO · CDC · NHS
190
+ </span>
191
+ <span>·</span>
192
+ <span>Private &amp; anonymous</span>
193
+ <span>·</span>
194
+ <span>24/7</span>
195
+ </div>
196
+ </div>
197
+ ),
198
+ {
199
+ width: 1200,
200
+ height: 630,
201
+ },
202
+ );
203
+ } catch {
204
+ // Never 500 an OG endpoint — social crawlers will blacklist the domain.
205
+ return new Response('OG image generation failed', { status: 500 });
206
+ }
207
+ }
app/api/rag/route.ts ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { searchMedicalKB } from '@/lib/rag/medical-kb';
3
+
4
+ export async function POST(request: NextRequest) {
5
+ try {
6
+ const { query, topN = 3 } = await request.json();
7
+
8
+ if (!query || typeof query !== 'string') {
9
+ return NextResponse.json(
10
+ { error: 'Query is required' },
11
+ { status: 400 }
12
+ );
13
+ }
14
+
15
+ const results = searchMedicalKB(query, topN);
16
+
17
+ return NextResponse.json({
18
+ results: results.map((r) => ({
19
+ topic: r.topic,
20
+ context: r.context,
21
+ })),
22
+ count: results.length,
23
+ });
24
+ } catch {
25
+ return NextResponse.json(
26
+ { error: 'Internal server error' },
27
+ { status: 500 }
28
+ );
29
+ }
30
+ }
app/api/scan/route.ts ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+
3
+ /**
4
+ * POST /api/scan — Server-side proxy to the Medicine Scanner Space.
5
+ *
6
+ * Why proxy instead of calling from the browser:
7
+ * - HF_TOKEN_INFERENCE stays server-side (never in the JS bundle)
8
+ * - Same-origin request from the browser (no CORS preflight)
9
+ * - Backend injects the token and forwards to the Scanner Space
10
+ * - If the Scanner Space is sleeping, this request wakes it
11
+ *
12
+ * The Scanner Space receives:
13
+ * - The image as multipart/form-data (passthrough)
14
+ * - Authorization: Bearer header with the inference token
15
+ * - Returns structured JSON with medicine data
16
+ */
17
+
18
+ const SCANNER_URL =
19
+ process.env.SCANNER_URL || 'https://ruslanmv-medicine-scanner.hf.space';
20
+ const INFERENCE_TOKEN = process.env.HF_TOKEN_INFERENCE || '';
21
+
22
+ export const runtime = 'nodejs';
23
+ export const dynamic = 'force-dynamic';
24
+
25
+ export async function POST(req: Request) {
26
+ try {
27
+ // Read the incoming form data (image file from the frontend)
28
+ const formData = await req.formData();
29
+
30
+ // Build outbound headers — inject the inference token server-side
31
+ const headers: Record<string, string> = {};
32
+ if (INFERENCE_TOKEN) {
33
+ headers['Authorization'] = `Bearer ${INFERENCE_TOKEN}`;
34
+ }
35
+
36
+ // Forward to the Medicine Scanner Space
37
+ const response = await fetch(`${SCANNER_URL}/api/scan`, {
38
+ method: 'POST',
39
+ headers,
40
+ body: formData,
41
+ });
42
+
43
+ const data = await response.json();
44
+ return NextResponse.json(data, { status: response.status });
45
+ } catch (error: any) {
46
+ console.error('[Scan Proxy]', error?.message);
47
+ return NextResponse.json(
48
+ {
49
+ success: false,
50
+ error: 'Medicine scanner unavailable. Please try again.',
51
+ medicine: null,
52
+ },
53
+ { status: 502 },
54
+ );
55
+ }
56
+ }
57
+
58
+ /**
59
+ * GET /api/scan/health — Check if the Scanner Space is awake.
60
+ * Used by the frontend to show "waking up" status.
61
+ */
62
+ export async function GET() {
63
+ try {
64
+ const controller = new AbortController();
65
+ const timeout = setTimeout(() => controller.abort(), 5000);
66
+
67
+ const res = await fetch(`${SCANNER_URL}/api/health`, {
68
+ signal: controller.signal,
69
+ });
70
+ clearTimeout(timeout);
71
+
72
+ if (res.ok) {
73
+ const data = await res.json();
74
+ return NextResponse.json(data);
75
+ }
76
+ return NextResponse.json({ status: 'unavailable' }, { status: 503 });
77
+ } catch {
78
+ return NextResponse.json({ status: 'sleeping' }, { status: 503 });
79
+ }
80
+ }
app/api/sessions/route.ts ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
3
+ import { join } from 'path';
4
+
5
+ /**
6
+ * Server-side session counter.
7
+ * Stores count in /tmp/medos-data/sessions.json (persists across requests, resets on container restart).
8
+ * On HF Spaces with persistent storage, use /data/ instead of /tmp/.
9
+ *
10
+ * GET /api/sessions → returns { count: number }
11
+ * POST /api/sessions → increments and returns { count: number }
12
+ */
13
+
14
+ const DATA_DIR = process.env.PERSISTENT_DIR || '/tmp/medos-data';
15
+ const COUNTER_FILE = join(DATA_DIR, 'sessions.json');
16
+ const BASE_COUNT = 423000; // Historical base from before server-side tracking
17
+
18
+ interface CounterData {
19
+ count: number;
20
+ lastUpdated: string;
21
+ }
22
+
23
+ function ensureDir(): void {
24
+ if (!existsSync(DATA_DIR)) {
25
+ mkdirSync(DATA_DIR, { recursive: true });
26
+ }
27
+ }
28
+
29
+ function readCounter(): number {
30
+ ensureDir();
31
+ try {
32
+ if (existsSync(COUNTER_FILE)) {
33
+ const data: CounterData = JSON.parse(readFileSync(COUNTER_FILE, 'utf8'));
34
+ return data.count;
35
+ }
36
+ } catch {
37
+ // corrupted file, reset
38
+ }
39
+ return 0;
40
+ }
41
+
42
+ function incrementCounter(): number {
43
+ ensureDir();
44
+ const current = readCounter();
45
+ const next = current + 1;
46
+ const data: CounterData = {
47
+ count: next,
48
+ lastUpdated: new Date().toISOString(),
49
+ };
50
+ writeFileSync(COUNTER_FILE, JSON.stringify(data), 'utf8');
51
+ return next;
52
+ }
53
+
54
+ export async function GET() {
55
+ const sessionCount = readCounter();
56
+ return NextResponse.json({
57
+ count: BASE_COUNT + sessionCount,
58
+ sessions: sessionCount,
59
+ base: BASE_COUNT,
60
+ });
61
+ }
62
+
63
+ export async function POST() {
64
+ const sessionCount = incrementCounter();
65
+ return NextResponse.json({
66
+ count: BASE_COUNT + sessionCount,
67
+ sessions: sessionCount,
68
+ base: BASE_COUNT,
69
+ });
70
+ }
app/api/triage/route.ts ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { triageMessage } from '@/lib/safety/triage';
3
+ import { getEmergencyInfo } from '@/lib/safety/emergency-numbers';
4
+
5
+ export async function POST(request: NextRequest) {
6
+ try {
7
+ const { message, countryCode = 'US' } = await request.json();
8
+
9
+ if (!message || typeof message !== 'string') {
10
+ return NextResponse.json(
11
+ { error: 'Message is required' },
12
+ { status: 400 }
13
+ );
14
+ }
15
+
16
+ const triage = triageMessage(message);
17
+ const emergencyInfo = getEmergencyInfo(countryCode);
18
+
19
+ return NextResponse.json({
20
+ ...triage,
21
+ emergencyInfo: triage.isEmergency ? emergencyInfo : null,
22
+ });
23
+ } catch {
24
+ return NextResponse.json(
25
+ { error: 'Internal server error' },
26
+ { status: 500 }
27
+ );
28
+ }
29
+ }
app/globals.css ADDED
@@ -0,0 +1,378 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ /* ============================================================
6
+ * MedOS design tokens — light + dark
7
+ * ============================================================ */
8
+ :root {
9
+ /* Surfaces (light mode — soft white, never pure #FFFFFF) */
10
+ --surface-0: 247 249 251; /* app backdrop #F7F9FB */
11
+ --surface-1: 255 255 255; /* cards */
12
+ --surface-2: 241 245 249; /* elevated panels #F1F5F9 */
13
+ --surface-3: 226 232 240; /* borders / rails */
14
+
15
+ --ink-base: 15 23 42; /* slate-900 */
16
+ --ink-muted: 71 85 105; /* slate-600 */
17
+ --ink-subtle: 148 163 184; /* slate-400 */
18
+ --ink-inverse: 255 255 255;
19
+
20
+ --line: 226 232 240; /* slate-200 */
21
+
22
+ color-scheme: light;
23
+ }
24
+
25
+ .dark {
26
+ /* Dark mode — warm deep navy, NOT pure black */
27
+ --surface-0: 11 18 32; /* #0B1220 */
28
+ --surface-1: 18 27 45; /* #121B2D elevated card */
29
+ --surface-2: 24 34 54; /* #182236 panel */
30
+ --surface-3: 34 46 71; /* #222E47 border */
31
+
32
+ --ink-base: 241 245 249; /* slate-100 */
33
+ --ink-muted: 148 163 184; /* slate-400 */
34
+ --ink-subtle: 100 116 139; /* slate-500 */
35
+ --ink-inverse: 15 23 42;
36
+
37
+ --line: 34 46 71;
38
+
39
+ color-scheme: dark;
40
+ }
41
+
42
+ @layer base {
43
+ html,
44
+ body {
45
+ @apply h-full w-full;
46
+ /* iOS Safari 100vh fix: dvh accounts for the collapsible address bar.
47
+ Falls back to 100vh for browsers that don't support dvh. */
48
+ height: 100vh;
49
+ height: 100dvh;
50
+ }
51
+
52
+ html {
53
+ font-family: var(--font-sans), Inter,
54
+ /* CJK fallbacks (Chinese, Japanese, Korean) */
55
+ "Noto Sans CJK SC", "PingFang SC", "Hiragino Sans", "MS Gothic",
56
+ "Malgun Gothic", "Apple SD Gothic Neo",
57
+ /* Arabic */
58
+ "Noto Sans Arabic", "Geeza Pro", "Tahoma",
59
+ /* Devanagari (Hindi) */
60
+ "Noto Sans Devanagari",
61
+ /* Thai */
62
+ "Noto Sans Thai",
63
+ /* System fallbacks */
64
+ ui-sans-serif, system-ui, -apple-system,
65
+ "Segoe UI", Roboto, sans-serif;
66
+ font-feature-settings: "cv02", "cv03", "cv04", "cv11", "ss01";
67
+ -webkit-font-smoothing: antialiased;
68
+ -moz-osx-font-smoothing: grayscale;
69
+ text-rendering: optimizeLegibility;
70
+ }
71
+
72
+ body {
73
+ @apply text-ink-base antialiased;
74
+ background: theme("backgroundImage.light-app");
75
+ background-attachment: fixed;
76
+ line-height: 1.6;
77
+ letter-spacing: -0.005em;
78
+ }
79
+
80
+ .dark body {
81
+ background: theme("backgroundImage.dark-app");
82
+ background-attachment: fixed;
83
+ }
84
+
85
+ /* Slightly larger, more readable body copy — medical trust */
86
+ p { line-height: 1.65; }
87
+
88
+ /* Focus rings that are visible in both modes */
89
+ :focus-visible {
90
+ outline: 2px solid rgb(var(--color-brand, 59 130 246));
91
+ outline-offset: 2px;
92
+ border-radius: 8px;
93
+ }
94
+ }
95
+
96
+ @layer components {
97
+ .glass-card {
98
+ @apply bg-surface-1/80 backdrop-blur-xl border border-line/60 shadow-soft;
99
+ }
100
+ .glass-strong {
101
+ @apply bg-surface-1/95 backdrop-blur-2xl border border-line/70 shadow-card;
102
+ }
103
+ /* Section headings inside an AI answer (Summary / Self-care …) */
104
+ .answer-section {
105
+ @apply relative pl-4 mt-4 first:mt-0;
106
+ }
107
+ .answer-section::before {
108
+ content: "";
109
+ @apply absolute left-0 top-1 bottom-1 w-1 rounded-full bg-brand-500/60;
110
+ }
111
+ }
112
+
113
+ @layer utilities {
114
+ .animate-in { animation: fadeIn 0.5s ease-in-out; }
115
+ .slide-in-from-bottom-4 { animation: slideInFromBottom 0.5s ease-in-out; }
116
+ .delay-100 { animation-delay: 100ms; }
117
+ .delay-200 { animation-delay: 200ms; }
118
+
119
+ /* Shimmer utility for the "Analyzing…" typing state */
120
+ .shimmer-text {
121
+ background: linear-gradient(
122
+ 90deg,
123
+ rgb(var(--ink-muted) / 0.5) 0%,
124
+ rgb(var(--ink-base)) 50%,
125
+ rgb(var(--ink-muted) / 0.5) 100%
126
+ );
127
+ background-size: 200% 100%;
128
+ -webkit-background-clip: text;
129
+ background-clip: text;
130
+ color: transparent;
131
+ animation: shimmer 2.2s linear infinite;
132
+ }
133
+
134
+ @keyframes fadeIn {
135
+ from { opacity: 0; }
136
+ to { opacity: 1; }
137
+ }
138
+ @keyframes slideInFromBottom {
139
+ from { opacity: 0; transform: translateY(1rem); }
140
+ to { opacity: 1; transform: translateY(0); }
141
+ }
142
+
143
+ /* Medicine scanner viewfinder — pulsing green border */
144
+ @keyframes scannerPulse {
145
+ 0%, 100% { border-color: rgba(34, 197, 94, 0.5); box-shadow: 0 0 0 0 rgba(34, 197, 94, 0); }
146
+ 50% { border-color: rgba(34, 197, 94, 1); box-shadow: 0 0 20px 4px rgba(34, 197, 94, 0.15); }
147
+ }
148
+ @keyframes scanLine {
149
+ 0% { top: 8%; }
150
+ 50% { top: 88%; }
151
+ 100% { top: 8%; }
152
+ }
153
+ @keyframes scanSuccess {
154
+ 0% { transform: scale(1); border-color: rgba(34, 197, 94, 0.6); }
155
+ 50% { transform: scale(1.02); border-color: rgba(34, 197, 94, 1); }
156
+ 100% { transform: scale(1); border-color: rgba(34, 197, 94, 0.6); }
157
+ }
158
+ @keyframes scanProgress {
159
+ 0% { width: 0%; }
160
+ 100% { width: 100%; }
161
+ }
162
+ .animate-scanner-pulse { animation: scannerPulse 2s ease-in-out infinite; }
163
+ .animate-scan-line { animation: scanLine 2.5s ease-in-out infinite; }
164
+ .animate-scan-success { animation: scanSuccess 0.6s ease-out; }
165
+ .animate-scan-progress { animation: scanProgress 3s ease-out forwards; }
166
+ }
167
+
168
+ /* ------------------------------------------------------------
169
+ * Dark-mode compatibility layer for legacy screens that still
170
+ * reference slate/white utility classes directly. Remaps them to
171
+ * the design-token surfaces so Settings/Records/Schedule/Topics/
172
+ * Emergency look right in dark mode without a full rewrite.
173
+ * New components should prefer the `bg-surface-*` and `text-ink-*`
174
+ * tokens directly and won't be affected by these rules.
175
+ * ------------------------------------------------------------ */
176
+ .dark .bg-white { background-color: rgb(var(--surface-1)) !important; }
177
+ .dark .bg-slate-50 { background-color: rgb(var(--surface-2)) !important; }
178
+ .dark .bg-slate-100 { background-color: rgb(var(--surface-2)) !important; }
179
+ .dark .bg-\[\#F8FAFC\] { background-color: rgb(var(--surface-0)) !important; }
180
+ .dark .bg-\[\#F7F9FB\] { background-color: rgb(var(--surface-0)) !important; }
181
+
182
+ .dark .text-slate-900 { color: rgb(var(--ink-base)) !important; }
183
+ .dark .text-slate-800 { color: rgb(var(--ink-base)) !important; }
184
+ .dark .text-slate-700 { color: rgb(var(--ink-base) / 0.92) !important; }
185
+ .dark .text-slate-600 { color: rgb(var(--ink-muted)) !important; }
186
+ .dark .text-slate-500 { color: rgb(var(--ink-muted)) !important; }
187
+ .dark .text-slate-400 { color: rgb(var(--ink-subtle)) !important; }
188
+ .dark .text-slate-300 { color: rgb(var(--ink-subtle) / 0.85) !important; }
189
+
190
+ .dark .border-slate-50 { border-color: rgb(var(--line) / 0.55) !important; }
191
+ .dark .border-slate-100 { border-color: rgb(var(--line) / 0.7) !important; }
192
+ .dark .border-slate-200 { border-color: rgb(var(--line) / 0.9) !important; }
193
+
194
+ .dark .placeholder-slate-400::placeholder { color: rgb(var(--ink-subtle)); }
195
+
196
+ /* Soft tinted surfaces used on a handful of views (blue-50, rose-50,
197
+ * amber-50, etc.) — in dark mode dim them into brand/accent tints. */
198
+ .dark .bg-blue-50 { background-color: rgba(59,130,246,0.10) !important; }
199
+ .dark .bg-blue-100 { background-color: rgba(59,130,246,0.18) !important; }
200
+ .dark .bg-indigo-50 { background-color: rgba(99,102,241,0.10) !important; }
201
+ .dark .bg-rose-50 { background-color: rgba(244,63,94,0.10) !important; }
202
+ .dark .bg-red-50 { background-color: rgba(239,68,68,0.10) !important; }
203
+ .dark .bg-amber-50 { background-color: rgba(245,158,11,0.10) !important; }
204
+ .dark .bg-emerald-50 { background-color: rgba(16,185,129,0.10) !important; }
205
+ .dark .bg-purple-50 { background-color: rgba(168,85,247,0.10) !important; }
206
+
207
+ .dark .border-blue-100 { border-color: rgba(59,130,246,0.28) !important; }
208
+ .dark .border-blue-200 { border-color: rgba(59,130,246,0.38) !important; }
209
+ .dark .border-red-200 { border-color: rgba(239,68,68,0.38) !important; }
210
+ .dark .border-amber-200 { border-color: rgba(245,158,11,0.38) !important; }
211
+
212
+ /* Scrollbars — subtle in both modes */
213
+ ::-webkit-scrollbar { width: 10px; height: 10px; }
214
+ ::-webkit-scrollbar-track { background: transparent; }
215
+ ::-webkit-scrollbar-thumb {
216
+ background: rgb(var(--ink-subtle) / 0.35);
217
+ border-radius: 9999px;
218
+ border: 2px solid transparent;
219
+ background-clip: padding-box;
220
+ }
221
+ ::-webkit-scrollbar-thumb:hover {
222
+ background: rgb(var(--ink-subtle) / 0.55);
223
+ background-clip: padding-box;
224
+ }
225
+
226
+ ::selection {
227
+ background: rgba(59, 130, 246, 0.22);
228
+ color: inherit;
229
+ }
230
+
231
+ /* Safe area utilities for notched phones (iPhone X+, Android gesture nav) */
232
+ .safe-area-bottom { padding-bottom: env(safe-area-inset-bottom, 0px); }
233
+ .pb-safe-area { padding-bottom: env(safe-area-inset-bottom, 0px); }
234
+ .safe-area-top { padding-top: env(safe-area-inset-top, 0px); }
235
+ .px-safe-area {
236
+ padding-left: env(safe-area-inset-left, 0px);
237
+ padding-right: env(safe-area-inset-right, 0px);
238
+ }
239
+
240
+ /* ============================================================
241
+ * RTL (Right-to-Left) support for Arabic (ar) and Urdu (ur)
242
+ * Set via document.documentElement.dir = "rtl" in MedOSApp.tsx
243
+ * ============================================================ */
244
+ [dir="rtl"] {
245
+ /* Base direction */
246
+ direction: rtl;
247
+ text-align: right;
248
+ }
249
+
250
+ /* Flex containers reverse in RTL */
251
+ [dir="rtl"] .flex { direction: rtl; }
252
+
253
+ /* Fix drawer slide direction (left → right for RTL) */
254
+ [dir="rtl"] aside { left: auto; right: 0; }
255
+ [dir="rtl"] .-translate-x-full { transform: translateX(100%); }
256
+ [dir="rtl"] .translate-x-0 { transform: translateX(0); }
257
+
258
+ /* Flip margins and paddings for common patterns */
259
+ [dir="rtl"] .ml-auto { margin-left: 0; margin-right: auto; }
260
+ [dir="rtl"] .mr-auto { margin-right: 0; margin-left: auto; }
261
+ [dir="rtl"] .text-left { text-align: right; }
262
+ [dir="rtl"] .text-right { text-align: left; }
263
+
264
+ /* Fix icon + text alignment in nav items */
265
+ [dir="rtl"] .gap-2 { gap: 0.5rem; }
266
+ [dir="rtl"] .gap-3 { gap: 0.75rem; }
267
+
268
+ /* Scrollbar on left side for RTL */
269
+ [dir="rtl"] ::-webkit-scrollbar { direction: rtl; }
270
+
271
+ /* Fix rounded corners (swap left/right) */
272
+ [dir="rtl"] .rounded-tl-sm { border-top-left-radius: 0; border-top-right-radius: 0.125rem; }
273
+ [dir="rtl"] .rounded-tr-sm { border-top-right-radius: 0; border-top-left-radius: 0.125rem; }
274
+
275
+ /* Large tap targets */
276
+ @media (pointer: coarse) {
277
+ button, a, select, input, textarea { min-height: 44px; }
278
+ }
279
+
280
+ /* ============================================================
281
+ * Mobile-first utilities
282
+ * ============================================================ */
283
+
284
+ /* Dynamic viewport height — works on iOS Safari, Android Chrome,
285
+ and every modern browser. Falls back to 100vh. */
286
+ .h-screen-safe {
287
+ height: 100vh;
288
+ height: 100dvh;
289
+ }
290
+
291
+ /* Sticky input bar that stays above the mobile keyboard.
292
+ Uses env(keyboard-inset-height) on supporting browsers and
293
+ falls back to standard sticky positioning elsewhere. */
294
+ .sticky-bottom-keyboard {
295
+ position: sticky;
296
+ bottom: 0;
297
+ bottom: env(keyboard-inset-height, 0px);
298
+ }
299
+
300
+ /* Prevent iOS input zoom — any input below 16px triggers a zoom.
301
+ We force 16px minimum on touch devices and compensate with
302
+ transforms where we need visually-smaller text. */
303
+ @media (pointer: coarse) {
304
+ input, textarea, select {
305
+ font-size: 16px !important;
306
+ }
307
+ }
308
+
309
+ /* CJK word-break — prevents overflow for Chinese/Japanese/Korean text */
310
+ :lang(zh), :lang(ja), :lang(ko) {
311
+ word-break: break-all;
312
+ overflow-wrap: break-word;
313
+ }
314
+
315
+ /* iOS momentum scrolling */
316
+ .scroll-touch {
317
+ -webkit-overflow-scrolling: touch;
318
+ }
319
+
320
+ /* Bottom padding spacer for content that sits above a fixed bottom nav.
321
+ The 5.5rem accounts for the nav height + safe-area-inset-bottom. */
322
+ .pb-mobile-nav {
323
+ padding-bottom: 5.5rem;
324
+ }
325
+ @media (min-width: 768px) {
326
+ .pb-mobile-nav {
327
+ padding-bottom: 0;
328
+ }
329
+ }
330
+
331
+ /* Respect reduced-motion preferences everywhere */
332
+ @media (prefers-reduced-motion: reduce) {
333
+ *,
334
+ *::before,
335
+ *::after {
336
+ animation-duration: 0.01ms !important;
337
+ animation-iteration-count: 1 !important;
338
+ transition-duration: 0.01ms !important;
339
+ }
340
+ }
341
+
342
+ /* ============================================================
343
+ * Print styles — professional PDF export via window.print()
344
+ * Hides navigation, maximizes content, clean typography
345
+ * ============================================================ */
346
+ @media print {
347
+ /* Hide navigation, headers, footers */
348
+ aside, nav, header, footer,
349
+ .safe-area-bottom, .pb-mobile-nav,
350
+ button, [role="navigation"],
351
+ .md\:hidden, .lg\:flex { display: none !important; }
352
+
353
+ /* Full width, no sidebar */
354
+ body { background: white !important; color: black !important; font-size: 12pt; }
355
+ .flex-1 { width: 100% !important; max-width: 100% !important; }
356
+ .overflow-hidden { overflow: visible !important; }
357
+ .overflow-y-auto { overflow: visible !important; }
358
+
359
+ /* Clean card borders for print */
360
+ .bg-surface-1, .bg-surface-0 { background: white !important; }
361
+ .border { border-color: #ddd !important; }
362
+ .shadow-soft, .shadow-card, .shadow-glow { box-shadow: none !important; }
363
+ .rounded-2xl { border-radius: 8px !important; }
364
+
365
+ /* Keep colors readable */
366
+ .text-ink-base { color: #111 !important; }
367
+ .text-ink-muted { color: #555 !important; }
368
+ .text-brand-500, .text-brand-600 { color: #2563eb !important; }
369
+ .text-danger-500 { color: #dc2626 !important; }
370
+ .text-success-500 { color: #16a34a !important; }
371
+
372
+ /* Page breaks */
373
+ .stat-card, .rounded-2xl { break-inside: avoid; }
374
+ h2, h3 { break-after: avoid; }
375
+
376
+ /* Print header */
377
+ @page { margin: 1.5cm; size: A4; }
378
+ }
app/icon.svg ADDED
app/layout.tsx ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Metadata, Viewport } from "next";
2
+ import { Inter } from "next/font/google";
3
+ import "./globals.css";
4
+
5
+ const inter = Inter({
6
+ subsets: ["latin"],
7
+ display: "swap",
8
+ variable: "--font-sans",
9
+ });
10
+
11
+ export const metadata: Metadata = {
12
+ title: "MedOS — your worldwide medical assistant",
13
+ description:
14
+ "Tell MedOS what's bothering you. Instant, private, multilingual health guidance aligned with WHO, CDC, and NHS.",
15
+ keywords: ["medical AI", "healthcare", "chatbot", "telemedicine", "WHO", "CDC"],
16
+ authors: [{ name: "MedOS Team" }],
17
+ manifest: "/manifest.webmanifest",
18
+ icons: {
19
+ icon: [{ url: "/favicon.svg", type: "image/svg+xml" }],
20
+ shortcut: "/favicon.svg",
21
+ },
22
+ openGraph: {
23
+ title: "MedOS — your worldwide medical assistant",
24
+ description:
25
+ "Private, multilingual health guidance aligned with WHO, CDC, and NHS — available 24/7.",
26
+ type: "website",
27
+ },
28
+ robots: { index: true, follow: true },
29
+ appleWebApp: {
30
+ capable: true,
31
+ title: "MedOS",
32
+ },
33
+ other: {
34
+ "mobile-web-app-capable": "yes",
35
+ },
36
+ };
37
+
38
+ export const viewport: Viewport = {
39
+ width: "device-width",
40
+ initialScale: 1,
41
+ maximumScale: 5,
42
+ userScalable: true,
43
+ viewportFit: "cover",
44
+ themeColor: [
45
+ { media: "(prefers-color-scheme: light)", color: "#F7F9FB" },
46
+ { media: "(prefers-color-scheme: dark)", color: "#0B1220" },
47
+ ],
48
+ };
49
+
50
+ /**
51
+ * Inline pre-hydration script: reads the stored theme before first paint.
52
+ */
53
+ const themeBootstrap = `
54
+ (function() {
55
+ try {
56
+ var stored = localStorage.getItem('medos_theme');
57
+ var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
58
+ var isDark = stored === 'dark' || (stored === 'system' && prefersDark);
59
+ if (isDark) document.documentElement.classList.add('dark');
60
+ document.documentElement.style.colorScheme = isDark ? 'dark' : 'light';
61
+ } catch (e) {}
62
+ })();
63
+ `;
64
+
65
+ export default function RootLayout({
66
+ children,
67
+ }: {
68
+ children: React.ReactNode;
69
+ }) {
70
+ return (
71
+ <html lang="en" className={inter.variable} suppressHydrationWarning>
72
+ <head>
73
+ <script dangerouslySetInnerHTML={{ __html: themeBootstrap }} />
74
+ </head>
75
+ <body className="min-h-screen antialiased">{children}</body>
76
+ </html>
77
+ );
78
+ }
app/manifest.ts ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { MetadataRoute } from "next";
2
+
3
+ /**
4
+ * Next.js native manifest route. Served at /manifest.webmanifest by
5
+ * the framework itself (not as a static file) so it bypasses Vercel's
6
+ * Deployment Protection on preview branches.
7
+ */
8
+ export default function manifest(): MetadataRoute.Manifest {
9
+ return {
10
+ name: "MedOS — your medical assistant",
11
+ short_name: "MedOS",
12
+ description:
13
+ "Free AI medical assistant. 20 languages. No sign-up. Private.",
14
+ start_url: "/?source=pwa",
15
+ scope: "/",
16
+ display: "standalone",
17
+ orientation: "portrait-primary",
18
+ theme_color: "#3B82F6",
19
+ background_color: "#F7F9FB",
20
+ categories: ["health", "medical", "lifestyle"],
21
+ icons: [
22
+ {
23
+ src: "/favicon.svg",
24
+ sizes: "any",
25
+ type: "image/svg+xml",
26
+ purpose: "any",
27
+ },
28
+ ],
29
+ shortcuts: [
30
+ {
31
+ name: "Ask a health question",
32
+ short_name: "Ask",
33
+ url: "/?source=shortcut",
34
+ },
35
+ {
36
+ name: "Health Dashboard",
37
+ short_name: "Health",
38
+ url: "/?view=health-dashboard",
39
+ },
40
+ ],
41
+ prefer_related_applications: false,
42
+ };
43
+ }
app/page.tsx ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ import MedOSApp from "@/components/MedOSApp";
2
+ export default function HomePage() { return <MedOSApp />; }
app/robots.ts ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { MetadataRoute } from 'next';
2
+
3
+ const SITE_URL = 'https://ruslanmv-medibot.hf.space';
4
+
5
+ /**
6
+ * Permissive robots.txt — we want every crawler to index the public
7
+ * pages (home, /symptoms, /stats). API routes are disallowed because
8
+ * they return dynamic or privacy-sensitive data (geo lookup, chat
9
+ * stream, session counter).
10
+ */
11
+ export default function robots(): MetadataRoute.Robots {
12
+ return {
13
+ rules: [
14
+ {
15
+ userAgent: '*',
16
+ allow: ['/', '/symptoms', '/stats'],
17
+ disallow: ['/api/'],
18
+ },
19
+ ],
20
+ sitemap: `${SITE_URL}/sitemap.xml`,
21
+ host: SITE_URL,
22
+ };
23
+ }
app/sitemap.ts ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { MetadataRoute } from 'next';
2
+ import { getAllSymptomSlugs } from '@/lib/symptoms';
3
+
4
+ const SITE_URL = 'https://ruslanmv-medibot.hf.space';
5
+
6
+ /**
7
+ * Static sitemap auto-generated from the symptom catalog so Google picks
8
+ * up every SEO landing page on day one. Served at `/sitemap.xml` by Next.
9
+ */
10
+ export default function sitemap(): MetadataRoute.Sitemap {
11
+ const now = new Date();
12
+
13
+ const staticPages: MetadataRoute.Sitemap = [
14
+ { url: SITE_URL, lastModified: now, changeFrequency: 'daily', priority: 1.0 },
15
+ { url: `${SITE_URL}/symptoms`, lastModified: now, changeFrequency: 'weekly', priority: 0.9 },
16
+ { url: `${SITE_URL}/stats`, lastModified: now, changeFrequency: 'weekly', priority: 0.7 },
17
+ ];
18
+
19
+ const symptomPages: MetadataRoute.Sitemap = getAllSymptomSlugs().map((slug) => ({
20
+ url: `${SITE_URL}/symptoms/${slug}`,
21
+ lastModified: now,
22
+ changeFrequency: 'weekly',
23
+ priority: 0.8,
24
+ }));
25
+
26
+ return [...staticPages, ...symptomPages];
27
+ }
app/stats/page.tsx ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+ import Link from 'next/link';
5
+ import {
6
+ Activity,
7
+ Globe2,
8
+ ShieldCheck,
9
+ Clock4,
10
+ ArrowRight,
11
+ Languages,
12
+ } from 'lucide-react';
13
+ import { TrustBar } from '@/components/chat/TrustBar';
14
+
15
+ interface SessionsResponse {
16
+ count: number;
17
+ sessions: number;
18
+ base: number;
19
+ }
20
+
21
+ const LANGUAGES = [
22
+ 'English',
23
+ 'Español',
24
+ 'Français',
25
+ 'Português',
26
+ 'Italiano',
27
+ 'Deutsch',
28
+ 'العربية',
29
+ 'हिन्दी',
30
+ 'Kiswahili',
31
+ '中文',
32
+ '日本語',
33
+ '한국어',
34
+ 'Русский',
35
+ 'Türkçe',
36
+ 'Tiếng Việt',
37
+ 'ไทย',
38
+ 'বাংলা',
39
+ 'اردو',
40
+ 'Polski',
41
+ 'Nederlands',
42
+ ];
43
+
44
+ /**
45
+ * Public transparency page. Shows the anonymous global session counter,
46
+ * supported languages, trust metrics, and the MedOS health-posture
47
+ * summary. Drives social proof ("N people helped this session") and is
48
+ * a natural link target from Product Hunt / Twitter / press.
49
+ *
50
+ * Server-rendered is tempting here, but we keep it client-side so the
51
+ * counter animates as it loads — tiny extra bundle, big UX win.
52
+ */
53
+ export default function StatsPage() {
54
+ const [data, setData] = useState<SessionsResponse | null>(null);
55
+ const [loading, setLoading] = useState(true);
56
+ const [displayCount, setDisplayCount] = useState(0);
57
+
58
+ useEffect(() => {
59
+ let cancelled = false;
60
+ fetch('/api/sessions', { cache: 'no-store' })
61
+ .then((r) => (r.ok ? r.json() : null))
62
+ .then((d: SessionsResponse | null) => {
63
+ if (!cancelled && d) {
64
+ setData(d);
65
+ animateTo(d.count, setDisplayCount);
66
+ }
67
+ })
68
+ .catch(() => {})
69
+ .finally(() => !cancelled && setLoading(false));
70
+ return () => {
71
+ cancelled = true;
72
+ };
73
+ }, []);
74
+
75
+ return (
76
+ <main className="min-h-screen bg-slate-900 text-slate-100">
77
+ <div className="max-w-3xl mx-auto px-4 sm:px-6 py-10 pb-32">
78
+ <header className="mb-8 text-center">
79
+ <p className="text-xs font-bold uppercase tracking-[0.18em] text-teal-400 mb-2">
80
+ MedOS transparency
81
+ </p>
82
+ <h1 className="text-4xl sm:text-5xl font-bold text-slate-50 tracking-tight mb-3">
83
+ The numbers behind MedOS
84
+ </h1>
85
+ <p className="text-lg text-slate-300 max-w-xl mx-auto leading-relaxed">
86
+ Free health guidance, open numbers. No tracking of people —
87
+ only a single anonymous counter that ticks once per session.
88
+ </p>
89
+ </header>
90
+
91
+ {/* Hero counter */}
92
+ <section className="mb-10 rounded-3xl border border-teal-500/30 bg-gradient-to-br from-blue-900/30 to-teal-900/20 p-8 text-center">
93
+ <div className="inline-flex items-center gap-2 text-xs font-bold uppercase tracking-wider text-teal-300 bg-teal-500/10 border border-teal-500/30 px-3 py-1 rounded-full mb-4">
94
+ <Activity size={12} className="animate-pulse" />
95
+ Live session counter
96
+ </div>
97
+ <div className="text-6xl sm:text-7xl font-black text-slate-50 tracking-tight tabular-nums">
98
+ {loading ? (
99
+ <span className="text-slate-600">…</span>
100
+ ) : (
101
+ formatNumber(displayCount)
102
+ )}
103
+ </div>
104
+ <p className="text-sm text-slate-400 mt-3">
105
+ conversations MedOS has helped with, anonymously, since launch
106
+ </p>
107
+ </section>
108
+
109
+ {/* Cards grid */}
110
+ <section className="grid sm:grid-cols-2 gap-4 mb-10">
111
+ <StatCard
112
+ Icon={Globe2}
113
+ label="Countries supported"
114
+ value="190+"
115
+ description="Emergency numbers localized per region"
116
+ />
117
+ <StatCard
118
+ Icon={Languages}
119
+ label="Languages"
120
+ value="20"
121
+ description="Auto-detected from browser and IP"
122
+ />
123
+ <StatCard
124
+ Icon={ShieldCheck}
125
+ label="Privacy"
126
+ value="Zero PII"
127
+ description="No accounts, no IP logging, no conversation storage"
128
+ />
129
+ <StatCard
130
+ Icon={Clock4}
131
+ label="Availability"
132
+ value="24/7"
133
+ description="Free forever on HuggingFace Spaces"
134
+ />
135
+ </section>
136
+
137
+ {/* Languages strip */}
138
+ <section className="mb-10 rounded-2xl border border-slate-700/60 bg-slate-800/50 p-6">
139
+ <h2 className="text-sm font-bold uppercase tracking-wider text-slate-400 mb-4 inline-flex items-center gap-2">
140
+ <Languages size={14} />
141
+ Supported languages
142
+ </h2>
143
+ <div className="flex flex-wrap gap-2">
144
+ {LANGUAGES.map((l) => (
145
+ <span
146
+ key={l}
147
+ className="px-3 py-1.5 rounded-full text-sm font-medium text-slate-200 bg-slate-700/60 border border-slate-600/50"
148
+ >
149
+ {l}
150
+ </span>
151
+ ))}
152
+ </div>
153
+ </section>
154
+
155
+ {/* Trust bar */}
156
+ <section className="mb-10">
157
+ <TrustBar language="en" />
158
+ </section>
159
+
160
+ {/* CTA */}
161
+ <div className="rounded-2xl border border-teal-500/30 bg-gradient-to-br from-blue-900/40 to-teal-900/30 p-6 text-center">
162
+ <h2 className="text-2xl font-bold text-slate-50 mb-2 tracking-tight">
163
+ Ready to ask your own question?
164
+ </h2>
165
+ <p className="text-slate-300 mb-4">
166
+ Free. Private. In your language. No sign-up.
167
+ </p>
168
+ <Link
169
+ href="/"
170
+ className="inline-flex items-center gap-2 px-5 py-3 rounded-xl bg-gradient-to-br from-blue-500 to-teal-500 text-white font-bold hover:brightness-110 transition-all shadow-lg shadow-blue-500/30"
171
+ >
172
+ Open MedOS
173
+ <ArrowRight size={18} />
174
+ </Link>
175
+ </div>
176
+
177
+ {data && (
178
+ <p className="mt-6 text-center text-xs text-slate-500">
179
+ Counter is a single integer stored server-side at{' '}
180
+ <code className="text-slate-400">/api/sessions</code>. No
181
+ request is ever correlated to an individual.
182
+ </p>
183
+ )}
184
+ </div>
185
+ </main>
186
+ );
187
+ }
188
+
189
+ function StatCard({
190
+ Icon,
191
+ label,
192
+ value,
193
+ description,
194
+ }: {
195
+ Icon: any;
196
+ label: string;
197
+ value: string;
198
+ description: string;
199
+ }) {
200
+ return (
201
+ <div className="rounded-2xl border border-slate-700/60 bg-slate-800/50 p-5">
202
+ <div className="flex items-center gap-2 text-teal-400 mb-3">
203
+ <Icon size={16} />
204
+ <span className="text-xs font-bold uppercase tracking-wider">
205
+ {label}
206
+ </span>
207
+ </div>
208
+ <div className="text-3xl font-black text-slate-50 tracking-tight mb-1">
209
+ {value}
210
+ </div>
211
+ <p className="text-sm text-slate-400 leading-snug">{description}</p>
212
+ </div>
213
+ );
214
+ }
215
+
216
+ /** Format a number with locale-aware thousands separators. */
217
+ function formatNumber(n: number): string {
218
+ try {
219
+ return new Intl.NumberFormat(undefined).format(n);
220
+ } catch {
221
+ return String(n);
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Animate a counter from zero to `target` over ~1.2s using an ease-out
227
+ * curve. Runs synchronously inside `requestAnimationFrame` so it never
228
+ * blocks the main thread.
229
+ */
230
+ function animateTo(target: number, setValue: (n: number) => void): void {
231
+ if (typeof window === 'undefined') {
232
+ setValue(target);
233
+ return;
234
+ }
235
+ const duration = 1200;
236
+ const start = performance.now();
237
+ const step = (t: number) => {
238
+ const elapsed = t - start;
239
+ const progress = Math.min(1, elapsed / duration);
240
+ const eased = 1 - Math.pow(1 - progress, 3); // cubic ease-out
241
+ setValue(Math.round(target * eased));
242
+ if (progress < 1) requestAnimationFrame(step);
243
+ };
244
+ requestAnimationFrame(step);
245
+ }
app/symptoms/[slug]/page.tsx ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Metadata } from 'next';
2
+ import { notFound } from 'next/navigation';
3
+ import Link from 'next/link';
4
+ import { AlertTriangle, ShieldCheck, ChevronLeft, ArrowRight } from 'lucide-react';
5
+ import {
6
+ getSymptomBySlug,
7
+ getAllSymptomSlugs,
8
+ type Symptom,
9
+ } from '@/lib/symptoms';
10
+
11
+ const SITE_URL = 'https://ruslanmv-medibot.hf.space';
12
+
13
+ interface Params {
14
+ params: { slug: string };
15
+ }
16
+
17
+ /**
18
+ * Pre-generate every symptom page at build time so they are fully static
19
+ * (and cacheable by HF Spaces' CDN / every downstream proxy).
20
+ */
21
+ export function generateStaticParams() {
22
+ return getAllSymptomSlugs().map((slug) => ({ slug }));
23
+ }
24
+
25
+ export function generateMetadata({ params }: Params): Metadata {
26
+ const symptom = getSymptomBySlug(params.slug);
27
+ if (!symptom) return { title: 'Symptom not found — MedOS' };
28
+
29
+ const ogUrl = `${SITE_URL}/api/og?q=${encodeURIComponent(symptom.headline)}`;
30
+ const canonical = `${SITE_URL}/symptoms/${symptom.slug}`;
31
+
32
+ return {
33
+ title: symptom.title,
34
+ description: symptom.metaDescription,
35
+ alternates: { canonical },
36
+ openGraph: {
37
+ title: symptom.title,
38
+ description: symptom.metaDescription,
39
+ url: canonical,
40
+ siteName: 'MedOS',
41
+ type: 'article',
42
+ images: [{ url: ogUrl, width: 1200, height: 630, alt: symptom.headline }],
43
+ },
44
+ twitter: {
45
+ card: 'summary_large_image',
46
+ title: symptom.title,
47
+ description: symptom.metaDescription,
48
+ images: [ogUrl],
49
+ },
50
+ };
51
+ }
52
+
53
+ export default function SymptomPage({ params }: Params) {
54
+ const symptom = getSymptomBySlug(params.slug);
55
+ if (!symptom) return notFound();
56
+
57
+ // Per-page FAQPage JSON-LD so Google can mine each entry as a rich
58
+ // snippet independently from the root layout's global FAQPage.
59
+ const faqJsonLd = {
60
+ '@context': 'https://schema.org',
61
+ '@type': 'FAQPage',
62
+ mainEntity: symptom.faqs.map((f) => ({
63
+ '@type': 'Question',
64
+ name: f.q,
65
+ acceptedAnswer: { '@type': 'Answer', text: f.a },
66
+ })),
67
+ };
68
+
69
+ // MedicalCondition JSON-LD — helps Google classify the page for the
70
+ // Health Knowledge Graph.
71
+ const medicalConditionJsonLd = {
72
+ '@context': 'https://schema.org',
73
+ '@type': 'MedicalCondition',
74
+ name: symptom.headline,
75
+ description: symptom.summary,
76
+ signOrSymptom: symptom.redFlags.map((r) => ({
77
+ '@type': 'MedicalSymptom',
78
+ name: r,
79
+ })),
80
+ possibleTreatment: symptom.selfCare.map((s) => ({
81
+ '@type': 'MedicalTherapy',
82
+ name: s,
83
+ })),
84
+ };
85
+
86
+ const chatDeepLink = `/?q=${encodeURIComponent(
87
+ `I want to ask about ${symptom.headline.toLowerCase()}`,
88
+ )}`;
89
+
90
+ return (
91
+ <main className="min-h-screen bg-slate-900 text-slate-100">
92
+ <script
93
+ type="application/ld+json"
94
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }}
95
+ />
96
+ <script
97
+ type="application/ld+json"
98
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(medicalConditionJsonLd) }}
99
+ />
100
+
101
+ <div className="max-w-3xl mx-auto px-4 sm:px-6 py-10 pb-32">
102
+ {/* Back link */}
103
+ <Link
104
+ href="/symptoms"
105
+ className="inline-flex items-center gap-1 text-sm text-slate-400 hover:text-teal-300 transition-colors mb-6"
106
+ >
107
+ <ChevronLeft size={16} />
108
+ All symptoms
109
+ </Link>
110
+
111
+ {/* Hero */}
112
+ <header className="mb-8">
113
+ <p className="text-xs font-bold uppercase tracking-[0.18em] text-teal-400 mb-2">
114
+ Symptom guide
115
+ </p>
116
+ <h1 className="text-4xl sm:text-5xl font-bold text-slate-50 tracking-tight mb-4">
117
+ {symptom.headline}
118
+ </h1>
119
+ <p className="text-lg text-slate-300 leading-relaxed">
120
+ {symptom.summary}
121
+ </p>
122
+ <div className="mt-6 inline-flex items-center gap-1.5 text-xs text-teal-300 bg-teal-500/10 border border-teal-500/30 px-3 py-1.5 rounded-full font-semibold">
123
+ <ShieldCheck size={12} />
124
+ Aligned with WHO · CDC · NHS guidance
125
+ </div>
126
+ </header>
127
+
128
+ {/* Red flags — first, highest visual priority */}
129
+ <Section title="When to seek emergency care" danger>
130
+ <ul className="space-y-2">
131
+ {symptom.redFlags.map((r) => (
132
+ <li key={r} className="flex items-start gap-2 text-slate-200">
133
+ <AlertTriangle
134
+ size={16}
135
+ className="flex-shrink-0 text-red-400 mt-0.5"
136
+ />
137
+ <span>{r}</span>
138
+ </li>
139
+ ))}
140
+ </ul>
141
+ </Section>
142
+
143
+ <Section title="Safe self-care at home">
144
+ <ul className="space-y-2">
145
+ {symptom.selfCare.map((s) => (
146
+ <li key={s} className="flex items-start gap-2 text-slate-200">
147
+ <span className="mt-1.5 w-1.5 h-1.5 rounded-full bg-teal-400 flex-shrink-0" />
148
+ <span>{s}</span>
149
+ </li>
150
+ ))}
151
+ </ul>
152
+ </Section>
153
+
154
+ <Section title="When to see a clinician">
155
+ <ul className="space-y-2">
156
+ {symptom.whenToSeekCare.map((w) => (
157
+ <li key={w} className="flex items-start gap-2 text-slate-200">
158
+ <span className="mt-1.5 w-1.5 h-1.5 rounded-full bg-blue-400 flex-shrink-0" />
159
+ <span>{w}</span>
160
+ </li>
161
+ ))}
162
+ </ul>
163
+ </Section>
164
+
165
+ {/* FAQ — also rendered for humans, not just search engines */}
166
+ <Section title="Frequently asked questions">
167
+ <div className="space-y-5">
168
+ {symptom.faqs.map((f) => (
169
+ <div key={f.q}>
170
+ <h3 className="font-bold text-slate-100 mb-1">{f.q}</h3>
171
+ <p className="text-slate-300 leading-relaxed">{f.a}</p>
172
+ </div>
173
+ ))}
174
+ </div>
175
+ </Section>
176
+
177
+ {/* Primary CTA into the live chatbot */}
178
+ <div className="mt-10 rounded-2xl border border-teal-500/30 bg-gradient-to-br from-blue-900/40 to-teal-900/30 p-6">
179
+ <p className="text-xs font-bold uppercase tracking-wider text-teal-400 mb-2">
180
+ Ask the live assistant
181
+ </p>
182
+ <h2 className="text-2xl font-bold text-slate-50 mb-2 tracking-tight">
183
+ Get a personalized answer in your language.
184
+ </h2>
185
+ <p className="text-slate-300 mb-4 leading-relaxed">
186
+ MedOS is free, private, and takes no account. Describe your
187
+ situation and get step-by-step guidance.
188
+ </p>
189
+ <Link
190
+ href={chatDeepLink}
191
+ className="inline-flex items-center gap-2 px-5 py-3 rounded-xl bg-gradient-to-br from-blue-500 to-teal-500 text-white font-bold hover:brightness-110 transition-all shadow-lg shadow-blue-500/30"
192
+ >
193
+ Ask about {symptom.headline.toLowerCase()}
194
+ <ArrowRight size={18} />
195
+ </Link>
196
+ </div>
197
+
198
+ <p className="mt-8 text-xs text-slate-500 leading-relaxed">
199
+ This page is general patient education aligned with WHO, CDC, and
200
+ NHS public guidance. It is not a diagnosis, prescription, or
201
+ substitute for care from a licensed clinician. If symptoms are
202
+ severe, worsening, or you are in doubt, contact a healthcare
203
+ provider or your local emergency number immediately.
204
+ </p>
205
+ </div>
206
+ </main>
207
+ );
208
+ }
209
+
210
+ function Section({
211
+ title,
212
+ danger,
213
+ children,
214
+ }: {
215
+ title: string;
216
+ danger?: boolean;
217
+ children: React.ReactNode;
218
+ }) {
219
+ return (
220
+ <section
221
+ className={`mt-8 rounded-2xl border p-5 ${
222
+ danger
223
+ ? 'border-red-500/40 bg-red-950/30'
224
+ : 'border-slate-700/60 bg-slate-800/40'
225
+ }`}
226
+ >
227
+ <h2
228
+ className={`text-lg font-bold mb-3 tracking-tight ${
229
+ danger ? 'text-red-300' : 'text-slate-100'
230
+ }`}
231
+ >
232
+ {title}
233
+ </h2>
234
+ {children}
235
+ </section>
236
+ );
237
+ }
app/symptoms/page.tsx ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Metadata } from 'next';
2
+ import Link from 'next/link';
3
+ import { ChevronRight, ShieldCheck } from 'lucide-react';
4
+ import { SYMPTOMS } from '@/lib/symptoms';
5
+
6
+ const SITE_URL = 'https://ruslanmv-medibot.hf.space';
7
+
8
+ export const metadata: Metadata = {
9
+ title: 'Symptom guides — free, WHO-aligned | MedOS',
10
+ description:
11
+ 'Browse evidence-based symptom guides: causes, safe self-care, red flags, and when to seek care. Free, multilingual, and aligned with WHO, CDC, and NHS.',
12
+ alternates: { canonical: `${SITE_URL}/symptoms` },
13
+ openGraph: {
14
+ title: 'Symptom guides — free, WHO-aligned | MedOS',
15
+ description:
16
+ 'Browse evidence-based symptom guides: causes, safe self-care, red flags, and when to seek care.',
17
+ url: `${SITE_URL}/symptoms`,
18
+ images: [`${SITE_URL}/api/og?q=${encodeURIComponent('Symptom guides')}`],
19
+ },
20
+ };
21
+
22
+ /**
23
+ * Symptom catalog index. Static, cacheable, zero JS-on-load cost.
24
+ * Works as a landing hub from organic search queries like
25
+ * "medos symptoms" or "symptom checker".
26
+ */
27
+ export default function SymptomsIndexPage() {
28
+ return (
29
+ <main className="min-h-screen bg-slate-900 text-slate-100">
30
+ <div className="max-w-3xl mx-auto px-4 sm:px-6 py-10 pb-32">
31
+ <header className="mb-8 text-center">
32
+ <p className="text-xs font-bold uppercase tracking-[0.18em] text-teal-400 mb-2">
33
+ Free patient guides
34
+ </p>
35
+ <h1 className="text-4xl sm:text-5xl font-bold text-slate-50 tracking-tight mb-3">
36
+ Symptom guides
37
+ </h1>
38
+ <p className="text-lg text-slate-300 max-w-xl mx-auto leading-relaxed">
39
+ Clear, trustworthy answers to the most common health
40
+ questions. Free, multilingual, and aligned with WHO, CDC,
41
+ and NHS guidance.
42
+ </p>
43
+ <div className="mt-5 inline-flex items-center gap-1.5 text-xs text-teal-300 bg-teal-500/10 border border-teal-500/30 px-3 py-1.5 rounded-full font-semibold">
44
+ <ShieldCheck size={12} />
45
+ Reviewed against WHO · CDC · NHS public guidance
46
+ </div>
47
+ </header>
48
+
49
+ <div className="grid sm:grid-cols-2 gap-3">
50
+ {SYMPTOMS.map((s) => (
51
+ <Link
52
+ key={s.slug}
53
+ href={`/symptoms/${s.slug}`}
54
+ className="group p-5 rounded-2xl border border-slate-700/60 bg-slate-800/60 hover:border-teal-500/50 hover:bg-teal-500/5 transition-all"
55
+ >
56
+ <div className="flex items-start justify-between gap-3">
57
+ <div className="flex-1 min-w-0">
58
+ <h2 className="font-bold text-slate-100 text-lg mb-1 tracking-tight">
59
+ {s.headline}
60
+ </h2>
61
+ <p className="text-sm text-slate-400 leading-relaxed line-clamp-2">
62
+ {s.summary}
63
+ </p>
64
+ </div>
65
+ <ChevronRight
66
+ size={18}
67
+ className="flex-shrink-0 text-slate-500 group-hover:text-teal-400 transition-colors mt-1"
68
+ />
69
+ </div>
70
+ </Link>
71
+ ))}
72
+ </div>
73
+
74
+ <div className="mt-10 text-center">
75
+ <Link
76
+ href="/"
77
+ className="inline-flex items-center gap-2 px-5 py-3 rounded-xl bg-gradient-to-br from-blue-500 to-teal-500 text-white font-bold hover:brightness-110 transition-all shadow-lg shadow-blue-500/30"
78
+ >
79
+ Open the live MedOS assistant
80
+ <ChevronRight size={18} />
81
+ </Link>
82
+ </div>
83
+ </div>
84
+ </main>
85
+ );
86
+ }
components/MedOSApp.tsx ADDED
@@ -0,0 +1,560 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useState, useCallback, useEffect, useRef } from "react";
4
+ import { Heart, Menu } from "lucide-react";
5
+ import { useGeoDetect } from "@/lib/hooks/useGeoDetect";
6
+ import { ThemeProvider } from "./ThemeProvider";
7
+ import { ThemeToggle } from "./ThemeToggle";
8
+ import { Sidebar, NavView } from "./chat/Sidebar";
9
+ import { AppDrawer } from "./chat/AppDrawer";
10
+ import { RightPanel } from "./chat/RightPanel";
11
+ import { NotificationBell } from "./chat/NotificationCenter";
12
+ import { ChatView } from "./views/ChatView";
13
+ import { HomeView } from "./views/HomeView";
14
+ import { EmergencyView } from "./views/EmergencyView";
15
+ import { TopicsView } from "./views/TopicsView";
16
+ import { SettingsView } from "./views/SettingsView";
17
+ import { RecordsView } from "./views/RecordsView";
18
+ import { HistoryView } from "./views/HistoryView";
19
+ import { MedicationsView } from "./views/MedicationsView";
20
+ import { AppointmentsView } from "./views/AppointmentsView";
21
+ import { VitalsView } from "./views/VitalsView";
22
+ import { HealthDashboard } from "./views/HealthDashboard";
23
+ import { ScheduleView } from "./views/ScheduleView";
24
+ import { WelcomeScreen } from "./WelcomeScreen";
25
+ import { useSettings } from "@/lib/hooks/useSettings";
26
+ import { useChat } from "@/lib/hooks/useChat";
27
+ import { useHealthStore } from "@/lib/hooks/useHealthStore";
28
+ import { useNotifications } from "@/lib/hooks/useNotifications";
29
+ import { useAuth } from "@/lib/hooks/useAuth";
30
+ import { LoginView } from "./views/LoginView";
31
+ import { ProfileView } from "./views/ProfileView";
32
+ import { EHRWizard } from "./views/EHRWizard";
33
+ import { MyMedicinesView } from "./views/MyMedicinesView";
34
+ import { ShareView } from "./views/ShareView";
35
+ import { AdminView } from "./views/AdminView";
36
+ import { NearbyView } from "./views/NearbyView";
37
+ import { ContactsView } from "./views/ContactsView";
38
+ import { DisclaimerBanner } from "./ui/DisclaimerBanner";
39
+ import { OfflineBanner } from "./ui/OfflineBanner";
40
+ import { InstallPrompt } from "./ui/InstallPrompt";
41
+ import { buildPatientContext, buildContactsContext, todayISO } from "@/lib/health-store";
42
+ import { t, type SupportedLanguage } from "@/lib/i18n";
43
+
44
+ export default function MedOSApp() {
45
+ return (
46
+ <ThemeProvider>
47
+ <MedOSAppInner />
48
+ </ThemeProvider>
49
+ );
50
+ }
51
+
52
+ function MedOSAppInner() {
53
+ const [activeNav, setActiveNav] = useState<NavView>("home");
54
+ const [drawerOpen, setDrawerOpen] = useState(false);
55
+ const settings = useSettings();
56
+ const auth = useAuth();
57
+ const { messages, isTyping, error, sendMessage, clearMessages } = useChat();
58
+ const health = useHealthStore(auth.token);
59
+ const notif = useNotifications();
60
+
61
+ // IP-based auto-detection. Only applies if the user hasn't manually
62
+ // chosen a language yet; the manual override in Settings wins forever.
63
+ const onGeo = useCallback(
64
+ (g: { country: string; language: any; emergencyNumber: string }) => {
65
+ settings.applyGeo(g);
66
+ },
67
+ [settings],
68
+ );
69
+ useGeoDetect({
70
+ skip: !settings.isLoaded || settings.explicitLanguage,
71
+ onResult: onGeo,
72
+ });
73
+
74
+ const handleSendMessage = (content: string) => {
75
+ sendMessage(content, {
76
+ preset: settings.advancedMode ? undefined : settings.preset,
77
+ provider: settings.advancedMode ? settings.provider : undefined,
78
+ // In advanced mode we let the server default the model; the
79
+ // dedicated provider files pick their own canonical model.
80
+ apiKey: settings.apiKey,
81
+ userHfToken: settings.hfToken || undefined,
82
+ context: {
83
+ country: settings.country,
84
+ language: settings.language,
85
+ emergencyNumber: settings.emergencyNumber,
86
+ },
87
+ });
88
+ // Auto-navigate to chat when sending a message from home/topics
89
+ if (activeNav !== "chat") {
90
+ setActiveNav("chat");
91
+ }
92
+ };
93
+
94
+ const handleStartVoice = () => {
95
+ setActiveNav("chat");
96
+ // Voice will auto-start via the ChatView component
97
+ };
98
+
99
+ const handleWelcomeComplete = (lang: SupportedLanguage, country: string) => {
100
+ // Welcome completion is an explicit user choice — lock it in so
101
+ // subsequent IP auto-detection never overrides it.
102
+ settings.setLanguageExplicit(lang);
103
+ settings.setCountryExplicit(country);
104
+ settings.setWelcomeCompleted(true);
105
+ };
106
+
107
+ // Auto-save the current chat session to history when navigating away
108
+ // from the chat view, or when the AI finishes responding and there are
109
+ // enough messages to be worth saving.
110
+ const lastSavedCount = useRef(0);
111
+ useEffect(() => {
112
+ const userMsgs = messages.filter((m) => m.role === "user");
113
+ if (
114
+ userMsgs.length > 0 &&
115
+ messages.length >= 3 &&
116
+ messages.length !== lastSavedCount.current &&
117
+ !isTyping
118
+ ) {
119
+ lastSavedCount.current = messages.length;
120
+ health.saveSession({
121
+ date: new Date().toISOString(),
122
+ preview: userMsgs[0].content.slice(0, 120),
123
+ messageCount: messages.length,
124
+ topic: undefined,
125
+ });
126
+ }
127
+ }, [messages.length, isTyping]); // eslint-disable-line react-hooks/exhaustive-deps
128
+
129
+ const handleNavigate = (view: string) => {
130
+ setActiveNav(view as NavView);
131
+ };
132
+
133
+ // Sync language and direction to HTML element for i18n + RTL
134
+ useEffect(() => {
135
+ const doc = document.documentElement;
136
+ doc.lang = settings.language;
137
+ doc.dir = settings.language === "ar" || settings.language === "ur" ? "rtl" : "ltr";
138
+ }, [settings.language]);
139
+
140
+ // Text size class
141
+ const textSizeClass =
142
+ settings.textSize === "large"
143
+ ? "text-lg"
144
+ : settings.textSize === "small"
145
+ ? "text-sm"
146
+ : "text-base";
147
+
148
+ const renderContent = () => {
149
+ switch (activeNav) {
150
+ case "home":
151
+ return (
152
+ <HomeView
153
+ language={settings.language}
154
+ country={settings.country}
155
+ emergencyNumber={settings.emergencyNumber}
156
+ onNavigate={handleNavigate}
157
+ onSendMessage={handleSendMessage}
158
+ onStartVoice={handleStartVoice}
159
+ />
160
+ );
161
+ case "emergency":
162
+ return (
163
+ <EmergencyView
164
+ language={settings.language}
165
+ emergencyNumber={settings.emergencyNumber}
166
+ />
167
+ );
168
+ case "topics":
169
+ return (
170
+ <TopicsView
171
+ language={settings.language}
172
+ onSelectTopic={(topic) => handleSendMessage(`Tell me about ${topic}`)}
173
+ />
174
+ );
175
+ case "settings":
176
+ return (
177
+ <SettingsView
178
+ preset={settings.preset}
179
+ setPreset={settings.setPreset}
180
+ hfToken={settings.hfToken}
181
+ setHfToken={settings.setHfToken}
182
+ clearHfToken={settings.clearHfToken}
183
+ provider={settings.provider}
184
+ setProvider={settings.setProvider}
185
+ apiKey={settings.apiKey}
186
+ setApiKey={settings.setApiKey}
187
+ clearApiKey={settings.clearApiKey}
188
+ advancedMode={settings.advancedMode}
189
+ setAdvancedMode={settings.setAdvancedMode}
190
+ language={settings.language}
191
+ setLanguage={settings.setLanguageExplicit}
192
+ country={settings.country}
193
+ setCountry={settings.setCountryExplicit}
194
+ voiceEnabled={settings.voiceEnabled}
195
+ setVoiceEnabled={settings.setVoiceEnabled}
196
+ readAloud={settings.readAloud}
197
+ setReadAloud={settings.setReadAloud}
198
+ textSize={settings.textSize}
199
+ setTextSize={settings.setTextSize}
200
+ simpleLanguage={settings.simpleLanguage}
201
+ setSimpleLanguage={settings.setSimpleLanguage}
202
+ darkMode={settings.darkMode}
203
+ setDarkMode={settings.setDarkMode}
204
+ emergencyNumber={settings.emergencyNumber}
205
+ />
206
+ );
207
+ case "schedule":
208
+ return (
209
+ <ScheduleView
210
+ medications={health.medications}
211
+ medicationLogs={health.medicationLogs}
212
+ appointments={health.appointments}
213
+ onMarkMedTaken={health.markMedTaken}
214
+ isMedTaken={health.isMedTaken}
215
+ onEditAppointment={health.editAppointment}
216
+ onNavigate={handleNavigate}
217
+ language={settings.language}
218
+ />
219
+ );
220
+ case "health-dashboard":
221
+ return (
222
+ <HealthDashboard
223
+ medications={health.medications}
224
+ medicationLogs={health.medicationLogs}
225
+ appointments={health.appointments}
226
+ vitals={health.vitals}
227
+ records={health.records}
228
+ onNavigate={handleNavigate}
229
+ onMarkMedTaken={health.markMedTaken}
230
+ isMedTaken={health.isMedTaken}
231
+ getMedStreak={health.getMedStreak}
232
+ onExport={health.downloadAll}
233
+ language={settings.language}
234
+ />
235
+ );
236
+ case "medications":
237
+ return (
238
+ <MedicationsView
239
+ medications={health.medications}
240
+ onAdd={health.addMedication}
241
+ onEdit={health.editMedication}
242
+ onDelete={health.deleteMedication}
243
+ onMarkTaken={health.markMedTaken}
244
+ isTaken={health.isMedTaken}
245
+ getStreak={health.getMedStreak}
246
+ language={settings.language}
247
+ />
248
+ );
249
+ case "appointments":
250
+ return (
251
+ <AppointmentsView
252
+ appointments={health.appointments}
253
+ onAdd={health.addAppointment}
254
+ onEdit={health.editAppointment}
255
+ onDelete={health.deleteAppointment}
256
+ language={settings.language}
257
+ />
258
+ );
259
+ case "vitals":
260
+ return (
261
+ <VitalsView
262
+ vitals={health.vitals}
263
+ onAdd={health.addVital}
264
+ onDelete={health.deleteVital}
265
+ language={settings.language}
266
+ />
267
+ );
268
+ case "records":
269
+ return (
270
+ <RecordsView
271
+ records={health.records}
272
+ onAdd={health.addRecord}
273
+ onEdit={health.editRecord}
274
+ onDelete={health.deleteRecord}
275
+ onExport={health.downloadAll}
276
+ language={settings.language}
277
+ />
278
+ );
279
+ case "my-medicines":
280
+ return (
281
+ <MyMedicinesView
282
+ medicines={health.medicines}
283
+ onAdd={health.addMedicine}
284
+ onUpdate={health.editMedicine}
285
+ onDelete={health.deleteMedicine}
286
+ onAddToSchedule={(med) => {
287
+ // Add to the medication schedule tracker
288
+ health.addMedication({
289
+ name: med.name,
290
+ dose: med.dose,
291
+ frequency: "daily",
292
+ times: ["08:00"],
293
+ startDate: todayISO(),
294
+ active: true,
295
+ });
296
+ setActiveNav("medications");
297
+ }}
298
+ language={settings.language}
299
+ />
300
+ );
301
+ case "nearby":
302
+ return (
303
+ <NearbyView
304
+ language={settings.language}
305
+ onSaveContact={(c) => health.addContact(c)}
306
+ />
307
+ );
308
+ case "contacts":
309
+ return (
310
+ <ContactsView
311
+ contacts={health.contacts}
312
+ onAdd={health.addContact}
313
+ onUpdate={health.editContact}
314
+ onDelete={health.deleteContact}
315
+ onNavigate={handleNavigate}
316
+ language={settings.language}
317
+ />
318
+ );
319
+ case "share":
320
+ return <ShareView language={settings.language} />;
321
+ case "admin":
322
+ return auth.user?.isAdmin ? (
323
+ <AdminView language={settings.language} token={auth.token} />
324
+ ) : (
325
+ <HomeView
326
+ language={settings.language}
327
+ country={settings.country}
328
+ emergencyNumber={settings.emergencyNumber}
329
+ onNavigate={handleNavigate}
330
+ onSendMessage={handleSendMessage}
331
+ onStartVoice={handleStartVoice}
332
+ />
333
+ );
334
+ case "history":
335
+ return (
336
+ <HistoryView
337
+ history={health.history}
338
+ onDelete={health.deleteSession}
339
+ onClearAll={health.clearAllHistory}
340
+ onReplay={(preview) => handleSendMessage(preview)}
341
+ language={settings.language}
342
+ />
343
+ );
344
+ case "login":
345
+ return (
346
+ <LoginView
347
+ onLogin={async (e, p) => {
348
+ const res = await auth.login(e, p);
349
+ if (res.ok) setActiveNav("home");
350
+ return res;
351
+ }}
352
+ onRegister={async (e, p, o) => {
353
+ const res = await auth.register(e, p, o);
354
+ if (res.ok && !res.needsVerification) setActiveNav("home");
355
+ return res;
356
+ }}
357
+ onVerifyEmail={async (code) => {
358
+ const res = await auth.verifyEmail(code);
359
+ if (res.ok) setActiveNav("home");
360
+ return res;
361
+ }}
362
+ onResendVerification={auth.resendVerification}
363
+ onForgotPassword={auth.forgotPassword}
364
+ onResetPassword={async (e, c, p) => {
365
+ const res = await auth.resetPassword(e, c, p);
366
+ if (res.ok) setActiveNav("home");
367
+ return res;
368
+ }}
369
+ language={settings.language}
370
+ />
371
+ );
372
+ case "ehr-wizard":
373
+ return (
374
+ <EHRWizard
375
+ onComplete={() => setActiveNav("profile")}
376
+ onCancel={() => setActiveNav("home")}
377
+ language={settings.language}
378
+ />
379
+ );
380
+ case "profile":
381
+ return auth.user ? (
382
+ <ProfileView
383
+ user={auth.user}
384
+ onLogout={() => {
385
+ auth.logout();
386
+ setActiveNav("home");
387
+ }}
388
+ onExport={health.downloadAll}
389
+ onOpenEHR={() => setActiveNav("ehr-wizard")}
390
+ medicationCount={health.medications.length}
391
+ appointmentCount={health.appointments.length}
392
+ vitalCount={health.vitals.length}
393
+ recordCount={health.records.length}
394
+ language={settings.language}
395
+ />
396
+ ) : (
397
+ <LoginView
398
+ onLogin={async (e, p) => {
399
+ const res = await auth.login(e, p);
400
+ if (res.ok) setActiveNav("profile");
401
+ return res;
402
+ }}
403
+ onRegister={async (e, p, o) => {
404
+ const res = await auth.register(e, p, o);
405
+ if (res.ok && !res.needsVerification) setActiveNav("profile");
406
+ return res;
407
+ }}
408
+ onVerifyEmail={auth.verifyEmail}
409
+ onResendVerification={auth.resendVerification}
410
+ onForgotPassword={auth.forgotPassword}
411
+ onResetPassword={auth.resetPassword}
412
+ language={settings.language}
413
+ />
414
+ );
415
+ default:
416
+ return (
417
+ <ChatView
418
+ messages={messages}
419
+ isTyping={isTyping}
420
+ onSendMessage={handleSendMessage}
421
+ language={settings.language}
422
+ emergencyNumber={settings.emergencyNumber}
423
+ voiceEnabled={settings.voiceEnabled}
424
+ readAloud={settings.readAloud}
425
+ onNavigateEmergency={() => setActiveNav("emergency")}
426
+ />
427
+ );
428
+ }
429
+ };
430
+
431
+ // Loading state
432
+ if (!settings.isLoaded) {
433
+ return (
434
+ <div className="flex h-screen w-full items-center justify-center bg-surface-0">
435
+ <div className="text-center">
436
+ <div className="w-12 h-12 mx-auto mb-4 rounded-2xl bg-brand-gradient flex items-center justify-center animate-pulse shadow-glow">
437
+ <Heart size={24} className="text-white" />
438
+ </div>
439
+ <p className="text-ink-muted font-medium">{t("loading", settings.language)}</p>
440
+ </div>
441
+ </div>
442
+ );
443
+ }
444
+
445
+ // Welcome screen for first-time users
446
+ if (!settings.welcomeCompleted) {
447
+ return (
448
+ <WelcomeScreen
449
+ detectedLanguage={settings.language}
450
+ detectedCountry={settings.country}
451
+ onComplete={handleWelcomeComplete}
452
+ />
453
+ );
454
+ }
455
+
456
+ const hasActiveChat = messages.length > 1;
457
+
458
+ return (
459
+ <div
460
+ className={`relative flex flex-col h-screen-safe w-full font-sans text-ink-base ${textSizeClass}`}
461
+ >
462
+ <OfflineBanner />
463
+ <InstallPrompt />
464
+
465
+ {/* Mobile drawer navigation */}
466
+ <AppDrawer
467
+ open={drawerOpen}
468
+ onClose={() => setDrawerOpen(false)}
469
+ activeKey={activeNav}
470
+ onNavigate={(key) => setActiveNav(key as NavView)}
471
+ onNewChat={() => { clearMessages(); setActiveNav("home"); }}
472
+ isAuthenticated={auth.isAuthenticated}
473
+ isAdmin={auth.user?.isAdmin}
474
+ username={auth.user?.displayName || auth.user?.email}
475
+ onLogout={() => { auth.logout(); setActiveNav("home"); }}
476
+ language={settings.language}
477
+ />
478
+
479
+ <div className="flex flex-1 overflow-hidden">
480
+ {/* Sidebar */}
481
+ <Sidebar
482
+ activeNav={activeNav}
483
+ setActiveNav={setActiveNav}
484
+ language={settings.language}
485
+ advancedMode={settings.advancedMode}
486
+ isAuthenticated={auth.isAuthenticated}
487
+ isAdmin={auth.user?.isAdmin}
488
+ username={auth.user?.displayName || auth.user?.email}
489
+ onLogout={() => { auth.logout(); setActiveNav("home"); }}
490
+ />
491
+
492
+ {/* Main Content */}
493
+ <div className="flex-1 flex flex-col relative overflow-hidden">
494
+ {/* Top Header — clean, mobile-first, always accessible */}
495
+ <header className="h-14 sm:h-16 bg-surface-1/90 backdrop-blur-xl border-b border-line/50 flex items-center justify-between px-3 sm:px-8 sticky top-0 z-20">
496
+ {/* Mobile: hamburger menu + logo */}
497
+ <div className="flex items-center gap-2 md:hidden">
498
+ <button
499
+ onClick={() => setDrawerOpen(true)}
500
+ className="w-10 h-10 rounded-xl flex items-center justify-center text-ink-base hover:bg-surface-2 transition-all active:scale-95"
501
+ aria-label="Open menu"
502
+ >
503
+ <Menu size={20} />
504
+ </button>
505
+ <div className="flex items-center gap-2">
506
+ <div className="w-7 h-7 rounded-lg bg-brand-gradient flex items-center justify-center text-white">
507
+ <Heart size={12} />
508
+ </div>
509
+ <span className="font-bold text-ink-base tracking-tight text-sm">MedOS</span>
510
+ </div>
511
+ </div>
512
+
513
+ <h2 className="hidden md:block font-bold text-lg text-ink-base tracking-tight">
514
+ {activeNav === "home"
515
+ ? t("nav_home", settings.language)
516
+ : activeNav === "chat"
517
+ ? t("nav_ask", settings.language)
518
+ : activeNav === "emergency"
519
+ ? t("nav_emergency", settings.language)
520
+ : activeNav === "topics"
521
+ ? t("nav_topics", settings.language)
522
+ : activeNav === "settings"
523
+ ? t("nav_settings", settings.language)
524
+ : activeNav}
525
+ </h2>
526
+
527
+ <div className="flex items-center gap-2 sm:gap-3">
528
+ <NotificationBell
529
+ notifications={notif.notifications}
530
+ count={notif.count}
531
+ onDismiss={notif.dismiss}
532
+ onDismissAll={notif.dismissAll}
533
+ />
534
+ <ThemeToggle />
535
+ </div>
536
+ </header>
537
+
538
+ {/* Dynamic Content Area */}
539
+ <main className="flex-1 flex relative overflow-hidden">
540
+ <div className="flex-1 flex flex-col relative">{renderContent()}</div>
541
+ </main>
542
+ </div>
543
+
544
+ {/* Right Panel — context-aware */}
545
+ <RightPanel
546
+ language={settings.language}
547
+ emergencyNumber={settings.emergencyNumber}
548
+ vitals={health.vitals}
549
+ medications={health.medications}
550
+ appointments={health.appointments}
551
+ isMedTaken={health.isMedTaken}
552
+ onNavigate={handleNavigate}
553
+ notificationCount={notif.count}
554
+ onOpenNotifications={() => {}}
555
+ />
556
+ </div>
557
+ <DisclaimerBanner language={settings.language} />
558
+ </div>
559
+ );
560
+ }
components/ThemeProvider.tsx ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import {
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useEffect,
8
+ useState,
9
+ } from "react";
10
+
11
+ export type ThemeMode = "light" | "dark" | "system";
12
+
13
+ type Ctx = {
14
+ theme: ThemeMode;
15
+ resolved: "light" | "dark";
16
+ setTheme: (t: ThemeMode) => void;
17
+ };
18
+
19
+ const ThemeCtx = createContext<Ctx | null>(null);
20
+
21
+ function systemPrefersDark(): boolean {
22
+ if (typeof window === "undefined") return false;
23
+ return window.matchMedia("(prefers-color-scheme: dark)").matches;
24
+ }
25
+
26
+ function applyDomClass(dark: boolean) {
27
+ const root = document.documentElement;
28
+ root.classList.toggle("dark", dark);
29
+ root.style.colorScheme = dark ? "dark" : "light";
30
+ }
31
+
32
+ export function ThemeProvider({ children }: { children: React.ReactNode }) {
33
+ const [theme, setThemeState] = useState<ThemeMode>("system");
34
+ const [resolved, setResolved] = useState<"light" | "dark">("light");
35
+
36
+ // Hydrate from localStorage on mount.
37
+ useEffect(() => {
38
+ const stored = (localStorage.getItem("medos_theme") as ThemeMode | null) ?? "light";
39
+ setThemeState(stored);
40
+ const dark = stored === "dark" || (stored === "system" && systemPrefersDark());
41
+ setResolved(dark ? "dark" : "light");
42
+ applyDomClass(dark);
43
+ }, []);
44
+
45
+ // React to OS changes while in "system" mode.
46
+ useEffect(() => {
47
+ if (theme !== "system") return;
48
+ const mq = window.matchMedia("(prefers-color-scheme: dark)");
49
+ const listener = (e: MediaQueryListEvent) => {
50
+ setResolved(e.matches ? "dark" : "light");
51
+ applyDomClass(e.matches);
52
+ };
53
+ mq.addEventListener("change", listener);
54
+ return () => mq.removeEventListener("change", listener);
55
+ }, [theme]);
56
+
57
+ const setTheme = useCallback((t: ThemeMode) => {
58
+ setThemeState(t);
59
+ localStorage.setItem("medos_theme", t);
60
+ const dark = t === "dark" || (t === "system" && systemPrefersDark());
61
+ setResolved(dark ? "dark" : "light");
62
+ applyDomClass(dark);
63
+ }, []);
64
+
65
+ return (
66
+ <ThemeCtx.Provider value={{ theme, resolved, setTheme }}>
67
+ {children}
68
+ </ThemeCtx.Provider>
69
+ );
70
+ }
71
+
72
+ export function useTheme(): Ctx {
73
+ const v = useContext(ThemeCtx);
74
+ if (!v) throw new Error("useTheme must be used inside <ThemeProvider>");
75
+ return v;
76
+ }
components/ThemeToggle.tsx ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { Sun, Moon, Monitor } from "lucide-react";
4
+ import { useTheme, type ThemeMode } from "./ThemeProvider";
5
+
6
+ const OPTIONS: { id: ThemeMode; label: string; Icon: typeof Sun }[] = [
7
+ { id: "light", label: "Light", Icon: Sun },
8
+ { id: "dark", label: "Dark", Icon: Moon },
9
+ { id: "system", label: "Auto", Icon: Monitor },
10
+ ];
11
+
12
+ export function ThemeToggle() {
13
+ const { theme, setTheme } = useTheme();
14
+ return (
15
+ <div
16
+ role="radiogroup"
17
+ aria-label="Theme"
18
+ className="inline-flex items-center gap-0.5 rounded-full p-0.5 bg-surface-2/70 border border-line/60 backdrop-blur"
19
+ >
20
+ {OPTIONS.map(({ id, label, Icon }) => {
21
+ const active = theme === id;
22
+ return (
23
+ <button
24
+ key={id}
25
+ role="radio"
26
+ aria-checked={active}
27
+ title={label}
28
+ onClick={() => setTheme(id)}
29
+ className={`flex items-center justify-center h-7 w-7 rounded-full transition-all ${
30
+ active
31
+ ? "bg-surface-1 text-brand-600 shadow-soft"
32
+ : "text-ink-muted hover:text-ink-base"
33
+ }`}
34
+ >
35
+ <Icon size={14} strokeWidth={2.25} />
36
+ </button>
37
+ );
38
+ })}
39
+ </div>
40
+ );
41
+ }
components/WelcomeScreen.tsx ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { Globe, MapPin, ChevronRight, Shield, Heart } from "lucide-react";
5
+ import {
6
+ t,
7
+ LANGUAGE_NAMES,
8
+ getCountryName,
9
+ getEmergencyNumber,
10
+ type SupportedLanguage,
11
+ } from "@/lib/i18n";
12
+
13
+ interface WelcomeScreenProps {
14
+ detectedLanguage: SupportedLanguage;
15
+ detectedCountry: string;
16
+ onComplete: (language: SupportedLanguage, country: string) => void;
17
+ }
18
+
19
+ export function WelcomeScreen({
20
+ detectedLanguage,
21
+ detectedCountry,
22
+ onComplete,
23
+ }: WelcomeScreenProps) {
24
+ const [step, setStep] = useState<"detected" | "language" | "region">("detected");
25
+ const [selectedLang, setSelectedLang] = useState(detectedLanguage);
26
+ const [selectedCountry, setSelectedCountry] = useState(detectedCountry);
27
+
28
+ const lang = selectedLang;
29
+
30
+ if (step === "language") {
31
+ return (
32
+ <div className="min-h-screen bg-white flex flex-col items-center justify-center p-6">
33
+ <h2 className="text-2xl font-bold text-slate-800 mb-8">
34
+ {t("choose_language", lang)}
35
+ </h2>
36
+ <div className="grid grid-cols-2 sm:grid-cols-3 gap-3 max-w-lg w-full">
37
+ {(Object.entries(LANGUAGE_NAMES) as [SupportedLanguage, string][]).map(
38
+ ([code, name]) => (
39
+ <button
40
+ key={code}
41
+ onClick={() => {
42
+ setSelectedLang(code);
43
+ setStep("detected");
44
+ }}
45
+ className={`p-4 rounded-2xl border-2 text-center font-semibold transition-all ${
46
+ selectedLang === code
47
+ ? "border-blue-500 bg-blue-50 text-blue-700"
48
+ : "border-slate-200 bg-white text-slate-700 hover:border-blue-200 hover:bg-blue-50/50"
49
+ }`}
50
+ >
51
+ <span className="text-lg block">{name}</span>
52
+ </button>
53
+ )
54
+ )}
55
+ </div>
56
+ </div>
57
+ );
58
+ }
59
+
60
+ if (step === "region") {
61
+ const countries = [
62
+ "US", "CA", "GB", "AU", "NZ", "IT", "DE", "FR", "ES", "PT", "NL", "PL",
63
+ "IN", "CN", "JP", "KR", "BR", "MX", "AR", "CO", "ZA", "NG", "KE", "TZ",
64
+ "EG", "MA", "TR", "RU", "SA", "AE", "PK", "BD", "VN", "TH", "PH", "ID", "MY",
65
+ ];
66
+ return (
67
+ <div className="min-h-screen bg-white flex flex-col items-center justify-center p-6">
68
+ <h2 className="text-2xl font-bold text-slate-800 mb-8">
69
+ {t("welcome_change_region", lang)}
70
+ </h2>
71
+ <div className="grid grid-cols-2 sm:grid-cols-3 gap-3 max-w-lg w-full max-h-[60vh] overflow-y-auto">
72
+ {countries.map((code) => (
73
+ <button
74
+ key={code}
75
+ onClick={() => {
76
+ setSelectedCountry(code);
77
+ setStep("detected");
78
+ }}
79
+ className={`p-3 rounded-2xl border-2 text-center font-medium transition-all text-sm ${
80
+ selectedCountry === code
81
+ ? "border-blue-500 bg-blue-50 text-blue-700"
82
+ : "border-slate-200 bg-white text-slate-700 hover:border-blue-200"
83
+ }`}
84
+ >
85
+ {getCountryName(code)}
86
+ </button>
87
+ ))}
88
+ </div>
89
+ </div>
90
+ );
91
+ }
92
+
93
+ // Main detected screen
94
+ return (
95
+ <div className="min-h-screen bg-gradient-to-b from-blue-50 to-white flex flex-col items-center justify-center p-6">
96
+ <div className="max-w-md w-full text-center">
97
+ {/* Logo */}
98
+ <div className="w-20 h-20 mx-auto mb-6 rounded-3xl bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center shadow-xl shadow-blue-200">
99
+ <Heart size={36} className="text-white" />
100
+ </div>
101
+
102
+ <h1 className="text-3xl font-bold text-slate-800 mb-2">
103
+ {t("welcome_title", lang)}
104
+ </h1>
105
+ <p className="text-lg text-slate-500 mb-10">
106
+ {t("welcome_subtitle", lang)}
107
+ </p>
108
+
109
+ {/* Detection card */}
110
+ <div className="bg-white rounded-3xl shadow-lg border border-slate-100 p-6 mb-8">
111
+ <p className="text-sm text-slate-500 mb-4 font-medium">
112
+ {t("welcome_detected", lang)}
113
+ </p>
114
+
115
+ <div className="flex items-center gap-3 p-4 bg-blue-50 rounded-2xl mb-3">
116
+ <Globe size={22} className="text-blue-500" />
117
+ <span className="text-lg font-semibold text-slate-800">
118
+ {LANGUAGE_NAMES[selectedLang]}
119
+ </span>
120
+ </div>
121
+
122
+ <div className="flex items-center gap-3 p-4 bg-blue-50 rounded-2xl">
123
+ <MapPin size={22} className="text-blue-500" />
124
+ <span className="text-lg font-semibold text-slate-800">
125
+ {getCountryName(selectedCountry)}
126
+ </span>
127
+ <span className="text-sm text-slate-400 ml-auto">
128
+ {t("emergency_call", lang)}: {getEmergencyNumber(selectedCountry)}
129
+ </span>
130
+ </div>
131
+ </div>
132
+
133
+ {/* Continue button */}
134
+ <button
135
+ onClick={() => onComplete(selectedLang, selectedCountry)}
136
+ className="w-full py-4 bg-blue-600 text-white rounded-2xl font-bold text-lg shadow-lg shadow-blue-200 hover:bg-blue-700 transition-colors flex items-center justify-center gap-2 mb-4"
137
+ >
138
+ {t("welcome_continue", lang)}
139
+ <ChevronRight size={20} />
140
+ </button>
141
+
142
+ {/* Change buttons */}
143
+ <div className="flex gap-3 justify-center">
144
+ <button
145
+ onClick={() => setStep("language")}
146
+ className="text-sm text-blue-600 font-medium hover:underline"
147
+ >
148
+ {t("welcome_change_language", lang)}
149
+ </button>
150
+ <span className="text-slate-300">|</span>
151
+ <button
152
+ onClick={() => setStep("region")}
153
+ className="text-sm text-blue-600 font-medium hover:underline"
154
+ >
155
+ {t("welcome_change_region", lang)}
156
+ </button>
157
+ </div>
158
+
159
+ {/* Trust badges */}
160
+ <div className="flex items-center justify-center gap-4 mt-10 flex-wrap">
161
+ <div className="flex items-center gap-1.5 text-xs text-slate-400 font-medium">
162
+ <Shield size={14} className="text-emerald-500" />
163
+ {t("badge_private", lang)}
164
+ </div>
165
+ <div className="flex items-center gap-1.5 text-xs text-slate-400 font-medium">
166
+ <Shield size={14} className="text-emerald-500" />
167
+ {t("badge_no_signup", lang)}
168
+ </div>
169
+ <div className="flex items-center gap-1.5 text-xs text-slate-400 font-medium">
170
+ <Shield size={14} className="text-emerald-500" />
171
+ {t("badge_free", lang)}
172
+ </div>
173
+ </div>
174
+ </div>
175
+ </div>
176
+ );
177
+ }
components/chat/AppDrawer.tsx ADDED
@@ -0,0 +1,234 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useEffect } from "react";
4
+ import {
5
+ X,
6
+ Plus,
7
+ MessageCircle,
8
+ Clock,
9
+ Pill,
10
+ MapPin,
11
+ Heart,
12
+ Activity,
13
+ FileText,
14
+ Calendar,
15
+ Settings,
16
+ User2,
17
+ LogIn,
18
+ LogOut,
19
+ Contact,
20
+ Camera,
21
+ AlertTriangle,
22
+ BookOpen,
23
+ Share2,
24
+ ShieldCheck,
25
+ } from "lucide-react";
26
+ import { t, type SupportedLanguage } from "@/lib/i18n";
27
+
28
+ interface AppDrawerProps {
29
+ open: boolean;
30
+ onClose: () => void;
31
+ activeKey: string;
32
+ onNavigate: (key: string) => void;
33
+ onNewChat?: () => void;
34
+ isAuthenticated?: boolean;
35
+ isAdmin?: boolean;
36
+ username?: string;
37
+ onLogout?: () => void;
38
+ language: SupportedLanguage;
39
+ }
40
+
41
+ export function AppDrawer({
42
+ open,
43
+ onClose,
44
+ activeKey,
45
+ onNavigate,
46
+ onNewChat,
47
+ isAuthenticated = false,
48
+ isAdmin = false,
49
+ username,
50
+ onLogout,
51
+ language,
52
+ }: AppDrawerProps) {
53
+ // Close on Escape
54
+ useEffect(() => {
55
+ if (!open) return;
56
+ const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
57
+ window.addEventListener("keydown", handler);
58
+ return () => window.removeEventListener("keydown", handler);
59
+ }, [open, onClose]);
60
+
61
+ // Prevent body scroll when open
62
+ useEffect(() => {
63
+ if (open) document.body.style.overflow = "hidden";
64
+ else document.body.style.overflow = "";
65
+ return () => { document.body.style.overflow = ""; };
66
+ }, [open]);
67
+
68
+ const nav = (key: string) => { onNavigate(key); onClose(); };
69
+
70
+ return (
71
+ <div
72
+ className={`md:hidden fixed inset-0 z-50 transition-all duration-300 ${
73
+ open ? "pointer-events-auto" : "pointer-events-none"
74
+ }`}
75
+ aria-hidden={!open}
76
+ >
77
+ {/* Backdrop */}
78
+ <div
79
+ className={`absolute inset-0 bg-black/40 backdrop-blur-sm transition-opacity duration-300 ${
80
+ open ? "opacity-100" : "opacity-0"
81
+ }`}
82
+ onClick={onClose}
83
+ />
84
+
85
+ {/* Drawer panel */}
86
+ <aside
87
+ className={`absolute left-0 top-0 h-full w-[82%] max-w-[320px] bg-surface-1 border-r border-line/40 shadow-card flex flex-col transition-transform duration-300 ease-out ${
88
+ open ? "translate-x-0" : "-translate-x-full"
89
+ }`}
90
+ >
91
+ {/* Header — user info + close */}
92
+ <div className="flex items-center justify-between px-4 pt-5 pb-3 safe-area-top">
93
+ <div className="flex items-center gap-2.5">
94
+ <div className="w-9 h-9 rounded-xl bg-brand-gradient flex items-center justify-center text-white shadow-soft">
95
+ <Heart size={16} />
96
+ </div>
97
+ <div className="min-w-0">
98
+ <div className="text-sm font-bold text-ink-base truncate">
99
+ {isAuthenticated ? (username || "Account") : "MedOS"}
100
+ </div>
101
+ {isAuthenticated && (
102
+ <div className="text-[11px] text-ink-muted truncate">{t("drawer_signed_in", language)}</div>
103
+ )}
104
+ {!isAuthenticated && (
105
+ <div className="text-[11px] text-ink-muted">{t("drawer_free_private", language)}</div>
106
+ )}
107
+ </div>
108
+ </div>
109
+ <button
110
+ onClick={onClose}
111
+ className="w-10 h-10 rounded-xl flex items-center justify-center text-ink-subtle hover:text-ink-base hover:bg-surface-2 transition-all"
112
+ aria-label={t("drawer_close", language)}
113
+ >
114
+ <X size={18} />
115
+ </button>
116
+ </div>
117
+
118
+ {/* New Chat button */}
119
+ <div className="px-4 pb-3">
120
+ <button
121
+ onClick={() => { onNewChat?.(); onClose(); }}
122
+ className="w-full py-2.5 bg-brand-gradient text-white rounded-xl font-bold text-sm shadow-glow hover:brightness-110 active:scale-[0.98] transition-all flex items-center justify-center gap-2"
123
+ >
124
+ <Plus size={16} />
125
+ {t("drawer_new_chat", language)}
126
+ </button>
127
+ </div>
128
+
129
+ {/* Navigation */}
130
+ <nav className="flex-1 overflow-y-auto px-3 scroll-touch">
131
+ <DrawerSection title={t("drawer_section_main", language)}>
132
+ <DrawerItem icon={MessageCircle} label={t("nav_ask", language)} active={activeKey === "chat" || activeKey === "home"} onClick={() => nav("home")} />
133
+ <DrawerItem icon={Clock} label={t("nav_history", language)} active={activeKey === "history"} onClick={() => nav("history")} />
134
+ </DrawerSection>
135
+
136
+ <DrawerSection title={t("nav_health_tracker", language)}>
137
+ <DrawerItem icon={Heart} label={t("nav_dashboard", language)} active={activeKey === "health-dashboard"} onClick={() => nav("health-dashboard")} />
138
+ <DrawerItem icon={Calendar} label={t("nav_schedule", language)} active={activeKey === "schedule"} onClick={() => nav("schedule")} />
139
+ <DrawerItem icon={Pill} label={t("nav_medications", language)} active={activeKey === "medications"} onClick={() => nav("medications")} />
140
+ <DrawerItem icon={Camera} label={t("medicines_title", language)} active={activeKey === "my-medicines"} onClick={() => nav("my-medicines")} />
141
+ <DrawerItem icon={Activity} label={t("nav_vitals", language)} active={activeKey === "vitals"} onClick={() => nav("vitals")} />
142
+ <DrawerItem icon={FileText} label={t("nav_records", language)} active={activeKey === "records"} onClick={() => nav("records")} />
143
+ <DrawerItem icon={Contact} label={t("contacts_title", language)} active={activeKey === "contacts"} onClick={() => nav("contacts")} />
144
+ </DrawerSection>
145
+
146
+ <DrawerSection title={t("drawer_section_tools", language)}>
147
+ <DrawerItem icon={MapPin} label={t("drawer_nearby", language)} active={activeKey === "nearby"} onClick={() => nav("nearby")} />
148
+ <DrawerItem icon={AlertTriangle} label={t("nav_emergency", language)} active={activeKey === "emergency"} onClick={() => nav("emergency")} urgent />
149
+ <DrawerItem icon={BookOpen} label={t("nav_topics", language)} active={activeKey === "topics"} onClick={() => nav("topics")} />
150
+ </DrawerSection>
151
+
152
+ <DrawerSection title={t("drawer_section_account", language)}>
153
+ {isAuthenticated ? (
154
+ <>
155
+ <DrawerItem icon={User2} label={t("nav_profile", language)} active={activeKey === "profile"} onClick={() => nav("profile")} />
156
+ <DrawerItem icon={Settings} label={t("nav_settings", language)} active={activeKey === "settings"} onClick={() => nav("settings")} />
157
+ {isAdmin && (
158
+ <DrawerItem icon={ShieldCheck} label="Admin" active={activeKey === "admin"} onClick={() => nav("admin")} />
159
+ )}
160
+ </>
161
+ ) : (
162
+ <>
163
+ <DrawerItem icon={LogIn} label={t("drawer_sign_up", language)} active={activeKey === "login"} onClick={() => nav("login")} />
164
+ <DrawerItem icon={Settings} label={t("nav_settings", language)} active={activeKey === "settings"} onClick={() => nav("settings")} />
165
+ </>
166
+ )}
167
+ </DrawerSection>
168
+ </nav>
169
+
170
+ {/* Footer */}
171
+ <div className="px-4 py-3 border-t border-line/40 safe-area-bottom">
172
+ {isAuthenticated && onLogout && (
173
+ <button
174
+ onClick={() => { onLogout(); onClose(); }}
175
+ className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl text-sm text-danger-500 hover:bg-danger-500/10 transition-colors mb-2"
176
+ >
177
+ <LogOut size={16} /> {t("drawer_logout", language)}
178
+ </button>
179
+ )}
180
+ <p className="text-[10px] text-ink-subtle leading-snug px-1">
181
+ {t("badge_not_doctor", language)}
182
+ </p>
183
+ </div>
184
+ </aside>
185
+ </div>
186
+ );
187
+ }
188
+
189
+ // ============================================================
190
+ // Sub-components
191
+ // ============================================================
192
+
193
+ function DrawerSection({ title, children }: { title: string; children: React.ReactNode }) {
194
+ return (
195
+ <div className="py-2">
196
+ <div className="px-3 pb-1.5 pt-1 text-[10px] font-bold uppercase tracking-[0.14em] text-ink-subtle">
197
+ {title}
198
+ </div>
199
+ <div className="space-y-0.5">{children}</div>
200
+ </div>
201
+ );
202
+ }
203
+
204
+ function DrawerItem({
205
+ icon: Icon,
206
+ label,
207
+ active,
208
+ onClick,
209
+ urgent,
210
+ }: {
211
+ icon: any;
212
+ label: string;
213
+ active: boolean;
214
+ onClick: () => void;
215
+ urgent?: boolean;
216
+ }) {
217
+ return (
218
+ <button
219
+ onClick={onClick}
220
+ className={`w-full h-11 rounded-xl px-3 flex items-center gap-3 text-left transition-all active:scale-[0.98] ${
221
+ active
222
+ ? urgent
223
+ ? "bg-danger-500/10 text-danger-500 font-semibold"
224
+ : "bg-brand-500/10 text-brand-600 font-semibold"
225
+ : urgent
226
+ ? "text-danger-500/70 hover:bg-danger-500/5"
227
+ : "text-ink-muted hover:bg-surface-2 hover:text-ink-base"
228
+ }`}
229
+ >
230
+ <Icon size={18} strokeWidth={active ? 2.25 : 1.75} className="flex-shrink-0" />
231
+ <span className="text-sm truncate">{label}</span>
232
+ </button>
233
+ );
234
+ }