diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..1a3af92 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..c66e86f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,19 @@ +version: 2 +updates: + # Single root install (npm workspaces, one package-lock.json) covers both + # frontend and backend dependencies. + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + groups: + all-deps: + patterns: ["*"] + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + all-actions: + patterns: ["*"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..11c0c15 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +# Gate for PRs (incl. Dependabot) and pushes to main. There's no test suite, so the +# meaningful checks for this repo are: it typechecks and it builds. +on: + pull_request: + push: + branches: [main] + +jobs: + ci: + name: Typecheck & build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e + with: + node-version: 24 + cache: npm + # One root install covers both workspaces (single package-lock.json). + - run: npm ci + - name: Typecheck frontend + run: npx tsc --noEmit -p frontend/tsconfig.json + - name: Typecheck backend + run: npx tsc --noEmit -p backend/tsconfig.json + # The frontend has no `build` npm script (dev-only repo); build via Vite directly + # as a bundle sanity check (catches ?raw imports, the worklet asset config, etc.). + - name: Build frontend + working-directory: frontend + run: npx vite build diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml new file mode 100644 index 0000000..3e26c86 --- /dev/null +++ b/.github/workflows/dependabot-auto-merge.yml @@ -0,0 +1,28 @@ +name: Dependabot auto-merge + +# Auto-merge Dependabot patch/minor bumps once CI is green. Major bumps are left for a +# human. Requires repo setting "Allow auto-merge" and a branch-protection rule on main +# that requires the CI check — `--auto` waits for that check before merging. +on: pull_request + +permissions: + contents: write + pull-requests: write + +jobs: + auto-merge: + if: github.actor == 'dependabot[bot]' + runs-on: ubuntu-latest + steps: + - id: metadata + uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + - name: Enable auto-merge for patch/minor updates + if: >- + steps.metadata.outputs.update-type == 'version-update:semver-patch' || + steps.metadata.outputs.update-type == 'version-update:semver-minor' + run: gh pr merge --auto --squash "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index bac7c58..9cf6cf0 100644 --- a/.gitignore +++ b/.gitignore @@ -4,12 +4,14 @@ logs npm-debug.log* # Folders +.cache/ node_modules/ dist/ # Editor directories and files .vscode/ .idea/ +.claude/ .DS_Store *.suo *.ntvs* diff --git a/README.md b/README.md index 1ab3719..d7ede97 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Nabla Core API Sample App -![Sample app screenshot](static/sample_app_screenshot.png) - -A minimal web app (in `app/`) that shows how to interact with the [Nabla Core API](https://docs.nabla.com). +An example integration for the [Nabla Core API](https://docs.nabla.com), showing an +end-to-end medical encounter: stream audio for live transcription, generate a +clinical note, then derive normalized FHIR data and patient instructions from it. --- @@ -10,74 +10,56 @@ A minimal web app (in `app/`) that shows how to interact with the [Nabla Core AP ### 0. Prerequisites -- Node.js v22+ +- Node.js v24+ - A Nabla Core API account ([contact us](mailto:api@nabla.com) to create one) -### 1. Download and setup the project +### 1. Clone, install, and run the Sample App ```bash git clone git@github.com:nabla/sample-app.git -cd sample-app/ +cd sample-app npm install +npm run dev ``` -### 2. Create an OAuth client +### 2. Explore the app -- Sign in to the Core API admin console: [Log in](https://pro.nabla.com/login). -- Follow the [documentation](https://docs.nabla.com/guides/authentication#1-creating-an-oauth-client) to create a new OAuth Client with the "Public Key (static)" method. +If it didn't open automatically, navigate to http://localhost:5173/onboarding.html and +follow the configuration steps. -### 3. Generate user tokens +The home page links the **Full Encounter Demo** and a set of **in-depth guides** that showcase +individual endpoints with live WebSocket message inspection and annotated code. -You need to use this OAuth client to generate initial user access and refresh tokens for the app. In a realistic architecture, this work would be done by a dedicated authentication backend server on your side. For simplicity's sake, however, we provide an helper node script that imitates a backend server that would create and authenticate a Core API user. +The backend stores the keypair, config, and tokens under `.cache/`. To start over, delete that +folder. -This script is located under `scripts/generate-tokens.js` and expects the following CLI required arguments: -* `--uuid` (type:`string`): the OAuth client UUID for authentication -* `--private-key` (type:`string`): the path to the private key file (generally `private_key.pem` if you followed the documentation closely at the previous step) -* `--hostname` (type:`string`): Nabla's API hostname: `us.api.nabla.com` for US region or `eu.api.nabla.com` for EU region. -Run the following command to generate a pair of user access/refresh tokens: +### 3. Explore the code -```bash -node scripts/generate-tokens.js \ - --uuid= \ - --private-key= \ - --hostname=us.api.nabla.com -``` +The project is split into two parts: -> ℹ️ **Need a server token instead?** -> Pass the `--type=server` argument to the command above to generate a long-lived **server access token** rather than user access/refresh tokens. Use this when calling the Server API directly from your own tools. +| Folder | What it is | +|--------|------------| +| `frontend/` | The reference UI — Vite + TypeScript + Tailwind. This is the code you're meant to read. | +| `backend/` | A tiny Express server that stands in for **your** auth backend: it holds the OAuth client key, mints tokens, and provisions a user. | -### 4. Configure the frontend +The backend exists so the sample is realistic: in production, token minting and user +provisioning belong on a server you control, not in the browser. You can read it in a few +minutes (`backend/src/auth.ts`). -To launch the app, the following environment variables need to be set: -- `VITE_NABLA_ACCESS_TOKEN`: a user access token -- `VITE_NABLA_REFRESH_TOKEN`: a user refresh token -- `VITE_NABLA_API_HOSTNAME`: Nabla's API hostname: `us.api.nabla.com` for US region or `eu.api.nabla.com` for EU region. - -Create a `.env.local` file at the root of the project, and add the credentials generated in **Step 3** (or any other source you use): - -```env -VITE_NABLA_ACCESS_TOKEN=my_user_access_token -VITE_NABLA_REFRESH_TOKEN=my_user_refresh_token -VITE_NABLA_API_HOSTNAME=us.api.nabla.com -``` +To explore the codebase, start with `frontend/src/pages/full-encounter-demo/encounter.ts` — +the entrypoint to the full encounter demo. It's a thin orchestrator that wires the per-step +modules (`setup`, `record`, `work-on-note`) together; each step keeps its controller and DOM +rendering side by side. -### 5. Launch the app +For focused, single-endpoint walkthroughs — with live WebSocket message inspection and +annotated code snippets — see the in-depth pages under `frontend/src/pages/in-depth/`. -Run the following command and navigate to http://localhost:5173/ +## API version -```bash -npm run dev -``` - -> [!NOTE] -> **API version notice** -> -> Please note that this sample app is only compatible with a specific version of the API, specified at the beginning of the [commonUtils.js](app/shared/commonUtils.js) file. - ---- +This sample targets a specific API version, pinned in `frontend/src/api/version.ts` and +`backend/src/version.ts`. ## Further reading -- **Authentication guide:** -- **Full API docs:** +**API docs:** diff --git a/app/ambient-encounter-demo/demo.js b/app/ambient-encounter-demo/demo.js deleted file mode 100644 index b8139b6..0000000 --- a/app/ambient-encounter-demo/demo.js +++ /dev/null @@ -1,547 +0,0 @@ -// Ambient encounter demo implementation -import { BufferedAudioStream } from '../shared/bufferAudioStream.js'; -import { getOrRefetchUserAccessToken, CORE_API_BASE_URL } from '../shared/authentication.js'; -import { - disableElementById, - enableElementById, - startThinking, - stopThinking, - endConnection, - initializeMediaStream, - stopAudio, - insertElementByStartOffset, - msToTime, - sleep, - API_VERSION -} from '../shared/commonUtils.js'; - -let generatedNote = undefined; -let websocket; -let bufferAudioStream; -let transcriptItems = {}; -let transcriptSeqId = 0; -let noteSectionsCustomization = {}; - -// Template section mapping -const templateSectionsMap = { - "GENERIC_MULTIPLE_SECTIONS": [ - "CHIEF_COMPLAINT", - "HISTORY_OF_PRESENT_ILLNESS", - "PAST_MEDICAL_HISTORY", - "PAST_SURGICAL_HISTORY", - "PAST_OBSTETRIC_HISTORY", - "FAMILY_HISTORY", - "SOCIAL_HISTORY", - "ALLERGIES", - "CURRENT_MEDICATIONS", - "IMMUNIZATIONS", - "VITALS", - "LAB_RESULTS", - "IMAGING_RESULTS", - "PHYSICAL_EXAM", - "ASSESSMENT", - "PLAN", - "PRESCRIPTION", - "APPOINTMENTS" - ], - "GENERIC_SOAP": [ - "SUBJECTIVE", - "OBJECTIVE", - "ASSESSMENT", - "PLAN" - ], -}; - -// UI Utilities -const clearTranscript = () => { - document.getElementById("transcript").innerHTML = "

Transcript:

"; -}; - -const clearNoteContent = () => { - document.getElementById("note").innerHTML = "

Note:

"; -}; - -const clearPatientInstructions = () => { - document.getElementById("patient-instructions").innerHTML = "

Patient instructions:

"; -}; - -const clearNormalizedData = () => { - document.getElementById("normalized-data").innerHTML = "

Normalized data:

"; -}; - -const disableAll = () => { - disableElementById("start-btn"); - disableElementById("generate-btn"); - disableElementById("normalize-btn"); - disableElementById("patient-instructions-btn"); -}; - -const enableAll = () => { - enableElementById("start-btn"); - enableElementById("generate-btn"); - enableElementById("normalize-btn"); - enableElementById("patient-instructions-btn"); -}; - -// Transcript handling -const insertTranscriptItem = (data) => { - transcriptItems[data.id] = data.text; - const transcriptContent = - `[${msToTime(data.start_offset_ms)} to ${msToTime(data.end_offset_ms)}]: ${data.text}`; - const transcriptContainer = document.getElementById("transcript"); - let transcriptItem = document.getElementById(data.id); - if (!transcriptItem) { - transcriptItem = document.createElement("div"); - transcriptItem.setAttribute("id", data.id); - transcriptItem.setAttribute("data-start-offset", data.start_offset_ms); - insertElementByStartOffset(transcriptItem, transcriptContainer); - } - transcriptItem.innerHTML = transcriptContent; - if (data.is_final) { - transcriptItem.classList.remove("temporary-item"); - } else if (!transcriptItem.classList.contains("temporary-item")) { - transcriptItem.classList.add("temporary-item"); - } -}; - -// WebSocket connection for transcript -const initializeTranscriptConnection = async () => { - // Get valid token for connection - const bearerToken = await getOrRefetchUserAccessToken(); - - // Initialize websocket connection - websocket = new WebSocket( - `wss://${CORE_API_BASE_URL}/user/transcribe-ws?nabla-api-version=${API_VERSION}`, - ["transcribe-protocol", "jwt-" + bearerToken], - ); - - websocket.onclose = (e) => { - console.log(`Websocket closed: ${e.code} ${e.reason}`); - }; - - websocket.onmessage = (mes) => { - if (websocket.readyState !== WebSocket.OPEN) return; - if (typeof mes.data === "string") { - const data = JSON.parse(mes.data); - - if (data.type === "AUDIO_CHUNK_ACK") { - bufferAudioStream.handlePacketAck(data); - } else if (data.type === "TRANSCRIPT_ITEM") { - insertTranscriptItem(data); - } else if (data.type === "ERROR_MESSAGE") { - console.error(data.message); - } - } - }; -}; - -// Form field getters -const getFirstTranscriptLocale = () => ( - document.getElementById("first-transcript-locale")?.selectedOptions[0]?.value ?? "ENGLISH_US" -); - -const getSecondTranscriptLocale = () => ( - document.getElementById("second-transcript-locale")?.selectedOptions[0]?.value ?? "SPANISH_ES" -); - -const getNoteTemplate = () => ( - document.getElementById("note-template")?.selectedOptions[0]?.value ?? "GENERIC_MULTIPLE_SECTIONS" -); - -const getPatientContext = () => ( - document.getElementById("patient-context")?.value -); - -const getNoteLanguage = () => ( - document.getElementById("note-locale")?.selectedOptions[0]?.value ?? "ENGLISH_US" -); - -// Recording functionality -const startRecording = async () => { - if (getFirstTranscriptLocale() === getSecondTranscriptLocale()) { - const errorMessage = document.createElement("p"); - errorMessage.classList.add("error"); - errorMessage.innerText = "First and second transcript locales must be different."; - document.getElementById("transcript").appendChild(errorMessage); - return; - } - - clearTranscript(); - enableElementById("generate-btn"); - - transcriptSeqId = 0; - await initializeTranscriptConnection(); - - // Await websocket being open - for (let i = 0; i < 10; i++) { - if (websocket.readyState !== WebSocket.OPEN) { - await sleep(100); - } else { - break; - } - } - - if (websocket.readyState !== WebSocket.OPEN) { - throw new Error("Websocket did not open"); - } - - if (!navigator.mediaDevices?.getUserMedia) { - console.error("Microphone audio stream is not accessible on this browser"); - return; - } - - bufferAudioStream = new BufferedAudioStream({ - serializeAudioPacket: (data) => JSON.stringify(data), - websocket, - }); - - const pcmWorker = await initializeMediaStream((audioAsBase64String) => { - const audioPacket = { - type: "AUDIO_CHUNK", - payload: audioAsBase64String, - stream_id: "stream1", - seq_id: transcriptSeqId++, - }; - bufferAudioStream.sendAndBuffer(audioPacket); - }); - - const configPacket = { - type: "CONFIG", - encoding: "PCM_S16LE", - sample_rate: 16000, - speech_locales: [getFirstTranscriptLocale(), getSecondTranscriptLocale()], - streams: [ - { id: "stream1", speaker_type: "unspecified" }, - ], - enable_audio_chunk_ack: true, - }; - websocket.send(JSON.stringify(configPacket)); - - pcmWorker.port.start(); -}; - -// Note generation -const generateNote = async () => { - if (Object.keys(transcriptItems).length === 0) return; - - disableAll(); - - stopAudio(); - await endConnection(websocket, { type: "END" }); - - clearNoteContent(); - await digest(); - - enableAll(); -}; - -const digest = async () => { - startThinking(document.getElementById("note")); - const bearerToken = await getOrRefetchUserAccessToken(); - - const noteTemplate = getNoteTemplate() - const noteSettingsResponse = await fetch(`https://${CORE_API_BASE_URL}/user/note-settings`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${bearerToken}`, - 'X-Nabla-Api-Version': API_VERSION - }, - body: JSON.stringify({ - note_template: noteTemplate, - note_locale: getNoteLanguage(), - }) - }); - if (!noteSettingsResponse.ok) { - await displayNoteGenerationQueryError(noteSettingsResponse); - return; - } - - const noteSectionsCustomizationArray = Object.entries(noteSectionsCustomization).map( - ([sectionKey, customizationOptions]) => ({ - section_key: sectionKey, - ...customizationOptions - }) - ); - const noteSectionCustomizationResponse = await fetch(`https://${CORE_API_BASE_URL}/user/note-settings/note-sections-customization/${noteTemplate}`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${bearerToken}`, - 'X-Nabla-Api-Version': API_VERSION - }, - body: JSON.stringify({ - note_sections_customization: noteSectionsCustomizationArray - }) - }); - if (!noteSectionCustomizationResponse.ok) { - await displayNoteGenerationQueryError(noteSectionCustomizationResponse); - return; - } - - const noteGenerationResponse = await fetch(`https://${CORE_API_BASE_URL}/user/generate-note`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${bearerToken}`, - 'X-Nabla-Api-Version': API_VERSION - }, - body: JSON.stringify({ - patient_context: getPatientContext(), - transcript_items: Object.values(transcriptItems).map((it) => ({ text: it, speaker_type: "unspecified" })), - }) - }); - if (!noteGenerationResponse.ok) { - await displayNoteGenerationQueryError(noteGenerationResponse); - return; - } - - const note = document.getElementById("note"); - stopThinking(note); - const data = await noteGenerationResponse.json(); - generatedNote = data.note; - - data.note.sections.forEach((section) => { - const title = document.createElement("h4"); - title.innerHTML = section.title; - const text = document.createElement("p"); - text.innerHTML = section.text; - note.appendChild(title); - note.appendChild(text); - }); -}; - -async function displayNoteGenerationQueryError(queryResponse) { - const note = document.getElementById("note"); - stopThinking(note); - - console.error('Error during one of the note generation queries:', queryResponse.status); - const errData = await queryResponse.json(); - const errText = document.createElement("p"); - errText.classList.add("error"); - errText.innerHTML = errData.message; - note.appendChild(errText); -} - -// Generate normalized data -const generateNormalizedData = async () => { - if (!generatedNote) return; - - disableAll(); - clearNormalizedData(); - const normalizationContainer = document.getElementById("normalized-data"); - startThinking(normalizationContainer); - - const note_locale = getNoteLanguage(); - if (!["FRENCH_FR", "ENGLISH_US", "ENGLISH_UK"].includes(note_locale)) { - const errorMessage = document.createElement("p"); - errorMessage.classList.add("error"); - errorMessage.innerText = "Normalized data are only available for note with locale FRENCH_FR, ENGLISH_US, ENGLISH_UK"; - normalizationContainer.appendChild(errorMessage); - return; - } - - const bearerToken = await getOrRefetchUserAccessToken(); - const response = await fetch(`https://${CORE_API_BASE_URL}/user/generate-normalized-data`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${bearerToken}`, - 'X-Nabla-Api-Version': API_VERSION - }, - body: JSON.stringify({ - note: generatedNote, - note_template: getNoteTemplate(), - note_locale - }) - }); - - stopThinking(normalizationContainer); - - if (!response.ok) { - console.error('Error during normalized data generation:', response.status); - const errData = await response.json(); - const errText = document.createElement("p"); - errText.classList.add("error"); - errText.innerHTML = errData.message; - normalizationContainer.appendChild(errText); - return; - } - - const data = await response.json(); - - const conditionTitle = document.createElement("h4"); - conditionTitle.innerHTML = "Conditions:"; - normalizationContainer.appendChild(conditionTitle); - - addConditions(data.conditions, normalizationContainer); - - const familyHistoryTitle = document.createElement("h4"); - familyHistoryTitle.innerHTML = "Family history:"; - normalizationContainer.appendChild(familyHistoryTitle); - - const historyList = document.createElement("ul"); - data.family_history.forEach((member) => { - const memberListItem = document.createElement("li"); - const relationship = document.createElement("span"); - relationship.innerText = member.relationship; - memberListItem.appendChild(relationship); - addConditions(member.conditions, memberListItem); - historyList.appendChild(memberListItem); - }); - normalizationContainer.appendChild(historyList); - - enableAll(); -}; - -const addConditions = (conditions, parent) => { - const conditionsList = document.createElement("ul"); - conditions.forEach((condition) => { - const element = document.createElement("li"); - element.innerHTML = `${condition.coding.display.toUpperCase()} (${condition.coding.code})
Clinical status: ${condition.clinical_status}
`; - if (condition.categories.length > 0) { - element.innerHTML += "Categories: [" + condition.categories.join() + "]"; - } - conditionsList.appendChild(element); - }); - parent.appendChild(conditionsList); -}; - -// Generate patient instructions -const generatePatientInstructions = async () => { - if (!generatedNote) return; - - clearPatientInstructions(); - disableAll(); - const patientInstructions = document.getElementById("patient-instructions"); - startThinking(patientInstructions); - - const bearerToken = await getOrRefetchUserAccessToken(); - const response = await fetch(`https://${CORE_API_BASE_URL}/user/generate-patient-instructions`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${bearerToken}`, - 'X-Nabla-Api-Version': API_VERSION - }, - body: JSON.stringify({ - note: generatedNote, - note_locale: "ENGLISH_US", - note_template: getNoteTemplate(), - instructions_locale: "ENGLISH_US", - consultation_type: "IN_PERSON" - }) - }); - - if (!response.ok) { - console.error('Error during patient instructions generation:', response.status); - } - - const data = await response.json(); - - stopThinking(patientInstructions); - const instructionsTitle = document.createElement("h4"); - instructionsTitle.innerHTML = "Instructions: "; - patientInstructions.appendChild(instructionsTitle); - - const text = document.createElement("p"); - text.innerHTML = data.instructions; - patientInstructions.appendChild(text); - enableAll(); -}; - -// Clear all data -const clearEncounter = async () => { - disableElementById("start-btn"); - disableAll(); - stopAudio(); - await endConnection(websocket, { type: "END" }); - clearNoteContent(); - clearNormalizedData(); - clearPatientInstructions(); - clearTranscript(); - enableElementById("start-btn"); - enableAll(); -}; - -// Note customization -const updateSectionsList = () => { - const template = getNoteTemplate(); - const selectElement = document.getElementById("note-sections"); - selectElement.innerHTML = ""; - - const sections = templateSectionsMap[template] || []; - sections.forEach((sectionKey) => { - const opt = document.createElement("option"); - opt.value = sectionKey; - opt.innerText = sectionKey; - selectElement.appendChild(opt); - }); - - selectElement.value = sections[0] || ""; - onSectionToCustomizeChange(); -}; - -const onTemplateChange = () => { - noteSectionsCustomization = {}; - updateSectionsList(); -}; - -const onSectionToCustomizeChange = () => { - const selected = document.getElementById("note-sections").value; - if (!selected) { - document.getElementById("section-customization-fields").style.display = "none"; - return; - } - - document.getElementById("section-customization-fields").style.display = "inline-block"; - const existing = noteSectionsCustomization[selected] || {}; - document.getElementById("style-select").value = existing.style || "AUTO"; - document.getElementById("custom-instruction").value = existing.custom_instruction || ""; -}; - -const onSectionStyleChange = () => { - const sectionKey = document.getElementById("note-sections").value; - if (!sectionKey) return; - - const styleValue = document.getElementById("style-select").value; - - const customizationOptions = noteSectionsCustomization[sectionKey] ?? {}; - customizationOptions.style = styleValue; - noteSectionsCustomization[sectionKey] = customizationOptions; -}; - -const onSectionCustomInstructionChange = () => { - const sectionKey = document.getElementById("note-sections").value; - if (!sectionKey) return; - - const customInstructionValue = document.getElementById("custom-instruction").value; - - const customizationOptions = noteSectionsCustomization[sectionKey] ?? {}; - customizationOptions.custom_instruction = customInstructionValue; - noteSectionsCustomization[sectionKey] = customizationOptions; -}; - -// Initialize the application -const initApp = () => { - // Initial call to display an error message directly if the refresh token is expired - getOrRefetchUserAccessToken(); - - // Set up event listeners - document.getElementById("start-btn").addEventListener("click", startRecording); - document.getElementById("generate-btn").addEventListener("click", generateNote); - document.getElementById("normalize-btn").addEventListener("click", generateNormalizedData); - document.getElementById("patient-instructions-btn").addEventListener("click", generatePatientInstructions); - document.getElementById("clear-btn").addEventListener("click", clearEncounter); - - document.getElementById("note-template").addEventListener("change", onTemplateChange); - document.getElementById("note-sections").addEventListener("change", onSectionToCustomizeChange); - document.getElementById("style-select").addEventListener("change", onSectionStyleChange); - document.getElementById("custom-instruction").addEventListener("input", onSectionCustomInstructionChange); - - // Initialize section customization - updateSectionsList(); -}; - -// Start the application when DOM is fully loaded -document.addEventListener("DOMContentLoaded", initApp); diff --git a/app/ambient-encounter-demo/index.html b/app/ambient-encounter-demo/index.html deleted file mode 100644 index b2c2a8f..0000000 --- a/app/ambient-encounter-demo/index.html +++ /dev/null @@ -1,168 +0,0 @@ - - - - Nabla Ambient Encounter Demo - - - - - - -
-

Nabla Ambient Encounter Demo

- ← Back to demo selection - -
- -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
-
- - -
- -
- - -
-
- -
- -
- - - - - -
- -
-

Transcript:

-
-
-

Note:

-
-
-

Normalized data:

-
-
-

Patient instructions:

-
-
- - diff --git a/app/ambient-encounter-demo/style.css b/app/ambient-encounter-demo/style.css deleted file mode 100644 index a7bcc37..0000000 --- a/app/ambient-encounter-demo/style.css +++ /dev/null @@ -1,22 +0,0 @@ -/* Ambient encounter specific styles */ - -/* Section customization */ -#section-customization-fields { - display: none; - border: 2px solid #ccc; - padding: 8px; -} - -#section-customization-fields .filters { - margin: 8px 0; -} - -#custom-instruction { - width: 200px; - height: 60px; -} - -#patient-context { - width: 400px; - height: 80px; -} diff --git a/app/dictated-note-demo/demo.js b/app/dictated-note-demo/demo.js deleted file mode 100644 index 10386bf..0000000 --- a/app/dictated-note-demo/demo.js +++ /dev/null @@ -1,134 +0,0 @@ -// Dictated note demo implementation -import { BufferedAudioStream } from '../shared/bufferAudioStream.js'; -import { getOrRefetchUserAccessToken, CORE_API_BASE_URL } from '../shared/authentication.js'; -import { - disableElementById, - enableElementById, - endConnection, - initializeMediaStream, - stopAudio, - sleep, - API_VERSION -} from '../shared/commonUtils.js'; - -let websocket; -let bufferAudioStream; - -// Dictation handling -const insertDictatedItem = (data) => { - let dictatedItem = document.createElement("span"); - dictatedItem.innerHTML = data.text; - document.getElementById("dictated-note").appendChild(dictatedItem); -}; - -// WebSocket connection for dictation -const initializeDictationConnection = async () => { - const bearerToken = await getOrRefetchUserAccessToken(); - websocket = new WebSocket( - `wss://${CORE_API_BASE_URL}/user/dictate-ws?nabla-api-version=${API_VERSION}`, - ["dictate-protocol", "jwt-" + bearerToken] - ); - - websocket.onclose = (e) => { - console.log(`Websocket closed: ${e.code} ${e.reason}`); - }; - - websocket.onmessage = (mes) => { - if (websocket.readyState !== WebSocket.OPEN) { - console.log("ws not open"); - return; - } - if (typeof mes.data === "string") { - const data = JSON.parse(mes.data); - - if (data.type === "AUDIO_CHUNK_ACK") { - bufferAudioStream.handlePacketAck(data); - } else if (data.type === "DICTATED_TEXT") { - insertDictatedItem(data); - } else if (data.type === "ERROR_MESSAGE") { - console.error(data.message); - } - } - }; -}; - -// Form field getters -const getDictationLocale = () => { - const dictationLocaleSelect = document.getElementById("dictationLocale"); - return dictationLocaleSelect.selectedOptions && dictationLocaleSelect.selectedOptions.length > 0 - ? dictationLocaleSelect.selectedOptions[0].value - : "ENGLISH_US"; -}; - -// Dictation controls -const startDictating = async () => { - disableElementById("dictate-btn"); - enableElementById("pause-btn"); - - await initializeDictationConnection(); - - // Await websocket being open - for (let i = 0; i < 10; i++) { - if (websocket.readyState !== WebSocket.OPEN) { - await sleep(100); - } else { - break; - } - } - if (websocket.readyState !== WebSocket.OPEN) { - throw new Error("Websocket did not open"); - } - - if (!navigator.mediaDevices?.getUserMedia) { - console.error("Microphone audio stream is not accessible on this browser"); - return; - } - - bufferAudioStream = new BufferedAudioStream({ - serializeAudioPacket: (data) => JSON.stringify(data), - websocket, - }); - - let dictateSeqId = 0; - - const pcmWorker = await initializeMediaStream((audioAsBase64String) => { - const audioPacket = { - type: "AUDIO_CHUNK", - payload: audioAsBase64String, - seq_id: dictateSeqId++, - }; - bufferAudioStream.sendAndBuffer(audioPacket); - }); - - const locale = getDictationLocale(); - const configPacket = { - type: "CONFIG", - encoding: "PCM_S16LE", - sample_rate: 16000, - dictation_locale: locale, - punctuation_mode: "EXPLICIT", - }; - websocket.send(JSON.stringify(configPacket)); - - pcmWorker.port.start(); -}; - -const pauseDictating = async () => { - disableElementById("pause-btn"); - stopAudio(); - await endConnection(websocket, { type: "END" }); - enableElementById("dictate-btn"); -}; - -// Initialize the application -const initApp = () => { - // Initial call to display an error message directly if the refresh token is expired - getOrRefetchUserAccessToken(); - - // Set up event listeners - document.getElementById("dictate-btn").addEventListener("click", startDictating); - document.getElementById("pause-btn").addEventListener("click", pauseDictating); -}; - -// Start the application when DOM is fully loaded -document.addEventListener("DOMContentLoaded", initApp); diff --git a/app/dictated-note-demo/index.html b/app/dictated-note-demo/index.html deleted file mode 100644 index c588fa5..0000000 --- a/app/dictated-note-demo/index.html +++ /dev/null @@ -1,42 +0,0 @@ - - - - Nabla Dictated Note Demo - - - - - - -
-

Nabla Dictated Note Demo

- ← Back to demo selection - -
- -
- -
- - -
- -
- -
- - -
- -
-

Dictated note:

-
-
- - diff --git a/app/dictated-note-demo/style.css b/app/dictated-note-demo/style.css deleted file mode 100644 index 62a9fbe..0000000 --- a/app/dictated-note-demo/style.css +++ /dev/null @@ -1,5 +0,0 @@ -/* Dictated note specific styles */ - -#dictated-note { - min-height: 200px; -} diff --git a/app/shared/authentication.js b/app/shared/authentication.js deleted file mode 100644 index b40b4be..0000000 --- a/app/shared/authentication.js +++ /dev/null @@ -1,71 +0,0 @@ -import { API_VERSION } from "./commonUtils.js"; - -const INITIAL_USER_ACCESS_TOKEN = import.meta.env.VITE_NABLA_ACCESS_TOKEN; -const INITIAL_USER_REFRESH_TOKEN = import.meta.env.VITE_NABLA_REFRESH_TOKEN; -const CORE_API_HOSTNAME = import.meta.env.VITE_NABLA_API_HOSTNAME; - -let userAccessToken = INITIAL_USER_ACCESS_TOKEN; -let userRefreshToken = INITIAL_USER_REFRESH_TOKEN; - -const CORE_API_BASE_URL = `${CORE_API_HOSTNAME}/v1/core`; - -const showTokenError = (message) => { - const errorDiv = document.getElementById("token-error"); - if (!errorDiv) return; - errorDiv.innerHTML = message; - errorDiv.classList.remove("hide"); -}; - -const decodeJWT = (token) => { - const parts = token.split('.'); - if (parts.length !== 3) { - showTokenError("The user tokens seem invalid. You maybe forgot to provide initial tokens in the source code."); - throw new Error("Invalid JWT token"); - } - const payload = parts[1]; - return JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/'))); // replace URL-safe characters -}; - -const isTokenExpiredOrExpiringSoon = (token) => { - const nowSeconds = Math.floor(Date.now() / 1000); - return (decodeJWT(token).exp - nowSeconds) < 5; -}; - -const setUserTokens = (newAccessToken, newRefreshToken) => { - userAccessToken = newAccessToken; - userRefreshToken = newRefreshToken; -}; - -const getOrRefetchUserAccessToken = async () => { - if (!isTokenExpiredOrExpiringSoon(userAccessToken)) { - return userAccessToken; - } - - if (isTokenExpiredOrExpiringSoon(userRefreshToken)) { - showTokenError("Your user refresh token has expired. Please provide new initial tokens in the source code."); - throw new Error("Refresh token expired"); - } - - const refreshResponse = await fetch(`https://${CORE_API_BASE_URL}/user/jwt/refresh`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Nabla-Api-Version': API_VERSION - }, - body: JSON.stringify({ refresh_token: userRefreshToken }), - }); - - if (!refreshResponse.ok) { - showTokenError("The user access token refresh failed. Please try to provide new initial tokens in the source code."); - throw new Error(`Refresh call failed (status: ${refreshResponse.status})`); - } - - const data = await refreshResponse.json(); - setUserTokens(data.access_token, data.refresh_token); - return userAccessToken; -}; - -export { - CORE_API_BASE_URL, - getOrRefetchUserAccessToken -}; diff --git a/app/shared/bufferAudioStream.js b/app/shared/bufferAudioStream.js deleted file mode 100644 index 2178be0..0000000 --- a/app/shared/bufferAudioStream.js +++ /dev/null @@ -1,81 +0,0 @@ -/** - * The max number of packets sent to the server but not ACKed yet. - * - * Note: The number of in-flight packets is limited to 10 seconds of audio. Since - * is configured to send 192 ms of audio per packet, the max number of in-flight - * packets is 50. - */ -const MAX_IN_FLIGHT_PACKETS = 50; - -/** - * A class to buffer audio packets and send them to the server. - * - * It is used to make data transfer more resilient to network issues: - * https://docs.nabla.com/guides/best-practices/transcription-network-resilience - */ -export class BufferedAudioStream { - /** The websocket to send the packets to. */ - #websocket; - - /** The function to serialize the audio packet. */ - #serializeAudioPacket; - - /** The packets that are waiting to be sent. */ - #bufferedPackets = []; - - /** The packets that are being sent to the server but not acknowledged yet. */ - #inflightPackets = []; - - constructor({ serializeAudioPacket, websocket }) { - this.#websocket = websocket; - this.#serializeAudioPacket = serializeAudioPacket; - - // If the socket is not open, wait for it and flush the buffer. - if (this.#websocket.readyState !== WebSocket.OPEN) { - this.#websocket.addEventListener("open", () => { - this.#sendBufferedPacketsIfNeeded(); - }); - } - } - - /** - * Send a packet to the server or buffer to be sent later. - */ - sendAndBuffer(data) { - this.#bufferedPackets.push(data); - this.#sendBufferedPacketsIfNeeded(); - } - - /** - * Handle the acknowledgement of a packet by the server and send the buffered packets if - * needed. - */ - handlePacketAck(data) { - this.#inflightPackets = this.#inflightPackets.filter( - packet => packet.seq_id <= data.seq_id - ); - this.#sendBufferedPacketsIfNeeded(); - } - - #sendBufferedPacketsIfNeeded() { - // Do nothing if the socket is not open. - if (this.#websocket.readyState !== WebSocket.OPEN) { - return; - } - - // Send the buffered packets as long as there are less than the max number of - // in-flight packets. - while ( - this.#bufferedPackets.length > 0 && - this.#inflightPackets.length < MAX_IN_FLIGHT_PACKETS - ) { - // Remove the packet from the buffered packets and add it to the inflight packets. - const packet = this.#bufferedPackets.shift(); - this.#inflightPackets.push(packet); - - // Serialize the packet and send it to the server. - const serializedPacket = this.#serializeAudioPacket(packet); - this.#websocket.send(serializedPacket); - } - } -} diff --git a/app/shared/commonUtils.js b/app/shared/commonUtils.js deleted file mode 100644 index d4fed63..0000000 --- a/app/shared/commonUtils.js +++ /dev/null @@ -1,162 +0,0 @@ -// Common utilities for Nabla API demos - -// This is the target API version for all API calls. -// Check this page before upgrading: https://docs.nabla.com/guides/api-versioning/changelog-and-upgrades -const API_VERSION = "2025-05-21" - -let thinkingId; -let pcmWorker; -let audioContext; -let mediaSource; - -// Element manipulation utilities -const disableElementById = (elementId) => { - const element = document.getElementById(elementId); - if (!element || element.hasAttribute("disabled")) return; - element.setAttribute("disabled", "disabled"); -}; - -const enableElementById = (elementId) => { - const element = document.getElementById(elementId); - if (!element || !element.hasAttribute("disabled")) return; - element.removeAttribute("disabled"); -}; - -// UI helpers -const startThinking = (parent) => { - const thinking = document.createElement("div"); - thinking.setAttribute("id", "thinking"); - let count = 0; - thinkingId = setInterval(() => { - const dots = ".".repeat(count % 3 + 1); - thinking.innerHTML = `Thinking${dots} `; - count++; - }, 500); - parent.appendChild(thinking); -}; - -const stopThinking = (parent) => { - clearInterval(thinkingId); - if (!parent) return; - const thinking = document.getElementById("thinking"); - if (thinking) { - parent.removeChild(thinking); - } -}; - -// Websocket utils -const endConnection = async (websocket, endObject) => { - if (!websocket || websocket.readyState !== WebSocket.OPEN) return; - - websocket.send(JSON.stringify(endObject)); - - // Await server closing the WS - for (let i = 0; i < 50; i++) { - if (websocket.readyState === WebSocket.OPEN) { - await sleep(100); - } else { - break; - } - } -}; - -// Audio utilities -const initializeMediaStream = async (handleAudioChunk) => { - // Ask authorization to access the microphone - const mediaStream = await navigator.mediaDevices.getUserMedia({ - audio: { - deviceId: "default", - sampleRate: 16000, - sampleSize: 16, - channelCount: 1, - }, - video: false, - }); - - audioContext = new AudioContext({ sampleRate: 16000 }); - await audioContext.audioWorklet.addModule("../shared/rawPcm16Processor.js"); - - pcmWorker = new AudioWorkletNode(audioContext, "raw-pcm-16-worker", { - outputChannelCount: [1], - }); - mediaSource = audioContext.createMediaStreamSource(mediaStream); - mediaSource.connect(pcmWorker); - - // pcm post on message - pcmWorker.port.onmessage = ({ data }) => { - const audioAsBase64String = btoa( - String.fromCodePoint(...new Uint8Array(data.buffer)), - ); - - handleAudioChunk(audioAsBase64String); - }; - - return pcmWorker; -}; - -const stopAudio = () => { - try { - audioContext?.close(); - } catch (e) { - console.error("Error while closing AudioContext", e); - } - - try { - pcmWorker?.port.close(); - pcmWorker?.disconnect(); - } catch (e) { - console.error("Error while closing PCM worker", e); - } - - try { - mediaSource?.mediaStream.getTracks().forEach((track) => track.stop()); - mediaSource?.disconnect(); - } catch (e) { - console.error("Error while closing media stream", e); - } -}; - -// UI utils -const insertElementByStartOffset = (element, parentElement) => { - const elementStartOffset = element.getAttribute("data-start-offset"); - let elementBefore = null; - for (let childElement of parentElement.childNodes) { - const childStartOffset = - childElement.nodeName === element.nodeName && childElement.hasAttribute("data-start-offset") - ? childElement.getAttribute("data-start-offset") - : 0; - if (Number(childStartOffset) > Number(elementStartOffset)) { - elementBefore = childElement; - break; - } - } - if (elementBefore) { - parentElement.insertBefore(element, elementBefore); - } else { - parentElement.appendChild(element); - } -}; - -// Time formatting -const msToTime = (milli) => { - const seconds = Math.floor((milli / 1000) % 60); - const minutes = Math.floor((milli / (60 * 1000)) % 60); - return `${String(minutes).padStart(2, 0)}:${String(seconds).padStart(2, 0)}`; -}; - -// Promises -const sleep = (duration) => new Promise((r) => setTimeout(r, duration)); - -export { - API_VERSION, - disableElementById, - enableElementById, - startThinking, - stopThinking, - endConnection, - initializeMediaStream, - stopAudio, - insertElementByStartOffset, - msToTime, - sleep -}; diff --git a/app/shared/rawPcm16Processor.js b/app/shared/rawPcm16Processor.js deleted file mode 100644 index c30c71f..0000000 --- a/app/shared/rawPcm16Processor.js +++ /dev/null @@ -1,35 +0,0 @@ -// Nodes of the Web Audio API process the audio stream in frames of the length -// of 128 samples. Cf https://www.w3.org/TR/webaudio/#rendering-loop -// (and they call it a quantum, plural quanta) -const quantumSize = 128; - -// Number of quanta per packet we will send to the speech to text. -const quantaPerPacket = 24; // (1/16 kHz) * 128 * 24 = 192 ms - -export class RawPCM16Processor extends AudioWorkletProcessor { - constructor() { - super(); - this.accumulatedQuantaCount = 0; - this.paquet = new Int16Array(quantumSize * quantaPerPacket); - } - - - process(inputs, outputs, parameters) { - const offset = quantumSize * this.accumulatedQuantaCount; - const channels = inputs[0]; - if (channels.length > 0) { - channels[0].forEach( - (sample, idx) => - (this.paquet[offset + idx] = Math.floor(sample * 0x7fff)), - ); - this.accumulatedQuantaCount = this.accumulatedQuantaCount + 1; - if (this.accumulatedQuantaCount === quantaPerPacket) { - this.port.postMessage(this.paquet); - this.accumulatedQuantaCount = 0; - } - } - return true; - } -} - -registerProcessor("raw-pcm-16-worker", RawPCM16Processor); diff --git a/app/shared/sharedStyle.css b/app/shared/sharedStyle.css deleted file mode 100644 index 85e755f..0000000 --- a/app/shared/sharedStyle.css +++ /dev/null @@ -1,267 +0,0 @@ -/* Common styles for Nabla API demos */ - -body { - display: flex; - justify-content: center; - font-size: 14px; - font-family: Verdana, Geneva, Tahoma, sans-serif -} - -.container { - margin: 16px; - width: 960px; - overflow-y: auto; -} - -/* Demo selector specific styles */ - -.demo-grid { - display: flex; - flex-wrap: wrap; - gap: 20px; - margin-top: 20px; - justify-content: center; -} - -.demo-card { - flex: 0 0 calc(50% - 20px); - max-width: 340px; - min-width: 280px; - border: 1px solid #ddd; - border-radius: 8px; - padding: 25px 20px; - text-align: center; - transition: all 0.3s ease; - background-color: #fff; - display: flex; - flex-direction: column; - justify-content: space-between; - height: auto; -} - -.demo-card:hover { - border-color: #2196F3; -} - -.demo-card h2 { - margin-top: 0; - color: #333; - font-size: 20px; - margin-bottom: 15px; -} - -.demo-card p { - color: #666; - margin-bottom: 25px; - font-size: 14px; - line-height: 1.5; - flex-grow: 1; -} - -.demo-btn { - display: inline-block; - background-color: #2196F3; - color: white !important; - padding: 10px 20px; - text-decoration: none; - border-radius: 4px; - font-weight: bold; - transition: background-color 0.2s; -} - -.demo-btn:hover { - background-color: #0b7dda; -} - -.note-section { - margin-top: 40px; - padding: 15px; - background-color: #e8f4fe; - border-left: 4px solid #2196F3; - border-radius: 4px; -} - -/* For smaller screens */ -@media (max-width: 768px) { - .demo-card { - flex: 0 0 100%; - max-width: 100%; - } - - .container { - padding: 15px; - margin: 10px; - } -} - -/* Common elements */ - -.results { - border: 1px black dashed; - padding: 8px; - margin-top: 16px; - background-color: lightgray; -} - -.results>h3 { - margin: 8px 0px; -} - -.results>span { - white-space: pre-wrap; -} - -.results>p { - white-space: pre-wrap; -} - -.filters { - margin: 16px 0px; - display: flex; - flex-direction: row; - align-items: start; -} - -.filters>label { - padding: 8px 0px; - margin-right: 8px; -} - -li { - list-style: disc; - margin-bottom: 8px; -} - -button { - padding: 8px; -} - -select { - margin-right: 8px; - padding: 8px; -} - -.temporary-item { - color: #606060; -} - -.error { - color: red; -} - -.active { - font-weight: bold; -} - -a { - text-decoration: none; -} - -a:visited { - color: blue; -} - -/* Switch: box around the slider */ -.switch { - padding: 0px !important; - position: relative; - display: inline-block; - width: 40px; - height: 22px; -} - -/* Hide default HTML checkbox */ -.switch input { - opacity: 0; - width: 0; - height: 0; -} - -/* Slider */ -.slider { - position: absolute; - cursor: pointer; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: #ccc; - -webkit-transition: .4s; - transition: .4s; -} - -.slider:before { - position: absolute; - content: ""; - height: 18px; - width: 18px; - left: 2px; - bottom: 2px; - background-color: white; - -webkit-transition: .4s; - transition: .4s; -} - -input:checked+.slider { - background-color: #2196F3; -} - -input:focus+.slider { - box-shadow: 0 0 1px #2196F3; -} - -input:checked+.slider:before { - -webkit-transform: translateX(18px); - -ms-transform: translateX(18px); - transform: translateX(18px); -} - -/* Rounded slider */ -.slider.round { - border-radius: 26px; -} - -.slider.round:before { - border-radius: 50%; -} - -.hide { - display: none; -} - -/* Section customization */ - -#section-customization-fields { - display: none; - border: 2px solid #ccc; - padding: 8px; -} - -#section-customization-fields .filters { - margin: 8px 0; -} - -#custom-instruction { - width: 200px; - height: 60px; -} - -#token-error { - background-color: #f8d7da; /* Light red background */ - color: #721c24; /* Dark red text */ - border: 1px solid #f5c6cb; /* Border matching the background */ - padding: 10px 20px; - margin: 10px; - border-radius: 4px; - font-weight: bold; -} - -.hide { - display: none; -} - -.back-link { - display: block; - margin: 10px 0; - font-size: 14px; -} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..a09ef66 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,17 @@ +{ + "name": "nabla-sample-app-backend", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "node --watch src/index.ts" + }, + "dependencies": { + "express": "^4.18.2", + "jose": "^5.9.6" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^22.0.0", + "typescript": "^5.5.0" + } +} diff --git a/backend/src/auth.ts b/backend/src/auth.ts new file mode 100644 index 0000000..052dbdf --- /dev/null +++ b/backend/src/auth.ts @@ -0,0 +1,174 @@ +import { Router } from 'express'; +import { importPKCS8, SignJWT, generateKeyPair, exportPKCS8, exportSPKI } from 'jose'; +import { loadConfig, saveConfig, loadTokens, saveTokens, clearTokens, loadKeypair, saveKeypair, apiBaseUrl, expiresSoon, type Config } from './store.ts'; +import { API_VERSION } from './version.ts'; + +export const authRouter = Router(); + +// Obtain a valid server token (client_credentials), using the cache when fresh. +// SECURITY: the value this returns is the server token and must NEVER be sent in +// an HTTP response to the frontend. +async function getServerToken(): Promise { + const config = loadConfig(); + if (!config) { + throw new Error('Not configured — POST /api/configure first'); + } + + const keypair = loadKeypair(); + if (!keypair) { + throw new Error('No keypair — POST /api/generate-keypair first'); + } + + const cached = loadTokens(); + if (cached.serverToken && !expiresSoon(cached.serverTokenExpiresAt)) { + return cached.serverToken; + } + + const privateKey = await importPKCS8(keypair.privateKeyPem, 'RS256'); + const oauthUrl = `${apiBaseUrl(config.host)}/v1/core/server/oauth/token`; + const nowSeconds = Math.floor(Date.now() / 1000); + const clientAssertion = await new SignJWT({}) + .setProtectedHeader({ alg: 'RS256' }) + .setSubject(config.clientUuid) + .setIssuer(config.clientUuid) + .setAudience(oauthUrl) + .setExpirationTime(nowSeconds + 300) + .setIssuedAt(nowSeconds) + .setJti(crypto.randomUUID()) + .sign(privateKey); + + const response = await fetch(oauthUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'client_credentials', + client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', + client_assertion: clientAssertion, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText); + } + + const tokenResponse = await response.json() as { access_token: string; expires_in: number }; + const serverTokenExpiresAt = nowSeconds + tokenResponse.expires_in; + saveTokens({ serverToken: tokenResponse.access_token, serverTokenExpiresAt }); + return tokenResponse.access_token; +} + +authRouter.post('/generate-keypair', async (_request, response) => { + const { privateKey, publicKey } = await generateKeyPair('RS256', { modulusLength: 2048 }); + const privateKeyPem = await exportPKCS8(privateKey); + const publicKeyPem = await exportSPKI(publicKey); + saveKeypair({ privateKeyPem, publicKeyPem }); + response.json({ publicKeyPem }); +}); + +authRouter.get('/keypair', (_request, response) => { + const keypair = loadKeypair(); + if (!keypair) { + response.status(404).json({ error: 'No keypair — POST /api/generate-keypair first' }); + return; + } + response.json({ publicKeyPem: keypair.publicKeyPem }); +}); + +authRouter.post('/configure', (request, response) => { + const { clientUuid, host } = request.body as Config; + if (!clientUuid || !host) { + response.status(400).json({ error: 'Missing required fields: clientUuid, host' }); + return; + } + if (!loadKeypair()) { + response.status(400).json({ error: 'No keypair — POST /api/generate-keypair first' }); + return; + } + saveConfig({ clientUuid, host }); + // Config changed (possibly a new host/client): drop the cached server token so the + // next call re-mints it against the new config instead of reusing a stale one. + clearTokens(); + response.json({ ok: true }); +}); + +authRouter.post('/server-token', async (_request, response) => { + const config = loadConfig(); + const keypair = loadKeypair(); + if (!config || !keypair) { + const errorMessage = !config + ? 'Not configured — POST /api/configure first' + : 'No keypair — POST /api/generate-keypair first'; + response.status(400).json({ error: errorMessage }); + return; + } + + try { + await getServerToken(); + // Read back the (possibly cached) expiry — the token itself is NEVER returned. + const { serverTokenExpiresAt } = loadTokens(); + response.json({ ok: true, expiresAt: serverTokenExpiresAt }); + } catch (error) { + response.status(500).json({ error: String(error) }); + } +}); + +authRouter.post('/provision-user', async (_request, response) => { + let config = loadConfig(); + if (!config) { + response.status(400).json({ error: 'Not configured' }); + return; + } + + let serverToken: string; + try { + serverToken = await getServerToken(); + } catch (error) { + response.status(500).json({ error: String(error) }); + return; + } + + const headers = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${serverToken}`, + 'X-Nabla-Api-Version': API_VERSION, + }; + + try { + let nablaUserId = config.nablaUserId; + + if (!nablaUserId) { + const createUserResponse = await fetch(`${apiBaseUrl(config.host)}/v1/core/server/users`, { + method: 'POST', + headers, + body: JSON.stringify({}), + }); + if (!createUserResponse.ok) { + const errorText = await createUserResponse.text(); + response.status(createUserResponse.status).json({ error: errorText }); + return; + } + const createdUser = await createUserResponse.json() as { id: string }; + nablaUserId = createdUser.id; + config = { ...config, nablaUserId }; + saveConfig(config); + } + + const authenticateResponse = await fetch( + `${apiBaseUrl(config.host)}/v1/core/server/jwt/authenticate/${encodeURIComponent(nablaUserId)}`, + { method: 'POST', headers } + ); + + if (!authenticateResponse.ok) { + const errorText = await authenticateResponse.text(); + response.status(authenticateResponse.status).json({ error: errorText }); + return; + } + + // These user tokens are SUPPOSED to go to the frontend; it now owns refresh. + const userTokens = await authenticateResponse.json() as { access_token: string; refresh_token: string }; + response.json({ access_token: userTokens.access_token, refresh_token: userTokens.refresh_token, nabla_user_id: nablaUserId }); + } catch (error) { + response.status(500).json({ error: String(error) }); + } +}); diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..e262d7b --- /dev/null +++ b/backend/src/index.ts @@ -0,0 +1,26 @@ +import express from 'express'; +import { authRouter } from './auth.ts'; +import { loadConfig } from './store.ts'; +import { API_VERSION } from './version.ts'; + +const PORT = parseInt(process.env.BACKEND_PORT ?? '3001', 10); +const HOST = process.env.BACKEND_HOST ?? 'localhost'; + +const app = express(); +app.use(express.json()); + +app.use('/api', authRouter); + +app.get('/api/status', (_request, response) => { + const config = loadConfig(); + response.json({ + configured: config !== null, + host: config?.host ?? null, + clientUuid: config?.clientUuid ?? null, + }); +}); + +app.listen(PORT, HOST, () => { + console.log(`Backend running on http://${HOST}:${PORT}`); + console.log(`API version: ${API_VERSION}`); +}); diff --git a/backend/src/store.ts b/backend/src/store.ts new file mode 100644 index 0000000..47be4fb --- /dev/null +++ b/backend/src/store.ts @@ -0,0 +1,90 @@ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const CACHE_DIR = path.join(__dirname, '../../.cache'); +const CONFIG_FILE = path.join(CACHE_DIR, 'config.json'); +const TOKENS_FILE = path.join(CACHE_DIR, 'tokens.json'); +const KEYPAIR_FILE = path.join(CACHE_DIR, 'keypair.json'); + +export interface Config { + clientUuid: string; + host: string; + nablaUserId?: string; // Nabla-assigned UUID, set after first provision +} + +// A configured `host` may include a scheme (e.g. http://localhost:8080 for a +// local proxy); a bare hostname like us.api.nabla.com defaults to https. +export function apiBaseUrl(host: string): string { + return host.includes('://') ? host : `https://${host}`; +} + +export interface Keypair { + privateKeyPem: string; + publicKeyPem: string; +} + +export interface Tokens { + serverToken: string | null; + serverTokenExpiresAt: number; +} + +// Renew this many seconds before the real expiry, so a token never lapses mid-request. +const SERVER_TOKEN_RENEW_MARGIN_SECONDS = 30; + +// True when the given expiry (epoch seconds) is within the renew margin. +export function expiresSoon(serverTokenExpiresAt: number): boolean { + const nowSeconds = Date.now() / 1000; + return nowSeconds >= serverTokenExpiresAt - SERVER_TOKEN_RENEW_MARGIN_SECONDS; +} + +function ensureCacheDir(): void { + if (!fs.existsSync(CACHE_DIR)) fs.mkdirSync(CACHE_DIR, { recursive: true }); +} + +export function loadConfig(): Config | null { + try { + return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8')) as Config; + } catch { + return null; + } +} + +export function saveConfig(config: Config): void { + ensureCacheDir(); + // 0o600 = owner-only. The keypair and server token are real OAuth credentials; + // this is a local-dev convenience — production should use a secrets manager/KMS. + // (POSIX-only; a no-op on Windows. Applied on file creation.) + fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 }); +} + +export function loadTokens(): Tokens { + try { + return JSON.parse(fs.readFileSync(TOKENS_FILE, 'utf-8')) as Tokens; + } catch { + return { serverToken: null, serverTokenExpiresAt: 0 }; + } +} + +export function saveTokens(tokens: Tokens): void { + ensureCacheDir(); + fs.writeFileSync(TOKENS_FILE, JSON.stringify(tokens, null, 2), { mode: 0o600 }); +} + +export function clearTokens(): void { + saveTokens({ serverToken: null, serverTokenExpiresAt: 0 }); +} + +export function loadKeypair(): Keypair | null { + try { + return JSON.parse(fs.readFileSync(KEYPAIR_FILE, 'utf-8')) as Keypair; + } catch { + return null; + } +} + +export function saveKeypair(keypair: Keypair): void { + ensureCacheDir(); + fs.writeFileSync(KEYPAIR_FILE, JSON.stringify(keypair, null, 2), { mode: 0o600 }); +} diff --git a/backend/src/version.ts b/backend/src/version.ts new file mode 100644 index 0000000..4beee82 --- /dev/null +++ b/backend/src/version.ts @@ -0,0 +1 @@ +export const API_VERSION = '2026-06-12'; diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..7cd947a --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": true, + "esModuleInterop": true + }, + "include": ["src"] +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..433a3c9 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,14 @@ +{ + "name": "nabla-sample-app-frontend", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "@types/node": "^22.0.0", + "typescript": "^5.5.0", + "vite": "^7.0.0" + } +} diff --git a/frontend/public/primary_care.wav b/frontend/public/primary_care.wav new file mode 100644 index 0000000..954e249 Binary files /dev/null and b/frontend/public/primary_care.wav differ diff --git a/frontend/public/transcript_items.json b/frontend/public/transcript_items.json new file mode 100644 index 0000000..e0b61ff --- /dev/null +++ b/frontend/public/transcript_items.json @@ -0,0 +1,1292 @@ +[ + { + "id": "3dd008d3-46d9-4b73-87e8-8f971237400f", + "text": "Hey Sir, how are you?", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 190, + "end_offset_ms": 1904, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "3dd008d3-46d9-4b73-dcff-639d4f23a948", + "text": "Good.", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 1904, + "end_offset_ms": 2247, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "3dd008d3-46d9-4b73-5897-3f78f4081130", + "text": "Sorry to keep you so long. Jenna is helping out. She's starting here. She's going to be seeing her own patients in what, 2-2 weeks or so?", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 2247, + "end_offset_ms": 11503, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "3dd008d3-46d9-4b73-aab7-6b601729da65", + "text": "Yeah.", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 11503, + "end_offset_ms": 11846, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "3dd008d3-46d9-4b73-c809-bd95fcf33f0b", + "text": "Good to see you. Thanks for coming in. We're talking about cholesterol, correct?", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 11846, + "end_offset_ms": 16303, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "3dd008d3-46d9-4b73-c2f4-0f70d3281629", + "text": "Yes, Sir.", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 16303, + "end_offset_ms": 16988, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "3dd008d3-46d9-4b73-40af-0b541bb404e0", + "text": "Anything else you want to talk about today?", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 16988, + "end_offset_ms": 19731, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "3dd008d3-46d9-4b73-48e2-9c0081b957a2", + "text": "My hearing is not the best, but it's not really not in not in your wheel, right? But", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 19731, + "end_offset_ms": 25902, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "3dd008d3-46d9-4b73-3766-dcfa48f9a738", + "text": "OK, we'll take a look in your ears though.", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 25902, + "end_offset_ms": 28988, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "3dd008d3-46d9-4b73-729b-513d9211725b", + "text": "Okay.", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 28988, + "end_offset_ms": 29330, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "3dd008d3-46d9-4b73-7408-c0ccdaa4a835", + "text": "Anything else besides cholesterol and hearing?", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 29330, + "end_offset_ms": 31387, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "3dd008d3-46d9-4b73-47c6-bb7d2f5fea28", + "text": "No.", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 31387, + "end_offset_ms": 31730, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "3dd008d3-46d9-4b73-86b8-46d6f784c8ad", + "text": "Nothing? Okay. Let me get your chart pulled up. You're doing two cholesterol pills, right? You're doing atorvastatin. It's 40, but you take 80, right? Two forties?", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 31730, + "end_offset_ms": 40987, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "3dd008d3-46d9-4b73-1cc9-355bd541eff6", + "text": "Yeah.", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 40987, + "end_offset_ms": 41330, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "3dd008d3-46d9-4b73-b905-d7a8515090cd", + "text": "And then you're taking ezetimibe, Zetia, we call it sometimes, 10 mg, right?", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 41330, + "end_offset_ms": 45786, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "3dd008d3-46d9-4b73-4757-9c25c0c5f93f", + "text": "That little baby pill, right?", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 45786, + "end_offset_ms": 47501, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "3dd008d3-46d9-4b73-6688-04aaf22659bd", + "text": "Yeah, baby pill. Yeah. And are you having any trouble with either one of them?", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 47501, + "end_offset_ms": 52643, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "3dd008d3-46d9-4b73-68db-ddcffbcea5e7", + "text": "No. I don't believe so.", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 52643, + "end_offset_ms": 54357, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "3dd008d3-46d9-4b73-e5f7-54f71ce675aa", + "text": "No? Good. I'm going to go look at our Last visit and then kind of work my way forward. Okay, so October 10th you had, you know, over the last couple months, I think you went from atorvastatin 40 up to 60 mg up to 80 mg. And then in December, that's when we added that ezetimibe, right?", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 54357, + "end_offset_ms": 74242, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "3dd008d3-46d9-4b73-9843-6b1ab43f980b", + "text": "Yeah, that sounds correct.", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 74242, + "end_offset_ms": 75613, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "3dd008d3-46d9-4b73-ca8d-73638c649df3", + "text": "Okay, when I saw you that day, you had a cough that had been going on for a month.", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 75613, + "end_offset_ms": 82127, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "3dd008d3-46d9-4b73-ea97-73f7d2a489ef", + "text": "Yeah, I finally got rid of that.", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 82127, + "end_offset_ms": 84527, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "3dd008d3-46d9-4b73-97a8-8b91ddb125f5", + "text": "Good.", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 84527, + "end_offset_ms": 84869, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "2c24f495-199b-4e1d-b2de-0f239249d225", + "text": "Right, then you saw your dermatologist in November. Okay. Turn out okay?", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 85640, + "end_offset_ms": 89651, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "2c24f495-199b-4e1d-c052-37d5d33c1146", + "text": "I think so.", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 89651, + "end_offset_ms": 90654, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "2c24f495-199b-4e1d-0be6-2e5f6f25bb88", + "text": "Then you saw Dr. Adams here for a cough. He gave you an antibiotic for about 5 days, right?", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 90654, + "end_offset_ms": 97006, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "2c24f495-199b-4e1d-1a8b-59fce3cdc319", + "text": "Yes.", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 97006, + "end_offset_ms": 97340, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "2c24f495-199b-4e1d-5380-c97e4023f0f7", + "text": "Okay.", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 97340, + "end_offset_ms": 97674, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "2c24f495-199b-4e1d-a7fa-5ab98d24fe99", + "text": "That's okay.", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 97674, + "end_offset_ms": 98343, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "2c24f495-199b-4e1d-7ff5-40cf9591a656", + "text": "Then you saw cardiology, Dr. Chen, January 5th, just for a routine follow-up, I think, right?", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 98343, + "end_offset_ms": 103692, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "2c24f495-199b-4e1d-eaec-1a5914cfeb4f", + "text": "Yes, yes. Do an annual visit.", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 103692, + "end_offset_ms": 105698, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "2c24f495-199b-4e1d-2a68-ae4d4fd22f3c", + "text": "Yeah. And he said just come back in 1 year, correct?", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 105698, + "end_offset_ms": 109375, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "2c24f495-199b-4e1d-9bbe-a10c22a33467", + "text": "Yes.", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 109375, + "end_offset_ms": 109709, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "2c24f495-199b-4e1d-de14-4ea9aed22a26", + "text": "And then actually it was January 21st when Dr. Peterson did maybe another procedure that", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 109709, + "end_offset_ms": 114724, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "2c24f495-199b-4e1d-c66b-bddf1a49bff5", + "text": "That was the back.", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 114724, + "end_offset_ms": 116061, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "2c24f495-199b-4e1d-b374-70b5b1351629", + "text": "Okay, and then you just had a set of labs. Did you get a chance to see them yet?", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 116061, + "end_offset_ms": 122413, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "2c24f495-199b-4e1d-2f35-98ef9ce5bd95", + "text": "No, I'm not good at going on that computer. I'm not good at it.", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 122413, + "end_offset_ms": 127093, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "2c24f495-199b-4e1d-971b-04bf6134ddb4", + "text": "Well, they look great. So this one that I put the little check mark next to, LDL, that's the bad cholesterol.", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 127093, + "end_offset_ms": 134113, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "2c24f495-199b-4e1d-b0f9-c2c2af192a39", + "text": "Right.", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 134113, + "end_offset_ms": 134447, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "2c24f495-199b-4e1d-e232-3f1b5df98a37", + "text": "That's the one that we're focused on. It looks really good at 56. So as I look back, it had been like 98 in August, then 85 in December. Now it came down all the way down to 56. And that's really where you want to be.", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 134447, + "end_offset_ms": 150160, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "d6e6eda8-fa8e-4ff5-8920-c006d1c11bc9", + "text": "So because of your heart stuff keeping you around 55 or so, which 56 is pretty close.", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 150390, + "end_offset_ms": 156538, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "d6e6eda8-fa8e-4ff5-d508-a83b5557229d", + "text": "Okay,", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 156538, + "end_offset_ms": 156900, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "d6e6eda8-fa8e-4ff5-c04a-e601b8c81f0a", + "text": "Looks good, so I'm super happy with that.", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 156900, + "end_offset_ms": 159793, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "d6e6eda8-fa8e-4ff5-92a1-e1ffb411a1e8", + "text": "Good.", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 159793, + "end_offset_ms": 160155, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "d6e6eda8-fa8e-4ff5-80a0-50dcb8ef41e8", + "text": "Mr. Davis, your atorvastatin in November, I refilled it for a year. Actually, Dr. Adams did in November. He gave you a year of your atorvastatin. Looks like he stole my pills for me. He prescribed that one in November as well. So both of your heart, your cholesterol pills have his name on it now, which it doesn't matter as long as somebody's Pulling it every year.", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 160155, + "end_offset_ms": 184750, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "d6e6eda8-fa8e-4ff5-3103-bdbb8cc4b070", + "text": "Okay,", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 184750, + "end_offset_ms": 185111, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "d6e6eda8-fa8e-4ff5-6430-ba4614fc0fe3", + "text": "So big picture for hyperlipidemia or dyslipidemia and your coronary artery calcifications, we're going to keep you on those two pills. No changes. Sound good?", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 185111, + "end_offset_ms": 194154, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "d6e6eda8-fa8e-4ff5-f6bb-e618587eaac8", + "text": "Yeah.", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 194154, + "end_offset_ms": 194515, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "d6e6eda8-fa8e-4ff5-9ebf-4069b7438d8d", + "text": "All right, then. You said your hearing's not great. How long has that been?", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 194515, + "end_offset_ms": 199579, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "d6e6eda8-fa8e-4ff5-13f4-d473e4b44a31", + "text": "My Susan would probably tell you a couple years.", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 199579, + "end_offset_ms": 202834, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "d6e6eda8-fa8e-4ff5-9f14-b1335a0507dc", + "text": "A couple years. Okay. Any like ear pain?", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 202834, + "end_offset_ms": 205728, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "d6e6eda8-fa8e-4ff5-2dec-7980bfc63648", + "text": "No. No.", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 205728, + "end_offset_ms": 206451, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "d6e6eda8-fa8e-4ff5-bd4f-9aa55ba62487", + "text": "Okay. Or sound like it's distorted or muffled at all?", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 206451, + "end_offset_ms": 210068, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "d6e6eda8-fa8e-4ff5-5296-4e023b4c493e", + "text": "No.", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 210068, + "end_offset_ms": 210430, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "176888be-57d2-4237-af19-dc293adc32d4", + "text": "OK And have you had any hearing tests?", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 210590, + "end_offset_ms": 213214, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "176888be-57d2-4237-1fc8-b9e050d7934a", + "text": "No.", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 213214, + "end_offset_ms": 213542, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "176888be-57d2-4237-07fe-df41eac44260", + "text": "And do you have a lot of noise exposure?", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 213542, + "end_offset_ms": 216495, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "176888be-57d2-4237-3fad-1b97e369c122", + "text": "Oh, to the farm? Some. Yeah, not so much. I used to maybe, but", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 216495, + "end_offset_ms": 221088, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "176888be-57d2-4237-89d7-3f637ab6a6dc", + "text": "Yeah. OK. No trauma like car accidents. Loud. No, no. OK, I'll take a look. And then he had this lung nodule. But that's fine. You don't need to worry about that anymore. Correct. I think that's what we came up with. Yeah, I believe that's. Perfect. All right, let's have you hop up there. We'll take a look in your ears. Anything else today that you want to talk about?", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 221088, + "end_offset_ms": 244383, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "176888be-57d2-4237-1701-642f20e65c73", + "text": "Both ears about the same, far as I know.", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 244383, + "end_offset_ms": 247335, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "176888be-57d2-4237-c191-f948e1532fce", + "text": "Yeah, a lot of those do, like a little.", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 247335, + "end_offset_ms": 250288, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "176888be-57d2-4237-1c37-3912dddc998c", + "text": "I got a bunion in my toe, but that's not yours.", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 250288, + "end_offset_ms": 253897, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "176888be-57d2-4237-36fd-6defbe26cff4", + "text": "You were going to say a bunion in your ears. I was going to be really confused. OK, listen to this. You hear that?", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 253897, + "end_offset_ms": 261771, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "176888be-57d2-4237-af27-16fa9aeba547", + "text": "No,", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 261771, + "end_offset_ms": 262099, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "176888be-57d2-4237-5cfa-e8b5cb64b52d", + "text": "Just a little noise like this. How about I'm going to do it like this. OK,", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 262099, + "end_offset_ms": 267349, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "176888be-57d2-4237-6f52-3000a2a59eef", + "text": "That I can hear.", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 267349, + "end_offset_ms": 268661, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "176888be-57d2-4237-1c60-0945b287f0a6", + "text": "Yeah, I'll do a little quieter.", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 268661, + "end_offset_ms": 270630, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "80faa667-4aac-4ee5-a36e-a48d4dbf2ae4", + "text": "Nothing? Okay, I'm gonna look real quick.", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 270910, + "end_offset_ms": 273083, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "80faa667-4aac-4ee5-4cd0-8ec94ca99dd4", + "text": "Wow, maybe. Hey Jenna,", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 273083, + "end_offset_ms": 274326, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "80faa667-4aac-4ee5-d338-b9daf629df14", + "text": "This one looks super clear. No dirt in there. See down to the eardrum? Yep. This one has a little bit of wax, but I can still see down to the eardrum. Do you see a ear doctor?", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 274326, + "end_offset_ms": 286127, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "80faa667-4aac-4ee5-35c3-3a6799fbf0d3", + "text": "I haven't so far, no.", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 286127, + "end_offset_ms": 287680, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "80faa667-4aac-4ee5-28e6-2895d54f9d94", + "text": "No. I think there's a couple of options. You go to WellCare.", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 287680, + "end_offset_ms": 291406, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "80faa667-4aac-4ee5-4958-709b9fc65eb8", + "text": "My Susan does. And that's what they they always come well-recommended.", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 291406, + "end_offset_ms": 294822, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "80faa667-4aac-4ee5-6d1c-36b0f353ed9f", + "text": "Yeah, I would go there. I think you have to call in an And then they'll get you set up for a hearing test in the back. That's free, I think, for WellCare members. And then if they decide that you do need hearing aids, a lot of people get them right through WellCare and they're a lot cheaper than everyone else's. Same thing. So", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 294822, + "end_offset_ms": 315008, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "80faa667-4aac-4ee5-3a6e-fbc531272267", + "text": "My Emily come home a couple weeks ago. She lives in Phoenix and she brought me a pair of them Audien.", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 315008, + "end_offset_ms": 321530, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "80faa667-4aac-4ee5-d12c-bf46b41bd9e2", + "text": "Okay,", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 321530, + "end_offset_ms": 321841, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "80faa667-4aac-4ee5-ba67-0f0b3b0bee97", + "text": "I can't stand but at first I can't get them in my ears. Now you really can't buy somebody else's hearing aids anyhow, but I couldn't physically get them in. And they just man they were loud but but the the little rubber suction cup thing was like too big large for my ear. But anyhow the moral of the story I don't think you should buy used hearing aids.", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 321841, + "end_offset_ms": 343580, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "80faa667-4aac-4ee5-d415-503beaf66bbd", + "text": "Normally that's something you get yourself.", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 343580, + "end_offset_ms": 345443, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "80faa667-4aac-4ee5-ae37-ac35b25a4620", + "text": "Yeah, she meant well.", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 345443, + "end_offset_ms": 346685, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "80faa667-4aac-4ee5-146e-619887aedf70", + "text": "Well, I'm gonna do a quick exam of your heart and then I'll have you take two big breaths. Let's do it. There's another one. Well, yeah, I think WellCare does a really good job testing and then getting people equipped with hearing aids.", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 346685, + "end_offset_ms": 360350, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "ad0b3d93-8845-4eeb-b61a-ee49abb411ea", + "text": "So I'd start there.", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 360470, + "end_offset_ms": 361660, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "ad0b3d93-8845-4eeb-d3e3-a4bb2e338309", + "text": "OK. I shall.", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 361660, + "end_offset_ms": 362553, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "ad0b3d93-8845-4eeb-1667-3a4d4681f121", + "text": "Yeah. Do it. Don't wait too long,", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 362553, + "end_offset_ms": 364636, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "ad0b3d93-8845-4eeb-a277-3e44e3a46128", + "text": "OK.", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 364636, + "end_offset_ms": 364934, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "ad0b3d93-8845-4eeb-4119-261a53239cb8", + "text": "I see too many people that wait, and then finally they're like, oh, yeah, I should have done that like, five years ago. So at least check them out.", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 364934, + "end_offset_ms": 373565, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "ad0b3d93-8845-4eeb-8f4a-50824e25b61d", + "text": "OK. All right.", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 373565, + "end_offset_ms": 374457, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "ad0b3d93-8845-4eeb-0eaa-e2ab096bbf0f", + "text": "Last year I saw you in August. Should we just meet back in August?", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 374457, + "end_offset_ms": 378624, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "ad0b3d93-8845-4eeb-b544-a77ce60c9b1e", + "text": "Yeah, I have a point. I have an appointment for that, my annual physical. And I should have an appointment.", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 378624, + "end_offset_ms": 384576, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "ad0b3d93-8845-4eeb-eb09-0d900016a8f5", + "text": "Perfect. Yeah. As long as you're already set up. Yeah, you do. August 19th, you come see me and then we're going to do blood work. Ahead of that. So so then to see like does he have anything I'd go I do it order review views all labs for release which he doesn't have anything. Let's see these HCC. You don't have PMR do you polymyalgia rheumatica?", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 384576, + "end_offset_ms": 404814, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "ad0b3d93-8845-4eeb-6c94-1a40ad218c80", + "text": "I hope not.", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 404814, + "end_offset_ms": 405707, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "ad0b3d93-8845-4eeb-711a-1423cf53a295", + "text": "Did you used to?", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 405707, + "end_offset_ms": 406898, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "ad0b3d93-8845-4eeb-e4f0-d35fb2a6ec8d", + "text": "I'm not sure what that even is.", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 406898, + "end_offset_ms": 408981, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "ad0b3d93-8845-4eeb-5b6e-99aab77ddbb6", + "text": "It's like where you can't lift your arms up because they're super stiff in your shoulders. You never had that before?", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 408981, + "end_offset_ms": 415231, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "ad0b3d93-8845-4eeb-3a10-b533348fbb81", + "text": "No, I don't think so.", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 415231, + "end_offset_ms": 416719, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "ad0b3d93-8845-4eeb-0686-bb85ea85f19d", + "text": "I'm going to leave that. So there's this like HCC Which we're all supposed to be doing it like helps the institution. Like have you heard about these at all?", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 416719, + "end_offset_ms": 425647, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "ad0b3d93-8845-4eeb-5e07-9cf64c1b8682", + "text": "I've I just saw.", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 425647, + "end_offset_ms": 426838, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "ad0b3d93-8845-4eeb-ec3a-9f46cea25929", + "text": "Yeah. So it's like, let's say a patient has like a lot of like chronic medical issues that we put in the diagnosis list every year. Then Medicare is like, oh, that's a more complicated patient. So the university gets paid more to manage their care, right? But if we forget it one year, we just like completely forget somebody's diabetes or whatever, then it's like, oh, they look like an easier patient than the institution like does worse.", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 426838, + "end_offset_ms": 450349, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "9e748f33-23c5-464c-a3d7-79e4681a2222", + "text": "So we're supposed to be doing it, but a lot of times it seems to to put things in there that aren't accurate. So we had to be careful. OK, so what", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 450750, + "end_offset_ms": 460169, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "9e748f33-23c5-464c-a833-f77b9f6f72e1", + "text": "Do you want for labs for", + "speaker_type": "UNSPECIFIED", + "locale": "ENGLISH_US", + "start_offset_ms": 460169, + "end_offset_ms": 461935, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "9e748f33-23c5-464c-c374-bff752e0ae33", + "text": "Him? Yeah. So see what it's or do you need to do it again? I think I would do another. He should get an annual lipid. He just had one last week, but I think the lipids would be good just to kind of keep an eye on it and then probably have it be in August every year thing. Like this year we're a little off because we were adjusting things. But lipids I would do and CMP I would do. So let's see, what else is he taking? He's just taken cholesterol pill. I kind of like CMP and lipids. He has impaired fasting glucose, so we usually get an A1c for him now. PSA we got in August. You have prostate cancer in the family?", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 461935, + "end_offset_ms": 499611, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "9e748f33-23c5-464c-d240-498e25dee041", + "text": "No.", + "speaker_type": "UNSPECIFIED", + "locale": "ENGLISH_US", + "start_offset_ms": 499611, + "end_offset_ms": 499906, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "9e748f33-23c5-464c-6596-e26c0768ca05", + "text": "You want to keep checking your prostate blood test.", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 499906, + "end_offset_ms": 502555, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "9e748f33-23c5-464c-e37e-c174b535eab9", + "text": "Yeah, probably a good idea.", + "speaker_type": "UNSPECIFIED", + "locale": "ENGLISH_US", + "start_offset_ms": 502555, + "end_offset_ms": 504026, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "9e748f33-23c5-464c-380b-0e01c1d7442e", + "text": "Got it. CBC. He did have a little anemia. I don't know that he needs it, but. And probably get that one all right. So CMP.", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 504026, + "end_offset_ms": 511680, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "6051eb7e-76f0-4c50-928d-3b6aef0960da", + "text": "I'm going to order lipids last because it gets goofy. A1c, there's like 1000 ways to do this. PSA and lipids. I do lipids last because it like pulls this up and makes me click fasting. So he's doing his lipids for those two things, his PSA. So then you can click like diagnosis association and if there's a problem list that's in there, that's a good one to use. For PSA, we do like screening for malignancy of the prostate gland. A1c would be good for there. And this one could be for there. So then once those are all checked, we're good. And then they all say today. So I don't like today. So I have like a six month, which is going to be August, which is a little early. You can do that and that. And then if it's seven months from now, you could do like M + 7, say that it's approximate hit accept. So it should do all of them. OK, sound good? All right, so we're going to do some labs in August, then you'll see me in August,", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 512550, + "end_offset_ms": 569896, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "6051eb7e-76f0-4c50-60a9-8a02f8aa1017", + "text": "OK.", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 569896, + "end_offset_ms": 570203, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "6051eb7e-76f0-4c50-1d27-b4560b73e114", + "text": "Go from there.", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 570203, + "end_offset_ms": 571123, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "6051eb7e-76f0-4c50-a13a-ff4aaeed988e", + "text": "OK.", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 571123, + "end_offset_ms": 571430, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "6051eb7e-76f0-4c50-0e44-9f96a5d74d57", + "text": "If you need anything before that, let me know. Otherwise, go to WellCare. Get your ears looked at.", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 571430, + "end_offset_ms": 576950, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "6051eb7e-76f0-4c50-944f-d14475959c37", + "text": "OK, one more thing. While I'm here, we want to look at my back and see that spot that she took off.", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 576950, + "end_offset_ms": 583696, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "6051eb7e-76f0-4c50-f35e-d508fb82269a", + "text": "Oh, sure. Yeah.", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 583696, + "end_offset_ms": 584616, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "6051eb7e-76f0-4c50-265b-5dacdafe7359", + "text": "I don't see her until May, May 15th. I guess I get my shirt pulled up, but I've. I've never seen it. So", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 584616, + "end_offset_ms": 591670, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "6051eb7e-76f0-4c50-745c-495b33a5f9cc", + "text": "Where was it? Down here?", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 591670, + "end_offset_ms": 593203, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "6051eb7e-76f0-4c50-6ef6-592adf68048a", + "text": "Yeah. Maybe it's all healed up and gone then.", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 593203, + "end_offset_ms": 595963, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "6051eb7e-76f0-4c50-4219-95870c0b651e", + "text": "The spot down here looks pretty healed up, if that's what they did.", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 595963, + "end_offset_ms": 599950, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "fcc3a0e4-2f22-4b74-9369-7e6db6566c43", + "text": "Yeah, I don't see anything else.", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 600620, + "end_offset_ms": 602775, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "fcc3a0e4-2f22-4b74-f44d-e9c9dfcf90d8", + "text": "It was kind of lower there, but", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 602775, + "end_offset_ms": 605289, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "fcc3a0e4-2f22-4b74-0fff-531428334d02", + "text": "Yeah, looks pretty good. Good, good, good. What they tell you it turned out to be anything.", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 605289, + "end_offset_ms": 611395, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "fcc3a0e4-2f22-4b74-a5ed-7ccb0c2595b0", + "text": "No, I don't think so. No cautious. So,", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 611395, + "end_offset_ms": 614269, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "fcc3a0e4-2f22-4b74-2f75-b0925f2276d6", + "text": "OK, dermatologists", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 614269, + "end_offset_ms": 614987, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "fcc3a0e4-2f22-4b74-ae21-201f6a0c3f3d", + "text": "Gotta pay their bills,", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 614987, + "end_offset_ms": 616424, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "fcc3a0e4-2f22-4b74-44b8-ed1b044627f1", + "text": "Right? So that's right. That's right.", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 616424, + "end_offset_ms": 618579, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "fcc3a0e4-2f22-4b74-4c3e-6922f386e5a9", + "text": "Get a new boat or something.", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 618579, + "end_offset_ms": 620734, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "fcc3a0e4-2f22-4b74-35d3-398377fe4d0f", + "text": "There you go.", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 620734, + "end_offset_ms": 621812, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "fcc3a0e4-2f22-4b74-19fb-4e71bb4c3f24", + "text": "OK, OK, so stay with the stay with the arvastatin and the small pill. Just like", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 621812, + "end_offset_ms": 627559, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "fcc3a0e4-2f22-4b74-00e1-92192af28cab", + "text": "Yeah, as long as you're tolerating. Their working super well. Your numbers are perfect.", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 627559, + "end_offset_ms": 632588, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "fcc3a0e4-2f22-4b74-b400-ebd771049dd6", + "text": "OK", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 632588, + "end_offset_ms": 632947, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "fcc3a0e4-2f22-4b74-e420-83d9d786d3b3", + "text": "Sound good", + "speaker_type": "DOCTOR", + "locale": "ENGLISH_US", + "start_offset_ms": 632947, + "end_offset_ms": 633665, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + }, + { + "id": "fcc3a0e4-2f22-4b74-3014-263138df30f7", + "text": "I like take a lot of pills.", + "speaker_type": "PATIENT", + "locale": "ENGLISH_US", + "start_offset_ms": 633665, + "end_offset_ms": 636179, + "is_final": true, + "type": "TRANSCRIPT_ITEM" + } +] diff --git a/frontend/src/api/dictate.ts b/frontend/src/api/dictate.ts new file mode 100644 index 0000000..ed142fe --- /dev/null +++ b/frontend/src/api/dictate.ts @@ -0,0 +1,76 @@ +import { encodePcm16ToBase64 } from "./encoding.js"; +import { nablaWebSocket } from "../transport/client.js"; + +export type DictationLocale = + | "ENGLISH_US" + | "ENGLISH_UK" + | "FRENCH_FR" + | "SPANISH_ES" + | "SPANISH_MX"; + +export interface DictatedText { + type: "DICTATED_TEXT"; + text: string; +} + +export interface AudioChunkAck { + type: "AUDIO_CHUNK_ACK"; + ack_id: number; +} + +export type DictateServerMessage = + | DictatedText + | AudioChunkAck + | { type: string }; + +export const DICTATE_ENCODING = "PCM_S16LE" as const; +export const DICTATE_SAMPLE_RATE_HZ = 16000; + +// dictate-ws supports a single dictation locale (unlike transcribe-ws, which takes +// an array of speech locales). Drives the locale picker on the page. +export const DICTATE_LOCALES: { value: DictationLocale; label: string }[] = [ + { value: "ENGLISH_US", label: "English (US)" }, + { value: "ENGLISH_UK", label: "English (UK)" }, + { value: "FRENCH_FR", label: "French (FR)" }, + { value: "SPANISH_ES", label: "Spanish (ES)" }, + { value: "SPANISH_MX", label: "Spanish (MX)" }, +]; + +// #region dictate-messages +// The messages a client sends over dictate-ws. + +export function buildDictateConfig(locale: DictationLocale, noteText: string) { + return { + type: "CONFIG" as const, + encoding: DICTATE_ENCODING, + sample_rate: DICTATE_SAMPLE_RATE_HZ, + dictation_locale: locale, + // EXPLICIT means the provider dictates punctuation out loud ("period", "comma"). + punctuation_mode: "EXPLICIT" as const, + // Tells the API the current text and where the caret is, so the first dictated + // word is capitalised correctly and output is inserted at the caret. We keep the + // caret at the end of the existing note with no selection, so dictation appends. + text_field_context: { + text: noteText, + selection_start: noteText.length, + selection_length: 0, + }, + }; +} + +export function buildDictateAudioChunk(seqId: number, pcm: Int16Array) { + return { + type: "AUDIO_CHUNK" as const, + seq_id: seqId, + payload: encodePcm16ToBase64(pcm), + }; +} + +export const DICTATE_END_MESSAGE = { + type: "END" as const, +}; +// #endregion dictate-messages + +export async function connectDictateWebSocket(): Promise { + return nablaWebSocket("/v1/core/user/dictate-ws", "dictate-protocol"); +} diff --git a/frontend/src/api/encoding.ts b/frontend/src/api/encoding.ts new file mode 100644 index 0000000..f404767 --- /dev/null +++ b/frontend/src/api/encoding.ts @@ -0,0 +1,15 @@ +// The streaming APIs expect each AUDIO_CHUNK's PCM-16 payload as a base64 string. +// This is a wire-format concern (used by the message builders), independent of where +// the audio came from — microphone or WAV file. +export function encodePcm16ToBase64(pcmData: Int16Array): string { + const bytes = new Uint8Array( + pcmData.buffer, + pcmData.byteOffset, + pcmData.byteLength, + ); + let binary = ""; + for (let byteIndex = 0; byteIndex < bytes.byteLength; byteIndex++) { + binary += String.fromCodePoint(bytes[byteIndex]); + } + return btoa(binary); +} diff --git a/frontend/src/api/normalize.ts b/frontend/src/api/normalize.ts new file mode 100644 index 0000000..a3862b8 --- /dev/null +++ b/frontend/src/api/normalize.ts @@ -0,0 +1,28 @@ +import { nablaFetch } from "../transport/client.js"; +import type { ClinicalNote } from "./note.js"; + +// The full response is a FHIR Condition list (plus family history and observations). +// The sample displays only the primary coding and status of each condition. +export interface Condition { + coding: { + code: string; + display: string; + }; + clinical_status?: string; +} + +export interface NormalizedData { + conditions: Condition[]; +} + +export async function generateNormalizedData( + note: ClinicalNote, +): Promise { + const response = await nablaFetch("/v1/core/user/generate-normalized-data", { + method: "POST", + body: JSON.stringify({ + note, + }), + }); + return response.json() as Promise; +} diff --git a/frontend/src/api/note-settings.ts b/frontend/src/api/note-settings.ts new file mode 100644 index 0000000..b331d90 --- /dev/null +++ b/frontend/src/api/note-settings.ts @@ -0,0 +1,42 @@ +import { nablaFetch } from "../transport/client.js"; + +export type NoteLocale = "ENGLISH_US" | "ENGLISH_UK" | "FRENCH_FR"; + +// A note template from Nabla's library. Fetch these via listNoteTemplates() instead +// of hardcoding, so the integration inherits new/improved templates automatically. +export interface NoteTemplate { + key: string; + title: string; + description: string; +} + +export interface NoteSettings { + note_template_key: string; + note_locale: NoteLocale; +} + +interface ListTemplatesResponse { + templates: NoteTemplate[]; +} + +// The templates available to the current user for note generation. +export async function listNoteTemplates(): Promise { + const response = await nablaFetch("/v1/core/user/note-settings/templates"); + const responseBody = (await response.json()) as ListTemplatesResponse; + return responseBody.templates; +} + +// Sets the user's note settings; subsequent generate-note calls use this template. +export async function updateNoteSettings(params: { + noteTemplateKey: string; + noteLocale: NoteLocale; +}): Promise { + const response = await nablaFetch("/v1/core/user/note-settings", { + method: "PATCH", + body: JSON.stringify({ + note_template_key: params.noteTemplateKey, + note_locale: params.noteLocale, + }), + }); + return response.json() as Promise; +} diff --git a/frontend/src/api/note.ts b/frontend/src/api/note.ts new file mode 100644 index 0000000..c97a83b --- /dev/null +++ b/frontend/src/api/note.ts @@ -0,0 +1,48 @@ +import { nablaFetch } from "../transport/client.js"; +import type { TranscriptItem } from "./transcribe.js"; + +// A generated clinical note. +export interface NoteSection { + key: string; + title: string; + text: string; +} + +export interface ClinicalNote { + title?: string; + sections: NoteSection[]; + locale?: string; + template_key?: string; +} + +interface GenerateNoteResponse { + note: ClinicalNote; +} + +export async function generateNote(params: { + transcriptItems: TranscriptItem[]; + patientContext: string; +}): Promise { + // /!\ To build a valid transcript, keep only the **final** transcript items + // /!\ and sort them by `start_offset_ms` in ascending order. + const transcript = params.transcriptItems + .filter((item) => item.is_final) + .sort((a, b) => a.start_offset_ms - b.start_offset_ms); + + // Generate the note using the generated transcript and patient context + const response = await nablaFetch("/v1/core/user/generate-note", { + method: "POST", + body: JSON.stringify({ + unstructured_context: params.patientContext || undefined, + structured_context: { + encounter_date: new Date().toISOString().split("T")[0], + }, + transcript_items: transcript.map((item) => ({ + text: item.text, + speaker_type: item.speaker_type ?? "UNSPECIFIED", + })), + }), + }); + const responseBody = (await response.json()) as GenerateNoteResponse; + return responseBody.note; +} diff --git a/frontend/src/api/patient-instructions.ts b/frontend/src/api/patient-instructions.ts new file mode 100644 index 0000000..057e53c --- /dev/null +++ b/frontend/src/api/patient-instructions.ts @@ -0,0 +1,35 @@ +import { nablaFetch } from "../transport/client.js"; +import type { ClinicalNote } from "./note.js"; + +export type RecipientType = "PATIENT" | "PARENT"; + +export type InstructionsLocale = + | "ENGLISH_US" + | "ENGLISH_UK" + | "SPANISH_ES" + | "SPANISH_MX" + | "FRENCH_FR" + | "ARABIC_EG" + | "MANDARIN_CN" + | "PORTUGUESE_PT" + | "RUSSIAN_RU"; + +interface PatientInstructionsResponse { + instructions: string; +} + +export async function generatePatientInstructions(params: { + note: ClinicalNote; + instructions_locale: InstructionsLocale; + recipient_type?: RecipientType; +}): Promise { + const response = await nablaFetch( + "/v1/core/user/generate-patient-instructions", + { + method: "POST", + body: JSON.stringify(params), + }, + ); + const responseBody = (await response.json()) as PatientInstructionsResponse; + return responseBody.instructions; +} diff --git a/frontend/src/api/transcribe.ts b/frontend/src/api/transcribe.ts new file mode 100644 index 0000000..acd900a --- /dev/null +++ b/frontend/src/api/transcribe.ts @@ -0,0 +1,61 @@ +import { encodePcm16ToBase64 } from "./encoding.js"; +import { nablaWebSocket } from "../transport/client.js"; + +export type Speaker = "DOCTOR" | "PATIENT" | "UNSPECIFIED"; + +export interface TranscriptItem { + id: string; + text: string; + start_offset_ms: number; + end_offset_ms: number; + is_final: boolean; + speaker_type?: Speaker; +} + +// What the server sends back: a transcript item, or a cumulative audio-chunk ack. +export type TranscribeServerMessage = + | ({ type: "TRANSCRIPT_ITEM" } & TranscriptItem) + | { type: "AUDIO_CHUNK_ACK"; ack_id: number }; + +export const TRANSCRIBE_ENCODING = "PCM_S16LE" as const; +export const TRANSCRIBE_SAMPLE_RATE_HZ = 16000; +export const TRANSCRIBE_STREAM_ID = "stream1"; +export const TRANSCRIBE_SPEECH_LOCALES = ["ENGLISH_US", "FRENCH_FR"] as const; + +// #region transcribe-messages +// The messages a client sends over transcribe-ws. + +export function buildTranscribeConfig() { + return { + type: "CONFIG" as const, + encoding: TRANSCRIBE_ENCODING, + sample_rate: TRANSCRIBE_SAMPLE_RATE_HZ, + speech_locales: TRANSCRIBE_SPEECH_LOCALES, + streams: [ + { + id: TRANSCRIBE_STREAM_ID, + speaker_type: "UNSPECIFIED", + }, + ], + enable_audio_chunk_ack: true, + split_by_sentence: true, + }; +} + +export function buildAudioChunk(seqId: number, pcm: Int16Array) { + return { + type: "AUDIO_CHUNK" as const, + stream_id: TRANSCRIBE_STREAM_ID, + seq_id: seqId, + payload: encodePcm16ToBase64(pcm), + }; +} + +export const TRANSCRIBE_END_MESSAGE = { + type: "END" as const, +}; +// #endregion transcribe-messages + +export async function connectTranscribeWebSocket(): Promise { + return nablaWebSocket("/v1/core/user/transcribe-ws", "transcribe-protocol"); +} diff --git a/frontend/src/api/version.ts b/frontend/src/api/version.ts new file mode 100644 index 0000000..91561a8 --- /dev/null +++ b/frontend/src/api/version.ts @@ -0,0 +1 @@ +export const API_VERSION = "2026-06-12"; diff --git a/frontend/src/audio/audio-source.ts b/frontend/src/audio/audio-source.ts new file mode 100644 index 0000000..029444d --- /dev/null +++ b/frontend/src/audio/audio-source.ts @@ -0,0 +1,55 @@ +import { startMicrophoneStream } from "./mic-stream.js"; +import { loadWavFile, streamWavChunks } from "./wav-stream.js"; + +// Where the encounter audio comes from. The only place this discriminates is the +// registry below — everything downstream consumes the uniform AudioStream. +export type AudioSource = "microphone" | "wav-file"; + +// A running audio source, pushing PCM-16 chunks to the callback it was opened with, +// until stop() is called. A WAV file would otherwise end on its own, but we don't +// auto-stop on it — recording always ends when the user stops it. +export interface AudioStream { + stop(): void; +} + +type AudioStreamFactory = ( + onChunk: (pcm: Int16Array) => void, +) => Promise; + +const openMicrophoneStream: AudioStreamFactory = async (onChunk) => { + const microphone = await startMicrophoneStream(onChunk); + return { + stop: () => microphone.stop(), + }; +}; + +const openWavFileStream: AudioStreamFactory = async (onChunk) => { + const wavFile = await loadWavFile(); + let stopped = false; + void (async () => { + for await (const chunk of streamWavChunks(wavFile)) { + if (stopped) { + break; + } + onChunk(chunk); + } + })(); + return { + stop: () => { + stopped = true; + }, + }; +}; + +// Adding a source = one entry here + its factory. All audio sources are handled uniformly. +const AUDIO_SOURCES: Record = { + microphone: openMicrophoneStream, + "wav-file": openWavFileStream, +}; + +export function openAudioStream( + source: AudioSource, + onChunk: (pcm: Int16Array) => void, +): Promise { + return AUDIO_SOURCES[source](onChunk); +} diff --git a/frontend/src/audio/mic-stream.ts b/frontend/src/audio/mic-stream.ts new file mode 100644 index 0000000..bc190a0 --- /dev/null +++ b/frontend/src/audio/mic-stream.ts @@ -0,0 +1,69 @@ +import { TRANSCRIBE_SAMPLE_RATE_HZ } from "../api/transcribe.js"; +// The AudioWorklet must be loaded as a JS module by URL. `?no-inline` tells Vite to +// emit it as a hashed asset and give us its URL, never an inlined `data:` URL (which +// `addModule` rejects) — so it can live next to this file instead of `public/`. +import rawPcm16ProcessorUrl from "./rawPcm16Processor.js?url&no-inline"; + +// #region microphone-stream +export async function startMicrophoneStream( + onChunk: (chunk: Int16Array) => void, +): Promise<{ + stop: () => void; +}> { + // In case multiple microphones are available, you should allow the user to select + // which one to use. + const stream = await navigator.mediaDevices.getUserMedia({ + audio: { + sampleRate: TRANSCRIBE_SAMPLE_RATE_HZ, + channelCount: 1, + echoCancellation: false, + noiseSuppression: false, + }, + }); + + const audioCtx = new AudioContext({ + sampleRate: TRANSCRIBE_SAMPLE_RATE_HZ, + }); + await audioCtx.audioWorklet.addModule(rawPcm16ProcessorUrl); + + const mediaStreamSource = audioCtx.createMediaStreamSource(stream); + const worklet = new AudioWorkletNode(audioCtx, "rawPcm16Processor"); + + const CHUNK_SAMPLES = TRANSCRIBE_SAMPLE_RATE_HZ / 10; // 100 ms of audio + let chunkBuffer = new Int16Array(CHUNK_SAMPLES); + let chunkBufferCurrentLength = 0; + + worklet.port.onmessage = ({ + data: incomingSamples, + }: MessageEvent) => { + let alreadyAdded = 0; + while (alreadyAdded < incomingSamples.length) { + const samplesToTake = Math.min( + CHUNK_SAMPLES - chunkBufferCurrentLength, // The remaining space in the buffer + incomingSamples.length - alreadyAdded, // The remaining samples in the incoming array + ); + for (let i = 0; i < samplesToTake; i++) { + chunkBuffer[chunkBufferCurrentLength + i] = incomingSamples[alreadyAdded + i]; + } + chunkBufferCurrentLength += samplesToTake; + alreadyAdded += samplesToTake; + if (chunkBufferCurrentLength === CHUNK_SAMPLES) { + onChunk(chunkBuffer); + chunkBuffer = new Int16Array(CHUNK_SAMPLES); + chunkBufferCurrentLength = 0; + } + } + }; + + mediaStreamSource.connect(worklet); + + return { + stop: () => { + mediaStreamSource.disconnect(); + worklet.disconnect(); + stream.getTracks().forEach((track) => track.stop()); + void audioCtx.close(); + }, + }; +} +// #endregion microphone-stream diff --git a/frontend/src/audio/rawPcm16Processor.js b/frontend/src/audio/rawPcm16Processor.js new file mode 100644 index 0000000..0bf960b --- /dev/null +++ b/frontend/src/audio/rawPcm16Processor.js @@ -0,0 +1,20 @@ +// #region pcm16-worklet +// An AudioWorklet that converts the browser's Float32 mic samples to PCM_S16LE — +// the encoding the Nabla streaming APIs require. Loaded via audioWorklet.addModule(). +class RawPcm16Processor extends AudioWorkletProcessor { + process(inputs) { + const input = inputs[0]?.[0]; + if (!input) { + return true; + } + const pcm16 = new Int16Array(input.length); + for (let i = 0; i < input.length; i++) { + const s = Math.max(-1, Math.min(1, input[i])); + pcm16[i] = s < 0 ? s * 0x8000 : s * 0x7fff; + } + this.port.postMessage(pcm16, [pcm16.buffer]); + return true; + } +} +registerProcessor("rawPcm16Processor", RawPcm16Processor); +// #endregion pcm16-worklet diff --git a/frontend/src/audio/wav-stream.ts b/frontend/src/audio/wav-stream.ts new file mode 100644 index 0000000..2200b1d --- /dev/null +++ b/frontend/src/audio/wav-stream.ts @@ -0,0 +1,68 @@ +// Decodes a WAV file and streams it as PCM-16 chunks — used to feed the mock audio +// source to the streaming APIs. +import { TRANSCRIBE_SAMPLE_RATE_HZ } from "../api/transcribe.js"; + +export function decodeWavHeader(buffer: ArrayBuffer): { + sampleRate: number; + dataOffset: number; + dataLength: number; +} { + const dataView = new DataView(buffer); + const sampleRate = dataView.getUint32(24, true); + let offset = 12; + while (offset < buffer.byteLength - 8) { + const chunkId = String.fromCharCode( + dataView.getUint8(offset), + dataView.getUint8(offset + 1), + dataView.getUint8(offset + 2), + dataView.getUint8(offset + 3), + ); + const chunkSize = dataView.getUint32(offset + 4, true); + if (chunkId === "data") { + return { + sampleRate, + dataOffset: offset + 8, + dataLength: chunkSize, + }; + } + // RIFF chunks are word-aligned: an odd-sized chunk is followed by a pad byte + // that isn't counted in chunkSize, so skip it too. + offset += 8 + chunkSize + (chunkSize % 2); + } + throw new Error("No data chunk found in WAV file"); +} + +// Stream in 100 ms chunks, paced in real time so the server sees a live-like feed. +const CHUNK_DURATION_MS = 100; +const CHUNK_SAMPLES = (TRANSCRIBE_SAMPLE_RATE_HZ * CHUNK_DURATION_MS) / 1000; + +export async function* streamWavChunks( + wavBuffer: ArrayBuffer, +): AsyncGenerator { + const { sampleRate, dataOffset, dataLength } = decodeWavHeader(wavBuffer); + if (sampleRate !== TRANSCRIBE_SAMPLE_RATE_HZ) { + throw new Error( + `WAV is ${sampleRate} Hz but the stream is configured for ${TRANSCRIBE_SAMPLE_RATE_HZ} Hz — ` + + "chunk pacing and the CONFIG message assume that rate.", + ); + } + const pcmSamples = new Int16Array(wavBuffer, dataOffset, dataLength / 2); + let offset = 0; + while (offset < pcmSamples.length) { + const chunk = pcmSamples.slice(offset, offset + CHUNK_SAMPLES); + offset += CHUNK_SAMPLES; + yield chunk; + await new Promise((resolve) => setTimeout(resolve, CHUNK_DURATION_MS)); + } +} + +// The bundled mock encounter audio, fetched once and cached for the session. +let wavFileBuffer: ArrayBuffer | null = null; + +export async function loadWavFile(): Promise { + if (!wavFileBuffer) { + const response = await fetch("/primary_care.wav"); + wavFileBuffer = await response.arrayBuffer(); + } + return wavFileBuffer; +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..2f117a0 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,51 @@ +@import "tailwindcss"; + +/* Nabla design-system color tokens (the subset this app uses). */ +@theme { + /* Primary — brand accent (violet) */ + --color-primary-50: #f7f7fd; + --color-primary-100: #efeefb; + --color-primary-200: #dfdcf7; + --color-primary-300: #c0b9ee; + --color-primary-400: #a097e6; + --color-primary-500: #8174dd; + --color-primary-600: #6151d5; + --color-primary-700: #4e41aa; + --color-primary-800: #3a3180; + + /* Grey — neutrals (surfaces, borders, text) */ + --color-grey-50: #f9fafc; + --color-grey-100: #f5f7fa; + --color-grey-200: #dce0e9; + --color-grey-250: #a5abbb; + --color-grey-300: #6d758d; + --color-grey-400: #212a2f; + --color-grey-500: #000000; + + /* Semantic */ + --color-success-100: #e7f7f0; + --color-success-200: #88dbb4; + --color-success-300: #12b76a; + --color-warning-100: #fef3e6; + --color-warning-200: #fee5c9; + --color-warning-300: #f79009; + --color-error-100: #f2b0ab; + --color-error-200: #f04438; + --color-error-300: #de3023; +} + +/* Small animation utilities shared across pages (recording dot, loaders). */ +@keyframes blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} +.blink { + animation: blink 1s step-end infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} +.spin { + animation: spin 0.8s linear infinite; +} diff --git a/frontend/src/pages/full-encounter-demo/demo.html b/frontend/src/pages/full-encounter-demo/demo.html new file mode 100644 index 0000000..08c367b --- /dev/null +++ b/frontend/src/pages/full-encounter-demo/demo.html @@ -0,0 +1,28 @@ + + + + + + Full Demo — Nabla Core API + + + + +
+ +
+
+

Full Encounter Demo

+

Run an end-to-end medical encounter, the way an integration would.

+
+ +
+ + +
+ +
+ + diff --git a/frontend/src/pages/full-encounter-demo/encounter.ts b/frontend/src/pages/full-encounter-demo/encounter.ts new file mode 100644 index 0000000..564625b --- /dev/null +++ b/frontend/src/pages/full-encounter-demo/encounter.ts @@ -0,0 +1,50 @@ +import type { TranscriptItem } from "../../api/transcribe.js"; +import { initPageChrome } from "../../shared/page-chrome.js"; +import type { AudioSource } from "../../audio/audio-source.js"; +import * as record from "./record.js"; +import * as setup from "./setup.js"; +import * as workOnNote from "./work-on-note.js"; + +// One encounter = setup → record → work-on-note. Each step renders into #encounter-root +// and calls back when it's done; this orchestrator is just the wiring between them. +// Entering a step tears down the previous one (its listeners detach, its DOM clears). + +const ROOT = "#encounter-root"; + +let exitStep: () => void = () => {}; + +function main(): void { + initPageChrome(); + document.getElementById("restart-link")?.addEventListener("click", goToSetup); + goToSetup(); +} + +main(); + +function goToSetup(): void { + setRestartVisible(false); // nothing to go back to from the first step + exitStep(); + // Setup step allows the user to choose the audio source (microphone or mock audio file) + exitStep = setup.startStep(ROOT, { onNext: goToRecord }); +} + +function goToRecord(audioSource: AudioSource): void { + setRestartVisible(true); + exitStep(); + // Record step allows the user to record an encounter and generate a transcript + exitStep = record.startStep(ROOT, { audioSource, onNext: goToWorkOnNote }); +} + +function goToWorkOnNote(result: { + transcript: TranscriptItem[]; + patientContext: string; +}): void { + exitStep(); + // Work on note will first generate the note from the transcript and patient context + // then allow the user to generate ICD-10 / LOINC Codes and patient instructions + exitStep = workOnNote.startStep(ROOT, result); +} + +function setRestartVisible(visible: boolean): void { + document.getElementById("restart-link")?.classList.toggle("hidden", !visible); +} diff --git a/frontend/src/pages/full-encounter-demo/record.render.ts b/frontend/src/pages/full-encounter-demo/record.render.ts new file mode 100644 index 0000000..b80f335 --- /dev/null +++ b/frontend/src/pages/full-encounter-demo/record.render.ts @@ -0,0 +1,139 @@ +import type { TranscriptItem } from "../../api/transcribe.js"; +import { DOCUMENTATION_LINKS } from "../../shared/documentationLinks.js"; + +function setHidden(id: string, hidden: boolean): void { + document.getElementById(id)?.classList.toggle("hidden", hidden); +} + +function setDisabled(id: string, disabled: boolean): void { + const button = document.getElementById(id) as HTMLButtonElement | null; + if (button) { + button.disabled = disabled; + } +} + +function setButton(id: string, label: string, disabled: boolean): void { + const button = document.getElementById(id) as HTMLButtonElement | null; + if (!button) { + return; + } + button.textContent = label; + button.disabled = disabled; +} + +function formatMs(milliseconds: number): string { + const totalSeconds = Math.floor(milliseconds / 1000); + return `${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, "0")}`; +} + +export function markup(): string { + return ` +
+
+

Encounter

+ transcribe-ws ↗ +
+ + + + +
Transcript
+
+
Transcript will appear here…
+
+ +
+ + + + +
+ + +
+
+
`; +} + +// ── Capture states ───────────────────────────────────────────────────────────────── + +export function setReviewState(hasTranscript: boolean): void { + setHidden("start-btn", false); + setHidden("stop-btn", true); + setHidden("recording-dot", true); + setHidden("fill-mock-btn", false); + setHidden("finishing-msg", true); + setButton( + "start-btn", + hasTranscript ? "Re-record" : "Start recording", + false, + ); + setHidden("generate-note-btn", false); + setDisabled("generate-note-btn", !hasTranscript); +} + +export function setRecordingState(): void { + setHidden("start-btn", true); + setHidden("stop-btn", false); + setHidden("recording-dot", false); + setHidden("fill-mock-btn", true); + setHidden("finishing-msg", true); + setHidden("generate-note-btn", false); + setDisabled("generate-note-btn", false); +} + +export function setFinishingState(): void { + setHidden("stop-btn", true); + setHidden("recording-dot", true); + setHidden("fill-mock-btn", true); + setDisabled("generate-note-btn", true); + setHidden("finishing-msg", false); +} + +// ── Transcript ─────────────────────────────────────────────────────────────────── + +export function renderFullTranscript(items: TranscriptItem[]): void { + const container = document.getElementById("transcript-items"); + if (container) { + container.innerHTML = ""; + } + items.forEach(renderTranscriptItem); +} + +export function renderTranscriptItem(item: TranscriptItem): void { + const container = document.getElementById("transcript-items"); + if (!container) { + return; + } + document.getElementById("transcript-placeholder")?.remove(); + const element = document.createElement("div"); + element.id = `ti-${item.id}`; + element.className = item.is_final ? "text-grey-400" : "text-grey-250 italic"; + const speaker = + item.speaker_type === "DOCTOR" + ? "Doctor> " + : item.speaker_type === "PATIENT" + ? "Patient> " + : ""; + element.textContent = `[${formatMs(item.start_offset_ms)}..${formatMs(item.end_offset_ms)}] ${speaker}${item.text}`; + container.appendChild(element); + container.scrollTop = container.scrollHeight; +} + +export function readPatientContext(): string { + return (document.getElementById("patient-context") as HTMLTextAreaElement) + .value; +} diff --git a/frontend/src/pages/full-encounter-demo/record.ts b/frontend/src/pages/full-encounter-demo/record.ts new file mode 100644 index 0000000..33ea7fe --- /dev/null +++ b/frontend/src/pages/full-encounter-demo/record.ts @@ -0,0 +1,117 @@ +import type { TranscriptItem } from "../../api/transcribe.js"; +import { + type AudioSource, + type AudioStream, + openAudioStream, +} from "../../audio/audio-source.js"; +import { saveTranscriptItems } from "../../shared/storage.js"; +import { TranscriptionSession } from "../../transcribe/transcription-session.js"; +import { + markup, + readPatientContext, + renderFullTranscript, + setFinishingState, + setRecordingState, + setReviewState, +} from "./record.render.js"; +import { mountStep, type StepTeardown, showError } from "./step.js"; + +interface RecordOptions { + audioSource: AudioSource; + onNext: (result: { + transcript: TranscriptItem[]; + patientContext: string; + }) => void; +} + +// Record step: capture an encounter and produce a transcript, then hand it to the note step. +export function startStep( + rootSelector: string, + { audioSource, onNext }: RecordOptions, +): StepTeardown { + return mountStep(rootSelector, markup(), ({ root, signal }) => { + // One session for the whole step; each take is a start()/stop() on it. `audio` + // being non-null means a take is in progress. Per-mount closure, so re-entering + // the step (via restart) starts fresh. + const transcriptionSession = new TranscriptionSession(); + let audio: AudioStream | null = null; + + // Re-render the live transcript from the session as items arrive (wired once). + transcriptionSession.onTranscriptItem(() => + renderFullTranscript(transcriptionSession.items()), + ); + + async function startRecording(): Promise { + setRecordingState(); + await transcriptionSession.start(); + // The page bridges audio → session; neither side knows about the other. + audio = await openAudioStream(audioSource, (pcm) => + transcriptionSession.sendAudio(pcm), + ); + } + + // Stop button: end the take and return to the review state. + async function stopRecording(): Promise { + await stopRecordingAndWaitForItems(); + setReviewState(transcriptionSession.items().length > 0); + } + + // Generate ends any active take first, then hands the accumulated transcript + // (live, mock, or both) to the note step. + async function generate(): Promise { + await stopRecordingAndWaitForItems(); + proceedToNote(); + } + + // Stop the take and wait for the server's remaining transcript items. + async function stopRecordingAndWaitForItems(): Promise { + audio?.stop(); + audio = null; + setFinishingState(); + await transcriptionSession.stop(); + } + + function proceedToNote(): void { + const transcript = transcriptionSession.items(); + saveTranscriptItems(transcript); + onNext({ transcript, patientContext: readPatientContext() }); + } + + async function fillMock(): Promise { + transcriptionSession.clear(); + transcriptionSession.addItems(await loadMockTranscript()); + renderFullTranscript(transcriptionSession.items()); + setReviewState(true); + } + + root + .querySelector("#start-btn") + ?.addEventListener("click", handle(startRecording), { signal }); + root + .querySelector("#stop-btn") + ?.addEventListener("click", handle(stopRecording), { signal }); + root + .querySelector("#fill-mock-btn") + ?.addEventListener("click", handle(fillMock), { signal }); + root + .querySelector("#generate-note-btn") + ?.addEventListener("click", handle(generate), { signal }); + setReviewState(false); + // Leaving the step mid-recording abandons the connection + mic. + signal.addEventListener("abort", () => { + audio?.stop(); + transcriptionSession.disconnect(); + }); + }); +} + +// Run an async click handler, surfacing failures via showError +const handle = (action: () => Promise) => + (): void => { + void action().catch(showError); + }; + +async function loadMockTranscript(): Promise { + const response = await fetch("/transcript_items.json"); + return response.json() as Promise; +} diff --git a/frontend/src/pages/full-encounter-demo/setup.render.ts b/frontend/src/pages/full-encounter-demo/setup.render.ts new file mode 100644 index 0000000..9d4beae --- /dev/null +++ b/frontend/src/pages/full-encounter-demo/setup.render.ts @@ -0,0 +1,24 @@ +import type { AudioSource } from "../../audio/audio-source.js"; + +export function markup(): string { + return ` +
+

Start Encounter

+

Choose how you'll capture the encounter audio.

+ + + + + +
`; +} + +export function readAudioSource(root: HTMLElement): AudioSource { + return (root.querySelector("#audio-source") as HTMLSelectElement) + .value as AudioSource; +} diff --git a/frontend/src/pages/full-encounter-demo/setup.ts b/frontend/src/pages/full-encounter-demo/setup.ts new file mode 100644 index 0000000..38d20fd --- /dev/null +++ b/frontend/src/pages/full-encounter-demo/setup.ts @@ -0,0 +1,21 @@ +import type { AudioSource } from "../../audio/audio-source.js"; +import { markup, readAudioSource } from "./setup.render.js"; +import { mountStep, type StepTeardown } from "./step.js"; + +interface SetupOptions { + onNext: (audioSource: AudioSource) => void; +} + +// Setup step allows the user to choose the audio source (microphone or mock audio file) +export function startStep( + rootSelector: string, + { onNext }: SetupOptions, +): StepTeardown { + return mountStep(rootSelector, markup(), ({ root, signal }) => { + root + .querySelector("#setup-next-btn") + ?.addEventListener("click", () => onNext(readAudioSource(root)), { + signal, + }); + }); +} diff --git a/frontend/src/pages/full-encounter-demo/step.ts b/frontend/src/pages/full-encounter-demo/step.ts new file mode 100644 index 0000000..1bdd526 --- /dev/null +++ b/frontend/src/pages/full-encounter-demo/step.ts @@ -0,0 +1,33 @@ +// The lifecycle every demo step shares: render its own markup into a container, wire +// listeners that auto-detach on teardown, then clear the container when it leaves. +// This is the "each step owns its DOM" model, encoded once so every step is identical. + +export interface StepContext { + // The container the step renders into; controllers query within it to wire listeners. + // (Render helpers look elements up globally by id — safe because only one step is + // mounted at a time, so ids stay unique.) + root: HTMLElement; + // Wire every listener with { signal }: leaving the step aborts it, so they all detach. + signal: AbortSignal; +} + +export type StepTeardown = () => void; + +export function mountStep( + rootSelector: string, + html: string, + setup: (context: StepContext) => void, +): StepTeardown { + const root = document.querySelector(rootSelector) as HTMLElement; + root.innerHTML = html; + const controller = new AbortController(); + setup({ root, signal: controller.signal }); + return () => { + controller.abort(); // detaches every listener wired with this signal + root.innerHTML = ""; + }; +} + +export function showError(error: unknown): void { + alert(error instanceof Error ? error.message : String(error)); +} diff --git a/frontend/src/pages/full-encounter-demo/work-on-note.render.ts b/frontend/src/pages/full-encounter-demo/work-on-note.render.ts new file mode 100644 index 0000000..25a632b --- /dev/null +++ b/frontend/src/pages/full-encounter-demo/work-on-note.render.ts @@ -0,0 +1,272 @@ +import type { NoteTemplate } from "../../api/note-settings.js"; +import type { NormalizedData } from "../../api/normalize.js"; +import type { ClinicalNote } from "../../api/note.js"; +import type { + InstructionsLocale, + RecipientType, +} from "../../api/patient-instructions.js"; +import { DOCUMENTATION_LINKS } from "../../shared/documentationLinks.js"; + +function show(id: string): void { + document.getElementById(id)?.classList.remove("hidden"); +} +function hide(id: string): void { + document.getElementById(id)?.classList.add("hidden"); +} +function setDisabled(id: string, disabled: boolean): void { + const button = document.getElementById(id) as HTMLButtonElement | null; + if (button) { + button.disabled = disabled; + } +} +function setButton(id: string, label: string, disabled: boolean): void { + const button = document.getElementById(id) as HTMLButtonElement | null; + if (!button) { + return; + } + button.textContent = label; + button.disabled = disabled; +} + +// The step mounts in its loading state: the Note zone shows a loader and the +// derivations are disabled until renderNote() arrives with the generated note. +export function markup(): string { + return ` +
+ + +
+ +

Edit the note here — the generators on the right use the note below. In a real integration, you might want to allow users per-section edits.

+
+
+ + +
+ +
+
+ + Generating note from transcript and patient context… +
+ +
+ + +
+ + +
+
+

Normalize Data

+ POST /generate-normalized-data ↗ +
+

Extract ICD-10 / LOINC codes in FHIR format from the note.

+ + +
+ + +
+
+

Patient Instructions

+ POST /generate-patient-instructions ↗ +
+

Generate plain-language post-visit instructions from the note.

+
+
+ + +
+
+ + +
+
+ + +
+ +
+
`; +} + +export function renderNote(note: ClinicalNote): void { + const textarea = document.getElementById( + "note-json", + ) as HTMLTextAreaElement | null; + if (textarea) { + textarea.value = JSON.stringify(note, null, 2); + } + hide("note-loading"); + show("note-json"); + setButton("note-generate-btn", "Generate note", false); + setDisabled("generate-normalized-btn", false); + setDisabled("generate-instructions-btn", false); +} + +// Clears the loading state and re-enables the Generate button when generation +// finishes — including on error, so the user can retry instead of staying stuck. +export function resetNoteGenerating(): void { + hide("note-loading"); + setButton("note-generate-btn", "Generate note", false); +} + +export function renderTemplateOptions(templates: NoteTemplate[]): void { + const select = document.getElementById( + "note-template", + ) as HTMLSelectElement | null; + if (!select) { + return; + } + // Build options as elements (not innerHTML) so API-provided titles can't break the + // markup or inject HTML. + select.replaceChildren( + ...templates.map((template) => { + const option = document.createElement("option"); + option.value = template.key; + option.textContent = template.title; + return option; + }), + ); + // Prefer the "Multiple Sections" template when it's available, else the first. + const preferred = templates.find( + (template) => template.key === "GENERIC_MULTIPLE_SECTIONS", + ); + if (preferred) { + select.value = preferred.key; + } + // Templates are loaded and a real key is selected — generation is now possible. + setDisabled("note-generate-btn", false); +} + +export function readNoteTemplateKey(): string { + return (document.getElementById("note-template") as HTMLSelectElement).value; +} + +// Generating (or regenerating) a note: show the loader, lock the buttons, and clear +// the derived outputs — the normalized codes and instructions belong to the old note. +export function setNoteGenerating(): void { + show("note-loading"); + hide("note-json"); + setButton("note-generate-btn", "Generating…", true); + hide("normalized-output"); + hide("instructions-output"); + const conditions = document.getElementById("normalized-conditions"); + if (conditions) { + conditions.innerHTML = ""; + } + const instructions = document.getElementById("instructions-output"); + if (instructions) { + instructions.textContent = ""; + } + // Reset the derive buttons' labels, then disable them last — they must not run + // against a note that's still generating (would read an empty/hidden textarea). + resetNormalizeButton(); + resetInstructionsButton(); + setDisabled("generate-normalized-btn", true); + setDisabled("generate-instructions-btn", true); +} + +// The note JSON is editable, so the textarea is the source of truth for the +// downstream calls. Throws if the user has made it invalid JSON. +export function readNoteDraft(): ClinicalNote { + const rawJson = (document.getElementById("note-json") as HTMLTextAreaElement) + .value; + return JSON.parse(rawJson) as ClinicalNote; +} + +export function readInstructionsLocale(): InstructionsLocale { + return (document.getElementById("instructions-locale") as HTMLSelectElement) + .value as InstructionsLocale; +} + +export function readRecipientType(): RecipientType { + return (document.getElementById("recipient-type") as HTMLSelectElement) + .value as RecipientType; +} + +export function setNormalizeLoading(): void { + setButton("generate-normalized-btn", "Extracting…", true); +} +export function resetNormalizeButton(): void { + setButton("generate-normalized-btn", "Extract normalized data", false); +} +export function setInstructionsLoading(): void { + setButton("generate-instructions-btn", "Generating…", true); +} +export function resetInstructionsButton(): void { + setButton("generate-instructions-btn", "Generate instructions", false); +} + +export function renderConditions(normalizedData: NormalizedData): void { + const container = document.getElementById("normalized-conditions"); + if (container) { + if (normalizedData.conditions.length === 0) { + const empty = document.createElement("p"); + empty.className = "text-xs text-grey-250 italic"; + empty.textContent = "No conditions extracted."; + container.replaceChildren(empty); + } else { + // Build rows as elements (not innerHTML) so API-provided codes/displays are + // treated as text and can't break the markup. + container.replaceChildren( + ...normalizedData.conditions.map((condition) => { + const row = document.createElement("div"); + row.className = "flex items-center gap-3 text-xs"; + + const code = document.createElement("span"); + code.className = + "bg-primary-100 text-primary-700 px-2 py-0.5 rounded font-mono shrink-0"; + code.textContent = condition.coding.code; + + const display = document.createElement("span"); + display.className = "text-grey-400"; + display.textContent = condition.coding.display; + + const status = document.createElement("span"); + status.className = "ml-auto text-grey-250 shrink-0"; + status.textContent = condition.clinical_status ?? ""; + + row.append(code, display, status); + return row; + }), + ); + } + } + show("normalized-output"); +} + +export function renderInstructions(text: string): void { + const output = document.getElementById("instructions-output"); + if (output) { + output.textContent = text; + } + show("instructions-output"); +} diff --git a/frontend/src/pages/full-encounter-demo/work-on-note.ts b/frontend/src/pages/full-encounter-demo/work-on-note.ts new file mode 100644 index 0000000..be036e3 --- /dev/null +++ b/frontend/src/pages/full-encounter-demo/work-on-note.ts @@ -0,0 +1,115 @@ +import { + listNoteTemplates, + type NoteLocale, + updateNoteSettings, +} from "../../api/note-settings.js"; +import { generateNormalizedData as apiGenerateNormalizedData } from "../../api/normalize.js"; +import { generateNote as apiGenerateNote } from "../../api/note.js"; +import { generatePatientInstructions as apiGeneratePatientInstructions } from "../../api/patient-instructions.js"; +import type { TranscriptItem } from "../../api/transcribe.js"; +import { mountStep, type StepTeardown, showError } from "./step.js"; +import { + markup, + readInstructionsLocale, + readNoteDraft, + readNoteTemplateKey, + readRecipientType, + renderConditions, + renderInstructions, + renderNote, + renderTemplateOptions, + resetInstructionsButton, + resetNoteGenerating, + resetNormalizeButton, + setInstructionsLoading, + setNoteGenerating, + setNormalizeLoading, +} from "./work-on-note.render.js"; + +// The note locale is set alongside the template; kept fixed here for simplicity. +const NOTE_LOCALE: NoteLocale = "ENGLISH_US"; + +interface WorkOnNoteOptions { + transcript: TranscriptItem[]; + patientContext: string; +} + +export function startStep( + rootSelector: string, + { transcript, patientContext }: WorkOnNoteOptions, +): StepTeardown { + return mountStep(rootSelector, markup(), ({ root, signal }) => { + + // Load the template library, then generate an initial note with the first one. + void init(); + + async function init(): Promise { + try { + renderTemplateOptions(await listNoteTemplates()); + } catch (error) { + showError(error); + return; + } + await generateNote(); + } + + // Set the chosen template as the user's note settings, then generate the note. + // Clears the derived outputs, which belonged to the previous note. + async function generateNote(): Promise { + setNoteGenerating(); + try { + await updateNoteSettings({ + noteTemplateKey: readNoteTemplateKey(), + noteLocale: NOTE_LOCALE, + }); + renderNote( + await apiGenerateNote({ transcriptItems: transcript, patientContext }), + ); + } catch (error) { + showError(error); + } finally { + resetNoteGenerating(); + } + } + + async function generateNormalizedData(): Promise { + setNormalizeLoading(); + try { + // Generate the normalized data using the generated note + renderConditions(await apiGenerateNormalizedData(readNoteDraft())); + } catch (error) { + showError(error); + } finally { + resetNormalizeButton(); + } + } + + async function generatePatientInstructions(): Promise { + setInstructionsLoading(); + try { + renderInstructions( + // Generate the patient instructions using the generated note + await apiGeneratePatientInstructions({ + note: readNoteDraft(), + instructions_locale: readInstructionsLocale(), + recipient_type: readRecipientType(), + }), + ); + } catch (error) { + showError(error); + } finally { + resetInstructionsButton(); + } + } + + root + .querySelector("#note-generate-btn") + ?.addEventListener("click", () => void generateNote(), { signal }); + root + .querySelector("#generate-normalized-btn") + ?.addEventListener("click", () => void generateNormalizedData(), { signal }); + root + .querySelector("#generate-instructions-btn") + ?.addEventListener("click", () => void generatePatientInstructions(), { signal }); + }); +} diff --git a/frontend/src/pages/in-depth/dictate/dictate.html b/frontend/src/pages/in-depth/dictate/dictate.html new file mode 100644 index 0000000..ded6a13 --- /dev/null +++ b/frontend/src/pages/in-depth/dictate/dictate.html @@ -0,0 +1,113 @@ + + + + + + Dictate — In-depth — Nabla Core API + + + + +
+ + +
+
Home / In-depth / Dictate
+
+
+
+

Dictate

+ WebSocket + wss://.../dictate-ws +
+

Live clinical dictation. Streams PCM-16 audio over WebSocket and returns dictated text segments to append directly into the note — punctuation is spoken explicitly ("period", "comma").

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+

Configuration

+
+ + +
+

Punctuation mode is EXPLICIT.

+
+ + +
+
+ + + + +
+ + +
+
Note
+ +
+
+ +
+ + +
+
+
+ WebSocket Log + + Idle + +
+
+ + +
+
+
No messages yet
+
+ +
+
+
+
+ + + + +
+ + diff --git a/frontend/src/pages/in-depth/dictate/dictate.render.ts b/frontend/src/pages/in-depth/dictate/dictate.render.ts new file mode 100644 index 0000000..b8926c7 --- /dev/null +++ b/frontend/src/pages/in-depth/dictate/dictate.render.ts @@ -0,0 +1,74 @@ +import { DICTATE_LOCALES, type DictationLocale } from "../../../api/dictate.js"; + +export { renderCodeSnippets } from "../../../shared/codeSnippets.render.js"; +// The WebSocket log and code-snippet rendering are generic and shared with the +// transcribe page. +export { + addWsMessage, + resetLog, + switchWsTab, + updateWsStatus, +} from "../../../shared/wsLog.render.js"; + +export function renderLocaleOptions(): void { + const select = document.getElementById( + "dictate-locale", + ) as HTMLSelectElement | null; + if (!select) { + return; + } + select.innerHTML = DICTATE_LOCALES.map( + (locale) => ``, + ).join(""); +} + +export function readLocaleSelection(): DictationLocale { + return (document.getElementById("dictate-locale") as HTMLSelectElement) + .value as DictationLocale; +} + +export function getNoteText(): string { + return (document.getElementById("dictation-note") as HTMLTextAreaElement) + .value; +} + +// dictate-ws emits DICTATED_TEXT segments that must be appended verbatim — no extra +// spaces, punctuation, or formatting (the server already handles those). +export function appendDictatedText(text: string): void { + const note = document.getElementById("dictation-note") as HTMLTextAreaElement; + note.value += text; + note.scrollTop = note.scrollHeight; +} + +export function clearNote(): void { + (document.getElementById("dictation-note") as HTMLTextAreaElement).value = ""; +} + +export function setLoadingState(): void { + const startButton = document.getElementById("start-btn") as HTMLButtonElement; + startButton.textContent = "Connecting…"; + startButton.disabled = true; +} + +export function setRecordingState(): void { + document.getElementById("start-btn")?.classList.add("hidden"); + document.getElementById("pause-btn")?.classList.remove("hidden"); + document.getElementById("recording-dot")?.classList.remove("hidden"); + (document.getElementById("dictate-locale") as HTMLSelectElement).disabled = + true; + (document.getElementById("dictation-note") as HTMLTextAreaElement).readOnly = + true; +} + +export function setIdleState(): void { + const startButton = document.getElementById("start-btn") as HTMLButtonElement; + startButton.textContent = "Start"; + startButton.disabled = false; + startButton.classList.remove("hidden"); + document.getElementById("pause-btn")?.classList.add("hidden"); + document.getElementById("recording-dot")?.classList.add("hidden"); + (document.getElementById("dictate-locale") as HTMLSelectElement).disabled = + false; + (document.getElementById("dictation-note") as HTMLTextAreaElement).readOnly = + false; +} diff --git a/frontend/src/pages/in-depth/dictate/dictate.ts b/frontend/src/pages/in-depth/dictate/dictate.ts new file mode 100644 index 0000000..1327e14 --- /dev/null +++ b/frontend/src/pages/in-depth/dictate/dictate.ts @@ -0,0 +1,244 @@ +import { + type AudioChunkAck, + buildDictateAudioChunk, + buildDictateConfig, + connectDictateWebSocket, + DICTATE_END_MESSAGE, + type DictatedText, + type DictateServerMessage, +} from "../../../api/dictate.js"; +import dictateApiSource from "../../../api/dictate.ts?raw"; +import { startMicrophoneStream } from "../../../audio/mic-stream.js"; +import micStreamSource from "../../../audio/mic-stream.ts?raw"; +import rawPcm16ProcessorSource from "../../../audio/rawPcm16Processor.js?raw"; +import { extractRegion } from "../../../shared/codeExtract.js"; +import { initPageChrome } from "../../../shared/page-chrome.js"; +import clientSource from "../../../transport/client.ts?raw"; +import { + addWsMessage, + appendDictatedText, + clearNote, + getNoteText, + readLocaleSelection, + renderCodeSnippets, + renderLocaleOptions, + resetLog, + setIdleState, + setLoadingState, + setRecordingState, + switchWsTab, + updateWsStatus, +} from "./dictate.render.js"; +import dictatePageSource from "./dictate.ts?raw"; + +// The "Code" tab is rendered from these real source regions — see #region markers. +const CODE_SNIPPETS = [ + { + title: "1. Capture mic audio in 100 ms PCM chunks", + file: "audio/mic-stream.ts", + source: micStreamSource, + region: "microphone-stream", + }, + { + title: "2. Convert mic audio to PCM-16 (AudioWorklet)", + file: "audio/rawPcm16Processor.js", + source: rawPcm16ProcessorSource, + region: "pcm16-worklet", + }, + { + title: "3. Open the socket with auth", + file: "transport/client.ts", + source: clientSource, + region: "nabla-websocket", + }, + { + title: "4. Build the CONFIG and audio-chunk messages", + file: "api/dictate.ts", + source: dictateApiSource, + region: "dictate-messages", + }, + { + title: "5. Receive DICTATED_TEXT & append verbatim", + file: "pages/in-depth/dictate/dictate.ts", + source: dictatePageSource, + region: "dictate-receive", + }, + { + title: "6. Pace sending with audio-chunk ACKs", + file: "pages/in-depth/dictate/dictate.ts", + source: dictatePageSource, + region: "dictate-pacing", + }, +]; + +let socket: WebSocket | null = null; +let microphone: { stop: () => void } | null = null; +let finalizing = false; + +// After we send END the server flushes its remaining dictated texts and then closes +// the socket — that close is how we know everything has arrived. Re-armed per connection. +let serverClosed: Promise = Promise.resolve(); +let resolveServerClosed: () => void = () => {}; + +function armServerClosed(): void { + serverClosed = new Promise((resolve) => { + resolveServerClosed = resolve; + }); +} + +// #region dictate-pacing +// dictate-ws acknowledges audio chunks and accepts at most ~10s of un-acked audio +// before erroring. We send chunks as the microphone produces them, but hold back +// once too many are outstanding and drain the backlog as ACKs arrive. (This is the +// lightweight version — the transcribe page shows full buffering + reconnect.) +const MAX_UNACKED_CHUNKS = 90; // ~9s at 100ms per chunk, just under the server's 10s limit +let nextSeqId = 0; +let lastAckId = -1; +const pendingChunks: Int16Array[] = []; + +function resetPacing(): void { + nextSeqId = 0; + lastAckId = -1; + pendingChunks.length = 0; +} + +function queueAudioChunk(chunk: Int16Array): void { + pendingChunks.push(chunk); + drainPendingChunks(); +} + +function drainPendingChunks(): void { + while ( + socket?.readyState === WebSocket.OPEN && + pendingChunks.length > 0 && + nextSeqId - lastAckId < MAX_UNACKED_CHUNKS + ) { + const message = buildDictateAudioChunk( + nextSeqId++, + pendingChunks.shift()!, + ); + socket.send(JSON.stringify(message)); + addWsMessage("send", JSON.stringify(message)); + } +} + +function handleAck(ackId: number): void { + lastAckId = ackId; + drainPendingChunks(); +} +// #endregion dictate-pacing + +function main(): void { + initPageChrome(); + renderLocaleOptions(); + setIdleState(); + renderCodeSnippets( + CODE_SNIPPETS.map((snippet) => ({ + title: snippet.title, + label: `${snippet.file} · #region ${snippet.region}`, + code: extractRegion(snippet.source, snippet.region), + })), + ); + exposePageHandlers(); +} + +function exposePageHandlers(): void { + const windowHandlers = window as unknown as Record; + windowHandlers.switchWsTab = switchWsTab; + windowHandlers.startDictation = startDictation; + windowHandlers.pauseDictation = pauseDictation; + windowHandlers.clearDictation = clearDictation; +} + +main(); + +export async function startDictation(): Promise { + setLoadingState(); + try { + const locale = readLocaleSelection(); + const noteText = getNoteText(); + resetLog(); + switchWsTab("key"); + resetPacing(); + microphone = await startMicrophoneStream((chunk) => queueAudioChunk(chunk)); + updateWsStatus("connecting"); + socket = await connectDictateWebSocket(); + updateWsStatus("connected"); + addWsMessage("system", "WebSocket connected"); + armServerClosed(); + attachSocketHandlers(); + // send the config before sending any audio + sendConfig(locale, noteText); + drainPendingChunks(); + setRecordingState(); + } catch (error) { + alert(error instanceof Error ? error.message : String(error)) + microphone?.stop(); + microphone = null; + socket?.close(); + socket = null; + setIdleState(); + } +} + +// Graceful pause: stop the mic, tell the server we're done, then wait for it to flush +// the remaining dictated texts and close the socket. The note is kept so Start resumes it. +export async function pauseDictation(): Promise { + if (!socket || finalizing) { + return; + } + finalizing = true; + microphone?.stop(); + microphone = null; + if (socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify(DICTATE_END_MESSAGE)); + addWsMessage("send", JSON.stringify(DICTATE_END_MESSAGE)); + } + await serverClosed; + socket = null; + setIdleState(); + finalizing = false; +} + +// Clear the note — this is how you start a fresh dictation (Clear, then Start). +export function clearDictation(): void { + clearNote(); +} + +function sendConfig( + locale: Parameters[0], + noteText: string, +): void { + const config = buildDictateConfig(locale, noteText); + socket?.send(JSON.stringify(config)); + addWsMessage("send", JSON.stringify(config)); +} + +// #region dictate-receive +function attachSocketHandlers(): void { + if (!socket) { + return; + } + socket.onmessage = (event: MessageEvent) => { + const message = JSON.parse(event.data) as DictateServerMessage; + addWsMessage("recv", event.data); + if (message.type === "DICTATED_TEXT") { + appendDictatedText((message as DictatedText).text); + } else if (message.type === "AUDIO_CHUNK_ACK") { + handleAck((message as AudioChunkAck).ack_id); + } + }; + socket.onclose = () => { + updateWsStatus("closed"); + addWsMessage("system", "WebSocket closed"); + resolveServerClosed(); + // Server-initiated close (e.g. silence timeout 83011) while still recording. + if (!finalizing) { + microphone?.stop(); + microphone = null; + socket = null; + setIdleState(); + } + }; +} +// #endregion dictate-receive diff --git a/frontend/src/pages/in-depth/transcribe/transcribe.html b/frontend/src/pages/in-depth/transcribe/transcribe.html new file mode 100644 index 0000000..a37b8ba --- /dev/null +++ b/frontend/src/pages/in-depth/transcribe/transcribe.html @@ -0,0 +1,170 @@ + + + + + + Transcribe — In-depth — Nabla Core API + + + + +
+ + +
+
Home / In-depth / Transcribe
+
+
+
+

Transcribe

+ WebSocket + wss://.../transcribe-ws +
+

Real-time multi-language transcription of medical encounters. Streams PCM-16 audio over WebSocket and returns transcript items as they are recognized.

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+

Configuration

+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + + + +
+ + + + + + +
+ +
+ + +
+
+
+ WebSocket Log + + Idle + +
+
+ + +
+
+
No messages yet
+
+ +
+
+
+
+ + + + +
+ + diff --git a/frontend/src/pages/in-depth/transcribe/transcribe.render.ts b/frontend/src/pages/in-depth/transcribe/transcribe.render.ts new file mode 100644 index 0000000..10973f0 --- /dev/null +++ b/frontend/src/pages/in-depth/transcribe/transcribe.render.ts @@ -0,0 +1,178 @@ +import type { AudioSource } from "../../../audio/audio-source.js"; +import type { TranscriptItem } from "../../../api/transcribe.js"; + +export { renderCodeSnippets } from "../../../shared/codeSnippets.render.js"; +// The WebSocket log and code-snippet rendering are generic and shared with the +// dictate page; re-exported here so this stays the transcribe page's single +// render entry point. +export { + addWsMessage, + resetLog, + switchWsTab, + updateWsStatus, +} from "../../../shared/wsLog.render.js"; + +function setBar(barId: string, percent: number, label: string): void { + const barElement = document.getElementById(`bar-${barId}`); + const labelElement = document.getElementById(`lbl-${barId}`); + if (barElement) { + barElement.style.width = `${percent}%`; + } + if (labelElement) { + labelElement.textContent = label; + } +} + +function formatMs(milliseconds: number): string { + const totalSeconds = Math.floor(milliseconds / 1000); + return `${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, "0")}`; +} + +export function updateTranscriptStats(items: TranscriptItem[]): void { + const finalCount = items.filter((item) => item.is_final).length; + const inProgressCount = items.filter((item) => !item.is_final).length; + const finalLabel = document.getElementById("lbl-final"); + const pendingLabel = document.getElementById("lbl-pending"); + if (finalLabel) { + finalLabel.textContent = `${finalCount} final`; + } + if (pendingLabel) { + pendingLabel.textContent = `${inProgressCount} in progress`; + } +} + +// Re-renders the whole transcript from the current set of items every update. Items +// can arrive out of order and as partials that finalize later, so rather than patch +// individual elements (easy to get wrong on reconnect/replay), we rebuild from the +// deduped source of truth each time — the displayed transcript is always the latest +// version of every item, sorted by start time. +export function renderTranscript(items: TranscriptItem[]): void { + const container = document.getElementById("transcript-items"); + if (!container) { + return; + } + container.replaceChildren(); + if (items.length === 0) { + const placeholder = document.createElement("div"); + placeholder.className = "text-grey-250 italic"; + placeholder.textContent = "Waiting for audio…"; + container.append(placeholder); + return; + } + const ordered = [...items].sort( + (left, right) => left.start_offset_ms - right.start_offset_ms, + ); + for (const item of ordered) { + const row = document.createElement("div"); + row.className = item.is_final ? "text-grey-400" : "text-grey-250 italic"; + const speaker = + item.speaker_type === "DOCTOR" + ? "Doctor> " + : item.speaker_type === "PATIENT" + ? "Patient> " + : ""; + const time = `[${formatMs(item.start_offset_ms)}..${formatMs(item.end_offset_ms)}]`; + row.textContent = `${time} ${speaker}${item.text}`; + container.append(row); + } + container.scrollTop = container.scrollHeight; +} + +export function showBufferVisualization(): void { + document.getElementById("buffer-viz")?.classList.remove("hidden"); +} + +export function updateBufferVisualization(stats: { + queued: number; + inflight: number; + totalAcked: number; +}): void { + const { queued, inflight, totalAcked } = stats; + setBar("queued", Math.min((queued / 50) * 100, 100), `${queued} pkts`); + setBar("inflight", Math.min((inflight / 50) * 100, 100), `${inflight} pkts`); + const total = queued + inflight + totalAcked; + setBar( + "acked", + total > 0 ? Math.min((totalAcked / total) * 100, 100) : 0, + `${totalAcked} pkts`, + ); +} + +let bufferVisualizationInterval: ReturnType | null = null; + +export function startBufferVisualization( + getStats: () => { queued: number; inflight: number; totalAcked: number } | undefined, +): void { + showBufferVisualization(); + if (bufferVisualizationInterval) { + clearInterval(bufferVisualizationInterval); + } + bufferVisualizationInterval = setInterval(() => { + const stats = getStats(); + if (stats) { + updateBufferVisualization(stats); + } + }, 200); +} + +export function stopBufferVisualization(): void { + if (bufferVisualizationInterval) { + clearInterval(bufferVisualizationInterval); + bufferVisualizationInterval = null; + } +} + +export function readAudioSourceSelection(): AudioSource { + return (document.getElementById("seed-select") as HTMLSelectElement) + .value as AudioSource; +} + +export function setLoadingState(): void { + const startButton = document.getElementById("start-btn") as HTMLButtonElement; + startButton.textContent = "Loading…"; + startButton.disabled = true; +} + +export function setRecordingState(isMockRecording: boolean): void { + const startButton = document.getElementById("start-btn") as HTMLButtonElement; + startButton.classList.add("hidden"); + document.getElementById("stop-btn")?.classList.remove("hidden"); + document.getElementById("recording-dot")?.classList.remove("hidden"); + document.getElementById("transcript-panel")?.classList.remove("hidden"); + if (isMockRecording) { + document.getElementById("mock-badge")?.classList.remove("hidden"); + } +} + +export function setStartState(): void { + const startButton = document.getElementById("start-btn") as HTMLButtonElement; + startButton.textContent = "Start"; + startButton.disabled = false; + startButton.classList.remove("hidden"); + document.getElementById("stop-btn")?.classList.add("hidden"); + document.getElementById("recording-dot")?.classList.add("hidden"); + document.getElementById("mock-badge")?.classList.add("hidden"); + document.getElementById("disconnect-warning")?.classList.add("hidden"); +} + +export function setDisconnectedState(): void { + document.getElementById("disconnect-btn")?.classList.add("hidden"); + document.getElementById("disconnect-warning")?.classList.remove("hidden"); +} + +export function setReconnectedState(): void { + document.getElementById("disconnect-warning")?.classList.add("hidden"); + document.getElementById("disconnect-btn")?.classList.remove("hidden"); +} + +// Active while a latency spike is in progress: the button is disabled and shows the +// buffer is absorbing it, until it auto-recovers. +export function setLatencyState(active: boolean): void { + const button = document.getElementById("latency-btn") as HTMLButtonElement | null; + if (!button) { + return; + } + button.textContent = active ? "Absorbing latency spike…" : "Simulate latency spike"; + button.disabled = active; + button.classList.toggle("opacity-60", active); +} diff --git a/frontend/src/pages/in-depth/transcribe/transcribe.ts b/frontend/src/pages/in-depth/transcribe/transcribe.ts new file mode 100644 index 0000000..1ce056a --- /dev/null +++ b/frontend/src/pages/in-depth/transcribe/transcribe.ts @@ -0,0 +1,251 @@ +import transcribeApiSource from "../../../api/transcribe.ts?raw"; +import bufferedStreamSource from "../../../transcribe/buffered-stream.ts?raw"; +import { connectTranscribeWebSocket } from "../../../api/transcribe.js"; +import { + InstrumentedWebSocket, + type ConnectionStatus, +} from "../../../transport/instrumented-websocket.js"; +import { type AudioStream, openAudioStream } from "../../../audio/audio-source.js"; +import micStreamSource from "../../../audio/mic-stream.ts?raw"; +import rawPcm16ProcessorSource from "../../../audio/rawPcm16Processor.js?raw"; +import { extractRegion } from "../../../shared/codeExtract.js"; +import { initPageChrome } from "../../../shared/page-chrome.js"; +import { saveTranscriptItems } from "../../../shared/storage.js"; +import { TranscriptionSession } from "../../../transcribe/transcription-session.js"; +import sessionSource from "../../../transcribe/transcription-session.ts?raw"; +import clientSource from "../../../transport/client.ts?raw"; +import { + addWsMessage, + readAudioSourceSelection, + renderCodeSnippets, + renderTranscript, + setDisconnectedState, + setLatencyState, + setLoadingState, + setReconnectedState, + setRecordingState, + setStartState, + startBufferVisualization, + stopBufferVisualization, + switchWsTab, + updateTranscriptStats, + updateWsStatus, +} from "./transcribe.render.js"; + +// The "Code" tab is rendered from these real source regions — see #region markers. +const CODE_SNIPPETS = [ + { + title: "1. Capture mic audio in 100 ms PCM chunks", + file: "audio/mic-stream.ts", + source: micStreamSource, + region: "microphone-stream", + }, + { + title: "2. Convert mic audio to PCM-16 (AudioWorklet)", + file: "audio/rawPcm16Processor.js", + source: rawPcm16ProcessorSource, + region: "pcm16-worklet", + }, + { + title: "3. Open the socket with auth", + file: "transport/client.ts", + source: clientSource, + region: "nabla-websocket", + }, + { + title: "4. Build the CONFIG and audio-chunk messages", + file: "api/transcribe.ts", + source: transcribeApiSource, + region: "transcribe-messages", + }, + { + title: "5. Receive transcript items & detect completion", + file: "transcribe/transcription-session.ts", + source: sessionSource, + region: "receive-transcript", + }, + { + title: "6. Buffer & replay on reconnect", + file: "transcribe/buffered-stream.ts", + source: bufferedStreamSource, + region: "buffered-audio-stream", + }, +]; + +const SIMULATED_ACK_LATENCY_MS = 10_000; + +let session: TranscriptionSession | null = null; +let audio: AudioStream | null = null; +let finalizing = false; +let latencyActive = false; +let receiveLatencyMs = 0; +let latencySpikeTimer: ReturnType | null = null; +let simulatedDisconnect = false; + +function main(): void { + initPageChrome(); + setStartState(); + renderCodeSnippets( + CODE_SNIPPETS.map((snippet) => ({ + title: snippet.title, + label: `${snippet.file} · #region ${snippet.region}`, + code: extractRegion(snippet.source, snippet.region), + })), + ); + exposePageHandlers(); +} + +function exposePageHandlers(): void { + const windowHandlers = window as unknown as Record; + windowHandlers.switchWsTab = switchWsTab; + windowHandlers.startTranscribing = startTranscribing; + windowHandlers.stopTranscribing = stopTranscribing; + windowHandlers.simulateDisconnect = simulateDisconnect; + windowHandlers.simulateReconnect = simulateReconnect; + windowHandlers.simulateHighLatency = simulateHighLatency; +} + +main(); + +// One session for the whole page: Start/Stop continue the same transcription — the +// timeline and accumulated transcript carry over. Reload the page to start fresh. +function getSession(): TranscriptionSession { + if (session) { + return session; + } + const transcriptionSession = new TranscriptionSession(async () => { + const socket = new InstrumentedWebSocket( + connectTranscribeWebSocket, + addWsMessage, + reportWsStatus, + () => receiveLatencyMs, + ); + await socket.open(); + return socket; + }); + transcriptionSession.onTranscriptItem(() => { + renderTranscript(transcriptionSession.items()); + updateTranscriptStats(transcriptionSession.items()); + }); + transcriptionSession.onClose(handleSocketClosed); + session = transcriptionSession; + return transcriptionSession; +} + +export async function startTranscribing(): Promise { + setLoadingState(); + try { + const audioSource = readAudioSourceSelection(); + switchWsTab("key"); + resetLatencySpike(); + simulatedDisconnect = false; + const transcriptionSession = getSession(); + await transcriptionSession.start(); + setRecordingState(audioSource === "wav-file"); + startBufferVisualization(() => transcriptionSession.getBufferStatistics()); + audio = await openAudioStream(audioSource, (pcm) => + transcriptionSession.sendAudio(pcm), + ); + } catch (error) { + alert(error instanceof Error ? error.message : String(error)); + setStartState(); + } +} + +function reportWsStatus(status: ConnectionStatus): void { + updateWsStatus(status); + if (status === "connected") { + addWsMessage("system", "WebSocket connected"); + } +} + +// Note: a real integration should inspect the close code (see the API docs). +function handleSocketClosed(code: number, reason: string): void { + addWsMessage( + "system", + `WebSocket closed (code ${code}${reason ? `: ${reason}` : ""})`, + ); + if (!audio || simulatedDisconnect) { + return; + } + audio.stop(); + audio = null; + stopBufferVisualization(); + resetLatencySpike(); + setStartState(); +} + +export async function stopTranscribing(): Promise { + if (session) { + await finalize(); + } +} + +// Graceful shutdown: stop audio, end the stream (END + await server close), then save. +async function finalize(): Promise { + const transcriptionSession = session; + if (!transcriptionSession || finalizing) { + return; + } + finalizing = true; + resetLatencySpike(); + audio?.stop(); + audio = null; + await transcriptionSession.stop(); + stopBufferVisualization(); + const items = transcriptionSession.items(); + if (items.length > 0) { + saveTranscriptItems(items); + } + finalizing = false; + setStartState(); + // The session is kept so the next Start continues the same transcript. +} + +export function simulateDisconnect(): void { + if (!session || !audio) { + return; + } + simulatedDisconnect = true; + setDisconnectedState(); + addWsMessage("system", "Connection lost — buffering audio locally"); + session.disconnect(); +} + +export async function simulateReconnect(): Promise { + if (!session || !audio) { + return; + } + setReconnectedState(); + const { queued, inflight } = session.getBufferStatistics(); + addWsMessage("system", `Replaying ${queued + inflight} buffered packets`); + await session.reconnect(); + simulatedDisconnect = false; +} + +// A one-shot latency spike: hold incoming frames for a few seconds so ACKs back up +// and the in-flight window fills, then auto-recover so the backlog drains. +export function simulateHighLatency(): void { + if (!audio || latencyActive) { + return; + } + latencyActive = true; + receiveLatencyMs = SIMULATED_ACK_LATENCY_MS; + setLatencyState(true); + latencySpikeTimer = setTimeout(() => { + latencySpikeTimer = null; + receiveLatencyMs = 0; + setLatencyState(false); + latencyActive = false; + }, SIMULATED_ACK_LATENCY_MS); +} + +function resetLatencySpike(): void { + if (latencySpikeTimer !== null) { + clearTimeout(latencySpikeTimer); + latencySpikeTimer = null; + } + latencyActive = false; + receiveLatencyMs = 0; + setLatencyState(false); +} diff --git a/frontend/src/pages/index.html b/frontend/src/pages/index.html new file mode 100644 index 0000000..437d7f6 --- /dev/null +++ b/frontend/src/pages/index.html @@ -0,0 +1,85 @@ + + + + + + Nabla Core API — Sample App + + + + + +
+ + +
+

Nabla Core API Sample App

+

Explore the full API end-to-end, or dive into individual endpoints with interactive demos, live request/response inspection, and annotated code samples.

+
+ + +
+
+ ⚠️ +
+

Setup required

+

Configure your OAuth client credentials to enable live API calls. Without this, only mock data is available.

+
+
+ + Set up now → + +
+ + + + + + + +
+ + diff --git a/frontend/src/pages/index.ts b/frontend/src/pages/index.ts new file mode 100644 index 0000000..7319e03 --- /dev/null +++ b/frontend/src/pages/index.ts @@ -0,0 +1,21 @@ +import { initPageChrome } from "../shared/page-chrome.js"; +import { fetchBackendStatus } from "../transport/client.js"; + +function main(): void { + initPageChrome(); + checkBackendStatus(); +} + +main(); + +async function checkBackendStatus(): Promise { + try { + const status = await fetchBackendStatus(); + // If the backend is configured, hide the setup banner + if (status.configured) { + document.getElementById("setup-banner")?.classList.add("hidden"); + } + } catch { + // Backend not running — keep the setup banner visible + } +} diff --git a/frontend/src/pages/onboarding/onboarding.html b/frontend/src/pages/onboarding/onboarding.html new file mode 100644 index 0000000..5a8f748 --- /dev/null +++ b/frontend/src/pages/onboarding/onboarding.html @@ -0,0 +1,170 @@ + + + + + + Setup — Nabla Core API + + + + +
+ +
+ ← Back +

Setup

+

Configure your Nabla Core API credentials to enable live API calls.

+
+ + +
+
+
1
+ OAuth Client +
+
+
+
2
+ Server Token +
+
+
+
3
+ Provision User +
+
+ + +
+
+

Create an OAuth Client

+

+ Generate an RSA key pair, then upload the public key to the API Admin Console when creating your OAuth Client (choose JWT Public Key). +

+ + +
+
+ + +
+
+ + Private key stays on the server — never leaves your machine. +
+ +
+ +
+
+ + +
+
+ + +
+
+
+
+ +
+
+ + + + + + + +
+ + diff --git a/frontend/src/pages/onboarding/onboarding.render.ts b/frontend/src/pages/onboarding/onboarding.render.ts new file mode 100644 index 0000000..7d9b419 --- /dev/null +++ b/frontend/src/pages/onboarding/onboarding.render.ts @@ -0,0 +1,157 @@ +export function readClientUuid(): string { + return ( + document.getElementById("client-uuid") as HTMLInputElement + ).value.trim(); +} + +export function readHost(): string { + return (document.getElementById("host") as HTMLInputElement).value.trim(); +} + +export function restoreClientUuid(uuid: string): void { + if (!uuid) { + return; + } + (document.getElementById("client-uuid") as HTMLInputElement).value = uuid; +} + +export function restoreHost(host: string): void { + (document.getElementById("host") as HTMLInputElement).value = host; +} + +export function setGenerateKeypairLoading(): void { + const button = document.getElementById( + "gen-keypair-btn", + ) as HTMLButtonElement | null; + if (!button) { + return; + } + button.textContent = "Generating…"; + button.disabled = true; +} + +export function resetGenerateKeypairButton(): void { + const button = document.getElementById( + "gen-keypair-btn", + ) as HTMLButtonElement | null; + if (!button) { + return; + } + button.textContent = "Generate key pair"; + button.disabled = false; +} + +export function setGenerateServerTokenLoading(): void { + const button = document.getElementById( + "gen-server-token-btn", + ) as HTMLButtonElement; + button.textContent = "Generating…"; + button.disabled = true; +} + +export function setGenerateServerTokenError(): void { + const button = document.getElementById( + "gen-server-token-btn", + ) as HTMLButtonElement; + button.textContent = "Error — retry"; + button.disabled = false; +} + +export function resetGenerateServerTokenButton(): void { + const button = document.getElementById( + "gen-server-token-btn", + ) as HTMLButtonElement; + button.textContent = "Regenerate"; + button.disabled = false; +} + +export function setProvisionUserLoading(): void { + const button = document.getElementById("provision-btn") as HTMLButtonElement; + button.textContent = "Provisioning…"; + button.disabled = true; +} + +export function setProvisionUserError(): void { + const button = document.getElementById("provision-btn") as HTMLButtonElement; + button.textContent = "Error — retry"; + button.disabled = false; +} + +export function resetProvisionUserButton(): void { + const button = document.getElementById("provision-btn") as HTMLButtonElement; + button.textContent = "Re-authenticate"; + button.disabled = false; +} + +export function showCopyConfirmation(): void { + const button = document.getElementById( + "copy-btn", + ) as HTMLButtonElement | null; + if (button) { + button.textContent = "✓ Copied"; + } +} + +export function resetCopyButton(): void { + const button = document.getElementById( + "copy-btn", + ) as HTMLButtonElement | null; + if (button) { + button.textContent = "Copy public key"; + } +} + +export function goToStep(targetStep: number): void { + document + .querySelectorAll(".step-panel") + .forEach((panel) => panel.classList.add("hidden")); + document.getElementById(`step-${targetStep}`)?.classList.remove("hidden"); + document.querySelectorAll("[data-step]").forEach((stepIndicator) => { + const step = parseInt(stepIndicator.getAttribute("data-step")!, 10); + const circle = stepIndicator.querySelector(".step-circle"); + const label = stepIndicator.querySelector(".step-label"); + if (!circle || !label) { + return; + } + if (step < targetStep) { + circle.className = + "w-7 h-7 rounded-full flex items-center justify-center text-xs font-semibold step-circle bg-success-100 text-success-300"; + circle.textContent = "✓"; + label.className = "text-sm font-medium step-label text-grey-300"; + } else if (step === targetStep) { + circle.className = + "w-7 h-7 rounded-full flex items-center justify-center text-xs font-semibold step-circle bg-primary-600 text-white"; + circle.textContent = String(step); + label.className = "text-sm font-medium step-label text-primary-600"; + } else { + circle.className = + "w-7 h-7 rounded-full flex items-center justify-center text-xs font-semibold step-circle bg-grey-200 text-grey-250"; + circle.textContent = String(step); + label.className = "text-sm font-medium step-label text-grey-250"; + } + }); +} + +export function showKeypairDisplay(publicKeyPem: string): void { + document.getElementById("keypair-idle")?.classList.add("hidden"); + document.getElementById("keypair-ready")?.classList.remove("hidden"); + document.getElementById("regen-btn")?.classList.remove("hidden"); + const publicKeyDisplay = document.getElementById("public-key-display"); + if (publicKeyDisplay) { + publicKeyDisplay.textContent = publicKeyPem; + } +} + +export function showServerTokenResponse(): void { + document.getElementById("server-token-response")?.classList.remove("hidden"); + (document.getElementById("step2-next") as HTMLButtonElement).disabled = false; +} + +export function showUserTokenResponse(data: { nabla_user_id?: string }): void { + document.getElementById("user-token-response")?.classList.remove("hidden"); + const userIdDisplay = document.getElementById("nabla-user-id-display"); + if (userIdDisplay && data.nabla_user_id) { + userIdDisplay.textContent = data.nabla_user_id; + } + document.getElementById("step3-finish")?.classList.remove("hidden"); +} diff --git a/frontend/src/pages/onboarding/onboarding.ts b/frontend/src/pages/onboarding/onboarding.ts new file mode 100644 index 0000000..deef543 --- /dev/null +++ b/frontend/src/pages/onboarding/onboarding.ts @@ -0,0 +1,198 @@ +import { initPageChrome } from "../../shared/page-chrome.js"; +import { saveSession } from "../../transport/auth.js"; +import { BACKEND_URL, fetchBackendStatus } from "../../transport/client.js"; +import { + goToStep, + readClientUuid, + readHost, + resetCopyButton, + resetGenerateKeypairButton, + resetGenerateServerTokenButton, + resetProvisionUserButton, + restoreClientUuid, + restoreHost, + setGenerateKeypairLoading, + setGenerateServerTokenError, + setGenerateServerTokenLoading, + setProvisionUserError, + setProvisionUserLoading, + showCopyConfirmation, + showKeypairDisplay, + showServerTokenResponse, + showUserTokenResponse, +} from "./onboarding.render.js"; + +interface ExistingKeypairResponse { + publicKeyPem: string; +} + +interface GenerateKeypairResponse { + publicKeyPem?: string; + error?: string; +} + +interface ConfigureResponse { + ok?: boolean; + error?: string; +} + +interface ServerTokenResponse { + ok?: boolean; + expiresAt?: number; + error?: string; +} + +interface ProvisionUserResponse { + access_token?: string; + refresh_token?: string; + nabla_user_id?: string; + error?: string; +} + +let currentPublicKey = ""; + +function main(): void { + initPageChrome(); + loadExistingConfig(); + loadExistingKeypair(); + exposePageHandlers(); +} + +// The backend is the single source of truth for config — pre-fill the form from +// it (not localStorage), so deleting the backend's `.cache/` truly resets Setup. +function loadExistingConfig(): void { + fetchBackendStatus() + .then((status) => { + restoreClientUuid(status.clientUuid ?? ""); + if (status.host) { + restoreHost(status.host); + } + }) + .catch(() => {}); +} + +function exposePageHandlers(): void { + const windowHandlers = window as unknown as Record; + windowHandlers.goToStep = goToStep; + windowHandlers.submitConfig = submitConfig; + windowHandlers.generateKeypair = generateKeypair; + windowHandlers.copyPublicKey = copyPublicKey; + windowHandlers.generateServerToken = generateServerToken; + windowHandlers.provisionUser = provisionUser; +} + +function loadExistingKeypair(): void { + fetch(`${BACKEND_URL}/api/keypair`) + .then((response) => + response.ok + ? (response.json() as Promise) + : Promise.reject(), + ) + .then(({ publicKeyPem }) => onKeypairGenerated(publicKeyPem)) + .catch(() => {}); +} + +main(); + +export async function generateKeypair(): Promise { + setGenerateKeypairLoading(); + try { + const response = await fetch(`${BACKEND_URL}/api/generate-keypair`, { + method: "POST", + }); + const data = (await response.json()) as GenerateKeypairResponse; + if (data.error || !data.publicKeyPem) { + throw new Error(data.error ?? "Unknown error"); + } + onKeypairGenerated(data.publicKeyPem); + } catch (error) { + alert(error instanceof Error ? error.message : "Could not reach backend"); + resetGenerateKeypairButton(); + } +} + +export function copyPublicKey(): void { + navigator.clipboard.writeText(currentPublicKey).then(() => { + showCopyConfirmation(); + setTimeout(resetCopyButton, 2000); + }); +} + +export async function submitConfig(): Promise { + const clientUuid = readClientUuid(); + const host = readHost(); + if (!clientUuid || !host) { + alert("Please fill in all required fields"); + return; + } + if (!currentPublicKey) { + alert("Generate a key pair first"); + return; + } + try { + const response = await fetch(`${BACKEND_URL}/api/configure`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + clientUuid, + host, + }), + }); + const data = (await response.json()) as ConfigureResponse; + if (data.error) { + throw new Error(data.error); + } + goToStep(2); + } catch (error) { + alert(error instanceof Error ? error.message : "Configuration failed"); + } +} + +export async function generateServerToken(): Promise { + setGenerateServerTokenLoading(); + try { + const response = await fetch(`${BACKEND_URL}/api/server-token`, { + method: "POST", + }); + const data = (await response.json()) as ServerTokenResponse; + if (data.error) { + throw new Error(data.error); + } + showServerTokenResponse(); + resetGenerateServerTokenButton(); + } catch (error) { + alert(error instanceof Error ? error.message : "Token generation failed"); + setGenerateServerTokenError(); + } +} + +export async function provisionUser(): Promise { + setProvisionUserLoading(); + try { + const response = await fetch(`${BACKEND_URL}/api/provision-user`, { + method: "POST", + }); + const data = (await response.json()) as ProvisionUserResponse; + if (data.error) { + throw new Error(data.error); + } + if (data.access_token && data.refresh_token) { + saveSession({ + access_token: data.access_token, + refresh_token: data.refresh_token, + }); + } + showUserTokenResponse(data); + resetProvisionUserButton(); + } catch (error) { + alert(error instanceof Error ? error.message : "User provisioning failed"); + setProvisionUserError(); + } +} + +function onKeypairGenerated(publicKeyPem: string): void { + currentPublicKey = publicKeyPem; + showKeypairDisplay(publicKeyPem); +} diff --git a/frontend/src/shared/codeExtract.ts b/frontend/src/shared/codeExtract.ts new file mode 100644 index 0000000..17b1bea --- /dev/null +++ b/frontend/src/shared/codeExtract.ts @@ -0,0 +1,12 @@ +export function extractRegion(source: string, region: string): string { + const regionStartIndex = source.indexOf(`// #region ${region}`); + if (regionStartIndex === -1) { + return ""; + } + const contentStartIndex = source.indexOf("\n", regionStartIndex) + 1; + const regionEndIndex = source.indexOf(`// #endregion ${region}`); + if (regionEndIndex === -1) { + return ""; + } + return source.slice(contentStartIndex, regionEndIndex).trimEnd(); +} diff --git a/frontend/src/shared/codeSnippets.render.ts b/frontend/src/shared/codeSnippets.render.ts new file mode 100644 index 0000000..1684fe9 --- /dev/null +++ b/frontend/src/shared/codeSnippets.render.ts @@ -0,0 +1,77 @@ +// Renders the "Code" tab from snippets pulled out of the real source files, so +// what's shown can never drift from what actually runs. Shared by the in-depth pages. +export function renderCodeSnippets( + snippets: { + title: string; + label: string; + code: string; + }[], +): void { + const container = document.getElementById("code-sections"); + if (!container) { + return; + } + container.innerHTML = snippets + .map( + (snippet) => ` +
+
+ ${snippet.title} + ${snippet.label} +
+
${highlight(snippet.code)}
+
+ `, + ) + .join(""); +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">"); +} + +// A tiny, dependency-free TypeScript highlighter. Not a real parser — it tokenizes +// in one pass (so each character is coloured at most once) and is good enough for +// read-only snippets. Order matters: comments and strings win over everything else. +const SYNTAX = new RegExp( + [ + /(\/\/[^\n]*)/.source, // 1 comment + /(`(?:\\.|[^`\\])*`|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')/.source, // 2 string + /(\b\d[\d_]*(?:\.\d+)?\b)/.source, // 3 number + /\b(const|let|var|function|return|await|async|new|if|else|for|of|in|import|from|export|type|interface|class|extends|implements|throw|try|catch|finally|while|break|continue|switch|case|default|typeof|instanceof|as|void|null|undefined|true|false|this)\b/ + .source, // 4 keyword + /([A-Za-z_$][\w$]*)(?=\s*:)/.source, // 5 property key + ].join("|"), + "g", +); + +function highlight(code: string): string { + let highlighted = ""; + let lastIndex = 0; + for (const match of code.matchAll(SYNTAX)) { + const matchStart = match.index; + highlighted += escapeHtml(code.slice(lastIndex, matchStart)); + highlighted += `${escapeHtml(match[0])}`; + lastIndex = matchStart + match[0].length; + } + return highlighted + escapeHtml(code.slice(lastIndex)); +} + +function colorFor(match: RegExpExecArray | RegExpMatchArray): string { + if (match[1]) { + return "text-grey-300"; // comment + } + if (match[2]) { + return "text-amber-300"; // string + } + if (match[3]) { + return "text-purple-400"; // number + } + if (match[4]) { + return "text-primary-400"; // keyword + } + return "text-emerald-400"; // property key +} diff --git a/frontend/src/shared/documentationLinks.ts b/frontend/src/shared/documentationLinks.ts new file mode 100644 index 0000000..33273da --- /dev/null +++ b/frontend/src/shared/documentationLinks.ts @@ -0,0 +1,8 @@ +// Single source of truth for the Nabla Core API documentation links the app points +// to, so URLs aren't scattered across pages. Update one here and every reference follows. +export const DOCUMENTATION_LINKS = { + transcribeWs: "https://docs.nabla.com/user/transcribe-ws", + generateNote: "https://docs.nabla.com/user/generate-note", + generateNormalizedData: "https://docs.nabla.com/user/generate-normalized-data", + generatePatientInstructions: "https://docs.nabla.com/user/generate-patient-instructions", +} as const; diff --git a/frontend/src/shared/navbar.render.ts b/frontend/src/shared/navbar.render.ts new file mode 100644 index 0000000..cd91761 --- /dev/null +++ b/frontend/src/shared/navbar.render.ts @@ -0,0 +1,104 @@ +import { API_VERSION } from "../api/version.js"; +import { fetchBackendStatus } from "../transport/client.js"; + +const INDEPTH_ITEMS = [ + { + label: "Transcribe", + file: "transcribe", + path: "in-depth/transcribe.html", + }, + { + label: "Dictate", + file: "dictate", + path: "in-depth/dictate.html", + }, +]; + +export function renderNavbar(): void { + const pathname = window.location.pathname; + const isInDepth = pathname.includes("/in-depth/"); + const root = isInDepth ? "../" : "./"; + const currentFile = + pathname.split("/").pop()?.replace(".html", "") ?? "index"; + + const isDemoActive = currentFile === "demo"; + const isInDepthActive = isInDepth; + + function navLink(href: string, label: string, active: boolean): string { + return `${label}`; + } + + const navElement = document.createElement("nav"); + navElement.className = + "bg-white border-b border-grey-200 flex items-center justify-between sticky top-0 z-50 h-14"; + navElement.innerHTML = ` +
+ +
+ N +
+ Nabla Core API +
+ ${navLink(`${root}demo.html`, "Full Demo", isDemoActive)} + +
+
+ v${API_VERSION} + + + Setup + +
+ `; + + document.body.insertBefore(navElement, document.body.firstChild); + + const dropdownButton = document.getElementById("nav-indepth-btn")!; + const dropdownMenu = document.getElementById("nav-indepth-menu")!; + dropdownButton.addEventListener("click", (event) => { + event.stopPropagation(); + dropdownMenu.classList.toggle("hidden"); + }); + document.addEventListener("click", () => + dropdownMenu.classList.add("hidden"), + ); + + fetchBackendStatus() + .then(({ configured }) => { + const statusElement = document.getElementById("nav-status"); + if (!statusElement) { + return; + } + if (configured) { + statusElement.innerHTML = + '
Ready'; + } else { + statusElement.innerHTML = + '
Not configured'; + } + }) + .catch(() => { + const statusElement = document.getElementById("nav-status"); + if (statusElement) { + statusElement.innerHTML = + '
Backend offline'; + } + }); +} diff --git a/frontend/src/shared/page-chrome.ts b/frontend/src/shared/page-chrome.ts new file mode 100644 index 0000000..e6a01e1 --- /dev/null +++ b/frontend/src/shared/page-chrome.ts @@ -0,0 +1,9 @@ +import { renderNavbar } from "./navbar.render.js"; +import { initTabSwitching } from "./tab-switching.js"; + +// Shared per-page bootstrap: every page's main() calls this to render the top +// navbar and wire up tab switching. +export function initPageChrome(): void { + renderNavbar(); + initTabSwitching(); +} diff --git a/frontend/src/shared/storage.ts b/frontend/src/shared/storage.ts new file mode 100644 index 0000000..4abd665 --- /dev/null +++ b/frontend/src/shared/storage.ts @@ -0,0 +1,5 @@ +import type { TranscriptItem } from "../api/transcribe.js"; + +export function saveTranscriptItems(items: TranscriptItem[]): void { + localStorage.setItem("nabla_transcript_items", JSON.stringify(items)); +} diff --git a/frontend/src/shared/tab-switching.ts b/frontend/src/shared/tab-switching.ts new file mode 100644 index 0000000..383f1d9 --- /dev/null +++ b/frontend/src/shared/tab-switching.ts @@ -0,0 +1,35 @@ +export function initTabSwitching(): void { + document.querySelectorAll("[data-tabs]").forEach((tabBar) => { + const group = tabBar.getAttribute("data-tabs")!; + const buttons = tabBar.querySelectorAll("[data-tab]"); + const panels = document.querySelectorAll( + `[data-tab-panel="${group}"][data-tab-content]`, + ); + + function activate(tab: string): void { + buttons.forEach((button) => { + const isActive = button.getAttribute("data-tab") === tab; + button.className = isActive + ? "text-sm px-4 py-3 border-b-2 border-primary-600 text-primary-600 font-medium transition-colors" + : "text-sm px-4 py-3 border-b-2 border-transparent text-grey-300 transition-colors"; + }); + panels.forEach((panel) => { + panel.classList.toggle( + "hidden", + panel.getAttribute("data-tab-content") !== tab, + ); + }); + } + + buttons.forEach((button) => { + button.addEventListener("click", () => + activate(button.getAttribute("data-tab")!), + ); + }); + + const firstTab = buttons[0]?.getAttribute("data-tab"); + if (firstTab) { + activate(firstTab); + } + }); +} diff --git a/frontend/src/shared/wsLog.render.ts b/frontend/src/shared/wsLog.render.ts new file mode 100644 index 0000000..0957528 --- /dev/null +++ b/frontend/src/shared/wsLog.render.ts @@ -0,0 +1,164 @@ +// A generic WebSocket message log used by the in-depth pages (transcribe, dictate). +// It splits high-volume audio frames into their own tab so the key protocol +// messages stay readable. + +function shortenUuids(text: string): string { + return text.replace( + /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, + (uuid) => `${uuid.slice(0, 3)}..${uuid.slice(-3)}`, + ); +} + +interface ParsedFrame { + type: string; + fields: Record; +} + +function parseFrame(raw: string): ParsedFrame | null { + try { + const { type, ...fields } = JSON.parse(raw) as Record; + return { type: String(type), fields }; + } catch { + return null; + } +} + +function formatFrame(frame: ParsedFrame): string { + const pairs = Object.entries(frame.fields).map(([key, value]) => { + const valueText = + typeof value === "string" ? value : JSON.stringify(value); + return `${key}=${shortenUuids(valueText)}`; + }); + return `${frame.type}${pairs.length ? ` ${pairs.join(" ")}` : ""}`; +} + +let wsCountKey = 0; +let wsCountAudio = 0; + +export function resetLog(): void { + wsCountKey = 0; + wsCountAudio = 0; + const logKey = document.getElementById("ws-log-key"); + const logAudio = document.getElementById("ws-log-audio"); + if (logKey) { + logKey.innerHTML = + '
No messages yet
'; + } + if (logAudio) { + logAudio.innerHTML = + '
No audio chunks yet
'; + } + const countKeyElement = document.getElementById("ws-count-key"); + const countAudioElement = document.getElementById("ws-count-audio"); + if (countKeyElement) { + countKeyElement.textContent = "0"; + } + if (countAudioElement) { + countAudioElement.textContent = "0"; + } +} + +export function addWsMessage( + direction: "send" | "recv" | "system", + message: string, +): void { + const frame = parseFrame(message); + const isAudioChunk = + frame?.type === "AUDIO_CHUNK" || frame?.type === "AUDIO_CHUNK_ACK"; + const logId = isAudioChunk ? "ws-log-audio" : "ws-log-key"; + const logElement = document.getElementById(logId); + if (!logElement) { + return; + } + logElement.querySelector(".italic")?.remove(); + + const row = document.createElement("div"); + const color = + direction === "send" + ? "text-primary-400" + : direction === "recv" + ? "text-success-300" + : "text-grey-300"; + const arrow = direction === "send" ? "→" : direction === "recv" ? "←" : "·"; + row.className = "flex gap-1.5 items-start whitespace-nowrap"; + const arrowSpan = document.createElement("span"); + arrowSpan.className = `${color} flex-shrink-0 font-bold`; + arrowSpan.textContent = arrow; + const textSpan = document.createElement("span"); + textSpan.className = "text-grey-250"; + textSpan.textContent = frame ? formatFrame(frame) : shortenUuids(message); + row.appendChild(arrowSpan); + row.appendChild(textSpan); + logElement.appendChild(row); + logElement.scrollTop = logElement.scrollHeight; + + if (isAudioChunk) { + wsCountAudio++; + const countElement = document.getElementById("ws-count-audio"); + if (countElement) { + countElement.textContent = String(wsCountAudio); + } + } else { + wsCountKey++; + const countElement = document.getElementById("ws-count-key"); + if (countElement) { + countElement.textContent = String(wsCountKey); + } + } +} + +function setTabActive(tab: HTMLElement | null, active: boolean): void { + const activeClass = ["text-primary-600", "border-primary-600", "font-medium"]; + const inactiveClass = ["text-grey-250", "border-transparent"]; + (active ? activeClass : inactiveClass).forEach((c) => tab?.classList.add(c)); + (active ? inactiveClass : activeClass).forEach((c) => tab?.classList.remove(c)); +} + +export function switchWsTab(tab: "key" | "audio"): void { + const isKey = tab === "key"; + document.getElementById("ws-log-key")?.classList.toggle("hidden", !isKey); + document.getElementById("ws-log-audio")?.classList.toggle("hidden", isKey); + setTabActive(document.getElementById("ws-tab-key"), isKey); + setTabActive(document.getElementById("ws-tab-audio"), !isKey); +} + +export function updateWsStatus( + state: "idle" | "connecting" | "connected" | "closed", +): void { + const statusElement = document.getElementById("ws-status"); + if (!statusElement) { + return; + } + const styles: Record< + string, + { + dot: string; + text: string; + label: string; + } + > = { + idle: { + dot: "bg-grey-250", + text: "text-grey-250", + label: "Idle", + }, + connecting: { + dot: "bg-warning-300", + text: "text-warning-300", + label: "Connecting…", + }, + connected: { + dot: "bg-success-300", + text: "text-success-300", + label: "Connected", + }, + closed: { + dot: "bg-grey-250", + text: "text-grey-250", + label: "Closed", + }, + }; + const style = styles[state]; + statusElement.innerHTML = ` ${style.label}`; + statusElement.className = `text-xs flex items-center gap-1.5 ${style.text}`; +} diff --git a/frontend/src/transcribe/buffered-stream.ts b/frontend/src/transcribe/buffered-stream.ts new file mode 100644 index 0000000..6125386 --- /dev/null +++ b/frontend/src/transcribe/buffered-stream.ts @@ -0,0 +1,95 @@ +import type { WebSocketInterface } from "../transport/websocket-interface.js"; + +interface SequencedMessage { + seq_id: number; + [key: string]: unknown; +} + +export interface BufferStats { + queued: number; + inflight: number; + totalAcked: number; +} + +// #region buffered-audio-stream +// We limit the number of in-flight audio chunks to 90 to avoid overwhelming the server. +// Because the server doesn't accept more than 10 seconds of audio in-flight. +// Here, we limit it to 90 chunks so it's 9 seconds of audio. +const MAX_UNACKED = 90; + +// The BufferedAudioStream is used to buffer audio chunks and replay them if the socket drops. +// So even in cases of network issues (disconnects or high latency), we keep the audio and +// we transcribe once the network is back. +export class BufferedAudioStream { + private ws: WebSocketInterface; + // Every chunk past the latest cumulative ACK, in send order. The first + // `sentCount` are on the wire awaiting an ACK; the rest are waiting for a free + // slot in the window. Acked chunks are dropped off the front; un-acked ones stay + // so they can be replayed if the socket drops. + private unacked: SequencedMessage[] = []; + private sentCount = 0; + private totalAcked = 0; + private nextSeqId = 0; + + constructor(socket: WebSocketInterface) { + this.ws = socket; + } + + // Point at the new socket and replay the un-acked buffer. + // + // Limitation: this recovers only audio that was still un-acked when the socket dropped. + // Audio the server already acked but hadn't yet turned into FINAL transcript items is + // gone from the buffer (we drop chunks on ACK), so those finals aren't recovered on an + // unexpected close. A production app would make sure to keep the audio chunks until the + // final transcript item corresponding to that audio chunk is received. + reconnect(socket: WebSocketInterface): void { + this.ws = socket; + this.sentCount = 0; + this.pump(); + } + + // The caller builds the wire message from the seq id we assign — sequencing is the + // buffer's concern (ACKs reference it, replay relies on its order). + send(buildMessage: (seqId: number) => SequencedMessage): void { + this.unacked.push(buildMessage(this.nextSeqId++)); + this.pump(); + } + + handlePacketAck(ack: { ack_id: number }): void { + // ACKs are cumulative: ack_id acknowledges every chunk up to and including it. + let acked = 0; + while ( + acked < this.unacked.length && + this.unacked[acked].seq_id <= ack.ack_id + ) { + acked++; + } + this.unacked.splice(0, acked); + this.sentCount = Math.max(0, this.sentCount - acked); + this.totalAcked += acked; + this.pump(); + } + + getStats(): BufferStats { + return { + queued: this.unacked.length - this.sentCount, + inflight: this.sentCount, + totalAcked: this.totalAcked, + }; + } + + // Send chunks until the un-acked window is full (or we run out, or the socket + // isn't open). While the socket is closed this is a no-op and chunks pile up in + // `unacked` for the next replay. + private pump(): void { + while ( + this.ws.readyState === WebSocket.OPEN && + this.sentCount < MAX_UNACKED && + this.sentCount < this.unacked.length + ) { + this.ws.send(JSON.stringify(this.unacked[this.sentCount])); + this.sentCount++; + } + } +} +// #endregion buffered-audio-stream diff --git a/frontend/src/transcribe/transcript.ts b/frontend/src/transcribe/transcript.ts new file mode 100644 index 0000000..e22fd37 --- /dev/null +++ b/frontend/src/transcribe/transcript.ts @@ -0,0 +1,22 @@ +import type { TranscriptItem } from "../api/transcribe.js"; + +// Accumulates transcript items for a whole session — across however many streams a +// pause/restart opens — deduped by id, so a re-sent final overwrites its partial. +export class Transcript { + private itemsById = new Map(); + + add(item: TranscriptItem): void { + this.itemsById.set(item.id, item); + } + + // Unique items, sorted by start time (items aren't guaranteed to arrive in order). + items(): TranscriptItem[] { + return [...this.itemsById.values()].sort( + (left, right) => left.start_offset_ms - right.start_offset_ms, + ); + } + + clear(): void { + this.itemsById.clear(); + } +} diff --git a/frontend/src/transcribe/transcription-session.ts b/frontend/src/transcribe/transcription-session.ts new file mode 100644 index 0000000..8f86d89 --- /dev/null +++ b/frontend/src/transcribe/transcription-session.ts @@ -0,0 +1,171 @@ +import { + buildAudioChunk, + buildTranscribeConfig, + connectTranscribeWebSocket, + TRANSCRIBE_END_MESSAGE, + type TranscribeServerMessage, + type TranscriptItem, +} from "../api/transcribe.js"; +import { BufferedAudioStream, type BufferStats } from "./buffered-stream.js"; +import { Transcript } from "./transcript.js"; +import type { WebSocketInterface } from "../transport/websocket-interface.js"; + +// A live transcription session. It owns the accumulated transcript and the current +// connection; push PCM in with sendAudio(), get items out via onTranscriptItem(). A +// session can span several streams — stop() ends the current one, keeping the +// transcript, start() opens a fresh one and keeps accumulating. +export class TranscriptionSession { + private transcript = new Transcript(); + private socket: WebSocketInterface | null = null; + private bufferedAudioStream: BufferedAudioStream | null = null; + + // Items are shifted onto a session-wide timeline by timelineBaseMs. Each new socket — + // a new take or a reconnect — snapshots it to where the transcript currently ends, so + // the socket's 0-based offsets continue past existing items instead of restarting. + private timelineBaseMs = 0; + + private itemListener: (item: TranscriptItem) => void = () => {}; + private closeListener: (code: number, reason: string) => void = () => {}; + + // The socket factory is injected so a caller can supply a custom socket wrapper. + constructor( + private readonly socketFactory: () => Promise = connectTranscribeWebSocket, + ) {} + + onTranscriptItem(listener: (item: TranscriptItem) => void): void { + this.itemListener = listener; + } + + onClose(listener: (code: number, reason: string) => void): void { + this.closeListener = listener; + } + + items(): TranscriptItem[] { + return this.transcript.items(); + } + clear(): void { + this.transcript.clear(); + this.timelineBaseMs = 0; + } + + // Open a fresh stream (new socket + buffer, CONFIG sent). + // The accumulated transcript is kept, so start() after stop() continues it. + async start(): Promise { + const socket = await this.startNewSocket(); + this.bufferedAudioStream = new BufferedAudioStream(socket); + } + + // New socket, same buffer → replay the un-acked audio to recover a dropped stream. + // Recovers un-acked audio only — see BufferedAudioStream.reconnect for the limitation. + async reconnect(): Promise { + if (!this.bufferedAudioStream) { + throw new Error("No buffered audio stream to reconnect, call start() first"); + } + const socket = await this.startNewSocket(); + this.bufferedAudioStream.reconnect(socket); + } + + // Graceful end: send END and wait for the server to flush the transcript & close. + async stop(): Promise { + const socket = this.socket; + if (!socket) { + return; + } + if (socket.readyState === WebSocket.OPEN) { + this.send(TRANSCRIBE_END_MESSAGE); + } + this.socket = null; + this.bufferedAudioStream = null; + + if (socket.readyState !== WebSocket.CLOSED) { + const { promise, resolve } = Promise.withResolvers(); + socket.addEventListener("close", () => resolve()); + await promise; + } + } + + sendAudio(pcm: Int16Array): void { + this.bufferedAudioStream?.send((seqId) => buildAudioChunk(seqId, pcm)); + } + + // Start a new socket and wire it up + private async startNewSocket(): Promise { + // Continue from where the transcript currently ends, so a new take or a reconnect's + // replayed items land after the existing ones instead of restarting at zero. + this.timelineBaseMs = this.maxEndOffsetMs(); + + const socket = await this.socketFactory(); + this.socket = socket; + this.listen(socket); + this.send(buildTranscribeConfig()); + return socket; + } + + private send(message: object): void { + this.socket?.send(JSON.stringify(message)); + } + + // #region receive-transcript + // Wire up the socket: accumulate TRANSCRIPT_ITEMs, feed ACKs to the send buffer, and + // notify the close listener when the socket closes. + private listen(socket: WebSocketInterface): void { + socket.onmessage = (event: MessageEvent) => { + const message = JSON.parse(event.data) as TranscribeServerMessage; + if (message.type === "AUDIO_CHUNK_ACK") { + this.bufferedAudioStream?.handlePacketAck({ ack_id: message.ack_id }); + } else if (message.type === "TRANSCRIPT_ITEM") { + const item = this.shiftToSessionTimeline(message); + this.transcript.add(item); + this.itemListener(item); + } + }; + socket.addEventListener("close", (event) => { + this.closeListener(event.code, event.reason); + }); + } + // #endregion receive-transcript + + // Re-express a socket-local item on the session timeline — offsets shifted by the base + // snapshotted when the socket opened — so takes and reconnects don't restart at zero. + private shiftToSessionTimeline(item: TranscriptItem): TranscriptItem { + return { + ...item, + start_offset_ms: item.start_offset_ms + this.timelineBaseMs, + end_offset_ms: item.end_offset_ms + this.timelineBaseMs, + }; + } + + // The furthest point the transcript currently reaches — the base for the next socket. + private maxEndOffsetMs(): number { + return this.transcript + .items() + .reduce((max, item) => Math.max(max, item.end_offset_ms), 0); + } + + //---------------------------------------------------- + // Demonstration / in-depth purpose only + //---------------------------------------------------- + + // Seed the transcript with externally-sourced items + addItems(items: TranscriptItem[]): void { + for (const item of items) { + this.transcript.add(item); + } + } + + // Get the buffer statistics + getBufferStatistics(): BufferStats { + return ( + this.bufferedAudioStream?.getStats() ?? { + queued: 0, + inflight: 0, + totalAcked: 0, + } + ); + } + + // Simulate a disconnect by hard-closing the socket + disconnect(): void { + this.socket?.close(); + } +} diff --git a/frontend/src/transport/auth.ts b/frontend/src/transport/auth.ts new file mode 100644 index 0000000..6bade33 --- /dev/null +++ b/frontend/src/transport/auth.ts @@ -0,0 +1,106 @@ +import { API_VERSION } from "../api/version.js"; +import { BACKEND_URL, getHost, httpUrl } from "./client.js"; + +interface SessionTokens { + access_token: string; + refresh_token: string; +} + +interface AccessTokenClaims { + exp: number; +} + +interface ProvisionResponse { + access_token?: string; + refresh_token?: string; + error?: string; +} + +const ACCESS_TOKEN_KEY = "nabla_access_token"; +const REFRESH_TOKEN_KEY = "nabla_refresh_token"; + +export function saveSession(tokens: SessionTokens): void { + localStorage.setItem(ACCESS_TOKEN_KEY, tokens.access_token); + localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refresh_token); +} + +function isExpiringSoon(accessToken: string): boolean { + try { + const base64 = accessToken + .split(".")[1] + .replace(/-/g, "+") + .replace(/_/g, "/"); + const { exp } = JSON.parse(atob(base64)) as AccessTokenClaims; + return exp < Date.now() / 1000 + 30; + } catch { + return true; + } +} + +let renewalPromise: Promise | null = null; + +export async function getAccessToken(): Promise { + const accessToken = localStorage.getItem(ACCESS_TOKEN_KEY); + if (accessToken && !isExpiringSoon(accessToken)) { + return accessToken; + } + if (!renewalPromise) { + renewalPromise = renewSession().finally(() => { + renewalPromise = null; + }); + } + return renewalPromise; +} + +// Get a fresh access token: refresh with our refresh token if we can, otherwise fall +// back to the backend, which re-authenticates the user and hands us new tokens. +async function renewSession(): Promise { + return (await refreshWithRefreshToken()) ?? (await reprovisionViaBackend()); +} + +async function refreshWithRefreshToken(): Promise { + const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY); + if (!refreshToken) { + return null; + } + const host = await getHost(); + const response = await fetch(httpUrl(host, "/v1/core/user/jwt/refresh"), { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Nabla-Api-Version": API_VERSION, + }, + body: JSON.stringify({ + refresh_token: refreshToken, + }), + }); + if (!response.ok) { + return null; // refresh token expired (30 days) or revoked + } + const tokens = (await response.json()) as SessionTokens; + saveSession(tokens); + return tokens.access_token; +} + +async function reprovisionViaBackend(): Promise { + // note we don't need to reprovision, we could just ask the backend + // to re-authenticate the existing provisioned user and hand us new tokens. + const response = await fetch(`${BACKEND_URL}/api/provision-user`, { + method: "POST", + }); + const provisionResult = (await response.json()) as ProvisionResponse; + if ( + !response.ok || + !provisionResult.access_token || + !provisionResult.refresh_token + ) { + throw new Error( + provisionResult.error ?? "No session — complete Setup first.", + ); + } + saveSession({ + access_token: provisionResult.access_token, + refresh_token: provisionResult.refresh_token, + }); + return provisionResult.access_token; +} diff --git a/frontend/src/transport/client.ts b/frontend/src/transport/client.ts new file mode 100644 index 0000000..deb1fb7 --- /dev/null +++ b/frontend/src/transport/client.ts @@ -0,0 +1,88 @@ +import { API_VERSION } from "../api/version.js"; +import { getAccessToken } from "./auth.js"; + +export const BACKEND_URL = ""; + +export interface BackendStatus { + host: string | null; + clientUuid: string | null; + configured: boolean; +} + +// Make sure we only fetch the status once. +let statusPromise: Promise | null = null; + +export function fetchBackendStatus(): Promise { + if (!statusPromise) { + statusPromise = fetch(`${BACKEND_URL}/api/status`) + .then((response) => response.json() as Promise) + .catch((error) => { + statusPromise = null; // let the next call retry + throw error; + }); + } + return statusPromise; +} + +// The backend tells us which Nabla host to call, so callers never thread it +// around. A host may include a scheme (e.g. http://localhost:8080 for a local +// proxy); a bare hostname defaults to https/wss. +export async function getHost(): Promise { + const status = await fetchBackendStatus(); + if (!status.configured || !status.host) { + throw new Error("Backend not configured — complete Setup first."); + } + return status.host; +} + +export function httpUrl(host: string, path: string): string { + const base = host.includes("://") ? host : `https://${host}`; + return `${base}${path}`; +} + +function webSocketUrl(host: string, path: string): string { + const base = host.includes("://") + ? host.replace(/^http/, "ws") + : `wss://${host}`; + return `${base}${path}`; +} + +export async function nablaFetch( + path: string, + options: RequestInit = {}, +): Promise { + const [host, accessToken] = await Promise.all([getHost(), getAccessToken()]); + const response = await fetch(httpUrl(host, path), { + ...options, + headers: { + "Content-Type": "application/json", + ...(options.headers as Record | undefined), + Authorization: `Bearer ${accessToken}`, + "X-Nabla-Api-Version": API_VERSION, + }, + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Nabla API ${response.status} on ${path}: ${body}`); + } + return response; +} + +// #region nabla-websocket +export async function nablaWebSocket( + path: string, + protocol: string, +): Promise { + const [host, accessToken] = await Promise.all([getHost(), getAccessToken()]); + const webSocket = new WebSocket( + webSocketUrl(host, `${path}?nabla-api-version=${API_VERSION}`), + [protocol, `jwt-${accessToken}`], + ); + const { promise, resolve, reject } = Promise.withResolvers(); + webSocket.onopen = () => resolve(); + webSocket.onerror = () => reject(new Error("WebSocket failed to connect")); + await promise; + return webSocket; +} +// #endregion nabla-websocket diff --git a/frontend/src/transport/instrumented-websocket.ts b/frontend/src/transport/instrumented-websocket.ts new file mode 100644 index 0000000..30c238a --- /dev/null +++ b/frontend/src/transport/instrumented-websocket.ts @@ -0,0 +1,87 @@ +import type { WebSocketInterface } from "./websocket-interface.js"; + +export type ConnectionStatus = "connecting" | "connected" | "closed"; + +const noopHandler = (): void => {}; + +// A WebSocketInterface that reports its own connection lifecycle (connecting → +// connected → closed) and every frame it sends/receives, and can hold incoming frames +// back to simulate latency. +export class InstrumentedWebSocket implements WebSocketInterface { + private socket: WebSocket | null = null; + private messageHandler: (event: MessageEvent) => void = noopHandler; + private readonly pendingFrameTimers = new Set>(); + + constructor( + private readonly openSocket: () => Promise, + private readonly onFrame: (direction: "send" | "recv", raw: string) => void, + private readonly onStatus: (status: ConnectionStatus) => void, + private readonly getReceiveLatencyMs: () => number = () => 0, + ) {} + + async open(): Promise { + this.onStatus("connecting"); + const socket = await this.openSocket(); + this.onStatus("connected"); + this.socket = socket; + socket.addEventListener("close", () => { + this.cancelPendingFrames(); + this.onStatus("closed"); + }); + socket.onmessage = (event) => this.handleIncomingFrame(event); + } + + // --- WebSocketInterface --- + + get readyState(): number { + return this.socket?.readyState ?? WebSocket.CONNECTING; + } + + send(data: string): void { + this.onFrame("send", data); + this.socket?.send(data); + } + + close(): void { + this.socket?.close(); + } + + get onmessage(): ((event: MessageEvent) => void) | null { + return this.messageHandler; + } + set onmessage(handler: ((event: MessageEvent) => void) | null) { + this.messageHandler = handler ?? noopHandler; + } + + addEventListener(type: "close", listener: (event: CloseEvent) => void): void { + this.socket?.addEventListener(type, listener); + } + + private handleIncomingFrame(event: MessageEvent): void { + const handler = this.messageHandler; + this.afterReceiveLatency(() => { + this.onFrame("recv", event.data); + handler(event); + }); + } + + private afterReceiveLatency(action: () => void): void { + const latency = this.getReceiveLatencyMs(); + if (latency <= 0) { + action(); + return; + } + const timer = setTimeout(() => { + this.pendingFrameTimers.delete(timer); + action(); + }, latency); + this.pendingFrameTimers.add(timer); + } + + private cancelPendingFrames(): void { + for (const timer of this.pendingFrameTimers) { + clearTimeout(timer); + } + this.pendingFrameTimers.clear(); + } +} diff --git a/frontend/src/transport/websocket-interface.ts b/frontend/src/transport/websocket-interface.ts new file mode 100644 index 0000000..4413165 --- /dev/null +++ b/frontend/src/transport/websocket-interface.ts @@ -0,0 +1,9 @@ +// A type wrapping a WebSocket, so we can wrap actual WebSockets +// in custom wrappers for debugging or instrumentation. +export interface WebSocketInterface { + readonly readyState: number; + send(data: string): void; + close(): void; + onmessage: ((event: MessageEvent) => void) | null; + addEventListener(type: "close", listener: (event: CloseEvent) => void): void; +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..cce023a --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2024", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src", "vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..69a1964 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,56 @@ +import { defineConfig, type Plugin } from 'vite'; +import tailwindcss from '@tailwindcss/vite'; +import { resolve } from 'path'; + +const backendHost = process.env.BACKEND_HOST ?? 'localhost'; +const backendPort = process.env.BACKEND_PORT ?? '3001'; + +// Each page's HTML lives next to its source under src/pages/, but we keep clean, +// stable URLs. This maps the public URL → the co-located file (used by both the dev +// server below and the build inputs). +const PAGES: Record = { + '/index.html': 'src/pages/index.html', + '/onboarding.html': 'src/pages/onboarding/onboarding.html', + '/demo.html': 'src/pages/full-encounter-demo/demo.html', + '/in-depth/transcribe.html': 'src/pages/in-depth/transcribe/transcribe.html', + '/in-depth/dictate.html': 'src/pages/in-depth/dictate/dictate.html', +}; + +// Dev only: rewrite a clean page URL to its actual file so Vite serves/transforms it. +// "/" is treated as "/index.html". Everything else passes through untouched. +function cleanPageUrls(): Plugin { + return { + name: 'clean-page-urls', + configureServer(server) { + server.middlewares.use((request, _response, next) => { + const [path, query] = (request.url ?? '').split('?'); + const target = PAGES[path === '/' ? '/index.html' : path]; + if (target) { + request.url = query ? `/${target}?${query}` : `/${target}`; + } + next(); + }); + }, + }; +} + +export default defineConfig({ + plugins: [tailwindcss(), cleanPageUrls()], + server: { + open: true, + proxy: { + '/api': `http://${backendHost}:${backendPort}`, + }, + }, + build: { + rollupOptions: { + input: { + index: resolve(__dirname, 'src/pages/index.html'), + onboarding: resolve(__dirname, 'src/pages/onboarding/onboarding.html'), + demo: resolve(__dirname, 'src/pages/full-encounter-demo/demo.html'), + transcribe: resolve(__dirname, 'src/pages/in-depth/transcribe/transcribe.html'), + dictate: resolve(__dirname, 'src/pages/in-depth/dictate/dictate.html'), + }, + }, + }, +}); diff --git a/index.html b/index.html deleted file mode 100644 index 7453f14..0000000 --- a/index.html +++ /dev/null @@ -1,34 +0,0 @@ - - - - Nabla Core API Demos - - - - -
-

Nabla Core API Demos

-

Select one of the demos below to explore Nabla Core API capabilities:

- -
- -
-
-

Ambient Encounter

-

Demonstrate real-time transcription of a medical conversation with multiple languages, and generate structured clinical notes.

- Launch Demo -
- -
-

Dictated Note

-

Experience medical dictation with real-time transcription and various language options.

- Launch Demo -
-
- -
-

Note: Both demos require you to update the authentication tokens in the authentication.js file.

-
-
- - diff --git a/package-lock.json b/package-lock.json index 5fae609..0234fe0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,24 +1,50 @@ { "name": "sample-app", - "version": "0.0.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sample-app", - "version": "0.0.0", + "version": "1.0.0", + "workspaces": [ + "frontend", + "backend" + ], "devDependencies": { - "jsrsasign": "^11.1.0", - "vite": "^7.3.2" + "concurrently": "^9.0.0" }, "engines": { "node": ">=22" } }, + "backend": { + "name": "nabla-sample-app-backend", + "version": "1.0.0", + "dependencies": { + "express": "^4.18.2", + "jose": "^5.9.6" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^22.0.0", + "typescript": "^5.5.0" + } + }, + "frontend": { + "name": "nabla-sample-app-frontend", + "version": "1.0.0", + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "@types/node": "^22.0.0", + "typescript": "^5.5.0", + "vite": "^7.0.0" + } + }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -28,14 +54,15 @@ "os": [ "aix" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -45,14 +72,15 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -62,14 +90,15 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -79,14 +108,15 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -96,14 +126,15 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -113,14 +144,15 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -130,14 +162,15 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -147,14 +180,15 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -164,14 +198,15 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -181,14 +216,15 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -198,14 +234,15 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -215,14 +252,15 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -232,14 +270,15 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -249,14 +288,15 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -266,14 +306,15 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -283,14 +324,15 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -300,14 +342,15 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -317,14 +360,15 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -334,14 +378,15 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -351,14 +396,15 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -368,14 +414,15 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -385,14 +432,15 @@ "os": [ "openharmony" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -402,14 +450,15 @@ "os": [ "sunos" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -419,14 +468,15 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -436,14 +486,15 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -453,14 +504,65 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", - "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", "cpu": [ "arm" ], @@ -472,9 +574,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", - "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", "cpu": [ "arm64" ], @@ -486,9 +588,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", - "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", "cpu": [ "arm64" ], @@ -500,9 +602,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", - "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", "cpu": [ "x64" ], @@ -514,9 +616,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", - "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", "cpu": [ "arm64" ], @@ -528,9 +630,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", - "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", "cpu": [ "x64" ], @@ -542,9 +644,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", - "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", "cpu": [ "arm" ], @@ -556,9 +658,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", - "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", "cpu": [ "arm" ], @@ -570,9 +672,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", - "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", "cpu": [ "arm64" ], @@ -584,9 +686,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", - "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", "cpu": [ "arm64" ], @@ -598,9 +700,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", - "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", "cpu": [ "loong64" ], @@ -612,9 +714,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", - "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", "cpu": [ "loong64" ], @@ -626,9 +728,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", - "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", "cpu": [ "ppc64" ], @@ -640,9 +742,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", - "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", "cpu": [ "ppc64" ], @@ -654,9 +756,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", - "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", "cpu": [ "riscv64" ], @@ -668,9 +770,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", - "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", "cpu": [ "riscv64" ], @@ -682,9 +784,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", - "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", "cpu": [ "s390x" ], @@ -696,9 +798,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", - "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", "cpu": [ "x64" ], @@ -710,9 +812,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", - "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", "cpu": [ "x64" ], @@ -724,9 +826,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", - "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", "cpu": [ "x64" ], @@ -738,9 +840,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", - "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", "cpu": [ "arm64" ], @@ -752,9 +854,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", - "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", "cpu": [ "arm64" ], @@ -766,9 +868,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", - "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", "cpu": [ "ia32" ], @@ -780,9 +882,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", - "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", "cpu": [ "x64" ], @@ -794,9 +896,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", - "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", "cpu": [ "x64" ], @@ -807,311 +909,2713 @@ "win32" ] }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "node_modules/@tailwindcss/node": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", + "integrity": "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.1" + } }, - "node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "node_modules/@tailwindcss/oxide": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz", + "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, "engines": { - "node": ">=18" + "node": ">= 20" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" + "@tailwindcss/oxide-android-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-x64": "4.3.1", + "@tailwindcss/oxide-freebsd-x64": "4.3.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-x64-musl": "4.3.1", + "@tailwindcss/oxide-wasm32-wasi": "4.3.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" } }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz", + "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } + "node": ">= 20" } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz", + "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==", + "cpu": [ + "arm64" + ], "dev": true, - "hasInstallScript": true, "license": "MIT", "optional": true, "os": [ "darwin" ], "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">= 20" } }, - "node_modules/jsrsasign": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/jsrsasign/-/jsrsasign-11.1.0.tgz", - "integrity": "sha512-Ov74K9GihaK9/9WncTe1mPmvrO7Py665TUfUKvraXBpu+xcTWitrtuOwcjf4KMU9maPaYn0OuaWy0HOzy/GBXg==", + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz", + "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "funding": { - "url": "https://github.com/kjur/jsrsasign#donations" + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" } }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz", + "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==", + "cpu": [ + "x64" ], + "dev": true, "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": ">= 20" } }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz", + "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "node": ">= 20" } }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz", + "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==", + "cpu": [ + "arm64" ], + "dev": true, "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^10 || ^12 || >=14" + "node": ">= 20" } }, - "node_modules/rollup": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", - "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz", + "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz", + "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz", + "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz", + "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", + "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz", + "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.1.tgz", + "integrity": "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.1", + "@tailwindcss/oxide": "4.3.1", + "tailwindcss": "4.3.1" }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.59.0", - "@rollup/rollup-android-arm64": "4.59.0", - "@rollup/rollup-darwin-arm64": "4.59.0", - "@rollup/rollup-darwin-x64": "4.59.0", - "@rollup/rollup-freebsd-arm64": "4.59.0", - "@rollup/rollup-freebsd-x64": "4.59.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", - "@rollup/rollup-linux-arm-musleabihf": "4.59.0", - "@rollup/rollup-linux-arm64-gnu": "4.59.0", - "@rollup/rollup-linux-arm64-musl": "4.59.0", - "@rollup/rollup-linux-loong64-gnu": "4.59.0", - "@rollup/rollup-linux-loong64-musl": "4.59.0", - "@rollup/rollup-linux-ppc64-gnu": "4.59.0", - "@rollup/rollup-linux-ppc64-musl": "4.59.0", - "@rollup/rollup-linux-riscv64-gnu": "4.59.0", - "@rollup/rollup-linux-riscv64-musl": "4.59.0", - "@rollup/rollup-linux-s390x-gnu": "4.59.0", - "@rollup/rollup-linux-x64-gnu": "4.59.0", - "@rollup/rollup-linux-x64-musl": "4.59.0", - "@rollup/rollup-openbsd-x64": "4.59.0", - "@rollup/rollup-openharmony-arm64": "4.59.0", - "@rollup/rollup-win32-arm64-msvc": "4.59.0", - "@rollup/rollup-win32-ia32-msvc": "4.59.0", - "@rollup/rollup-win32-x64-gnu": "4.59.0", - "@rollup/rollup-win32-x64-msvc": "4.59.0", - "fsevents": "~2.3.2" + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, - "node_modules/source-map-js": { + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.6" } }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "color-convert": "^2.0.1" }, "engines": { - "node": ">=12.0.0" + "node": ">=8" }, "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/vite": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", - "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", - "dev": true, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", "license": "MIT", "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" }, - "bin": { - "vite": "bin/vite.js" + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" }, - "optionalDependencies": { - "fsevents": "~2.3.3" + "engines": { + "node": ">=10" }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concurrently": { + "version": "9.2.3", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.3.tgz", + "integrity": "sha512-ihjs0E2SxvDgq/MK418hX6YycQgKhsqxpbZuZbHo0yKfqDWdymWMjWYIpCIzqDDLLKClHlXev8whW/8WXmJ0BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.4", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "peer": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/nabla-sample-app-backend": { + "resolved": "backend", + "link": true + }, + "node_modules/nabla-sample-app-frontend": { + "resolved": "frontend", + "link": true + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shell-quote": { + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", + "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", + "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz", + "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" } } } diff --git a/package.json b/package.json index 7af3ed7..5115e7a 100644 --- a/package.json +++ b/package.json @@ -1,22 +1,23 @@ { "name": "sample-app", - "private": true, "type": "module", - "version": "0.0.0", + "version": "1.0.0", "description": "A minimal web app that shows how to interact with the Nabla Core API", + "workspaces": [ + "frontend", + "backend" + ], "scripts": { - "dev": "vite", - "build": "vite build" + "dev": "concurrently --kill-others-on-fail -n frontend,backend -c cyan,yellow \"npm run dev -w frontend\" \"npm run dev -w backend\"" }, "repository": { "type": "git", "url": "git+https://github.com/nabla/sample-app.git" }, "devDependencies": { - "jsrsasign": "^11.1.0", - "vite": "^7.3.2" + "concurrently": "^9.0.0" }, "engines": { - "node": ">=22" + "node": ">=24" } } diff --git a/scripts/generate-tokens.js b/scripts/generate-tokens.js deleted file mode 100644 index 11d55fd..0000000 --- a/scripts/generate-tokens.js +++ /dev/null @@ -1,179 +0,0 @@ -/** - * - * This script simulates the backend server of a Nabla Core API customer which would: - * - Authenticate to the Core API (by constructing a JWT client assertion with its OAuth UUID and private key) - * - Create a new API user - * - Authenticate the API user, to provide him its initial access and refresh tokens - * - * Usage: - * node generate-tokens.js --uuid= --private-key= --hostname= [--type=server|user] - * - * Required Arguments: - * --uuid= OAuth UUID for authentication - * --private-key= Path to the private key file - * --hostname= API hostname (e.g., eu.api.nabla.com) - * - * Optional Arguments: - * --type=server Generate server access token only - * --type=user Generate user access/refresh tokens (default) - * - */ - -import fs from 'node:fs'; -import { parseArgs } from 'node:util'; - -import { KJUR } from 'jsrsasign'; - -const REQUIRED_ARGUMENTS = ['uuid', 'private-key', 'hostname']; -const SUPPORTED_TYPES = new Set(['server', 'user']); - -function parseArguments() { - const { values } = parseArgs({ - args: process.argv.slice(2), - options: { - uuid: { - type: 'string', - }, - 'private-key': { - type: 'string', - }, - hostname: { - type: 'string', - }, - type: { - type: 'string', - default: 'user', - } - } - }); - - for (const argument of REQUIRED_ARGUMENTS) { - if (!values[argument]) { - console.error(`Missing required argument --${argument}`); - process.exit(1); - } - } - - if (values.type && !SUPPORTED_TYPES.has(values.type)) { - console.error(`Invalid value for --type: ${values.type}.`); - process.exit(1); - } - - return { - type: values.type ?? 'user', - privateKeyFilename: values['private-key'], - uuid: values.uuid, - hostname: values.hostname, - }; -}; - -async function main() { - try { - const options = parseArguments(); - const serverTokens = await fetchServerAccessToken(options); - - if (options.type === 'server') { - console.log(bold('Server access token: '), serverTokens.accessToken); - } else { - const userId = await createUser(serverTokens.accessToken, options); - const { userRefreshToken, userAccessToken } = await authenticateUser(serverTokens.accessToken, userId, options); - - console.log(bold('User access token: '), userAccessToken); - console.log(bold('User refresh token: '), userRefreshToken); - } - } catch (err) { - console.error("Error during authentication flow. You maybe forgot to provide OAuth UUID " + - "and private key in the source code.", err); - } -} - -async function fetchServerAccessToken(options) { - const { uuid, privateKeyFilename, hostname } = options; - - const nowSeconds = Math.floor(Date.now() / 1000); - const assertionHeader = { alg: "RS256", typ: "JWT" }; - const payload = { - sub: uuid, - iss: uuid, - aud: `https://${hostname}/v1/core/server/oauth/token`, - exp: nowSeconds + 60, - iat: nowSeconds, - }; - - const privateKey = fs.readFileSync(privateKeyFilename, 'utf8'); - - const jwtAssertion = KJUR.jws.JWS.sign( - assertionHeader.alg, - JSON.stringify(assertionHeader), - JSON.stringify(payload), - privateKey, - ); - - const response = await fetch(`https://${hostname}/v1/core/server/oauth/token`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - grant_type: "client_credentials", - client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", - client_assertion: jwtAssertion, - }), - }); - - if (!response.ok) { - throw new Error(`Could not get server access token (status: ${response.status})`); - } - - const data = await response.json(); - return { - accessToken: data.access_token - }; -} - -async function createUser(serverAccessToken, options) { - const { hostname } = options; - - const response = await fetch(`https://${hostname}/v1/core/server/users`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${serverAccessToken}` - }, - body: JSON.stringify({}), - }); - if (!response.ok) { - throw new Error(`Unexpected error during user creation (status: ${response.status})`); - } - const data = await response.json(); - return data.id; -} - -async function authenticateUser(serverAccessToken, userId, options) { - const { hostname } = options; - - const response = await fetch( - `https://${hostname}/v1/core/server/jwt/authenticate/${userId}`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${serverAccessToken}` - }, - body: JSON.stringify({}), - } - ); - if (!response.ok) { - throw new Error(`Error retrieving initial user tokens (status: ${response.status})`); - } - const data = await response.json(); - return { - userRefreshToken : data.refresh_token, - userAccessToken : data.access_token, - }; -}; - -function bold(text) { - return `\x1b[1m${text}\x1b[0m`; -} - -// Run the script -main(); diff --git a/static/sample_app_screenshot.png b/static/sample_app_screenshot.png deleted file mode 100644 index 65b4ba5..0000000 Binary files a/static/sample_app_screenshot.png and /dev/null differ diff --git a/vite.config.js b/vite.config.js deleted file mode 100644 index f556fb2..0000000 --- a/vite.config.js +++ /dev/null @@ -1,18 +0,0 @@ -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -import { defineConfig } from "vite"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -export default defineConfig({ - build: { - rollupOptions: { - input: { - main: resolve(__dirname, "index.html"), - dictatedNote: resolve(__dirname, "app/dictated-note-demo/index.html"), - ambientEncounter: resolve(__dirname, "app/ambient-encounter-demo/index.html"), - }, - }, - }, -});