Surya Prakash commited on
Commit Β·
44b44cb
1
Parent(s): 04f6e2a
-Ui deployement
Browse filesThis view is limited to 50 files because it contains too many changes. Β See raw diff
- .dockerignore +24 -0
- .env +1 -0
- .gitignore +16 -0
- Dockerfile +30 -9
- app.yaml +268 -0
- azure/deploy.md +98 -0
- backend/__init__.py +0 -0
- backend/main.py +130 -0
- backend/rag_service.py +204 -0
- backend/schemas.py +41 -0
- frontend/.gitignore +4 -0
- frontend/index.html +18 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +33 -0
- frontend/postcss.config.js +6 -0
- frontend/src/App.tsx +31 -0
- frontend/src/components/ChatInput.tsx +77 -0
- frontend/src/components/ChatThread.tsx +35 -0
- frontend/src/components/ExamplePrompts.tsx +32 -0
- frontend/src/components/MessageBubble.tsx +91 -0
- frontend/src/components/PdfViewer.tsx +131 -0
- frontend/src/components/ReportPicker.tsx +83 -0
- frontend/src/components/Sidebar.tsx +64 -0
- frontend/src/components/SourceCard.tsx +38 -0
- frontend/src/components/ThemeToggle.tsx +12 -0
- frontend/src/components/UploadPanel.tsx +119 -0
- frontend/src/components/ui/button.tsx +34 -0
- frontend/src/index.css +94 -0
- frontend/src/lib/api.ts +126 -0
- frontend/src/lib/sse.ts +39 -0
- frontend/src/lib/theme.tsx +36 -0
- frontend/src/lib/useChat.ts +82 -0
- frontend/src/lib/utils.ts +6 -0
- frontend/src/main.tsx +20 -0
- frontend/src/views/DocumentChat.tsx +117 -0
- frontend/src/views/GlobalSearch.tsx +86 -0
- frontend/src/vite-env.d.ts +1 -0
- frontend/tailwind.config.js +55 -0
- frontend/tsconfig.json +23 -0
- frontend/tsconfig.node.json +11 -0
- frontend/tsconfig.node.tsbuildinfo +1 -0
- frontend/tsconfig.tsbuildinfo +1 -0
- frontend/vite.config.d.ts +2 -0
- frontend/vite.config.js +20 -0
- frontend/vite.config.ts +21 -0
- output/biomedbert_vector_db/faiss.index +2 -2
- output/biomedbert_vector_db/metadata.pkl +2 -2
- requirements.txt +28 -17
- src/__pycache__/document_processor.cpython-313.pyc +0 -0
- src/__pycache__/retriever.cpython-313.pyc +0 -0
.dockerignore
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Frontend build artifacts / deps (rebuilt inside the image)
|
| 2 |
+
frontend/node_modules
|
| 3 |
+
frontend/dist
|
| 4 |
+
|
| 5 |
+
# Python caches / envs
|
| 6 |
+
**/__pycache__
|
| 7 |
+
*.pyc
|
| 8 |
+
rag_env
|
| 9 |
+
.venv
|
| 10 |
+
venv
|
| 11 |
+
|
| 12 |
+
# Legacy Streamlit UI (superseded by the React frontend; not run in the container)
|
| 13 |
+
app.py
|
| 14 |
+
ui/
|
| 15 |
+
|
| 16 |
+
# Local runtime data (mounted via Azure Files in prod)
|
| 17 |
+
uploaded_reports
|
| 18 |
+
|
| 19 |
+
# Repo/tooling noise
|
| 20 |
+
.git
|
| 21 |
+
.gitattributes
|
| 22 |
+
image.png
|
| 23 |
+
README.md
|
| 24 |
+
azure
|
.env
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
GEMINI_API_KEY=AIzaSyD6LIwVuyNnAF7oe5jzwoN5mAc6Z6FXjFY
|
.gitignore
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
| 4 |
+
.venv/
|
| 5 |
+
venv/
|
| 6 |
+
rag_env/
|
| 7 |
+
|
| 8 |
+
# Node / frontend
|
| 9 |
+
frontend/node_modules/
|
| 10 |
+
frontend/dist/
|
| 11 |
+
|
| 12 |
+
# Local runtime data
|
| 13 |
+
uploaded_reports/
|
| 14 |
+
|
| 15 |
+
# OS
|
| 16 |
+
.DS_Store
|
Dockerfile
CHANGED
|
@@ -1,20 +1,41 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
|
|
|
|
|
|
| 3 |
WORKDIR /app
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
git \
|
| 9 |
&& rm -rf /var/lib/apt/lists/*
|
| 10 |
|
|
|
|
| 11 |
COPY requirements.txt ./
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
COPY src/ ./src/
|
|
|
|
|
|
|
| 13 |
|
| 14 |
-
|
|
|
|
| 15 |
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
-
|
|
|
|
|
|
|
| 19 |
|
| 20 |
-
|
|
|
|
| 1 |
+
# ββ Stage 1: build the React frontend βββββββββββββββββββββββββββββββββββββββ
|
| 2 |
+
FROM node:20-slim AS frontend
|
| 3 |
+
WORKDIR /app/frontend
|
| 4 |
+
COPY frontend/package.json frontend/package-lock.json* ./
|
| 5 |
+
RUN npm ci
|
| 6 |
+
COPY frontend/ ./
|
| 7 |
+
RUN npm run build # emits /app/frontend/dist
|
| 8 |
|
| 9 |
+
# ββ Stage 2: python runtime (FastAPI serves API + built SPA) βββββββββββββββββ
|
| 10 |
+
FROM python:3.11-slim AS runtime
|
| 11 |
WORKDIR /app
|
| 12 |
|
| 13 |
+
# Build deps for faiss / paddle / native wheels
|
| 14 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 15 |
+
build-essential curl libgl1 libglib2.0-0 libgomp1 \
|
|
|
|
| 16 |
&& rm -rf /var/lib/apt/lists/*
|
| 17 |
|
| 18 |
+
# Python deps first (better layer caching)
|
| 19 |
COPY requirements.txt ./
|
| 20 |
+
RUN pip install "torch>=2.3" --index-url https://download.pytorch.org/whl/cpu && \
|
| 21 |
+
pip install -r requirements.txt
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# App code + the base vector DB
|
| 25 |
COPY src/ ./src/
|
| 26 |
+
COPY backend/ ./backend/
|
| 27 |
+
COPY output/ ./output/
|
| 28 |
|
| 29 |
+
# Built frontend from stage 1
|
| 30 |
+
COPY --from=frontend /app/frontend/dist ./frontend/dist
|
| 31 |
|
| 32 |
+
ENV CUDA_VISIBLE_DEVICES="" \
|
| 33 |
+
DB_PATH=output/biomedbert_vector_db \
|
| 34 |
+
UPLOAD_DIR=uploaded_reports \
|
| 35 |
+
PORT=8000
|
| 36 |
|
| 37 |
+
EXPOSE 8000
|
| 38 |
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=90s \
|
| 39 |
+
CMD curl -fsS http://localhost:8000/api/health || exit 1
|
| 40 |
|
| 41 |
+
CMD ["sh", "-c", "uvicorn backend.main:app --host 0.0.0.0 --port ${PORT}"]
|
app.yaml
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
id: /subscriptions/c5763c41-1363-4244-8f55-befc04bc3fdc/resourceGroups/patho-rag/providers/Microsoft.App/containerapps/pathorag
|
| 2 |
+
identity:
|
| 3 |
+
principalId: a2dd65f8-ce7c-42ac-982e-d42dbe6dbe14
|
| 4 |
+
tenantId: 61f3e3b8-9b52-433a-a4eb-c67334ce54d5
|
| 5 |
+
type: SystemAssigned
|
| 6 |
+
location: France Central
|
| 7 |
+
name: pathorag
|
| 8 |
+
properties:
|
| 9 |
+
configuration:
|
| 10 |
+
activeRevisionsMode: Single
|
| 11 |
+
dapr: null
|
| 12 |
+
identitySettings: []
|
| 13 |
+
ingress:
|
| 14 |
+
additionalPortMappings: null
|
| 15 |
+
allowInsecure: false
|
| 16 |
+
clientCertificateMode: null
|
| 17 |
+
corsPolicy: null
|
| 18 |
+
customDomains: null
|
| 19 |
+
exposedPort: 0
|
| 20 |
+
external: true
|
| 21 |
+
fqdn: pathorag.agreeablebeach-ed9dda6b.francecentral.azurecontainerapps.io
|
| 22 |
+
ipSecurityRestrictions: null
|
| 23 |
+
stickySessions: null
|
| 24 |
+
targetPort: 8000
|
| 25 |
+
targetPortHttpScheme: null
|
| 26 |
+
traffic:
|
| 27 |
+
- latestRevision: true
|
| 28 |
+
weight: 100
|
| 29 |
+
transport: Auto
|
| 30 |
+
maxInactiveRevisions: null
|
| 31 |
+
registries:
|
| 32 |
+
- identity: system
|
| 33 |
+
passwordSecretRef: ''
|
| 34 |
+
server: pathoragacr.azurecr.io
|
| 35 |
+
username: ''
|
| 36 |
+
revisionTransitionThreshold: null
|
| 37 |
+
runtime: null
|
| 38 |
+
secrets:
|
| 39 |
+
- name: google-api-key
|
| 40 |
+
service: null
|
| 41 |
+
targetLabel: ''
|
| 42 |
+
customDomainVerificationId: 8B0174A32E63E74188290E5173041F60F4E8386B5C95F7487A60CD2B2BC53587
|
| 43 |
+
delegatedIdentities: []
|
| 44 |
+
environmentId: /subscriptions/c5763c41-1363-4244-8f55-befc04bc3fdc/resourceGroups/lidc-rg/providers/Microsoft.App/managedEnvironments/managedEnvironment-lidcrg-a5f0
|
| 45 |
+
eventStreamEndpoint: https://francecentral.azurecontainerapps.dev/subscriptions/c5763c41-1363-4244-8f55-befc04bc3fdc/resourceGroups/patho-rag/containerApps/pathorag/eventstream
|
| 46 |
+
latestReadyRevisionName: pathorag--0000001
|
| 47 |
+
latestRevisionFqdn: pathorag--0000001.agreeablebeach-ed9dda6b.francecentral.azurecontainerapps.io
|
| 48 |
+
latestRevisionName: pathorag--0000001
|
| 49 |
+
managedEnvironmentId: /subscriptions/c5763c41-1363-4244-8f55-befc04bc3fdc/resourceGroups/lidc-rg/providers/Microsoft.App/managedEnvironments/managedEnvironment-lidcrg-a5f0
|
| 50 |
+
outboundIpAddresses:
|
| 51 |
+
- 20.74.16.183
|
| 52 |
+
- 20.74.94.128
|
| 53 |
+
- 20.74.94.150
|
| 54 |
+
- 20.74.93.40
|
| 55 |
+
- 98.66.249.192
|
| 56 |
+
- 98.66.250.10
|
| 57 |
+
- 98.66.248.181
|
| 58 |
+
- 98.66.249.135
|
| 59 |
+
- 98.66.248.214
|
| 60 |
+
- 98.66.250.141
|
| 61 |
+
- 20.19.239.83
|
| 62 |
+
- 172.189.12.75
|
| 63 |
+
- 172.189.174.219
|
| 64 |
+
- 4.178.216.19
|
| 65 |
+
- 172.189.173.164
|
| 66 |
+
- 4.178.14.102
|
| 67 |
+
- 20.216.214.144
|
| 68 |
+
- 20.19.159.158
|
| 69 |
+
- 20.216.214.132
|
| 70 |
+
- 172.189.28.145
|
| 71 |
+
- 40.66.62.27
|
| 72 |
+
- 4.251.149.121
|
| 73 |
+
- 20.19.159.161
|
| 74 |
+
- 4.176.16.39
|
| 75 |
+
- 172.189.105.120
|
| 76 |
+
- 40.66.63.35
|
| 77 |
+
- 4.233.27.154
|
| 78 |
+
- 4.176.16.32
|
| 79 |
+
- 4.233.27.155
|
| 80 |
+
- 172.189.165.195
|
| 81 |
+
- 172.189.15.42
|
| 82 |
+
- 4.233.27.160
|
| 83 |
+
- 20.19.232.87
|
| 84 |
+
- 172.189.15.26
|
| 85 |
+
- 172.189.28.153
|
| 86 |
+
- 172.189.14.215
|
| 87 |
+
- 4.251.149.128
|
| 88 |
+
- 172.189.158.145
|
| 89 |
+
- 172.189.107.176
|
| 90 |
+
- 4.176.46.125
|
| 91 |
+
- 172.189.28.148
|
| 92 |
+
- 4.176.16.37
|
| 93 |
+
- 4.176.16.44
|
| 94 |
+
- 4.176.46.141
|
| 95 |
+
- 20.216.214.197
|
| 96 |
+
- 172.189.28.156
|
| 97 |
+
- 172.189.14.226
|
| 98 |
+
- 4.251.149.135
|
| 99 |
+
- 20.19.236.225
|
| 100 |
+
- 172.189.14.230
|
| 101 |
+
- 4.178.14.133
|
| 102 |
+
- 20.19.159.180
|
| 103 |
+
- 4.176.46.129
|
| 104 |
+
- 172.189.107.188
|
| 105 |
+
- 20.216.221.212
|
| 106 |
+
- 4.233.27.166
|
| 107 |
+
- 4.176.46.151
|
| 108 |
+
- 20.216.214.182
|
| 109 |
+
- 4.251.201.207
|
| 110 |
+
- 20.216.214.169
|
| 111 |
+
- 20.216.221.231
|
| 112 |
+
- 4.251.201.192
|
| 113 |
+
- 172.189.14.240
|
| 114 |
+
- 4.176.46.122
|
| 115 |
+
- 4.178.14.148
|
| 116 |
+
- 4.176.46.177
|
| 117 |
+
- 4.176.46.173
|
| 118 |
+
- 4.251.149.136
|
| 119 |
+
- 4.233.27.161
|
| 120 |
+
- 172.189.107.190
|
| 121 |
+
- 172.189.165.198
|
| 122 |
+
- 172.189.28.167
|
| 123 |
+
- 172.189.28.164
|
| 124 |
+
- 20.19.159.183
|
| 125 |
+
- 20.216.221.229
|
| 126 |
+
- 4.178.14.135
|
| 127 |
+
- 20.19.232.86
|
| 128 |
+
- 20.216.221.203
|
| 129 |
+
- 20.216.214.158
|
| 130 |
+
- 20.216.221.205
|
| 131 |
+
- 20.40.148.255
|
| 132 |
+
- 4.211.69.69
|
| 133 |
+
- 4.251.202.168
|
| 134 |
+
- 4.251.186.182
|
| 135 |
+
- 4.178.14.42
|
| 136 |
+
- 4.251.138.82
|
| 137 |
+
- 172.189.13.56
|
| 138 |
+
- 4.211.69.75
|
| 139 |
+
- 172.189.46.179
|
| 140 |
+
- 20.216.196.172
|
| 141 |
+
- 20.216.214.129
|
| 142 |
+
- 4.251.201.171
|
| 143 |
+
- 51.103.5.3
|
| 144 |
+
- 20.199.32.9
|
| 145 |
+
- 172.189.158.203
|
| 146 |
+
- 51.11.233.150
|
| 147 |
+
- 172.189.14.180
|
| 148 |
+
- 172.189.27.233
|
| 149 |
+
- 20.74.47.218
|
| 150 |
+
- 172.189.28.133
|
| 151 |
+
- 20.216.216.188
|
| 152 |
+
- 20.216.216.219
|
| 153 |
+
- 20.216.217.5
|
| 154 |
+
- 20.216.216.182
|
| 155 |
+
- 98.66.242.126
|
| 156 |
+
- 98.66.242.144
|
| 157 |
+
- 98.66.242.209
|
| 158 |
+
- 98.66.242.230
|
| 159 |
+
- 98.66.242.239
|
| 160 |
+
- 98.66.242.232
|
| 161 |
+
- 172.189.12.72
|
| 162 |
+
- 20.19.223.96
|
| 163 |
+
- 20.19.220.136
|
| 164 |
+
- 20.19.221.135
|
| 165 |
+
- 4.233.16.221
|
| 166 |
+
- 4.176.46.82
|
| 167 |
+
- 4.251.201.180
|
| 168 |
+
- 20.74.96.18
|
| 169 |
+
- 172.189.14.188
|
| 170 |
+
- 20.19.207.214
|
| 171 |
+
- 4.251.149.8
|
| 172 |
+
- 4.233.27.143
|
| 173 |
+
- 20.216.221.100
|
| 174 |
+
- 20.19.207.234
|
| 175 |
+
- 4.233.27.148
|
| 176 |
+
- 20.19.207.210
|
| 177 |
+
- 4.233.28.23
|
| 178 |
+
- 20.216.221.171
|
| 179 |
+
- 172.189.28.136
|
| 180 |
+
- 4.233.27.142
|
| 181 |
+
- 20.216.221.185
|
| 182 |
+
- 20.216.221.177
|
| 183 |
+
- 4.178.14.70
|
| 184 |
+
- 172.189.159.19
|
| 185 |
+
- 20.19.159.153
|
| 186 |
+
- 4.178.14.41
|
| 187 |
+
- 172.189.107.169
|
| 188 |
+
- 20.19.159.143
|
| 189 |
+
- 4.176.46.112
|
| 190 |
+
- 172.189.107.149
|
| 191 |
+
- 172.189.165.187
|
| 192 |
+
- 4.176.16.0
|
| 193 |
+
- 172.189.165.182
|
| 194 |
+
- 4.176.16.27
|
| 195 |
+
- 4.251.149.91
|
| 196 |
+
- 4.176.16.14
|
| 197 |
+
- 172.189.158.226
|
| 198 |
+
- 4.178.13.255
|
| 199 |
+
- 4.178.14.18
|
| 200 |
+
- 4.178.14.63
|
| 201 |
+
- 4.178.14.36
|
| 202 |
+
- 172.189.14.208
|
| 203 |
+
- 172.189.158.239
|
| 204 |
+
- 20.19.159.41
|
| 205 |
+
- 20.216.221.200
|
| 206 |
+
- 4.176.16.21
|
| 207 |
+
- 20.19.207.222
|
| 208 |
+
- 4.233.27.244
|
| 209 |
+
- 172.189.158.230
|
| 210 |
+
- 4.176.46.94
|
| 211 |
+
- 4.251.149.99
|
| 212 |
+
- 4.251.149.50
|
| 213 |
+
- 4.251.149.109
|
| 214 |
+
- 4.251.201.186
|
| 215 |
+
- 172.189.107.139
|
| 216 |
+
- 20.216.214.131
|
| 217 |
+
- 172.189.28.140
|
| 218 |
+
- 172.189.14.205
|
| 219 |
+
- 20.19.159.140
|
| 220 |
+
- 172.189.28.143
|
| 221 |
+
- 172.189.28.135
|
| 222 |
+
- 20.19.159.136
|
| 223 |
+
- 172.189.158.223
|
| 224 |
+
- 40.66.60.105
|
| 225 |
+
- 4.233.26.37
|
| 226 |
+
- 4.178.14.2
|
| 227 |
+
- 20.216.221.164
|
| 228 |
+
- 172.189.105.174
|
| 229 |
+
- 4.233.27.134
|
| 230 |
+
- 20.216.219.52
|
| 231 |
+
- 172.189.44.67
|
| 232 |
+
patchingMode: Automatic
|
| 233 |
+
provisioningState: Succeeded
|
| 234 |
+
runningStatus: Running
|
| 235 |
+
template:
|
| 236 |
+
containers:
|
| 237 |
+
- env:
|
| 238 |
+
- name: GOOGLE_API_KEY
|
| 239 |
+
secretRef: google-api-key
|
| 240 |
+
image: pathoragacr.azurecr.io/pathorag:latest
|
| 241 |
+
imageType: ContainerImage
|
| 242 |
+
name: pathorag
|
| 243 |
+
resources:
|
| 244 |
+
cpu: 2.0
|
| 245 |
+
ephemeralStorage: 8Gi
|
| 246 |
+
memory: 4Gi
|
| 247 |
+
customMetricsSettings: null
|
| 248 |
+
initContainers: null
|
| 249 |
+
revisionSuffix: ''
|
| 250 |
+
scale:
|
| 251 |
+
cooldownPeriod: 300
|
| 252 |
+
maxReplicas: 3
|
| 253 |
+
minReplicas: 1
|
| 254 |
+
pollingInterval: 30
|
| 255 |
+
rules: null
|
| 256 |
+
serviceBinds: null
|
| 257 |
+
terminationGracePeriodSeconds: null
|
| 258 |
+
volumes: null
|
| 259 |
+
workloadProfileName: Consumption
|
| 260 |
+
resourceGroup: patho-rag
|
| 261 |
+
systemData:
|
| 262 |
+
createdAt: '2026-07-10T20:52:23.0352323'
|
| 263 |
+
createdBy: surya-prakash.selva@student-cs.fr
|
| 264 |
+
createdByType: User
|
| 265 |
+
lastModifiedAt: '2026-07-10T20:52:42.5113595'
|
| 266 |
+
lastModifiedBy: surya-prakash.selva@student-cs.fr
|
| 267 |
+
lastModifiedByType: User
|
| 268 |
+
type: Microsoft.App/containerApps
|
azure/deploy.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Deploying to Azure Container Apps
|
| 2 |
+
|
| 3 |
+
Single Docker image: FastAPI (`backend/main.py`) serves both the `/api/*` routes
|
| 4 |
+
and the built React SPA at `/`. These steps build the image in Azure Container
|
| 5 |
+
Registry (ACR) and run it on Azure Container Apps (ACA).
|
| 6 |
+
|
| 7 |
+
## Prerequisites
|
| 8 |
+
|
| 9 |
+
- `az` CLI logged in (`az login`) with a subscription selected.
|
| 10 |
+
- A resource group, e.g. `az group create -n patho-rag -l eastus`.
|
| 11 |
+
- Your `GOOGLE_API_KEY` for Gemini.
|
| 12 |
+
- **Vector DB present locally**: `output/biomedbert_vector_db/{faiss.index,metadata.pkl}`
|
| 13 |
+
are tracked with git-LFS. Run `git lfs pull` so the image bakes real files,
|
| 14 |
+
not LFS pointer stubs (otherwise the app boots "degraded").
|
| 15 |
+
|
| 16 |
+
## 1. Create an ACR and build the image
|
| 17 |
+
|
| 18 |
+
```bash
|
| 19 |
+
az acr create -g patho-rag -n pathoragacr --sku Basic
|
| 20 |
+
az acr build -r pathoragacr -t pathorag:latest . # builds Dockerfile in the cloud
|
| 21 |
+
```
|
| 22 |
+
|
| 23 |
+
## 2. Create the Container Apps environment
|
| 24 |
+
|
| 25 |
+
```bash
|
| 26 |
+
az extension add --name containerapp --upgrade
|
| 27 |
+
az containerapp env create -g patho-rag -n patho-env -l eastus
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
## 3. Create the app
|
| 31 |
+
|
| 32 |
+
```bash
|
| 33 |
+
az containerapp create \
|
| 34 |
+
-g patho-rag -n pathorag \
|
| 35 |
+
--environment patho-env \
|
| 36 |
+
--image pathoragacr.azurecr.io/pathorag:latest \
|
| 37 |
+
--registry-server pathoragacr.azurecr.io \
|
| 38 |
+
--target-port 8000 --ingress external \
|
| 39 |
+
--min-replicas 1 --max-replicas 3 \
|
| 40 |
+
--cpu 2 --memory 4Gi \
|
| 41 |
+
--secrets google-api-key=<YOUR_GEMINI_KEY> \
|
| 42 |
+
--env-vars GOOGLE_API_KEY=secretref:google-api-key
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
- `--min-replicas 1` avoids scale-to-zero so the models stay warm (cold start
|
| 46 |
+
reloads BiomedBERT + cross-encoder + PaddleOCR, which is slow).
|
| 47 |
+
- Bump `--cpu/--memory` if OCR/embedding is heavy; torch + paddle want headroom.
|
| 48 |
+
|
| 49 |
+
The public URL is the app's FQDN:
|
| 50 |
+
|
| 51 |
+
```bash
|
| 52 |
+
az containerapp show -g patho-rag -n pathorag --query properties.configuration.ingress.fqdn -o tsv
|
| 53 |
+
```
|
| 54 |
+
|
| 55 |
+
## 4. Persist uploads + index across restarts (important)
|
| 56 |
+
|
| 57 |
+
The container filesystem is **ephemeral** β uploaded PDFs and the FAISS index
|
| 58 |
+
updates from new documents are lost on restart/redeploy unless mounted.
|
| 59 |
+
|
| 60 |
+
1. Create a storage account + file share, and register it with the ACA env:
|
| 61 |
+
|
| 62 |
+
```bash
|
| 63 |
+
az storage account create -g patho-rag -n pathoragstore --sku Standard_LRS
|
| 64 |
+
az storage share create --account-name pathoragstore -n ragdata
|
| 65 |
+
az containerapp env storage set -g patho-rag -n patho-env \
|
| 66 |
+
--storage-name ragdata --azure-file-account-name pathoragstore \
|
| 67 |
+
--azure-file-account-key <KEY> --azure-file-share-name ragdata \
|
| 68 |
+
--access-mode ReadWrite
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
2. Mount it into the app (via `az containerapp update --yaml` volume/volumeMounts)
|
| 72 |
+
at both `uploaded_reports/` and `output/biomedbert_vector_db/`, and seed the
|
| 73 |
+
share with the initial `output/biomedbert_vector_db/` contents so the base
|
| 74 |
+
corpus is available on first boot.
|
| 75 |
+
|
| 76 |
+
## 5. Model cache (optional, faster cold starts)
|
| 77 |
+
|
| 78 |
+
BiomedBERT, the cross-encoder, and PaddleOCR weights download on first use. To
|
| 79 |
+
avoid re-downloading on every new replica, either bake a warm cache into the
|
| 80 |
+
image (pre-download during `docker build`) or point `HF_HOME` / the paddle cache
|
| 81 |
+
dir at the mounted Azure Files share.
|
| 82 |
+
|
| 83 |
+
## Update / redeploy
|
| 84 |
+
|
| 85 |
+
```bash
|
| 86 |
+
az acr build -r pathoragacr -t pathorag:latest .
|
| 87 |
+
az containerapp update -g patho-rag -n pathorag \
|
| 88 |
+
--image pathoragacr.azurecr.io/pathorag:latest
|
| 89 |
+
```
|
| 90 |
+
|
| 91 |
+
## Smoke test
|
| 92 |
+
|
| 93 |
+
- Open the FQDN β the SPA loads.
|
| 94 |
+
- `GET /api/health` β `{"status":"ok","num_documents":N}`.
|
| 95 |
+
- Ask a question β answer streams token-by-token with citations.
|
| 96 |
+
- Upload a PDF β progress bar β the doc becomes selectable and highlights work.
|
| 97 |
+
- Restart a replica β previously uploaded docs still present (confirms the mount).
|
| 98 |
+
```
|
backend/__init__.py
ADDED
|
File without changes
|
backend/main.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI app for the Pathology RAG system.
|
| 2 |
+
|
| 3 |
+
Serves a streaming chat API over the existing RAG pipeline and (in the
|
| 4 |
+
container) the built React SPA from the same origin.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
import json
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from contextlib import asynccontextmanager
|
| 11 |
+
|
| 12 |
+
from fastapi import FastAPI, UploadFile, File, HTTPException
|
| 13 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 14 |
+
from fastapi.responses import FileResponse
|
| 15 |
+
from fastapi.staticfiles import StaticFiles
|
| 16 |
+
from sse_starlette.sse import EventSourceResponse
|
| 17 |
+
|
| 18 |
+
from .schemas import (
|
| 19 |
+
ChatRequest, ReportsResponse, HealthResponse, UploadStartResponse,
|
| 20 |
+
)
|
| 21 |
+
from .rag_service import service, UPLOAD_DIR
|
| 22 |
+
|
| 23 |
+
FRONTEND_DIST = Path(__file__).resolve().parent.parent / "frontend" / "dist"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@asynccontextmanager
|
| 27 |
+
async def lifespan(app: FastAPI):
|
| 28 |
+
# Load the (expensive) pipeline once at startup.
|
| 29 |
+
service.load()
|
| 30 |
+
yield
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
app = FastAPI(title="Pathology RAG API", lifespan=lifespan)
|
| 34 |
+
|
| 35 |
+
# Permissive CORS only matters for local dev (Vite on :5173 β API on :8000).
|
| 36 |
+
# In the container the SPA is same-origin so this is a no-op.
|
| 37 |
+
app.add_middleware(
|
| 38 |
+
CORSMiddleware,
|
| 39 |
+
allow_origins=["*"],
|
| 40 |
+
allow_methods=["*"],
|
| 41 |
+
allow_headers=["*"],
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
async def _sse(threaded_stream):
|
| 46 |
+
"""Adapt a ThreadedEventStream ({"event","data":dict}) to the dict shape
|
| 47 |
+
EventSourceResponse expects ({"event", "data": <json str>})."""
|
| 48 |
+
async for item in threaded_stream:
|
| 49 |
+
yield {"event": item["event"], "data": json.dumps(item["data"])}
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
# ββ API βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 53 |
+
@app.get("/api/health", response_model=HealthResponse)
|
| 54 |
+
async def health():
|
| 55 |
+
if service.ready:
|
| 56 |
+
return HealthResponse(status="ok", num_documents=len(service.get_reports()))
|
| 57 |
+
return HealthResponse(status="degraded", num_documents=0, detail=service.load_error)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
@app.get("/api/reports", response_model=ReportsResponse)
|
| 61 |
+
async def reports():
|
| 62 |
+
return ReportsResponse(reports=service.get_reports())
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@app.post("/api/chat")
|
| 66 |
+
async def chat(req: ChatRequest):
|
| 67 |
+
if not service.ready:
|
| 68 |
+
raise HTTPException(503, detail=f"Pipeline not ready: {service.load_error}")
|
| 69 |
+
|
| 70 |
+
history = [m.model_dump() for m in req.history] if req.history else None
|
| 71 |
+
producer = service.make_chat_producer(
|
| 72 |
+
question=req.question,
|
| 73 |
+
report_name=req.report_name,
|
| 74 |
+
report_names=req.report_names,
|
| 75 |
+
top_k=req.top_k,
|
| 76 |
+
history=history,
|
| 77 |
+
)
|
| 78 |
+
from .rag_service import ThreadedEventStream
|
| 79 |
+
stream = ThreadedEventStream()
|
| 80 |
+
stream.run(producer)
|
| 81 |
+
return EventSourceResponse(_sse(stream))
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
@app.post("/api/upload", response_model=UploadStartResponse)
|
| 85 |
+
async def upload(file: UploadFile = File(...)):
|
| 86 |
+
if not service.ready:
|
| 87 |
+
raise HTTPException(503, detail=f"Pipeline not ready: {service.load_error}")
|
| 88 |
+
if not file.filename.lower().endswith(".pdf"):
|
| 89 |
+
raise HTTPException(400, detail="Only PDF files are supported.")
|
| 90 |
+
|
| 91 |
+
upload_dir = Path(UPLOAD_DIR)
|
| 92 |
+
upload_dir.mkdir(exist_ok=True)
|
| 93 |
+
# Strip any path components from the client-supplied name.
|
| 94 |
+
safe_name = Path(file.filename).name
|
| 95 |
+
dest = upload_dir / safe_name
|
| 96 |
+
dest.write_bytes(await file.read())
|
| 97 |
+
|
| 98 |
+
job_id = service.start_upload(str(dest))
|
| 99 |
+
return UploadStartResponse(job_id=job_id, filename=safe_name)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
@app.get("/api/upload/{job_id}/events")
|
| 103 |
+
async def upload_events(job_id: str):
|
| 104 |
+
stream = service.get_job(job_id)
|
| 105 |
+
if stream is None:
|
| 106 |
+
raise HTTPException(404, detail="Unknown or expired upload job.")
|
| 107 |
+
|
| 108 |
+
async def gen():
|
| 109 |
+
async for evt in _sse(stream):
|
| 110 |
+
yield evt
|
| 111 |
+
service.pop_job(job_id)
|
| 112 |
+
|
| 113 |
+
return EventSourceResponse(gen())
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
@app.get("/api/pdf/{name}")
|
| 117 |
+
async def get_pdf(name: str):
|
| 118 |
+
"""Serve a raw uploaded PDF for the viewer. `name` may be the report stem
|
| 119 |
+
or the full file name; path components are stripped to prevent traversal."""
|
| 120 |
+
stem = Path(name).name
|
| 121 |
+
upload_dir = Path(UPLOAD_DIR)
|
| 122 |
+
candidate = upload_dir / (stem if stem.lower().endswith(".pdf") else f"{stem}.pdf")
|
| 123 |
+
if not candidate.exists():
|
| 124 |
+
raise HTTPException(404, detail="PDF not found.")
|
| 125 |
+
return FileResponse(str(candidate), media_type="application/pdf")
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
# ββ Static SPA (only when a build exists; in dev Vite serves the frontend) ββ
|
| 129 |
+
if FRONTEND_DIST.exists():
|
| 130 |
+
app.mount("/", StaticFiles(directory=str(FRONTEND_DIST), html=True), name="spa")
|
backend/rag_service.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Service layer around the existing RAG pipeline.
|
| 2 |
+
|
| 3 |
+
Wraps `CompleteRAGPipeline` (retrieval + streaming generation) and
|
| 4 |
+
`DynamicRAGUpdater` (OCR upload + index update), and provides a small
|
| 5 |
+
threadβasync bridge so the blocking ML work streams cleanly over SSE
|
| 6 |
+
without blocking the event loop.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import os
|
| 10 |
+
import sys
|
| 11 |
+
import uuid
|
| 12 |
+
import queue
|
| 13 |
+
import asyncio
|
| 14 |
+
import threading
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
from typing import Dict, List, Optional, Callable
|
| 17 |
+
|
| 18 |
+
# Reuse the existing, working RAG code in src/
|
| 19 |
+
sys.path.append(str(Path(__file__).resolve().parent.parent / "src"))
|
| 20 |
+
|
| 21 |
+
DB_PATH = os.getenv("DB_PATH", "output/biomedbert_vector_db")
|
| 22 |
+
EMBEDDING_MODEL = os.getenv(
|
| 23 |
+
"EMBEDDING_MODEL",
|
| 24 |
+
"microsoft/BiomedNLP-BiomedBERT-base-uncased-abstract-fulltext",
|
| 25 |
+
)
|
| 26 |
+
UPLOAD_DIR = os.getenv("UPLOAD_DIR", "uploaded_reports")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 30 |
+
# Thread β async SSE bridge
|
| 31 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 32 |
+
class ThreadedEventStream:
|
| 33 |
+
"""Runs a blocking `producer(emit)` in a daemon thread and exposes an async
|
| 34 |
+
iterator of the events it emits. The producer calls `emit({"event","data"})`
|
| 35 |
+
for each event; iteration ends when the producer returns (or raises, which
|
| 36 |
+
is surfaced as a final error event)."""
|
| 37 |
+
|
| 38 |
+
_DONE = object()
|
| 39 |
+
|
| 40 |
+
def __init__(self):
|
| 41 |
+
self._q: "queue.Queue" = queue.Queue()
|
| 42 |
+
|
| 43 |
+
def run(self, producer: Callable[[Callable[[dict], None]], None]):
|
| 44 |
+
def worker():
|
| 45 |
+
try:
|
| 46 |
+
producer(self._q.put)
|
| 47 |
+
except Exception as exc: # noqa: BLE001 - surface to the client
|
| 48 |
+
self._q.put({"event": "error", "data": {"message": str(exc)}})
|
| 49 |
+
finally:
|
| 50 |
+
self._q.put(self._DONE)
|
| 51 |
+
|
| 52 |
+
threading.Thread(target=worker, daemon=True).start()
|
| 53 |
+
|
| 54 |
+
async def __aiter__(self):
|
| 55 |
+
while True:
|
| 56 |
+
try:
|
| 57 |
+
item = self._q.get_nowait()
|
| 58 |
+
except queue.Empty:
|
| 59 |
+
await asyncio.sleep(0.05)
|
| 60 |
+
continue
|
| 61 |
+
if item is self._DONE:
|
| 62 |
+
break
|
| 63 |
+
yield item
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class RagService:
|
| 67 |
+
def __init__(self):
|
| 68 |
+
self.pipeline = None
|
| 69 |
+
self._updater = None
|
| 70 |
+
self._ocr_engine = None
|
| 71 |
+
self.load_error: Optional[str] = None
|
| 72 |
+
self._jobs: Dict[str, ThreadedEventStream] = {}
|
| 73 |
+
self._job_files: Dict[str, str] = {}
|
| 74 |
+
|
| 75 |
+
# ββ lifecycle ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 76 |
+
def load(self):
|
| 77 |
+
"""Load the pipeline once at startup. Failures (missing API key / DB)
|
| 78 |
+
are captured so the app can still serve the frontend and report a
|
| 79 |
+
degraded health status instead of crashing."""
|
| 80 |
+
try:
|
| 81 |
+
from retriever import CompleteRAGPipeline
|
| 82 |
+
self.pipeline = CompleteRAGPipeline(
|
| 83 |
+
faiss_db_path=DB_PATH,
|
| 84 |
+
embedding_model=EMBEDDING_MODEL,
|
| 85 |
+
)
|
| 86 |
+
self.load_error = None
|
| 87 |
+
except Exception as exc: # noqa: BLE001
|
| 88 |
+
import traceback
|
| 89 |
+
self.pipeline = None
|
| 90 |
+
self.load_error = str(exc)
|
| 91 |
+
# Print the full traceback so the real failing import shows up in
|
| 92 |
+
# container logs instead of just the top-level message.
|
| 93 |
+
print("=== PIPELINE LOAD FAILED ===", flush=True)
|
| 94 |
+
traceback.print_exc()
|
| 95 |
+
|
| 96 |
+
@property
|
| 97 |
+
def ready(self) -> bool:
|
| 98 |
+
return self.pipeline is not None
|
| 99 |
+
|
| 100 |
+
def _get_updater(self):
|
| 101 |
+
"""Lazily build the OCR-based updater on first upload, reusing the
|
| 102 |
+
pipeline's already-loaded embedder so only one embedding model lives
|
| 103 |
+
in memory (same pattern the Streamlit app used)."""
|
| 104 |
+
if self._updater is None:
|
| 105 |
+
from paddleocr import PaddleOCR
|
| 106 |
+
from document_processor import DynamicRAGUpdater
|
| 107 |
+
if self._ocr_engine is None:
|
| 108 |
+
# PaddleOCR 3.x API (see document_processor for rationale).
|
| 109 |
+
self._ocr_engine = PaddleOCR(
|
| 110 |
+
lang="en",
|
| 111 |
+
use_doc_orientation_classify=False,
|
| 112 |
+
use_doc_unwarping=False,
|
| 113 |
+
use_textline_orientation=False,
|
| 114 |
+
)
|
| 115 |
+
self._updater = DynamicRAGUpdater(
|
| 116 |
+
vector_db_path=DB_PATH,
|
| 117 |
+
upload_dir=UPLOAD_DIR,
|
| 118 |
+
ocr_engine=self._ocr_engine,
|
| 119 |
+
embedder=self.pipeline.query_processor.model,
|
| 120 |
+
)
|
| 121 |
+
return self._updater
|
| 122 |
+
|
| 123 |
+
# ββ reads βββββββββββββββββββββββοΏ½οΏ½ββββββββββββββββββββββββββββββββββββββ
|
| 124 |
+
def get_reports(self) -> List[str]:
|
| 125 |
+
if not self.ready:
|
| 126 |
+
return []
|
| 127 |
+
return self.pipeline.get_available_reports()
|
| 128 |
+
|
| 129 |
+
# ββ retrieval + streaming generation ββββββββββββββββββββββββββββββββββββ
|
| 130 |
+
@staticmethod
|
| 131 |
+
def _sources_payload(top_chunks: List[Dict]) -> List[dict]:
|
| 132 |
+
out = []
|
| 133 |
+
for i, src in enumerate(top_chunks, 1):
|
| 134 |
+
chunk = src["chunk"]
|
| 135 |
+
out.append({
|
| 136 |
+
"index": i,
|
| 137 |
+
"filename": chunk.get("filename", "unknown"),
|
| 138 |
+
"page": chunk.get("page"),
|
| 139 |
+
"line_bboxes": chunk.get("line_bboxes", []),
|
| 140 |
+
"text": chunk.get("text", ""),
|
| 141 |
+
"score": float(src.get("ce_score", src.get("score", 0.0))),
|
| 142 |
+
})
|
| 143 |
+
return out
|
| 144 |
+
|
| 145 |
+
def make_chat_producer(
|
| 146 |
+
self,
|
| 147 |
+
question: str,
|
| 148 |
+
report_name: Optional[str],
|
| 149 |
+
report_names: Optional[List[str]],
|
| 150 |
+
top_k: int,
|
| 151 |
+
history: Optional[List[dict]],
|
| 152 |
+
):
|
| 153 |
+
"""Returns a producer(emit) that: retrieves β emits `sources` β
|
| 154 |
+
streams `token` deltas β emits `done`."""
|
| 155 |
+
|
| 156 |
+
def producer(emit: Callable[[dict], None]):
|
| 157 |
+
top_chunks = self.pipeline.retrieve(
|
| 158 |
+
question,
|
| 159 |
+
report_name=report_name,
|
| 160 |
+
report_names=report_names,
|
| 161 |
+
top_k=top_k,
|
| 162 |
+
)
|
| 163 |
+
emit({"event": "sources", "data": {"sources": self._sources_payload(top_chunks)}})
|
| 164 |
+
|
| 165 |
+
if not top_chunks:
|
| 166 |
+
emit({"event": "token", "data": {"text": "No relevant information found for this question."}})
|
| 167 |
+
emit({"event": "done", "data": {"num_sources": 0}})
|
| 168 |
+
return
|
| 169 |
+
|
| 170 |
+
for delta in self.pipeline.llm.generate_stream(question, top_chunks, history):
|
| 171 |
+
emit({"event": "token", "data": {"text": delta}})
|
| 172 |
+
|
| 173 |
+
emit({"event": "done", "data": {"num_sources": len(top_chunks)}})
|
| 174 |
+
|
| 175 |
+
return producer
|
| 176 |
+
|
| 177 |
+
# ββ upload as a background job with progress SSE βββββββββββββββββββββββββ
|
| 178 |
+
def start_upload(self, pdf_path: str) -> str:
|
| 179 |
+
job_id = uuid.uuid4().hex
|
| 180 |
+
stream = ThreadedEventStream()
|
| 181 |
+
|
| 182 |
+
def producer(emit: Callable[[dict], None]):
|
| 183 |
+
def cb(stage: str, detail: dict):
|
| 184 |
+
emit({"event": "progress", "data": {"stage": stage, **(detail or {})}})
|
| 185 |
+
|
| 186 |
+
updater = self._get_updater()
|
| 187 |
+
stats = updater.process_and_add_pdf(pdf_path, progress_callback=cb)
|
| 188 |
+
self.pipeline.reload_index()
|
| 189 |
+
emit({"event": "complete", "data": stats})
|
| 190 |
+
|
| 191 |
+
stream.run(producer)
|
| 192 |
+
self._jobs[job_id] = stream
|
| 193 |
+
self._job_files[job_id] = pdf_path
|
| 194 |
+
return job_id
|
| 195 |
+
|
| 196 |
+
def get_job(self, job_id: str) -> Optional[ThreadedEventStream]:
|
| 197 |
+
return self._jobs.get(job_id)
|
| 198 |
+
|
| 199 |
+
def pop_job(self, job_id: str):
|
| 200 |
+
self._jobs.pop(job_id, None)
|
| 201 |
+
self._job_files.pop(job_id, None)
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
service = RagService()
|
backend/schemas.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pydantic request/response models for the Pathology RAG API."""
|
| 2 |
+
|
| 3 |
+
from typing import List, Optional
|
| 4 |
+
from pydantic import BaseModel
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class ChatMessage(BaseModel):
|
| 8 |
+
role: str # "user" | "assistant"
|
| 9 |
+
content: str
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class ChatRequest(BaseModel):
|
| 13 |
+
question: str
|
| 14 |
+
report_name: Optional[str] = None # single-document scope (Document Chat)
|
| 15 |
+
report_names: Optional[List[str]] = None # multi-document scope (Global Search)
|
| 16 |
+
top_k: int = 5
|
| 17 |
+
history: Optional[List[ChatMessage]] = None
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class SourceOut(BaseModel):
|
| 21 |
+
index: int
|
| 22 |
+
filename: str
|
| 23 |
+
page: Optional[int] = None
|
| 24 |
+
line_bboxes: List[List[float]] = []
|
| 25 |
+
text: str
|
| 26 |
+
score: float
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class ReportsResponse(BaseModel):
|
| 30 |
+
reports: List[str]
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class HealthResponse(BaseModel):
|
| 34 |
+
status: str # "ok" | "degraded"
|
| 35 |
+
num_documents: int
|
| 36 |
+
detail: Optional[str] = None
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class UploadStartResponse(BaseModel):
|
| 40 |
+
job_id: str
|
| 41 |
+
filename: str
|
frontend/.gitignore
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules
|
| 2 |
+
dist
|
| 3 |
+
*.local
|
| 4 |
+
.DS_Store
|
frontend/index.html
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>Pathology RAG</title>
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 8 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 9 |
+
<link
|
| 10 |
+
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
|
| 11 |
+
rel="stylesheet"
|
| 12 |
+
/>
|
| 13 |
+
</head>
|
| 14 |
+
<body>
|
| 15 |
+
<div id="root"></div>
|
| 16 |
+
<script type="module" src="/src/main.tsx"></script>
|
| 17 |
+
</body>
|
| 18 |
+
</html>
|
frontend/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "pathology-rag-frontend",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "1.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "tsc -b && vite build",
|
| 9 |
+
"preview": "vite preview"
|
| 10 |
+
},
|
| 11 |
+
"dependencies": {
|
| 12 |
+
"@tanstack/react-query": "^5.51.0",
|
| 13 |
+
"class-variance-authority": "^0.7.0",
|
| 14 |
+
"clsx": "^2.1.1",
|
| 15 |
+
"lucide-react": "^0.412.0",
|
| 16 |
+
"react": "^18.3.1",
|
| 17 |
+
"react-dom": "^18.3.1",
|
| 18 |
+
"react-markdown": "^9.0.1",
|
| 19 |
+
"react-pdf": "^9.1.1",
|
| 20 |
+
"tailwind-merge": "^2.4.0"
|
| 21 |
+
},
|
| 22 |
+
"devDependencies": {
|
| 23 |
+
"@types/node": "^20.19.43",
|
| 24 |
+
"@types/react": "^18.3.3",
|
| 25 |
+
"@types/react-dom": "^18.3.0",
|
| 26 |
+
"@vitejs/plugin-react": "^4.3.1",
|
| 27 |
+
"autoprefixer": "^10.4.19",
|
| 28 |
+
"postcss": "^8.4.40",
|
| 29 |
+
"tailwindcss": "^3.4.7",
|
| 30 |
+
"typescript": "^5.5.4",
|
| 31 |
+
"vite": "^5.3.5"
|
| 32 |
+
}
|
| 33 |
+
}
|
frontend/postcss.config.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export default {
|
| 2 |
+
plugins: {
|
| 3 |
+
tailwindcss: {},
|
| 4 |
+
autoprefixer: {},
|
| 5 |
+
},
|
| 6 |
+
};
|
frontend/src/App.tsx
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from "react";
|
| 2 |
+
import { useQuery } from "@tanstack/react-query";
|
| 3 |
+
import { AlertTriangle } from "lucide-react";
|
| 4 |
+
import { getHealth } from "@/lib/api";
|
| 5 |
+
import { Sidebar, type View } from "@/components/Sidebar";
|
| 6 |
+
import { DocumentChat } from "@/views/DocumentChat";
|
| 7 |
+
import { GlobalSearch } from "@/views/GlobalSearch";
|
| 8 |
+
|
| 9 |
+
export default function App() {
|
| 10 |
+
const [view, setView] = useState<View>("document");
|
| 11 |
+
const { data: health } = useQuery({ queryKey: ["health"], queryFn: getHealth });
|
| 12 |
+
|
| 13 |
+
return (
|
| 14 |
+
<div className="flex h-screen overflow-hidden">
|
| 15 |
+
<Sidebar view={view} onView={setView} />
|
| 16 |
+
<main className="flex min-w-0 flex-1 flex-col">
|
| 17 |
+
{health?.status === "degraded" && (
|
| 18 |
+
<div className="flex items-center gap-2 border-b border-amber-500/30 bg-amber-500/10 px-4 py-2 text-xs text-amber-600 dark:text-amber-400">
|
| 19 |
+
<AlertTriangle className="h-4 w-4 shrink-0" />
|
| 20 |
+
<span className="truncate">
|
| 21 |
+
Backend not fully ready: {health.detail ?? "pipeline unavailable"}
|
| 22 |
+
</span>
|
| 23 |
+
</div>
|
| 24 |
+
)}
|
| 25 |
+
<div className="min-h-0 flex-1">
|
| 26 |
+
{view === "document" ? <DocumentChat /> : <GlobalSearch />}
|
| 27 |
+
</div>
|
| 28 |
+
</main>
|
| 29 |
+
</div>
|
| 30 |
+
);
|
| 31 |
+
}
|
frontend/src/components/ChatInput.tsx
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useRef, useState, type KeyboardEvent } from "react";
|
| 2 |
+
import { ArrowUp, Square } from "lucide-react";
|
| 3 |
+
import { cn } from "@/lib/utils";
|
| 4 |
+
|
| 5 |
+
export function ChatInput({
|
| 6 |
+
onSend,
|
| 7 |
+
onStop,
|
| 8 |
+
busy,
|
| 9 |
+
disabled,
|
| 10 |
+
placeholder,
|
| 11 |
+
}: {
|
| 12 |
+
onSend: (text: string) => void;
|
| 13 |
+
onStop?: () => void;
|
| 14 |
+
busy?: boolean;
|
| 15 |
+
disabled?: boolean;
|
| 16 |
+
placeholder?: string;
|
| 17 |
+
}) {
|
| 18 |
+
const [text, setText] = useState("");
|
| 19 |
+
const ref = useRef<HTMLTextAreaElement>(null);
|
| 20 |
+
|
| 21 |
+
const submit = () => {
|
| 22 |
+
const t = text.trim();
|
| 23 |
+
if (!t || busy || disabled) return;
|
| 24 |
+
onSend(t);
|
| 25 |
+
setText("");
|
| 26 |
+
if (ref.current) ref.current.style.height = "auto";
|
| 27 |
+
};
|
| 28 |
+
|
| 29 |
+
const onKey = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
| 30 |
+
if (e.key === "Enter" && !e.shiftKey) {
|
| 31 |
+
e.preventDefault();
|
| 32 |
+
submit();
|
| 33 |
+
}
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
return (
|
| 37 |
+
<div
|
| 38 |
+
className={cn(
|
| 39 |
+
"flex items-end gap-2 rounded-2xl border border-border bg-card p-2 shadow-sm transition-colors focus-within:border-primary/60",
|
| 40 |
+
disabled && "opacity-60",
|
| 41 |
+
)}
|
| 42 |
+
>
|
| 43 |
+
<textarea
|
| 44 |
+
ref={ref}
|
| 45 |
+
value={text}
|
| 46 |
+
rows={1}
|
| 47 |
+
disabled={disabled}
|
| 48 |
+
placeholder={placeholder ?? "Ask a questionβ¦"}
|
| 49 |
+
onChange={(e) => {
|
| 50 |
+
setText(e.target.value);
|
| 51 |
+
e.target.style.height = "auto";
|
| 52 |
+
e.target.style.height = Math.min(e.target.scrollHeight, 200) + "px";
|
| 53 |
+
}}
|
| 54 |
+
onKeyDown={onKey}
|
| 55 |
+
className="max-h-[200px] flex-1 resize-none bg-transparent px-3 py-2 text-sm outline-none placeholder:text-muted-foreground"
|
| 56 |
+
/>
|
| 57 |
+
{busy ? (
|
| 58 |
+
<button
|
| 59 |
+
onClick={onStop}
|
| 60 |
+
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-muted text-foreground hover:bg-accent"
|
| 61 |
+
title="Stop"
|
| 62 |
+
>
|
| 63 |
+
<Square className="h-4 w-4" />
|
| 64 |
+
</button>
|
| 65 |
+
) : (
|
| 66 |
+
<button
|
| 67 |
+
onClick={submit}
|
| 68 |
+
disabled={!text.trim() || disabled}
|
| 69 |
+
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-primary text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-40"
|
| 70 |
+
title="Send"
|
| 71 |
+
>
|
| 72 |
+
<ArrowUp className="h-4 w-4" />
|
| 73 |
+
</button>
|
| 74 |
+
)}
|
| 75 |
+
</div>
|
| 76 |
+
);
|
| 77 |
+
}
|
frontend/src/components/ChatThread.tsx
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useRef } from "react";
|
| 2 |
+
import { MessageBubble, type Message } from "./MessageBubble";
|
| 3 |
+
|
| 4 |
+
export function ChatThread({
|
| 5 |
+
messages,
|
| 6 |
+
onCitation,
|
| 7 |
+
renderExtra,
|
| 8 |
+
empty,
|
| 9 |
+
}: {
|
| 10 |
+
messages: Message[];
|
| 11 |
+
onCitation?: (message: Message, index: number) => void;
|
| 12 |
+
renderExtra?: (message: Message) => React.ReactNode;
|
| 13 |
+
empty?: React.ReactNode;
|
| 14 |
+
}) {
|
| 15 |
+
const endRef = useRef<HTMLDivElement>(null);
|
| 16 |
+
|
| 17 |
+
useEffect(() => {
|
| 18 |
+
endRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
| 19 |
+
}, [messages]);
|
| 20 |
+
|
| 21 |
+
if (messages.length === 0 && empty) {
|
| 22 |
+
return <div className="flex flex-1 items-center justify-center p-6">{empty}</div>;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
return (
|
| 26 |
+
<div className="flex-1 space-y-5 overflow-y-auto px-1 py-4">
|
| 27 |
+
{messages.map((m) => (
|
| 28 |
+
<MessageBubble key={m.id} message={m} onCitation={(idx) => onCitation?.(m, idx)}>
|
| 29 |
+
{renderExtra?.(m)}
|
| 30 |
+
</MessageBubble>
|
| 31 |
+
))}
|
| 32 |
+
<div ref={endRef} />
|
| 33 |
+
</div>
|
| 34 |
+
);
|
| 35 |
+
}
|
frontend/src/components/ExamplePrompts.tsx
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Sparkles } from "lucide-react";
|
| 2 |
+
|
| 3 |
+
export function ExamplePrompts({
|
| 4 |
+
title,
|
| 5 |
+
prompts,
|
| 6 |
+
onPick,
|
| 7 |
+
}: {
|
| 8 |
+
title: string;
|
| 9 |
+
prompts: string[];
|
| 10 |
+
onPick: (p: string) => void;
|
| 11 |
+
}) {
|
| 12 |
+
return (
|
| 13 |
+
<div className="max-w-md text-center">
|
| 14 |
+
<div className="mx-auto mb-3 flex h-11 w-11 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
| 15 |
+
<Sparkles className="h-5 w-5" />
|
| 16 |
+
</div>
|
| 17 |
+
<h2 className="mb-1 text-lg font-semibold">{title}</h2>
|
| 18 |
+
<p className="mb-4 text-sm text-muted-foreground">Try one of these to get started</p>
|
| 19 |
+
<div className="flex flex-col gap-2">
|
| 20 |
+
{prompts.map((p) => (
|
| 21 |
+
<button
|
| 22 |
+
key={p}
|
| 23 |
+
onClick={() => onPick(p)}
|
| 24 |
+
className="rounded-lg border border-border bg-card px-3 py-2 text-left text-sm transition-colors hover:border-primary/60 hover:bg-muted"
|
| 25 |
+
>
|
| 26 |
+
{p}
|
| 27 |
+
</button>
|
| 28 |
+
))}
|
| 29 |
+
</div>
|
| 30 |
+
</div>
|
| 31 |
+
);
|
| 32 |
+
}
|
frontend/src/components/MessageBubble.tsx
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import ReactMarkdown from "react-markdown";
|
| 2 |
+
import { Microscope, User } from "lucide-react";
|
| 3 |
+
import { cn } from "@/lib/utils";
|
| 4 |
+
import type { Source } from "@/lib/api";
|
| 5 |
+
|
| 6 |
+
export interface Message {
|
| 7 |
+
id: string;
|
| 8 |
+
role: "user" | "assistant";
|
| 9 |
+
content: string;
|
| 10 |
+
sources?: Source[];
|
| 11 |
+
streaming?: boolean;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
/** Turn `[3]` reference markers into markdown links (#cite-3) so we can render
|
| 15 |
+
* them as interactive citation chips via the `a` component override. */
|
| 16 |
+
function withCitationLinks(text: string): string {
|
| 17 |
+
return text.replace(/\[(\d+)\]/g, "[$1](#cite-$1)");
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
export function MessageBubble({
|
| 21 |
+
message,
|
| 22 |
+
onCitation,
|
| 23 |
+
children,
|
| 24 |
+
}: {
|
| 25 |
+
message: Message;
|
| 26 |
+
onCitation?: (index: number) => void;
|
| 27 |
+
children?: React.ReactNode;
|
| 28 |
+
}) {
|
| 29 |
+
const isUser = message.role === "user";
|
| 30 |
+
|
| 31 |
+
return (
|
| 32 |
+
<div className={cn("flex gap-3 animate-fade-in", isUser && "flex-row-reverse")}>
|
| 33 |
+
<div
|
| 34 |
+
className={cn(
|
| 35 |
+
"flex h-8 w-8 shrink-0 items-center justify-center rounded-lg",
|
| 36 |
+
isUser ? "bg-muted text-foreground" : "bg-primary text-primary-foreground",
|
| 37 |
+
)}
|
| 38 |
+
>
|
| 39 |
+
{isUser ? <User className="h-4 w-4" /> : <Microscope className="h-4 w-4" />}
|
| 40 |
+
</div>
|
| 41 |
+
|
| 42 |
+
<div className={cn("min-w-0 max-w-[85%] space-y-2", isUser && "flex flex-col items-end")}>
|
| 43 |
+
<div
|
| 44 |
+
className={cn(
|
| 45 |
+
"rounded-2xl px-4 py-2.5 text-sm",
|
| 46 |
+
isUser
|
| 47 |
+
? "bg-primary text-primary-foreground"
|
| 48 |
+
: "border border-border bg-card text-card-foreground",
|
| 49 |
+
)}
|
| 50 |
+
>
|
| 51 |
+
{isUser ? (
|
| 52 |
+
<span className="whitespace-pre-wrap">{message.content}</span>
|
| 53 |
+
) : (
|
| 54 |
+
<div className="markdown">
|
| 55 |
+
<ReactMarkdown
|
| 56 |
+
components={{
|
| 57 |
+
a: ({ href, children: c }) => {
|
| 58 |
+
const m = href?.match(/^#cite-(\d+)$/);
|
| 59 |
+
if (m) {
|
| 60 |
+
const idx = Number(m[1]);
|
| 61 |
+
return (
|
| 62 |
+
<button
|
| 63 |
+
onClick={() => onCitation?.(idx)}
|
| 64 |
+
className="mx-0.5 inline-flex h-5 min-w-5 items-center justify-center rounded-md bg-primary/15 px-1 align-middle text-xs font-semibold text-primary hover:bg-primary/25"
|
| 65 |
+
title={`Jump to source ${idx}`}
|
| 66 |
+
>
|
| 67 |
+
{idx}
|
| 68 |
+
</button>
|
| 69 |
+
);
|
| 70 |
+
}
|
| 71 |
+
return (
|
| 72 |
+
<a href={href} target="_blank" rel="noreferrer" className="text-primary underline">
|
| 73 |
+
{c}
|
| 74 |
+
</a>
|
| 75 |
+
);
|
| 76 |
+
},
|
| 77 |
+
}}
|
| 78 |
+
>
|
| 79 |
+
{withCitationLinks(message.content) || "β"}
|
| 80 |
+
</ReactMarkdown>
|
| 81 |
+
{message.streaming && (
|
| 82 |
+
<span className="ml-0.5 inline-block h-4 w-1.5 translate-y-0.5 animate-pulse rounded-sm bg-primary" />
|
| 83 |
+
)}
|
| 84 |
+
</div>
|
| 85 |
+
)}
|
| 86 |
+
</div>
|
| 87 |
+
{children}
|
| 88 |
+
</div>
|
| 89 |
+
</div>
|
| 90 |
+
);
|
| 91 |
+
}
|
frontend/src/components/PdfViewer.tsx
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useMemo, useRef, useState } from "react";
|
| 2 |
+
import { Document, Page, pdfjs } from "react-pdf";
|
| 3 |
+
import { ChevronLeft, ChevronRight, ZoomIn, ZoomOut, Loader2 } from "lucide-react";
|
| 4 |
+
import { pdfUrl } from "@/lib/api";
|
| 5 |
+
import { Button } from "@/components/ui/button";
|
| 6 |
+
|
| 7 |
+
// pdf.js worker (bundled by Vite)
|
| 8 |
+
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
|
| 9 |
+
"pdfjs-dist/build/pdf.worker.min.mjs",
|
| 10 |
+
import.meta.url,
|
| 11 |
+
).toString();
|
| 12 |
+
|
| 13 |
+
export function PdfViewer({
|
| 14 |
+
filename,
|
| 15 |
+
page,
|
| 16 |
+
highlights,
|
| 17 |
+
onPageChange,
|
| 18 |
+
}: {
|
| 19 |
+
filename: string | null;
|
| 20 |
+
page: number;
|
| 21 |
+
highlights: number[][];
|
| 22 |
+
onPageChange: (p: number) => void;
|
| 23 |
+
}) {
|
| 24 |
+
const containerRef = useRef<HTMLDivElement>(null);
|
| 25 |
+
const [containerWidth, setContainerWidth] = useState(600);
|
| 26 |
+
const [numPages, setNumPages] = useState(0);
|
| 27 |
+
const [zoom, setZoom] = useState(1);
|
| 28 |
+
const [renderScale, setRenderScale] = useState(1); // px per PDF point
|
| 29 |
+
|
| 30 |
+
useEffect(() => {
|
| 31 |
+
if (!containerRef.current) return;
|
| 32 |
+
const el = containerRef.current;
|
| 33 |
+
const ro = new ResizeObserver(() => setContainerWidth(el.clientWidth - 32));
|
| 34 |
+
ro.observe(el);
|
| 35 |
+
setContainerWidth(el.clientWidth - 32);
|
| 36 |
+
return () => ro.disconnect();
|
| 37 |
+
}, []);
|
| 38 |
+
|
| 39 |
+
const file = useMemo(() => (filename ? pdfUrl(filename) : null), [filename]);
|
| 40 |
+
const pageWidth = Math.max(200, containerWidth * zoom);
|
| 41 |
+
|
| 42 |
+
if (!file) {
|
| 43 |
+
return (
|
| 44 |
+
<div className="flex h-full items-center justify-center rounded-xl border-2 border-dashed border-border text-sm text-muted-foreground">
|
| 45 |
+
Upload or select a document to preview it here
|
| 46 |
+
</div>
|
| 47 |
+
);
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
return (
|
| 51 |
+
<div className="flex h-full flex-col rounded-xl border border-border bg-muted/30">
|
| 52 |
+
{/* toolbar */}
|
| 53 |
+
<div className="flex items-center gap-1 border-b border-border px-3 py-2">
|
| 54 |
+
<Button
|
| 55 |
+
variant="ghost"
|
| 56 |
+
size="icon"
|
| 57 |
+
disabled={page <= 1}
|
| 58 |
+
onClick={() => onPageChange(page - 1)}
|
| 59 |
+
>
|
| 60 |
+
<ChevronLeft className="h-4 w-4" />
|
| 61 |
+
</Button>
|
| 62 |
+
<span className="min-w-[90px] text-center text-xs text-muted-foreground">
|
| 63 |
+
Page {page} / {numPages || "β¦"}
|
| 64 |
+
</span>
|
| 65 |
+
<Button
|
| 66 |
+
variant="ghost"
|
| 67 |
+
size="icon"
|
| 68 |
+
disabled={numPages > 0 && page >= numPages}
|
| 69 |
+
onClick={() => onPageChange(page + 1)}
|
| 70 |
+
>
|
| 71 |
+
<ChevronRight className="h-4 w-4" />
|
| 72 |
+
</Button>
|
| 73 |
+
<div className="ml-auto flex items-center gap-1">
|
| 74 |
+
<Button variant="ghost" size="icon" onClick={() => setZoom((z) => Math.max(0.6, z - 0.15))}>
|
| 75 |
+
<ZoomOut className="h-4 w-4" />
|
| 76 |
+
</Button>
|
| 77 |
+
<span className="w-10 text-center text-xs text-muted-foreground">{Math.round(zoom * 100)}%</span>
|
| 78 |
+
<Button variant="ghost" size="icon" onClick={() => setZoom((z) => Math.min(2.5, z + 0.15))}>
|
| 79 |
+
<ZoomIn className="h-4 w-4" />
|
| 80 |
+
</Button>
|
| 81 |
+
</div>
|
| 82 |
+
</div>
|
| 83 |
+
|
| 84 |
+
{/* page */}
|
| 85 |
+
<div ref={containerRef} className="flex-1 overflow-auto p-4">
|
| 86 |
+
<Document
|
| 87 |
+
file={file}
|
| 88 |
+
onLoadSuccess={(pdf) => setNumPages(pdf.numPages)}
|
| 89 |
+
loading={
|
| 90 |
+
<div className="flex h-40 items-center justify-center text-muted-foreground">
|
| 91 |
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Loading documentβ¦
|
| 92 |
+
</div>
|
| 93 |
+
}
|
| 94 |
+
error={
|
| 95 |
+
<div className="flex h-40 items-center justify-center text-sm text-red-500">
|
| 96 |
+
Failed to load PDF.
|
| 97 |
+
</div>
|
| 98 |
+
}
|
| 99 |
+
className="flex justify-center"
|
| 100 |
+
>
|
| 101 |
+
<div className="relative shadow-lg" style={{ width: pageWidth }}>
|
| 102 |
+
<Page
|
| 103 |
+
pageNumber={page}
|
| 104 |
+
width={pageWidth}
|
| 105 |
+
renderTextLayer={false}
|
| 106 |
+
renderAnnotationLayer={false}
|
| 107 |
+
onLoadSuccess={(p) => setRenderScale(pageWidth / p.originalWidth)}
|
| 108 |
+
/>
|
| 109 |
+
{/* highlight overlay (PDF points β pixels via renderScale) */}
|
| 110 |
+
<div className="pointer-events-none absolute inset-0">
|
| 111 |
+
{highlights.map((b, i) => (
|
| 112 |
+
<div
|
| 113 |
+
key={i}
|
| 114 |
+
className="absolute rounded-sm animate-pulse-ring"
|
| 115 |
+
style={{
|
| 116 |
+
left: b[0] * renderScale,
|
| 117 |
+
top: b[1] * renderScale,
|
| 118 |
+
width: (b[2] - b[0]) * renderScale,
|
| 119 |
+
height: (b[3] - b[1]) * renderScale,
|
| 120 |
+
background: "hsl(var(--highlight) / 0.38)",
|
| 121 |
+
outline: "1px solid hsl(var(--highlight) / 0.8)",
|
| 122 |
+
}}
|
| 123 |
+
/>
|
| 124 |
+
))}
|
| 125 |
+
</div>
|
| 126 |
+
</div>
|
| 127 |
+
</Document>
|
| 128 |
+
</div>
|
| 129 |
+
</div>
|
| 130 |
+
);
|
| 131 |
+
}
|
frontend/src/components/ReportPicker.tsx
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Check, ChevronDown, X } from "lucide-react";
|
| 2 |
+
import { useState } from "react";
|
| 3 |
+
import { cn } from "@/lib/utils";
|
| 4 |
+
|
| 5 |
+
export function ReportPicker({
|
| 6 |
+
reports,
|
| 7 |
+
selected,
|
| 8 |
+
onChange,
|
| 9 |
+
}: {
|
| 10 |
+
reports: string[];
|
| 11 |
+
selected: string[];
|
| 12 |
+
onChange: (next: string[]) => void;
|
| 13 |
+
}) {
|
| 14 |
+
const [open, setOpen] = useState(false);
|
| 15 |
+
|
| 16 |
+
const toggle = (r: string) =>
|
| 17 |
+
onChange(selected.includes(r) ? selected.filter((x) => x !== r) : [...selected, r]);
|
| 18 |
+
|
| 19 |
+
return (
|
| 20 |
+
<div className="relative">
|
| 21 |
+
<button
|
| 22 |
+
onClick={() => setOpen((o) => !o)}
|
| 23 |
+
className="flex w-full items-center gap-2 rounded-lg border border-border bg-card px-3 py-2 text-sm"
|
| 24 |
+
>
|
| 25 |
+
<span className="text-muted-foreground">Scope:</span>
|
| 26 |
+
<span className="flex-1 truncate text-left">
|
| 27 |
+
{selected.length === 0 ? "All documents" : `${selected.length} selected`}
|
| 28 |
+
</span>
|
| 29 |
+
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
| 30 |
+
</button>
|
| 31 |
+
|
| 32 |
+
{selected.length > 0 && (
|
| 33 |
+
<div className="mt-2 flex flex-wrap gap-1.5">
|
| 34 |
+
{selected.map((r) => (
|
| 35 |
+
<span
|
| 36 |
+
key={r}
|
| 37 |
+
className="inline-flex items-center gap-1 rounded-full bg-primary/12 px-2 py-0.5 text-xs text-primary"
|
| 38 |
+
>
|
| 39 |
+
{r}
|
| 40 |
+
<button onClick={() => toggle(r)}>
|
| 41 |
+
<X className="h-3 w-3" />
|
| 42 |
+
</button>
|
| 43 |
+
</span>
|
| 44 |
+
))}
|
| 45 |
+
</div>
|
| 46 |
+
)}
|
| 47 |
+
|
| 48 |
+
{open && (
|
| 49 |
+
<>
|
| 50 |
+
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
|
| 51 |
+
<div className="absolute z-20 mt-1 max-h-72 w-full overflow-auto rounded-lg border border-border bg-card p-1 shadow-lg">
|
| 52 |
+
{reports.length === 0 && (
|
| 53 |
+
<div className="px-3 py-2 text-xs text-muted-foreground">No documents indexed.</div>
|
| 54 |
+
)}
|
| 55 |
+
{reports.map((r) => {
|
| 56 |
+
const on = selected.includes(r);
|
| 57 |
+
return (
|
| 58 |
+
<button
|
| 59 |
+
key={r}
|
| 60 |
+
onClick={() => toggle(r)}
|
| 61 |
+
className={cn(
|
| 62 |
+
"flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-sm hover:bg-muted",
|
| 63 |
+
on && "text-primary",
|
| 64 |
+
)}
|
| 65 |
+
>
|
| 66 |
+
<span
|
| 67 |
+
className={cn(
|
| 68 |
+
"flex h-4 w-4 items-center justify-center rounded border",
|
| 69 |
+
on ? "border-primary bg-primary text-primary-foreground" : "border-border",
|
| 70 |
+
)}
|
| 71 |
+
>
|
| 72 |
+
{on && <Check className="h-3 w-3" />}
|
| 73 |
+
</span>
|
| 74 |
+
<span className="truncate">{r}</span>
|
| 75 |
+
</button>
|
| 76 |
+
);
|
| 77 |
+
})}
|
| 78 |
+
</div>
|
| 79 |
+
</>
|
| 80 |
+
)}
|
| 81 |
+
</div>
|
| 82 |
+
);
|
| 83 |
+
}
|
frontend/src/components/Sidebar.tsx
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { FileText, Globe, Microscope } from "lucide-react";
|
| 2 |
+
import { useQuery } from "@tanstack/react-query";
|
| 3 |
+
import { getReports } from "@/lib/api";
|
| 4 |
+
import { cn } from "@/lib/utils";
|
| 5 |
+
import { ThemeToggle } from "./ThemeToggle";
|
| 6 |
+
|
| 7 |
+
export type View = "document" | "global";
|
| 8 |
+
|
| 9 |
+
const NAV: { id: View; label: string; icon: typeof FileText; desc: string }[] = [
|
| 10 |
+
{ id: "document", label: "Document Chat", icon: FileText, desc: "Chat with one report" },
|
| 11 |
+
{ id: "global", label: "Global Search", icon: Globe, desc: "Search across reports" },
|
| 12 |
+
];
|
| 13 |
+
|
| 14 |
+
export function Sidebar({ view, onView }: { view: View; onView: (v: View) => void }) {
|
| 15 |
+
const { data: reports } = useQuery({ queryKey: ["reports"], queryFn: getReports });
|
| 16 |
+
|
| 17 |
+
return (
|
| 18 |
+
<aside className="flex w-64 shrink-0 flex-col border-r border-border bg-card/50 backdrop-blur">
|
| 19 |
+
<div className="flex items-center gap-2.5 px-5 py-5">
|
| 20 |
+
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
| 21 |
+
<Microscope className="h-5 w-5" />
|
| 22 |
+
</div>
|
| 23 |
+
<div>
|
| 24 |
+
<div className="text-sm font-semibold leading-tight">Pathology RAG</div>
|
| 25 |
+
<div className="text-xs text-muted-foreground">Clinical document AI</div>
|
| 26 |
+
</div>
|
| 27 |
+
</div>
|
| 28 |
+
|
| 29 |
+
<nav className="flex flex-col gap-1 px-3">
|
| 30 |
+
{NAV.map((item) => {
|
| 31 |
+
const Icon = item.icon;
|
| 32 |
+
const active = view === item.id;
|
| 33 |
+
return (
|
| 34 |
+
<button
|
| 35 |
+
key={item.id}
|
| 36 |
+
onClick={() => onView(item.id)}
|
| 37 |
+
className={cn(
|
| 38 |
+
"group flex items-start gap-3 rounded-lg px-3 py-2.5 text-left transition-colors",
|
| 39 |
+
active ? "bg-primary/10 text-primary" : "hover:bg-muted text-foreground",
|
| 40 |
+
)}
|
| 41 |
+
>
|
| 42 |
+
<Icon className={cn("mt-0.5 h-4 w-4", active ? "text-primary" : "text-muted-foreground")} />
|
| 43 |
+
<span>
|
| 44 |
+
<span className="block text-sm font-medium">{item.label}</span>
|
| 45 |
+
<span className="block text-xs text-muted-foreground">{item.desc}</span>
|
| 46 |
+
</span>
|
| 47 |
+
</button>
|
| 48 |
+
);
|
| 49 |
+
})}
|
| 50 |
+
</nav>
|
| 51 |
+
|
| 52 |
+
<div className="mt-auto space-y-3 px-5 py-5">
|
| 53 |
+
<div className="rounded-lg border border-border bg-background/60 px-3 py-2.5">
|
| 54 |
+
<div className="text-xs text-muted-foreground">Documents indexed</div>
|
| 55 |
+
<div className="text-lg font-semibold">{reports?.length ?? "β"}</div>
|
| 56 |
+
</div>
|
| 57 |
+
<div className="flex items-center justify-between">
|
| 58 |
+
<span className="text-xs text-muted-foreground">Appearance</span>
|
| 59 |
+
<ThemeToggle />
|
| 60 |
+
</div>
|
| 61 |
+
</div>
|
| 62 |
+
</aside>
|
| 63 |
+
);
|
| 64 |
+
}
|
frontend/src/components/SourceCard.tsx
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { FileText } from "lucide-react";
|
| 2 |
+
import { cn } from "@/lib/utils";
|
| 3 |
+
import type { Source } from "@/lib/api";
|
| 4 |
+
|
| 5 |
+
export function SourceCard({
|
| 6 |
+
source,
|
| 7 |
+
onClick,
|
| 8 |
+
active,
|
| 9 |
+
}: {
|
| 10 |
+
source: Source;
|
| 11 |
+
onClick?: () => void;
|
| 12 |
+
active?: boolean;
|
| 13 |
+
}) {
|
| 14 |
+
return (
|
| 15 |
+
<button
|
| 16 |
+
onClick={onClick}
|
| 17 |
+
className={cn(
|
| 18 |
+
"w-full rounded-lg border bg-card px-3 py-2.5 text-left transition-colors",
|
| 19 |
+
active ? "border-primary ring-1 ring-primary" : "border-border hover:border-primary/60",
|
| 20 |
+
)}
|
| 21 |
+
>
|
| 22 |
+
<div className="mb-1 flex items-center gap-2 text-xs">
|
| 23 |
+
<span className="flex h-5 w-5 items-center justify-center rounded bg-primary/15 font-semibold text-primary">
|
| 24 |
+
{source.index}
|
| 25 |
+
</span>
|
| 26 |
+
<FileText className="h-3.5 w-3.5 text-muted-foreground" />
|
| 27 |
+
<span className="truncate font-medium">{source.filename}</span>
|
| 28 |
+
{source.page != null && (
|
| 29 |
+
<span className="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
|
| 30 |
+
page {source.page}
|
| 31 |
+
</span>
|
| 32 |
+
)}
|
| 33 |
+
<span className="ml-auto text-[11px] text-muted-foreground">{source.score.toFixed(3)}</span>
|
| 34 |
+
</div>
|
| 35 |
+
<p className="line-clamp-2 text-xs text-muted-foreground">{source.text}</p>
|
| 36 |
+
</button>
|
| 37 |
+
);
|
| 38 |
+
}
|
frontend/src/components/ThemeToggle.tsx
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Moon, Sun } from "lucide-react";
|
| 2 |
+
import { useTheme } from "@/lib/theme";
|
| 3 |
+
import { Button } from "@/components/ui/button";
|
| 4 |
+
|
| 5 |
+
export function ThemeToggle() {
|
| 6 |
+
const { theme, toggle } = useTheme();
|
| 7 |
+
return (
|
| 8 |
+
<Button variant="ghost" size="icon" onClick={toggle} title="Toggle theme" aria-label="Toggle theme">
|
| 9 |
+
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
| 10 |
+
</Button>
|
| 11 |
+
);
|
| 12 |
+
}
|
frontend/src/components/UploadPanel.tsx
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useCallback, useRef, useState } from "react";
|
| 2 |
+
import { UploadCloud, Loader2, CheckCircle2 } from "lucide-react";
|
| 3 |
+
import { uploadAndTrack, type UploadProgress } from "@/lib/api";
|
| 4 |
+
import { cn } from "@/lib/utils";
|
| 5 |
+
|
| 6 |
+
const STAGE_LABEL: Record<string, string> = {
|
| 7 |
+
ocr: "Reading pages (OCR)",
|
| 8 |
+
embedding: "Generating embeddings",
|
| 9 |
+
indexing: "Updating index",
|
| 10 |
+
done: "Finishing up",
|
| 11 |
+
};
|
| 12 |
+
|
| 13 |
+
export function UploadPanel({ onIndexed }: { onIndexed: (filename: string) => void }) {
|
| 14 |
+
const inputRef = useRef<HTMLInputElement>(null);
|
| 15 |
+
const [drag, setDrag] = useState(false);
|
| 16 |
+
const [busy, setBusy] = useState(false);
|
| 17 |
+
const [progress, setProgress] = useState<UploadProgress | null>(null);
|
| 18 |
+
const [doneName, setDoneName] = useState<string | null>(null);
|
| 19 |
+
const [error, setError] = useState<string | null>(null);
|
| 20 |
+
|
| 21 |
+
const handle = useCallback(
|
| 22 |
+
(file: File) => {
|
| 23 |
+
if (!file.name.toLowerCase().endsWith(".pdf")) {
|
| 24 |
+
setError("Please choose a PDF file.");
|
| 25 |
+
return;
|
| 26 |
+
}
|
| 27 |
+
setBusy(true);
|
| 28 |
+
setError(null);
|
| 29 |
+
setDoneName(null);
|
| 30 |
+
setProgress({ stage: "ocr" });
|
| 31 |
+
const stem = file.name.replace(/\.pdf$/i, "");
|
| 32 |
+
uploadAndTrack(
|
| 33 |
+
file,
|
| 34 |
+
(p) => setProgress(p),
|
| 35 |
+
() => {
|
| 36 |
+
setBusy(false);
|
| 37 |
+
setProgress(null);
|
| 38 |
+
setDoneName(stem);
|
| 39 |
+
onIndexed(stem);
|
| 40 |
+
},
|
| 41 |
+
(msg) => {
|
| 42 |
+
setBusy(false);
|
| 43 |
+
setProgress(null);
|
| 44 |
+
setError(msg);
|
| 45 |
+
},
|
| 46 |
+
);
|
| 47 |
+
},
|
| 48 |
+
[onIndexed],
|
| 49 |
+
);
|
| 50 |
+
|
| 51 |
+
return (
|
| 52 |
+
<div>
|
| 53 |
+
<div
|
| 54 |
+
onDragOver={(e) => {
|
| 55 |
+
e.preventDefault();
|
| 56 |
+
setDrag(true);
|
| 57 |
+
}}
|
| 58 |
+
onDragLeave={() => setDrag(false)}
|
| 59 |
+
onDrop={(e) => {
|
| 60 |
+
e.preventDefault();
|
| 61 |
+
setDrag(false);
|
| 62 |
+
if (e.dataTransfer.files[0]) handle(e.dataTransfer.files[0]);
|
| 63 |
+
}}
|
| 64 |
+
onClick={() => !busy && inputRef.current?.click()}
|
| 65 |
+
className={cn(
|
| 66 |
+
"flex cursor-pointer flex-col items-center gap-2 rounded-xl border-2 border-dashed px-4 py-6 text-center transition-colors",
|
| 67 |
+
drag ? "border-primary bg-primary/5" : "border-border hover:border-primary/50",
|
| 68 |
+
busy && "cursor-default opacity-80",
|
| 69 |
+
)}
|
| 70 |
+
>
|
| 71 |
+
{busy ? (
|
| 72 |
+
<>
|
| 73 |
+
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
| 74 |
+
<div className="text-sm font-medium">
|
| 75 |
+
{progress ? STAGE_LABEL[progress.stage] ?? "Processing" : "Processing"}
|
| 76 |
+
{progress?.page && progress?.total ? ` Β· page ${progress.page}/${progress.total}` : ""}
|
| 77 |
+
</div>
|
| 78 |
+
<ProgressBar progress={progress} />
|
| 79 |
+
</>
|
| 80 |
+
) : doneName ? (
|
| 81 |
+
<>
|
| 82 |
+
<CheckCircle2 className="h-6 w-6 text-emerald-500" />
|
| 83 |
+
<div className="text-sm font-medium">Indexed β{doneName}β</div>
|
| 84 |
+
<div className="text-xs text-muted-foreground">Click to upload another</div>
|
| 85 |
+
</>
|
| 86 |
+
) : (
|
| 87 |
+
<>
|
| 88 |
+
<UploadCloud className="h-6 w-6 text-muted-foreground" />
|
| 89 |
+
<div className="text-sm font-medium">Drop a pathology PDF or click to browse</div>
|
| 90 |
+
<div className="text-xs text-muted-foreground">OCR + embedding runs automatically</div>
|
| 91 |
+
</>
|
| 92 |
+
)}
|
| 93 |
+
<input
|
| 94 |
+
ref={inputRef}
|
| 95 |
+
type="file"
|
| 96 |
+
accept="application/pdf"
|
| 97 |
+
className="hidden"
|
| 98 |
+
onChange={(e) => e.target.files?.[0] && handle(e.target.files[0])}
|
| 99 |
+
/>
|
| 100 |
+
</div>
|
| 101 |
+
{error && <p className="mt-2 text-xs text-red-500">{error}</p>}
|
| 102 |
+
</div>
|
| 103 |
+
);
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
function ProgressBar({ progress }: { progress: UploadProgress | null }) {
|
| 107 |
+
// Coarse stage-based fill; OCR uses page fraction when available.
|
| 108 |
+
let pct = 10;
|
| 109 |
+
if (progress?.stage === "ocr") {
|
| 110 |
+
pct = progress.page && progress.total ? 10 + (progress.page / progress.total) * 50 : 30;
|
| 111 |
+
} else if (progress?.stage === "embedding") pct = 70;
|
| 112 |
+
else if (progress?.stage === "indexing") pct = 88;
|
| 113 |
+
else if (progress?.stage === "done") pct = 96;
|
| 114 |
+
return (
|
| 115 |
+
<div className="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
| 116 |
+
<div className="h-full rounded-full bg-primary transition-all duration-500" style={{ width: `${pct}%` }} />
|
| 117 |
+
</div>
|
| 118 |
+
);
|
| 119 |
+
}
|
frontend/src/components/ui/button.tsx
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { forwardRef } from "react";
|
| 2 |
+
import { cva, type VariantProps } from "class-variance-authority";
|
| 3 |
+
import { cn } from "@/lib/utils";
|
| 4 |
+
|
| 5 |
+
const buttonVariants = cva(
|
| 6 |
+
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
| 7 |
+
{
|
| 8 |
+
variants: {
|
| 9 |
+
variant: {
|
| 10 |
+
default: "bg-primary text-primary-foreground hover:opacity-90 shadow-sm",
|
| 11 |
+
outline: "border border-border bg-transparent hover:bg-muted",
|
| 12 |
+
ghost: "hover:bg-muted",
|
| 13 |
+
subtle: "bg-muted text-foreground hover:bg-accent",
|
| 14 |
+
},
|
| 15 |
+
size: {
|
| 16 |
+
default: "h-10 px-4 py-2",
|
| 17 |
+
sm: "h-8 px-3 text-xs",
|
| 18 |
+
icon: "h-9 w-9",
|
| 19 |
+
},
|
| 20 |
+
},
|
| 21 |
+
defaultVariants: { variant: "default", size: "default" },
|
| 22 |
+
},
|
| 23 |
+
);
|
| 24 |
+
|
| 25 |
+
export interface ButtonProps
|
| 26 |
+
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
| 27 |
+
VariantProps<typeof buttonVariants> {}
|
| 28 |
+
|
| 29 |
+
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
| 30 |
+
({ className, variant, size, ...props }, ref) => (
|
| 31 |
+
<button ref={ref} className={cn(buttonVariants({ variant, size, className }))} {...props} />
|
| 32 |
+
),
|
| 33 |
+
);
|
| 34 |
+
Button.displayName = "Button";
|
frontend/src/index.css
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@tailwind base;
|
| 2 |
+
@tailwind components;
|
| 3 |
+
@tailwind utilities;
|
| 4 |
+
|
| 5 |
+
@layer base {
|
| 6 |
+
:root {
|
| 7 |
+
--background: 210 40% 98%;
|
| 8 |
+
--foreground: 222 47% 11%;
|
| 9 |
+
--card: 0 0% 100%;
|
| 10 |
+
--card-foreground: 222 47% 11%;
|
| 11 |
+
--muted: 210 40% 94%;
|
| 12 |
+
--muted-foreground: 215 16% 47%;
|
| 13 |
+
--primary: 221 83% 53%;
|
| 14 |
+
--primary-foreground: 0 0% 100%;
|
| 15 |
+
--accent: 210 40% 92%;
|
| 16 |
+
--accent-foreground: 222 47% 11%;
|
| 17 |
+
--border: 214 32% 88%;
|
| 18 |
+
--input: 214 32% 88%;
|
| 19 |
+
--ring: 221 83% 53%;
|
| 20 |
+
--radius: 0.75rem;
|
| 21 |
+
--highlight: 45 93% 58%;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
.dark {
|
| 25 |
+
--background: 222 47% 7%;
|
| 26 |
+
--foreground: 210 40% 96%;
|
| 27 |
+
--card: 222 40% 10%;
|
| 28 |
+
--card-foreground: 210 40% 96%;
|
| 29 |
+
--muted: 217 33% 16%;
|
| 30 |
+
--muted-foreground: 215 20% 65%;
|
| 31 |
+
--primary: 217 91% 60%;
|
| 32 |
+
--primary-foreground: 222 47% 11%;
|
| 33 |
+
--accent: 217 33% 18%;
|
| 34 |
+
--accent-foreground: 210 40% 96%;
|
| 35 |
+
--border: 217 33% 20%;
|
| 36 |
+
--input: 217 33% 20%;
|
| 37 |
+
--ring: 217 91% 60%;
|
| 38 |
+
--highlight: 45 93% 58%;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
* {
|
| 42 |
+
border-color: hsl(var(--border));
|
| 43 |
+
}
|
| 44 |
+
html,
|
| 45 |
+
body,
|
| 46 |
+
#root {
|
| 47 |
+
height: 100%;
|
| 48 |
+
}
|
| 49 |
+
body {
|
| 50 |
+
background-color: hsl(var(--background));
|
| 51 |
+
color: hsl(var(--foreground));
|
| 52 |
+
font-family: Inter, system-ui, sans-serif;
|
| 53 |
+
-webkit-font-smoothing: antialiased;
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
/* Slim, theme-aware scrollbars */
|
| 58 |
+
::-webkit-scrollbar {
|
| 59 |
+
width: 10px;
|
| 60 |
+
height: 10px;
|
| 61 |
+
}
|
| 62 |
+
::-webkit-scrollbar-thumb {
|
| 63 |
+
background: hsl(var(--border));
|
| 64 |
+
border-radius: 999px;
|
| 65 |
+
}
|
| 66 |
+
::-webkit-scrollbar-track {
|
| 67 |
+
background: transparent;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
/* Markdown answer typography */
|
| 71 |
+
.markdown p {
|
| 72 |
+
margin: 0 0 0.7rem;
|
| 73 |
+
line-height: 1.7;
|
| 74 |
+
}
|
| 75 |
+
.markdown p:last-child {
|
| 76 |
+
margin-bottom: 0;
|
| 77 |
+
}
|
| 78 |
+
.markdown ul,
|
| 79 |
+
.markdown ol {
|
| 80 |
+
margin: 0 0 0.7rem 1.2rem;
|
| 81 |
+
list-style: revert;
|
| 82 |
+
}
|
| 83 |
+
.markdown li {
|
| 84 |
+
margin: 0.2rem 0;
|
| 85 |
+
}
|
| 86 |
+
.markdown strong {
|
| 87 |
+
font-weight: 600;
|
| 88 |
+
}
|
| 89 |
+
.markdown code {
|
| 90 |
+
background: hsl(var(--muted));
|
| 91 |
+
padding: 0.1rem 0.35rem;
|
| 92 |
+
border-radius: 6px;
|
| 93 |
+
font-size: 0.85em;
|
| 94 |
+
}
|
frontend/src/lib/api.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { parseSSE } from "./sse";
|
| 2 |
+
|
| 3 |
+
export interface Source {
|
| 4 |
+
index: number;
|
| 5 |
+
filename: string;
|
| 6 |
+
page: number | null;
|
| 7 |
+
line_bboxes: number[][];
|
| 8 |
+
text: string;
|
| 9 |
+
score: number;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export interface ChatTurn {
|
| 13 |
+
role: "user" | "assistant";
|
| 14 |
+
content: string;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export interface ChatRequest {
|
| 18 |
+
question: string;
|
| 19 |
+
report_name?: string | null;
|
| 20 |
+
report_names?: string[] | null;
|
| 21 |
+
top_k?: number;
|
| 22 |
+
history?: ChatTurn[];
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
export interface ChatCallbacks {
|
| 26 |
+
onSources?: (sources: Source[]) => void;
|
| 27 |
+
onToken?: (text: string) => void;
|
| 28 |
+
onDone?: (numSources: number) => void;
|
| 29 |
+
onError?: (message: string) => void;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
export async function getReports(): Promise<string[]> {
|
| 33 |
+
const r = await fetch("/api/reports");
|
| 34 |
+
if (!r.ok) throw new Error("Failed to load reports");
|
| 35 |
+
return (await r.json()).reports as string[];
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
export interface Health {
|
| 39 |
+
status: "ok" | "degraded";
|
| 40 |
+
num_documents: number;
|
| 41 |
+
detail?: string | null;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
export async function getHealth(): Promise<Health> {
|
| 45 |
+
const r = await fetch("/api/health");
|
| 46 |
+
return r.json();
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
/** Stream a chat answer. Resolves when the stream ends. */
|
| 50 |
+
export async function streamChat(
|
| 51 |
+
req: ChatRequest,
|
| 52 |
+
cb: ChatCallbacks,
|
| 53 |
+
signal?: AbortSignal,
|
| 54 |
+
): Promise<void> {
|
| 55 |
+
const resp = await fetch("/api/chat", {
|
| 56 |
+
method: "POST",
|
| 57 |
+
headers: { "Content-Type": "application/json" },
|
| 58 |
+
body: JSON.stringify(req),
|
| 59 |
+
signal,
|
| 60 |
+
});
|
| 61 |
+
|
| 62 |
+
if (!resp.ok) {
|
| 63 |
+
const detail = await resp.text().catch(() => "");
|
| 64 |
+
cb.onError?.(`Request failed (${resp.status}). ${detail}`);
|
| 65 |
+
return;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
for await (const evt of parseSSE(resp)) {
|
| 69 |
+
const data = safeParse(evt.data);
|
| 70 |
+
if (evt.event === "sources") cb.onSources?.(data.sources ?? []);
|
| 71 |
+
else if (evt.event === "token") cb.onToken?.(data.text ?? "");
|
| 72 |
+
else if (evt.event === "done") cb.onDone?.(data.num_sources ?? 0);
|
| 73 |
+
else if (evt.event === "error") cb.onError?.(data.message ?? "Unknown error");
|
| 74 |
+
}
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
export interface UploadProgress {
|
| 78 |
+
stage: string; // ocr | embedding | indexing | done | complete
|
| 79 |
+
page?: number;
|
| 80 |
+
total?: number;
|
| 81 |
+
num_chunks?: number;
|
| 82 |
+
vectors_added?: number;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
/** Upload a PDF, then stream OCR/embedding/indexing progress until complete. */
|
| 86 |
+
export async function uploadAndTrack(
|
| 87 |
+
file: File,
|
| 88 |
+
onProgress: (p: UploadProgress) => void,
|
| 89 |
+
onComplete: (stats: Record<string, unknown>) => void,
|
| 90 |
+
onError: (message: string) => void,
|
| 91 |
+
): Promise<void> {
|
| 92 |
+
const form = new FormData();
|
| 93 |
+
form.append("file", file);
|
| 94 |
+
|
| 95 |
+
const start = await fetch("/api/upload", { method: "POST", body: form });
|
| 96 |
+
if (!start.ok) {
|
| 97 |
+
onError(`Upload failed (${start.status}).`);
|
| 98 |
+
return;
|
| 99 |
+
}
|
| 100 |
+
const { job_id } = await start.json();
|
| 101 |
+
|
| 102 |
+
const events = await fetch(`/api/upload/${job_id}/events`);
|
| 103 |
+
if (!events.ok) {
|
| 104 |
+
onError("Failed to open progress stream.");
|
| 105 |
+
return;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
for await (const evt of parseSSE(events)) {
|
| 109 |
+
const data = safeParse(evt.data);
|
| 110 |
+
if (evt.event === "progress") onProgress(data as UploadProgress);
|
| 111 |
+
else if (evt.event === "complete") onComplete(data);
|
| 112 |
+
else if (evt.event === "error") onError(data.message ?? "Processing failed");
|
| 113 |
+
}
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
export function pdfUrl(filename: string): string {
|
| 117 |
+
return `/api/pdf/${encodeURIComponent(filename)}`;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
function safeParse(s: string): any {
|
| 121 |
+
try {
|
| 122 |
+
return JSON.parse(s);
|
| 123 |
+
} catch {
|
| 124 |
+
return {};
|
| 125 |
+
}
|
| 126 |
+
}
|
frontend/src/lib/sse.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export interface SSEvent {
|
| 2 |
+
event: string;
|
| 3 |
+
data: string;
|
| 4 |
+
}
|
| 5 |
+
|
| 6 |
+
/**
|
| 7 |
+
* Parse a Server-Sent Events stream from a fetch Response body into an async
|
| 8 |
+
* iterator of {event, data}. Used for POST-based SSE where EventSource (GET
|
| 9 |
+
* only) can't be used.
|
| 10 |
+
*/
|
| 11 |
+
export async function* parseSSE(response: Response): AsyncGenerator<SSEvent> {
|
| 12 |
+
if (!response.body) throw new Error("Response has no body");
|
| 13 |
+
const reader = response.body.getReader();
|
| 14 |
+
const decoder = new TextDecoder();
|
| 15 |
+
let buffer = "";
|
| 16 |
+
|
| 17 |
+
while (true) {
|
| 18 |
+
const { done, value } = await reader.read();
|
| 19 |
+
if (done) break;
|
| 20 |
+
// Normalize CRLF (sse-starlette uses "\r\n" line endings) so a single
|
| 21 |
+
// "\n\n" boundary check works regardless of the server's separator.
|
| 22 |
+
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n");
|
| 23 |
+
|
| 24 |
+
let sep: number;
|
| 25 |
+
// Events are separated by a blank line.
|
| 26 |
+
while ((sep = buffer.indexOf("\n\n")) !== -1) {
|
| 27 |
+
const raw = buffer.slice(0, sep);
|
| 28 |
+
buffer = buffer.slice(sep + 2);
|
| 29 |
+
|
| 30 |
+
let event = "message";
|
| 31 |
+
const dataLines: string[] = [];
|
| 32 |
+
for (const line of raw.split("\n")) {
|
| 33 |
+
if (line.startsWith("event:")) event = line.slice(6).trim();
|
| 34 |
+
else if (line.startsWith("data:")) dataLines.push(line.slice(5).replace(/^ /, ""));
|
| 35 |
+
}
|
| 36 |
+
if (dataLines.length) yield { event, data: dataLines.join("\n") };
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
}
|
frontend/src/lib/theme.tsx
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
|
| 2 |
+
|
| 3 |
+
type Theme = "light" | "dark";
|
| 4 |
+
|
| 5 |
+
interface ThemeCtx {
|
| 6 |
+
theme: Theme;
|
| 7 |
+
toggle: () => void;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
const Ctx = createContext<ThemeCtx | null>(null);
|
| 11 |
+
|
| 12 |
+
function initialTheme(): Theme {
|
| 13 |
+
const stored = localStorage.getItem("theme");
|
| 14 |
+
if (stored === "light" || stored === "dark") return stored;
|
| 15 |
+
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export function ThemeProvider({ children }: { children: ReactNode }) {
|
| 19 |
+
const [theme, setTheme] = useState<Theme>(initialTheme);
|
| 20 |
+
|
| 21 |
+
useEffect(() => {
|
| 22 |
+
const root = document.documentElement;
|
| 23 |
+
root.classList.toggle("dark", theme === "dark");
|
| 24 |
+
localStorage.setItem("theme", theme);
|
| 25 |
+
}, [theme]);
|
| 26 |
+
|
| 27 |
+
const toggle = () => setTheme((t) => (t === "dark" ? "light" : "dark"));
|
| 28 |
+
|
| 29 |
+
return <Ctx.Provider value={{ theme, toggle }}>{children}</Ctx.Provider>;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
export function useTheme() {
|
| 33 |
+
const ctx = useContext(Ctx);
|
| 34 |
+
if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
|
| 35 |
+
return ctx;
|
| 36 |
+
}
|
frontend/src/lib/useChat.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useCallback, useRef, useState } from "react";
|
| 2 |
+
import { streamChat, type Source, type ChatTurn } from "./api";
|
| 3 |
+
import type { Message } from "@/components/MessageBubble";
|
| 4 |
+
|
| 5 |
+
let idCounter = 0;
|
| 6 |
+
const nextId = () => `m${++idCounter}`;
|
| 7 |
+
|
| 8 |
+
export interface SendScope {
|
| 9 |
+
report_name?: string | null;
|
| 10 |
+
report_names?: string[] | null;
|
| 11 |
+
top_k?: number;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export function useChat(onSources?: (sources: Source[], forMessageId: string) => void) {
|
| 15 |
+
const [messages, setMessages] = useState<Message[]>([]);
|
| 16 |
+
const [busy, setBusy] = useState(false);
|
| 17 |
+
const abortRef = useRef<AbortController | null>(null);
|
| 18 |
+
|
| 19 |
+
const patch = (id: string, fn: (m: Message) => Message) =>
|
| 20 |
+
setMessages((prev) => prev.map((m) => (m.id === id ? fn(m) : m)));
|
| 21 |
+
|
| 22 |
+
const send = useCallback(
|
| 23 |
+
(text: string, scope: SendScope) => {
|
| 24 |
+
if (busy) return;
|
| 25 |
+
const history: ChatTurn[] = messages.map((m) => ({ role: m.role, content: m.content }));
|
| 26 |
+
|
| 27 |
+
const userMsg: Message = { id: nextId(), role: "user", content: text };
|
| 28 |
+
const botId = nextId();
|
| 29 |
+
const botMsg: Message = { id: botId, role: "assistant", content: "", streaming: true };
|
| 30 |
+
setMessages((prev) => [...prev, userMsg, botMsg]);
|
| 31 |
+
setBusy(true);
|
| 32 |
+
|
| 33 |
+
const ctrl = new AbortController();
|
| 34 |
+
abortRef.current = ctrl;
|
| 35 |
+
|
| 36 |
+
streamChat(
|
| 37 |
+
{
|
| 38 |
+
question: text,
|
| 39 |
+
report_name: scope.report_name ?? null,
|
| 40 |
+
report_names: scope.report_names ?? null,
|
| 41 |
+
top_k: scope.top_k ?? 5,
|
| 42 |
+
history,
|
| 43 |
+
},
|
| 44 |
+
{
|
| 45 |
+
onSources: (sources) => {
|
| 46 |
+
patch(botId, (m) => ({ ...m, sources }));
|
| 47 |
+
onSources?.(sources, botId);
|
| 48 |
+
},
|
| 49 |
+
onToken: (t) => patch(botId, (m) => ({ ...m, content: m.content + t })),
|
| 50 |
+
onDone: () => {
|
| 51 |
+
patch(botId, (m) => ({ ...m, streaming: false }));
|
| 52 |
+
setBusy(false);
|
| 53 |
+
},
|
| 54 |
+
onError: (msg) => {
|
| 55 |
+
patch(botId, (m) => ({
|
| 56 |
+
...m,
|
| 57 |
+
streaming: false,
|
| 58 |
+
content: m.content || `β οΈ ${msg}`,
|
| 59 |
+
}));
|
| 60 |
+
setBusy(false);
|
| 61 |
+
},
|
| 62 |
+
},
|
| 63 |
+
ctrl.signal,
|
| 64 |
+
);
|
| 65 |
+
},
|
| 66 |
+
[busy, messages, onSources],
|
| 67 |
+
);
|
| 68 |
+
|
| 69 |
+
const stop = useCallback(() => {
|
| 70 |
+
abortRef.current?.abort();
|
| 71 |
+
setBusy(false);
|
| 72 |
+
setMessages((prev) => prev.map((m) => (m.streaming ? { ...m, streaming: false } : m)));
|
| 73 |
+
}, []);
|
| 74 |
+
|
| 75 |
+
const reset = useCallback(() => {
|
| 76 |
+
abortRef.current?.abort();
|
| 77 |
+
setMessages([]);
|
| 78 |
+
setBusy(false);
|
| 79 |
+
}, []);
|
| 80 |
+
|
| 81 |
+
return { messages, busy, send, stop, reset };
|
| 82 |
+
}
|
frontend/src/lib/utils.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { clsx, type ClassValue } from "clsx";
|
| 2 |
+
import { twMerge } from "tailwind-merge";
|
| 3 |
+
|
| 4 |
+
export function cn(...inputs: ClassValue[]) {
|
| 5 |
+
return twMerge(clsx(inputs));
|
| 6 |
+
}
|
frontend/src/main.tsx
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
import ReactDOM from "react-dom/client";
|
| 3 |
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
| 4 |
+
import { ThemeProvider } from "./lib/theme";
|
| 5 |
+
import App from "./App";
|
| 6 |
+
import "./index.css";
|
| 7 |
+
|
| 8 |
+
const queryClient = new QueryClient({
|
| 9 |
+
defaultOptions: { queries: { refetchOnWindowFocus: false } },
|
| 10 |
+
});
|
| 11 |
+
|
| 12 |
+
ReactDOM.createRoot(document.getElementById("root")!).render(
|
| 13 |
+
<React.StrictMode>
|
| 14 |
+
<QueryClientProvider client={queryClient}>
|
| 15 |
+
<ThemeProvider>
|
| 16 |
+
<App />
|
| 17 |
+
</ThemeProvider>
|
| 18 |
+
</QueryClientProvider>
|
| 19 |
+
</React.StrictMode>,
|
| 20 |
+
);
|
frontend/src/views/DocumentChat.tsx
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useCallback, useEffect, useState } from "react";
|
| 2 |
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
| 3 |
+
import { getReports, type Source } from "@/lib/api";
|
| 4 |
+
import { useChat } from "@/lib/useChat";
|
| 5 |
+
import { PdfViewer } from "@/components/PdfViewer";
|
| 6 |
+
import { UploadPanel } from "@/components/UploadPanel";
|
| 7 |
+
import { ChatThread } from "@/components/ChatThread";
|
| 8 |
+
import { ChatInput } from "@/components/ChatInput";
|
| 9 |
+
import { ExamplePrompts } from "@/components/ExamplePrompts";
|
| 10 |
+
import type { Message } from "@/components/MessageBubble";
|
| 11 |
+
|
| 12 |
+
const EXAMPLES = [
|
| 13 |
+
"What are the key abnormal findings?",
|
| 14 |
+
"What is the tumor grade and stage?",
|
| 15 |
+
"Summarize the ER / PR / HER2 receptor status.",
|
| 16 |
+
];
|
| 17 |
+
|
| 18 |
+
export function DocumentChat() {
|
| 19 |
+
const qc = useQueryClient();
|
| 20 |
+
const { data: reports } = useQuery({ queryKey: ["reports"], queryFn: getReports });
|
| 21 |
+
const [activeDoc, setActiveDoc] = useState<string | null>(null);
|
| 22 |
+
const [page, setPage] = useState(1);
|
| 23 |
+
const [highlights, setHighlights] = useState<number[][]>([]);
|
| 24 |
+
|
| 25 |
+
// Focus the viewer on the top source's page and highlight its lines.
|
| 26 |
+
const focusSources = useCallback((sources: Source[]) => {
|
| 27 |
+
const top = sources.find((s) => s.page != null);
|
| 28 |
+
if (!top || top.page == null) {
|
| 29 |
+
setHighlights([]);
|
| 30 |
+
return;
|
| 31 |
+
}
|
| 32 |
+
const boxes = sources
|
| 33 |
+
.filter((s) => s.page === top.page)
|
| 34 |
+
.flatMap((s) => s.line_bboxes);
|
| 35 |
+
setPage(top.page);
|
| 36 |
+
setHighlights(boxes);
|
| 37 |
+
}, []);
|
| 38 |
+
|
| 39 |
+
const { messages, busy, send, stop, reset } = useChat((sources) => focusSources(sources));
|
| 40 |
+
|
| 41 |
+
// When the active document changes, start a fresh conversation.
|
| 42 |
+
useEffect(() => {
|
| 43 |
+
reset();
|
| 44 |
+
setPage(1);
|
| 45 |
+
setHighlights([]);
|
| 46 |
+
}, [activeDoc, reset]);
|
| 47 |
+
|
| 48 |
+
const onCitation = (message: Message, index: number) => {
|
| 49 |
+
const src = message.sources?.find((s) => s.index === index);
|
| 50 |
+
if (src?.page != null) {
|
| 51 |
+
setPage(src.page);
|
| 52 |
+
setHighlights(src.line_bboxes);
|
| 53 |
+
}
|
| 54 |
+
};
|
| 55 |
+
|
| 56 |
+
const submit = (text: string) => {
|
| 57 |
+
if (!activeDoc) return;
|
| 58 |
+
send(text, { report_name: activeDoc });
|
| 59 |
+
};
|
| 60 |
+
|
| 61 |
+
return (
|
| 62 |
+
<div className="flex h-full gap-4 p-4">
|
| 63 |
+
{/* LEFT β document */}
|
| 64 |
+
<div className="flex w-[54%] flex-col gap-3">
|
| 65 |
+
<div className="flex items-center gap-2">
|
| 66 |
+
<select
|
| 67 |
+
value={activeDoc ?? ""}
|
| 68 |
+
onChange={(e) => setActiveDoc(e.target.value || null)}
|
| 69 |
+
className="flex-1 rounded-lg border border-border bg-card px-3 py-2 text-sm outline-none"
|
| 70 |
+
>
|
| 71 |
+
<option value="">Select a documentβ¦</option>
|
| 72 |
+
{reports?.map((r) => (
|
| 73 |
+
<option key={r} value={r}>
|
| 74 |
+
{r}
|
| 75 |
+
</option>
|
| 76 |
+
))}
|
| 77 |
+
</select>
|
| 78 |
+
</div>
|
| 79 |
+
<div className="min-h-0 flex-1">
|
| 80 |
+
<PdfViewer filename={activeDoc} page={page} highlights={highlights} onPageChange={setPage} />
|
| 81 |
+
</div>
|
| 82 |
+
<UploadPanel
|
| 83 |
+
onIndexed={(name) => {
|
| 84 |
+
qc.invalidateQueries({ queryKey: ["reports"] });
|
| 85 |
+
setActiveDoc(name);
|
| 86 |
+
}}
|
| 87 |
+
/>
|
| 88 |
+
</div>
|
| 89 |
+
|
| 90 |
+
{/* RIGHT β chat */}
|
| 91 |
+
<div className="flex min-w-0 flex-1 flex-col">
|
| 92 |
+
<ChatThread
|
| 93 |
+
messages={messages}
|
| 94 |
+
onCitation={onCitation}
|
| 95 |
+
empty={
|
| 96 |
+
activeDoc ? (
|
| 97 |
+
<ExamplePrompts title={`Ask about ${activeDoc}`} prompts={EXAMPLES} onPick={submit} />
|
| 98 |
+
) : (
|
| 99 |
+
<div className="max-w-sm text-center text-sm text-muted-foreground">
|
| 100 |
+
Upload a pathology PDF or select an indexed document to start chatting with it.
|
| 101 |
+
</div>
|
| 102 |
+
)
|
| 103 |
+
}
|
| 104 |
+
/>
|
| 105 |
+
<div className="pt-2">
|
| 106 |
+
<ChatInput
|
| 107 |
+
onSend={submit}
|
| 108 |
+
onStop={stop}
|
| 109 |
+
busy={busy}
|
| 110 |
+
disabled={!activeDoc}
|
| 111 |
+
placeholder={activeDoc ? "Ask about this documentβ¦" : "Select a document first"}
|
| 112 |
+
/>
|
| 113 |
+
</div>
|
| 114 |
+
</div>
|
| 115 |
+
</div>
|
| 116 |
+
);
|
| 117 |
+
}
|
frontend/src/views/GlobalSearch.tsx
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from "react";
|
| 2 |
+
import { useQuery } from "@tanstack/react-query";
|
| 3 |
+
import { X } from "lucide-react";
|
| 4 |
+
import { getReports, type Source } from "@/lib/api";
|
| 5 |
+
import { useChat } from "@/lib/useChat";
|
| 6 |
+
import { ChatThread } from "@/components/ChatThread";
|
| 7 |
+
import { ChatInput } from "@/components/ChatInput";
|
| 8 |
+
import { ReportPicker } from "@/components/ReportPicker";
|
| 9 |
+
import { SourceCard } from "@/components/SourceCard";
|
| 10 |
+
import { PdfViewer } from "@/components/PdfViewer";
|
| 11 |
+
import { ExamplePrompts } from "@/components/ExamplePrompts";
|
| 12 |
+
|
| 13 |
+
const EXAMPLES = [
|
| 14 |
+
"What ER/PR/HER2 receptor patterns appear across reports?",
|
| 15 |
+
"Which reports mention lymph node involvement?",
|
| 16 |
+
"Summarize the most common diagnoses.",
|
| 17 |
+
];
|
| 18 |
+
|
| 19 |
+
interface Preview {
|
| 20 |
+
filename: string;
|
| 21 |
+
page: number;
|
| 22 |
+
highlights: number[][];
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
export function GlobalSearch() {
|
| 26 |
+
const { data: reports } = useQuery({ queryKey: ["reports"], queryFn: getReports });
|
| 27 |
+
const [selected, setSelected] = useState<string[]>([]);
|
| 28 |
+
const [preview, setPreview] = useState<Preview | null>(null);
|
| 29 |
+
const { messages, busy, send, stop } = useChat();
|
| 30 |
+
|
| 31 |
+
const submit = (text: string) =>
|
| 32 |
+
send(text, { report_names: selected.length ? selected : null });
|
| 33 |
+
|
| 34 |
+
const openSource = (s: Source) =>
|
| 35 |
+
setPreview({ filename: s.filename, page: s.page ?? 1, highlights: s.line_bboxes });
|
| 36 |
+
|
| 37 |
+
return (
|
| 38 |
+
<div className="mx-auto flex h-full w-full max-w-3xl flex-col p-4">
|
| 39 |
+
<div className="pb-3">
|
| 40 |
+
<ReportPicker reports={reports ?? []} selected={selected} onChange={setSelected} />
|
| 41 |
+
</div>
|
| 42 |
+
|
| 43 |
+
<ChatThread
|
| 44 |
+
messages={messages}
|
| 45 |
+
empty={<ExamplePrompts title="Search across all reports" prompts={EXAMPLES} onPick={submit} />}
|
| 46 |
+
renderExtra={(m) =>
|
| 47 |
+
m.role === "assistant" && m.sources && m.sources.length > 0 ? (
|
| 48 |
+
<div className="grid gap-2 sm:grid-cols-2">
|
| 49 |
+
{m.sources.map((s) => (
|
| 50 |
+
<SourceCard key={s.index} source={s} onClick={() => openSource(s)} />
|
| 51 |
+
))}
|
| 52 |
+
</div>
|
| 53 |
+
) : null
|
| 54 |
+
}
|
| 55 |
+
/>
|
| 56 |
+
|
| 57 |
+
<div className="pt-2">
|
| 58 |
+
<ChatInput onSend={submit} onStop={stop} busy={busy} placeholder="Ask across your reportsβ¦" />
|
| 59 |
+
</div>
|
| 60 |
+
|
| 61 |
+
{preview && (
|
| 62 |
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-6" onClick={() => setPreview(null)}>
|
| 63 |
+
<div
|
| 64 |
+
className="flex h-[85vh] w-full max-w-3xl flex-col overflow-hidden rounded-xl border border-border bg-background"
|
| 65 |
+
onClick={(e) => e.stopPropagation()}
|
| 66 |
+
>
|
| 67 |
+
<div className="flex items-center justify-between border-b border-border px-4 py-2.5">
|
| 68 |
+
<span className="truncate text-sm font-medium">{preview.filename}</span>
|
| 69 |
+
<button onClick={() => setPreview(null)} className="rounded-md p-1 hover:bg-muted">
|
| 70 |
+
<X className="h-4 w-4" />
|
| 71 |
+
</button>
|
| 72 |
+
</div>
|
| 73 |
+
<div className="min-h-0 flex-1 p-3">
|
| 74 |
+
<PdfViewer
|
| 75 |
+
filename={preview.filename}
|
| 76 |
+
page={preview.page}
|
| 77 |
+
highlights={preview.highlights}
|
| 78 |
+
onPageChange={(p) => setPreview((prev) => (prev ? { ...prev, page: p, highlights: [] } : prev))}
|
| 79 |
+
/>
|
| 80 |
+
</div>
|
| 81 |
+
</div>
|
| 82 |
+
</div>
|
| 83 |
+
)}
|
| 84 |
+
</div>
|
| 85 |
+
);
|
| 86 |
+
}
|
frontend/src/vite-env.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
/// <reference types="vite/client" />
|
frontend/tailwind.config.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** @type {import('tailwindcss').Config} */
|
| 2 |
+
export default {
|
| 3 |
+
darkMode: "class",
|
| 4 |
+
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
| 5 |
+
theme: {
|
| 6 |
+
extend: {
|
| 7 |
+
colors: {
|
| 8 |
+
border: "hsl(var(--border))",
|
| 9 |
+
input: "hsl(var(--input))",
|
| 10 |
+
ring: "hsl(var(--ring))",
|
| 11 |
+
background: "hsl(var(--background))",
|
| 12 |
+
foreground: "hsl(var(--foreground))",
|
| 13 |
+
muted: {
|
| 14 |
+
DEFAULT: "hsl(var(--muted))",
|
| 15 |
+
foreground: "hsl(var(--muted-foreground))",
|
| 16 |
+
},
|
| 17 |
+
card: {
|
| 18 |
+
DEFAULT: "hsl(var(--card))",
|
| 19 |
+
foreground: "hsl(var(--card-foreground))",
|
| 20 |
+
},
|
| 21 |
+
primary: {
|
| 22 |
+
DEFAULT: "hsl(var(--primary))",
|
| 23 |
+
foreground: "hsl(var(--primary-foreground))",
|
| 24 |
+
},
|
| 25 |
+
accent: {
|
| 26 |
+
DEFAULT: "hsl(var(--accent))",
|
| 27 |
+
foreground: "hsl(var(--accent-foreground))",
|
| 28 |
+
},
|
| 29 |
+
},
|
| 30 |
+
borderRadius: {
|
| 31 |
+
lg: "var(--radius)",
|
| 32 |
+
md: "calc(var(--radius) - 2px)",
|
| 33 |
+
sm: "calc(var(--radius) - 4px)",
|
| 34 |
+
},
|
| 35 |
+
fontFamily: {
|
| 36 |
+
sans: ["Inter", "system-ui", "sans-serif"],
|
| 37 |
+
},
|
| 38 |
+
keyframes: {
|
| 39 |
+
"fade-in": {
|
| 40 |
+
from: { opacity: "0", transform: "translateY(4px)" },
|
| 41 |
+
to: { opacity: "1", transform: "translateY(0)" },
|
| 42 |
+
},
|
| 43 |
+
"pulse-ring": {
|
| 44 |
+
"0%, 100%": { opacity: "0.55" },
|
| 45 |
+
"50%": { opacity: "0.9" },
|
| 46 |
+
},
|
| 47 |
+
},
|
| 48 |
+
animation: {
|
| 49 |
+
"fade-in": "fade-in 0.25s ease-out",
|
| 50 |
+
"pulse-ring": "pulse-ring 1.2s ease-in-out 2",
|
| 51 |
+
},
|
| 52 |
+
},
|
| 53 |
+
},
|
| 54 |
+
plugins: [],
|
| 55 |
+
};
|
frontend/tsconfig.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"target": "ES2020",
|
| 4 |
+
"useDefineForClassFields": true,
|
| 5 |
+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
| 6 |
+
"module": "ESNext",
|
| 7 |
+
"skipLibCheck": true,
|
| 8 |
+
"moduleResolution": "bundler",
|
| 9 |
+
"allowImportingTsExtensions": true,
|
| 10 |
+
"resolveJsonModule": true,
|
| 11 |
+
"isolatedModules": true,
|
| 12 |
+
"noEmit": true,
|
| 13 |
+
"jsx": "react-jsx",
|
| 14 |
+
"strict": true,
|
| 15 |
+
"noUnusedLocals": true,
|
| 16 |
+
"noUnusedParameters": true,
|
| 17 |
+
"noFallthroughCasesInSwitch": true,
|
| 18 |
+
"baseUrl": ".",
|
| 19 |
+
"paths": { "@/*": ["./src/*"] }
|
| 20 |
+
},
|
| 21 |
+
"include": ["src"],
|
| 22 |
+
"references": [{ "path": "./tsconfig.node.json" }]
|
| 23 |
+
}
|
frontend/tsconfig.node.json
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"composite": true,
|
| 4 |
+
"skipLibCheck": true,
|
| 5 |
+
"module": "ESNext",
|
| 6 |
+
"moduleResolution": "bundler",
|
| 7 |
+
"allowSyntheticDefaultImports": true,
|
| 8 |
+
"strict": true
|
| 9 |
+
},
|
| 10 |
+
"include": ["vite.config.ts"]
|
| 11 |
+
}
|
frontend/tsconfig.node.tsbuildinfo
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{"fileNames":["./node_modules/typescript/lib/lib.d.ts","./node_modules/typescript/lib/lib.es5.d.ts","./node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/typescript/lib/lib.dom.d.ts","./node_modules/typescript/lib/lib.webworker.importscripts.d.ts","./node_modules/typescript/lib/lib.scripthost.d.ts","./node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/typescript/lib/lib.decorators.d.ts","./node_modules/typescript/lib/lib.decorators.legacy.d.ts","./node_modules/@types/node/compatibility/disposable.d.ts","./node_modules/@types/node/compatibility/indexable.d.ts","./node_modules/@types/node/compatibility/iterators.d.ts","./node_modules/@types/node/compatibility/index.d.ts","./node_modules/@types/node/globals.typedarray.d.ts","./node_modules/@types/node/buffer.buffer.d.ts","./node_modules/@types/node/globals.d.ts","./node_modules/@types/node/web-globals/abortcontroller.d.ts","./node_modules/@types/node/web-globals/domexception.d.ts","./node_modules/@types/node/web-globals/events.d.ts","./node_modules/buffer/index.d.ts","./node_modules/undici-types/header.d.ts","./node_modules/undici-types/readable.d.ts","./node_modules/undici-types/file.d.ts","./node_modules/undici-types/fetch.d.ts","./node_modules/undici-types/formdata.d.ts","./node_modules/undici-types/connector.d.ts","./node_modules/undici-types/client.d.ts","./node_modules/undici-types/errors.d.ts","./node_modules/undici-types/dispatcher.d.ts","./node_modules/undici-types/global-dispatcher.d.ts","./node_modules/undici-types/global-origin.d.ts","./node_modules/undici-types/pool-stats.d.ts","./node_modules/undici-types/pool.d.ts","./node_modules/undici-types/handlers.d.ts","./node_modules/undici-types/balanced-pool.d.ts","./node_modules/undici-types/agent.d.ts","./node_modules/undici-types/mock-interceptor.d.ts","./node_modules/undici-types/mock-agent.d.ts","./node_modules/undici-types/mock-client.d.ts","./node_modules/undici-types/mock-pool.d.ts","./node_modules/undici-types/mock-errors.d.ts","./node_modules/undici-types/proxy-agent.d.ts","./node_modules/undici-types/env-http-proxy-agent.d.ts","./node_modules/undici-types/retry-handler.d.ts","./node_modules/undici-types/retry-agent.d.ts","./node_modules/undici-types/api.d.ts","./node_modules/undici-types/interceptors.d.ts","./node_modules/undici-types/util.d.ts","./node_modules/undici-types/cookies.d.ts","./node_modules/undici-types/patch.d.ts","./node_modules/undici-types/websocket.d.ts","./node_modules/undici-types/eventsource.d.ts","./node_modules/undici-types/filereader.d.ts","./node_modules/undici-types/diagnostics-channel.d.ts","./node_modules/undici-types/content-type.d.ts","./node_modules/undici-types/cache.d.ts","./node_modules/undici-types/index.d.ts","./node_modules/@types/node/web-globals/fetch.d.ts","./node_modules/@types/node/assert.d.ts","./node_modules/@types/node/assert/strict.d.ts","./node_modules/@types/node/async_hooks.d.ts","./node_modules/@types/node/buffer.d.ts","./node_modules/@types/node/child_process.d.ts","./node_modules/@types/node/cluster.d.ts","./node_modules/@types/node/console.d.ts","./node_modules/@types/node/constants.d.ts","./node_modules/@types/node/crypto.d.ts","./node_modules/@types/node/dgram.d.ts","./node_modules/@types/node/diagnostics_channel.d.ts","./node_modules/@types/node/dns.d.ts","./node_modules/@types/node/dns/promises.d.ts","./node_modules/@types/node/domain.d.ts","./node_modules/@types/node/events.d.ts","./node_modules/@types/node/fs.d.ts","./node_modules/@types/node/fs/promises.d.ts","./node_modules/@types/node/http.d.ts","./node_modules/@types/node/http2.d.ts","./node_modules/@types/node/https.d.ts","./node_modules/@types/node/inspector.generated.d.ts","./node_modules/@types/node/module.d.ts","./node_modules/@types/node/net.d.ts","./node_modules/@types/node/os.d.ts","./node_modules/@types/node/path.d.ts","./node_modules/@types/node/perf_hooks.d.ts","./node_modules/@types/node/process.d.ts","./node_modules/@types/node/punycode.d.ts","./node_modules/@types/node/querystring.d.ts","./node_modules/@types/node/readline.d.ts","./node_modules/@types/node/readline/promises.d.ts","./node_modules/@types/node/repl.d.ts","./node_modules/@types/node/sea.d.ts","./node_modules/@types/node/stream.d.ts","./node_modules/@types/node/stream/promises.d.ts","./node_modules/@types/node/stream/consumers.d.ts","./node_modules/@types/node/stream/web.d.ts","./node_modules/@types/node/string_decoder.d.ts","./node_modules/@types/node/test.d.ts","./node_modules/@types/node/timers.d.ts","./node_modules/@types/node/timers/promises.d.ts","./node_modules/@types/node/tls.d.ts","./node_modules/@types/node/trace_events.d.ts","./node_modules/@types/node/tty.d.ts","./node_modules/@types/node/url.d.ts","./node_modules/@types/node/util.d.ts","./node_modules/@types/node/v8.d.ts","./node_modules/@types/node/vm.d.ts","./node_modules/@types/node/wasi.d.ts","./node_modules/@types/node/worker_threads.d.ts","./node_modules/@types/node/zlib.d.ts","./node_modules/@types/node/index.d.ts","./node_modules/@types/estree/index.d.ts","./node_modules/rollup/dist/rollup.d.ts","./node_modules/rollup/dist/parseast.d.ts","./node_modules/vite/types/hmrpayload.d.ts","./node_modules/vite/types/customevent.d.ts","./node_modules/vite/types/hot.d.ts","./node_modules/vite/dist/node/types.d-agj9qkwt.d.ts","./node_modules/esbuild/lib/main.d.ts","./node_modules/source-map-js/source-map.d.ts","./node_modules/postcss/lib/previous-map.d.ts","./node_modules/postcss/lib/input.d.ts","./node_modules/postcss/lib/css-syntax-error.d.ts","./node_modules/postcss/lib/declaration.d.ts","./node_modules/postcss/lib/root.d.ts","./node_modules/postcss/lib/warning.d.ts","./node_modules/postcss/lib/lazy-result.d.ts","./node_modules/postcss/lib/no-work-result.d.ts","./node_modules/postcss/lib/processor.d.ts","./node_modules/postcss/lib/result.d.ts","./node_modules/postcss/lib/document.d.ts","./node_modules/postcss/lib/rule.d.ts","./node_modules/postcss/lib/node.d.ts","./node_modules/postcss/lib/comment.d.ts","./node_modules/postcss/lib/container.d.ts","./node_modules/postcss/lib/at-rule.d.ts","./node_modules/postcss/lib/list.d.ts","./node_modules/postcss/lib/postcss.d.ts","./node_modules/postcss/lib/postcss.d.mts","./node_modules/vite/dist/node/runtime.d.ts","./node_modules/vite/types/importglob.d.ts","./node_modules/vite/types/metadata.d.ts","./node_modules/vite/dist/node/index.d.ts","./node_modules/@babel/types/lib/index.d.ts","./node_modules/@types/babel__generator/index.d.ts","./node_modules/@babel/parser/typings/babel-parser.d.ts","./node_modules/@types/babel__template/index.d.ts","./node_modules/@types/babel__traverse/index.d.ts","./node_modules/@types/babel__core/index.d.ts","./node_modules/@vitejs/plugin-react/dist/index.d.ts","./vite.config.ts","./node_modules/@types/ms/index.d.ts","./node_modules/@types/debug/index.d.ts","./node_modules/@types/estree-jsx/index.d.ts","./node_modules/@types/unist/index.d.ts","./node_modules/@types/hast/index.d.ts","./node_modules/@types/mdast/index.d.ts","./node_modules/@types/prop-types/index.d.ts","./node_modules/@types/react/global.d.ts","./node_modules/csstype/index.d.ts","./node_modules/@types/react/index.d.ts","./node_modules/@types/react-dom/index.d.ts"],"fileIdsList":[[55,102,183],[55,102],[55,102,183,184,185,186,187],[55,102,183,185],[55,102,191],[55,102,151,152,193],[55,102,194],[55,99,102],[55,101,102],[102],[55,102,107,135],[55,102,103,108,113,121,132,143],[55,102,103,104,113,121],[50,51,52,55,102],[55,102,105,144],[55,102,106,107,114,122],[55,102,107,132,140],[55,102,108,110,113,121],[55,101,102,109],[55,102,110,111],[55,102,112,113],[55,101,102,113],[55,102,113,114,115,132,143],[55,102,113,114,115,128,132,135],[55,102,110,113,116,121,132,143],[55,102,113,114,116,117,121,132,140,143],[55,102,116,118,132,140,143],[53,54,55,56,57,58,59,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149],[55,102,113,119],[55,102,120,143,148],[55,102,110,113,121,132],[55,102,122],[55,102,123],[55,101,102,124],[55,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149],[55,102,126],[55,102,127],[55,102,113,128,129],[55,102,128,130,144,146],[55,102,113,132,133,135],[55,102,134,135],[55,102,132,133],[55,102,135],[55,102,136],[55,99,102,132,137],[55,102,113,138,139],[55,102,138,139],[55,102,107,121,132,140],[55,102,141],[55,102,121,142],[55,102,116,127,143],[55,102,107,144],[55,102,132,145],[55,102,120,146],[55,102,147],[55,97,102],[55,97,102,113,115,124,132,135,143,146,148],[55,102,132,149],[55,102,200],[55,102,197,198,199],[55,102,182,188],[55,102,174],[55,102,172,174],[55,102,163,171,172,173,175,177],[55,102,161],[55,102,164,169,174,177],[55,102,160,177],[55,102,164,165,168,169,170,177],[55,102,164,165,166,168,169,177],[55,102,161,162,163,164,165,169,170,171,173,174,175,177],[55,102,177],[55,102,159,161,162,163,164,165,166,168,169,170,171,172,173,174,175,176],[55,102,159,177],[55,102,164,166,167,169,170,177],[55,102,168,177],[55,102,169,170,174,177],[55,102,162,172],[55,102,152,181],[55,69,73,102,143],[55,69,102,132,143],[55,64,102],[55,66,69,102,140,143],[55,102,121,140],[55,102,150],[55,64,102,150],[55,66,69,102,121,143],[55,61,62,65,68,102,113,132,143],[55,69,76,102],[55,61,67,102],[55,69,90,91,102],[55,65,69,102,135,143,150],[55,90,102,150],[55,63,64,102,150],[55,69,102],[55,63,64,65,66,67,68,69,70,71,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,91,92,93,94,95,96,102],[55,69,84,102],[55,69,76,77,102],[55,67,69,77,78,102],[55,68,102],[55,61,64,69,102],[55,69,73,77,78,102],[55,73,102],[55,67,69,72,102,143],[55,61,66,69,76,102],[55,102,132],[55,64,69,90,102,148,150],[55,102,113,114,116,117,118,121,132,140,143,149,150,152,153,154,155,156,157,158,178,179,180,181],[55,102,154,155,156,157],[55,102,154,155,156],[55,102,154],[55,102,155],[55,102,152],[55,102,123,182,189]],"fileInfos":[{"version":"a7297ff837fcdf174a9524925966429eb8e5feecc2cc55cc06574e6b092c1eaa","impliedFormat":1},{"version":"c430d44666289dae81f30fa7b2edebf186ecc91a2d4c71266ea6ae76388792e1","affectsGlobalScope":true,"impliedFormat":1},{"version":"45b7ab580deca34ae9729e97c13cfd999df04416a79116c3bfb483804f85ded4","impliedFormat":1},{"version":"3facaf05f0c5fc569c5649dd359892c98a85557e3e0c847964caeb67076f4d75","impliedFormat":1},{"version":"e44bb8bbac7f10ecc786703fe0a6a4b952189f908707980ba8f3c8975a760962","impliedFormat":1},{"version":"5e1c4c362065a6b95ff952c0eab010f04dcd2c3494e813b493ecfd4fcb9fc0d8","impliedFormat":1},{"version":"68d73b4a11549f9c0b7d352d10e91e5dca8faa3322bfb77b661839c42b1ddec7","impliedFormat":1},{"version":"5efce4fc3c29ea84e8928f97adec086e3dc876365e0982cc8479a07954a3efd4","impliedFormat":1},{"version":"080941d9f9ff9307f7e27a83bcd888b7c8270716c39af943532438932ec1d0b9","affectsGlobalScope":true,"impliedFormat":1},{"version":"80e18897e5884b6723488d4f5652167e7bb5024f946743134ecc4aa4ee731f89","affectsGlobalScope":true,"impliedFormat":1},{"version":"cd034f499c6cdca722b60c04b5b1b78e058487a7085a8e0d6fb50809947ee573","affectsGlobalScope":true,"impliedFormat":1},{"version":"c57796738e7f83dbc4b8e65132f11a377649c00dd3eee333f672b8f0a6bea671","affectsGlobalScope":true,"impliedFormat":1},{"version":"dc2df20b1bcdc8c2d34af4926e2c3ab15ffe1160a63e58b7e09833f616efff44","affectsGlobalScope":true,"impliedFormat":1},{"version":"515d0b7b9bea2e31ea4ec968e9edd2c39d3eebf4a2d5cbd04e88639819ae3b71","affectsGlobalScope":true,"impliedFormat":1},{"version":"0559b1f683ac7505ae451f9a96ce4c3c92bdc71411651ca6ddb0e88baaaad6a3","affectsGlobalScope":true,"impliedFormat":1},{"version":"0dc1e7ceda9b8b9b455c3a2d67b0412feab00bd2f66656cd8850e8831b08b537","affectsGlobalScope":true,"impliedFormat":1},{"version":"ce691fb9e5c64efb9547083e4a34091bcbe5bdb41027e310ebba8f7d96a98671","affectsGlobalScope":true,"impliedFormat":1},{"version":"8d697a2a929a5fcb38b7a65594020fcef05ec1630804a33748829c5ff53640d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ff2a353abf8a80ee399af572debb8faab2d33ad38c4b4474cff7f26e7653b8d","affectsGlobalScope":true,"impliedFormat":1},{"version":"fb0f136d372979348d59b3f5020b4cdb81b5504192b1cacff5d1fbba29378aa1","affectsGlobalScope":true,"impliedFormat":1},{"version":"d15bea3d62cbbdb9797079416b8ac375ae99162a7fba5de2c6c505446486ac0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"68d18b664c9d32a7336a70235958b8997ebc1c3b8505f4f1ae2b7e7753b87618","affectsGlobalScope":true,"impliedFormat":1},{"version":"eb3d66c8327153d8fa7dd03f9c58d351107fe824c79e9b56b462935176cdf12a","affectsGlobalScope":true,"impliedFormat":1},{"version":"38f0219c9e23c915ef9790ab1d680440d95419ad264816fa15009a8851e79119","affectsGlobalScope":true,"impliedFormat":1},{"version":"69ab18c3b76cd9b1be3d188eaf8bba06112ebbe2f47f6c322b5105a6fbc45a2e","affectsGlobalScope":true,"impliedFormat":1},{"version":"a680117f487a4d2f30ea46f1b4b7f58bef1480456e18ba53ee85c2746eeca012","affectsGlobalScope":true,"impliedFormat":1},{"version":"2f11ff796926e0832f9ae148008138ad583bd181899ab7dd768a2666700b1893","affectsGlobalScope":true,"impliedFormat":1},{"version":"4de680d5bb41c17f7f68e0419412ca23c98d5749dcaaea1896172f06435891fc","affectsGlobalScope":true,"impliedFormat":1},{"version":"954296b30da6d508a104a3a0b5d96b76495c709785c1d11610908e63481ee667","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac9538681b19688c8eae65811b329d3744af679e0bdfa5d842d0e32524c73e1c","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a969edff4bd52585473d24995c5ef223f6652d6ef46193309b3921d65dd4376","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e9fbd7030c440b33d021da145d3232984c8bb7916f277e8ffd3dc2e3eae2bdb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811ec78f7fefcabbda4bfa93b3eb67d9ae166ef95f9bff989d964061cbf81a0c","affectsGlobalScope":true,"impliedFormat":1},{"version":"717937616a17072082152a2ef351cb51f98802fb4b2fdabd32399843875974ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"d7e7d9b7b50e5f22c915b525acc5a49a7a6584cf8f62d0569e557c5cfc4b2ac2","affectsGlobalScope":true,"impliedFormat":1},{"version":"71c37f4c9543f31dfced6c7840e068c5a5aacb7b89111a4364b1d5276b852557","affectsGlobalScope":true,"impliedFormat":1},{"version":"576711e016cf4f1804676043e6a0a5414252560eb57de9faceee34d79798c850","affectsGlobalScope":true,"impliedFormat":1},{"version":"89c1b1281ba7b8a96efc676b11b264de7a8374c5ea1e6617f11880a13fc56dc6","affectsGlobalScope":true,"impliedFormat":1},{"version":"74f7fa2d027d5b33eb0471c8e82a6c87216223181ec31247c357a3e8e2fddc5b","affectsGlobalScope":true,"impliedFormat":1},{"version":"d6d7ae4d1f1f3772e2a3cde568ed08991a8ae34a080ff1151af28b7f798e22ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"063600664504610fe3e99b717a1223f8b1900087fab0b4cad1496a114744f8df","affectsGlobalScope":true,"impliedFormat":1},{"version":"934019d7e3c81950f9a8426d093458b65d5aff2c7c1511233c0fd5b941e608ab","affectsGlobalScope":true,"impliedFormat":1},{"version":"52ada8e0b6e0482b728070b7639ee42e83a9b1c22d205992756fe020fd9f4a47","affectsGlobalScope":true,"impliedFormat":1},{"version":"3bdefe1bfd4d6dee0e26f928f93ccc128f1b64d5d501ff4a8cf3c6371200e5e6","affectsGlobalScope":true,"impliedFormat":1},{"version":"59fb2c069260b4ba00b5643b907ef5d5341b167e7d1dbf58dfd895658bda2867","affectsGlobalScope":true,"impliedFormat":1},{"version":"639e512c0dfc3fad96a84caad71b8834d66329a1f28dc95e3946c9b58176c73a","affectsGlobalScope":true,"impliedFormat":1},{"version":"368af93f74c9c932edd84c58883e736c9e3d53cec1fe24c0b0ff451f529ceab1","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e7f8264d0fb4c5339605a15daadb037bf238c10b654bb3eee14208f860a32ea","affectsGlobalScope":true,"impliedFormat":1},{"version":"782dec38049b92d4e85c1585fbea5474a219c6984a35b004963b00beb1aab538","affectsGlobalScope":true,"impliedFormat":1},{"version":"70521b6ab0dcba37539e5303104f29b721bfb2940b2776da4cc818c07e1fefc1","affectsGlobalScope":true,"impliedFormat":1},{"version":"ab41ef1f2cdafb8df48be20cd969d875602483859dc194e9c97c8a576892c052","affectsGlobalScope":true,"impliedFormat":1},{"version":"d153a11543fd884b596587ccd97aebbeed950b26933ee000f94009f1ab142848","affectsGlobalScope":true,"impliedFormat":1},{"version":"21d819c173c0cf7cc3ce57c3276e77fd9a8a01d35a06ad87158781515c9a438a","impliedFormat":1},{"version":"98cffbf06d6bab333473c70a893770dbe990783904002c4f1a960447b4b53dca","affectsGlobalScope":true,"impliedFormat":1},{"version":"ba481bca06f37d3f2c137ce343c7d5937029b2468f8e26111f3c9d9963d6568d","affectsGlobalScope":true,"impliedFormat":1},{"version":"6d9ef24f9a22a88e3e9b3b3d8c40ab1ddb0853f1bfbd5c843c37800138437b61","affectsGlobalScope":true,"impliedFormat":1},{"version":"1db0b7dca579049ca4193d034d835f6bfe73096c73663e5ef9a0b5779939f3d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"9798340ffb0d067d69b1ae5b32faa17ab31b82466a3fc00d8f2f2df0c8554aaa","affectsGlobalScope":true,"impliedFormat":1},{"version":"f26b11d8d8e4b8028f1c7d618b22274c892e4b0ef5b3678a8ccbad85419aef43","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e9c23ba78aabc2e0a27033f18737a6df754067731e69dc5f52823957d60a4b6","impliedFormat":1},{"version":"5929864ce17fba74232584d90cb721a89b7ad277220627cc97054ba15a98ea8f","impliedFormat":1},{"version":"763fe0f42b3d79b440a9b6e51e9ba3f3f91352469c1e4b3b67bfa4ff6352f3f4","impliedFormat":1},{"version":"25c8056edf4314820382a5fdb4bb7816999acdcb929c8f75e3f39473b87e85bc","impliedFormat":1},{"version":"c464d66b20788266e5353b48dc4aa6bc0dc4a707276df1e7152ab0c9ae21fad8","impliedFormat":1},{"version":"78d0d27c130d35c60b5e5566c9f1e5be77caf39804636bc1a40133919a949f21","impliedFormat":1},{"version":"c6fd2c5a395f2432786c9cb8deb870b9b0e8ff7e22c029954fabdd692bff6195","impliedFormat":1},{"version":"1d6e127068ea8e104a912e42fc0a110e2aa5a66a356a917a163e8cf9a65e4a75","impliedFormat":1},{"version":"5ded6427296cdf3b9542de4471d2aa8d3983671d4cac0f4bf9c637208d1ced43","impliedFormat":1},{"version":"7f182617db458e98fc18dfb272d40aa2fff3a353c44a89b2c0ccb3937709bfb5","impliedFormat":1},{"version":"cadc8aced301244057c4e7e73fbcae534b0f5b12a37b150d80e5a45aa4bebcbd","impliedFormat":1},{"version":"385aab901643aa54e1c36f5ef3107913b10d1b5bb8cbcd933d4263b80a0d7f20","impliedFormat":1},{"version":"9670d44354bab9d9982eca21945686b5c24a3f893db73c0dae0fd74217a4c219","impliedFormat":1},{"version":"0b8a9268adaf4da35e7fa830c8981cfa22adbbe5b3f6f5ab91f6658899e657a7","impliedFormat":1},{"version":"11396ed8a44c02ab9798b7dca436009f866e8dae3c9c25e8c1fbc396880bf1bb","impliedFormat":1},{"version":"ba7bc87d01492633cb5a0e5da8a4a42a1c86270e7b3d2dea5d156828a84e4882","impliedFormat":1},{"version":"4893a895ea92c85345017a04ed427cbd6a1710453338df26881a6019432febdd","impliedFormat":1},{"version":"c21dc52e277bcfc75fac0436ccb75c204f9e1b3fa5e12729670910639f27343e","impliedFormat":1},{"version":"13f6f39e12b1518c6650bbb220c8985999020fe0f21d818e28f512b7771d00f9","impliedFormat":1},{"version":"9b5369969f6e7175740bf51223112ff209f94ba43ecd3bb09eefff9fd675624a","impliedFormat":1},{"version":"4fe9e626e7164748e8769bbf74b538e09607f07ed17c2f20af8d680ee49fc1da","impliedFormat":1},{"version":"24515859bc0b836719105bb6cc3d68255042a9f02a6022b3187948b204946bd2","impliedFormat":1},{"version":"ea0148f897b45a76544ae179784c95af1bd6721b8610af9ffa467a518a086a43","impliedFormat":1},{"version":"24c6a117721e606c9984335f71711877293a9651e44f59f3d21c1ea0856f9cc9","impliedFormat":1},{"version":"dd3273ead9fbde62a72949c97dbec2247ea08e0c6952e701a483d74ef92d6a17","impliedFormat":1},{"version":"405822be75ad3e4d162e07439bac80c6bcc6dbae1929e179cf467ec0b9ee4e2e","impliedFormat":1},{"version":"0db18c6e78ea846316c012478888f33c11ffadab9efd1cc8bcc12daded7a60b6","impliedFormat":1},{"version":"e61be3f894b41b7baa1fbd6a66893f2579bfad01d208b4ff61daef21493ef0a8","impliedFormat":1},{"version":"bd0532fd6556073727d28da0edfd1736417a3f9f394877b6d5ef6ad88fba1d1a","impliedFormat":1},{"version":"89167d696a849fce5ca508032aabfe901c0868f833a8625d5a9c6e861ef935d2","impliedFormat":1},{"version":"615ba88d0128ed16bf83ef8ccbb6aff05c3ee2db1cc0f89ab50a4939bfc1943f","impliedFormat":1},{"version":"a4d551dbf8746780194d550c88f26cf937caf8d56f102969a110cfaed4b06656","impliedFormat":1},{"version":"8bd86b8e8f6a6aa6c49b71e14c4ffe1211a0e97c80f08d2c8cc98838006e4b88","impliedFormat":1},{"version":"317e63deeb21ac07f3992f5b50cdca8338f10acd4fbb7257ebf56735bf52ab00","impliedFormat":1},{"version":"4732aec92b20fb28c5fe9ad99521fb59974289ed1e45aecb282616202184064f","impliedFormat":1},{"version":"2e85db9e6fd73cfa3d7f28e0ab6b55417ea18931423bd47b409a96e4a169e8e6","impliedFormat":1},{"version":"c46e079fe54c76f95c67fb89081b3e399da2c7d109e7dca8e4b58d83e332e605","impliedFormat":1},{"version":"bf67d53d168abc1298888693338cb82854bdb2e69ef83f8a0092093c2d562107","impliedFormat":1},{"version":"b52476feb4a0cbcb25e5931b930fc73cb6643fb1a5060bf8a3dda0eeae5b4b68","affectsGlobalScope":true,"impliedFormat":1},{"version":"e2677634fe27e87348825bb041651e22d50a613e2fdf6a4a3ade971d71bac37e","impliedFormat":1},{"version":"7394959e5a741b185456e1ef5d64599c36c60a323207450991e7a42e08911419","impliedFormat":1},{"version":"8c0bcd6c6b67b4b503c11e91a1fb91522ed585900eab2ab1f61bba7d7caa9d6f","impliedFormat":1},{"version":"8cd19276b6590b3ebbeeb030ac271871b9ed0afc3074ac88a94ed2449174b776","affectsGlobalScope":true,"impliedFormat":1},{"version":"696eb8d28f5949b87d894b26dc97318ef944c794a9a4e4f62360cd1d1958014b","impliedFormat":1},{"version":"3f8fa3061bd7402970b399300880d55257953ee6d3cd408722cb9ac20126460c","impliedFormat":1},{"version":"35ec8b6760fd7138bbf5809b84551e31028fb2ba7b6dc91d95d098bf212ca8b4","affectsGlobalScope":true,"impliedFormat":1},{"version":"5524481e56c48ff486f42926778c0a3cce1cc85dc46683b92b1271865bcf015a","impliedFormat":1},{"version":"68bd56c92c2bd7d2339457eb84d63e7de3bd56a69b25f3576e1568d21a162398","affectsGlobalScope":true,"impliedFormat":1},{"version":"3e93b123f7c2944969d291b35fed2af79a6e9e27fdd5faa99748a51c07c02d28","impliedFormat":1},{"version":"9d19808c8c291a9010a6c788e8532a2da70f811adb431c97520803e0ec649991","impliedFormat":1},{"version":"87aad3dd9752067dc875cfaa466fc44246451c0c560b820796bdd528e29bef40","impliedFormat":1},{"version":"4aacb0dd020eeaef65426153686cc639a78ec2885dc72ad220be1d25f1a439df","impliedFormat":1},{"version":"f0bd7e6d931657b59605c44112eaf8b980ba7f957a5051ed21cb93d978cf2f45","impliedFormat":1},{"version":"8db0ae9cb14d9955b14c214f34dae1b9ef2baee2fe4ce794a4cd3ac2531e3255","affectsGlobalScope":true,"impliedFormat":1},{"version":"15fc6f7512c86810273af28f224251a5a879e4261b4d4c7e532abfbfc3983134","impliedFormat":1},{"version":"58adba1a8ab2d10b54dc1dced4e41f4e7c9772cbbac40939c0dc8ce2cdb1d442","impliedFormat":1},{"version":"641942a78f9063caa5d6b777c99304b7d1dc7328076038c6d94d8a0b81fc95c1","impliedFormat":1},{"version":"1123a83f35cf56c97de746f0a7250012153c61a167e4a61668bf50e558162d14","impliedFormat":1},{"version":"855cd5f7eb396f5f1ab1bc0f8580339bff77b68a770f84c6b254e319bbfd1ac7","impliedFormat":1},{"version":"5650cf3dace09e7c25d384e3e6b818b938f68f4e8de96f52d9c5a1b3db068e86","impliedFormat":1},{"version":"1354ca5c38bd3fd3836a68e0f7c9f91f172582ba30ab15bb8c075891b91502b7","affectsGlobalScope":true,"impliedFormat":1},{"version":"7e20d899c28ca26a2a7afc98beaa69e63ff7fba0a8bc47b4e3bf3ede5e09e424","impliedFormat":1},{"version":"2d2fcaab481b31a5882065c7951255703ddbe1c0e507af56ea42d79ac3911201","impliedFormat":1},{"version":"a192fe8ec33f75edbc8d8f3ed79f768dfae11ff5735e7fe52bfa69956e46d78d","impliedFormat":1},{"version":"ca867399f7db82df981d6915bcbb2d81131d7d1ef683bc782b59f71dda59bc85","affectsGlobalScope":true,"impliedFormat":1},{"version":"372413016d17d804e1d139418aca0c68e47a83fb6669490857f4b318de8cccb3","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e043a1bc8fbf2a255bccf9bf27e0f1caf916c3b0518ea34aa72357c0afd42ec","impliedFormat":1},{"version":"b4f70ec656a11d570e1a9edce07d118cd58d9760239e2ece99306ee9dfe61d02","impliedFormat":1},{"version":"3bc2f1e2c95c04048212c569ed38e338873f6a8593930cf5a7ef24ffb38fc3b6","impliedFormat":1},{"version":"6e70e9570e98aae2b825b533aa6292b6abd542e8d9f6e9475e88e1d7ba17c866","impliedFormat":1},{"version":"f9d9d753d430ed050dc1bf2667a1bab711ccbb1c1507183d794cc195a5b085cc","impliedFormat":1},{"version":"9eece5e586312581ccd106d4853e861aaaa1a39f8e3ea672b8c3847eedd12f6e","impliedFormat":1},{"version":"085f552d005479e2e6a7311cdbbe5d8c55c497b4d19274285df161ee9684cd9c","impliedFormat":1},{"version":"37ba7b45141a45ce6e80e66f2a96c8a5ab1bcef0fc2d0f56bb58df96ec67e972","impliedFormat":1},{"version":"45650f47bfb376c8a8ed39d4bcda5902ab899a3150029684ee4c10676d9fbaee","impliedFormat":1},{"version":"007faacc9268357caa21d24169f3f3f2497af3e9241308df2d89f6e6d9bb3f2e","affectsGlobalScope":true,"impliedFormat":1},{"version":"74cf591a0f63db318651e0e04cb55f8791385f86e987a67fd4d2eaab8191f730","impliedFormat":1},{"version":"5eab9b3dc9b34f185417342436ec3f106898da5f4801992d8ff38ab3aff346b5","impliedFormat":1},{"version":"12ed4559eba17cd977aa0db658d25c4047067444b51acfdcbf38470630642b23","affectsGlobalScope":true,"impliedFormat":1},{"version":"f3ffabc95802521e1e4bcba4c88d8615176dc6e09111d920c7a213bdda6e1d65","impliedFormat":1},{"version":"809821b8a065e3234a55b3a9d7846231ed18d66dd749f2494c66288d890daf7f","impliedFormat":1},{"version":"ae56f65caf3be91108707bd8dfbccc2a57a91feb5daabf7165a06a945545ed26","impliedFormat":1},{"version":"a136d5de521da20f31631a0a96bf712370779d1c05b7015d7019a9b2a0446ca9","impliedFormat":1},{"version":"c3b41e74b9a84b88b1dca61ec39eee25c0dbc8e7d519ba11bb070918cfacf656","affectsGlobalScope":true,"impliedFormat":1},{"version":"4737a9dc24d0e68b734e6cfbcea0c15a2cfafeb493485e27905f7856988c6b29","affectsGlobalScope":true,"impliedFormat":1},{"version":"36d8d3e7506b631c9582c251a2c0b8a28855af3f76719b12b534c6edf952748d","impliedFormat":1},{"version":"1ca69210cc42729e7ca97d3a9ad48f2e9cb0042bada4075b588ae5387debd318","impliedFormat":1},{"version":"f5ebe66baaf7c552cfa59d75f2bfba679f329204847db3cec385acda245e574e","impliedFormat":1},{"version":"ed59add13139f84da271cafd32e2171876b0a0af2f798d0c663e8eeb867732cf","affectsGlobalScope":true,"impliedFormat":1},{"version":"b7c5e2ea4a9749097c347454805e933844ed207b6eefec6b7cfd418b5f5f7b28","impliedFormat":1},{"version":"b1810689b76fd473bd12cc9ee219f8e62f54a7d08019a235d07424afbf074d25","impliedFormat":1},{"version":"751764bb94219b4ce8f5475dc35d3de2e432fea01a0c9610cd7f69ad05e398c6","impliedFormat":1},{"version":"ee70b8037ecdf0de6c04f35277f253663a536d7e38f1539d270e4e916d225a3f","affectsGlobalScope":true,"impliedFormat":1},{"version":"a660aa95476042d3fdcc1343cf6bb8fdf24772d31712b1db321c5a4dcc325434","impliedFormat":1},{"version":"282f98006ed7fa9bb2cd9bdbe2524595cfc4bcd58a0bb3232e4519f2138df811","impliedFormat":1},{"version":"6222e987b58abfe92597e1273ad7233626285bc2d78409d4a7b113d81a83496b","impliedFormat":1},{"version":"cbe726263ae9a7bf32352380f7e8ab66ee25b3457137e316929269c19e18a2be","impliedFormat":1},{"version":"8b96046bf5fb0a815cba6b0880d9f97b7f3a93cf187e8dcfe8e2792e97f38f87","impliedFormat":99},{"version":"bacf2c84cf448b2cd02c717ad46c3d7fd530e0c91282888c923ad64810a4d511","affectsGlobalScope":true,"impliedFormat":1},{"version":"402e5c534fb2b85fa771170595db3ac0dd532112c8fa44fc23f233bc6967488b","impliedFormat":1},{"version":"52dcc257df5119fb66d864625112ce5033ac51a4c2afe376a0b299d2f7f76e4a","impliedFormat":1},{"version":"e5bab5f871ef708d52d47b3e5d0aa72a08ee7a152f33931d9a60809711a2a9a3","impliedFormat":1},{"version":"e16dc2a81595736024a206c7d5c8a39bfe2e6039208ef29981d0d95434ba8fcf","impliedFormat":1},{"version":"cc4a4903fb698ca1d961d4c10dce658aa3a479faf40509d526f122b044eaf6a4","impliedFormat":1},{"version":"19ee8416e6473ed6c7adb868fa796b5653cf0fa2a337658e677eaa0d134388c3","impliedFormat":1},{"version":"1328ab4e442614b28cdb3d4b414cf68325c0da0dca07287a338d0654b7a00261","impliedFormat":1},{"version":"a039dc21f045919f3cbee2ec13812cc6cc3eebc99dae4be00973230f468d19a6","impliedFormat":1},{"version":"3fbe57af01460e49dcd29df55d6931e1672bc6f1be0fb073d11410bc16f9037d","impliedFormat":1},{"version":"f760be449e8562ec5c09bb5187e8e1eabf3c113c0c58cddda53ef8c69f3e2131","impliedFormat":1},{"version":"44325ed13294fce6ab825b82947bbeed2611db7dad9d9135260192f375e5a189","impliedFormat":1},{"version":"e392e8fb5b514eafc585601c1d781485aa6dd6a320e75daf1064a4c6918a1b45","impliedFormat":1},{"version":"46e4a36e8ddbdfb4e7330e11c81c970dc8b218611df9183d39c41c5f8c653b55","impliedFormat":1},{"version":"370bde134aa8c2abc926d0e99d3a4d5d5dba65c6ee65459137e4f02670cbf841","impliedFormat":1},{"version":"6332f565867cf4a740a70e30f31cefba37ef7cebcf74f22eab8d744fde6d193e","impliedFormat":1},{"version":"2977b7884aedc895a1d0c9c210c7cf3272c29d6959a08a6fa3ff71e0aff08175","impliedFormat":1},{"version":"17f2922d41ddd032830a91371c948cd9ce903b35c95adca72271a54584f19b0b","impliedFormat":1},{"version":"3eed76ede2a1a14d7c9bb0a642041282dcc264811139d3dd275c9fe14efc9840","impliedFormat":1},{"version":"e3cf0611709328b449ec13f8c436712d62003620ce480139fae46ce001c2ee9f","impliedFormat":1},{"version":"8d369483f0c2b9ee388129cfdb6a43bc8112b377e86a41884bd06e19ce04f4c1","impliedFormat":99},{"version":"82e687ebd99518bc63ea04b0c3810fb6e50aa6942decd0ca6f7a56d9b9a212a6","impliedFormat":99},{"version":"7f698624bbbb060ece7c0e51b7236520ebada74b747d7523c7df376453ed6fea","impliedFormat":1},{"version":"8f07f2b6514744ac96e51d7cb8518c0f4de319471237ea10cf688b8d0e9d0225","impliedFormat":1},{"version":"257b83faa134d971c738a6b9e4c47e59bb7b23274719d92197580dd662bfafc3","impliedFormat":99},{"version":"556ccd493ec36c7d7cb130d51be66e147b91cc1415be383d71da0f1e49f742a9","impliedFormat":1},{"version":"b6d03c9cfe2cf0ba4c673c209fcd7c46c815b2619fd2aad59fc4229aaef2ed43","impliedFormat":1},{"version":"95aba78013d782537cc5e23868e736bec5d377b918990e28ed56110e3ae8b958","impliedFormat":1},{"version":"670a76db379b27c8ff42f1ba927828a22862e2ab0b0908e38b671f0e912cc5ed","impliedFormat":1},{"version":"13b77ab19ef7aadd86a1e54f2f08ea23a6d74e102909e3c00d31f231ed040f62","impliedFormat":1},{"version":"069bebfee29864e3955378107e243508b163e77ab10de6a5ee03ae06939f0bb9","impliedFormat":1},{"version":"26e0ffceb2198feb1ef460d5d14111c69ad07d44c5a67fd4bfeb74c969aa9afb","impliedFormat":99},{"version":"c3e01ed1d025a74b084b9bcc0436479db22df816130bdfd894df1b02c15c4f55","signature":"4b96dd19fd2949d28ce80e913412b0026dc421e5bf6c31d87c7b5eb11b5753b4"},{"version":"fb893a0dfc3c9fb0f9ca93d0648694dd95f33cbad2c0f2c629f842981dfd4e2e","impliedFormat":1},{"version":"89e326922cadcc2331d7e851011cf9f0456a681aaf3c95b48b81f8d80e8cdfba","impliedFormat":1},{"version":"5d08a179b846f5ee674624b349ebebe2121c455e3a265dc93da4e8d9e89722b4","impliedFormat":1},{"version":"89121c1bf2990f5219bfd802a3e7fc557de447c62058d6af68d6b6348d64499a","impliedFormat":1},{"version":"79b4369233a12c6fa4a07301ecb7085802c98f3a77cf9ab97eee27e1656f82e6","impliedFormat":1},{"version":"d4a22007b481fe2a2e6bfd3a42c00cd62d41edb36d30fc4697df2692e9891fc8","impliedFormat":1},{"version":"87d9d29dbc745f182683f63187bf3d53fd8673e5fca38ad5eaab69798ed29fbc","impliedFormat":1},{"version":"eb5b19b86227ace1d29ea4cf81387279d04bb34051e944bc53df69f58914b788","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac51dd7d31333793807a6abaa5ae168512b6131bd41d9c5b98477fc3b7800f9f","impliedFormat":1},{"version":"09ddcfcfbe77a8232d155ca1030005106b1328f6210df43629d0be750da07c16","affectsGlobalScope":true,"impliedFormat":1},{"version":"17ed71200119e86ccef2d96b73b02ce8854b76ad6bd21b5021d4269bec527b5f","impliedFormat":1}],"root":[190],"options":{"allowSyntheticDefaultImports":true,"composite":true,"module":99,"skipLibCheck":true,"strict":true},"referencedMap":[[185,1],[183,2],[188,3],[184,1],[186,4],[187,1],[192,5],[193,6],[151,2],[195,7],[196,7],[191,2],[99,8],[100,8],[101,9],[55,10],[102,11],[103,12],[104,13],[50,2],[53,14],[51,2],[52,2],[105,15],[106,16],[107,17],[108,18],[109,19],[110,20],[111,20],[112,21],[113,22],[114,23],[115,24],[56,2],[54,2],[116,25],[117,26],[118,27],[150,28],[119,29],[120,30],[121,31],[122,32],[123,33],[124,34],[125,35],[126,36],[127,37],[128,38],[129,38],[130,39],[131,2],[132,40],[134,41],[133,42],[135,43],[136,44],[137,45],[138,46],[139,47],[140,48],[141,49],[142,50],[143,51],[144,52],[145,53],[146,54],[147,55],[57,2],[58,2],[59,2],[98,56],[148,57],[149,58],[197,2],[201,59],[198,2],[200,60],[194,2],[189,61],[60,2],[199,2],[158,2],[175,62],[173,63],[174,64],[162,65],[163,63],[170,66],[161,67],[166,68],[176,2],[167,69],[172,70],[178,71],[177,72],[160,73],[168,74],[169,75],[164,76],[171,62],[165,77],[153,78],[152,6],[159,2],[1,2],[48,2],[49,2],[9,2],[13,2],[12,2],[3,2],[14,2],[15,2],[16,2],[17,2],[18,2],[19,2],[20,2],[21,2],[4,2],[22,2],[23,2],[5,2],[24,2],[28,2],[25,2],[26,2],[27,2],[29,2],[30,2],[31,2],[6,2],[32,2],[33,2],[34,2],[35,2],[7,2],[39,2],[36,2],[37,2],[38,2],[40,2],[8,2],[41,2],[46,2],[47,2],[42,2],[43,2],[44,2],[45,2],[2,2],[11,2],[10,2],[76,79],[86,80],[75,79],[96,81],[67,82],[66,83],[95,84],[89,85],[94,86],[69,87],[83,88],[68,89],[92,90],[64,91],[63,84],[93,92],[65,93],[70,94],[71,2],[74,94],[61,2],[97,95],[87,96],[78,97],[79,98],[81,99],[77,100],[80,101],[90,84],[72,102],[73,103],[82,104],[62,105],[85,96],[84,94],[88,2],[91,106],[182,107],[179,108],[157,109],[155,110],[154,2],[156,111],[180,2],[181,112],[190,113]],"latestChangedDtsFile":"./vite.config.d.ts","version":"5.9.3"}
|
frontend/tsconfig.tsbuildinfo
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/chatinput.tsx","./src/components/chatthread.tsx","./src/components/exampleprompts.tsx","./src/components/messagebubble.tsx","./src/components/pdfviewer.tsx","./src/components/reportpicker.tsx","./src/components/sidebar.tsx","./src/components/sourcecard.tsx","./src/components/themetoggle.tsx","./src/components/uploadpanel.tsx","./src/components/ui/button.tsx","./src/lib/api.ts","./src/lib/sse.ts","./src/lib/theme.tsx","./src/lib/usechat.ts","./src/lib/utils.ts","./src/views/documentchat.tsx","./src/views/globalsearch.tsx"],"version":"5.9.3"}
|
frontend/vite.config.d.ts
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
declare const _default: import("vite").UserConfig;
|
| 2 |
+
export default _default;
|
frontend/vite.config.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig } from "vite";
|
| 2 |
+
import react from "@vitejs/plugin-react";
|
| 3 |
+
import path from "path";
|
| 4 |
+
export default defineConfig({
|
| 5 |
+
plugins: [react()],
|
| 6 |
+
resolve: {
|
| 7 |
+
alias: { "@": path.resolve(__dirname, "./src") },
|
| 8 |
+
},
|
| 9 |
+
server: {
|
| 10 |
+
port: 5173,
|
| 11 |
+
// Dev-only: proxy API calls to the FastAPI backend so the SPA and API
|
| 12 |
+
// share an origin during development (mirrors the container in prod).
|
| 13 |
+
proxy: {
|
| 14 |
+
"/api": {
|
| 15 |
+
target: "http://localhost:8000",
|
| 16 |
+
changeOrigin: true,
|
| 17 |
+
},
|
| 18 |
+
},
|
| 19 |
+
},
|
| 20 |
+
});
|
frontend/vite.config.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig } from "vite";
|
| 2 |
+
import react from "@vitejs/plugin-react";
|
| 3 |
+
import path from "path";
|
| 4 |
+
|
| 5 |
+
export default defineConfig({
|
| 6 |
+
plugins: [react()],
|
| 7 |
+
resolve: {
|
| 8 |
+
alias: { "@": path.resolve(__dirname, "./src") },
|
| 9 |
+
},
|
| 10 |
+
server: {
|
| 11 |
+
port: 5173,
|
| 12 |
+
// Dev-only: proxy API calls to the FastAPI backend so the SPA and API
|
| 13 |
+
// share an origin during development (mirrors the container in prod).
|
| 14 |
+
proxy: {
|
| 15 |
+
"/api": {
|
| 16 |
+
target: "http://localhost:8000",
|
| 17 |
+
changeOrigin: true,
|
| 18 |
+
},
|
| 19 |
+
},
|
| 20 |
+
},
|
| 21 |
+
});
|
output/biomedbert_vector_db/faiss.index
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:bccf824362c2bc66295313f5c1d327c0409a9b9e86b6767fd9f46de9de9c39df
|
| 3 |
+
size 30591021
|
output/biomedbert_vector_db/metadata.pkl
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:b7cd713d5984a5bbee9eb14706f4e6a6daa7e2e13116c860ef3dbb369fd69275
|
| 3 |
+
size 10205100
|
requirements.txt
CHANGED
|
@@ -1,15 +1,24 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
faiss-cpu>=1.8.0
|
| 15 |
rank-bm25
|
|
@@ -20,11 +29,13 @@ pandas
|
|
| 20 |
scikit-learn
|
| 21 |
tqdm
|
| 22 |
|
|
|
|
| 23 |
pillow
|
| 24 |
-
pdf2image
|
| 25 |
-
pytesseract
|
| 26 |
pymupdf
|
| 27 |
-
|
| 28 |
-
|
|
|
|
|
|
|
| 29 |
|
| 30 |
-
|
|
|
|
|
|
| 1 |
+
# ββ Web API (React + FastAPI deployment) ββ
|
| 2 |
+
fastapi>=0.110.0
|
| 3 |
+
uvicorn[standard]>=0.29.0
|
| 4 |
+
python-multipart>=0.0.9
|
| 5 |
+
sse-starlette>=2.0.0
|
| 6 |
+
|
| 7 |
+
# numpy 2.3.x: matches the set paddlepaddle 3.x is happy with.
|
| 8 |
+
numpy>=1.26,<2.4
|
| 9 |
+
|
| 10 |
+
# ββ ML / RAG stack ββ
|
| 11 |
+
# torch is installed separately in the Dockerfile from the CPU wheel index
|
| 12 |
+
# (https://download.pytorch.org/whl/cpu) so we never pull the ~2GB CUDA build.
|
| 13 |
+
# The transformers stack below is pinned to a known-good, mutually-compatible
|
| 14 |
+
# set (verified importing cleanly locally) so a fresh container build can't
|
| 15 |
+
# resolve an incompatible combination that breaks `PreTrainedModel` at import.
|
| 16 |
+
transformers==4.57.3
|
| 17 |
+
sentence-transformers==5.2.0
|
| 18 |
+
huggingface-hub==0.36.0
|
| 19 |
+
tokenizers==0.22.2
|
| 20 |
+
safetensors==0.7.0
|
| 21 |
+
accelerate==1.13.0
|
| 22 |
|
| 23 |
faiss-cpu>=1.8.0
|
| 24 |
rank-bm25
|
|
|
|
| 29 |
scikit-learn
|
| 30 |
tqdm
|
| 31 |
|
| 32 |
+
# ββ PDF + OCR ββ
|
| 33 |
pillow
|
|
|
|
|
|
|
| 34 |
pymupdf
|
| 35 |
+
# PaddleOCR 3.x β the OCR extraction code targets the 3.x predict() API
|
| 36 |
+
# (rec_texts / dt_polys). Do not drop below 3.0.
|
| 37 |
+
paddlepaddle>=3.0.0,<4.0.0
|
| 38 |
+
paddleocr>=3.0.0,<4.0.0
|
| 39 |
|
| 40 |
+
# Note: the legacy Streamlit UI (app.py / ui/) is not run in the container and
|
| 41 |
+
# is excluded via .dockerignore; install `streamlit` separately to run it.
|
src/__pycache__/document_processor.cpython-313.pyc
CHANGED
|
Binary files a/src/__pycache__/document_processor.cpython-313.pyc and b/src/__pycache__/document_processor.cpython-313.pyc differ
|
|
|
src/__pycache__/retriever.cpython-313.pyc
CHANGED
|
Binary files a/src/__pycache__/retriever.cpython-313.pyc and b/src/__pycache__/retriever.cpython-313.pyc differ
|
|
|