github-actions[bot] commited on
Commit ·
4dc72d3
0
Parent(s):
Deploy MediBot from ac379a12
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .env.example +26 -0
- .gitignore +6 -0
- Dockerfile +56 -0
- Makefile +195 -0
- README.md +95 -0
- app/admin/page.tsx +327 -0
- app/api/admin/config/route.ts +121 -0
- app/api/admin/fetch-models/route.ts +423 -0
- app/api/admin/llm-health/route.ts +127 -0
- app/api/admin/reset-password/route.ts +62 -0
- app/api/admin/stats/route.ts +46 -0
- app/api/admin/test-connection/route.ts +243 -0
- app/api/admin/users/route.ts +78 -0
- app/api/auth/delete-account/route.ts +80 -0
- app/api/auth/forgot-password/route.ts +44 -0
- app/api/auth/login/route.ts +62 -0
- app/api/auth/logout/route.ts +14 -0
- app/api/auth/me/route.ts +32 -0
- app/api/auth/register/route.ts +68 -0
- app/api/auth/resend-verification/route.ts +28 -0
- app/api/auth/reset-password/route.ts +63 -0
- app/api/auth/verify-email/route.ts +58 -0
- app/api/chat-history/route.ts +95 -0
- app/api/chat/route.ts +173 -0
- app/api/geo/route.ts +142 -0
- app/api/health-data/route.ts +113 -0
- app/api/health-data/sync/route.ts +75 -0
- app/api/health/route.ts +10 -0
- app/api/models/route.ts +14 -0
- app/api/nearby/route.ts +94 -0
- app/api/og/route.tsx +207 -0
- app/api/rag/route.ts +30 -0
- app/api/scan/route.ts +80 -0
- app/api/sessions/route.ts +70 -0
- app/api/triage/route.ts +29 -0
- app/globals.css +378 -0
- app/icon.svg +10 -0
- app/layout.tsx +78 -0
- app/manifest.ts +43 -0
- app/page.tsx +2 -0
- app/robots.ts +23 -0
- app/sitemap.ts +27 -0
- app/stats/page.tsx +245 -0
- app/symptoms/[slug]/page.tsx +237 -0
- app/symptoms/page.tsx +86 -0
- components/MedOSApp.tsx +560 -0
- components/ThemeProvider.tsx +76 -0
- components/ThemeToggle.tsx +41 -0
- components/WelcomeScreen.tsx +177 -0
- 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 |
+
[](https://huggingface.co/spaces/ruslanmv/MediBot)
|
| 55 |
+
[](#)
|
| 56 |
+
[](#)
|
| 57 |
+
[](#)
|
| 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 & 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 |
+
}
|