feat: ship reviewed prompt history, conversation limits and account protections
This commit is contained in:
parent
5c5d68a7c1
commit
cfaf8e957b
100 changed files with 7316 additions and 3756 deletions
|
|
@ -8,3 +8,6 @@ data/
|
|||
*.log
|
||||
*.md
|
||||
.DS_Store
|
||||
|
||||
# Always generated inside the image from the validated build argument.
|
||||
BUILD_ID
|
||||
|
|
|
|||
|
|
@ -9,7 +9,21 @@ on:
|
|||
- 'v*'
|
||||
|
||||
jobs:
|
||||
root-test:
|
||||
name: Root app tests
|
||||
runs-on: forgejo-local
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: https://github.com/actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
cache-dependency-path: package-lock.json
|
||||
- run: npm ci
|
||||
- run: npm test
|
||||
|
||||
build:
|
||||
needs: root-test
|
||||
name: Build signed APK
|
||||
runs-on: forgejo-local
|
||||
steps:
|
||||
|
|
|
|||
|
|
@ -9,7 +9,21 @@ on:
|
|||
default: 'true'
|
||||
|
||||
jobs:
|
||||
root-test:
|
||||
name: Root app tests
|
||||
runs-on: forgejo-local
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: https://github.com/actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
cache-dependency-path: package-lock.json
|
||||
- run: npm ci
|
||||
- run: npm test
|
||||
|
||||
build:
|
||||
needs: root-test
|
||||
name: Build Docker image
|
||||
runs-on: forgejo-local
|
||||
steps:
|
||||
|
|
@ -24,7 +38,7 @@ jobs:
|
|||
run: docker compose -f docker-compose.yml config >/tmp/ped-ai-compose.yml
|
||||
|
||||
- name: Build compose service
|
||||
run: docker compose -f docker-compose.yml build pediatric-scribe
|
||||
run: ./scripts/build-image.sh
|
||||
|
||||
- name: Tag image
|
||||
run: |
|
||||
|
|
|
|||
15
.github/workflows/android-release.yml
vendored
15
.github/workflows/android-release.yml
vendored
|
|
@ -20,7 +20,22 @@ permissions:
|
|||
contents: write # needed to create GitHub releases from the runner
|
||||
|
||||
jobs:
|
||||
root-test:
|
||||
if: ${{ github.server_url == 'https://github.com' }}
|
||||
name: Root app tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
cache-dependency-path: package-lock.json
|
||||
- run: npm ci
|
||||
- run: npm test
|
||||
|
||||
build:
|
||||
needs: root-test
|
||||
if: ${{ github.server_url == 'https://github.com' }}
|
||||
name: Build signed APK
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
15
.github/workflows/build-apk.yml
vendored
15
.github/workflows/build-apk.yml
vendored
|
|
@ -13,7 +13,22 @@ env:
|
|||
APP_URL: ${{ github.event.inputs.app_url || secrets.APP_URL || 'https://peds.danvics.com' }}
|
||||
|
||||
jobs:
|
||||
root-test:
|
||||
if: ${{ github.server_url == 'https://github.com' }}
|
||||
name: Root app tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
cache-dependency-path: package-lock.json
|
||||
- run: npm ci
|
||||
- run: npm test
|
||||
|
||||
build-apk:
|
||||
needs: root-test
|
||||
if: ${{ github.server_url == 'https://github.com' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
|
|
|
|||
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
|
|
@ -20,15 +20,15 @@ jobs:
|
|||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node 22
|
||||
- name: Setup Node 24
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- name: Install
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Unit tests
|
||||
run: npm test
|
||||
|
|
|
|||
17
.github/workflows/docker-publish.yml
vendored
17
.github/workflows/docker-publish.yml
vendored
|
|
@ -23,7 +23,22 @@ env:
|
|||
IMAGE: danielonyejesi/pediatric-ai-scribe-v3
|
||||
|
||||
jobs:
|
||||
root-test:
|
||||
if: ${{ github.server_url == 'https://github.com' }}
|
||||
name: Root app tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
cache-dependency-path: package-lock.json
|
||||
- run: npm ci
|
||||
- run: npm test
|
||||
|
||||
build:
|
||||
needs: root-test
|
||||
if: ${{ github.server_url == 'https://github.com' }}
|
||||
# Build one variant per matrix entry, push by digest only.
|
||||
name: Build ${{ matrix.platform }}
|
||||
|
|
@ -60,6 +75,8 @@ jobs:
|
|||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
build-args: |
|
||||
GIT_REVISION=${{ github.sha }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
|
||||
|
|
|
|||
4
.github/workflows/security.yml
vendored
4
.github/workflows/security.yml
vendored
|
|
@ -17,10 +17,10 @@ jobs:
|
|||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node 22
|
||||
- name: Setup Node 24
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
node-version: '24'
|
||||
|
||||
- name: Audit root app
|
||||
run: |
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ Web changes hot-reload via browser refresh (JS/CSS cached 1h — add `?v=` query
|
|||
or clear cache; the build-ID server-side cache-buster appends `?v=<git SHA>`
|
||||
automatically on fresh page loads).
|
||||
|
||||
Server code changes require `docker compose build pediatric-scribe && docker compose up -d`.
|
||||
Server code changes require `./scripts/build-image.sh && docker compose up -d --no-build`.
|
||||
|
||||
## Mobile
|
||||
|
||||
|
|
|
|||
12
Dockerfile
12
Dockerfile
|
|
@ -3,7 +3,7 @@
|
|||
# safe to drop into the Node alpine image as-is.
|
||||
FROM openbao/openbao:2.5.3 AS bao-src
|
||||
|
||||
FROM node:20-alpine
|
||||
FROM node:24-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
|
@ -17,14 +17,20 @@ RUN apk add --no-cache ffmpeg curl jq
|
|||
COPY --from=bao-src /bin/bao /usr/local/bin/bao
|
||||
RUN /usr/local/bin/bao version
|
||||
|
||||
COPY package.json ./
|
||||
COPY package.json package-lock.json ./
|
||||
# argon2 compiles native code via node-gyp — needs python3/make/g++ at build time
|
||||
RUN apk add --no-cache --virtual .build-deps python3 make g++ \
|
||||
&& npm install --omit=dev \
|
||||
&& npm ci --omit=dev \
|
||||
&& apk del .build-deps
|
||||
|
||||
COPY . .
|
||||
|
||||
# One validated source revision for both runtime cache busting and OCI provenance.
|
||||
# Direct development builds without an explicit revision remain visibly unversioned.
|
||||
ARG GIT_REVISION=unknown
|
||||
RUN node -e 'const r=process.argv[1]; if (r !== "unknown" && !require("./src/utils/buildId").isGitRevision(r)) throw new Error("GIT_REVISION must be a full lowercase Git SHA"); require("node:fs").writeFileSync("BUILD_ID", r + "\n");' -- "$GIT_REVISION"
|
||||
LABEL org.opencontainers.image.revision=$GIT_REVISION
|
||||
|
||||
# Ensure the entrypoint is executable regardless of host file permissions
|
||||
RUN chmod +x /app/docker-entrypoint.sh
|
||||
|
||||
|
|
|
|||
|
|
@ -57,7 +57,8 @@ Speech-to-text is handled server-side through configured providers such as Googl
|
|||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose up -d --build
|
||||
./scripts/build-image.sh
|
||||
docker compose up -d --no-build
|
||||
```
|
||||
|
||||
The default compose exposes the app on `127.0.0.1:3552` and starts:
|
||||
|
|
|
|||
|
|
@ -11,7 +11,10 @@
|
|||
|
||||
services:
|
||||
pediatric-scribe-e2e:
|
||||
build: .
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
GIT_REVISION: ${GIT_REVISION:-unknown}
|
||||
image: ped-ai-local:latest
|
||||
ports:
|
||||
- "127.0.0.1:3553:3000"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
services:
|
||||
pediatric-scribe:
|
||||
build: .
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
GIT_REVISION: ${GIT_REVISION:-unknown}
|
||||
ports:
|
||||
- "3552:3000"
|
||||
env_file:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
services:
|
||||
pediatric-scribe:
|
||||
build: .
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
GIT_REVISION: ${GIT_REVISION:-unknown}
|
||||
image: ped-ai-local:latest
|
||||
ports:
|
||||
- "127.0.0.1:3552:3000"
|
||||
|
|
|
|||
|
|
@ -6,13 +6,15 @@ This is the practical guide for changing Ped-AI safely.
|
|||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose up -d --build
|
||||
./scripts/build-image.sh
|
||||
docker compose up -d --no-build
|
||||
curl -fsS http://127.0.0.1:3552/api/health
|
||||
```
|
||||
|
||||
Run tests from the repository root:
|
||||
Use Node 24 LTS. Run a locked install and tests from the repository root:
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm test
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -21,9 +21,38 @@ git clone https://github.com/ifedan-ed/pediatric-ai-scribe-v3.git
|
|||
cd pediatric-ai-scribe-v3
|
||||
cp .env.example .env
|
||||
# edit .env — required: APP_URL, JWT_SECRET, DATA_ENCRYPTION_KEY, DB_PASSWORD, an AI provider
|
||||
docker compose up -d --build
|
||||
./scripts/build-image.sh
|
||||
docker compose up -d --no-build
|
||||
```
|
||||
|
||||
The build uses Node 24 LTS and `npm ci --omit=dev` from the root lockfile.
|
||||
`./scripts/build-image.sh` resolves the full checkout Git commit (including
|
||||
worktrees/packed refs) and passes `GIT_REVISION` through Compose. It only builds;
|
||||
starting or replacing production services remains a separate reviewed step.
|
||||
Use `COMPOSE_FILE=docker-compose.local.yml ./scripts/build-image.sh` for the local
|
||||
variant. For direct Docker builds:
|
||||
|
||||
```bash
|
||||
docker build --build-arg GIT_REVISION="$(git rev-parse --verify 'HEAD^{commit}')" -t ped-ai-local:latest .
|
||||
```
|
||||
|
||||
All Compose variants accept the same `GIT_REVISION` environment variable. A build without one is explicitly
|
||||
`unknown` (unversioned development), not a release provenance claim.
|
||||
|
||||
The Dockerfile rejects malformed revisions and writes the same full SHA to
|
||||
`/app/BUILD_ID` and `org.opencontainers.image.revision`. `/api/build`, the
|
||||
`X-Build-Id` header and asset query strings use that baked value. Git identifies
|
||||
the source commit, not local uncommitted changes: release from a clean checkout;
|
||||
a local dirty test image is not an exact representation of that commit.
|
||||
|
||||
Forgejo's existing trusted push/manual release workflows run a Node 24 root
|
||||
`npm ci` / `npm test` job on `forgejo-local`; APK and Docker jobs require it via
|
||||
`needs`. No untrusted pull-request code may run on that privileged runner.
|
||||
An isolated, unprivileged Forgejo PR runner is separate future provisioning,
|
||||
not an assumed label in these workflows. GitHub-hosted PR CI uses Node 24;
|
||||
GitHub release workflows also gate builds on root tests. Mobile dependency
|
||||
versions and signing/publishing gates are unchanged.
|
||||
|
||||
The default compose starts `pediatric-ai-scribe` on `127.0.0.1:3552`, `pedscribe-db` internally, and `ped-ai-redis` internally.
|
||||
|
||||
## Minimum `.env`
|
||||
|
|
@ -105,7 +134,7 @@ docker compose up -d
|
|||
|
||||
```bash
|
||||
git pull
|
||||
docker compose build --no-cache
|
||||
./scripts/build-image.sh --no-cache
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ request
|
|||
|
||||
On boot:
|
||||
- `APP_VERSION` read from `package.json`, printed + returned by `/api/health/detailed`.
|
||||
- `BUILD_ID` = short git HEAD SHA (or random on non-git deploys). Rewritten into HTML at startup.
|
||||
- `BUILD_ID` = full Git HEAD SHA (worktrees and packed refs supported), or the validated image-baked revision. Unversioned development builds report `unknown`; no random SHA is invented.
|
||||
- `JWT_SECRET` / `DATA_ENCRYPTION_KEY` fail-fast if missing in production.
|
||||
- `initDatabase()` → `runMigrations()` → collation drift check.
|
||||
- SIGTERM / SIGINT handler drains the audit queue and closes the pool.
|
||||
|
|
@ -358,7 +358,7 @@ catch it.
|
|||
|
||||
```bash
|
||||
docker compose up -d postgres # just the DB
|
||||
npm install
|
||||
npm ci
|
||||
cp .env.example .env # set JWT_SECRET, DATA_ENCRYPTION_KEY, provider credentials
|
||||
node server.js
|
||||
```
|
||||
|
|
|
|||
76
docs/global-prompt-administration.md
Normal file
76
docs/global-prompt-administration.md
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
# Global prompt administration and conversation budget
|
||||
|
||||
`CLINICAL_ASSISTANT_CONVERSATION_CHARS` is the sole conversation budget source.
|
||||
Missing/empty uses 120000; nonempty values must validate as an integer from 1000
|
||||
through 1000000. Invalid configuration returns 503 before query rewrite,
|
||||
retrieval, handoff generation or chat provider calls. Counting is exactly
|
||||
JavaScript string length (UTF-16 code units), including all history plus draft,
|
||||
not model tokens. The old `clinical_assistant.conversation_chars` database value
|
||||
is ignored and its generic config PUT is rejected. Status retains
|
||||
`conversationChars`/`conversationUnit` and adds `conversationEnv`,
|
||||
`conversationSource`, `conversationMeasure`. Admin config exposes the same
|
||||
metadata as top-level `conversationBudget: {limit,unit,measure,env,source}`.
|
||||
Full-history storage, 8 MiB saves and explicit successful-only handoffs are unchanged.
|
||||
|
||||
## Catalogue and runtime use
|
||||
|
||||
Authenticated admins can use `/api/admin/config/prompts`. Its finite catalogue
|
||||
contains 29 AI Scribe strings, `clinical_assistant.system_behavior` for clinical
|
||||
text answers, and `clinical_assistant.image_behavior` for both existing image
|
||||
routes. Each entry includes `key`, `dbKey`, `value`, `family`, `purpose`, `usedBy`,
|
||||
`revision`, and `editable`. Scribe defaults remain in `utils/prompts.js`; clinical
|
||||
defaults and image assembly are in `utils/clinicalPrompts.js`. All shipped default
|
||||
text is unchanged. The poster instruction follows the input and precedes the
|
||||
existing conditional portrait/landscape suffixes. Fixed clinical citation
|
||||
safeguards, retrieval and generation settings are unchanged. Memories and private
|
||||
user templates are not part of this catalogue.
|
||||
|
||||
## Revision API
|
||||
|
||||
All endpoints below are under `/api/admin/config` and require the existing admin
|
||||
gate. Prompt keys for history/reset/restore accept canonical `dbKey` or a bare
|
||||
Scribe key.
|
||||
|
||||
- `PUT /:dbKey` with `{value, expectedRevision?}` saves nonempty string text.
|
||||
- `POST /prompts/:key/reset` with `{expectedRevision?}` removes the override and
|
||||
records the current shipped default.
|
||||
- `GET /prompts/:key/history?limit=20` returns `{success,revisions,revision}`;
|
||||
newest first, at most 100. Metadata has `id`, `createdAt`, `createdBy`,
|
||||
`restoredFrom`, `wasDefault`, never prompt text.
|
||||
- `GET /prompts/:key/revisions/:id` returns `{success,revision}` with the recorded
|
||||
`value` and metadata, checking key/id association.
|
||||
- `POST /prompts/:key/restore` with `{revisionId,expectedRevision?}` restores the
|
||||
recorded effective text **as an explicit override**, even when the historical
|
||||
revision used a different shipped default. The new revision has `wasDefault:false`
|
||||
and `restoredFrom` pointing to the original. Reset again to follow shipped defaults.
|
||||
|
||||
Mutations return `{success:true,value,revision}`. Revision is the latest numeric
|
||||
row id for that key, not a contiguous per-key counter; 0 means no history yet.
|
||||
First mutation records the previous effective baseline plus the edit. Its baseline
|
||||
actor is null (unknown), with capture time rather than an invented original edit
|
||||
time. Provided stale `expectedRevision` returns 409 without changes; legacy callers
|
||||
may omit it. Reload a conflicted editor before explicitly retrying. Other settings
|
||||
retain their existing API contracts.
|
||||
|
||||
## Persistence and verification
|
||||
|
||||
Apply `1777700000000_add-prompt-revisions.js` through the existing migration runner
|
||||
before edits. Missing schema fails prompt operations safely, without unversioned
|
||||
fallback. The migration count increases from six to seven; old frozen checks that
|
||||
assert six need a separate reviewed update, not changes to their evidence.
|
||||
|
||||
`prompt_revisions` stores global administrative text, not credentials or private
|
||||
content, matching plaintext global `app_settings` storage. A finite-key constraint,
|
||||
append-only update/delete trigger and same-key restore FK protect history. Actor
|
||||
ids are historical integers, not cascading foreign keys. One `db.pool.connect()`
|
||||
client holds a per-key transaction advisory lock for baseline, revision append and
|
||||
setting upsert/delete. Rollbacks do not publish memory changes; only committed
|
||||
values mutate the original shared Scribe object. Scribe's existing process-local
|
||||
cache model remains; this slice does not introduce multi-process invalidation.
|
||||
|
||||
Run `node --test test/prompt-administration.test.js test/clinical-conversation.test.js`
|
||||
with synthetic service boundaries, and the complete `npm test` suite. Tests include
|
||||
actual admin middleware/routes, rollback/concurrency, missing schema, old-default
|
||||
restore, default byte hashes, object identity and startup races, exact UTF-16
|
||||
boundaries, legacy DB ignoring, and both image routes. Migration SQL is dry-run
|
||||
through the installed node-pg-migrate engine, not applied to a live database.
|
||||
60
migrations/1777700000000_add-prompt-revisions.js
Normal file
60
migrations/1777700000000_add-prompt-revisions.js
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
// Global prompts only. No private templates, Memories, or credential settings.
|
||||
exports.up = pgm => {
|
||||
pgm.sql(`
|
||||
CREATE TABLE prompt_revisions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
prompt_key TEXT NOT NULL CHECK (prompt_key IN (
|
||||
'prompt.hpiEncounter',
|
||||
'prompt.hpiDictation',
|
||||
'prompt.hpiInpatient',
|
||||
'prompt.hospitalCourseShort',
|
||||
'prompt.hospitalCourseLong',
|
||||
'prompt.hospitalCourseICU',
|
||||
'prompt.hospitalCoursePsych',
|
||||
'prompt.chartReviewOutpatient',
|
||||
'prompt.chartReviewSubspecialty',
|
||||
'prompt.chartReviewED',
|
||||
'prompt.soapFull',
|
||||
'prompt.soapSubjective',
|
||||
'prompt.milestoneNarrative',
|
||||
'prompt.milestoneList',
|
||||
'prompt.milestoneSummary',
|
||||
'prompt.peGuideNarrative',
|
||||
'prompt.peGuideList',
|
||||
'prompt.refine',
|
||||
'prompt.shortenDocument',
|
||||
'prompt.askClarification',
|
||||
'prompt.shadessAssessment',
|
||||
'prompt.wellVisitNote',
|
||||
'prompt.wellVisitShort',
|
||||
'prompt.sickVisitNote',
|
||||
'prompt.edEncounterStaged',
|
||||
'prompt.edConsolidate',
|
||||
'prompt.edFinalize',
|
||||
'prompt.dontMissTooltip',
|
||||
'prompt.patientEducation',
|
||||
'clinical_assistant.system_behavior',
|
||||
'clinical_assistant.image_behavior'
|
||||
)),
|
||||
value TEXT NOT NULL,
|
||||
was_default BOOLEAN NOT NULL,
|
||||
created_by INTEGER,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
restored_from INTEGER,
|
||||
UNIQUE (prompt_key, id),
|
||||
FOREIGN KEY (prompt_key, restored_from) REFERENCES prompt_revisions (prompt_key, id)
|
||||
);
|
||||
-- Actor is a historical id, not a FK that user deletion could rewrite.
|
||||
CREATE FUNCTION reject_prompt_revision_mutation() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'Prompt revisions are append-only';
|
||||
END;
|
||||
$$;
|
||||
CREATE TRIGGER prompt_revisions_immutable BEFORE UPDATE OR DELETE ON prompt_revisions
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_prompt_revision_mutation();
|
||||
`);
|
||||
};
|
||||
|
||||
exports.down = pgm => {
|
||||
pgm.sql('DROP TABLE prompt_revisions; DROP FUNCTION reject_prompt_revision_mutation();');
|
||||
};
|
||||
2757
package-lock.json
generated
2757
package-lock.json
generated
File diff suppressed because it is too large
Load diff
22
package.json
22
package.json
|
|
@ -16,7 +16,6 @@
|
|||
"migrate:new": "node-pg-migrate create"
|
||||
},
|
||||
"dependencies": {
|
||||
"@marp-team/marp-cli": "^4.3.1",
|
||||
"@marp-team/marp-core": "^4.3.0",
|
||||
"@tiptap/core": "^3.20.4",
|
||||
"@tiptap/extension-color": "^3.20.4",
|
||||
|
|
@ -30,6 +29,7 @@
|
|||
"bcryptjs": "^2.4.3",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dompurify": "^3.4.1",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.21.0",
|
||||
"express-rate-limit": "^7.4.0",
|
||||
|
|
@ -40,7 +40,7 @@
|
|||
"marked": "^18.0.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"node-pg-migrate": "^7.7.0",
|
||||
"nodemailer": "^8.0.5",
|
||||
"nodemailer": "9.0.1",
|
||||
"openai": "^4.73.0",
|
||||
"openid-client": "^6.8.2",
|
||||
"pdf-parse": "^1.1.1",
|
||||
|
|
@ -60,7 +60,23 @@
|
|||
"@google-cloud/vertexai": "^1.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"dompurify": "^3.4.1",
|
||||
"jsdom": "^29.0.2"
|
||||
},
|
||||
"overrides": {
|
||||
"node-pg-migrate": {
|
||||
"glob": "11.1.0"
|
||||
},
|
||||
"express": {
|
||||
"qs": "6.16.0"
|
||||
},
|
||||
"body-parser": {
|
||||
"qs": "6.16.0"
|
||||
},
|
||||
"speech-rule-engine": {
|
||||
"@xmldom/xmldom": "0.9.12"
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
"node": "24.x"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -160,22 +160,15 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── CMS: AI Prompts ────────────────────────────────────────── -->
|
||||
<!-- ── CMS: AI Scribe Prompts ─────────────────────────────────── -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-robot"></i> AI Prompts</h3>
|
||||
<span style="font-size:12px;color:var(--g500);">Changes take effect immediately, no restart needed</span>
|
||||
<h3><i class="fas fa-robot"></i> AI Scribe — plain-text / JSON instructions</h3>
|
||||
<span style="font-size:12px;color:var(--g500);">Saved overrides replace shipped defaults immediately</span>
|
||||
</div>
|
||||
<div style="padding:16px;display:flex;flex-direction:column;gap:12px;">
|
||||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
|
||||
<label style="font-size:13px;font-weight:600;">Prompt:</label>
|
||||
<select id="cms-prompt-select" style="font-size:13px;padding:4px 8px;border:1px solid var(--g300);border-radius:6px;max-width:320px;"></select>
|
||||
</div>
|
||||
<textarea id="cms-prompt-text" rows="10" style="width:100%;font-size:12px;font-family:monospace;padding:8px;border:1px solid var(--g300);border-radius:6px;resize:vertical;box-sizing:border-box;" placeholder="Loading..."></textarea>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
||||
<button id="btn-save-prompt" class="btn-sm btn-primary">Save Prompt</button>
|
||||
<button id="btn-reset-prompt" class="btn-sm btn-ghost"><i class="fas fa-rotate-left"></i> Reset to Default</button>
|
||||
</div>
|
||||
<div style="padding:16px;">
|
||||
<p>Global Scribe instructions for the operations listed under each prompt. These are not Clinical Assistant prompts or Markdown formatting controls. Personal Memories and templates are separate and are not managed here.</p>
|
||||
<div id="cms-scribe-prompts">Loading Scribe prompts...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -320,7 +313,7 @@
|
|||
<div id="assistant-chat-test-result" style="font-size:12px;color:var(--g500);"></div>
|
||||
<div style="border:1px solid var(--g100);border-radius:8px;padding:10px;display:flex;gap:10px;align-items:center;justify-content:space-between;flex-wrap:wrap;">
|
||||
<div>
|
||||
<div style="font-size:13px;font-weight:600;color:var(--g700);">Starter prompt pool</div>
|
||||
<div style="font-size:13px;font-weight:600;color:var(--g700);">Starter prompt pool — suggested questions, not global prompt history</div>
|
||||
<div id="assistant-prompt-pool-status" style="font-size:12px;color:var(--g500);margin-top:3px;">Checking prompt pool...</div>
|
||||
</div>
|
||||
<button id="btn-regenerate-assistant-prompt-pool" class="btn-sm btn-ghost" type="button"><i class="fas fa-rotate"></i> Regenerate Pool</button>
|
||||
|
|
@ -343,21 +336,36 @@
|
|||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
|
||||
<div>
|
||||
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">Search result limit</label>
|
||||
<label for="assistant-search-limit" style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">Retrieval result limit (count, not characters)</label>
|
||||
<input id="assistant-search-limit" type="number" min="3" max="20" value="8" style="width:100%;font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">Context chars</label>
|
||||
<label for="assistant-context-chars" style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">Requested surrounding context per retrieval excerpt (characters)</label>
|
||||
<input id="assistant-context-chars" type="number" min="300" max="4000" value="1400" style="width:100%;font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;">
|
||||
</div>
|
||||
</div>
|
||||
<p class="assistant-muted">The result limit also bounds selected sources; visual queries separately request up to 8 multimodal candidates before selection. Excerpt size is not the conversation budget.</p>
|
||||
<div>
|
||||
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">System behavior</label>
|
||||
<textarea id="assistant-system-behavior" rows="5" style="width:100%;font-size:12px;font-family:monospace;padding:8px;border:1px solid var(--g300);border-radius:6px;resize:vertical;box-sizing:border-box;" placeholder="Concise clinical assistant behavior..."></textarea>
|
||||
<strong>Conversation input budget — read-only server configuration</strong>
|
||||
<p id="assistant-conversation-budget" role="status">Loading server budget metadata...</p>
|
||||
<p id="assistant-conversation-help" class="assistant-muted">Set by CLINICAL_ASSISTANT_CONVERSATION_CHARS, or the server default when unset; legacy saved settings are ignored. Counts all prior user/assistant text plus the new question using JavaScript string length (UTF-16 code units), not provider tokens. Over-budget requests stop before inference; no automatic clipping or summarization.</p>
|
||||
<p class="assistant-muted">Text answer output limits: 2,600 tokens initially and 5,000 for completion retries. Image input is separate: existing image routes trim and clip the user prompt at 5,000 UTF-16 code units before adding poster/layout instructions. This is not a new image budget or output-token limit.</p>
|
||||
</div>
|
||||
<section aria-labelledby="assistant-text-prompts-heading">
|
||||
<h4 id="assistant-text-prompts-heading">Clinical Assistant TEXT — system behavior</h4>
|
||||
<p>Overrides the default behavior for retrieved text answers (including streaming). Fixed greeting/no-source replies, search rewrites and explicit handoff instructions are separate, not controlled by this editor.</p>
|
||||
<p><strong>Fixed citation safeguards (read-only):</strong> Use only retrieved evidence for factual claims and keep the exact provided source numbers. Do not invent, renumber, merge or move citations. Runtime evidence/citation safeguards are appended separately; this editor does not replace them.</p>
|
||||
<div id="cms-clinical-text-prompts">Loading clinical text prompt...</div>
|
||||
</section>
|
||||
<section aria-labelledby="assistant-image-prompts-heading">
|
||||
<h4 id="assistant-image-prompts-heading">Clinical Assistant IMAGE — poster instructions</h4>
|
||||
<p>Overrides the default poster instruction appended to the image user prompt by both direct and background-job image generation. Existing automatic portrait/landscape layout suffixes still follow it. This does not change text answers or image-job storage.</p>
|
||||
<div id="cms-clinical-image-prompts">Loading clinical image prompt...</div>
|
||||
</section>
|
||||
<div>
|
||||
<button id="btn-save-assistant-config" class="btn-sm btn-primary"><i class="fas fa-floppy-disk"></i> Save Assistant Settings</button>
|
||||
<span id="assistant-admin-status" style="font-size:12px;color:var(--g500);margin-left:8px;"></span>
|
||||
<button id="btn-save-assistant-config" class="btn-sm btn-primary" disabled><i class="fas fa-floppy-disk"></i> Save Model & Retrieval Settings</button>
|
||||
<button id="btn-retry-assistant-config" class="btn-sm btn-ghost" type="button" hidden>Retry loading settings</button>
|
||||
<span id="assistant-admin-status" role="status" style="font-size:12px;color:var(--g500);margin-left:8px;"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -17,9 +17,11 @@
|
|||
<span id="assistant-model-label" class="model-tag">Admin model</span>
|
||||
</div>
|
||||
<div class="assistant-toolbar-actions">
|
||||
<button id="btn-assistant-clear" class="btn-sm btn-ghost" type="button"><i class="fas fa-rotate-left"></i> Clear</button>
|
||||
<button id="btn-assistant-clear" class="btn-sm btn-ghost" type="button"><i class="fas fa-rotate-left"></i> New chat</button>
|
||||
<button id="btn-assistant-copy" class="btn-sm btn-ghost" type="button"><i class="fas fa-copy"></i> Copy answer</button>
|
||||
<button id="btn-assistant-save" class="btn-sm btn-ghost" type="button"><i class="fas fa-bookmark"></i> Save chat</button>
|
||||
<button id="btn-assistant-download-chat" class="btn-sm btn-ghost" type="button">Download transcript</button>
|
||||
<button id="btn-assistant-handoff" class="btn-sm btn-ghost" type="button">Request handoff summary</button>
|
||||
<button id="btn-assistant-export-pdf" class="btn-sm btn-ghost" type="button"><i class="fas fa-file-pdf"></i> Export PDF</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -38,7 +40,16 @@
|
|||
</div>
|
||||
|
||||
<form id="assistant-form" class="assistant-composer">
|
||||
<textarea id="assistant-input" rows="3" placeholder="Ask a focused clinical question..." autocomplete="off"></textarea>
|
||||
<label for="assistant-input">Clinical question</label>
|
||||
<textarea id="assistant-input" rows="3" placeholder="Ask a focused clinical question..." autocomplete="off" aria-describedby="assistant-context-budget assistant-context-warning"></textarea>
|
||||
<p id="assistant-context-budget" class="assistant-muted" aria-live="polite">Loading conversation limit; history and draft are counted in UTF-16 code units. The server validates each request.</p>
|
||||
<div id="assistant-context-warning" role="alert" hidden></div>
|
||||
<div id="assistant-handoff-panel" hidden>
|
||||
<label for="assistant-handoff-text">Requested handoff — conversation context, not verified clinical evidence</label>
|
||||
<textarea id="assistant-handoff-text" rows="8" readonly></textarea>
|
||||
<button id="btn-assistant-copy-handoff" class="btn-sm btn-ghost" type="button">Copy handoff</button>
|
||||
<p class="assistant-muted">Review this summary before using it. Your original chat is unchanged; nothing starts a new chat automatically.</p>
|
||||
</div>
|
||||
<div class="assistant-composer-footer">
|
||||
<label class="assistant-check"><input type="checkbox" id="assistant-include-context" checked> retrieve broader context</label>
|
||||
<button id="btn-assistant-cancel" class="btn-sm btn-ghost" type="button" hidden><i class="fas fa-stop"></i> Cancel search</button>
|
||||
|
|
@ -84,124 +95,4 @@
|
|||
</aside>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.assistant-header { display:flex; justify-content:space-between; gap:12px; align-items:flex-start; }
|
||||
.assistant-status { display:flex; align-items:center; gap:6px; font-size:12px; color:var(--g500); background:white; border:1px solid var(--g200); border-radius:999px; padding:5px 10px; box-shadow:var(--shadow); }
|
||||
.assistant-dot { width:8px; height:8px; border-radius:50%; background:var(--green); display:inline-block; }
|
||||
.assistant-status.busy .assistant-dot { background:var(--amber); animation:pulse 1.5s infinite; }
|
||||
.assistant-status.error .assistant-dot { background:var(--red); }
|
||||
.assistant-layout { display:grid; grid-template-columns:minmax(0,1fr) 330px; gap:14px; align-items:start; }
|
||||
.assistant-layout > * { min-width:0; }
|
||||
.assistant-main { display:grid; grid-template-rows:auto minmax(420px,1fr) auto; min-height:calc(100vh - 190px); min-width:0; }
|
||||
.assistant-toolbar { display:flex; justify-content:space-between; align-items:center; gap:10px; padding:10px 14px; border-bottom:1px solid var(--g200); background:var(--g50); }
|
||||
.assistant-toolbar-actions { display:flex; gap:6px; flex-wrap:wrap; }
|
||||
.assistant-messages { padding:16px; overflow-y:auto; overflow-x:hidden; background:linear-gradient(180deg,#fff,var(--g50)); min-width:0; }
|
||||
.assistant-empty { max-width:680px; margin:50px auto; text-align:center; color:var(--g500); }
|
||||
.assistant-empty i { font-size:34px; color:var(--purple); margin-bottom:10px; }
|
||||
.assistant-empty h3 { color:var(--g800); font-size:18px; margin-bottom:6px; }
|
||||
.assistant-examples { display:flex; gap:8px; flex-wrap:wrap; justify-content:center; margin-top:16px; }
|
||||
.assistant-examples button { border:1px solid var(--g200); background:white; color:var(--blue); border-radius:999px; padding:7px 10px; font-size:12px; cursor:pointer; }
|
||||
.assistant-suggestion-buttons { display:flex; gap:8px; flex-wrap:wrap; margin-top:12px; }
|
||||
.assistant-suggestion-buttons button { border:1px solid var(--purple-light); background:#faf5ff; color:var(--purple); border-radius:999px; padding:7px 10px; font-size:12px; cursor:pointer; text-align:left; }
|
||||
.assistant-suggestion-buttons button:hover { border-color:var(--purple); background:var(--purple-light); }
|
||||
.assistant-msg { max-width:900px; min-width:0; margin:0 0 14px; display:grid; gap:6px; }
|
||||
.assistant-msg.user { margin-left:auto; max-width:760px; }
|
||||
.assistant-msg-label { font-size:11px; font-weight:700; color:var(--g400); text-transform:uppercase; letter-spacing:.04em; }
|
||||
.assistant-bubble { border:1px solid var(--g200); border-radius:14px; padding:12px 14px; background:white; box-shadow:var(--shadow); font-size:13px; line-height:1.75; overflow-wrap:anywhere; min-width:0; max-width:100%; }
|
||||
.assistant-msg.user .assistant-bubble { background:var(--blue); color:white; border-color:var(--blue); }
|
||||
.assistant-bubble h1, .assistant-bubble h2, .assistant-bubble h3 { margin:16px 0 8px; line-height:1.25; color:var(--g900); }
|
||||
.assistant-bubble h1:first-child, .assistant-bubble h2:first-child, .assistant-bubble h3:first-child { margin-top:0; }
|
||||
.assistant-bubble h1 { font-size:20px; }
|
||||
.assistant-bubble h2 { font-size:17px; border-bottom:1px solid var(--g200); padding-bottom:4px; }
|
||||
.assistant-bubble h3 { font-size:15px; }
|
||||
.assistant-bubble p { margin:0 0 10px; }
|
||||
.assistant-bubble p:last-child { margin-bottom:0; }
|
||||
.assistant-bubble ul, .assistant-bubble ol { padding-left:20px; margin:8px 0; }
|
||||
.assistant-bubble li { margin:4px 0; }
|
||||
.assistant-bubble blockquote { margin:10px 0; padding:8px 12px; border-left:3px solid var(--blue); background:var(--blue-light); color:var(--g700); border-radius:8px; }
|
||||
.assistant-table-scroll { max-width:100%; overflow-x:auto; overflow-y:hidden; -webkit-overflow-scrolling:touch; margin:12px 0; border:1px solid var(--g200); border-radius:12px; background:white; box-shadow:inset 0 -1px 0 rgba(0,0,0,.03); }
|
||||
.assistant-table-scroll table { width:max-content; min-width:100%; max-width:none; border-collapse:separate; border-spacing:0; margin:0; border:0; border-radius:0; font-size:12px; }
|
||||
.assistant-table-scroll::after { content:'Swipe table'; display:none; position:sticky; left:0; bottom:0; padding:3px 9px; font-size:10px; font-weight:700; color:var(--g500); background:linear-gradient(90deg,rgba(255,255,255,.95),rgba(255,255,255,0)); pointer-events:none; }
|
||||
.assistant-bubble th, .assistant-bubble td { padding:8px 10px; border-bottom:1px solid var(--g200); vertical-align:top; text-align:left; }
|
||||
.assistant-bubble th, .assistant-bubble td { overflow-wrap:normal; word-break:normal; min-width:120px; }
|
||||
.assistant-bubble th { background:var(--g50); font-weight:700; color:var(--g800); }
|
||||
.assistant-bubble tr:last-child td { border-bottom:0; }
|
||||
.assistant-bubble code { background:var(--g100); border-radius:4px; padding:1px 4px; }
|
||||
.assistant-bubble pre { background:var(--g900); color:white; border-radius:8px; padding:10px; overflow:auto; margin:10px 0; }
|
||||
.assistant-bubble .katex-display { overflow-x:auto; overflow-y:hidden; padding:4px 0; }
|
||||
.assistant-thinking { background:linear-gradient(90deg,#fff,#f8fafc,#fff); background-size:220% 100%; animation:assistantShimmer 1.8s ease-in-out infinite; }
|
||||
.assistant-thinking-line { display:flex; align-items:center; gap:6px; color:var(--g800); }
|
||||
.assistant-thinking-detail { color:var(--g500); font-size:12px; margin-top:3px; }
|
||||
.assistant-thinking-dot { width:7px; height:7px; border-radius:50%; background:var(--purple); display:inline-block; animation:assistantBounce 1.2s infinite ease-in-out; }
|
||||
.assistant-thinking-dot:nth-child(2) { animation-delay:.15s; }
|
||||
.assistant-thinking-dot:nth-child(3) { animation-delay:.3s; margin-right:3px; }
|
||||
@keyframes assistantBounce { 0%,80%,100% { transform:scale(.65); opacity:.45; } 40% { transform:scale(1); opacity:1; } }
|
||||
@keyframes assistantShimmer { 0% { background-position:100% 0; } 100% { background-position:-100% 0; } }
|
||||
.assistant-cite { display:inline-flex; align-items:center; justify-content:center; min-width:18px; height:18px; padding:0 6px; margin:0 1px; border-radius:999px; background:var(--purple-light); color:var(--purple); font-size:10px; font-weight:800; text-decoration:none; vertical-align:baseline; border:1px solid rgba(124,58,237,.18); text-transform:uppercase; letter-spacing:.03em; }
|
||||
.assistant-cite:hover { background:var(--purple); color:white; text-decoration:none; }
|
||||
.assistant-composer { border-top:1px solid var(--g200); padding:12px; background:white; display:grid; gap:8px; }
|
||||
.assistant-composer textarea, .assistant-side textarea { width:100%; border:1.5px solid var(--g300); border-radius:10px; padding:10px 12px; resize:vertical; font-family:inherit; font-size:13px; outline:none; }
|
||||
.assistant-composer textarea:focus, .assistant-side textarea:focus { border-color:var(--blue); box-shadow:0 0 0 3px var(--blue-light); }
|
||||
.assistant-composer-footer { display:flex; justify-content:space-between; align-items:center; gap:10px; }
|
||||
.assistant-composer-footer .btn-generate { width:auto; margin:0; padding:9px 18px; }
|
||||
.assistant-composer-footer #btn-assistant-cancel[hidden] { display:none !important; }
|
||||
.assistant-composer-footer #btn-assistant-cancel:not([hidden]) { display:inline-flex; }
|
||||
.assistant-check { font-size:12px; color:var(--g500); display:flex; align-items:center; gap:6px; }
|
||||
.assistant-side { display:grid; gap:12px; }
|
||||
.assistant-side-body { padding:12px; display:grid; gap:10px; font-size:13px; }
|
||||
.assistant-visual-output { display:grid; gap:8px; }
|
||||
.assistant-image-buttons { display:flex; gap:8px; flex-wrap:wrap; }
|
||||
.assistant-visual-output img { width:100%; border-radius:10px; border:1px solid var(--g200); background:white; }
|
||||
.assistant-generated-image { display:grid; gap:8px; }
|
||||
.assistant-generated-image img { width:100%; border-radius:10px; border:1px solid var(--g200); background:white; }
|
||||
.assistant-image-actions { display:flex; gap:8px; flex-wrap:wrap; }
|
||||
.assistant-image-preview-open { overflow:hidden; }
|
||||
.assistant-image-modal { position:fixed; inset:0; z-index:9999; background:rgba(15,23,42,.82); display:flex; align-items:center; justify-content:center; padding:24px; }
|
||||
.assistant-image-modal-card { position:relative; display:grid; gap:10px; max-width:min(96vw,1200px); max-height:92vh; }
|
||||
.assistant-image-modal-card img { max-width:100%; max-height:92vh; border-radius:14px; background:white; box-shadow:0 24px 80px rgba(0,0,0,.35); }
|
||||
.assistant-image-modal-close { position:absolute; top:8px; right:8px; z-index:1; width:38px; height:38px; border:0; border-radius:999px; background:white; color:var(--g800); font-size:24px; line-height:1; cursor:pointer; box-shadow:var(--shadow); }
|
||||
.assistant-image-modal-cancel { justify-self:center; border:0; border-radius:999px; background:white; color:var(--g800); font-weight:700; padding:9px 14px; box-shadow:var(--shadow); cursor:pointer; }
|
||||
.assistant-sources { padding:10px 12px; display:grid; gap:8px; max-height:520px; overflow-y:auto; }
|
||||
.assistant-saved-chats { padding:10px 12px; display:grid; gap:8px; max-height:220px; overflow-y:auto; }
|
||||
.assistant-saved-chat { border:1px solid var(--g200); border-radius:10px; padding:8px; background:white; display:grid; gap:5px; }
|
||||
.assistant-saved-chat-title { font-size:12px; font-weight:700; color:var(--g800); line-height:1.35; }
|
||||
.assistant-saved-chat-meta { font-size:11px; color:var(--g500); }
|
||||
.assistant-saved-chat-actions { display:flex; gap:6px; flex-wrap:wrap; }
|
||||
.assistant-save-panel { border-top:1px solid var(--g200); padding:10px 12px; display:grid; gap:7px; }
|
||||
.assistant-save-panel[hidden] { display:none; }
|
||||
.assistant-save-panel label { font-size:11px; font-weight:700; color:var(--g500); text-transform:uppercase; letter-spacing:.04em; }
|
||||
.assistant-save-panel input { width:100%; border:1.5px solid var(--g300); border-radius:9px; padding:8px 10px; font-size:12px; outline:none; }
|
||||
.assistant-save-panel input:focus { border-color:var(--blue); box-shadow:0 0 0 3px var(--blue-light); }
|
||||
.assistant-save-actions { display:flex; gap:6px; flex-wrap:wrap; }
|
||||
.assistant-source { border:1px solid var(--g200); border-radius:10px; padding:9px; background:white; font-size:12px; line-height:1.5; }
|
||||
.assistant-source strong { color:var(--g800); }
|
||||
.assistant-source-badges { display:flex; gap:5px; flex-wrap:wrap; margin-top:6px; }
|
||||
.assistant-source-badges span { border:1px solid var(--g200); border-radius:999px; background:var(--g50); color:var(--g600); padding:2px 7px; font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.03em; }
|
||||
.assistant-source-meta { color:var(--g500); font-size:11px; margin-top:3px; }
|
||||
.assistant-source-preview { margin-top:8px; }
|
||||
.assistant-source-preview button { border:0; padding:0; background:transparent; cursor:pointer; width:100%; display:block; }
|
||||
.assistant-source-preview img { width:100%; max-height:220px; object-fit:contain; border:1px solid var(--g200); border-radius:10px; background:white; display:block; }
|
||||
.assistant-source-excerpt { margin-top:7px; color:var(--g600); max-height:170px; overflow:auto; }
|
||||
.assistant-source-excerpt p { margin:0 0 6px; }
|
||||
.assistant-source-excerpt ul, .assistant-source-excerpt ol { padding-left:16px; margin:4px 0; }
|
||||
.assistant-source-excerpt strong { color:var(--g700); }
|
||||
.assistant-muted { color:var(--g500); font-size:12px; line-height:1.6; }
|
||||
.assistant-mermaid { background:white; border:1px solid var(--g200); border-radius:10px; padding:10px; margin:10px 0; overflow:auto; }
|
||||
@media (max-width: 960px) { .assistant-layout { grid-template-columns:1fr; } .assistant-main { min-height:auto; grid-template-rows:auto minmax(320px,1fr) auto; } }
|
||||
@media (max-width: 640px) {
|
||||
.assistant-header { flex-direction:column; align-items:stretch; }
|
||||
.assistant-status { align-self:flex-start; }
|
||||
.assistant-toolbar { flex-direction:column; align-items:stretch; }
|
||||
.assistant-toolbar-actions { display:grid; grid-template-columns:1fr 1fr; }
|
||||
.assistant-toolbar-actions .btn-sm { width:100%; justify-content:center; }
|
||||
.assistant-messages { padding:10px; }
|
||||
.assistant-msg, .assistant-msg.user { max-width:100%; }
|
||||
.assistant-bubble { font-size:13px; padding:11px 12px; }
|
||||
.assistant-table-scroll::after { display:block; }
|
||||
.assistant-composer { position:sticky; bottom:0; z-index:3; }
|
||||
.assistant-composer-footer { flex-direction:column; align-items:stretch; }
|
||||
.assistant-composer-footer .btn-generate { width:100%; }
|
||||
.assistant-composer-footer #btn-assistant-cancel:not([hidden]) { width:100%; justify-content:center; }
|
||||
.assistant-side { gap:10px; }
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="/css/assistant.css">
|
||||
|
|
|
|||
|
|
@ -141,7 +141,7 @@
|
|||
<div class="lh-ai-tabs">
|
||||
<button class="lh-ai-tab active" data-aitab="topic"><i class="fas fa-lightbulb"></i> Describe Content</button>
|
||||
<button class="lh-ai-tab" data-aitab="upload"><i class="fas fa-file-upload"></i> Upload File</button>
|
||||
<button class="lh-ai-tab" data-aitab="webdav" id="lh-ai-tab-webdav"><i class="fas fa-cloud"></i> Nextcloud</button>
|
||||
<button class="lh-ai-tab" data-aitab="webdav" data-feature="nextcloud" id="lh-ai-tab-webdav"><i class="fas fa-cloud"></i> Nextcloud</button>
|
||||
</div>
|
||||
|
||||
<!-- Topic tab -->
|
||||
|
|
@ -168,7 +168,7 @@
|
|||
</div>
|
||||
|
||||
<!-- WebDAV tab -->
|
||||
<div class="lh-ai-tabpanel hidden" id="lh-ai-tp-webdav">
|
||||
<div class="lh-ai-tabpanel hidden" data-feature="nextcloud" id="lh-ai-tp-webdav">
|
||||
<!-- File browser — hidden once a file is selected -->
|
||||
<div id="lh-ai-webdav-browser">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
</select>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:16px;">
|
||||
<div data-feature="read_aloud" style="margin-bottom:16px;">
|
||||
<label style="display:block;font-size:13px;font-weight:600;color:var(--g700);margin-bottom:6px;">Text-to-Speech Voice (Read Aloud)</label>
|
||||
<p style="font-size:12px;color:var(--g500);margin:4px 0 8px;">Choose the voice for the "Read Aloud" feature. Preview available after selection.</p>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||||
|
|
@ -119,7 +119,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Nextcloud -->
|
||||
<div class="settings-section card">
|
||||
<div class="settings-section card" data-feature="nextcloud">
|
||||
<h3><i class="fas fa-cloud"></i> Nextcloud Integration</h3>
|
||||
<p>Export generated documents to your Nextcloud.</p>
|
||||
<div id="nc-status">Not connected</div>
|
||||
|
|
@ -149,7 +149,7 @@
|
|||
</div>
|
||||
|
||||
<!-- My Templates / Memories -->
|
||||
<div class="settings-section card">
|
||||
<div class="settings-section card" data-feature="memories">
|
||||
<h3><i class="fas fa-book-medical"></i> My Templates</h3>
|
||||
<p style="font-size:13px;color:var(--g600);">Save reusable templates for physical exam, ROS, encounter format, etc. Only template categories are sent to AI when generating notes. You can reference them by saying "use my normal physical exam" in dictation.</p>
|
||||
<div style="margin-bottom:10px;display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
|
||||
|
|
|
|||
120
public/css/assistant.css
Normal file
120
public/css/assistant.css
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
|
||||
.assistant-header { display:flex; justify-content:space-between; gap:12px; align-items:flex-start; }
|
||||
.assistant-status { display:flex; align-items:center; gap:6px; font-size:12px; color:var(--g500); background:white; border:1px solid var(--g200); border-radius:999px; padding:5px 10px; box-shadow:var(--shadow); }
|
||||
.assistant-dot { width:8px; height:8px; border-radius:50%; background:var(--green); display:inline-block; }
|
||||
.assistant-status.busy .assistant-dot { background:var(--amber); animation:pulse 1.5s infinite; }
|
||||
.assistant-status.error .assistant-dot { background:var(--red); }
|
||||
.assistant-layout { display:grid; grid-template-columns:minmax(0,1fr) 330px; gap:14px; align-items:start; }
|
||||
.assistant-layout > * { min-width:0; }
|
||||
.assistant-main { display:grid; grid-template-rows:auto minmax(420px,1fr) auto; min-height:calc(100vh - 190px); min-width:0; }
|
||||
.assistant-toolbar { display:flex; justify-content:space-between; align-items:center; gap:10px; padding:10px 14px; border-bottom:1px solid var(--g200); background:var(--g50); }
|
||||
.assistant-toolbar-actions { display:flex; gap:6px; flex-wrap:wrap; }
|
||||
.assistant-messages { padding:16px; max-height:65vh; overflow-y:auto; overflow-x:hidden; background:linear-gradient(180deg,#fff,var(--g50)); min-width:0; }
|
||||
.assistant-empty { max-width:680px; margin:50px auto; text-align:center; color:var(--g500); }
|
||||
.assistant-empty i { font-size:34px; color:var(--purple); margin-bottom:10px; }
|
||||
.assistant-empty h3 { color:var(--g800); font-size:18px; margin-bottom:6px; }
|
||||
.assistant-examples { display:flex; gap:8px; flex-wrap:wrap; justify-content:center; margin-top:16px; }
|
||||
.assistant-examples button { border:1px solid var(--g200); background:white; color:var(--blue); border-radius:999px; padding:7px 10px; font-size:12px; cursor:pointer; }
|
||||
.assistant-suggestion-buttons { display:flex; gap:8px; flex-wrap:wrap; margin-top:12px; }
|
||||
.assistant-suggestion-buttons button { border:1px solid var(--purple-light); background:#faf5ff; color:var(--purple); border-radius:999px; padding:7px 10px; font-size:12px; cursor:pointer; text-align:left; }
|
||||
.assistant-suggestion-buttons button:hover { border-color:var(--purple); background:var(--purple-light); }
|
||||
.assistant-msg { max-width:900px; min-width:0; margin:0 0 14px; display:grid; gap:6px; }
|
||||
.assistant-msg.user { margin-left:auto; max-width:760px; }
|
||||
.assistant-msg-label { font-size:11px; font-weight:700; color:var(--g400); text-transform:uppercase; letter-spacing:.04em; }
|
||||
.assistant-bubble { border:1px solid var(--g200); border-radius:14px; padding:12px 14px; background:white; box-shadow:var(--shadow); font-size:13px; line-height:1.75; overflow-wrap:anywhere; min-width:0; max-width:100%; }
|
||||
.assistant-msg.user .assistant-bubble { white-space:pre-wrap; background:var(--blue); color:white; border-color:var(--blue); }
|
||||
.assistant-bubble h1, .assistant-bubble h2, .assistant-bubble h3 { margin:16px 0 8px; line-height:1.25; color:var(--g900); }
|
||||
.assistant-bubble h1:first-child, .assistant-bubble h2:first-child, .assistant-bubble h3:first-child { margin-top:0; }
|
||||
.assistant-bubble h1 { font-size:20px; }
|
||||
.assistant-bubble h2 { font-size:17px; border-bottom:1px solid var(--g200); padding-bottom:4px; }
|
||||
.assistant-bubble h3 { font-size:15px; }
|
||||
.assistant-bubble p { margin:0 0 10px; }
|
||||
.assistant-bubble p:last-child { margin-bottom:0; }
|
||||
.assistant-bubble ul, .assistant-bubble ol { padding-left:20px; margin:8px 0; }
|
||||
.assistant-bubble li { margin:4px 0; }
|
||||
.assistant-bubble blockquote { margin:10px 0; padding:8px 12px; border-left:3px solid var(--blue); background:var(--blue-light); color:var(--g700); border-radius:8px; }
|
||||
.assistant-table-scroll { max-width:100%; overflow-x:auto; overflow-y:hidden; -webkit-overflow-scrolling:touch; margin:12px 0; border:1px solid var(--g200); border-radius:12px; background:white; box-shadow:inset 0 -1px 0 rgba(0,0,0,.03); }
|
||||
.assistant-table-scroll table { width:max-content; min-width:100%; max-width:none; border-collapse:separate; border-spacing:0; margin:0; border:0; border-radius:0; font-size:12px; }
|
||||
.assistant-table-scroll::after { content:'Swipe table'; display:none; position:sticky; left:0; bottom:0; padding:3px 9px; font-size:10px; font-weight:700; color:var(--g500); background:linear-gradient(90deg,rgba(255,255,255,.95),rgba(255,255,255,0)); pointer-events:none; }
|
||||
.assistant-bubble th, .assistant-bubble td { padding:8px 10px; border-bottom:1px solid var(--g200); vertical-align:top; text-align:left; }
|
||||
.assistant-bubble th, .assistant-bubble td { overflow-wrap:normal; word-break:normal; min-width:120px; }
|
||||
.assistant-bubble th { background:var(--g50); font-weight:700; color:var(--g800); }
|
||||
.assistant-bubble tr:last-child td { border-bottom:0; }
|
||||
.assistant-bubble code { background:var(--g100); border-radius:4px; padding:1px 4px; }
|
||||
.assistant-bubble pre { background:var(--g900); color:white; border-radius:8px; padding:10px; overflow:auto; margin:10px 0; }
|
||||
.assistant-bubble .katex-display { overflow-x:auto; overflow-y:hidden; padding:4px 0; }
|
||||
.assistant-thinking { background:linear-gradient(90deg,#fff,#f8fafc,#fff); background-size:220% 100%; animation:assistantShimmer 1.8s ease-in-out infinite; }
|
||||
.assistant-thinking-line { display:flex; align-items:center; gap:6px; color:var(--g800); }
|
||||
.assistant-thinking-detail { color:var(--g500); font-size:12px; margin-top:3px; }
|
||||
.assistant-thinking-dot { width:7px; height:7px; border-radius:50%; background:var(--purple); display:inline-block; animation:assistantBounce 1.2s infinite ease-in-out; }
|
||||
.assistant-thinking-dot:nth-child(2) { animation-delay:.15s; }
|
||||
.assistant-thinking-dot:nth-child(3) { animation-delay:.3s; margin-right:3px; }
|
||||
@keyframes assistantBounce { 0%,80%,100% { transform:scale(.65); opacity:.45; } 40% { transform:scale(1); opacity:1; } }
|
||||
@keyframes assistantShimmer { 0% { background-position:100% 0; } 100% { background-position:-100% 0; } }
|
||||
.assistant-cite { display:inline-flex; align-items:center; justify-content:center; min-width:18px; height:18px; padding:0 6px; margin:0 1px; border-radius:999px; background:var(--purple-light); color:var(--purple); font-size:10px; font-weight:800; text-decoration:none; vertical-align:baseline; border:1px solid rgba(124,58,237,.18); text-transform:uppercase; letter-spacing:.03em; }
|
||||
.assistant-cite:hover { background:var(--purple); color:white; text-decoration:none; }
|
||||
.assistant-composer { border-top:1px solid var(--g200); padding:12px; background:white; display:grid; gap:8px; }
|
||||
.assistant-composer textarea, .assistant-side textarea { width:100%; border:1.5px solid var(--g300); border-radius:10px; padding:10px 12px; resize:vertical; font-family:inherit; font-size:13px; outline:none; }
|
||||
.assistant-composer textarea:focus, .assistant-side textarea:focus { border-color:var(--blue); box-shadow:0 0 0 3px var(--blue-light); }
|
||||
.assistant-composer-footer { display:flex; justify-content:space-between; align-items:center; gap:10px; }
|
||||
.assistant-composer-footer .btn-generate { width:auto; margin:0; padding:9px 18px; }
|
||||
.assistant-composer-footer #btn-assistant-cancel[hidden] { display:none !important; }
|
||||
.assistant-composer-footer #btn-assistant-cancel:not([hidden]) { display:inline-flex; }
|
||||
.assistant-check { font-size:12px; color:var(--g500); display:flex; align-items:center; gap:6px; }
|
||||
.assistant-side { display:grid; gap:12px; }
|
||||
.assistant-side-body { padding:12px; display:grid; gap:10px; font-size:13px; }
|
||||
.assistant-visual-output { display:grid; gap:8px; }
|
||||
.assistant-image-buttons { display:flex; gap:8px; flex-wrap:wrap; }
|
||||
.assistant-visual-output img { width:100%; border-radius:10px; border:1px solid var(--g200); background:white; }
|
||||
.assistant-generated-image { display:grid; gap:8px; }
|
||||
.assistant-generated-image img { width:100%; border-radius:10px; border:1px solid var(--g200); background:white; }
|
||||
.assistant-image-actions { display:flex; gap:8px; flex-wrap:wrap; }
|
||||
.assistant-image-preview-open { overflow:hidden; }
|
||||
.assistant-image-modal { position:fixed; inset:0; z-index:9999; background:rgba(15,23,42,.82); display:flex; align-items:center; justify-content:center; padding:24px; }
|
||||
.assistant-image-modal-card { position:relative; display:grid; gap:10px; max-width:min(96vw,1200px); max-height:92vh; }
|
||||
.assistant-image-modal-card img { max-width:100%; max-height:92vh; border-radius:14px; background:white; box-shadow:0 24px 80px rgba(0,0,0,.35); }
|
||||
.assistant-image-modal-close { position:absolute; top:8px; right:8px; z-index:1; width:38px; height:38px; border:0; border-radius:999px; background:white; color:var(--g800); font-size:24px; line-height:1; cursor:pointer; box-shadow:var(--shadow); }
|
||||
.assistant-image-modal-cancel { justify-self:center; border:0; border-radius:999px; background:white; color:var(--g800); font-weight:700; padding:9px 14px; box-shadow:var(--shadow); cursor:pointer; }
|
||||
.assistant-sources { padding:10px 12px; display:grid; gap:8px; max-height:520px; overflow-y:auto; }
|
||||
.assistant-saved-chats { padding:10px 12px; display:grid; gap:8px; max-height:220px; overflow-y:auto; }
|
||||
.assistant-saved-chat { border:1px solid var(--g200); border-radius:10px; padding:8px; background:white; display:grid; gap:5px; }
|
||||
.assistant-saved-chat-title { font-size:12px; font-weight:700; color:var(--g800); line-height:1.35; }
|
||||
.assistant-saved-chat-meta { font-size:11px; color:var(--g500); }
|
||||
.assistant-saved-chat-actions { display:flex; gap:6px; flex-wrap:wrap; }
|
||||
.assistant-save-panel { border-top:1px solid var(--g200); padding:10px 12px; display:grid; gap:7px; }
|
||||
.assistant-save-panel[hidden] { display:none; }
|
||||
.assistant-save-panel label { font-size:11px; font-weight:700; color:var(--g500); text-transform:uppercase; letter-spacing:.04em; }
|
||||
.assistant-save-panel input { width:100%; border:1.5px solid var(--g300); border-radius:9px; padding:8px 10px; font-size:12px; outline:none; }
|
||||
.assistant-save-panel input:focus { border-color:var(--blue); box-shadow:0 0 0 3px var(--blue-light); }
|
||||
.assistant-save-actions { display:flex; gap:6px; flex-wrap:wrap; }
|
||||
.assistant-source { border:1px solid var(--g200); border-radius:10px; padding:9px; background:white; font-size:12px; line-height:1.5; }
|
||||
.assistant-source strong { color:var(--g800); }
|
||||
.assistant-source-badges { display:flex; gap:5px; flex-wrap:wrap; margin-top:6px; }
|
||||
.assistant-source-badges span { border:1px solid var(--g200); border-radius:999px; background:var(--g50); color:var(--g600); padding:2px 7px; font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.03em; }
|
||||
.assistant-source-meta { color:var(--g500); font-size:11px; margin-top:3px; }
|
||||
.assistant-source-preview { margin-top:8px; }
|
||||
.assistant-source-preview button { border:0; padding:0; background:transparent; cursor:pointer; width:100%; display:block; }
|
||||
.assistant-source-preview img { width:100%; max-height:220px; object-fit:contain; border:1px solid var(--g200); border-radius:10px; background:white; display:block; }
|
||||
.assistant-source-excerpt { margin-top:7px; color:var(--g600); max-height:170px; overflow:auto; }
|
||||
.assistant-source-excerpt p { margin:0 0 6px; }
|
||||
.assistant-source-excerpt ul, .assistant-source-excerpt ol { padding-left:16px; margin:4px 0; }
|
||||
.assistant-source-excerpt strong { color:var(--g700); }
|
||||
.assistant-muted { color:var(--g500); font-size:12px; line-height:1.6; }
|
||||
.assistant-mermaid { background:white; border:1px solid var(--g200); border-radius:10px; padding:10px; margin:10px 0; overflow:auto; }
|
||||
@media (max-width: 960px) { .assistant-layout { grid-template-columns:1fr; } .assistant-main { min-height:auto; grid-template-rows:auto minmax(320px,1fr) auto; } }
|
||||
@media (max-width: 640px) {
|
||||
.assistant-header { flex-direction:column; align-items:stretch; }
|
||||
.assistant-status { align-self:flex-start; }
|
||||
.assistant-toolbar { flex-direction:column; align-items:stretch; }
|
||||
.assistant-toolbar-actions { display:grid; grid-template-columns:1fr 1fr; }
|
||||
.assistant-toolbar-actions .btn-sm { width:100%; justify-content:center; }
|
||||
.assistant-messages { padding:10px; }
|
||||
.assistant-msg, .assistant-msg.user { max-width:100%; }
|
||||
.assistant-bubble { font-size:13px; padding:11px 12px; }
|
||||
.assistant-table-scroll::after { display:block; }
|
||||
.assistant-composer { position:sticky; bottom:0; z-index:3; }
|
||||
.assistant-composer-footer { flex-direction:column; align-items:stretch; }
|
||||
.assistant-composer-footer .btn-generate { width:100%; }
|
||||
.assistant-composer-footer #btn-assistant-cancel:not([hidden]) { width:100%; justify-content:center; }
|
||||
.assistant-side { gap:10px; }
|
||||
}
|
||||
|
|
@ -1,3 +1,9 @@
|
|||
html:not([data-feature-read_aloud="true"]) [data-action="speak"],
|
||||
html:not([data-feature-read_aloud="true"]) [data-feature="read_aloud"],
|
||||
html:not([data-feature-nextcloud="true"]) [data-action="nc-export"],
|
||||
html:not([data-feature-nextcloud="true"]) [data-feature="nextcloud"],
|
||||
html:not([data-feature-memories="true"]) [data-feature="memories"] { display: none !important; }
|
||||
|
||||
:root {
|
||||
--blue: #2563eb; --blue-dark: #1d4ed8; --blue-light: #dbeafe;
|
||||
--purple: #7c3aed; --purple-light: #ede9fe;
|
||||
|
|
|
|||
|
|
@ -16,9 +16,7 @@
|
|||
complete a challenge inside a display:none container. auth.js renders
|
||||
each widget the first time its form is shown. -->
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" async defer></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.1.6/purify.min.js"
|
||||
integrity="sha384-+VfUPEb0PdtChMwmBcBmykRMDd+v6D/oFmB3rZM/puCMDYcIvF968OimRh4KQY9a"
|
||||
crossorigin="anonymous" referrerpolicy="no-referrer" defer></script>
|
||||
<script src="/vendor/dompurify/purify.min.js"></script>
|
||||
<link rel="stylesheet" href="/vendor/katex/katex.min.css">
|
||||
<script src="/vendor/marked/marked.umd.js" defer></script>
|
||||
<script src="/vendor/markdown-it/markdown-it.min.js" defer></script>
|
||||
|
|
@ -35,6 +33,12 @@
|
|||
<link rel="icon" type="image/png" sizes="16x16" href="/icons/icon-16.png">
|
||||
<link rel="apple-touch-icon" sizes="192x192" href="/icons/icon-192.png">
|
||||
<link rel="apple-touch-icon" sizes="512x512" href="/icons/icon-512.png">
|
||||
<style>
|
||||
html.account-transition body > :not(#account-recovery) { display: none !important; }
|
||||
#account-recovery { padding: 2rem; font-family: sans-serif; }
|
||||
</style>
|
||||
<script src="/js/accountBoundary.js"></script>
|
||||
<script type="module" src="/js/authFetch.js"></script>
|
||||
<!-- Auth screen hidden by default — auth.js shows it only when no valid session exists -->
|
||||
<style>#auth-screen { display: none; }</style>
|
||||
</head>
|
||||
|
|
@ -480,7 +484,6 @@
|
|||
<script defer src="/js/app.js?v=7.1.3"></script>
|
||||
<script type="module" src="/js/ui-state.js"></script>
|
||||
<script type="module" src="/js/secureStorage.js"></script>
|
||||
<script type="module" src="/js/authFetch.js"></script>
|
||||
<script defer src="/js/auth.js"></script>
|
||||
<script defer src="/js/liveEncounter.js"></script>
|
||||
<script defer src="/js/voiceDictation.js"></script>
|
||||
|
|
|
|||
218
public/js/accountBoundary.js
Normal file
218
public/js/accountBoundary.js
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
// One verified owner per document. Never unlock: a new account needs a new JS realm.
|
||||
(function() {
|
||||
var KEY = 'ped_account_boundary_v1';
|
||||
var owner = null, generation = null, locked = false, revision = 0;
|
||||
var controller = new AbortController();
|
||||
var channel = null;
|
||||
var storageFailed = false;
|
||||
var signInRequired = false;
|
||||
var SIGN_IN_KEY = 'ped_signin_required';
|
||||
function needsSignIn() {
|
||||
try { return sessionStorage.getItem(SIGN_IN_KEY) === '1'; }
|
||||
catch (e) { return true; }
|
||||
}
|
||||
function recoverSignIn() {
|
||||
signInRequired = true;
|
||||
freeze();
|
||||
document.querySelector('#account-recovery p').textContent =
|
||||
'Your saved credentials no longer match this session. Sign in again in a fresh page.';
|
||||
document.querySelector('#account-recovery button').textContent = 'Sign in again';
|
||||
}
|
||||
function read() {
|
||||
try { return JSON.parse(localStorage.getItem(KEY) || 'null'); }
|
||||
catch (e) { storageFailed = true; return null; }
|
||||
}
|
||||
function publish(next) {
|
||||
try {
|
||||
var value = JSON.stringify(next);
|
||||
localStorage.setItem(KEY, value);
|
||||
if (localStorage.getItem(KEY) !== value) throw new Error('Account isolation write not confirmed');
|
||||
storageFailed = false;
|
||||
}
|
||||
catch (e) { storageFailed = true; freeze(); throw new Error('Account isolation storage unavailable'); }
|
||||
try { if (channel) channel.postMessage(next); } catch (e) {}
|
||||
return next;
|
||||
}
|
||||
function fresh(nextOwner) {
|
||||
return { owner: nextOwner, generation: crypto.randomUUID(), signedOut: !nextOwner };
|
||||
}
|
||||
function abortError() { return new DOMException('Account changed; reload required', 'AbortError'); }
|
||||
function reload() {
|
||||
// A failed publication is only realm-local until a durable signed-out latch
|
||||
// exists. Never discard that failure by navigating back to surviving auth.
|
||||
if (storageFailed) {
|
||||
freeze();
|
||||
try { publish(fresh(null)); }
|
||||
catch (e) {
|
||||
document.querySelector('#account-recovery p').textContent =
|
||||
'Cannot safely reload: session storage is unavailable. Restore storage access, then retry Reload.';
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (signInRequired) {
|
||||
// Realm-local recovery must not erase a newer sibling's owner/credentials.
|
||||
try {
|
||||
sessionStorage.setItem(SIGN_IN_KEY, '1');
|
||||
if (sessionStorage.getItem(SIGN_IN_KEY) !== '1') throw new Error('Sign-in recovery write not confirmed');
|
||||
} catch (e) {
|
||||
document.querySelector('#account-recovery p').textContent =
|
||||
'Cannot safely reload: session storage is unavailable. Restore storage access, then retry Sign in again.';
|
||||
return;
|
||||
}
|
||||
}
|
||||
try { window.location.reload(); } catch (e) { /* Recovery stays available. */ }
|
||||
}
|
||||
function freeze() {
|
||||
if (locked) return;
|
||||
locked = true;
|
||||
revision++;
|
||||
controller.abort();
|
||||
// CSS also hides late modal/async DOM insertions outside main-app.
|
||||
document.documentElement.classList.add('account-transition');
|
||||
Array.from(document.body.children).forEach(function(el) { el.inert = true; });
|
||||
var notice = document.createElement('section');
|
||||
notice.id = 'account-recovery';
|
||||
notice.setAttribute('role', 'alert');
|
||||
var text = document.createElement('p');
|
||||
text.textContent = 'Your account session changed. Reload to continue safely.';
|
||||
var button = document.createElement('button');
|
||||
button.textContent = 'Reload';
|
||||
button.addEventListener('click', reload);
|
||||
notice.append(text, button);
|
||||
document.body.appendChild(notice);
|
||||
button.focus();
|
||||
window.dispatchEvent(new Event('account-boundary'));
|
||||
}
|
||||
function current() {
|
||||
if (locked) return false;
|
||||
var shared = read();
|
||||
if (owner && (!shared || shared.signedOut || shared.owner !== owner || shared.generation !== generation)) {
|
||||
freeze();
|
||||
reload();
|
||||
}
|
||||
return !locked && !storageFailed && !!owner;
|
||||
}
|
||||
function capture() { return current() ? owner : null; }
|
||||
function valid(ticket) { return !!ticket && ticket === owner && current(); }
|
||||
function receive(message) {
|
||||
var latest = read();
|
||||
// Ignore queued events from A after B has already published its session.
|
||||
if (!message || !latest || message.generation !== latest.generation) return;
|
||||
if (owner && (message.signedOut || message.owner !== owner || message.generation !== generation)) {
|
||||
freeze();
|
||||
reload(); // Sibling events must never delete another tab's newly persisted native credentials.
|
||||
}
|
||||
}
|
||||
try { channel = new BroadcastChannel('pedscribe-auth'); channel.onmessage = function(e) { receive(e.data); }; } catch (e) {}
|
||||
window.addEventListener('storage', function(e) {
|
||||
if (e.key !== KEY) return;
|
||||
if (!e.newValue) { if (owner) { freeze(); reload(); } return; }
|
||||
try { receive(JSON.parse(e.newValue)); } catch (err) {}
|
||||
});
|
||||
window.addEventListener('pageshow', function(e) {
|
||||
if (e.persisted) { freeze(); reload(); }
|
||||
else if (owner) current();
|
||||
});
|
||||
// Hide before a BFCache snapshot is taken, not merely after it is restored.
|
||||
window.addEventListener('pagehide', freeze);
|
||||
|
||||
// Preserve unowned legacy clinical data, but never read or adopt it.
|
||||
// Clinical callers use storageKey() only after a verified owner enters.
|
||||
|
||||
window.AccountBoundary = {
|
||||
key: KEY, read: read, freeze: freeze, reload: reload, capture: capture, valid: valid,
|
||||
recoverSignIn: recoverSignIn, needsSignIn: needsSignIn,
|
||||
completeSignIn: function() { sessionStorage.removeItem(SIGN_IN_KEY); },
|
||||
active: current, blocked: function() { return locked; }, error: abortError,
|
||||
revision: function() { return revision; }, signal: function() { return controller.signal; },
|
||||
signedOut: function() { var state = read(); return storageFailed || !!(state && state.signedOut); },
|
||||
startLogin: function() {
|
||||
if (locked) throw abortError();
|
||||
if (owner) freeze();
|
||||
publish(fresh(null)); // Freeze siblings before a login response can replace the shared cookie.
|
||||
return revision;
|
||||
},
|
||||
end: function() {
|
||||
freeze();
|
||||
var published = false;
|
||||
try { publish(fresh(null)); published = true; } catch (e) { /* reload() retries the durable latch. */ }
|
||||
window.AUTH_TOKEN = null;
|
||||
window.CURRENT_USER = null;
|
||||
return published;
|
||||
},
|
||||
enter: function(user, explicit) {
|
||||
if (!user || user.id == null || storageFailed) { freeze(); return false; }
|
||||
var nextOwner = String(user.id);
|
||||
if (locked || (owner && owner !== nextOwner)) { freeze(); return false; }
|
||||
if (!explicit && (this.signedOut() || needsSignIn())) return false;
|
||||
var state = read();
|
||||
if (storageFailed) { freeze(); return false; }
|
||||
if (!explicit && state && state.owner !== nextOwner) { recoverSignIn(); return false; }
|
||||
if (explicit || !state) state = publish(fresh(nextOwner));
|
||||
owner = nextOwner; generation = state.generation;
|
||||
window.dispatchEvent(new Event('account-ready'));
|
||||
return true;
|
||||
},
|
||||
// Successful replacement login persists credentials first, but cannot enter this document.
|
||||
publishLogin: function(user) { publish(fresh(String(user.id))); },
|
||||
storageKey: function(key) {
|
||||
if (!current()) throw abortError();
|
||||
return key + ':owner:' + encodeURIComponent(owner);
|
||||
}
|
||||
};
|
||||
|
||||
// All actual recorder callers share these platform boundaries, including
|
||||
// Notes, ED, the assistant and recorders constructed during pause/resume.
|
||||
var recorders = new Set(), streams = new Set(), recognizers = new Set();
|
||||
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
|
||||
var getUserMedia = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices);
|
||||
navigator.mediaDevices.getUserMedia = function(constraints) {
|
||||
var ticket = capture();
|
||||
if (!ticket) return Promise.reject(abortError());
|
||||
return getUserMedia(constraints).then(function(stream) {
|
||||
if (!valid(ticket)) { stream.getTracks().forEach(function(t) { t.stop(); }); throw abortError(); }
|
||||
streams.add(stream);
|
||||
return stream;
|
||||
});
|
||||
};
|
||||
}
|
||||
if (window.MediaRecorder) {
|
||||
var startRecording = MediaRecorder.prototype.start;
|
||||
MediaRecorder.prototype.start = function() {
|
||||
if (!current()) throw abortError();
|
||||
recorders.add(this);
|
||||
var recorder = this;
|
||||
this.addEventListener('stop', function() { recorders.delete(recorder); streams.delete(recorder.stream); }, { once: true });
|
||||
['stop', 'dataavailable'].forEach(function(type) {
|
||||
recorder.addEventListener(type, function(e) {
|
||||
if (!current()) e.stopImmediatePropagation();
|
||||
}, { capture: true });
|
||||
});
|
||||
return startRecording.apply(this, arguments);
|
||||
};
|
||||
}
|
||||
[window.SpeechRecognition, window.webkitSpeechRecognition].filter(function(value, i, all) {
|
||||
return value && all.indexOf(value) === i;
|
||||
}).forEach(function(Recognition) {
|
||||
var start = Recognition.prototype.start;
|
||||
Recognition.prototype.start = function() {
|
||||
if (!current()) throw abortError();
|
||||
recognizers.add(this);
|
||||
return start.apply(this, arguments);
|
||||
};
|
||||
});
|
||||
window.addEventListener('account-boundary', function() {
|
||||
recognizers.forEach(function(rec) {
|
||||
rec.onresult = rec.onend = rec.onerror = null;
|
||||
try { rec.abort(); } catch (e) {}
|
||||
});
|
||||
recorders.forEach(function(rec) {
|
||||
rec.ondataavailable = rec.onstop = rec.onerror = null;
|
||||
try { if (rec.state !== 'inactive') rec.stop(); } catch (e) {}
|
||||
});
|
||||
streams.forEach(function(stream) { stream.getTracks().forEach(function(track) { track.stop(); }); });
|
||||
recorders.clear(); streams.clear(); recognizers.clear();
|
||||
if (window.nativeStopRecordingService) window.nativeStopRecordingService();
|
||||
if (window.nativeKeepAwake) window.nativeKeepAwake(false);
|
||||
});
|
||||
})();
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import { initClinicalAssistantAdmin } from './admin/clinicalAssistant.js';
|
||||
|
||||
// ============================================================
|
||||
// ADMIN.JS — Admin panel: users, settings, stats
|
||||
// ============================================================
|
||||
|
|
@ -305,8 +307,6 @@ function adminFlashButtonBackground(btn, color) {
|
|||
if (e.target.closest('#btn-save-flags')) saveFlags();
|
||||
if (e.target.closest('#btn-save-email')) saveEmail();
|
||||
if (e.target.closest('#btn-test-email')) sendTestEmail();
|
||||
if (e.target.closest('#btn-save-prompt')) savePrompt();
|
||||
if (e.target.closest('#btn-reset-prompt')) resetPrompt();
|
||||
if (e.target.closest('#btn-save-smtp')) saveSmtp();
|
||||
if (e.target.closest('#btn-clear-smtp')) clearSmtp();
|
||||
if (e.target.closest('#btn-save-auto-delete')) saveAutoDelete();
|
||||
|
|
@ -316,7 +316,6 @@ function adminFlashButtonBackground(btn, color) {
|
|||
// When email template selector changes, repopulate fields
|
||||
document.addEventListener('change', function(e) {
|
||||
if (e.target.id === 'cms-email-template') loadEmailFields(e.target.value);
|
||||
if (e.target.id === 'cms-prompt-select') loadPromptText(e.target.value);
|
||||
});
|
||||
|
||||
// ---- LOAD ALL CONFIG ----
|
||||
|
|
@ -512,75 +511,162 @@ function adminFlashButtonBackground(btn, color) {
|
|||
}
|
||||
});
|
||||
|
||||
function loadPromptList() {
|
||||
fetch('/api/admin/config/prompts', { headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.success) return;
|
||||
var sel = document.getElementById('cms-prompt-select');
|
||||
if (!sel) return;
|
||||
sel.innerHTML = '';
|
||||
(data.prompts || []).forEach(function(p) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = p.key;
|
||||
opt.textContent = p.key;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
window._adminPrompts = {};
|
||||
(data.prompts || []).forEach(function(p) { window._adminPrompts[p.key] = p.value; });
|
||||
if (data.prompts && data.prompts.length > 0) {
|
||||
loadPromptText(data.prompts[0].key);
|
||||
}
|
||||
})
|
||||
.catch(function(err) { console.error('[AdminCMS] Prompts load failed:', err); });
|
||||
}
|
||||
let promptsLoaded = false;
|
||||
let promptsLoading = false;
|
||||
|
||||
function loadPromptText(key) {
|
||||
var textarea = document.getElementById('cms-prompt-text');
|
||||
if (!textarea) return;
|
||||
textarea.value = (window._adminPrompts && window._adminPrompts[key]) || '';
|
||||
}
|
||||
|
||||
function savePrompt() {
|
||||
var sel = document.getElementById('cms-prompt-select');
|
||||
var text = document.getElementById('cms-prompt-text');
|
||||
if (!sel || !text) return;
|
||||
var key = sel.value;
|
||||
var value = text.value;
|
||||
if (!key) return;
|
||||
|
||||
putConfig('prompt.' + key, value)
|
||||
.then(function() {
|
||||
if (!window._adminPrompts) window._adminPrompts = {};
|
||||
window._adminPrompts[key] = value;
|
||||
showToast('Prompt saved', 'success');
|
||||
})
|
||||
.catch(function() { showToast('Save failed', 'error'); });
|
||||
}
|
||||
|
||||
function resetPrompt() {
|
||||
var sel = document.getElementById('cms-prompt-select');
|
||||
if (!sel || !sel.value) return;
|
||||
var key = sel.value;
|
||||
showConfirm('Reset "' + key + '" to the hardcoded default? This cannot be undone.', function() {
|
||||
fetch('/api/admin/config/prompts/' + key + '/reset', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success) {
|
||||
if (!window._adminPrompts) window._adminPrompts = {};
|
||||
window._adminPrompts[key] = data.value;
|
||||
var textarea = document.getElementById('cms-prompt-text');
|
||||
if (textarea) textarea.value = data.value;
|
||||
showToast('Prompt reset to default', 'success');
|
||||
} else {
|
||||
showToast(data.error || 'Reset failed', 'error');
|
||||
}
|
||||
})
|
||||
.catch(function() { showToast('Request failed', 'error'); });
|
||||
async function promptRequest(url, method, body) {
|
||||
var response = await fetch(url, {
|
||||
headers: getAuthHeaders(), method: method || 'GET',
|
||||
body: body === undefined ? undefined : JSON.stringify(body)
|
||||
});
|
||||
var data = await response.json();
|
||||
if (response.status === 409) throw new Error('Conflict: this prompt changed elsewhere. Your draft is unchanged. Open History, view the current revision, then explicitly use it as your save baseline.');
|
||||
if (!response.ok || !data.success) throw new Error(data.error || 'Prompt request failed');
|
||||
return data;
|
||||
}
|
||||
|
||||
async function loadPromptList() {
|
||||
// General settings reloads must not replace any prompt drafts.
|
||||
if (promptsLoaded || promptsLoading) return;
|
||||
promptsLoading = true;
|
||||
var groups = [
|
||||
['scribe', document.getElementById('cms-scribe-prompts')],
|
||||
['clinical-text', document.getElementById('cms-clinical-text-prompts')],
|
||||
['clinical-image', document.getElementById('cms-clinical-image-prompts')]
|
||||
];
|
||||
try {
|
||||
var data = await promptRequest('/api/admin/config/prompts');
|
||||
groups.forEach(function(group) {
|
||||
if (!group[1]) return;
|
||||
group[1].replaceChildren();
|
||||
(data.prompts || []).filter(function(p) { return p.family === group[0] && p.editable === true; })
|
||||
.forEach(function(p) { group[1].appendChild(createPromptEditor(p)); });
|
||||
if (!group[1].children.length) group[1].textContent = 'No editable prompts available in this family.';
|
||||
});
|
||||
promptsLoaded = true;
|
||||
} catch (error) {
|
||||
groups.forEach(function(group) {
|
||||
if (!group[1]) return;
|
||||
group[1].textContent = 'Could not load prompts: ' + error.message + ' ';
|
||||
var retry = document.createElement('button');
|
||||
retry.type = 'button'; retry.className = 'btn-sm btn-ghost'; retry.textContent = 'Retry loading prompts';
|
||||
retry.onclick = loadPromptList;
|
||||
group[1].appendChild(retry);
|
||||
});
|
||||
} finally { promptsLoading = false; }
|
||||
}
|
||||
|
||||
function createPromptEditor(prompt) {
|
||||
var revision = prompt.revision;
|
||||
var currentRevision = revision;
|
||||
var viewedRevision = null;
|
||||
var busy = false;
|
||||
var base = '/api/admin/config/prompts/' + encodeURIComponent(prompt.dbKey);
|
||||
var editor = document.createElement('details');
|
||||
editor.dataset.promptKey = prompt.dbKey;
|
||||
editor.open = prompt.family !== 'scribe';
|
||||
editor.style.cssText = 'border-top:1px solid var(--g200);padding:12px 0;';
|
||||
// Only static markup is parsed; all catalogue/revision content is assigned as text.
|
||||
editor.innerHTML = '<summary></summary><p class="prompt-purpose"></p><p class="prompt-usage"></p>' +
|
||||
'<label>Effective prompt / draft<textarea class="prompt-draft" rows="8" style="display:block;width:100%;box-sizing:border-box;font-family:monospace;"></textarea></label>' +
|
||||
'<p class="prompt-baseline"></p><div style="display:flex;gap:8px;flex-wrap:wrap;">' +
|
||||
'<button type="button" class="btn-sm btn-primary" data-prompt-action="save">Save prompt</button>' +
|
||||
'<button type="button" class="btn-sm btn-ghost" data-prompt-action="reset">Reset to shipped default</button>' +
|
||||
'<button type="button" class="btn-sm btn-ghost" data-prompt-action="history">History (latest 100)</button></div>' +
|
||||
'<p class="prompt-status" role="status"></p><div class="prompt-history" hidden>' +
|
||||
'<label>Saved revisions<select class="prompt-revisions" style="display:block;max-width:100%;"></select></label>' +
|
||||
'<button type="button" class="btn-sm btn-ghost" data-prompt-action="view">View revision</button>' +
|
||||
'<label>Revision text (read-only)<textarea class="prompt-revision-text" rows="8" readonly style="display:block;width:100%;box-sizing:border-box;font-family:monospace;"></textarea></label>' +
|
||||
'<p class="prompt-revision-meta"></p>' +
|
||||
'<button type="button" class="btn-sm btn-ghost" data-prompt-action="restore">Restore viewed revision</button>' +
|
||||
'<button type="button" class="btn-sm btn-ghost" data-prompt-action="baseline">Keep draft; use viewed current revision as save baseline</button></div>';
|
||||
editor.querySelector('summary').textContent = prompt.key;
|
||||
editor.querySelector('.prompt-purpose').textContent = 'Purpose: ' + prompt.purpose;
|
||||
editor.querySelector('.prompt-usage').textContent = 'Used by: ' + (prompt.usedBy || []).join('; ');
|
||||
var text = editor.querySelector('.prompt-draft');
|
||||
text.value = prompt.value;
|
||||
var status = editor.querySelector('.prompt-status');
|
||||
var history = editor.querySelector('.prompt-history');
|
||||
var select = editor.querySelector('.prompt-revisions');
|
||||
var preview = editor.querySelector('.prompt-revision-text');
|
||||
var meta = editor.querySelector('.prompt-revision-meta');
|
||||
var buttons = {};
|
||||
editor.querySelectorAll('[data-prompt-action]').forEach(function(button) { buttons[button.dataset.promptAction] = button; });
|
||||
|
||||
function controls() {
|
||||
Object.values(buttons).forEach(function(button) { button.disabled = busy; });
|
||||
select.disabled = busy;
|
||||
buttons.view.disabled = busy || !select.value;
|
||||
buttons.restore.disabled = busy || !viewedRevision;
|
||||
buttons.baseline.disabled = busy || !viewedRevision || viewedRevision.id !== currentRevision;
|
||||
editor.querySelector('.prompt-baseline').textContent = 'Save baseline: revision ' + revision + (revision === 0 ? ' (no history yet).' : '.');
|
||||
}
|
||||
async function run(task) {
|
||||
if (busy) return;
|
||||
busy = true; controls(); status.textContent = 'Working...';
|
||||
try { await task(); }
|
||||
catch (error) { status.textContent = error.message + ' Unsaved edits are preserved.'; }
|
||||
finally { busy = false; controls(); }
|
||||
}
|
||||
function clearPreview() {
|
||||
viewedRevision = null; preview.value = ''; meta.textContent = ''; controls();
|
||||
}
|
||||
function revisionLabel(item) {
|
||||
return '#' + item.id + ' — ' + item.createdAt + ' — ' + (item.createdBy == null ? 'unknown actor' : item.createdBy) +
|
||||
(item.wasDefault ? ' — default snapshot' : '') + (item.restoredFrom == null ? '' : ' — restored from #' + item.restoredFrom);
|
||||
}
|
||||
async function mutate(action, revisionId) {
|
||||
var draft = text.value;
|
||||
var body = { expectedRevision: revision };
|
||||
if (action === 'save') body.value = draft;
|
||||
if (action === 'restore') body.revisionId = revisionId;
|
||||
var data = await promptRequest(action === 'save' ? '/api/admin/config/' + encodeURIComponent(prompt.dbKey) : base + '/' + action,
|
||||
action === 'save' ? 'PUT' : 'POST', body);
|
||||
revision = data.revision;
|
||||
currentRevision = revision;
|
||||
// Typing while a request is pending must not be overwritten by its response.
|
||||
if (text.value === draft) text.value = data.value;
|
||||
history.hidden = true; select.replaceChildren(); clearPreview();
|
||||
status.textContent = 'Saved revision ' + revision + (text.value === data.value ? '.' : '. Newer draft edits are still unsaved.');
|
||||
}
|
||||
buttons.save.onclick = function() { run(function() { return mutate('save'); }); };
|
||||
buttons.reset.onclick = function() {
|
||||
showConfirm('Reset only this prompt to its shipped default? This replaces this editor’s draft and creates a revision; other editors are unchanged.', function() {
|
||||
run(function() { return mutate('reset'); });
|
||||
});
|
||||
};
|
||||
buttons.history.onclick = function() { run(async function() {
|
||||
var data = await promptRequest(base + '/history?limit=100');
|
||||
currentRevision = data.revision;
|
||||
select.replaceChildren(); clearPreview();
|
||||
(data.revisions || []).forEach(function(item) {
|
||||
var option = document.createElement('option'); option.value = item.id; option.textContent = revisionLabel(item); select.appendChild(option);
|
||||
});
|
||||
history.hidden = false;
|
||||
status.textContent = 'Current server revision: ' + currentRevision + '. ' + (select.options.length ? 'Select a revision to view. Your draft is unchanged.' : 'No saved revisions yet.');
|
||||
}); };
|
||||
select.onchange = clearPreview;
|
||||
buttons.view.onclick = function() { run(async function() {
|
||||
var data = await promptRequest(base + '/revisions/' + encodeURIComponent(select.value));
|
||||
viewedRevision = data.revision;
|
||||
preview.value = viewedRevision.value;
|
||||
meta.textContent = revisionLabel(viewedRevision);
|
||||
status.textContent = 'Viewing revision ' + viewedRevision.id + '. Your draft is unchanged.';
|
||||
}); };
|
||||
buttons.restore.onclick = function() {
|
||||
var id = viewedRevision && viewedRevision.id;
|
||||
if (!id) return;
|
||||
showConfirm('Restore revision ' + id + ' for only this prompt? This replaces this editor’s draft and creates a new revision.', function() {
|
||||
run(function() { return mutate('restore', id); });
|
||||
});
|
||||
};
|
||||
buttons.baseline.onclick = function() {
|
||||
if (!viewedRevision || viewedRevision.id !== currentRevision) return;
|
||||
revision = currentRevision; controls();
|
||||
status.textContent = 'Draft kept. The next save will use revision ' + revision + ' as its baseline; review your edits before saving.';
|
||||
};
|
||||
controls();
|
||||
return editor;
|
||||
}
|
||||
|
||||
// ---- SMTP ----
|
||||
|
|
@ -686,281 +772,7 @@ function adminFlashButtonBackground(btn, color) {
|
|||
// ============================================================
|
||||
// ADMIN CLINICAL ASSISTANT SETTINGS
|
||||
// ============================================================
|
||||
{
|
||||
let loaded = false;
|
||||
const defaults = {
|
||||
behavior: 'You are a concise pediatric clinical assistant. Use retrieved context only for factual claims. If the user input is a greeting or too vague, answer briefly and ask what they want to look up. Synthesize across sources and cite factual claims with the exact provided source numbers like [1]. Do not invent, renumber, merge, or move citations.'
|
||||
};
|
||||
|
||||
document.addEventListener('tabChanged', function(e) {
|
||||
if (e.detail && e.detail.tab === 'admin') {
|
||||
if (!loaded) { loadAssistantAdmin(); loaded = true; }
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.closest('#btn-save-assistant-config')) saveAssistantAdmin();
|
||||
if (e.target.closest('#btn-test-assistant-chat-model')) testAssistantChatModel();
|
||||
if (e.target.closest('#btn-regenerate-assistant-prompt-pool')) regenerateAssistantPromptPool();
|
||||
if (e.target.closest('#btn-restore-assistant-prompt-pool')) restoreAssistantPromptPool();
|
||||
if (e.target.closest('#btn-refresh-assistant-image-models')) loadAssistantImageModels();
|
||||
if (e.target.closest('#btn-test-assistant-image-model')) testAssistantImageModel();
|
||||
if (e.target.closest('#btn-use-custom-assistant-image-model')) useCustomAssistantImageModel();
|
||||
});
|
||||
|
||||
function loadAssistantAdmin() {
|
||||
Promise.all([
|
||||
fetch('/api/models', { headers: getAuthHeaders() }).then(function(r) { return r.json(); }).catch(function() { return {}; }),
|
||||
fetch('/api/admin/config', { headers: getAuthHeaders() }).then(function(r) { return r.json(); }).catch(function() { return {}; })
|
||||
]).then(function(results) {
|
||||
var modelsData = results[0] || {};
|
||||
var configData = results[1] || {};
|
||||
var cfg = {};
|
||||
(configData.config || []).forEach(function(row) { cfg[row.key] = row.value; });
|
||||
|
||||
var chatSelect = document.getElementById('assistant-chat-model');
|
||||
if (chatSelect) {
|
||||
var savedChatModel = cfg['clinical_assistant.chat_model'] || '';
|
||||
var defaultLabel = modelsData.defaultModel ? ('Use global default (' + modelsData.defaultModel + ')') : 'Use global default';
|
||||
chatSelect.innerHTML = '';
|
||||
var defaultOpt = document.createElement('option');
|
||||
defaultOpt.value = '';
|
||||
defaultOpt.textContent = defaultLabel;
|
||||
chatSelect.appendChild(defaultOpt);
|
||||
(modelsData.models || []).forEach(function(m) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = m.id;
|
||||
opt.textContent = m.name || m.id;
|
||||
chatSelect.appendChild(opt);
|
||||
});
|
||||
if (savedChatModel && !Array.prototype.some.call(chatSelect.options, function(o) { return o.value === savedChatModel; })) {
|
||||
var saved = document.createElement('option');
|
||||
saved.value = savedChatModel;
|
||||
saved.textContent = savedChatModel + ' (saved/custom)';
|
||||
chatSelect.appendChild(saved);
|
||||
}
|
||||
chatSelect.value = savedChatModel;
|
||||
}
|
||||
window._assistantImageModelValue = cfg['clinical_assistant.image_model'] || '';
|
||||
loadAssistantImageModels();
|
||||
loadAssistantPromptPoolStatus();
|
||||
setValue('assistant-search-limit', cfg['clinical_assistant.search_limit'] || '8');
|
||||
setValue('assistant-context-chars', cfg['clinical_assistant.context_chars'] || '1400');
|
||||
setValue('assistant-system-behavior', cfg['clinical_assistant.system_behavior'] || defaults.behavior);
|
||||
});
|
||||
}
|
||||
|
||||
function loadAssistantImageModels() {
|
||||
var sel = document.getElementById('assistant-image-model');
|
||||
if (!sel) return;
|
||||
var current = sel.value || window._assistantImageModelValue || '';
|
||||
sel.innerHTML = '<option value="">Loading image models...</option>';
|
||||
fetch('/api/admin/config/image-models/discover', { headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
sel.innerHTML = '<option value="">Use default (openai-gpt-image-1)</option>';
|
||||
(data.models || []).forEach(function(m) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = m.id;
|
||||
opt.textContent = m.name || m.id;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
if (current) {
|
||||
if (!Array.prototype.some.call(sel.options, function(o) { return o.value === current; })) {
|
||||
var custom = document.createElement('option');
|
||||
custom.value = current;
|
||||
custom.textContent = current + ' (saved/custom)';
|
||||
sel.appendChild(custom);
|
||||
}
|
||||
sel.value = current;
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
sel.innerHTML = '<option value="">Use default (openai-gpt-image-1)</option>';
|
||||
['openai-gpt-image-1', 'openai-gpt-image-1-mini', 'openai-gpt-image-1.5', 'openai-dall-e-3'].forEach(function(id) {
|
||||
var opt = document.createElement('option'); opt.value = id; opt.textContent = id; sel.appendChild(opt);
|
||||
});
|
||||
if (current) sel.value = current;
|
||||
});
|
||||
}
|
||||
|
||||
function testAssistantChatModel() {
|
||||
var model = getValue('assistant-chat-model');
|
||||
var result = document.getElementById('assistant-chat-test-result');
|
||||
if (!model) {
|
||||
var select = document.getElementById('assistant-chat-model');
|
||||
var selected = select && select.options[select.selectedIndex] ? select.options[select.selectedIndex].textContent : 'global default';
|
||||
if (result) result.textContent = 'Testing ' + selected + '...';
|
||||
} else if (result) {
|
||||
result.textContent = 'Testing ' + model + '...';
|
||||
}
|
||||
fetch('/api/admin/config/models/test', {
|
||||
method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ modelId: model || getGlobalDefaultFromAssistantSelect() })
|
||||
}).then(function(r) { return r.json(); }).then(function(data) {
|
||||
if (!data.success) throw new Error(data.error || 'Chat model test failed');
|
||||
if (result) result.textContent = 'Chat model OK (' + data.duration + ' ms): ' + (data.response || '').trim();
|
||||
showToast('Chat model works', 'success');
|
||||
}).catch(function(err) {
|
||||
if (result) result.textContent = err.message;
|
||||
showToast(err.message, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function getGlobalDefaultFromAssistantSelect() {
|
||||
var select = document.getElementById('assistant-chat-model');
|
||||
if (!select || !select.options[0]) return '';
|
||||
var match = select.options[0].textContent.match(/\((.+)\)$/);
|
||||
return match ? match[1] : '';
|
||||
}
|
||||
|
||||
function loadAssistantPromptPoolStatus() {
|
||||
var result = document.getElementById('assistant-prompt-pool-status');
|
||||
if (result) result.textContent = 'Checking prompt pool...';
|
||||
fetch('/api/admin/clinical-assistant/prompt-pool', { headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.success) throw new Error(data.error || 'Prompt pool status failed');
|
||||
renderAssistantPromptPoolStatus(data.meta);
|
||||
renderAssistantPromptPoolSnapshots(data.snapshots || []);
|
||||
})
|
||||
.catch(function(err) {
|
||||
if (result) result.textContent = err.message;
|
||||
});
|
||||
}
|
||||
|
||||
function regenerateAssistantPromptPool() {
|
||||
var result = document.getElementById('assistant-prompt-pool-status');
|
||||
var btn = document.getElementById('btn-regenerate-assistant-prompt-pool');
|
||||
if (btn) btn.disabled = true;
|
||||
if (result) result.textContent = 'Regenerating prompt pool. This can take several minutes...';
|
||||
fetch('/api/admin/clinical-assistant/prompt-pool/regenerate', {
|
||||
method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({})
|
||||
}).then(function(r) { return r.json(); }).then(function(data) {
|
||||
if (!data.success) throw new Error(data.error || 'Prompt pool regeneration failed');
|
||||
renderAssistantPromptPoolStatus(data.meta || { count: data.count, generatedAt: Date.now() });
|
||||
renderAssistantPromptPoolSnapshots(data.snapshots || []);
|
||||
showToast('Prompt pool regenerated', 'success');
|
||||
}).catch(function(err) {
|
||||
if (result) result.textContent = err.message;
|
||||
showToast(err.message, 'error');
|
||||
}).finally(function() {
|
||||
if (btn) btn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function renderAssistantPromptPoolStatus(meta) {
|
||||
var result = document.getElementById('assistant-prompt-pool-status');
|
||||
if (!result) return;
|
||||
if (!meta) {
|
||||
result.textContent = 'No generated pool found. The assistant will use indexed-topic fallback until you generate one.';
|
||||
return;
|
||||
}
|
||||
var generated = meta.generatedAt ? new Date(meta.generatedAt).toLocaleString() : 'unknown time';
|
||||
result.textContent = 'Generated pool: ' + (meta.count || 0) + ' prompts, target ' + (meta.target || '?') + ', generated ' + generated + (meta.restoredFrom ? ', restored from snapshot #' + meta.restoredFrom : '') + '.';
|
||||
}
|
||||
|
||||
function renderAssistantPromptPoolSnapshots(snapshots) {
|
||||
var select = document.getElementById('assistant-prompt-pool-snapshots');
|
||||
if (!select) return;
|
||||
select.innerHTML = '';
|
||||
if (!snapshots.length) {
|
||||
var empty = document.createElement('option');
|
||||
empty.value = '';
|
||||
empty.textContent = 'No saved snapshots';
|
||||
select.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
snapshots.forEach(function(s) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = s.id;
|
||||
var date = s.created_at ? new Date(s.created_at).toLocaleString() : 'unknown time';
|
||||
opt.textContent = '#' + s.id + ' - ' + (s.count || 0) + ' prompts - ' + date + (s.restored_from ? ' (restored from #' + s.restored_from + ')' : '');
|
||||
select.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
function restoreAssistantPromptPool() {
|
||||
var select = document.getElementById('assistant-prompt-pool-snapshots');
|
||||
var id = select ? select.value : '';
|
||||
var result = document.getElementById('assistant-prompt-pool-status');
|
||||
if (!id) { showToast('Select a prompt pool snapshot', 'error'); return; }
|
||||
if (result) result.textContent = 'Restoring prompt pool snapshot #' + id + '...';
|
||||
fetch('/api/admin/clinical-assistant/prompt-pool/restore', {
|
||||
method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ id: Number(id) })
|
||||
}).then(function(r) { return r.json(); }).then(function(data) {
|
||||
if (!data.success) throw new Error(data.error || 'Prompt pool restore failed');
|
||||
renderAssistantPromptPoolStatus(data.meta || { count: data.count, generatedAt: Date.now(), restoredFrom: id });
|
||||
renderAssistantPromptPoolSnapshots(data.snapshots || []);
|
||||
showToast('Prompt pool restored', 'success');
|
||||
}).catch(function(err) {
|
||||
if (result) result.textContent = err.message;
|
||||
showToast(err.message, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function testAssistantImageModel() {
|
||||
var model = getValue('assistant-image-model') || 'openai-gpt-image-1';
|
||||
var result = document.getElementById('assistant-image-test-result');
|
||||
if (result) result.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Testing ' + escAssistant(model) + '...';
|
||||
fetch('/api/admin/config/image-models/test', {
|
||||
method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ modelId: model })
|
||||
}).then(function(r) { return r.json(); }).then(function(data) {
|
||||
if (!data.success) throw new Error(data.error || 'Image test failed');
|
||||
var src = data.imageUrl || (data.base64 ? ('data:image/png;base64,' + data.base64) : '');
|
||||
if (result) result.innerHTML = 'Image model OK (' + data.duration + ' ms)' + (src ? '<div style="margin-top:8px;"><img src="' + escAssistant(src) + '" alt="test image" style="max-width:180px;border:1px solid var(--g200);border-radius:8px;"></div>' : '');
|
||||
showToast('Image model works', 'success');
|
||||
}).catch(function(err) {
|
||||
if (result) result.textContent = err.message;
|
||||
showToast(err.message, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function useCustomAssistantImageModel() {
|
||||
var input = document.getElementById('assistant-custom-image-model');
|
||||
var sel = document.getElementById('assistant-image-model');
|
||||
var model = input ? input.value.trim() : '';
|
||||
if (!model) { showToast('Enter an image model ID', 'error'); return; }
|
||||
if (sel && !Array.prototype.some.call(sel.options, function(o) { return o.value === model; })) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = model;
|
||||
opt.textContent = model + ' (custom)';
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
if (sel) sel.value = model;
|
||||
window._assistantImageModelValue = model;
|
||||
showToast('Custom image model selected. Save settings to keep it.', 'info');
|
||||
}
|
||||
|
||||
function saveAssistantAdmin() {
|
||||
var status = document.getElementById('assistant-admin-status');
|
||||
if (status) status.textContent = 'Saving...';
|
||||
Promise.all([
|
||||
putAssistantConfig('clinical_assistant.chat_model', getValue('assistant-chat-model')),
|
||||
putAssistantConfig('clinical_assistant.image_model', getValue('assistant-image-model')),
|
||||
putAssistantConfig('clinical_assistant.search_limit', getValue('assistant-search-limit') || '8'),
|
||||
putAssistantConfig('clinical_assistant.context_chars', getValue('assistant-context-chars') || '1400'),
|
||||
putAssistantConfig('clinical_assistant.system_behavior', getValue('assistant-system-behavior') || defaults.behavior)
|
||||
]).then(function() {
|
||||
if (status) status.textContent = '';
|
||||
showToast('Assistant settings saved', 'success');
|
||||
}).catch(function(err) {
|
||||
if (status) status.textContent = '';
|
||||
showToast(err.message || 'Save failed', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function putAssistantConfig(key, value) {
|
||||
return fetch('/api/admin/config/' + encodeURIComponent(key), {
|
||||
method: 'PUT', headers: getAuthHeaders(), body: JSON.stringify({ value: value })
|
||||
}).then(function(r) { return r.json(); }).then(function(data) {
|
||||
if (!data.success) throw new Error(data.error || 'Save failed');
|
||||
return data;
|
||||
});
|
||||
}
|
||||
function getValue(id) { var el = document.getElementById(id); return el ? el.value.trim() : ''; }
|
||||
function setValue(id, value) { var el = document.getElementById(id); if (el) el.value = value; }
|
||||
const escAssistant = adminEscapeHtml;
|
||||
}
|
||||
initClinicalAssistantAdmin(adminEscapeHtml);
|
||||
|
||||
// ============================================================
|
||||
// ADMIN MODEL MANAGEMENT — Discover, search, enable/disable, custom models
|
||||
|
|
@ -1009,12 +821,14 @@ function adminFlashButtonBackground(btn, color) {
|
|||
if (defaultSel) {
|
||||
defaultSel.innerHTML = '';
|
||||
data.models.forEach(function(m) {
|
||||
if (m.enabled === false) return;
|
||||
var opt = document.createElement('option');
|
||||
opt.value = m.id;
|
||||
opt.textContent = m.name + (m.enabled === false ? ' (disabled)' : '');
|
||||
opt.textContent = m.name;
|
||||
defaultSel.appendChild(opt);
|
||||
});
|
||||
(data.custom || []).forEach(function(m) {
|
||||
if (m.enabled === false) return;
|
||||
var opt = document.createElement('option');
|
||||
opt.value = m.id;
|
||||
opt.textContent = m.name;
|
||||
|
|
@ -1073,6 +887,7 @@ function adminFlashButtonBackground(btn, color) {
|
|||
.then(function(data) {
|
||||
if (data.success) {
|
||||
showToast(modelId + ' ' + (enabled ? 'enabled' : 'disabled'), 'success');
|
||||
loadAdminModels();
|
||||
} else {
|
||||
showToast(data.error || 'Failed', 'error');
|
||||
// Revert checkbox
|
||||
|
|
@ -1146,12 +961,14 @@ function adminFlashButtonBackground(btn, color) {
|
|||
if (defaultSel) {
|
||||
defaultSel.innerHTML = '';
|
||||
refreshed.models.forEach(function(m) {
|
||||
if (m.enabled === false) return;
|
||||
var opt = document.createElement('option');
|
||||
opt.value = m.id;
|
||||
opt.textContent = m.name + (m.enabled === false ? ' (disabled)' : '');
|
||||
opt.textContent = m.name;
|
||||
defaultSel.appendChild(opt);
|
||||
});
|
||||
(refreshed.custom || []).forEach(function(m) {
|
||||
if (m.enabled === false) return;
|
||||
var opt = document.createElement('option');
|
||||
opt.value = m.id;
|
||||
opt.textContent = m.name;
|
||||
|
|
|
|||
328
public/js/admin/clinicalAssistant.js
Normal file
328
public/js/admin/clinicalAssistant.js
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
export function initClinicalAssistantAdmin(adminEscapeHtml) {
|
||||
let configState = 'idle';
|
||||
let imageModelsLoading = false;
|
||||
|
||||
document.addEventListener('tabChanged', function(e) {
|
||||
if (e.detail && e.detail.tab === 'admin') loadAssistantAdmin();
|
||||
});
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.closest('#btn-save-assistant-config')) saveAssistantAdmin();
|
||||
if (e.target.closest('#btn-retry-assistant-config')) loadAssistantAdmin();
|
||||
if (e.target.closest('#btn-test-assistant-chat-model')) testAssistantChatModel();
|
||||
if (e.target.closest('#btn-regenerate-assistant-prompt-pool')) regenerateAssistantPromptPool();
|
||||
if (e.target.closest('#btn-restore-assistant-prompt-pool')) restoreAssistantPromptPool();
|
||||
if (e.target.closest('#btn-refresh-assistant-image-models')) loadAssistantImageModels();
|
||||
if (e.target.closest('#btn-test-assistant-image-model')) testAssistantImageModel();
|
||||
if (e.target.closest('#btn-use-custom-assistant-image-model')) useCustomAssistantImageModel();
|
||||
});
|
||||
|
||||
function updateAssistantLoadState() {
|
||||
var save = document.getElementById('btn-save-assistant-config');
|
||||
if (save) save.disabled = configState !== 'ready' || imageModelsLoading;
|
||||
var retry = document.getElementById('btn-retry-assistant-config');
|
||||
if (retry) retry.hidden = configState !== 'failed';
|
||||
var status = document.getElementById('assistant-admin-status');
|
||||
if (status) status.textContent = configState === 'failed' ?
|
||||
'Settings load failed. Drafts are unchanged. Retry loading settings or revisit the Admin tab.' :
|
||||
configState !== 'ready' ? 'Loading assistant settings...' :
|
||||
imageModelsLoading ? 'Loading image models; saving is unavailable until discovery finishes.' : 'Settings ready.';
|
||||
}
|
||||
|
||||
function loadAssistantAdmin() {
|
||||
if (configState === 'loading' || configState === 'ready') return;
|
||||
configState = 'loading';
|
||||
updateAssistantLoadState();
|
||||
Promise.all([
|
||||
fetch('/api/models', { headers: getAuthHeaders() }).then(function(r) {
|
||||
if (!r.ok) throw new Error('Model discovery failed');
|
||||
return r.json();
|
||||
}).catch(function() { return {}; }),
|
||||
fetch('/api/admin/config', { headers: getAuthHeaders() }).then(function(r) {
|
||||
if (!r.ok) throw new Error('Settings request failed');
|
||||
return r.json();
|
||||
})
|
||||
]).then(function(results) {
|
||||
var modelsData = results[0] || {};
|
||||
var configData = results[1];
|
||||
var budget = configData && configData.conversationBudget;
|
||||
var validBudget = budget && Number.isInteger(budget.limit) && budget.limit >= 1000 && budget.limit <= 1000000 &&
|
||||
budget.unit === 'characters' && budget.measure === 'UTF-16 code units' &&
|
||||
budget.env === 'CLINICAL_ASSISTANT_CONVERSATION_CHARS' && ['environment', 'default'].includes(budget.source);
|
||||
// Validate the entire response before touching any setting or draft.
|
||||
if (!configData || configData.success !== true || !Array.isArray(configData.config) ||
|
||||
!configData.config.every(function(row) { return row && typeof row.key === 'string' && row.key && typeof row.value === 'string'; }) ||
|
||||
!validBudget) throw new Error('Invalid settings response');
|
||||
var cfg = Object.create(null);
|
||||
configData.config.forEach(function(row) { cfg[row.key] = row.value; });
|
||||
|
||||
var chatSelect = document.getElementById('assistant-chat-model');
|
||||
if (chatSelect) {
|
||||
var savedChatModel = cfg['clinical_assistant.chat_model'] || '';
|
||||
var defaultLabel = modelsData.defaultModel ? ('Use global default (' + modelsData.defaultModel + ')') : 'Use global default';
|
||||
chatSelect.innerHTML = '';
|
||||
var defaultOpt = document.createElement('option');
|
||||
defaultOpt.value = '';
|
||||
defaultOpt.textContent = defaultLabel;
|
||||
chatSelect.appendChild(defaultOpt);
|
||||
(Array.isArray(modelsData.models) ? modelsData.models : []).filter(function(m) { return m && typeof m.id === 'string' && m.id; }).forEach(function(m) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = m.id;
|
||||
opt.textContent = m.name || m.id;
|
||||
chatSelect.appendChild(opt);
|
||||
});
|
||||
if (savedChatModel && !Array.prototype.some.call(chatSelect.options, function(o) { return o.value === savedChatModel; })) {
|
||||
var saved = document.createElement('option');
|
||||
saved.value = savedChatModel;
|
||||
saved.textContent = savedChatModel + ' (saved/custom)';
|
||||
chatSelect.appendChild(saved);
|
||||
}
|
||||
chatSelect.value = savedChatModel;
|
||||
}
|
||||
window._assistantImageModelValue = cfg['clinical_assistant.image_model'] || '';
|
||||
renderAssistantImageModels([], window._assistantImageModelValue);
|
||||
setValue('assistant-search-limit', cfg['clinical_assistant.search_limit'] || '8');
|
||||
setValue('assistant-context-chars', cfg['clinical_assistant.context_chars'] || '1400');
|
||||
var budgetLabel = document.getElementById('assistant-conversation-budget');
|
||||
if (budgetLabel) budgetLabel.textContent =
|
||||
budget.limit.toLocaleString() + ' ' + budget.unit + ' (' + budget.measure + ') — ' + budget.env +
|
||||
(budget.source === 'environment' ? ' (environment).' : ' (server default; environment unset).');
|
||||
configState = 'ready';
|
||||
updateAssistantLoadState();
|
||||
loadAssistantImageModels();
|
||||
loadAssistantPromptPoolStatus();
|
||||
}).catch(function() {
|
||||
configState = 'failed';
|
||||
updateAssistantLoadState();
|
||||
var budgetLabel = document.getElementById('assistant-conversation-budget');
|
||||
if (budgetLabel) budgetLabel.textContent = 'Conversation budget unavailable. Check server environment configuration; no fallback limit is assumed.';
|
||||
});
|
||||
}
|
||||
|
||||
function renderAssistantImageModels(models, current) {
|
||||
var sel = document.getElementById('assistant-image-model');
|
||||
if (!sel) return;
|
||||
sel.innerHTML = '<option value="">Use default (openai-gpt-image-1)</option>';
|
||||
models.forEach(function(m) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = m.id;
|
||||
opt.textContent = m.name || m.id;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
if (current && !Array.prototype.some.call(sel.options, function(o) { return o.value === current; })) {
|
||||
var custom = document.createElement('option');
|
||||
custom.value = current;
|
||||
custom.textContent = current + ' (saved/custom)';
|
||||
sel.appendChild(custom);
|
||||
}
|
||||
sel.value = current;
|
||||
}
|
||||
|
||||
function loadAssistantImageModels() {
|
||||
var sel = document.getElementById('assistant-image-model');
|
||||
if (!sel || configState !== 'ready' || imageModelsLoading) return;
|
||||
// Keep the real selection, never an empty loading option. Read it again on
|
||||
// completion so a newer custom selection wins over an in-flight discovery.
|
||||
imageModelsLoading = true;
|
||||
sel.disabled = true;
|
||||
updateAssistantLoadState();
|
||||
fetch('/api/admin/config/image-models/discover', { headers: getAuthHeaders() })
|
||||
.then(function(r) {
|
||||
if (!r.ok) throw new Error('Image discovery failed');
|
||||
return r.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
if (!data || data.success === false || !Array.isArray(data.models) ||
|
||||
!data.models.every(function(m) { return m && typeof m.id === 'string' && m.id; })) throw new Error('Invalid image models');
|
||||
renderAssistantImageModels(data.models, sel.value);
|
||||
})
|
||||
.catch(function() {
|
||||
renderAssistantImageModels(['openai-gpt-image-1', 'openai-gpt-image-1-mini', 'openai-gpt-image-1.5', 'openai-dall-e-3'].map(function(id) { return { id: id }; }), sel.value);
|
||||
})
|
||||
.finally(function() {
|
||||
imageModelsLoading = false;
|
||||
sel.disabled = false;
|
||||
updateAssistantLoadState();
|
||||
});
|
||||
}
|
||||
|
||||
function testAssistantChatModel() {
|
||||
var model = getValue('assistant-chat-model');
|
||||
var result = document.getElementById('assistant-chat-test-result');
|
||||
if (!model) {
|
||||
var select = document.getElementById('assistant-chat-model');
|
||||
var selected = select && select.options[select.selectedIndex] ? select.options[select.selectedIndex].textContent : 'global default';
|
||||
if (result) result.textContent = 'Testing ' + selected + '...';
|
||||
} else if (result) {
|
||||
result.textContent = 'Testing ' + model + '...';
|
||||
}
|
||||
fetch('/api/admin/config/models/test', {
|
||||
method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ modelId: model || getGlobalDefaultFromAssistantSelect() })
|
||||
}).then(function(r) { return r.json(); }).then(function(data) {
|
||||
if (!data.success) throw new Error(data.error || 'Chat model test failed');
|
||||
if (result) result.textContent = 'Chat model OK (' + data.duration + ' ms): ' + (data.response || '').trim();
|
||||
showToast('Chat model works', 'success');
|
||||
}).catch(function(err) {
|
||||
if (result) result.textContent = err.message;
|
||||
showToast(err.message, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function getGlobalDefaultFromAssistantSelect() {
|
||||
var select = document.getElementById('assistant-chat-model');
|
||||
if (!select || !select.options[0]) return '';
|
||||
var match = select.options[0].textContent.match(/\((.+)\)$/);
|
||||
return match ? match[1] : '';
|
||||
}
|
||||
|
||||
function loadAssistantPromptPoolStatus() {
|
||||
var result = document.getElementById('assistant-prompt-pool-status');
|
||||
if (result) result.textContent = 'Checking prompt pool...';
|
||||
fetch('/api/admin/clinical-assistant/prompt-pool', { headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.success) throw new Error(data.error || 'Prompt pool status failed');
|
||||
renderAssistantPromptPoolStatus(data.meta);
|
||||
renderAssistantPromptPoolSnapshots(data.snapshots || []);
|
||||
})
|
||||
.catch(function(err) {
|
||||
if (result) result.textContent = err.message;
|
||||
});
|
||||
}
|
||||
|
||||
function regenerateAssistantPromptPool() {
|
||||
var result = document.getElementById('assistant-prompt-pool-status');
|
||||
var btn = document.getElementById('btn-regenerate-assistant-prompt-pool');
|
||||
if (btn) btn.disabled = true;
|
||||
if (result) result.textContent = 'Regenerating prompt pool. This can take several minutes...';
|
||||
fetch('/api/admin/clinical-assistant/prompt-pool/regenerate', {
|
||||
method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({})
|
||||
}).then(function(r) { return r.json(); }).then(function(data) {
|
||||
if (!data.success) throw new Error(data.error || 'Prompt pool regeneration failed');
|
||||
renderAssistantPromptPoolStatus(data.meta || { count: data.count, generatedAt: Date.now() });
|
||||
renderAssistantPromptPoolSnapshots(data.snapshots || []);
|
||||
showToast('Prompt pool regenerated', 'success');
|
||||
}).catch(function(err) {
|
||||
if (result) result.textContent = err.message;
|
||||
showToast(err.message, 'error');
|
||||
}).finally(function() {
|
||||
if (btn) btn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function renderAssistantPromptPoolStatus(meta) {
|
||||
var result = document.getElementById('assistant-prompt-pool-status');
|
||||
if (!result) return;
|
||||
if (!meta) {
|
||||
result.textContent = 'No generated pool found. The assistant will use indexed-topic fallback until you generate one.';
|
||||
return;
|
||||
}
|
||||
var generated = meta.generatedAt ? new Date(meta.generatedAt).toLocaleString() : 'unknown time';
|
||||
result.textContent = 'Generated pool: ' + (meta.count || 0) + ' prompts, target ' + (meta.target || '?') + ', generated ' + generated + (meta.restoredFrom ? ', restored from snapshot #' + meta.restoredFrom : '') + '.';
|
||||
}
|
||||
|
||||
function renderAssistantPromptPoolSnapshots(snapshots) {
|
||||
var select = document.getElementById('assistant-prompt-pool-snapshots');
|
||||
if (!select) return;
|
||||
select.innerHTML = '';
|
||||
if (!snapshots.length) {
|
||||
var empty = document.createElement('option');
|
||||
empty.value = '';
|
||||
empty.textContent = 'No saved snapshots';
|
||||
select.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
snapshots.forEach(function(s) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = s.id;
|
||||
var date = s.created_at ? new Date(s.created_at).toLocaleString() : 'unknown time';
|
||||
opt.textContent = '#' + s.id + ' - ' + (s.count || 0) + ' prompts - ' + date + (s.restored_from ? ' (restored from #' + s.restored_from + ')' : '');
|
||||
select.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
function restoreAssistantPromptPool() {
|
||||
var select = document.getElementById('assistant-prompt-pool-snapshots');
|
||||
var id = select ? select.value : '';
|
||||
var result = document.getElementById('assistant-prompt-pool-status');
|
||||
if (!id) { showToast('Select a prompt pool snapshot', 'error'); return; }
|
||||
if (result) result.textContent = 'Restoring prompt pool snapshot #' + id + '...';
|
||||
fetch('/api/admin/clinical-assistant/prompt-pool/restore', {
|
||||
method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ id: Number(id) })
|
||||
}).then(function(r) { return r.json(); }).then(function(data) {
|
||||
if (!data.success) throw new Error(data.error || 'Prompt pool restore failed');
|
||||
renderAssistantPromptPoolStatus(data.meta || { count: data.count, generatedAt: Date.now(), restoredFrom: id });
|
||||
renderAssistantPromptPoolSnapshots(data.snapshots || []);
|
||||
showToast('Prompt pool restored', 'success');
|
||||
}).catch(function(err) {
|
||||
if (result) result.textContent = err.message;
|
||||
showToast(err.message, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function testAssistantImageModel() {
|
||||
var model = getValue('assistant-image-model') || 'openai-gpt-image-1';
|
||||
var result = document.getElementById('assistant-image-test-result');
|
||||
if (result) result.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Testing ' + escAssistant(model) + '...';
|
||||
fetch('/api/admin/config/image-models/test', {
|
||||
method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ modelId: model })
|
||||
}).then(function(r) { return r.json(); }).then(function(data) {
|
||||
if (!data.success) throw new Error(data.error || 'Image test failed');
|
||||
var src = data.imageUrl || (data.base64 ? ('data:image/png;base64,' + data.base64) : '');
|
||||
if (result) result.innerHTML = 'Image model OK (' + data.duration + ' ms)' + (src ? '<div style="margin-top:8px;"><img src="' + escAssistant(src) + '" alt="test image" style="max-width:180px;border:1px solid var(--g200);border-radius:8px;"></div>' : '');
|
||||
showToast('Image model works', 'success');
|
||||
}).catch(function(err) {
|
||||
if (result) result.textContent = err.message;
|
||||
showToast(err.message, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function useCustomAssistantImageModel() {
|
||||
var input = document.getElementById('assistant-custom-image-model');
|
||||
var sel = document.getElementById('assistant-image-model');
|
||||
var model = input ? input.value.trim() : '';
|
||||
if (!model) { showToast('Enter an image model ID', 'error'); return; }
|
||||
if (sel && !Array.prototype.some.call(sel.options, function(o) { return o.value === model; })) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = model;
|
||||
opt.textContent = model + ' (custom)';
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
if (sel) sel.value = model;
|
||||
window._assistantImageModelValue = model;
|
||||
showToast('Custom image model selected. Save settings to keep it.', 'info');
|
||||
}
|
||||
|
||||
function saveAssistantAdmin() {
|
||||
if (configState !== 'ready' || imageModelsLoading) return;
|
||||
var chat = document.getElementById('assistant-chat-model');
|
||||
var image = document.getElementById('assistant-image-model');
|
||||
if (!chat || chat.selectedIndex < 0 || !image || image.selectedIndex < 0) return;
|
||||
var status = document.getElementById('assistant-admin-status');
|
||||
if (status) status.textContent = 'Saving...';
|
||||
Promise.all([
|
||||
putAssistantConfig('clinical_assistant.chat_model', getValue('assistant-chat-model')),
|
||||
putAssistantConfig('clinical_assistant.image_model', getValue('assistant-image-model')),
|
||||
putAssistantConfig('clinical_assistant.search_limit', getValue('assistant-search-limit') || '8'),
|
||||
putAssistantConfig('clinical_assistant.context_chars', getValue('assistant-context-chars') || '1400')
|
||||
]).then(function() {
|
||||
if (status) status.textContent = '';
|
||||
showToast('Assistant settings saved', 'success');
|
||||
}).catch(function(err) {
|
||||
if (status) status.textContent = '';
|
||||
showToast(err.message || 'Save failed', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function putAssistantConfig(key, value) {
|
||||
return fetch('/api/admin/config/' + encodeURIComponent(key), {
|
||||
method: 'PUT', headers: getAuthHeaders(), body: JSON.stringify({ value: value })
|
||||
}).then(function(r) { return r.json(); }).then(function(data) {
|
||||
if (!data.success) throw new Error(data.error || 'Save failed');
|
||||
return data;
|
||||
});
|
||||
}
|
||||
function getValue(id) { var el = document.getElementById(id); return el ? el.value.trim() : ''; }
|
||||
function setValue(id, value) { var el = document.getElementById(id); if (el) el.value = value; }
|
||||
const escAssistant = adminEscapeHtml;
|
||||
}
|
||||
146
public/js/app.js
146
public/js/app.js
|
|
@ -37,22 +37,46 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
window.PEDSCRIBE_COMPONENT_VERSION = COMPONENT_VERSION;
|
||||
var _componentCache = {};
|
||||
var _componentLoading = {};
|
||||
var _tabActivation = 0;
|
||||
|
||||
function loadComponent(tabEl) {
|
||||
var component = tabEl.getAttribute('data-component');
|
||||
if (!component || tabEl.dataset.loaded) return Promise.resolve();
|
||||
if (_componentCache[component]) {
|
||||
tabEl.innerHTML = _componentCache[component];
|
||||
tabEl.dataset.loaded = '1';
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (_componentLoading[component]) return _componentLoading[component];
|
||||
|
||||
_componentLoading[component] = fetch('/components/' + component + '.html?v=' + COMPONENT_VERSION)
|
||||
.then(function(r) { if (!r.ok) throw new Error(r.status); return r.text(); })
|
||||
_componentLoading[component] = (_componentCache[component] ? Promise.resolve(_componentCache[component]) :
|
||||
fetch('/components/' + component + '.html?v=' + COMPONENT_VERSION)
|
||||
.then(function(r) { if (!r.ok) throw new Error(r.status); return r.text(); }))
|
||||
.then(function(html) {
|
||||
_componentCache[component] = html;
|
||||
tabEl.innerHTML = html;
|
||||
var template = document.createElement('template');
|
||||
template.innerHTML = html;
|
||||
var styles = Array.from(template.content.querySelectorAll('link[rel="stylesheet"]')).map(function(link) {
|
||||
var url = new URL(link.getAttribute('href'), window.location.href);
|
||||
if (url.origin === window.location.origin) url.searchParams.set('v', COMPONENT_VERSION);
|
||||
link.href = url.href;
|
||||
return new Promise(function(resolve, reject) {
|
||||
link.addEventListener('load', resolve, { once: true });
|
||||
link.addEventListener('error', function() { reject(new Error('Component stylesheet failed to load')); }, { once: true });
|
||||
});
|
||||
});
|
||||
// CSS must load in its original cascade position, but controls must not work before initialization.
|
||||
if (styles.length) {
|
||||
Array.from(template.content.children).forEach(function(child) {
|
||||
if (!child.hasAttribute('inert')) {
|
||||
child.setAttribute('inert', '');
|
||||
child.setAttribute('data-component-pending', '');
|
||||
}
|
||||
});
|
||||
var loading = document.createElement('p');
|
||||
loading.setAttribute('role', 'status');
|
||||
loading.setAttribute('data-component-status', '');
|
||||
loading.textContent = 'Loading…';
|
||||
tabEl.setAttribute('aria-busy', 'true');
|
||||
tabEl.replaceChildren(loading, template.content);
|
||||
} else tabEl.replaceChildren(template.content);
|
||||
return Promise.all(styles).then(function() { _componentCache[component] = html; });
|
||||
})
|
||||
.then(function() {
|
||||
tabEl.dataset.loaded = '1';
|
||||
// Re-attach per-tab model selectors
|
||||
tabEl.querySelectorAll('.tab-model-select').forEach(function(sel) {
|
||||
|
|
@ -62,7 +86,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
})
|
||||
.catch(function(err) {
|
||||
console.warn('[Component] Failed to load ' + component + ':', err);
|
||||
tabEl.innerHTML = '<div style="padding:40px;text-align:center;color:var(--g400);">Failed to load. Please refresh.</div>';
|
||||
tabEl.innerHTML = '<div role="alert" style="padding:40px;text-align:center;color:var(--g400);">Failed to load. Please refresh or select this tab again.</div>';
|
||||
tabEl.removeAttribute('aria-busy');
|
||||
delete _componentLoading[component];
|
||||
});
|
||||
return _componentLoading[component];
|
||||
|
|
@ -76,6 +101,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
function activateTab(tabName) {
|
||||
var btn = document.querySelector('.tab-btn[data-tab="' + tabName + '"]');
|
||||
if (!btn || btn.classList.contains('hidden')) return false;
|
||||
var activation = ++_tabActivation;
|
||||
document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('active'); });
|
||||
document.querySelectorAll('.tab-content').forEach(function(c) { c.classList.remove('active'); });
|
||||
btn.classList.add('active');
|
||||
|
|
@ -84,7 +110,16 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
tabEl.classList.add('active');
|
||||
// Lazy-load component HTML, then fire tabChanged after DOM is ready
|
||||
loadComponent(tabEl).then(function() {
|
||||
if (activation !== _tabActivation || (tabEl.hasAttribute('data-component') && !tabEl.dataset.loaded)) return;
|
||||
document.dispatchEvent(new CustomEvent('tabChanged', { detail: { tab: tabName } }));
|
||||
// Event listeners bind synchronously; only now allow native form interaction.
|
||||
tabEl.querySelectorAll('[data-component-pending]').forEach(function(child) {
|
||||
child.removeAttribute('inert');
|
||||
child.removeAttribute('data-component-pending');
|
||||
});
|
||||
var loading = tabEl.querySelector('[data-component-status]');
|
||||
if (loading) loading.remove();
|
||||
tabEl.removeAttribute('aria-busy');
|
||||
});
|
||||
} else {
|
||||
document.dispatchEvent(new CustomEvent('tabChanged', { detail: { tab: tabName } }));
|
||||
|
|
@ -209,12 +244,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
opt.textContent = m.name;
|
||||
selectEl.appendChild(opt);
|
||||
});
|
||||
if (window._defaultModelId && !Array.prototype.some.call(selectEl.options, function(opt) { return opt.value === window._defaultModelId; })) {
|
||||
var saved = document.createElement('option');
|
||||
saved.value = window._defaultModelId;
|
||||
saved.textContent = window._defaultModelId + ' (saved default)';
|
||||
selectEl.appendChild(saved);
|
||||
}
|
||||
if (window._defaultModelId) selectEl.value = window._defaultModelId;
|
||||
}
|
||||
|
||||
|
|
@ -263,11 +292,10 @@ document.addEventListener('click', function(e) {
|
|||
if (typeof copyText === 'function') copyText(targetId);
|
||||
// Log PHI copy event (fire-and-forget, best-effort)
|
||||
try {
|
||||
var token = window.AUTH_TOKEN || localStorage.getItem('ped_scribe_token') || '';
|
||||
if (token) {
|
||||
if (window.AccountBoundary.active()) {
|
||||
fetch('/api/logs/client-event', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ action: 'copy_to_clipboard', target: targetId })
|
||||
}).catch(function() {});
|
||||
}
|
||||
|
|
@ -285,8 +313,26 @@ document.addEventListener('click', function(e) {
|
|||
}
|
||||
});
|
||||
|
||||
// Attributes also hide controls in subsequently lazy-loaded components.
|
||||
window.userFeatures = {};
|
||||
function loadUserFeatures() {
|
||||
return fetch('/api/user/features', { headers: getAuthHeaders() })
|
||||
.then(function(r) { if (!r.ok) throw new Error('Feature policy unavailable'); return r.json(); })
|
||||
.then(function(data) { applyUserFeatures(data.features || {}); })
|
||||
.catch(function() { applyUserFeatures({}); });
|
||||
}
|
||||
function applyUserFeatures(features) {
|
||||
window.userFeatures = features;
|
||||
['read_aloud', 'nextcloud', 'memories'].forEach(function(name) {
|
||||
document.documentElement.setAttribute('data-feature-' + name, features[name] === true ? 'true' : 'false');
|
||||
});
|
||||
if (!features.read_aloud) stopReading();
|
||||
}
|
||||
document.addEventListener('tabChanged', loadUserFeatures);
|
||||
|
||||
// ── Announcement banner ────────────────────────────────────
|
||||
function loadAnnouncement() {
|
||||
loadUserFeatures();
|
||||
fetch('/api/admin/config/announcement', { headers: getAuthHeaders() })
|
||||
.then(function(r) { if (!r.ok) throw new Error('not ok'); return r.json(); })
|
||||
.then(function(data) {
|
||||
|
|
@ -465,8 +511,14 @@ function copyText(elementId) {
|
|||
// Speak / Stop
|
||||
var currentlyReadingId = null;
|
||||
var currentAudio = null;
|
||||
var currentAudioURL = null;
|
||||
var playbackGeneration = 0;
|
||||
window.addEventListener('account-boundary', stopReading);
|
||||
|
||||
function speakText(elementId) {
|
||||
var boundary = window.AccountBoundary;
|
||||
var ticket = boundary && boundary.capture();
|
||||
if (!ticket || window.userFeatures.read_aloud !== true) return;
|
||||
if (currentlyReadingId === elementId) { stopReading(); return; }
|
||||
stopReading();
|
||||
var el = document.getElementById(elementId);
|
||||
|
|
@ -474,6 +526,10 @@ function speakText(elementId) {
|
|||
var text = (el.innerText || el.textContent).trim();
|
||||
if (!text) { showToast('Nothing to read', 'error'); return; }
|
||||
|
||||
var playback = playbackGeneration;
|
||||
function current() {
|
||||
return playback === playbackGeneration && boundary.valid(ticket) && window.userFeatures.read_aloud === true;
|
||||
}
|
||||
currentlyReadingId = elementId;
|
||||
var btn = findReadButton(elementId);
|
||||
if (btn) { btn.classList.add('btn-reading'); btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Loading...'; }
|
||||
|
|
@ -484,26 +540,40 @@ function speakText(elementId) {
|
|||
body: JSON.stringify({ text: text })
|
||||
})
|
||||
.then(function(r) {
|
||||
if (!r.ok) throw new Error('TTS request failed (' + r.status + ')');
|
||||
if (!current()) throw boundary.error();
|
||||
if (!r.ok) {
|
||||
var error = new Error('TTS request failed (' + r.status + ')');
|
||||
error.policyDenied = [401, 403, 503].includes(r.status);
|
||||
throw error;
|
||||
}
|
||||
var ttsProvider = r.headers.get('X-TTS-Provider') || 'server';
|
||||
return r.blob().then(function(blob) { return { blob: blob, provider: ttsProvider }; });
|
||||
})
|
||||
.then(function(result) {
|
||||
var url = URL.createObjectURL(result.blob);
|
||||
currentAudio = new Audio(url);
|
||||
currentAudio.onended = function() { URL.revokeObjectURL(url); stopReading(); };
|
||||
currentAudio.onerror = function() { URL.revokeObjectURL(url); stopReading(); showToast('Audio playback error', 'error'); };
|
||||
currentAudio.play();
|
||||
if (btn) { btn.classList.add('btn-reading'); btn.innerHTML = '<i class="fas fa-stop"></i> Stop'; }
|
||||
showToast('Reading aloud (' + result.provider + ')', 'info');
|
||||
if (!current()) throw boundary.error();
|
||||
currentAudioURL = URL.createObjectURL(result.blob);
|
||||
currentAudio = new Audio(currentAudioURL);
|
||||
currentAudio.onended = function() { if (current()) stopReading(); };
|
||||
currentAudio.onerror = function() {
|
||||
if (!current()) return;
|
||||
stopReading(); showToast('Audio playback error', 'error');
|
||||
};
|
||||
return Promise.resolve(currentAudio.play()).then(function() {
|
||||
if (!current()) return;
|
||||
if (btn) { btn.classList.add('btn-reading'); btn.innerHTML = '<i class="fas fa-stop"></i> Stop'; }
|
||||
showToast('Reading aloud (' + result.provider + ')', 'info');
|
||||
});
|
||||
})
|
||||
.catch(function(err) {
|
||||
if (!current()) return; // Stale completions must not stop newer playback either.
|
||||
stopReading();
|
||||
// Fallback to browser TTS if server TTS fails
|
||||
if ('speechSynthesis' in window) {
|
||||
playback = playbackGeneration;
|
||||
// Account/abort/policy failures must never speak the captured clinical text.
|
||||
if (err.name === 'AbortError') return;
|
||||
if (!err.policyDenied && current() && 'speechSynthesis' in window) {
|
||||
var utter = new SpeechSynthesisUtterance(text);
|
||||
utter.rate = 0.9;
|
||||
utter.onend = function() { stopReading(); };
|
||||
utter.onend = function() { if (current()) stopReading(); };
|
||||
currentlyReadingId = elementId;
|
||||
if (btn) { btn.classList.add('btn-reading'); btn.innerHTML = '<i class="fas fa-stop"></i> Stop'; }
|
||||
window.speechSynthesis.speak(utter);
|
||||
|
|
@ -515,8 +585,13 @@ function speakText(elementId) {
|
|||
}
|
||||
|
||||
function stopReading() {
|
||||
playbackGeneration++;
|
||||
if ('speechSynthesis' in window) window.speechSynthesis.cancel();
|
||||
if (currentAudio) { currentAudio.pause(); currentAudio = null; }
|
||||
if (currentAudio) {
|
||||
currentAudio.onended = currentAudio.onerror = null;
|
||||
currentAudio.pause(); currentAudio = null;
|
||||
}
|
||||
if (currentAudioURL) { URL.revokeObjectURL(currentAudioURL); currentAudioURL = null; }
|
||||
if (currentlyReadingId) {
|
||||
var btn = findReadButton(currentlyReadingId);
|
||||
if (btn) { btn.classList.remove('btn-reading'); btn.innerHTML = '<i class="fas fa-volume-high"></i> Read'; }
|
||||
|
|
@ -628,10 +703,9 @@ window.isNativeApp = function() {
|
|||
// Check if server-side transcription (Whisper/AWS) is available
|
||||
window._transcribeAvailable = null; // null = not checked yet, true/false after check
|
||||
function checkTranscribeStatus() {
|
||||
var token = window.AUTH_TOKEN || localStorage.getItem('ped_scribe_token') || '';
|
||||
if (!token) return;
|
||||
if (!window.AccountBoundary.active()) return;
|
||||
fetch('/api/transcribe/status', {
|
||||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
|
|
@ -657,9 +731,11 @@ function _serverTranscribe(blob) {
|
|||
var startTime = Date.now();
|
||||
var formData = new FormData();
|
||||
formData.append('audio', blob, 'audio.webm');
|
||||
var headers = getAuthHeaders();
|
||||
delete headers['Content-Type']; // FormData supplies its own boundary.
|
||||
return fetch('/api/transcribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + (window.AUTH_TOKEN || localStorage.getItem('ped_scribe_token') || '') },
|
||||
headers: headers,
|
||||
body: formData
|
||||
}).then(function(r) { return r.json(); }).then(function(data) {
|
||||
var elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
|
|
|
|||
|
|
@ -41,6 +41,13 @@ export function fetchAssistantChat(payload, options) {
|
|||
}).then(parseJsonWithStatus);
|
||||
}
|
||||
|
||||
export function requestAssistantHandoff(history) {
|
||||
return fetch('/api/clinical-assistant/handoff', {
|
||||
method: 'POST', headers: authHeaders(), credentials: 'same-origin',
|
||||
body: JSON.stringify({ history: history })
|
||||
}).then(parseJsonWithStatus);
|
||||
}
|
||||
|
||||
export function requestAssistantImage(prompt) {
|
||||
return fetch('/api/clinical-assistant/image', {
|
||||
method: 'POST',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { escapeHtml } from './citations.js';
|
||||
import { escapeAttr, escapeHtml } from './citations.js';
|
||||
|
||||
export function renderSourcesList(sources) {
|
||||
if (!sources || sources.length === 0) return '<p class="assistant-muted">No citations returned.</p>';
|
||||
|
|
@ -13,11 +13,11 @@ export function renderSourcesList(sources) {
|
|||
if (s.category) meta.push(s.category);
|
||||
if (s.doc_type || s.type) meta.push(s.doc_type || s.type);
|
||||
if (s.score != null) meta.push('score ' + Number(s.score).toFixed(3));
|
||||
return '<div class="assistant-source" id="assistant-source-' + n + '">' +
|
||||
'<strong>[' + n + '] ' + escapeHtml(s.title || s.resource || 'Untitled source') + '</strong>' +
|
||||
return '<div class="assistant-source" id="assistant-source-' + escapeAttr(n) + '">' +
|
||||
'<strong>[' + escapeHtml(n) + '] ' + escapeHtml(s.title || s.resource || 'Untitled source') + '</strong>' +
|
||||
renderSourceBadges(s) +
|
||||
'<div class="assistant-source-meta">' + escapeHtml(meta.join(' · ') || 'indexed source') + '</div>' +
|
||||
(s.excerpt ? '<div class="assistant-source-excerpt"><p>' + escapeHtml(cleanSourceExcerpt(s.excerpt).slice(0, 900)) + '</p></div>' : '') +
|
||||
(s.excerpt ? '<div class="assistant-source-excerpt"><p style="white-space: pre-wrap">' + escapeHtml(s.excerpt) + '</p></div>' : '') +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
|
@ -34,15 +34,3 @@ function renderSourceBadges(source) {
|
|||
return '<span>' + escapeHtml(badge) + '</span>';
|
||||
}).join('') + '</div>';
|
||||
}
|
||||
|
||||
function cleanSourceExcerpt(text) {
|
||||
return String(text || '')
|
||||
.replace(/^\[Page-image match\]\s*/i, '')
|
||||
.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
|
||||
.replace(/<br\s*\/?>/gi, ' ')
|
||||
.replace(/\*\*/g, '')
|
||||
.replace(/\|\s*-{2,}\s*/g, ' ')
|
||||
.replace(/\|/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,17 @@ var DB_VERSION = 1;
|
|||
var MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
var _db = null;
|
||||
var boundary = window.AccountBoundary;
|
||||
|
||||
function guardTransaction(tx, owner) {
|
||||
if (!boundary.valid(owner)) { tx.abort(); throw boundary.error(); }
|
||||
function abort() { try { tx.abort(); } catch (e) {} }
|
||||
window.addEventListener('account-boundary', abort, { once: true });
|
||||
function done() { window.removeEventListener('account-boundary', abort); }
|
||||
tx.addEventListener('complete', done);
|
||||
tx.addEventListener('abort', done);
|
||||
}
|
||||
|
||||
|
||||
function openDB() {
|
||||
if (_db) return Promise.resolve(_db);
|
||||
|
|
@ -29,16 +40,19 @@ var _db = null;
|
|||
|
||||
// Save audio — tries server first, falls back to IndexedDB
|
||||
window.saveAudioBackup = function(blob, module) {
|
||||
var owner = boundary.capture();
|
||||
if (!owner) return Promise.resolve(null);
|
||||
// Try server save first
|
||||
return saveToServer(blob, module).then(function(serverId) {
|
||||
if (!boundary.valid(owner)) return null;
|
||||
if (serverId) {
|
||||
window._lastAudioBackupId = 'server_' + serverId;
|
||||
return serverId;
|
||||
}
|
||||
// Fallback to IndexedDB
|
||||
return saveToIndexedDB(blob, module);
|
||||
return saveToIndexedDB(blob, module, owner);
|
||||
}).catch(function() {
|
||||
return saveToIndexedDB(blob, module);
|
||||
return saveToIndexedDB(blob, module, owner);
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -47,9 +61,8 @@ var _db = null;
|
|||
formData.append('audio', blob, 'audio.webm');
|
||||
formData.append('module', module || 'encounter');
|
||||
|
||||
var headers = {};
|
||||
var token = window.AUTH_TOKEN || localStorage.getItem('ped_scribe_token') || '';
|
||||
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||
var headers = getAuthHeaders();
|
||||
delete headers['Content-Type']; // FormData supplies its own boundary.
|
||||
|
||||
return fetch('/api/audio-backups', {
|
||||
method: 'POST',
|
||||
|
|
@ -65,12 +78,16 @@ var _db = null;
|
|||
.catch(function() { return null; });
|
||||
}
|
||||
|
||||
function saveToIndexedDB(blob, module) {
|
||||
function saveToIndexedDB(blob, module, owner) {
|
||||
return openDB().then(function(db) {
|
||||
if (!boundary.valid(owner)) return null;
|
||||
return new Promise(function(resolve, reject) {
|
||||
var tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
guardTransaction(tx, owner);
|
||||
tx.onabort = function() { resolve(null); };
|
||||
var store = tx.objectStore(STORE_NAME);
|
||||
var record = {
|
||||
owner: owner,
|
||||
blob: blob,
|
||||
module: module || 'unknown',
|
||||
timestamp: Date.now(),
|
||||
|
|
@ -78,10 +95,11 @@ var _db = null;
|
|||
mimeType: blob.type
|
||||
};
|
||||
var req = store.add(record);
|
||||
req.onsuccess = function() { resolve(req.result); };
|
||||
tx.oncomplete = function() { resolve(boundary.valid(owner) ? req.result : null); };
|
||||
req.onerror = function() { reject(new Error('Failed to save audio backup')); };
|
||||
});
|
||||
}).then(function(id) {
|
||||
if (!id || !boundary.valid(owner)) return null;
|
||||
window._lastAudioBackupId = 'local_' + id;
|
||||
cleanupOldLocalBackups();
|
||||
return id;
|
||||
|
|
@ -93,6 +111,8 @@ var _db = null;
|
|||
|
||||
// Delete a specific backup (server or local)
|
||||
window.deleteAudioBackup = function(id) {
|
||||
var owner = boundary.capture();
|
||||
if (!owner) return Promise.resolve();
|
||||
if (typeof id === 'string' && id.startsWith('server_')) {
|
||||
var serverId = id.replace('server_', '');
|
||||
return fetch('/api/audio-backups/' + serverId, {
|
||||
|
|
@ -103,9 +123,16 @@ var _db = null;
|
|||
// Local IndexedDB delete
|
||||
var localId = typeof id === 'string' ? parseInt(id.replace('local_', '')) : id;
|
||||
return openDB().then(function(db) {
|
||||
if (!boundary.valid(owner)) return;
|
||||
return new Promise(function(resolve) {
|
||||
var tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
tx.objectStore(STORE_NAME).delete(localId);
|
||||
guardTransaction(tx, owner);
|
||||
var store = tx.objectStore(STORE_NAME);
|
||||
var request = store.get(localId);
|
||||
request.onsuccess = function() {
|
||||
if (boundary.valid(owner) && request.result && request.result.owner === owner) store.delete(localId);
|
||||
};
|
||||
tx.onabort = function() { resolve(); };
|
||||
tx.oncomplete = function() { resolve(); };
|
||||
tx.onerror = function() { resolve(); };
|
||||
});
|
||||
|
|
@ -114,10 +141,13 @@ var _db = null;
|
|||
|
||||
// Get all backups (merged: server + local)
|
||||
window.getAudioBackups = function() {
|
||||
var owner = boundary.capture();
|
||||
if (!owner) return Promise.resolve([]);
|
||||
var serverPromise = fetchServerBackups();
|
||||
var localPromise = getLocalBackups();
|
||||
|
||||
return Promise.all([serverPromise, localPromise]).then(function(results) {
|
||||
if (!boundary.valid(owner)) return [];
|
||||
var server = results[0].map(function(b) {
|
||||
return {
|
||||
id: 'server_' + b.id,
|
||||
|
|
@ -143,9 +173,7 @@ var _db = null;
|
|||
};
|
||||
|
||||
function fetchServerBackups() {
|
||||
var headers = { 'Content-Type': 'application/json' };
|
||||
var token = window.AUTH_TOKEN || localStorage.getItem('ped_scribe_token') || '';
|
||||
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||
var headers = getAuthHeaders();
|
||||
return fetch('/api/audio-backups', {
|
||||
headers: headers,
|
||||
credentials: 'same-origin'
|
||||
|
|
@ -156,13 +184,15 @@ var _db = null;
|
|||
}
|
||||
|
||||
function getLocalBackups() {
|
||||
var owner = boundary.capture();
|
||||
if (!owner) return Promise.resolve([]);
|
||||
return openDB().then(function(db) {
|
||||
return new Promise(function(resolve) {
|
||||
var tx = db.transaction(STORE_NAME, 'readonly');
|
||||
var req = tx.objectStore(STORE_NAME).getAll();
|
||||
req.onsuccess = function() {
|
||||
var records = (req.result || []).filter(function(r) {
|
||||
return (Date.now() - r.timestamp) < MAX_AGE_MS;
|
||||
return boundary.valid(owner) && r.owner === owner && (Date.now() - r.timestamp) < MAX_AGE_MS;
|
||||
});
|
||||
resolve(records);
|
||||
};
|
||||
|
|
@ -173,17 +203,20 @@ var _db = null;
|
|||
|
||||
// Retry transcription from backup
|
||||
window.retryAudioBackup = function(id) {
|
||||
var owner = boundary.capture();
|
||||
if (!owner) return Promise.reject(boundary.error());
|
||||
if (typeof id === 'string' && id.startsWith('server_')) {
|
||||
var serverId = id.replace('server_', '');
|
||||
showLoading('Downloading and re-transcribing...');
|
||||
var token = window.AUTH_TOKEN || localStorage.getItem('ped_scribe_token') || '';
|
||||
return fetch('/api/audio-backups/' + serverId + '/audio', {
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
.then(function(r) { if (!r.ok) throw new Error('Download failed'); return r.blob(); })
|
||||
.then(function(blob) {
|
||||
if (!boundary.valid(owner)) throw boundary.error();
|
||||
window._lastAudioBackupId = id;
|
||||
return transcribeAudio(blob).then(function(data) {
|
||||
if (!boundary.valid(owner)) throw boundary.error();
|
||||
hideLoading();
|
||||
if (data.success) {
|
||||
showToast('Backup transcribed!', 'success');
|
||||
|
|
@ -207,15 +240,17 @@ var _db = null;
|
|||
var tx = db.transaction(STORE_NAME, 'readonly');
|
||||
var req = tx.objectStore(STORE_NAME).get(localId);
|
||||
req.onsuccess = function() {
|
||||
if (!req.result) { reject(new Error('Backup not found')); return; }
|
||||
if (!boundary.valid(owner) || !req.result || req.result.owner !== owner) { reject(new Error('Backup not found')); return; }
|
||||
resolve(req.result);
|
||||
};
|
||||
req.onerror = function() { reject(new Error('Failed to read backup')); };
|
||||
});
|
||||
}).then(function(record) {
|
||||
if (!boundary.valid(owner)) throw boundary.error();
|
||||
showLoading('Re-transcribing audio backup...');
|
||||
window._lastAudioBackupId = id;
|
||||
return transcribeAudio(record.blob).then(function(data) {
|
||||
if (!boundary.valid(owner)) throw boundary.error();
|
||||
hideLoading();
|
||||
if (data.success) {
|
||||
showToast('Backup transcribed!', 'success');
|
||||
|
|
@ -235,13 +270,14 @@ var _db = null;
|
|||
openDB().then(function(db) {
|
||||
var tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
var store = tx.objectStore(STORE_NAME);
|
||||
var index = store.index('timestamp');
|
||||
var cutoff = Date.now() - MAX_AGE_MS;
|
||||
var range = IDBKeyRange.upperBound(cutoff);
|
||||
var req = index.openCursor(range);
|
||||
var req = store.openCursor();
|
||||
req.onsuccess = function(e) {
|
||||
var cursor = e.target.result;
|
||||
if (cursor) { cursor.delete(); cursor.continue(); }
|
||||
if (cursor) {
|
||||
if (cursor.value.timestamp <= cutoff) cursor.delete();
|
||||
cursor.continue();
|
||||
}
|
||||
};
|
||||
}).catch(function() {});
|
||||
}
|
||||
|
|
@ -250,7 +286,9 @@ var _db = null;
|
|||
window.renderAudioBackups = function() {
|
||||
var container = document.getElementById('audio-backups-list');
|
||||
if (!container) return;
|
||||
var owner = boundary.capture();
|
||||
getAudioBackups().then(function(backups) {
|
||||
if (!boundary.valid(owner)) return;
|
||||
container.textContent = '';
|
||||
if (backups.length === 0) {
|
||||
var empty = document.createElement('p');
|
||||
|
|
|
|||
|
|
@ -15,6 +15,31 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
var TOKEN_KEY = 'ped_scribe_token';
|
||||
var USER_KEY = 'ped_scribe_user';
|
||||
var SESSION_KEY = 'ped_session_id';
|
||||
var boundary = window.AccountBoundary;
|
||||
var authenticationPending = false;
|
||||
function authState() {
|
||||
var state = boundary.read();
|
||||
return { revision: boundary.revision(), generation: state && state.generation };
|
||||
}
|
||||
function authCurrent(state) {
|
||||
var now = authState();
|
||||
return state.revision === now.revision && state.generation === now.generation;
|
||||
}
|
||||
async function authenticate(work, message) {
|
||||
// Admission covers biometric retrieval, response parsing AND native persistence.
|
||||
// Disabled buttons alone cannot fence programmatic submits or sibling forms.
|
||||
if (authenticationPending || boundary.blocked()) return Promise.resolve(false);
|
||||
authenticationPending = true;
|
||||
if (authScreen) authScreen.setAttribute('aria-busy', 'true');
|
||||
showLoading(message);
|
||||
try { return await work(); }
|
||||
finally {
|
||||
authenticationPending = false;
|
||||
if (authScreen) authScreen.removeAttribute('aria-busy');
|
||||
hideLoading();
|
||||
}
|
||||
}
|
||||
var bootstrapState = authState();
|
||||
|
||||
// Runtime-split auth model:
|
||||
// - Web browser → httpOnly cookie only (no localStorage token, XSS-safe).
|
||||
|
|
@ -160,29 +185,58 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
|
||||
// Auth screen is hidden by CSS default — only show it when there is no valid session
|
||||
function showAuthScreen() {
|
||||
if (authScreen) authScreen.style.display = 'flex';
|
||||
if (!boundary.blocked() && !window.CURRENT_USER && authScreen) authScreen.style.display = 'flex';
|
||||
}
|
||||
|
||||
// ── Check for SSO redirect (token is in httpOnly cookie) ──
|
||||
var urlParams = new URLSearchParams(window.location.search);
|
||||
var ssoOk = urlParams.get('sso');
|
||||
var ssoError = urlParams.get('error');
|
||||
if (ssoOk === 'ok') {
|
||||
var ssoIntent = false;
|
||||
try {
|
||||
var intentTime = Number(sessionStorage.getItem('ped_sso_intent'));
|
||||
ssoIntent = intentTime > 0 && Date.now() - intentTime >= 0 && Date.now() - intentTime < 5 * 60 * 1000;
|
||||
sessionStorage.removeItem('ped_sso_intent');
|
||||
} catch (e) {}
|
||||
var ssoButton = document.getElementById('btn-sso');
|
||||
if (ssoButton) ssoButton.addEventListener('click', async function(e) {
|
||||
if (!e.isTrusted || authenticationPending || boundary.blocked()) { e.preventDefault(); return; }
|
||||
boundary.capture(); // Detect a replaced sibling session before capturing credentials.
|
||||
if (boundary.blocked()) { e.preventDefault(); return; }
|
||||
// No owner marker can also mean bootstrap failed with an old cookie still
|
||||
// valid. Every SSO start must prove cookie absence before issuing intent.
|
||||
e.preventDefault();
|
||||
var headers = getAuthHeaders();
|
||||
var href = ssoButton.href;
|
||||
try {
|
||||
boundary.startLogin(); // Publish before logout can change the shared cookie.
|
||||
boundary.freeze();
|
||||
var generation = boundary.read().generation;
|
||||
var logout = await boundary.logoutRequest(headers);
|
||||
if (!logout.ok || boundary.read().generation !== generation) throw boundary.error();
|
||||
// Logout swallows DB deletion errors: 2xx is not proof the cookie is gone.
|
||||
var probe = await boundary.cookieSessionRequest();
|
||||
if (probe.status !== 401 || boundary.read().generation !== generation) throw boundary.error();
|
||||
boundary.completeSignIn();
|
||||
sessionStorage.setItem('ped_sso_intent', String(Date.now()));
|
||||
window.location.href = href;
|
||||
} catch (err) {
|
||||
boundary.freeze(); // Failure/canceled navigation cannot unlock retained clinical state.
|
||||
try { sessionStorage.removeItem('ped_sso_intent'); } catch (e) {}
|
||||
}
|
||||
});
|
||||
if (ssoOk === 'ok' && !boundary.needsSignIn() && (!boundary.signedOut() || ssoIntent)) {
|
||||
var ssoSid = urlParams.get('sid');
|
||||
history.replaceState(null, '', window.location.pathname);
|
||||
if (ssoSid && isNativeApp()) {
|
||||
window.SecureStorage.set(SESSION_KEY, ssoSid);
|
||||
}
|
||||
// Token is in httpOnly cookie — verify via /me endpoint (cookie sent automatically)
|
||||
fetch('/api/auth/me', { credentials: 'same-origin' })
|
||||
.then(function(r) { if (r.ok) return r.json(); throw new Error('invalid'); })
|
||||
.then(function(data) {
|
||||
if (data && data.user) {
|
||||
enterApp(data.user, '');
|
||||
showToast('Welcome, ' + data.user.name + '!', 'success');
|
||||
} else { showAuthScreen(); clearSession(); }
|
||||
return enterApp(data.user, '', ssoIntent, ssoSid, bootstrapState);
|
||||
} else { showAuthScreen(); }
|
||||
})
|
||||
.catch(function() { showAuthScreen(); clearSession(); });
|
||||
.catch(function() { showAuthScreen(); });
|
||||
} else if (ssoError) {
|
||||
history.replaceState(null, '', window.location.pathname);
|
||||
var errorMsgs = {
|
||||
|
|
@ -191,6 +245,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
no_email: 'Your identity provider did not return an email',
|
||||
disabled: 'Account disabled',
|
||||
sso_failed: 'SSO login failed',
|
||||
sso_disabled: 'SSO is no longer enabled. Use local sign-in.',
|
||||
account_link_required: 'This local account is unverified. Account recovery and administrator-assisted linking are required before SSO sign-in.',
|
||||
email_unverified: 'Your identity provider did not confirm your email is verified. Contact your administrator.',
|
||||
sub_mismatch: 'Your SSO identity does not match the linked account. Contact your administrator.'
|
||||
};
|
||||
|
|
@ -219,19 +275,22 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
})
|
||||
.catch(function() {});
|
||||
|
||||
if (isNativeApp()) {
|
||||
if (boundary.needsSignIn() || (boundary.signedOut() && !(ssoOk === 'ok' && ssoIntent))) {
|
||||
showAuthScreen();
|
||||
} else if (isNativeApp()) {
|
||||
// Native app path: token lives in Keychain/Keystore via SecureStorage
|
||||
window.SecureStorage.hydrate([TOKEN_KEY, USER_KEY, SESSION_KEY]).then(function() {
|
||||
if (!authCurrent(bootstrapState)) return;
|
||||
var savedToken = window.SecureStorage.getSync(TOKEN_KEY);
|
||||
if (ssoOk || ssoError) return; // SSO branch handled above
|
||||
if (!savedToken) { showAuthScreen(); return; }
|
||||
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + savedToken } })
|
||||
.then(function(r) { if (r.ok) return r.json(); throw new Error('expired'); })
|
||||
.then(function(data) {
|
||||
if (data && data.user) enterApp(data.user, savedToken);
|
||||
else { showAuthScreen(); clearSession(); }
|
||||
if (data && data.user) return enterApp(data.user, savedToken, false, null, bootstrapState);
|
||||
else { showAuthScreen(); }
|
||||
})
|
||||
.catch(function() { showAuthScreen(); clearSession(); });
|
||||
.catch(function() { showAuthScreen(); });
|
||||
});
|
||||
} else {
|
||||
// Web path: rely on httpOnly cookie. fetch sends same-origin cookies
|
||||
|
|
@ -242,7 +301,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
fetch('/api/auth/me', { credentials: 'same-origin' })
|
||||
.then(function(r) { if (r.ok) return r.json(); throw new Error('not-logged-in'); })
|
||||
.then(function(data) {
|
||||
if (data && data.user) enterApp(data.user, '');
|
||||
if (data && data.user) return enterApp(data.user, '', false, null, bootstrapState);
|
||||
else showAuthScreen();
|
||||
})
|
||||
.catch(function() { showAuthScreen(); });
|
||||
|
|
@ -336,14 +395,43 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
renderTurnstile('forgot');
|
||||
}
|
||||
|
||||
function enterApp(user, token) {
|
||||
window.AUTH_TOKEN = token;
|
||||
window.CURRENT_USER = user; // cached so settings page doesn't re-fetch /me just to read canLocalAuth
|
||||
if (isNativeApp()) {
|
||||
// Mobile: persist token to Keychain/Keystore
|
||||
window.SecureStorage.set(TOKEN_KEY, token);
|
||||
window.SecureStorage.set(USER_KEY, JSON.stringify(user));
|
||||
async function enterApp(user, token, explicit, sessionId, attempt) {
|
||||
if (!user || user.id == null) return false;
|
||||
if (!authCurrent(attempt)) { boundary.recoverSignIn(); return false; }
|
||||
if (!explicit && (boundary.blocked() || boundary.signedOut() || boundary.needsSignIn())) return false;
|
||||
var shared = boundary.read();
|
||||
// Verification of A is not authorization to replace a published B. Passive
|
||||
// bootstrap never writes credentials (native bridge writes cannot be canceled).
|
||||
if (!explicit && shared && shared.owner !== String(user.id)) {
|
||||
boundary.recoverSignIn(); return false;
|
||||
}
|
||||
var oldOwner = boundary.capture();
|
||||
if (oldOwner && oldOwner !== String(user.id)) boundary.freeze();
|
||||
if (!authCurrent(attempt)) { boundary.recoverSignIn(); return false; }
|
||||
if (isNativeApp() && explicit) {
|
||||
var writes = await Promise.allSettled([
|
||||
window.SecureStorage.set(TOKEN_KEY, token),
|
||||
window.SecureStorage.set(USER_KEY, JSON.stringify(user)),
|
||||
window.SecureStorage.set(SESSION_KEY, sessionId || '')
|
||||
]);
|
||||
// Wait for ALL bridge calls, including after one fails. Late successful
|
||||
// writes must neither trigger an automatic reload nor clean up newer B.
|
||||
if (!authCurrent(attempt)) { boundary.recoverSignIn(); return false; }
|
||||
if (writes.some(function(result) { return result.status === 'rejected'; })) {
|
||||
boundary.end();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (!authCurrent(attempt)) { boundary.recoverSignIn(); return false; }
|
||||
if (explicit) boundary.completeSignIn();
|
||||
if (boundary.blocked()) {
|
||||
if (explicit) boundary.publishLogin(user);
|
||||
boundary.reload();
|
||||
return false;
|
||||
}
|
||||
if (!boundary.enter(user, explicit)) return false;
|
||||
window.AUTH_TOKEN = token;
|
||||
window.CURRENT_USER = user;
|
||||
// Web: do not persist the token anywhere — session lives in the
|
||||
// httpOnly cookie already set by the server. User info is
|
||||
// re-fetched via /api/auth/me on each boot.
|
||||
|
|
@ -395,82 +483,40 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
|
||||
// Check if server-side transcription is configured
|
||||
if (typeof checkTranscribeStatus === 'function') checkTranscribeStatus();
|
||||
return true;
|
||||
}
|
||||
|
||||
function exitApp() {
|
||||
// Destroy session server-side before clearing local state
|
||||
fetch('/api/auth/logout', { method: 'POST', headers: getAuthHeaders(), credentials: 'same-origin' }).catch(function() {});
|
||||
// Tell sibling tabs to drop their UI too.
|
||||
try { if (window.__authBroadcast) window.__authBroadcast.postMessage({ type: 'logout' }); } catch(e) {}
|
||||
clearSession();
|
||||
document.documentElement.classList.remove('has-session');
|
||||
history.replaceState(null, '', window.location.pathname);
|
||||
if (authScreen) authScreen.style.display = 'flex';
|
||||
if (mainApp) mainApp.style.display = 'none';
|
||||
var adminTabBtn = document.getElementById('admin-tab-btn');
|
||||
if (adminTabBtn) adminTabBtn.classList.add('hidden');
|
||||
var docsTabBtn = document.getElementById('docs-tab-btn');
|
||||
if (docsTabBtn) docsTabBtn.classList.add('hidden');
|
||||
// Explicit logout clears biometric — assume the user is leaving the
|
||||
// device for someone else. Auto-logout (token expiry, network) does
|
||||
// NOT come through this path, so biometric persists across silent
|
||||
// session resets.
|
||||
if (window.PedBio && typeof window.PedBio.forget === 'function') {
|
||||
window.PedBio.forget();
|
||||
}
|
||||
var bioBtn = document.getElementById('btn-bio-login');
|
||||
if (bioBtn) { bioBtn.classList.add('hidden'); bioBtn.style.display = 'none'; }
|
||||
var bioDiv = document.getElementById('bio-divider');
|
||||
if (bioDiv) { bioDiv.classList.add('hidden'); bioDiv.style.display = 'none'; }
|
||||
showLoginForm();
|
||||
// Clear fields after browser autofill has had a chance to run
|
||||
setTimeout(function() {
|
||||
['login-email', 'login-password', 'reg-name', 'reg-email', 'reg-password', 'login-totp'].forEach(function(id) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.value = '';
|
||||
});
|
||||
}, 100);
|
||||
if (boundary.blocked()) return;
|
||||
var headers = getAuthHeaders();
|
||||
boundary.end(); // Synchronously hide and stop activity before changing credentials/cookies.
|
||||
var logout = boundary.logoutRequest(headers).catch(function() {});
|
||||
var clearing = clearSession(true);
|
||||
var forgetting = window.PedBio ? window.PedBio.forget() : Promise.resolve();
|
||||
// The signed-out latch survives both failed logout and a canceled reload.
|
||||
Promise.all([logout, clearing, forgetting]).finally(function() { boundary.reload(); });
|
||||
// A hung network/native bridge must not leave the old UI usable either.
|
||||
setTimeout(function() { boundary.reload(); }, 3000);
|
||||
}
|
||||
|
||||
function clearSession() {
|
||||
if (isNativeApp()) {
|
||||
window.SecureStorage.remove(TOKEN_KEY);
|
||||
window.SecureStorage.remove(USER_KEY);
|
||||
window.SecureStorage.remove(SESSION_KEY);
|
||||
} else {
|
||||
// Web: cookie cleared by /api/auth/logout. Also wipe any legacy
|
||||
// localStorage entries from the dual-mode era so they don't linger.
|
||||
try {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
localStorage.removeItem(SESSION_KEY);
|
||||
} catch(e) {}
|
||||
}
|
||||
function clearSession(explicit) {
|
||||
if (!explicit && (boundary.blocked() || window.CURRENT_USER)) return Promise.resolve();
|
||||
if (boundary.active()) { boundary.end(); boundary.reload(); }
|
||||
window.AUTH_TOKEN = null;
|
||||
// Clear service worker caches so a logged-out user on a shared device
|
||||
// cannot read cached pages from offline mode.
|
||||
try {
|
||||
if (window.caches && caches.keys) {
|
||||
caches.keys().then(function(names) {
|
||||
names.forEach(function(n) { caches.delete(n); });
|
||||
}).catch(function(){});
|
||||
}
|
||||
} catch(e) {}
|
||||
window.CURRENT_USER = null;
|
||||
return Promise.all([TOKEN_KEY, USER_KEY, SESSION_KEY].map(function(key) {
|
||||
try { localStorage.removeItem(key); } catch (e) {}
|
||||
return isNativeApp() ? window.SecureStorage.remove(key) : Promise.resolve();
|
||||
}));
|
||||
}
|
||||
|
||||
window.getAuthHeaders = function() {
|
||||
// Web: no Authorization header. The httpOnly cookie is sent automatically
|
||||
// by fetch (default credentials = same-origin). Server middleware falls
|
||||
// back to cookie when Bearer is absent.
|
||||
if (!isNativeApp()) {
|
||||
return { 'Content-Type': 'application/json' };
|
||||
}
|
||||
// Native: Bearer from Keychain/Keystore via SecureStorage.
|
||||
var token = window.AUTH_TOKEN || window.SecureStorage.getSync(TOKEN_KEY) || '';
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ' + token
|
||||
};
|
||||
var headers = { 'Content-Type': 'application/json' };
|
||||
// Only the verified runtime token may authenticate native requests. Late
|
||||
// storage hydration can still contain A while the verified cookie owns B.
|
||||
// Explicit native /me bootstrap verifies its stored token separately.
|
||||
if (isNativeApp() && window.AUTH_TOKEN) headers.Authorization = 'Bearer ' + window.AUTH_TOKEN;
|
||||
return headers;
|
||||
};
|
||||
|
||||
// ---- FORM TOGGLE LINKS ----
|
||||
|
|
@ -628,15 +674,18 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
|
||||
// ---- BIOMETRIC LOGIN BUTTON ----
|
||||
// Reads the email + password from the OS-secured keychain (gated behind
|
||||
// Face ID / Touch ID / fingerprint) and fills the login form. Submits the
|
||||
// form so all the existing flow (2FA prompt, error handling,
|
||||
// Face ID / Touch ID / fingerprint) and fills the login form. Reuses its
|
||||
// promise-returning handler so all the existing flow (2FA prompt, error handling,
|
||||
// session storage) runs unchanged. If biometric verification fails, the
|
||||
// user just gets a toast and falls through to typing the password.
|
||||
var bioBtn = document.getElementById('btn-bio-login');
|
||||
if (bioBtn) {
|
||||
bioBtn.addEventListener('click', function () {
|
||||
bioRetrieve()
|
||||
authenticate(function() {
|
||||
var retrievalState = authState();
|
||||
return bioRetrieve()
|
||||
.then(function (creds) {
|
||||
if (!authCurrent(retrievalState)) { boundary.recoverSignIn(); return; }
|
||||
if (!creds || !creds.username || !creds.password) {
|
||||
showToast('No stored credentials', 'error');
|
||||
return;
|
||||
|
|
@ -647,8 +696,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
if (pwEl) pwEl.value = creds.password;
|
||||
// Trigger the same submit path as the password form so all the
|
||||
// existing handling (2FA, session storage, etc.) runs unchanged.
|
||||
if (loginForm && typeof loginForm.requestSubmit === 'function') loginForm.requestSubmit();
|
||||
else if (loginForm) loginForm.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
|
||||
if (loginForm) return submitLogin();
|
||||
})
|
||||
.catch(function (err) {
|
||||
// User cancelled or biometric failed (locked out, no enrolled
|
||||
|
|
@ -656,15 +704,12 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
var msg = (err && (err.message || err.code)) || '';
|
||||
if (/cancel/i.test(msg)) return;
|
||||
showToast('Biometric sign-in failed', 'error');
|
||||
});
|
||||
}); }, 'Signing in...');
|
||||
});
|
||||
}
|
||||
|
||||
// ---- LOGIN FORM SUBMIT ----
|
||||
if (loginForm) {
|
||||
loginForm.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
function submitLogin() {
|
||||
|
||||
var email = document.getElementById('login-email').value.trim();
|
||||
var password = document.getElementById('login-password').value;
|
||||
|
|
@ -676,20 +721,19 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
return false;
|
||||
}
|
||||
|
||||
showLoading('Signing in...');
|
||||
|
||||
var body = { email: email, password: password };
|
||||
if (totpCode) body.totpCode = totpCode;
|
||||
|
||||
fetch('/api/auth/login', {
|
||||
var request = fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
});
|
||||
var attempt = authState();
|
||||
return request
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
hideLoading();
|
||||
|
||||
if (boundary.blocked() && !(data.success && data.token && data.user)) return;
|
||||
if (data.requires2FA) {
|
||||
var grp = document.getElementById('totp-group');
|
||||
if (grp) { grp.style.display = 'block'; grp.className = grp.className.replace('hidden', '').trim(); }
|
||||
|
|
@ -705,40 +749,43 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
}
|
||||
|
||||
if (data.success && data.token && data.user) {
|
||||
if (data.sessionId && isNativeApp()) {
|
||||
window.SecureStorage.set(SESSION_KEY, data.sessionId);
|
||||
}
|
||||
enterApp(data.user, data.token);
|
||||
showToast('Welcome, ' + data.user.name + '!', 'success');
|
||||
// Offer biometric enrollment after the very first successful
|
||||
// password login on a Capacitor device. Only ask once per
|
||||
// (device, account) — enrollment flips the BIO_ENABLED_KEY flag.
|
||||
if (isNativeApp() && !bioStored()) {
|
||||
bioAvailable().then(function (s) {
|
||||
if (!s.ok) return;
|
||||
var typeName = s.typeName || 'biometric';
|
||||
if (typeof showConfirm === 'function') {
|
||||
showConfirm('Enable ' + typeName + ' for faster sign-in next time?', function () {
|
||||
bioEnroll(email, password)
|
||||
.then(function () { showToast(typeName + ' enabled. Use it next time you sign in.', 'success'); })
|
||||
.catch(function () { showToast('Could not enable ' + typeName, 'error'); });
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return enterApp(data.user, data.token, true, data.sessionId, attempt).then(function(entered) {
|
||||
if (!entered) return;
|
||||
showToast('Welcome, ' + data.user.name + '!', 'success');
|
||||
// Offer biometric enrollment after the very first successful
|
||||
// password login on a Capacitor device. Only ask once per
|
||||
// (device, account) — enrollment flips the BIO_ENABLED_KEY flag.
|
||||
if (isNativeApp() && !bioStored()) {
|
||||
bioAvailable().then(function (s) {
|
||||
if (!s.ok || !boundary.active()) return;
|
||||
var typeName = s.typeName || 'biometric';
|
||||
if (typeof showConfirm === 'function') {
|
||||
showConfirm('Enable ' + typeName + ' for faster sign-in next time?', function () {
|
||||
if (!boundary.active()) return;
|
||||
bioEnroll(email, password)
|
||||
.then(function () { showToast(typeName + ' enabled. Use it next time you sign in.', 'success'); })
|
||||
.catch(function () { showToast('Could not enable ' + typeName, 'error'); });
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
showToast(data.error || 'Login failed', 'error');
|
||||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
hideLoading();
|
||||
if (boundary.blocked() || err.name === 'AbortError') return;
|
||||
console.error('[Auth] Login error:', err);
|
||||
showToast('Connection error', 'error');
|
||||
});
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
if (loginForm) loginForm.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
authenticate(submitLogin, 'Signing in...');
|
||||
});
|
||||
|
||||
// ---- RESEND VERIFICATION LINK ----
|
||||
var resendLink = document.getElementById('resend-verify-link');
|
||||
|
|
@ -798,23 +845,20 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
return false;
|
||||
}
|
||||
|
||||
showLoading('Creating account...');
|
||||
|
||||
fetch('/api/auth/register', {
|
||||
authenticate(function() {
|
||||
var request = fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: name, email: email, password: password, turnstileToken: regToken })
|
||||
})
|
||||
});
|
||||
var attempt = authState();
|
||||
return request
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
hideLoading();
|
||||
|
||||
if (data.success && data.token && data.user) {
|
||||
if (data.sessionId && isNativeApp()) {
|
||||
window.SecureStorage.set(SESSION_KEY, data.sessionId);
|
||||
}
|
||||
enterApp(data.user, data.token);
|
||||
showToast(data.message || 'Account created!', 'success');
|
||||
return enterApp(data.user, data.token, true, data.sessionId, attempt);
|
||||
} else if (boundary.blocked()) {
|
||||
return;
|
||||
} else if (data.success && data.needsVerification) {
|
||||
showToast(data.message || 'Check email to verify', 'success');
|
||||
// The token was just consumed server-side — clear it so coming back
|
||||
|
|
@ -827,11 +871,12 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
hideLoading();
|
||||
if (boundary.blocked() || err.name === 'AbortError') return;
|
||||
console.error('[Auth] Register error:', err);
|
||||
showToast('Connection error', 'error');
|
||||
resetTurnstile('register');
|
||||
});
|
||||
}, 'Creating account...');
|
||||
|
||||
return false;
|
||||
});
|
||||
|
|
@ -842,6 +887,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
forgotForm.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (authenticationPending || boundary.blocked()) return false;
|
||||
|
||||
var email = document.getElementById('forgot-email').value.trim();
|
||||
if (!email) { showToast('Enter email', 'error'); return false; }
|
||||
|
|
@ -996,7 +1042,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
return;
|
||||
}
|
||||
}
|
||||
// Keep cache in sync
|
||||
// /me may reveal a shared cookie replaced before the tab event arrived.
|
||||
if (boundary.capture() !== String(data.user.id)) { boundary.freeze(); boundary.reload(); return; }
|
||||
window.CURRENT_USER = data.user;
|
||||
// Sections are display:none by default. Show them unless the server
|
||||
// explicitly marks this user as SSO-only (canLocalAuth === false).
|
||||
|
|
|
|||
|
|
@ -1,98 +1,91 @@
|
|||
// ============================================================
|
||||
// Global fetch interceptor for auth failures
|
||||
// ============================================================
|
||||
// When the server responds 401 to any /api/ request, the user's
|
||||
// session has been revoked or expired. Previously this would leave
|
||||
// the app UI on screen silently because each fetch handled its own
|
||||
// errors. Now we detect it once, clear local state, and bounce to
|
||||
// the login screen.
|
||||
// ============================================================
|
||||
|
||||
// Account-bound requests cannot outlive the document's verified owner.
|
||||
if (!window.__fetchAuthIntercepted) {
|
||||
window.__fetchAuthIntercepted = true;
|
||||
var rawFetch = window.fetch.bind(window);
|
||||
var boundary = window.AccountBoundary;
|
||||
var authPaths = new Set([
|
||||
'/api/auth/login', '/api/auth/register', '/api/auth/logout',
|
||||
'/api/auth/forgot-password', '/api/auth/reset-password',
|
||||
'/api/auth/verify-email', '/api/auth/resend-verification',
|
||||
'/api/auth/oidc', '/api/auth/oidc-status', '/api/auth/registration-status'
|
||||
]);
|
||||
|
||||
var _fetch = window.fetch.bind(window);
|
||||
var handlingLogout = false;
|
||||
// Narrow transition requests bypass the frozen clinical transport. Logout
|
||||
// headers are captured before freezing; its verification is cookie-only.
|
||||
boundary.logoutRequest = function(headers) {
|
||||
return rawFetch('/api/auth/logout', {
|
||||
method: 'POST', headers: headers, credentials: 'same-origin', keepalive: true
|
||||
});
|
||||
};
|
||||
boundary.cookieSessionRequest = function() {
|
||||
return rawFetch('/api/auth/me', { credentials: 'same-origin', cache: 'no-store' });
|
||||
};
|
||||
|
||||
// Cross-tab sync: when one tab logs out, all other open tabs drop
|
||||
// their UI within milliseconds instead of waiting for their next
|
||||
// failed fetch. Uses the same BroadcastChannel name across tabs.
|
||||
var bc = null;
|
||||
try { bc = new BroadcastChannel('pedscribe-auth'); } catch (e) { bc = null; }
|
||||
window.__authBroadcast = bc;
|
||||
if (bc) {
|
||||
bc.onmessage = function(ev) {
|
||||
if (ev && ev.data && ev.data.type === 'logout') {
|
||||
handleAuthFailure(null, 'You signed out in another tab.');
|
||||
}
|
||||
if (navigator.sendBeacon) {
|
||||
var sendBeacon = navigator.sendBeacon.bind(navigator);
|
||||
navigator.sendBeacon = function(input, data) {
|
||||
var url = new URL(input, window.location.href);
|
||||
if (url.origin === window.location.origin && url.pathname.startsWith('/api/') && !boundary.active()) return false;
|
||||
return sendBeacon(input, data);
|
||||
};
|
||||
}
|
||||
|
||||
function handleAuthFailure(status, reason) {
|
||||
if (handlingLogout) return;
|
||||
handlingLogout = true;
|
||||
try {
|
||||
// Clear any cached token + user info
|
||||
window.AUTH_TOKEN = null;
|
||||
window.CURRENT_USER = null;
|
||||
if (window.SecureStorage) {
|
||||
try { window.SecureStorage.remove('ped_scribe_token'); } catch(e) {}
|
||||
try { window.SecureStorage.remove('ped_scribe_user'); } catch(e) {}
|
||||
try { window.SecureStorage.remove('ped_session_id'); } catch(e) {}
|
||||
}
|
||||
try {
|
||||
localStorage.removeItem('ped_scribe_token');
|
||||
localStorage.removeItem('ped_scribe_user');
|
||||
localStorage.removeItem('ped_session_id');
|
||||
} catch(e) {}
|
||||
|
||||
// Brief user-visible notice before the reload swap
|
||||
try {
|
||||
if (typeof window.showToast === 'function') {
|
||||
window.showToast(reason || 'Your session has ended. Please sign in again.', 'info');
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
// Reload — the boot flow in auth.js will call /api/auth/me, get a
|
||||
// fresh 401 (no cookie / no token), and show the login screen.
|
||||
setTimeout(function() { window.location.reload(); }, 800);
|
||||
} catch (e) {
|
||||
// Last resort
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
window.fetch = function(input, init) {
|
||||
return _fetch(input, init).then(function(resp) {
|
||||
// Only act on same-origin /api/ calls; don't interfere with login
|
||||
// or logout endpoints themselves (they're allowed to return 401).
|
||||
try {
|
||||
var url = typeof input === 'string' ? input : (input && input.url) || '';
|
||||
var isApiCall = url.indexOf('/api/') !== -1;
|
||||
var isAuthEndpoint = url.indexOf('/api/auth/login') !== -1
|
||||
|| url.indexOf('/api/auth/register') !== -1
|
||||
|| url.indexOf('/api/auth/logout') !== -1
|
||||
|| url.indexOf('/api/auth/forgot-password') !== -1
|
||||
|| url.indexOf('/api/auth/reset-password') !== -1
|
||||
|| url.indexOf('/api/auth/verify-email') !== -1
|
||||
|| url.indexOf('/api/auth/resend-verification') !== -1
|
||||
|| url.indexOf('/api/auth/oidc') !== -1
|
||||
|| url.indexOf('/api/auth/me') !== -1
|
||||
|| url.indexOf('/api/auth/2fa') !== -1;
|
||||
if (resp.status === 401 && isApiCall && !isAuthEndpoint) {
|
||||
// Only treat as global session failure if the app thinks the user
|
||||
// was logged in (AUTH_TOKEN set OR main-app visible). Avoid
|
||||
// flashing the login screen on public-endpoint 401s during the
|
||||
// pre-login phase.
|
||||
var authed = !!window.AUTH_TOKEN
|
||||
|| document.documentElement.classList.contains('has-session')
|
||||
|| (document.getElementById('main-app') && document.getElementById('main-app').style.display === 'block');
|
||||
if (authed) {
|
||||
handleAuthFailure(resp.status, 'Your session was ended by another device. Please sign in again.');
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
var url;
|
||||
try { url = new URL(typeof input === 'string' || input instanceof URL ? input : input.url, window.location.href); }
|
||||
catch (e) { return rawFetch(input, init); }
|
||||
if (url.origin !== window.location.origin || !url.pathname.startsWith('/api/')) return rawFetch(input, init);
|
||||
if (boundary.blocked()) return Promise.reject(boundary.error());
|
||||
var ticket = boundary.capture(); // Also detects shared-cookie changes before storage events arrive.
|
||||
if (boundary.blocked()) return Promise.reject(boundary.error());
|
||||
// No clinical module may preload a previous cookie's data on the login screen.
|
||||
if (!ticket && !authPaths.has(url.pathname) && url.pathname !== '/api/auth/me' && url.pathname !== '/api/models') {
|
||||
return Promise.reject(boundary.error());
|
||||
}
|
||||
var login = (url.pathname === '/api/auth/login' || url.pathname === '/api/auth/register')
|
||||
&& String((init && init.method) || (input && input.method) || 'GET').toUpperCase() === 'POST';
|
||||
try { if (login) boundary.startLogin(); } catch (e) { return Promise.reject(e); }
|
||||
var revision = boundary.revision();
|
||||
var shared = boundary.read();
|
||||
var generation = shared && shared.generation;
|
||||
var abort = new AbortController();
|
||||
var callerSignal = (init && init.signal) || (input && input.signal);
|
||||
var accountSignal = boundary.signal();
|
||||
function cancel() { abort.abort(); }
|
||||
if (callerSignal) {
|
||||
if (callerSignal.aborted) cancel();
|
||||
else callerSignal.addEventListener('abort', cancel, { once: true });
|
||||
}
|
||||
if (!login) accountSignal.addEventListener('abort', cancel, { once: true });
|
||||
function check() {
|
||||
var now = boundary.read();
|
||||
if (revision !== boundary.revision() || generation !== (now && now.generation)
|
||||
|| (!login && (boundary.blocked() || (ticket && !boundary.valid(ticket))))) throw boundary.error();
|
||||
}
|
||||
function guardResponse(resp) {
|
||||
check();
|
||||
if (resp.status === 401 && ticket && !authPaths.has(url.pathname)) {
|
||||
boundary.end();
|
||||
boundary.reload();
|
||||
throw boundary.error();
|
||||
}
|
||||
// Body parsing can finish after fetch itself; gate those continuations too.
|
||||
['json', 'text', 'blob', 'arrayBuffer', 'formData'].forEach(function(method) {
|
||||
if (!resp[method]) return;
|
||||
var original = resp[method].bind(resp);
|
||||
resp[method] = function() { check(); return original().then(function(value) { check(); return value; }); };
|
||||
});
|
||||
if (resp.clone) {
|
||||
var clone = resp.clone.bind(resp);
|
||||
resp.clone = function() { check(); return guardResponse(clone()); };
|
||||
}
|
||||
return resp;
|
||||
});
|
||||
}
|
||||
return rawFetch(input, Object.assign({}, init, { signal: abort.signal }))
|
||||
.then(guardResponse).finally(function() {
|
||||
if (callerSignal) callerSignal.removeEventListener('abort', cancel);
|
||||
// Keep the account abort listener until document disposal: it also
|
||||
// cancels a response body that has not finished streaming yet.
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@ import {
|
|||
openAssistantStream,
|
||||
fetchAssistantImageJob,
|
||||
startAssistantImageJob,
|
||||
saveAssistantChat
|
||||
saveAssistantChat,
|
||||
requestAssistantHandoff
|
||||
} from './assistant/api.js';
|
||||
var initialized = false;
|
||||
var messages = [];
|
||||
|
|
@ -30,6 +31,7 @@ import {
|
|||
var markdownRenderer = null;
|
||||
var assistantBusy = false;
|
||||
var activeAssistantRequest = null;
|
||||
var conversationChars = null;
|
||||
var STREAM_MARKDOWN_LIMIT = 3500;
|
||||
var exporter = createAssistantExporter({ renderMarkdown: renderMarkdown, showToast: window.showToast });
|
||||
var imageStore = createAssistantImageStore();
|
||||
|
|
@ -63,6 +65,14 @@ import {
|
|||
|
||||
if (form) form.addEventListener('submit', onAsk);
|
||||
if (clearBtn) clearBtn.addEventListener('click', clearConversation);
|
||||
document.getElementById('btn-assistant-download-chat').addEventListener('click', downloadTranscript);
|
||||
document.getElementById('btn-assistant-handoff').addEventListener('click', requestHandoff);
|
||||
document.getElementById('btn-assistant-copy-handoff').addEventListener('click', function() {
|
||||
navigator.clipboard.writeText(document.getElementById('assistant-handoff-text').value).catch(function() {
|
||||
if (typeof showToast === 'function') showToast('Could not copy; select the summary text instead.', 'error');
|
||||
});
|
||||
});
|
||||
if (input) input.addEventListener('input', updateConversationBudget);
|
||||
if (cancelBtn) cancelBtn.addEventListener('click', cancelAssistantSearch);
|
||||
if (copyBtn) copyBtn.addEventListener('click', copyLastAnswer);
|
||||
if (saveBtn) saveBtn.addEventListener('click', showSavePanel);
|
||||
|
|
@ -81,15 +91,18 @@ import {
|
|||
}
|
||||
|
||||
function loadStatus() {
|
||||
fetchAssistantStatus()
|
||||
return fetchAssistantStatus()
|
||||
.then(function (data) {
|
||||
var label = document.getElementById('assistant-model-label');
|
||||
if (label && data.success) {
|
||||
label.textContent = data.chatModel ? ('Chat: ' + data.chatModel) : 'Admin model';
|
||||
}
|
||||
conversationChars = data.success && validConversationLimit(data.conversationChars) ? data.conversationChars : null;
|
||||
updateConversationBudget();
|
||||
})
|
||||
.catch(function () {
|
||||
// Endpoint may not exist until backend slice lands; keep UI usable.
|
||||
conversationChars = null;
|
||||
updateConversationBudget(); // No guessed cap; the server remains authoritative.
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -111,40 +124,58 @@ import {
|
|||
if (e) e.preventDefault();
|
||||
var input = document.getElementById('assistant-input');
|
||||
var includeContext = document.getElementById('assistant-include-context');
|
||||
var text = input ? input.value.trim() : '';
|
||||
var text = input ? input.value : '';
|
||||
if (assistantBusy) {
|
||||
if (typeof showToast === 'function') showToast('Assistant is still finishing the current answer', 'error');
|
||||
return;
|
||||
}
|
||||
if (!text) {
|
||||
if (!text.trim()) {
|
||||
if (typeof showToast === 'function') showToast('Enter a clinical question', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
appendMessage('user', text);
|
||||
exporter.invalidate();
|
||||
if (input) input.value = '';
|
||||
|
||||
if (isImageRequest(text)) {
|
||||
prepareSidebarImagePrompt(text);
|
||||
if (conversationChars !== null && conversationSize(text) > conversationChars) {
|
||||
updateConversationBudget();
|
||||
if (typeof showToast === 'function') showToast('Conversation limit reached. Save/download this chat, start a new chat, or explicitly request a handoff summary.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isImageRequest(text)) {
|
||||
appendMessage('user', text);
|
||||
if (input) input.value = '';
|
||||
prepareSidebarImagePrompt(text);
|
||||
updateConversationBudget();
|
||||
return;
|
||||
}
|
||||
|
||||
// Only text/roles go to inference; the full source maps/images stay in the transcript.
|
||||
var history = messages.map(function(m) { return { role: m.role, content: m.content }; });
|
||||
var request = createAssistantRequest();
|
||||
activeAssistantRequest = request;
|
||||
setBusy(true, 'Looking up sources...');
|
||||
var loading = appendLoadingMessage('Looking up sources', 'Retrieving and synthesizing references...');
|
||||
request.loading = loading;
|
||||
request.accept = function() {
|
||||
if (request.accepted || request.cancelled) return;
|
||||
request.accepted = true;
|
||||
var row = appendMessage('user', text);
|
||||
if (row && loading && loading.parentNode) loading.parentNode.insertBefore(row, loading);
|
||||
if (input) input.value = '';
|
||||
exporter.invalidate();
|
||||
updateConversationBudget();
|
||||
};
|
||||
|
||||
streamAssistantResponse({
|
||||
return streamAssistantResponse({
|
||||
message: text,
|
||||
history: messages.slice(-8),
|
||||
history: history,
|
||||
includeContext: !includeContext || includeContext.checked
|
||||
}, loading, request)
|
||||
.catch(function (err) {
|
||||
if (request.cancelled) return;
|
||||
setBusy(false, 'Error', true);
|
||||
replaceLoadingMessage(loading, 'I could not complete the assistant request. ' + err.message + '\n\nThe indexed search service may be busy or temporarily unavailable. Please try again in a moment.');
|
||||
if (loading) loading.remove(); // Errors are not invented assistant turns.
|
||||
if (err.budget) conversationChars = validConversationLimit(err.budget.limit) ? err.budget.limit : null;
|
||||
updateConversationBudget();
|
||||
if (typeof showToast === 'function') showToast(err.message, 'error');
|
||||
})
|
||||
.finally(function () {
|
||||
|
|
@ -182,8 +213,9 @@ import {
|
|||
var response = await openAssistantStream(payload, { signal: request ? request.signal : undefined });
|
||||
if (!response.ok || !response.body) {
|
||||
var fallback = await response.json().catch(function () { return {}; });
|
||||
throw new Error(fallback.error || ('Request failed (' + response.status + ')'));
|
||||
throw Object.assign(new Error(fallback.error || ('Request failed (' + response.status + ')')), { code: fallback.code, budget: fallback.budget });
|
||||
}
|
||||
if (request && request.accept) request.accept();
|
||||
|
||||
var partial = '';
|
||||
var streamSources = [];
|
||||
|
|
@ -225,7 +257,7 @@ import {
|
|||
finalData = data || {};
|
||||
return;
|
||||
}
|
||||
if (type === 'error') throw new Error(data.error || 'Assistant stream failed');
|
||||
if (type === 'error') throw Object.assign(new Error(data.error || 'Assistant stream failed'), { code: data.code, budget: data.budget });
|
||||
}
|
||||
|
||||
var reader = response.body.getReader();
|
||||
|
|
@ -345,6 +377,7 @@ import {
|
|||
var wrap = document.getElementById('assistant-messages');
|
||||
if (wrap) wrap.scrollTop = wrap.scrollHeight;
|
||||
messages.push({ role: 'assistant', content: content, sources: sources || [] });
|
||||
updateConversationBudget();
|
||||
}
|
||||
|
||||
function renderAssistantBubbleHtml(content, sources, rawHtml) {
|
||||
|
|
@ -380,6 +413,7 @@ import {
|
|||
wrap.appendChild(row);
|
||||
renderEmbeddedBlocks(bubble);
|
||||
wrap.scrollTop = wrap.scrollHeight;
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderSuggestionButtons(suggestions) {
|
||||
|
|
@ -583,7 +617,10 @@ import {
|
|||
setBusy(false, 'Ready');
|
||||
}
|
||||
|
||||
function clearConversation() {
|
||||
function clearConversation(event) {
|
||||
if (assistantBusy && !activeAssistantRequest) return;
|
||||
if (event && messages.length && !window.confirm('Start a new chat? Save or download this conversation first if you want to keep it.')) return;
|
||||
if (activeAssistantRequest) cancelAssistantSearch();
|
||||
messages = [];
|
||||
lastAnswer = '';
|
||||
lastSources = [];
|
||||
|
|
@ -596,6 +633,9 @@ import {
|
|||
}
|
||||
renderSources([]);
|
||||
clearGeneratedImage();
|
||||
document.getElementById('assistant-handoff-panel').hidden = true;
|
||||
document.getElementById('assistant-handoff-text').value = '';
|
||||
updateConversationBudget();
|
||||
loadSavedChats();
|
||||
}
|
||||
|
||||
|
|
@ -605,9 +645,7 @@ import {
|
|||
request.abort();
|
||||
activeAssistantRequest = null;
|
||||
setBusy(false, 'Ready');
|
||||
if (request.loading && request.loading.parentNode) {
|
||||
replaceLoadingMessage(request.loading, 'Search cancelled.');
|
||||
}
|
||||
if (request.loading) request.loading.remove();
|
||||
}
|
||||
|
||||
function renderEmptyState() {
|
||||
|
|
@ -640,6 +678,7 @@ import {
|
|||
var el = document.getElementById('assistant-input');
|
||||
if (el) {
|
||||
el.value = btn.getAttribute('data-assistant-example') || '';
|
||||
updateConversationBudget();
|
||||
el.focus();
|
||||
}
|
||||
});
|
||||
|
|
@ -665,11 +704,12 @@ import {
|
|||
}
|
||||
|
||||
function saveCurrentChat() {
|
||||
if (!messages.length || !lastAnswer) { if (typeof showToast === 'function') showToast('No assistant chat to save yet', 'error'); return; }
|
||||
if (assistantBusy) return;
|
||||
if (!messages.length) { if (typeof showToast === 'function') showToast('No assistant chat to save yet', 'error'); return; }
|
||||
var titleEl = document.getElementById('assistant-save-title');
|
||||
var title = String(titleEl && titleEl.value || deriveChatTitle()).trim() || deriveChatTitle();
|
||||
setBusy(true, 'Saving chat...');
|
||||
saveAssistantChat({
|
||||
return saveAssistantChat({
|
||||
title: title,
|
||||
messages: messages,
|
||||
sources: lastSources,
|
||||
|
|
@ -690,7 +730,8 @@ import {
|
|||
}
|
||||
|
||||
function showSavePanel() {
|
||||
if (!messages.length || !lastAnswer) { if (typeof showToast === 'function') showToast('No assistant chat to save yet', 'error'); return; }
|
||||
if (assistantBusy) return;
|
||||
if (!messages.length) { if (typeof showToast === 'function') showToast('No assistant chat to save yet', 'error'); return; }
|
||||
var panel = document.getElementById('assistant-save-panel');
|
||||
var titleEl = document.getElementById('assistant-save-title');
|
||||
if (!panel || !titleEl) return saveCurrentChat();
|
||||
|
|
@ -730,13 +771,16 @@ import {
|
|||
}
|
||||
|
||||
function loadSavedChat(id) {
|
||||
fetchSavedAssistantChat(id)
|
||||
if (assistantBusy) return;
|
||||
setBusy(true, 'Loading chat...');
|
||||
return fetchSavedAssistantChat(id)
|
||||
.then(function (data) {
|
||||
if (!data.success) throw new Error(data.error || 'Load failed');
|
||||
restoreSavedChat(data.chat && data.chat.payload || {});
|
||||
if (typeof showToast === 'function') showToast('Loaded saved chat', 'success');
|
||||
})
|
||||
.catch(function (err) { if (typeof showToast === 'function') showToast(err.message, 'error'); });
|
||||
.catch(function (err) { if (typeof showToast === 'function') showToast(err.message, 'error'); })
|
||||
.finally(function() { setBusy(false, 'Ready'); });
|
||||
}
|
||||
|
||||
function deleteSavedChat(id) {
|
||||
|
|
@ -773,6 +817,10 @@ import {
|
|||
renderSources(lastSources);
|
||||
var out = document.getElementById('assistant-visual-output');
|
||||
if (out) out.innerHTML = lastGeneratedImageSrc ? imageStore.renderGeneratedImage(lastGeneratedImageSrc, 'Generated clinical visual') : '';
|
||||
exporter.invalidate();
|
||||
document.getElementById('assistant-handoff-panel').hidden = true;
|
||||
document.getElementById('assistant-handoff-text').value = '';
|
||||
updateConversationBudget();
|
||||
}
|
||||
|
||||
function appendMessageNode(role, content, sources) {
|
||||
|
|
@ -797,9 +845,69 @@ import {
|
|||
return String(first && first.content || 'Clinical assistant chat').replace(/\s+/g, ' ').trim().slice(0, 80);
|
||||
}
|
||||
|
||||
function conversationSize(question) {
|
||||
return messages.reduce(function(total, message) { return total + message.content.length; }, String(question || '').length);
|
||||
}
|
||||
|
||||
function validConversationLimit(limit) {
|
||||
return Number.isInteger(limit) && limit >= 1000 && limit <= 1000000;
|
||||
}
|
||||
|
||||
function updateConversationBudget() {
|
||||
var input = document.getElementById('assistant-input');
|
||||
var used = conversationSize(input ? input.value : '');
|
||||
var label = document.getElementById('assistant-context-budget');
|
||||
if (label) label.textContent = used.toLocaleString() + (conversationChars === null ?
|
||||
' conversation characters (UTF-16 code units; includes your draft). Limit unavailable; the server must validate each request.' :
|
||||
' / ' + conversationChars.toLocaleString() + ' conversation characters (UTF-16 code units; includes your draft).');
|
||||
var warning = document.getElementById('assistant-context-warning');
|
||||
if (warning) {
|
||||
warning.hidden = conversationChars === null || used * 10 < conversationChars * 9;
|
||||
warning.textContent = (used > conversationChars ? 'Conversation limit exceeded. Sending is blocked.' :
|
||||
used === conversationChars ? 'At the conversation limit. Any additional input will exceed it.' :
|
||||
'Approaching the conversation limit (90% or more used).') +
|
||||
' Nothing is truncated or automatically summarized. Save or download this chat, then choose New chat or explicitly request a handoff summary. A handoff also requires the existing history to fit the budget; your unsent draft is not included in a handoff.';
|
||||
}
|
||||
}
|
||||
|
||||
function downloadTranscript() {
|
||||
var payload = { version: 2, title: deriveChatTitle(), messages: messages, sources: lastSources,
|
||||
lastAnswer: lastAnswer, generatedImage: lastGeneratedImageSrc, savedAt: new Date().toISOString() };
|
||||
var url = URL.createObjectURL(new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }));
|
||||
var link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = 'clinical-chat.json';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
setTimeout(function() { URL.revokeObjectURL(url); }, 60000);
|
||||
}
|
||||
|
||||
function requestHandoff() {
|
||||
if (assistantBusy || !messages.length) return;
|
||||
if (conversationChars !== null && conversationSize('') > conversationChars) {
|
||||
updateConversationBudget();
|
||||
if (typeof showToast === 'function') showToast('History exceeds the handoff budget. Save/download the full chat; nothing has been changed.', 'error');
|
||||
return;
|
||||
}
|
||||
setBusy(true, 'Creating requested handoff...');
|
||||
return requestAssistantHandoff(messages.map(function(m) { return { role: m.role, content: m.content }; }))
|
||||
.then(function(data) {
|
||||
if (!data.success) {
|
||||
if (data.budget) conversationChars = validConversationLimit(data.budget.limit) ? data.budget.limit : null;
|
||||
updateConversationBudget();
|
||||
throw new Error(data.error || 'Handoff failed. Your conversation is unchanged.');
|
||||
}
|
||||
document.getElementById('assistant-handoff-text').value = data.summary || '';
|
||||
document.getElementById('assistant-handoff-panel').hidden = false;
|
||||
})
|
||||
.catch(function(error) { if (typeof showToast === 'function') showToast(error.message, 'error'); })
|
||||
.finally(function() { setBusy(false, 'Ready'); });
|
||||
}
|
||||
|
||||
function imageForSavedChatPayload(src) {
|
||||
src = String(src || '');
|
||||
return /^https?:\/\//i.test(src) ? src : '';
|
||||
return src; // Preserve inline generated images; the server validates supported image formats.
|
||||
}
|
||||
|
||||
function lastAssistantMessage(items) {
|
||||
|
|
@ -842,4 +950,4 @@ import {
|
|||
if (input) input.disabled = !!isBusy;
|
||||
}
|
||||
|
||||
function sanitize(html) { return window.DOMPurify ? window.DOMPurify.sanitize(html, { ADD_ATTR: ['target'] }) : html; }
|
||||
function sanitize(html) { return window.DOMPurify ? window.DOMPurify.sanitize(html, { ADD_ATTR: ['target'] }) : escapeHtml(html); }
|
||||
|
|
|
|||
|
|
@ -60,11 +60,11 @@
|
|||
formData.append('description', descInput ? descInput.value : '');
|
||||
|
||||
showLoading('Uploading document...');
|
||||
var headers = getAuthHeaders();
|
||||
delete headers['Content-Type']; // FormData supplies its own boundary.
|
||||
fetch('/api/documents/upload', {
|
||||
method: 'POST',
|
||||
headers: window.IS_NATIVE_APP
|
||||
? { 'Authorization': 'Bearer ' + (window.AUTH_TOKEN || (window.SecureStorage && window.SecureStorage.getSync('ped_scribe_token')) || '') }
|
||||
: {}, // web: httpOnly cookie sent automatically via credentials:'same-origin' default
|
||||
headers: headers,
|
||||
credentials: 'same-origin',
|
||||
body: formData
|
||||
})
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@
|
|||
// ============================================================
|
||||
|
||||
var LS_KEY = 'ped_ed_draft_v1';
|
||||
var boundary = window.AccountBoundary;
|
||||
|
||||
var _state = freshState();
|
||||
var _saveTimer = null;
|
||||
|
|
@ -79,8 +80,11 @@
|
|||
|
||||
// ── Persistence ──────────────────────────────────────────────────────
|
||||
function persistLocal() {
|
||||
var owner = boundary.capture();
|
||||
if (!owner) return;
|
||||
if (_saveTimer) clearTimeout(_saveTimer);
|
||||
_saveTimer = setTimeout(function() {
|
||||
if (!boundary.valid(owner)) return;
|
||||
try {
|
||||
gatherCurrentNotes();
|
||||
var snap = {
|
||||
|
|
@ -91,14 +95,14 @@
|
|||
cc: getVal('ed-cc'),
|
||||
currentTranscript: getText('ed-transcript')
|
||||
};
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(snap));
|
||||
localStorage.setItem(boundary.storageKey(LS_KEY), JSON.stringify(snap));
|
||||
} catch (e) {}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function loadLocal() {
|
||||
try {
|
||||
var raw = localStorage.getItem(LS_KEY);
|
||||
var raw = localStorage.getItem(boundary.storageKey(LS_KEY));
|
||||
if (!raw) return;
|
||||
var snap = JSON.parse(raw);
|
||||
if (!snap || !snap.state) return;
|
||||
|
|
@ -118,7 +122,8 @@
|
|||
}
|
||||
|
||||
function clearLocal() {
|
||||
try { localStorage.removeItem(LS_KEY); } catch (e) {}
|
||||
if (!boundary.active()) return;
|
||||
try { localStorage.removeItem(boundary.storageKey(LS_KEY)); } catch (e) {}
|
||||
}
|
||||
|
||||
// ── Stage rendering ──────────────────────────────────────────────────
|
||||
|
|
@ -353,6 +358,7 @@
|
|||
|
||||
// ── Generate (per-stage) ─────────────────────────────────────────────
|
||||
function generateStage() {
|
||||
if (!boundary.active()) return;
|
||||
if (_state.finalized) { showToast('Encounter is finalized — start a new one', 'error'); return; }
|
||||
var cc = getVal('ed-cc');
|
||||
if (!cc.trim()) { showToast('Enter a chief complaint first', 'error'); return; }
|
||||
|
|
@ -389,6 +395,7 @@
|
|||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!boundary.active()) return;
|
||||
hideBusy();
|
||||
if (!data.success) { showToast(data.error || 'Generation failed', 'error'); return; }
|
||||
|
||||
|
|
@ -435,6 +442,7 @@
|
|||
|
||||
// ── Finalize → consolidate + MDM + persist as final ──────────────────
|
||||
function finalize() {
|
||||
if (!boundary.active()) return;
|
||||
if (_state.finalized) { showToast('Already finalized', 'info'); return; }
|
||||
if (_state.stages.length === 0) { showToast('Generate at least one stage first', 'error'); return; }
|
||||
var label = getVal('ed-label');
|
||||
|
|
@ -462,7 +470,7 @@
|
|||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (_state !== encounterState) return;
|
||||
if (!boundary.active() || _state !== encounterState) return;
|
||||
if (!data.success) { hideBusy(); showToast(data.error || 'Finalize failed', 'error'); return; }
|
||||
_state.finalNote = data.finalNote || '';
|
||||
_state.mdm = data.mdm;
|
||||
|
|
@ -487,8 +495,9 @@
|
|||
partial_data: partial,
|
||||
status: 'final',
|
||||
onSaved: function(id) {
|
||||
if (!boundary.active()) return;
|
||||
window._savedEncId_ed = id;
|
||||
try { sessionStorage.setItem('_savedEncId_ed', id); } catch(e) {}
|
||||
try { sessionStorage.setItem(boundary.storageKey('_savedEncId_ed'), id); } catch(e) {}
|
||||
clearLocal();
|
||||
hideBusy();
|
||||
showToast('Encounter finalized and saved.', 'success');
|
||||
|
|
@ -499,7 +508,7 @@
|
|||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
if (_state !== encounterState) return;
|
||||
if (!boundary.active() || _state !== encounterState) return;
|
||||
hideBusy(); showToast(err.message, 'error');
|
||||
});
|
||||
}
|
||||
|
|
@ -519,6 +528,7 @@
|
|||
|
||||
// ── Save draft (manual / auto on stage transition) ──────────────────
|
||||
function autoSaveDraft() {
|
||||
if (!boundary.active()) return;
|
||||
var label = getVal('ed-label');
|
||||
if (!label.trim()) return;
|
||||
if (typeof saveEncounter !== 'function') return;
|
||||
|
|
@ -535,8 +545,9 @@
|
|||
partial_data: partial,
|
||||
status: 'draft',
|
||||
onSaved: function(id) {
|
||||
if (!boundary.active()) return;
|
||||
window._savedEncId_ed = id;
|
||||
try { sessionStorage.setItem('_savedEncId_ed', id); } catch(e) {}
|
||||
try { sessionStorage.setItem(boundary.storageKey('_savedEncId_ed'), id); } catch(e) {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -564,7 +575,7 @@
|
|||
hideEl('ed-tail-controls');
|
||||
hideEl('ed-mdm-card');
|
||||
window._savedEncId_ed = null;
|
||||
try { sessionStorage.removeItem('_savedEncId_ed'); } catch(e) {}
|
||||
try { sessionStorage.removeItem(boundary.storageKey('_savedEncId_ed')); } catch(e) {}
|
||||
if (typeof window.resetIdempotencyKey === 'function') window.resetIdempotencyKey('ed');
|
||||
clearLocal();
|
||||
updateBadge();
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ function setupEncountersModule() {
|
|||
|
||||
// ── Saved Encounters Manager ───────────────────────────────────────────
|
||||
|
||||
var boundary = window.AccountBoundary;
|
||||
var _savedEncounters = [];
|
||||
var _loadCallback = null; // set by each module to handle loading a saved encounter
|
||||
var _savingInProgress = {}; // prevent duplicate saves on double-click
|
||||
|
|
@ -77,10 +78,10 @@ function setupEncountersModule() {
|
|||
function getIdempotencyKey(type) {
|
||||
var key = '_idempKey_' + type;
|
||||
if (!window[key]) {
|
||||
try { window[key] = sessionStorage.getItem(key); } catch(e) {}
|
||||
try { window[key] = sessionStorage.getItem(boundary.storageKey(key)); } catch(e) {}
|
||||
if (!window[key]) {
|
||||
window[key] = generateUUID();
|
||||
try { sessionStorage.setItem(key, window[key]); } catch(e) {}
|
||||
try { sessionStorage.setItem(boundary.storageKey(key), window[key]); } catch(e) {}
|
||||
}
|
||||
}
|
||||
return window[key];
|
||||
|
|
@ -90,7 +91,7 @@ function setupEncountersModule() {
|
|||
function resetIdempotencyKey(type) {
|
||||
var key = '_idempKey_' + type;
|
||||
window[key] = null;
|
||||
try { sessionStorage.removeItem(key); } catch(e) {}
|
||||
try { sessionStorage.removeItem(boundary.storageKey(key)); } catch(e) {}
|
||||
}
|
||||
|
||||
window.resetIdempotencyKey = resetIdempotencyKey;
|
||||
|
|
@ -99,12 +100,13 @@ function setupEncountersModule() {
|
|||
function restoreSavedEncounterIds() {
|
||||
['encounter','dictation','ed','hospital','chart','wellvisit','sickvisit','soap'].forEach(function(t) {
|
||||
try {
|
||||
var id = sessionStorage.getItem('_savedEncId_' + t);
|
||||
var id = sessionStorage.getItem(boundary.storageKey('_savedEncId_' + t));
|
||||
if (id) window['_savedEncId_' + t] = id;
|
||||
} catch(e) {}
|
||||
});
|
||||
}
|
||||
restoreSavedEncounterIds();
|
||||
window.addEventListener('account-ready', restoreSavedEncounterIds);
|
||||
if (boundary.active()) restoreSavedEncounterIds();
|
||||
|
||||
// Register a load handler for a specific tab type
|
||||
window.registerEncounterLoadHandler = function(type, fn) {
|
||||
|
|
@ -114,6 +116,8 @@ function setupEncountersModule() {
|
|||
|
||||
// Save current encounter state
|
||||
window.saveEncounter = function(opts) {
|
||||
var owner = boundary.capture();
|
||||
if (!owner) return;
|
||||
// opts: { id, label, enc_type, transcript, generated_note, partial_data, onSaved }
|
||||
if (!opts.label || !opts.label.trim()) { showToast('Enter a patient label first', 'error'); return; }
|
||||
var type = opts.enc_type || 'encounter';
|
||||
|
|
@ -136,7 +140,7 @@ function setupEncountersModule() {
|
|||
.then(function(r) { return r.json().then(function(d) { d._status = r.status; return d; }); })
|
||||
.then(function(data) {
|
||||
// A completed save must not attach the previous patient's ID after New Patient.
|
||||
if (getIdempotencyKey(type) !== identityKey) return;
|
||||
if (!boundary.valid(owner) || getIdempotencyKey(type) !== identityKey) return;
|
||||
_savingInProgress[type] = false;
|
||||
if (data._status === 409) {
|
||||
showToast('Someone else edited this encounter. Reload to see the latest version.', 'error');
|
||||
|
|
@ -152,7 +156,7 @@ function setupEncountersModule() {
|
|||
// Persist ID so refreshing the page won't create a duplicate
|
||||
if (data.id) {
|
||||
if (opts.onSaved) opts.onSaved(data.id);
|
||||
try { sessionStorage.setItem('_savedEncId_' + type, data.id); } catch(e) {}
|
||||
try { sessionStorage.setItem(boundary.storageKey('_savedEncId_' + type), data.id); } catch(e) {}
|
||||
}
|
||||
loadSavedEncountersList();
|
||||
} else {
|
||||
|
|
@ -160,7 +164,7 @@ function setupEncountersModule() {
|
|||
}
|
||||
})
|
||||
.catch(function() {
|
||||
if (getIdempotencyKey(type) !== identityKey) return;
|
||||
if (!boundary.valid(owner) || getIdempotencyKey(type) !== identityKey) return;
|
||||
_savingInProgress[type] = false;
|
||||
showToast('Save failed', 'error');
|
||||
});
|
||||
|
|
@ -507,7 +511,7 @@ function setupEncountersModule() {
|
|||
if (genderEl) genderEl.value = '';
|
||||
// Reset saved ID and idempotency key (memory + sessionStorage)
|
||||
window['_savedEncId_' + type] = null;
|
||||
try { sessionStorage.removeItem('_savedEncId_' + type); } catch(e) {}
|
||||
try { sessionStorage.removeItem(boundary.storageKey('_savedEncId_' + type)); } catch(e) {}
|
||||
resetIdempotencyKey(type);
|
||||
// Chart review has additional fields/cards to clear
|
||||
if (type === 'chart' && typeof window.resetChartReview === 'function') {
|
||||
|
|
|
|||
|
|
@ -497,10 +497,11 @@ import { createWebdavController } from './learningHub/webdavController.js';
|
|||
showBusy('AI is generating content...');
|
||||
|
||||
// FormData: no Content-Type header (browser sets it with boundary)
|
||||
var token = window.AUTH_TOKEN || localStorage.getItem('ped_scribe_token') || '';
|
||||
var headers = getAuthHeaders();
|
||||
delete headers['Content-Type'];
|
||||
fetch('/api/admin/learning/ai-generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + token },
|
||||
headers: headers,
|
||||
body: formData
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { esc, formatWhen, sanitizeHtml } from './notes/utils.js';
|
|||
// layout's data-view attribute controls which one. Desktop
|
||||
// always shows the sidebar + the right pane together.
|
||||
// ============================================================
|
||||
var boundary = window.AccountBoundary;
|
||||
var _inited = false;
|
||||
var _notes = []; // active notes (deleted_at IS NULL)
|
||||
var _trash = []; // trashed notes (deleted_at IS NOT NULL)
|
||||
|
|
@ -49,6 +50,14 @@ import { esc, formatWhen, sanitizeHtml } from './notes/utils.js';
|
|||
scheduleAutosave: scheduleAutosave
|
||||
});
|
||||
|
||||
window.addEventListener('account-boundary', function() {
|
||||
_dirty = false;
|
||||
_pendingAfterInflight = false;
|
||||
clearTimeout(_autosaveTimer);
|
||||
_autosaveTimer = null;
|
||||
_recorder.stop(true);
|
||||
});
|
||||
|
||||
// Wait for the Notes tab to be activated — the component HTML is
|
||||
// lazy-loaded, so elements don't exist until the user clicks the tab.
|
||||
document.addEventListener('tabChanged', function(e) {
|
||||
|
|
@ -165,13 +174,13 @@ import { esc, formatWhen, sanitizeHtml } from './notes/utils.js';
|
|||
// with keepalive:true is the right primitive here (works for both POST
|
||||
// for new notes and PUT for existing).
|
||||
window.addEventListener('beforeunload', function() {
|
||||
if (!_dirty) return;
|
||||
if (!boundary.active() || !_dirty) return;
|
||||
var title = getTitle();
|
||||
if (!title) return;
|
||||
var body = getBody();
|
||||
var isNew = _activeId == null;
|
||||
try {
|
||||
NotesApi.saveNoteKeepalive(isNew ? null : _activeId, { title: title, body: body });
|
||||
NotesApi.saveNoteKeepalive(isNew ? null : _activeId, { title: title, body: body }).catch(function() {});
|
||||
} catch (e) {}
|
||||
});
|
||||
|
||||
|
|
@ -365,6 +374,7 @@ import { esc, formatWhen, sanitizeHtml } from './notes/utils.js';
|
|||
|
||||
// ── Save + autosave ──────────────────────────────────────
|
||||
function scheduleAutosave() {
|
||||
if (!boundary.active()) return;
|
||||
if (_autosaveTimer) clearTimeout(_autosaveTimer);
|
||||
_autosaveTimer = setTimeout(function() {
|
||||
_autosaveTimer = null;
|
||||
|
|
@ -382,6 +392,7 @@ import { esc, formatWhen, sanitizeHtml } from './notes/utils.js';
|
|||
}
|
||||
|
||||
function saveNote(opts) {
|
||||
if (!boundary.active()) return;
|
||||
opts = opts || {};
|
||||
var title = getTitle();
|
||||
var body = getBody();
|
||||
|
|
@ -410,6 +421,7 @@ import { esc, formatWhen, sanitizeHtml } from './notes/utils.js';
|
|||
|
||||
NotesApi.saveNote(isNew ? null : _activeId, { title: title, body: body })
|
||||
.then(function(data) {
|
||||
if (!boundary.active()) return;
|
||||
_autosaveInFlight = false;
|
||||
if (!data.success) { updateStatus(data.error || 'Save failed', 'err'); return; }
|
||||
if (isNew && data.id) _activeId = data.id;
|
||||
|
|
@ -475,6 +487,7 @@ import { esc, formatWhen, sanitizeHtml } from './notes/utils.js';
|
|||
}
|
||||
|
||||
function applyGeneratedNote(title, body) {
|
||||
if (!boundary.active()) return;
|
||||
var titleEl = $('note-title');
|
||||
var container = $('note-body-editor');
|
||||
if (!titleEl || !container) {
|
||||
|
|
|
|||
|
|
@ -1,16 +1,18 @@
|
|||
export function createNotesRecorder(options) {
|
||||
options = options || {};
|
||||
var boundary = window.AccountBoundary;
|
||||
var recorder = null;
|
||||
var recTimer = null;
|
||||
var recPaused = false;
|
||||
var recActive = false;
|
||||
|
||||
function start() {
|
||||
if (recActive) return;
|
||||
if (!boundary.active() || recActive) return;
|
||||
if (typeof options.ensureEditor === 'function') options.ensureEditor();
|
||||
if (typeof AudioRecorder === 'undefined') { updateStatus('Recorder unavailable', 'err'); return; }
|
||||
recorder = new AudioRecorder();
|
||||
recorder.start().then(function() {
|
||||
if (!boundary.active()) return;
|
||||
recActive = true; recPaused = false;
|
||||
setUI('recording');
|
||||
startTimer();
|
||||
|
|
@ -56,9 +58,11 @@ export function createNotesRecorder(options) {
|
|||
updateStatus('Transcribing...', 'saving');
|
||||
|
||||
recorder.stop().then(function(blob) {
|
||||
if (!boundary.active()) return;
|
||||
if (!blob || blob.size === 0) { updateStatus('Nothing recorded', 'err'); setUI('idle'); return; }
|
||||
if (typeof transcribeAudio !== 'function') { updateStatus('Transcription unavailable', 'err'); setUI('idle'); return; }
|
||||
return transcribeAudio(blob).then(function(resp) {
|
||||
if (!boundary.active()) return;
|
||||
if (!resp || !resp.success || !resp.text) {
|
||||
var msg = (resp && (resp.error || (resp.noProvider ? 'No STT provider configured' : null))) || 'Transcription failed';
|
||||
updateStatus(msg, 'err'); setUI('idle'); return;
|
||||
|
|
@ -68,6 +72,7 @@ export function createNotesRecorder(options) {
|
|||
var selectedModel = modelEl ? modelEl.value : '';
|
||||
return options.noteFromVoice({ transcript: resp.text, model: selectedModel || undefined })
|
||||
.then(function(data) {
|
||||
if (!boundary.active()) return;
|
||||
setUI('idle');
|
||||
if (!data.success) { updateStatus(data.error || 'Generation failed', 'err'); return; }
|
||||
if (typeof options.applyGeneratedNote === 'function') options.applyGeneratedNote(data.title || 'Voice note', data.body || '');
|
||||
|
|
|
|||
|
|
@ -31,13 +31,15 @@ window.SecureStorage = {
|
|||
try { return Promise.resolve(localStorage.getItem(key)); } catch(e) { return Promise.resolve(null); }
|
||||
},
|
||||
|
||||
set: function(key, value) {
|
||||
memCache[key] = value;
|
||||
if (isNative() && plugin()) {
|
||||
return plugin().set({ key: key, value: value }).catch(function() {});
|
||||
set: async function(key, value) {
|
||||
if (isNative()) {
|
||||
var p = plugin();
|
||||
if (!p) throw new Error('Secure storage unavailable');
|
||||
await p.set({ key: key, value: value });
|
||||
} else {
|
||||
localStorage.setItem(key, value);
|
||||
}
|
||||
try { localStorage.setItem(key, value); } catch(e) {}
|
||||
return Promise.resolve();
|
||||
memCache[key] = value;
|
||||
},
|
||||
|
||||
remove: function(key) {
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ import { applyWellVisitScheduleGlobals, loadWellVisitScheduleData } from './well
|
|||
var SSHADESS_VISITS = ['12y','13y','14y','15y','16y','17y','18y','19y','20y','21y'];
|
||||
|
||||
function saveStatusesToStorage() {
|
||||
try { localStorage.setItem('ped_visit_statuses', JSON.stringify(_visitStatuses)); } catch(e) {}
|
||||
try { localStorage.setItem(window.AccountBoundary.storageKey('ped_visit_statuses'), JSON.stringify(_visitStatuses)); } catch(e) {}
|
||||
}
|
||||
|
||||
function clearCurrentVisit() {
|
||||
|
|
@ -117,7 +117,7 @@ import { applyWellVisitScheduleGlobals, loadWellVisitScheduleData } from './well
|
|||
function init() {
|
||||
// Restore statuses from localStorage
|
||||
try {
|
||||
var saved = localStorage.getItem('ped_visit_statuses');
|
||||
var saved = localStorage.getItem(window.AccountBoundary.storageKey('ped_visit_statuses'));
|
||||
if (saved) _visitStatuses = JSON.parse(saved) || {};
|
||||
} catch(e) { _visitStatuses = {}; }
|
||||
|
||||
|
|
@ -204,7 +204,7 @@ import { applyWellVisitScheduleGlobals, loadWellVisitScheduleData } from './well
|
|||
var sel = document.getElementById('wv-visit-select');
|
||||
var selectedOption = sel ? sel.options[sel.selectedIndex] : null;
|
||||
window._wellVisitAge = selectedOption ? selectedOption.textContent : visitId;
|
||||
try { sessionStorage.setItem('ped_visit_age', window._wellVisitAge); } catch(e) {}
|
||||
try { sessionStorage.setItem(window.AccountBoundary.storageKey('ped_visit_age'), window._wellVisitAge); } catch(e) {}
|
||||
renderVisitPanel(visitId);
|
||||
// Show SSHADESS subtab only for age 12+ visits
|
||||
var shadessBtn = document.querySelector('.wv-subtab-btn[data-subtab="shadess"]');
|
||||
|
|
|
|||
16
scripts/build-image.sh
Executable file
16
scripts/build-image.sh
Executable file
|
|
@ -0,0 +1,16 @@
|
|||
#!/bin/sh
|
||||
# Build only; this never starts services. COMPOSE_FILE selects an alternate Compose file.
|
||||
set -eu
|
||||
cd "$(dirname "$0")/.."
|
||||
if [ -e .git ]; then
|
||||
GIT_REVISION=$(env -i PATH="$PATH" HOME="${HOME:-}" git rev-parse --verify 'HEAD^{commit}')
|
||||
printf '%s\n' "$GIT_REVISION" | grep -Eq '^[0-9a-f]{40}$' || {
|
||||
echo 'Expected a full lowercase Git SHA' >&2
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
GIT_REVISION=unknown
|
||||
echo 'Unversioned development build: revision unknown' >&2
|
||||
fi
|
||||
export GIT_REVISION
|
||||
exec docker compose build "$@" pediatric-scribe
|
||||
|
|
@ -7,14 +7,14 @@
|
|||
# scripts/release.sh 6.1.1 --push # also git push + tag push
|
||||
#
|
||||
# What it does:
|
||||
# 1. Updates version in root package.json
|
||||
# 1. Updates version in root package.json and package-lock.json (requires Node)
|
||||
# 2. Updates version in mobile/package.json
|
||||
# 3. Updates versionName + bumps versionCode in Android build.gradle
|
||||
# 4. Commits the version bump
|
||||
# 5. (optional) git push + push the new tag
|
||||
#
|
||||
# It does NOT:
|
||||
# - Build the Docker image (run `docker compose build`/`up -d` yourself
|
||||
# - Build the Docker image (run `./scripts/build-image.sh`/`docker compose up -d --no-build` yourself
|
||||
# or wire it to a deploy script / CI hook)
|
||||
# - Build the Android APK itself. Forgejo CI does that
|
||||
# (.forgejo/workflows/android-apk.yml): any branch push builds a signed
|
||||
|
|
@ -56,19 +56,23 @@ fi
|
|||
|
||||
echo "==> Bumping to v$VERSION"
|
||||
|
||||
# Bump a package.json "version" field without needing node/jq.
|
||||
# Only touches the first top-level "version": "…" line, which is the
|
||||
# canonical location npm puts it. Fragile only if someone hand-wrote
|
||||
# a nested "version" ABOVE the top-level one.
|
||||
bump_pkg_version() {
|
||||
local file="$1" new="$2"
|
||||
sed -i -E "0,/(\"version\"[[:space:]]*:[[:space:]]*\")[^\"]+(\")/ s//\1${new}\2/" "$file"
|
||||
# Parse every manifest before writing; change metadata only, never resolve dependencies.
|
||||
# Replace the lock atomically so its two version fields cannot be partially updated.
|
||||
node - "$VERSION" <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const version = process.argv[2];
|
||||
const files = ['package.json', 'package-lock.json', 'mobile/package.json'];
|
||||
const updates = files.map(file => [file, JSON.parse(fs.readFileSync(file, 'utf8'))]);
|
||||
for (const [file, data] of updates) {
|
||||
data.version = version;
|
||||
if (file === 'package-lock.json') data.packages[''].version = version;
|
||||
}
|
||||
|
||||
bump_pkg_version package.json "$VERSION"
|
||||
echo " updated package.json"
|
||||
bump_pkg_version mobile/package.json "$VERSION"
|
||||
echo " updated mobile/package.json"
|
||||
for (const [file, data] of updates) {
|
||||
fs.writeFileSync(file + '.tmp', JSON.stringify(data, null, 2) + '\n');
|
||||
fs.renameSync(file + '.tmp', file);
|
||||
}
|
||||
NODE
|
||||
echo " updated package.json, package-lock.json and mobile/package.json"
|
||||
|
||||
# Android build.gradle
|
||||
# versionCode: encode X.Y.Z as X*100000 + Y*1000 + Z (room for 999 patches)
|
||||
|
|
@ -81,7 +85,7 @@ sed -i -E \
|
|||
echo " updated mobile/android/app/build.gradle (code=$ANDROID_CODE, name=$VERSION)"
|
||||
|
||||
# Commit
|
||||
git add package.json mobile/package.json mobile/android/app/build.gradle
|
||||
git add package.json package-lock.json mobile/package.json mobile/android/app/build.gradle
|
||||
git commit -m "Release v${VERSION}"
|
||||
echo " committed"
|
||||
|
||||
|
|
|
|||
51
server.js
51
server.js
|
|
@ -140,28 +140,10 @@ app.get('/.well-known/assetlinks.json', (req, res) => {
|
|||
// ============================================================
|
||||
// CACHE-BUSTING VERSION STAMP
|
||||
// ============================================================
|
||||
// Compute a per-boot BUILD_ID (short hex). Inject it as ?v=BUILD_ID
|
||||
// on every local /js/*.js and /css/*.css reference in index.html so
|
||||
// browsers always fetch fresh JS/CSS after a deploy instead of
|
||||
// serving from the 1-hour cache.
|
||||
// Use the full source revision for asset cache busting and /api/build.
|
||||
// Unversioned development builds are explicitly "unknown", never random SHAs.
|
||||
var fs = require('fs');
|
||||
var crypto = require('crypto');
|
||||
var BUILD_ID = crypto.randomBytes(4).toString('hex');
|
||||
try {
|
||||
var gitHead = fs.readFileSync(path.join(__dirname, '.git/HEAD'), 'utf8').trim();
|
||||
if (gitHead.indexOf('ref:') === 0) {
|
||||
var refPath = gitHead.split(' ')[1];
|
||||
BUILD_ID = fs.readFileSync(path.join(__dirname, '.git', refPath), 'utf8').trim().slice(0, 7);
|
||||
} else {
|
||||
BUILD_ID = gitHead.slice(0, 7);
|
||||
}
|
||||
} catch (e) {
|
||||
// Non-git environment (Docker image) — use /app/BUILD_ID file if present,
|
||||
// otherwise stick with the random-on-boot value generated above.
|
||||
try {
|
||||
BUILD_ID = fs.readFileSync(path.join(__dirname, 'BUILD_ID'), 'utf8').trim() || BUILD_ID;
|
||||
} catch (_) {}
|
||||
}
|
||||
var BUILD_ID = require('./src/utils/buildId').getBuildId(__dirname);
|
||||
console.log('🔖 Build ID:', BUILD_ID);
|
||||
|
||||
// Template index.html on each request if the file changed (mtime-based).
|
||||
|
|
@ -203,6 +185,11 @@ app.get('/api/build', function(req, res) { res.json({ buildId: BUILD_ID }); });
|
|||
app.get('/metrics', metricsHandler);
|
||||
|
||||
app.use(loggingMiddleware);
|
||||
app.use('/vendor/dompurify', express.static(path.join(__dirname, 'node_modules', 'dompurify', 'dist'), {
|
||||
setHeaders: function(res) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||
}
|
||||
}));
|
||||
app.use('/vendor/markdown-it', express.static(path.join(__dirname, 'node_modules', 'markdown-it', 'dist'), {
|
||||
setHeaders: function(res) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||
|
|
@ -247,9 +234,11 @@ app.use('/api/auth', require('./src/routes/oidc'));
|
|||
// Learning Hub CMS — must come BEFORE general /api/admin to avoid adminMiddleware conflict
|
||||
// (moderators need access to /api/admin/learning but not other /api/admin routes)
|
||||
app.use('/api/admin/learning', require('./src/routes/learningAdmin'));
|
||||
app.use('/api/admin/learning', require('./src/routes/learningAI'));
|
||||
|
||||
app.use('/api/admin', require('./src/routes/admin'));
|
||||
// Config exposes only its authenticated announcement before its own admin guard.
|
||||
app.use('/api/admin', require('./src/routes/adminConfig'));
|
||||
app.use('/api/admin', require('./src/routes/admin'));
|
||||
app.use('/api/admin', require('./src/routes/adminMilestones'));
|
||||
app.use('/api/admin/docs', require('./src/routes/adminDocs'));
|
||||
|
||||
|
|
@ -257,16 +246,13 @@ app.use('/api/admin/docs', require('./src/routes/adminDocs'));
|
|||
const { getAvailableModels, activeProvider: modelsProvider } = require('./src/utils/models');
|
||||
app.get('/api/models', async (req, res) => {
|
||||
try {
|
||||
var { getAvailableModelsWithOverrides, DEFAULT_MODEL } = require('./src/utils/models');
|
||||
var { getAvailableModelsWithOverrides, getEffectiveDefaultModel } = require('./src/utils/models');
|
||||
var db = require('./src/db/database');
|
||||
var models = await getAvailableModelsWithOverrides(db);
|
||||
var defaultOverride = await db.getSetting('models.default');
|
||||
if (defaultOverride && !models.find(function(m) { return m.id === defaultOverride; })) {
|
||||
models.push({ id: defaultOverride, name: defaultOverride + ' (saved default)', tag: 'SAVED' });
|
||||
}
|
||||
res.json({ models: models, provider: modelsProvider, defaultModel: defaultOverride || DEFAULT_MODEL });
|
||||
var defaultModel = await getEffectiveDefaultModel(db, models);
|
||||
res.json({ models: models, provider: modelsProvider, defaultModel: defaultModel });
|
||||
} catch(e) {
|
||||
res.json({ models: getAvailableModels(), provider: modelsProvider });
|
||||
res.status(503).json({ models: [], provider: modelsProvider, defaultModel: '', error: 'Model policy unavailable' });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -322,13 +308,12 @@ app.use('/api', require('./src/routes/edEncounters'));
|
|||
app.use('/api', require('./src/routes/dontMiss'));
|
||||
app.use('/api', require('./src/routes/patientEducation'));
|
||||
app.use('/api/user', require('./src/routes/userPreferences'));
|
||||
app.use('/api/admin/learning', require('./src/routes/learningAI'));
|
||||
|
||||
// User-level preference: save WebDAV learning path (auth only, not moderator-only)
|
||||
(function() {
|
||||
var { authMiddleware } = require('./src/middleware/auth');
|
||||
var db = require('./src/db/database');
|
||||
app.post('/api/user/webdav-path', authMiddleware, async function(req, res) {
|
||||
app.post('/api/user/webdav-path', authMiddleware, require('./src/utils/policy').requireFeature('nextcloud'), async function(req, res) {
|
||||
try {
|
||||
await db.run('UPDATE users SET webdav_learning_path = ? WHERE id = ?', [req.body.path || null, req.user.id]);
|
||||
res.json({ success: true });
|
||||
|
|
@ -396,6 +381,10 @@ function shutdown(signal) {
|
|||
console.log('[shutdown] DB pool drained.');
|
||||
}
|
||||
} catch (e) {}
|
||||
// Shared corpus client belongs to the app, not to individual user logouts.
|
||||
// Data drainage above takes priority; the existing hard deadline still applies.
|
||||
try { await require('./src/utils/clinicalMcpClient').closeMcpSession(); }
|
||||
catch (e) { console.error('[shutdown] MCP cleanup incomplete'); }
|
||||
process.exit(0);
|
||||
});
|
||||
// Hard deadline — Docker sends SIGKILL after 10s by default, so beat it
|
||||
|
|
|
|||
|
|
@ -157,7 +157,7 @@ router.delete('/users/:id', async function(req, res) {
|
|||
// ============================================================
|
||||
// RESET USER PASSWORD (admin override)
|
||||
// ============================================================
|
||||
router.post('/users/:id/reset-password', async function(req, res) {
|
||||
router.post('/users/:id/reset-password', require('../utils/policy').requireLocalAuth, async function(req, res) {
|
||||
try {
|
||||
var { newPassword } = req.body;
|
||||
if (!newPassword || newPassword.length < 8) return res.status(400).json({ error: 'Password must be 8+ characters' });
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ var router = express.Router();
|
|||
var db = require('../db/database');
|
||||
var { authMiddleware, adminMiddleware } = require('../middleware/auth');
|
||||
var PROMPTS = require('../utils/prompts');
|
||||
var promptCatalog = require('../utils/promptCatalog');
|
||||
var promptRevisions = require('../utils/promptRevisions');
|
||||
var { conversationBudget } = require('../utils/clinicalConversation');
|
||||
var logger = require('../utils/logger');
|
||||
var { gatewayUrl } = require('../utils/errors');
|
||||
var { getTTSEnvProvider, getLiteLLMTTSDiscoveryItems, getLiteLLMTTSRequestOptions, getLiteLLMTTSVoicesForModel, isLiteLLMTTSVoiceCompatible, getTTSProvider } = require('../utils/ttsProvider');
|
||||
|
|
@ -74,6 +77,7 @@ router.use(adminMiddleware);
|
|||
// ── GET all config entries ─────────────────────────────────────────────────
|
||||
router.get('/config', async function(req, res) {
|
||||
try {
|
||||
var budget = conversationBudget(process.env);
|
||||
var rows = await db.all(
|
||||
"SELECT key, value, updated_at FROM app_settings ORDER BY key",
|
||||
[]
|
||||
|
|
@ -87,8 +91,8 @@ router.get('/config', async function(req, res) {
|
|||
rows.push({ key: dbKey, value: PROMPTS[key], updated_at: null });
|
||||
}
|
||||
});
|
||||
res.json({ success: true, config: rows });
|
||||
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
|
||||
res.json({ success: true, config: rows, conversationBudget: budget });
|
||||
} catch (e) { res.status(e.statusCode || 500).json({ error: 'Request failed' }); }
|
||||
});
|
||||
|
||||
// ── Clinical assistant starter prompt pool ─────────────────────────────────
|
||||
|
|
@ -171,40 +175,42 @@ router.post('/config/test-email', async function(req, res) {
|
|||
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
|
||||
});
|
||||
|
||||
// ── GET prompt list (for editor UI) ───────────────────────────────────────
|
||||
// ── Global prompt catalogue and immutable revision history ─────────────────
|
||||
router.get('/config/prompts', async function(req, res) {
|
||||
try {
|
||||
var prompts = PROMPTS.getAllPrompts();
|
||||
// Load any DB overrides
|
||||
var dbRows = await db.all("SELECT key, value FROM app_settings WHERE key LIKE 'prompt.%'", []);
|
||||
var dbMap = {};
|
||||
dbRows.forEach(function(r) { dbMap[r.key] = r.value; });
|
||||
prompts.forEach(function(p) {
|
||||
var dbKey = 'prompt.' + p.key;
|
||||
if (dbMap[dbKey]) p.value = dbMap[dbKey];
|
||||
p.dbKey = dbKey;
|
||||
});
|
||||
res.json({ success: true, prompts: prompts });
|
||||
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
|
||||
res.json({ success: true, prompts: await promptRevisions.list(db) });
|
||||
} catch (e) { promptRevisions.respondError(res, e); }
|
||||
});
|
||||
|
||||
// ── POST reset a prompt to hardcoded default ──────────────────────────────
|
||||
router.post('/config/prompts/:key/reset', async function(req, res) {
|
||||
router.get('/config/prompts/:key/history', async function(req, res) {
|
||||
try {
|
||||
var key = req.params.key;
|
||||
if (!Object.prototype.hasOwnProperty.call(PROMPTS, key) || typeof PROMPTS[key] !== 'string') {
|
||||
return res.status(404).json({ error: 'Prompt not found' });
|
||||
}
|
||||
// Delete DB override so hardcoded default is used
|
||||
await db.run("DELETE FROM app_settings WHERE key = ?", ['prompt.' + key]);
|
||||
// Reload from hardcoded source (re-require)
|
||||
delete require.cache[require.resolve('../utils/prompts')];
|
||||
var fresh = require('../utils/prompts');
|
||||
PROMPTS[key] = fresh[key] || PROMPTS[key];
|
||||
res.json(Object.assign({ success: true }, await promptRevisions.history(db, req.params.key, req.query.limit)));
|
||||
} catch (e) { promptRevisions.respondError(res, e); }
|
||||
});
|
||||
|
||||
logger.audit(req.user.id, 'admin_config_reset', 'Reset prompt to default: ' + key, req, { category: 'admin' });
|
||||
res.json({ success: true, value: PROMPTS[key] });
|
||||
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
|
||||
router.get('/config/prompts/:key/revisions/:id', async function(req, res) {
|
||||
try {
|
||||
res.json({ success: true, revision: await promptRevisions.read(db, req.params.key, req.params.id) });
|
||||
} catch (e) { promptRevisions.respondError(res, e); }
|
||||
});
|
||||
|
||||
async function changePrompt(req, res, action, key) {
|
||||
try {
|
||||
var result = await promptRevisions.mutate(db, key, {
|
||||
action: action, value: req.body.value, expectedRevision: req.body.expectedRevision,
|
||||
revisionId: req.body.revisionId, actor: req.user.id
|
||||
});
|
||||
logger.audit(req.user.id, 'admin_prompt_' + action, 'Updated global prompt: ' + key, req, { category: 'admin' });
|
||||
res.json(Object.assign({ success: true }, result));
|
||||
} catch (e) { promptRevisions.respondError(res, e); }
|
||||
}
|
||||
|
||||
router.post('/config/prompts/:key/reset', function(req, res) {
|
||||
return changePrompt(req, res, 'reset', req.params.key);
|
||||
});
|
||||
|
||||
router.post('/config/prompts/:key/restore', function(req, res) {
|
||||
return changePrompt(req, res, 'restore', req.params.key);
|
||||
});
|
||||
|
||||
// ── POST reset all non-prompt settings to defaults ──────────────────────
|
||||
|
|
@ -307,10 +313,13 @@ router.get('/config/models', async function(req, res) {
|
|||
|
||||
var disabledRaw = await db.getSetting('models.disabled') || '[]';
|
||||
var customRaw = await db.getSetting('models.custom') || '[]';
|
||||
var defaultModel = await db.getSetting('models.default') || '';
|
||||
var defaultModel = await require('../utils/models').getEffectiveDefaultModel(db);
|
||||
var disabled, custom;
|
||||
try { disabled = JSON.parse(disabledRaw); } catch(e) { disabled = []; }
|
||||
try { custom = JSON.parse(customRaw); } catch(e) { custom = []; }
|
||||
disabled = JSON.parse(disabledRaw);
|
||||
if (!Array.isArray(disabled)) throw new Error('Invalid disabled model settings');
|
||||
custom = JSON.parse(customRaw);
|
||||
if (!Array.isArray(custom)) throw new Error('Invalid custom model settings');
|
||||
custom = custom.map(function(m) { return Object.assign({}, m, { enabled: !disabled.includes(m.id) }); });
|
||||
|
||||
var models = providerModels.map(function(m) {
|
||||
return Object.assign({}, m, { enabled: !disabled.includes(m.id) });
|
||||
|
|
@ -331,11 +340,15 @@ router.get('/config/models', async function(req, res) {
|
|||
router.put('/config/models/toggle', async function(req, res) {
|
||||
try {
|
||||
var { modelId, enabled } = req.body;
|
||||
if (!modelId) return res.status(400).json({ error: 'modelId required' });
|
||||
if (typeof modelId !== 'string' || !modelId.trim() || typeof enabled !== 'boolean') return res.status(400).json({ error: 'modelId and boolean enabled required' });
|
||||
var modelPolicy = require('../utils/models');
|
||||
var roster = modelPolicy.getAvailableModels().concat(JSON.parse(await db.getSetting('models.custom') || '[]'));
|
||||
if (!roster.some(function(m) { return m.id === modelId; })) return res.status(400).json({ error: 'Unknown model' });
|
||||
|
||||
var disabledRaw = await db.getSetting('models.disabled') || '[]';
|
||||
var disabled;
|
||||
try { disabled = JSON.parse(disabledRaw); } catch(e) { disabled = []; }
|
||||
disabled = JSON.parse(disabledRaw);
|
||||
if (!Array.isArray(disabled)) throw new Error('Invalid disabled model settings');
|
||||
|
||||
if (enabled) {
|
||||
disabled = disabled.filter(function(id) { return id !== modelId; });
|
||||
|
|
@ -344,7 +357,7 @@ router.put('/config/models/toggle', async function(req, res) {
|
|||
}
|
||||
|
||||
await db.setSetting('models.disabled', JSON.stringify(disabled));
|
||||
require('../utils/models').invalidateAllowedModelsCache();
|
||||
await require('../utils/models').reconcileDefaultModel(db);
|
||||
logger.audit(req.user.id, 'admin_model_toggle', (enabled ? 'Enabled' : 'Disabled') + ' model: ' + modelId, req, { category: 'admin' });
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
|
||||
|
|
@ -354,9 +367,10 @@ router.put('/config/models/toggle', async function(req, res) {
|
|||
router.put('/config/models/default', async function(req, res) {
|
||||
try {
|
||||
var { modelId } = req.body;
|
||||
if (!modelId) return res.status(400).json({ error: 'modelId required' });
|
||||
if (typeof modelId !== 'string' || !modelId.trim()) return res.status(400).json({ error: 'modelId required' });
|
||||
var models = await require('../utils/models').getAvailableModelsWithOverrides(db);
|
||||
if (!models.some(function(m) { return m.id === modelId.trim(); })) return res.status(400).json({ error: 'Default model must be enabled' });
|
||||
await db.setSetting('models.default', modelId.trim());
|
||||
require('../utils/models').invalidateAllowedModelsCache();
|
||||
logger.audit(req.user.id, 'admin_model_default', 'Set default model: ' + modelId, req, { category: 'admin' });
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
|
||||
|
|
@ -386,14 +400,14 @@ router.post('/config/models/custom', async function(req, res) {
|
|||
|
||||
var customRaw = await db.getSetting('models.custom') || '[]';
|
||||
var custom;
|
||||
try { custom = JSON.parse(customRaw); } catch(e) { custom = []; }
|
||||
custom = JSON.parse(customRaw);
|
||||
if (!Array.isArray(custom)) throw new Error('Invalid custom model settings');
|
||||
|
||||
var existing = custom.find(function(m) { return m.id === trimmedId; });
|
||||
custom = custom.filter(function(m) { return m.id !== trimmedId; });
|
||||
custom.push({ id: trimmedId, name: name.trim().substring(0, 100), cost: (cost || '?').substring(0, 20), category: cat, tag: 'CUSTOM' });
|
||||
|
||||
await db.setSetting('models.custom', JSON.stringify(custom));
|
||||
require('../utils/models').invalidateAllowedModelsCache();
|
||||
logger.audit(req.user.id, existing ? 'admin_model_update' : 'admin_model_add', (existing ? 'Updated' : 'Added') + ' custom model: ' + trimmedId, req, { category: 'admin' });
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
|
||||
|
|
@ -405,10 +419,11 @@ router.delete('/config/models/custom/:modelId(*)', async function(req, res) {
|
|||
var modelId = req.params.modelId;
|
||||
var customRaw = await db.getSetting('models.custom') || '[]';
|
||||
var custom;
|
||||
try { custom = JSON.parse(customRaw); } catch(e) { custom = []; }
|
||||
custom = JSON.parse(customRaw);
|
||||
if (!Array.isArray(custom)) throw new Error('Invalid custom model settings');
|
||||
custom = custom.filter(function(m) { return m.id !== modelId; });
|
||||
await db.setSetting('models.custom', JSON.stringify(custom));
|
||||
require('../utils/models').invalidateAllowedModelsCache();
|
||||
await require('../utils/models').reconcileDefaultModel(db);
|
||||
logger.audit(req.user.id, 'admin_model_delete', 'Removed custom model: ' + modelId, req, { category: 'admin' });
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
|
||||
|
|
@ -420,7 +435,6 @@ router.post('/config/models/clear-all', async function(req, res) {
|
|||
await db.setSetting('models.custom', '[]');
|
||||
await db.setSetting('models.disabled', '[]');
|
||||
await db.setSetting('models.default', '');
|
||||
require('../utils/models').invalidateAllowedModelsCache();
|
||||
logger.audit(req.user.id, 'admin_models_clear_all', 'Cleared all custom models and disabled list', req, { category: 'admin' });
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
|
||||
|
|
@ -455,13 +469,13 @@ router.post('/config/models/add-discovered', async function(req, res) {
|
|||
|
||||
var customRaw = await db.getSetting('models.custom') || '[]';
|
||||
var custom;
|
||||
try { custom = JSON.parse(customRaw); } catch(e) { custom = []; }
|
||||
custom = JSON.parse(customRaw);
|
||||
if (!Array.isArray(custom)) throw new Error('Invalid custom model settings');
|
||||
|
||||
custom = custom.filter(function(m) { return m.id !== trimmedId; });
|
||||
custom.push({ id: trimmedId, name: name.trim().substring(0, 100), cost: (cost || '?').substring(0, 20), category: cat, tag: 'DISCOVERED' });
|
||||
|
||||
await db.setSetting('models.custom', JSON.stringify(custom));
|
||||
require('../utils/models').invalidateAllowedModelsCache();
|
||||
logger.audit(req.user.id, 'admin_model_discover_add', 'Added discovered model: ' + trimmedId, req, { category: 'admin' });
|
||||
res.json({ success: true, id: trimmedId });
|
||||
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
|
||||
|
|
@ -724,8 +738,11 @@ router.post('/config/stt/test', async function(req, res) {
|
|||
form.append('model', sttModel);
|
||||
var sttResp = await fetch(gatewayUrl('/audio/transcriptions'), {
|
||||
method: 'POST', headers: getLiteLLMHeaders(), body: form
|
||||
}).then(function(r) { return r.json().then(function(d) { return { data: d }; }); });
|
||||
text = sttResp.data && sttResp.data.text ? sttResp.data.text : '';
|
||||
});
|
||||
if (!sttResp.ok) throw new Error('LiteLLM transcription failed (HTTP ' + sttResp.status + ')');
|
||||
var sttData = await sttResp.json();
|
||||
if (!sttData || typeof sttData.text !== 'string') throw new Error('Invalid transcription response');
|
||||
text = sttData.text;
|
||||
|
||||
res.json({ success: true, text: text.trim(), provider: provider, duration: Date.now() - start });
|
||||
} catch (e) {
|
||||
|
|
@ -837,13 +854,18 @@ router.put('/config/:key(*)', async function(req, res) {
|
|||
return res.status(400).json({ error: 'Unknown config key' });
|
||||
}
|
||||
|
||||
await db.setSetting(key, String(value));
|
||||
|
||||
// Update in-memory prompt immediately if it's a prompt key
|
||||
if (key.startsWith('prompt.')) {
|
||||
var promptKey = key.replace('prompt.', '');
|
||||
PROMPTS.updatePrompt(promptKey, String(value));
|
||||
// Model policy mutations must use the validated model endpoints.
|
||||
if (key.startsWith('models.')) return res.status(400).json({ error: 'Use the model configuration endpoints' });
|
||||
if (key.startsWith('feature.') && !['true', 'false'].includes(String(value))) return res.status(400).json({ error: 'Feature value must be true or false' });
|
||||
if (key === 'clinical_assistant.conversation_chars') {
|
||||
return res.status(400).json({ error: 'Conversation budget is controlled by CLINICAL_ASSISTANT_CONVERSATION_CHARS, not saved settings' });
|
||||
}
|
||||
if (key.startsWith('prompt.') || promptCatalog.find(key)) {
|
||||
if (!promptCatalog.find(key)) return res.status(400).json({ error: 'Unknown prompt key' });
|
||||
return changePrompt(req, res, 'save', key);
|
||||
}
|
||||
|
||||
await db.setSetting(key, String(value));
|
||||
|
||||
logger.audit(req.user.id, 'admin_config_update', 'Updated config: ' + key, req, { category: 'admin' });
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ const { JWT_SECRET, authMiddleware } = require('../middleware/auth');
|
|||
const { hashToken, parseUserAgent, generateSessionId } = require('../utils/sessions');
|
||||
const { notifyNewLogin, notifyPasswordChanged, notifyNewRegistration } = require('../utils/notify');
|
||||
var logger = require('../utils/logger');
|
||||
const { requireLocalAuth, isSSOOnly } = require('../utils/policy');
|
||||
|
||||
// Check password against Have I Been Pwned (k-anonymity — only first 5 chars of SHA-1 sent)
|
||||
async function checkPwnedPassword(password) {
|
||||
|
|
@ -174,7 +175,7 @@ async function sendEmail(to, subject, html) {
|
|||
// ============================================================
|
||||
// REGISTER (checks if registration is enabled)
|
||||
// ============================================================
|
||||
router.post('/register', async (req, res) => {
|
||||
router.post('/register', requireLocalAuth, async (req, res) => {
|
||||
try {
|
||||
var regEnabled = await db.getSetting('registration_enabled');
|
||||
if (regEnabled === 'false') {
|
||||
|
|
@ -238,10 +239,8 @@ router.post('/register', async (req, res) => {
|
|||
await db.run('UPDATE users SET email_verified = true, verify_token = NULL WHERE id = ?', [userId]);
|
||||
var token = signAuthToken(userId, req);
|
||||
var regSessionId = generateSessionId();
|
||||
try {
|
||||
await db.run('INSERT INTO user_sessions (id, user_id, token_hash, ip_address, user_agent, device_label) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[regSessionId, userId, hashToken(token), req.ip, req.headers['user-agent'] || '', parseUserAgent(req.headers['user-agent'])]);
|
||||
} catch (e) { /* table may not exist yet */ }
|
||||
await db.run('INSERT INTO user_sessions (id, user_id, token_hash, ip_address, user_agent, device_label) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[regSessionId, userId, hashToken(token), req.ip, req.headers['user-agent'] || '', parseUserAgent(req.headers['user-agent'])]);
|
||||
setAuthCookie(res, token);
|
||||
return res.json({
|
||||
success: true, token: token, sessionId: regSessionId,
|
||||
|
|
@ -297,7 +296,7 @@ router.post('/resend-verification', async (req, res) => {
|
|||
// ============================================================
|
||||
// LOGIN (checks disabled status)
|
||||
// ============================================================
|
||||
router.post('/login', async (req, res) => {
|
||||
router.post('/login', requireLocalAuth, async (req, res) => {
|
||||
try {
|
||||
var { email, password, totpCode } = req.body;
|
||||
if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
|
||||
|
|
@ -371,10 +370,8 @@ router.post('/login', async (req, res) => {
|
|||
|
||||
// Create session record
|
||||
var sessionId = generateSessionId();
|
||||
try {
|
||||
await db.run('INSERT INTO user_sessions (id, user_id, token_hash, ip_address, user_agent, device_label) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[sessionId, user.id, hashToken(token), req.ip, req.headers['user-agent'] || '', parseUserAgent(req.headers['user-agent'])]);
|
||||
} catch (e) { /* table may not exist yet */ }
|
||||
await db.run('INSERT INTO user_sessions (id, user_id, token_hash, ip_address, user_agent, device_label) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[sessionId, user.id, hashToken(token), req.ip, req.headers['user-agent'] || '', parseUserAgent(req.headers['user-agent'])]);
|
||||
|
||||
// Notify user of new login (fire-and-forget)
|
||||
notifyNewLogin(user.id, parseUserAgent(req.headers['user-agent']), req.ip);
|
||||
|
|
@ -457,7 +454,7 @@ async function tryConsumeBackupCode(userId, submitted) {
|
|||
}
|
||||
|
||||
// Generate (or regenerate) backup codes. Requires current password to authorise.
|
||||
router.post('/2fa/backup-codes', authMiddleware, async (req, res) => {
|
||||
router.post('/2fa/backup-codes', authMiddleware, requireLocalAuth, async (req, res) => {
|
||||
try {
|
||||
var { password } = req.body;
|
||||
if (!password) return res.status(400).json({ error: 'Current password required' });
|
||||
|
|
@ -489,7 +486,7 @@ router.get('/2fa/backup-codes/count', authMiddleware, async (req, res) => {
|
|||
});
|
||||
|
||||
// 2FA
|
||||
router.post('/setup-2fa', authMiddleware, async (req, res) => {
|
||||
router.post('/setup-2fa', authMiddleware, requireLocalAuth, async (req, res) => {
|
||||
try {
|
||||
// SSO-only accounts never go through the local password flow, so TOTP
|
||||
// would sit dormant. Reject up front with a clear message.
|
||||
|
|
@ -504,7 +501,7 @@ router.post('/setup-2fa', authMiddleware, async (req, res) => {
|
|||
} catch (err) { console.error('[Auth] 2FA setup error:', err.message); res.status(500).json({ error: '2FA setup failed' }); }
|
||||
});
|
||||
|
||||
router.post('/verify-2fa', authMiddleware, async (req, res) => {
|
||||
router.post('/verify-2fa', authMiddleware, requireLocalAuth, async (req, res) => {
|
||||
try {
|
||||
var user = await db.get('SELECT totp_secret, totp_enabled FROM users WHERE id = ?', [req.user.id]);
|
||||
var verified = speakeasy.totp.verify({ secret: user.totp_secret, encoding: 'base32', token: req.body.code, window: 1 });
|
||||
|
|
@ -541,7 +538,7 @@ router.post('/disable-2fa', authMiddleware, async (req, res) => {
|
|||
// time (no SMTP RTT on hit, no extra latency on miss), closing the
|
||||
// user-enumeration oracle that previously let an attacker distinguish
|
||||
// registered emails by response time.
|
||||
router.post('/forgot-password', async (req, res) => {
|
||||
router.post('/forgot-password', requireLocalAuth, async (req, res) => {
|
||||
try {
|
||||
// Cloudflare Turnstile verification (runs for every request regardless
|
||||
// of whether the account exists, so timing is equal)
|
||||
|
|
@ -587,7 +584,7 @@ router.post('/forgot-password', async (req, res) => {
|
|||
} catch (err) { console.error('[Auth] Forgot password error:', err.message); res.status(500).json({ error: 'Password reset request failed' }); }
|
||||
});
|
||||
|
||||
router.post('/reset-password', async (req, res) => {
|
||||
router.post('/reset-password', requireLocalAuth, async (req, res) => {
|
||||
try {
|
||||
var { token, newPassword } = req.body;
|
||||
if (!token || !newPassword || newPassword.length < 8) return res.status(400).json({ error: 'Valid token and 8+ char password required' });
|
||||
|
|
@ -627,7 +624,7 @@ function hasLocalPassword(hash) {
|
|||
}
|
||||
|
||||
// Change password (requires current password)
|
||||
router.post('/change-password', authMiddleware, async (req, res) => {
|
||||
router.post('/change-password', authMiddleware, requireLocalAuth, async (req, res) => {
|
||||
try {
|
||||
var { currentPassword, newPassword } = req.body;
|
||||
if (!currentPassword || !newPassword) return res.status(400).json({ error: 'Current and new password required' });
|
||||
|
|
@ -673,7 +670,7 @@ router.get('/me', authMiddleware, async (req, res) => {
|
|||
var canLocalAuth = !!(user && user.password && (/^\$2[aby]\$/.test(user.password) || user.password.indexOf('$argon2') === 0));
|
||||
// Don't leak the password hash in the response.
|
||||
if (user) delete user.password;
|
||||
if (user) user.canLocalAuth = canLocalAuth;
|
||||
if (user) user.canLocalAuth = canLocalAuth && !await isSSOOnly();
|
||||
res.json({ user: user });
|
||||
} catch (err) { console.error('[Auth] Me error:', err.message); res.status(500).json({ error: 'Failed to load user' }); }
|
||||
});
|
||||
|
|
@ -682,8 +679,8 @@ router.get('/me', authMiddleware, async (req, res) => {
|
|||
router.get('/registration-status', async (req, res) => {
|
||||
try {
|
||||
var enabled = await db.getSetting('registration_enabled');
|
||||
res.json({ registrationEnabled: enabled !== 'false' });
|
||||
} catch (err) { res.json({ registrationEnabled: true }); }
|
||||
res.json({ registrationEnabled: enabled !== 'false' && !await isSSOOnly() });
|
||||
} catch (err) { res.json({ registrationEnabled: false }); }
|
||||
});
|
||||
|
||||
// Expose helpers for adminConfig test-email and email template loading
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ var {
|
|||
warmMcpSession
|
||||
} = require('../utils/clinicalMcpClient');
|
||||
var {
|
||||
cleanSourceExcerpt,
|
||||
normalizeMcpSearchResponse,
|
||||
normalizeMcpMultimodalResponse,
|
||||
dedupeSources,
|
||||
|
|
@ -40,12 +41,14 @@ var {
|
|||
finalizeAssistantAnswer
|
||||
} = require('../utils/clinicalAnswer');
|
||||
|
||||
var { conversationBudget, checkConversation, savedChatPayload } = require('../utils/clinicalConversation');
|
||||
|
||||
var { DEFAULT_BEHAVIOR, DEFAULT_IMAGE_BEHAVIOR, imagePromptForCanvas } = require('../utils/clinicalPrompts');
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
var DEFAULT_BEHAVIOR = 'You are a concise pediatric clinical assistant. Use retrieved context only for factual claims. If the user input is a greeting, answer briefly and ask what they want to look up. Synthesize across sources and cite factual claims with the exact provided source numbers like [1]. Do not invent, renumber, merge, or move citations.';
|
||||
var GREETING_RE = /^(hi|hello|hey|yo|good\s+(morning|afternoon|evening)|thanks|thank you|ok|okay|sup)[\s.!?]*$/i;
|
||||
var MAX_SAVED_CHATS_PER_USER = 100;
|
||||
var MAX_SAVED_CHAT_PAYLOAD = 250000;
|
||||
var MAX_SAVED_CHAT_TITLE = 160;
|
||||
var IMAGE_JOB_TTL_SECONDS = 15 * 60;
|
||||
var imageJobs = new Map();
|
||||
|
|
@ -86,6 +89,7 @@ router.get('/clinical-assistant/status', async function(req, res) {
|
|||
var imageModel = await getSetting('clinical_assistant.image_model', '');
|
||||
var searchLimit = clampInt(await getSetting('clinical_assistant.search_limit', '8'), 3, 20, 8);
|
||||
var contextChars = clampInt(await getSetting('clinical_assistant.context_chars', '1400'), 300, 4000, 1400);
|
||||
var budget = conversationBudget(process.env);
|
||||
var mcpHealth = await getMcpHealth();
|
||||
res.json({
|
||||
success: true,
|
||||
|
|
@ -93,10 +97,15 @@ router.get('/clinical-assistant/status', async function(req, res) {
|
|||
imageModel: imageModel,
|
||||
searchLimit: searchLimit,
|
||||
contextChars: contextChars,
|
||||
conversationChars: budget.limit,
|
||||
conversationUnit: budget.unit,
|
||||
conversationEnv: budget.env,
|
||||
conversationSource: budget.source,
|
||||
conversationMeasure: budget.measure,
|
||||
mcp: mcpHealth
|
||||
});
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: 'Request failed' });
|
||||
res.status(e.statusCode || 500).json({ error: 'Request failed' });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -145,9 +154,8 @@ router.get('/clinical-assistant/chats/:id', async function(req, res) {
|
|||
router.post('/clinical-assistant/chats', async function(req, res) {
|
||||
try {
|
||||
var title = cleanSavedChatTitle(req.body.title || firstUserMessage(req.body.messages) || 'Clinical assistant chat');
|
||||
var payload = buildSavedChatPayload(req.body);
|
||||
var payload = savedChatPayload(req.body);
|
||||
var payloadText = JSON.stringify(payload);
|
||||
if (payloadText.length > MAX_SAVED_CHAT_PAYLOAD) return res.status(400).json({ error: 'Saved chat is too large' });
|
||||
|
||||
var count = await db.get('SELECT COUNT(*) as cnt FROM clinical_assistant_chats WHERE user_id = $1', [req.user.id]);
|
||||
if (count && Number(count.cnt) >= MAX_SAVED_CHATS_PER_USER) {
|
||||
|
|
@ -161,8 +169,8 @@ router.post('/clinical-assistant/chats', async function(req, res) {
|
|||
logger.audit(req.user.id, 'clinical_assistant_chat_save', 'Saved clinical assistant chat', req, { category: 'clinical' });
|
||||
res.json({ success: true, id: result.lastInsertRowid, title: title });
|
||||
} catch (e) {
|
||||
logger.error('POST /clinical-assistant/chats', e.message);
|
||||
res.status(500).json({ error: 'Request failed' });
|
||||
if (!e.statusCode || e.statusCode >= 500) logger.error('POST /clinical-assistant/chats', e.message);
|
||||
res.status(e.statusCode || 500).json({ error: e.statusCode ? e.message : 'Request failed', code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -212,7 +220,7 @@ router.post('/clinical-assistant/chat', async function(req, res) {
|
|||
});
|
||||
} catch (e) {
|
||||
console.error('[clinical-assistant]', e.message, e.stack || '');
|
||||
res.status(500).json({ error: assistantErrorMessage(e) });
|
||||
res.status(e.statusCode || 500).json({ error: assistantErrorMessage(e), code: e.code, budget: e.budget });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -226,14 +234,15 @@ router.post('/clinical-assistant/chat/stream', async function(req, res) {
|
|||
}
|
||||
|
||||
try {
|
||||
// Validate before opening SSE, rewriting a query or making any paid call.
|
||||
var prepared = await prepareAssistantChat(req.body);
|
||||
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
if (typeof res.flushHeaders === 'function') res.flushHeaders();
|
||||
streamOpen = true;
|
||||
sendEvent('status', { message: 'Looking up sources...' });
|
||||
sendEvent('status', { message: 'Sources checked; preparing answer...' });
|
||||
|
||||
var prepared = await prepareAssistantChat(req.body);
|
||||
if (prepared.direct) {
|
||||
sendEvent('done', Object.assign({ duration: Date.now() - started }, prepared.direct));
|
||||
return res.end();
|
||||
|
|
@ -277,12 +286,33 @@ router.post('/clinical-assistant/chat/stream', async function(req, res) {
|
|||
res.end();
|
||||
} catch (e) {
|
||||
console.error('[clinical-assistant stream]', e.message, e.stack || '');
|
||||
if (!streamOpen) return res.status(e.statusCode || 500).json({ error: assistantErrorMessage(e) });
|
||||
sendEvent('error', { error: assistantErrorMessage(e) });
|
||||
if (!streamOpen) return res.status(e.statusCode || 500).json({ error: assistantErrorMessage(e), code: e.code, budget: e.budget });
|
||||
sendEvent('error', { error: assistantErrorMessage(e), code: e.code, budget: e.budget });
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/clinical-assistant/handoff', async function(req, res) {
|
||||
try {
|
||||
var checked = checkConversation(req.body.history, '', await getConversationLimit(), true);
|
||||
if (!checked.history.length) return res.status(400).json({ error: 'There is no conversation to summarize.' });
|
||||
var model = await getSetting('clinical_assistant.chat_model', '') || await getSetting('models.default', '');
|
||||
var ai = await callAI([
|
||||
{ role: 'system', content: 'Create a concise handoff summary only because the user explicitly requested it. Preserve user-reported clinical facts, age, units, medication names/doses, corrections, contradictions and uncertainty. Distinguish user facts from prior assistant suggestions; prior AI output is not evidence. Do not add facts or clinical recommendations. Include unresolved questions. Label this as conversation context, not a verified clinical source. Do not silently resolve contradictions. This does not start or replace a chat.' },
|
||||
{ role: 'user', content: checked.history.map(function(m) { return m.role.toUpperCase() + ': ' + m.content; }).join('\n\n') }
|
||||
], assistantGenerationOptions({ model: model || undefined, temperature: 0, maxTokens: 1200 }));
|
||||
if (!ai || !String(ai.content || '').trim()) throw new Error('No handoff summary was returned. Your conversation is unchanged.');
|
||||
if (ai.finishReason !== 'stop') {
|
||||
var incomplete = new Error('The handoff summary did not report a complete response and was not accepted. Your conversation is unchanged; download the full transcript instead.');
|
||||
incomplete.statusCode = 503;
|
||||
throw incomplete;
|
||||
}
|
||||
res.json({ success: true, summary: ai.content, model: ai.model || model, budget: checked.budget });
|
||||
} catch (e) {
|
||||
res.status(e.statusCode || 500).json({ error: assistantErrorMessage(e), code: e.code, budget: e.budget });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/clinical-assistant/image', async function(req, res) {
|
||||
try {
|
||||
var prompt = String(req.body.prompt || '').trim();
|
||||
|
|
@ -358,17 +388,9 @@ router.get('/clinical-assistant/image/jobs/:id/download', async function(req, re
|
|||
|
||||
async function prepareAssistantChat(body) {
|
||||
body = body || {};
|
||||
var message = String(body.message || '').trim();
|
||||
if (!message) {
|
||||
var emptyErr = new Error('Question is required');
|
||||
emptyErr.statusCode = 400;
|
||||
throw emptyErr;
|
||||
}
|
||||
if (message.length > 4000) {
|
||||
var longErr = new Error('Question too long');
|
||||
longErr.statusCode = 400;
|
||||
throw longErr;
|
||||
}
|
||||
var checked = checkConversation(body.history, body.message, await getConversationLimit());
|
||||
var message = checked.message;
|
||||
var history = checked.history;
|
||||
|
||||
var chatModel = await getSetting('clinical_assistant.chat_model', '') || await getSetting('models.default', '');
|
||||
var searchLimit = clampInt(await getSetting('clinical_assistant.search_limit', '8'), 3, 20, 8);
|
||||
|
|
@ -386,8 +408,6 @@ async function prepareAssistantChat(body) {
|
|||
} };
|
||||
}
|
||||
|
||||
var history = Array.isArray(body.history) ? body.history.slice(-8) : [];
|
||||
|
||||
var searchQuery = await rewriteSearchQuery(message, history, chatModel).catch(function(e) {
|
||||
console.warn('[clinical-assistant] query rewrite skipped:', e.message);
|
||||
return message;
|
||||
|
|
@ -410,8 +430,7 @@ async function prepareAssistantChat(body) {
|
|||
var rawResults = rawTextResults.concat(rawMultimodalResults);
|
||||
console.info('[clinical-assistant] retrieval counts:', {
|
||||
text: rawTextResults.length,
|
||||
multimodal: rawMultimodalResults.length,
|
||||
query: searchQuery
|
||||
multimodal: rawMultimodalResults.length
|
||||
});
|
||||
var visualSlots = rawMultimodalResults.length ? Math.min(2, Math.max(1, Math.floor(searchLimit / 4))) : 0;
|
||||
var textSlots = searchLimit - visualSlots;
|
||||
|
|
@ -453,17 +472,10 @@ async function prepareAssistantChat(body) {
|
|||
|
||||
async function rewriteSearchQuery(message, history, chatModel) {
|
||||
message = String(message || '').trim();
|
||||
history = Array.isArray(history) ? history.filter(function(m) { return m && (m.role === 'user' || m.role === 'assistant') && m.content; }).slice(-8) : [];
|
||||
if (history.length && history[history.length - 1].role === 'user' && String(history[history.length - 1].content || '').trim() === message) {
|
||||
history = history.slice(0, -1);
|
||||
}
|
||||
if (!history.length || !needsContextualRewrite(message)) return message;
|
||||
|
||||
var deterministic = deterministicFollowupQuery(message, history);
|
||||
if (deterministic) return deterministic;
|
||||
|
||||
var hist = history.map(function(m) {
|
||||
return m.role.toUpperCase() + ': ' + String(m.content).substring(0, 900);
|
||||
return m.role.toUpperCase() + ': ' + m.content;
|
||||
}).join('\n');
|
||||
var ai = await callAI([
|
||||
{
|
||||
|
|
@ -492,28 +504,6 @@ function needsContextualRewrite(message) {
|
|||
return words.length <= 8 || /\b(it|this|that|they|them|he|she|dose|dosing|how much|what about|next|admit|discharge|criteria|side effects?|contraindications?|monitor|monitoring)\b/i.test(text);
|
||||
}
|
||||
|
||||
function deterministicFollowupQuery(message, history) {
|
||||
var text = String(message || '').trim().toLowerCase();
|
||||
if (!/^(dose|dosing|what dose|dose\?|how much|med dose|medication dose)$/i.test(text)) return '';
|
||||
var prior = previousUserQuestion(history) || previousAssistantTopic(history);
|
||||
if (!prior) return '';
|
||||
return 'medication dosing and immediate treatment details for: ' + prior;
|
||||
}
|
||||
|
||||
function previousUserQuestion(history) {
|
||||
for (var i = history.length - 1; i >= 0; i--) {
|
||||
if (history[i] && history[i].role === 'user' && history[i].content) return clip(history[i].content, 300);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function previousAssistantTopic(history) {
|
||||
for (var i = history.length - 1; i >= 0; i--) {
|
||||
if (history[i] && history[i].role === 'assistant' && history[i].content) return clip(history[i].content, 300);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function formatSourcesForPrompt(sources) {
|
||||
return sources.map(function(s) {
|
||||
var label = s.source_type === 'multimodal_page' ? ' [visual PDF page match]' : '';
|
||||
|
|
@ -530,54 +520,6 @@ function sanitizeSourcesForClient(sources) {
|
|||
});
|
||||
}
|
||||
|
||||
function buildSavedChatPayload(body) {
|
||||
var messages = Array.isArray(body.messages) ? body.messages.slice(-80).map(function(m) {
|
||||
return {
|
||||
role: m && m.role === 'assistant' ? 'assistant' : 'user',
|
||||
content: clip(m && m.content, 12000),
|
||||
sources: Array.isArray(m && m.sources) ? m.sources.slice(0, 30).map(function(s, idx) {
|
||||
return {
|
||||
number: Number(s.number || idx + 1),
|
||||
title: clip(s.title || s.resource || 'Source', 500),
|
||||
resource: clip(s.resource || '', 500),
|
||||
page: s.page || s.page_number || s.pageNumber || null,
|
||||
source_type: clip(s.source_type || '', 80),
|
||||
doc_type: clip(s.doc_type || s.type || '', 80),
|
||||
excerpt: clip(s.excerpt || '', 1800),
|
||||
score: s.score == null ? null : Number(s.score)
|
||||
};
|
||||
}) : []
|
||||
};
|
||||
}).filter(function(m) { return m.content; }) : [];
|
||||
var sources = Array.isArray(body.sources) ? body.sources.slice(0, 30).map(function(s, idx) {
|
||||
return {
|
||||
number: Number(s.number || idx + 1),
|
||||
title: clip(s.title || s.resource || 'Source', 500),
|
||||
resource: clip(s.resource || '', 500),
|
||||
page: s.page || s.page_number || s.pageNumber || null,
|
||||
source_type: clip(s.source_type || '', 80),
|
||||
doc_type: clip(s.doc_type || s.type || '', 80),
|
||||
excerpt: clip(s.excerpt || '', 1800),
|
||||
score: s.score == null ? null : Number(s.score)
|
||||
};
|
||||
}) : [];
|
||||
return {
|
||||
version: 1,
|
||||
messages: messages,
|
||||
sources: sources,
|
||||
lastAnswer: clip(body.lastAnswer || '', 30000),
|
||||
generatedImage: safeImageForSave(body.generatedImage),
|
||||
savedAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
function safeImageForSave(image) {
|
||||
image = String(image || '');
|
||||
if (!image) return '';
|
||||
if (/^https?:\/\//i.test(image)) return image.substring(0, 5000);
|
||||
return '';
|
||||
}
|
||||
|
||||
function firstUserMessage(messages) {
|
||||
if (!Array.isArray(messages)) return '';
|
||||
for (var i = 0; i < messages.length; i++) {
|
||||
|
|
@ -676,7 +618,8 @@ function isUsefulIndexedTopicExample(item) {
|
|||
async function generateImage(prompt, model) {
|
||||
if (!process.env.LITELLM_API_BASE) throw new Error('LiteLLM is required for image generation');
|
||||
var headers = getLiteLLMHeaders('application/json');
|
||||
var renderedPrompt = imagePromptForCanvas(prompt);
|
||||
var behavior = await getSetting('clinical_assistant.image_behavior', DEFAULT_IMAGE_BEHAVIOR);
|
||||
var renderedPrompt = imagePromptForCanvas(prompt, behavior);
|
||||
var size = process.env.CLINICAL_ASSISTANT_IMAGE_SIZE || 'auto';
|
||||
var resp = await generateImageRequest(model, renderedPrompt, size, headers).catch(async function(e) {
|
||||
if (!isInvalidImageSizeError(e) || size === '1024x1024') throw e;
|
||||
|
|
@ -745,23 +688,15 @@ function generateImageRequest(model, prompt, size, headers) {
|
|||
}, { headers: headers, timeout: 120000 });
|
||||
}
|
||||
|
||||
function imagePromptForCanvas(prompt) {
|
||||
var text = String(prompt || '');
|
||||
var guidance = ' Compose as a single complete medical teaching poster. Keep every element fully inside the canvas with a 10% safe margin on all sides. Do not crop boxes, arrows, labels, legends, or body parts. Use fewer words per box, large readable type, and generous spacing.';
|
||||
if (/\b(flow\s*chart|flowchart|algorithm|pathway|timeline|vertical|stepwise|decision\s*tree|age\s*group|0-21|22-28|29-60)\b/i.test(text)) {
|
||||
guidance += ' Use a tall portrait layout with top-to-bottom flow, no more than 6-8 main nodes, and ample spacing between decision nodes.';
|
||||
}
|
||||
if (/\b(table|matrix|comparison|wide|landscape|side-by-side)\b/i.test(text)) {
|
||||
guidance += ' Use a wide landscape layout with compact columns, ample horizontal spacing, and no text near the edges.';
|
||||
}
|
||||
return text.trim() + guidance;
|
||||
}
|
||||
|
||||
function isInvalidImageSizeError(e) {
|
||||
var detail = e && e.response && e.response.data ? JSON.stringify(e.response.data) : (e && e.message ? e.message : '');
|
||||
return /invalid size|unsupported size|supported sizes/i.test(detail);
|
||||
}
|
||||
|
||||
function getConversationLimit() {
|
||||
return conversationBudget(process.env).limit;
|
||||
}
|
||||
|
||||
async function getSetting(key, fallback) {
|
||||
try {
|
||||
var val = await db.getSetting(key);
|
||||
|
|
@ -785,17 +720,6 @@ function cleanTitle(s) {
|
|||
.replace(/\s*\(z-library\.sk,\s*1lib\.sk,\s*z-lib\.sk\)\s*/ig, '')
|
||||
.trim();
|
||||
}
|
||||
function cleanSourceExcerpt(text) {
|
||||
return String(text || '')
|
||||
.replace(/^\[Page-image match\]\s*/i, '')
|
||||
.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
|
||||
.replace(/<br\s*\/?>/gi, ' ')
|
||||
.replace(/\*\*/g, '')
|
||||
.replace(/\|\s*-{2,}\s*/g, ' ')
|
||||
.replace(/\|/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
function assistantErrorMessage(e) {
|
||||
var msg = e && e.message ? e.message : String(e);
|
||||
if (/ECONNREFUSED|fetch failed|Failed to open SSE|MCP/i.test(msg)) return 'Could not reach the MCP search server. Check CLINICAL_ASSISTANT_MCP_URL or the MCP container.';
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ router.post('/ed-encounters/generate', async function (req, res) {
|
|||
context += 'PREVIOUS-STAGE NOTE (baseline to integrate on top of — do not start fresh):\n'
|
||||
+ wrapUserText('previous_note', previousNote) + '\n\n';
|
||||
}
|
||||
if (physicianMemories && physicianMemories.trim()) {
|
||||
if (physicianMemories && physicianMemories.trim() && await require('../utils/policy').isFeatureEnabled('memories')) {
|
||||
context += physicianMemories + '\n\n';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ router.post('/generate-hospital-course', authMiddleware, async (req, res) => {
|
|||
if (additionalInstructions) {
|
||||
prompt += `\n\nADDITIONAL INSTRUCTIONS FROM PHYSICIAN (operator-supplied, trusted):\n${additionalInstructions}`;
|
||||
}
|
||||
if (physicianMemories) clinicalData += '\n\n[STYLE HINTS (low priority — only apply if relevant to the current note, never copy content from these examples)]\n'
|
||||
if (physicianMemories && await require('../utils/policy').isFeatureEnabled('memories')) clinicalData += '\n\n[STYLE HINTS (low priority — only apply if relevant to the current note, never copy content from these examples)]\n'
|
||||
+ wrapUserText('style_hints', physicianMemories) + '\n[END STYLE HINTS]';
|
||||
|
||||
const result = await callAI([
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ router.post('/generate-hpi-encounter', authMiddleware, async (req, res) => {
|
|||
|
||||
var context = 'Patient: ' + (patientAge || 'Unknown') + ', ' + (patientGender || 'Unknown')
|
||||
+ '\nSetting: ' + (setting || 'outpatient') + '\n\n' + wrapUserText('transcript', transcript);
|
||||
if (physicianMemories) context += '\n\n[STYLE HINTS (low priority — only apply if relevant to the current note, never copy content from these examples)]\n'
|
||||
if (physicianMemories && await require('../utils/policy').isFeatureEnabled('memories')) context += '\n\n[STYLE HINTS (low priority — only apply if relevant to the current note, never copy content from these examples)]\n'
|
||||
+ wrapUserText('style_hints', physicianMemories) + '\n[END STYLE HINTS]';
|
||||
|
||||
const result = await callAI([
|
||||
|
|
@ -41,7 +41,7 @@ router.post('/generate-hpi-dictation', authMiddleware, async (req, res) => {
|
|||
|
||||
var context = 'Patient: ' + (patientAge || 'Unknown') + ', ' + (patientGender || 'Unknown')
|
||||
+ '\nSetting: ' + (setting || 'outpatient') + '\n\n' + wrapUserText('dictation', transcript);
|
||||
if (physicianMemories) context += '\n\n[STYLE HINTS (low priority — only apply if relevant to the current note, never copy content from these examples)]\n'
|
||||
if (physicianMemories && await require('../utils/policy').isFeatureEnabled('memories')) context += '\n\n[STYLE HINTS (low priority — only apply if relevant to the current note, never copy content from these examples)]\n'
|
||||
+ wrapUserText('style_hints', physicianMemories) + '\n[END STYLE HINTS]';
|
||||
|
||||
const result = await callAI([
|
||||
|
|
|
|||
|
|
@ -285,6 +285,7 @@ router.post('/ai-generate', upload.array('files', 10), async function(req, res)
|
|||
|
||||
// 2 — Nextcloud WebDAV path
|
||||
else if (webdavPath) {
|
||||
if (!await require('../utils/policy').isFeatureEnabled('nextcloud')) return res.status(403).json({ error: 'Feature disabled' });
|
||||
var user = await db.get(
|
||||
'SELECT nextcloud_url, nextcloud_user, nextcloud_token FROM users WHERE id = ?',
|
||||
[req.user.id]
|
||||
|
|
@ -446,7 +447,7 @@ Return ONLY the refined HTML body (same structure, no JSON wrapper, no markdown
|
|||
// ── GET /api/admin/learning/webdav-browse ────────────────────
|
||||
// Browse user's Nextcloud folder
|
||||
|
||||
router.get('/webdav-browse', async function(req, res) {
|
||||
router.get('/webdav-browse', require('../utils/policy').requireFeature('nextcloud'), async function(req, res) {
|
||||
try {
|
||||
var user = await db.get(
|
||||
'SELECT nextcloud_url, nextcloud_user, nextcloud_token, nextcloud_folder, webdav_learning_path FROM users WHERE id = ?',
|
||||
|
|
@ -532,7 +533,7 @@ router.get('/webdav-browse', async function(req, res) {
|
|||
// ── POST /api/admin/learning/webdav-path ─────────────────────
|
||||
// Save user's preferred WebDAV learning path
|
||||
|
||||
router.post('/webdav-path', async function(req, res) {
|
||||
router.post('/webdav-path', require('../utils/policy').requireFeature('nextcloud'), async function(req, res) {
|
||||
try {
|
||||
var { path: wPath } = req.body;
|
||||
await db.run('UPDATE users SET webdav_learning_path = ? WHERE id = ?', [wPath || null, req.user.id]);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ var logger = require('../utils/logger');
|
|||
var cryptoUtil = require('../utils/crypto');
|
||||
|
||||
router.use(authMiddleware);
|
||||
router.use('/memories', require('../utils/policy').requireFeature('memories'));
|
||||
|
||||
// Decrypt a row's user-facing fields. Safe against legacy plaintext rows —
|
||||
// cryptoUtil.decryptString passes through values without the "enc1:" prefix.
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ var cryptoUtil = require('../utils/crypto');
|
|||
var { serverError } = require('../utils/errors');
|
||||
var { assertSafeHttpsUrl } = require('../utils/urlSafety');
|
||||
|
||||
router.use('/nextcloud', authMiddleware, require('../utils/policy').requireFeature('nextcloud'));
|
||||
|
||||
function davRoot(baseUrl, username) {
|
||||
return baseUrl + '/remote.php/dav/files/' + encodeURIComponent(username);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -240,17 +240,8 @@ router.post('/notes/from-voice', async function (req, res) {
|
|||
'Voice dictation to convert into a personal note:\n' +
|
||||
wrapUserText('dictation', transcript);
|
||||
|
||||
// Model: prefer the client's selection (validated against the admin
|
||||
// allow-list inside callAI), fall back to the admin-configured default,
|
||||
// then to the LITELLM_DEFAULT_MODEL env var. Passing an empty string
|
||||
// to LiteLLM returns a 400, so we guard against that.
|
||||
var requested = (req.body.model || '').trim();
|
||||
var adminDefault = '';
|
||||
try { adminDefault = await db.getSetting('models.default'); } catch (e) {}
|
||||
var fallback = (adminDefault || process.env.LITELLM_DEFAULT_MODEL || '').trim();
|
||||
var model = requested || fallback;
|
||||
var opts = { maxTokens: 4000 };
|
||||
if (model) opts.model = model;
|
||||
// Shared AI policy resolves omitted selections to an enabled default.
|
||||
var opts = { maxTokens: 4000, model: req.body.model };
|
||||
|
||||
var result = await callAI([
|
||||
{ role: 'system', content: systemPrompt },
|
||||
|
|
|
|||
|
|
@ -46,21 +46,26 @@ async function assertSafeIssuer(urlStr) {
|
|||
}
|
||||
}
|
||||
|
||||
// Signed, stateless OIDC state — survives restart / scales to multiple processes.
|
||||
// Transaction lives only in an HttpOnly cookie; the URL contains an opaque challenge.
|
||||
// ponytail: one pending login per browser; keyed cookies only if parallel logins are needed.
|
||||
var transactionCookie = 'ped_oidc';
|
||||
var transactionOptions = { httpOnly: true, secure: true, sameSite: 'lax', path: '/api/auth/oidc' };
|
||||
var transactionTTL = 5 * 60 * 1000;
|
||||
function signState(payload) {
|
||||
var body = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||
var sig = crypto.createHmac('sha256', JWT_SECRET).update(body).digest('base64url');
|
||||
return body + '.' + sig;
|
||||
}
|
||||
function verifyState(token) {
|
||||
if (!token || typeof token !== 'string') return null;
|
||||
if (typeof token !== 'string' || token.length > 2048) return null;
|
||||
var parts = token.split('.');
|
||||
if (parts.length !== 2) return null;
|
||||
if (parts.length !== 2 || !/^[A-Za-z0-9_-]+$/.test(parts[0]) || !/^[A-Za-z0-9_-]{43}$/.test(parts[1])) return null;
|
||||
var expected = crypto.createHmac('sha256', JWT_SECRET).update(parts[0]).digest('base64url');
|
||||
if (!crypto.timingSafeEqual(Buffer.from(parts[1]), Buffer.from(expected))) return null;
|
||||
if (parts[1].length !== expected.length || !crypto.timingSafeEqual(Buffer.from(parts[1]), Buffer.from(expected))) return null;
|
||||
try {
|
||||
var payload = JSON.parse(Buffer.from(parts[0], 'base64url').toString('utf8'));
|
||||
if (!payload.expires || payload.expires < Date.now()) return null;
|
||||
if (!payload || !Number.isSafeInteger(payload.expires) || payload.expires <= Date.now() || payload.expires > Date.now() + transactionTTL) return null;
|
||||
if (typeof payload.s !== 'string' || !/^[a-f0-9]{48}$/.test(payload.s) || typeof payload.n !== 'string' || !/^[a-f0-9]{48}$/.test(payload.n) || typeof payload.v !== 'string' || !/^[A-Za-z0-9._~-]{43,128}$/.test(payload.v)) return null;
|
||||
return payload;
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
|
@ -84,7 +89,7 @@ router.get('/oidc-status', async function(req, res) {
|
|||
var buttonLabel = await db.getSetting('oidc.button_label');
|
||||
res.json({
|
||||
oidcEnabled: enabled === 'true',
|
||||
disableLocalAuth: disableLocal === 'true',
|
||||
disableLocalAuth: enabled === 'true' && disableLocal === 'true',
|
||||
buttonLabel: buttonLabel || 'Sign in with SSO'
|
||||
});
|
||||
} catch (e) {
|
||||
|
|
@ -119,12 +124,13 @@ router.get('/oidc', async function(req, res) {
|
|||
var codeVerifier = oidc.randomPKCECodeVerifier();
|
||||
var codeChallenge = await oidc.calculatePKCECodeChallenge(codeVerifier);
|
||||
|
||||
// Stateless signed state — survives restart, works across multiple processes.
|
||||
var state = signState({
|
||||
var state = crypto.randomBytes(24).toString('hex');
|
||||
res.cookie(transactionCookie, signState({
|
||||
s: state,
|
||||
n: nonce,
|
||||
v: codeVerifier,
|
||||
expires: Date.now() + 5 * 60 * 1000
|
||||
});
|
||||
expires: Date.now() + transactionTTL
|
||||
}), Object.assign({ maxAge: transactionTTL }, transactionOptions));
|
||||
|
||||
var authUrl = oidc.buildAuthorizationUrl(config, {
|
||||
redirect_uri: redirectUri,
|
||||
|
|
@ -137,8 +143,9 @@ router.get('/oidc', async function(req, res) {
|
|||
|
||||
res.redirect(authUrl.href);
|
||||
} catch (err) {
|
||||
console.error('[OIDC] Auth initiation failed:', err.message);
|
||||
res.status(500).json({ error: 'SSO login failed: ' + err.message });
|
||||
res.clearCookie(transactionCookie, transactionOptions);
|
||||
console.error('[OIDC] Auth initiation failed');
|
||||
res.status(500).json({ error: 'SSO login failed' });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -146,13 +153,15 @@ router.get('/oidc', async function(req, res) {
|
|||
router.get('/oidc/callback', async function(req, res) {
|
||||
var appUrl = (process.env.APP_URL || 'http://localhost:3000').replace(/\/$/, '');
|
||||
|
||||
res.clearCookie(transactionCookie, transactionOptions);
|
||||
try {
|
||||
var state = req.query.state;
|
||||
var pending = verifyState(state);
|
||||
if (!pending) {
|
||||
var pending = verifyState(req.cookies && req.cookies[transactionCookie]);
|
||||
if (typeof state !== 'string' || !/^[a-f0-9]{48}$/.test(state) || !pending || pending.s !== state) {
|
||||
return res.redirect(appUrl + '?error=invalid_state');
|
||||
}
|
||||
|
||||
if (await db.getSetting('oidc.enabled') !== 'true') return res.redirect(appUrl + '?error=sso_disabled');
|
||||
var issuer = await db.getSetting('oidc.issuer');
|
||||
var clientId = await db.getSetting('oidc.client_id');
|
||||
var clientSecret = await db.getSetting('oidc.client_secret');
|
||||
|
|
@ -171,6 +180,7 @@ router.get('/oidc/callback', async function(req, res) {
|
|||
|
||||
var claims = tokens.claims();
|
||||
var sub = claims.sub;
|
||||
if (typeof sub !== 'string' || !sub || sub.length > 255) return res.redirect(appUrl + '?error=invalid_identity');
|
||||
var email = claims.email;
|
||||
var emailVerified = claims.email_verified;
|
||||
var name = claims.name || claims.preferred_username || email;
|
||||
|
|
@ -193,16 +203,19 @@ router.get('/oidc/callback', async function(req, res) {
|
|||
var user = await db.get('SELECT * FROM users WHERE email = ?', [email]);
|
||||
|
||||
if (user) {
|
||||
if (user.disabled) return res.redirect(appUrl + '?error=disabled');
|
||||
// Existing user. Two safe linking paths:
|
||||
// 1. Already linked (oidc_sub matches) → log in
|
||||
// 2. Not yet linked → auto-link ONLY if the IdP asserts the email
|
||||
// is verified. Otherwise an attacker at the IdP could squat on
|
||||
// 2. Not yet linked → auto-link ONLY if both local and IdP email
|
||||
// are verified. Otherwise an attacker could squat on
|
||||
// an email they don't own and take over the local account.
|
||||
if (user.oidc_sub && user.oidc_sub !== sub) {
|
||||
console.warn('[OIDC] sub mismatch on existing user id=' + user.id + ' — IdP returned different sub than recorded. Refusing to auto-relink.');
|
||||
return res.redirect(appUrl + '?error=sub_mismatch');
|
||||
}
|
||||
if (!user.oidc_sub) {
|
||||
// A pre-registered, unverified account may have an attacker-known password.
|
||||
if (user.email_verified !== true) return res.redirect(appUrl + '?error=account_link_required');
|
||||
// Some IdPs (rare) serialize booleans as strings — accept both.
|
||||
var verified = emailVerified === true || emailVerified === 'true';
|
||||
if (!verified) {
|
||||
|
|
@ -213,9 +226,6 @@ router.get('/oidc/callback', async function(req, res) {
|
|||
await db.run('INSERT INTO audit_log (user_id, action, category, details, ip_address) VALUES (?, ?, ?, ?, ?)',
|
||||
[user.id, 'oidc_linked', 'auth', 'Linked SSO identity ' + sub + ' via ' + issuer, req.ip]).catch(function(){});
|
||||
}
|
||||
if (user.disabled) {
|
||||
return res.redirect(appUrl + '?error=disabled');
|
||||
}
|
||||
} else {
|
||||
// Auto-create user from OIDC
|
||||
var userCount = await db.get('SELECT COUNT(*) as count FROM users', []);
|
||||
|
|
@ -231,22 +241,21 @@ router.get('/oidc/callback', async function(req, res) {
|
|||
|
||||
// Issue JWT
|
||||
var token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: isMobileClient(req) ? '365d' : '30d' });
|
||||
setAuthCookie(res, token);
|
||||
|
||||
// Create session record
|
||||
// Persist the session before issuing any authentication cookie.
|
||||
var ssoSessionId = generateSessionId();
|
||||
try {
|
||||
await db.run('INSERT INTO user_sessions (id, user_id, token_hash, ip_address, user_agent, device_label) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[ssoSessionId, user.id, hashToken(token), req.ip, req.headers['user-agent'] || '', parseUserAgent(req.headers['user-agent'])]);
|
||||
} catch (e) { /* table may not exist yet */ }
|
||||
await db.run('INSERT INTO user_sessions (id, user_id, token_hash, ip_address, user_agent, device_label) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[ssoSessionId, user.id, hashToken(token), req.ip, req.headers['user-agent'] || '', parseUserAgent(req.headers['user-agent'])]);
|
||||
|
||||
await db.run('INSERT INTO audit_log (user_id, action, ip_address, details) VALUES (?, ?, ?, ?)',
|
||||
[user.id, 'login_oidc', req.ip, 'SSO via ' + issuer]);
|
||||
|
||||
setAuthCookie(res, token);
|
||||
// Redirect to app — token is in httpOnly cookie, pass session ID
|
||||
res.redirect(appUrl + '?sso=ok&sid=' + ssoSessionId);
|
||||
} catch (err) {
|
||||
console.error('[OIDC] Callback error:', err.message);
|
||||
// IdP errors can contain callback URLs or PKCE material; do not log them.
|
||||
console.error('[OIDC] Callback failed');
|
||||
res.redirect(appUrl + '?error=sso_failed');
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ router.post('/sick-visit/note', authMiddleware, async function(req, res) {
|
|||
if (ros) context += wrapUserText('ros', ros) + '\n\n';
|
||||
if (physicalExam) context += wrapUserText('physical_exam', physicalExam) + '\n\n';
|
||||
if (diagnoses) context += wrapUserText('diagnoses', diagnoses) + '\n\n';
|
||||
if (physicianMemories) {
|
||||
if (physicianMemories && await require('../utils/policy').isFeatureEnabled('memories')) {
|
||||
context += '[STYLE HINTS (low priority — only apply if relevant to the current note, never copy content from these examples)]\n'
|
||||
+ wrapUserText('style_hints', physicianMemories) + '\n[END STYLE HINTS]\n\n';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ router.post('/generate-soap', authMiddleware, async (req, res) => {
|
|||
|
||||
var context = 'Patient: ' + (patientAge || 'Unknown') + ', ' + (patientGender || 'Unknown') + '\n\n'
|
||||
+ wrapUserText('transcript', transcript);
|
||||
if (physicianMemories) {
|
||||
if (physicianMemories && await require('../utils/policy').isFeatureEnabled('memories')) {
|
||||
context += '\n\n[STYLE HINTS (low priority — only apply if relevant to the current note, never copy content from these examples)]\n'
|
||||
+ wrapUserText('style_hints', physicianMemories)
|
||||
+ '\n[END STYLE HINTS]';
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ var { getLiteLLMHeaders } = require('../utils/litellm');
|
|||
var ttsProvider = getTTSProvider();
|
||||
console.log('🔊 TTS provider:', ttsProvider);
|
||||
|
||||
router.post('/text-to-speech', authMiddleware, async (req, res) => {
|
||||
router.post('/text-to-speech', authMiddleware, require('../utils/policy').requireFeature('read_aloud'), async (req, res) => {
|
||||
try {
|
||||
var text = (req.body.text || '').substring(0, 5000);
|
||||
if (!text) return res.status(400).json({ error: 'No text provided' });
|
||||
|
|
|
|||
|
|
@ -11,6 +11,11 @@ var { getLiteLLMTTSVoicesForModel, getTTSProvider } = require('../utils/ttsProvi
|
|||
|
||||
router.use(authMiddleware);
|
||||
|
||||
router.get('/features', async function(req, res) {
|
||||
try { res.json({ features: await require('../utils/policy').getUserFeatures() }); }
|
||||
catch (e) { res.status(503).json({ error: 'Feature policy unavailable' }); }
|
||||
});
|
||||
|
||||
// Get current user's STT/TTS preferences
|
||||
router.get('/preferences', async function(req, res) {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -167,7 +167,7 @@ router.post('/well-visit/note', authMiddleware, async function(req, res) {
|
|||
if (ros) context += wrapUserText('ros', ros) + '\n\n';
|
||||
if (physicalExam) context += wrapUserText('physical_exam', physicalExam) + '\n\n';
|
||||
if (diagnoses) context += wrapUserText('diagnoses', diagnoses) + '\n\n';
|
||||
if (physicianMemories) {
|
||||
if (physicianMemories && await require('../utils/policy').isFeatureEnabled('memories')) {
|
||||
context += '[STYLE HINTS (low priority — only apply if relevant to the current note, never copy content from these examples)]\n'
|
||||
+ wrapUserText('style_hints', physicianMemories) + '\n[END STYLE HINTS]\n\n';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
// ============================================================
|
||||
|
||||
const { OpenAI } = require('openai');
|
||||
const { DEFAULT_MODEL, FALLBACK_MODEL, getBedrockModelId, getBedrockMaxOut } = require('./models');
|
||||
const { FALLBACK_MODEL, getBedrockModelId, getBedrockMaxOut } = require('./models');
|
||||
const logger = require('./logger');
|
||||
const { resolveGenerationOptions, addReasoningOptions } = require('./generationOptions');
|
||||
|
||||
|
|
@ -139,6 +139,18 @@ if (activeProvider === 'openrouter' && !openrouter) {
|
|||
|
||||
console.log('🤖 Active AI provider:', activeProvider);
|
||||
|
||||
// Preserve unrecognized provider statuses; only explicit successful stops are complete.
|
||||
function normalizeFinishReason(reason) {
|
||||
if (typeof reason !== 'string') return reason ?? null;
|
||||
switch (reason.toLowerCase()) {
|
||||
case 'max_tokens': return 'length';
|
||||
case 'stop':
|
||||
case 'end_turn':
|
||||
case 'stop_sequence': return 'stop';
|
||||
default: return reason;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CALL OPENROUTER
|
||||
// ============================================================
|
||||
|
|
@ -169,7 +181,7 @@ async function callAzure(messages, model, temperature, maxTokens) {
|
|||
if (!azureClient) throw new Error('Azure OpenAI not configured');
|
||||
|
||||
var completion = await azureClient.chat.completions.create({
|
||||
model: process.env.AZURE_DEPLOYMENT_NAME || 'gpt-4o-mini',
|
||||
model: model,
|
||||
messages: messages,
|
||||
temperature: temperature,
|
||||
max_tokens: maxTokens
|
||||
|
|
@ -178,7 +190,7 @@ async function callAzure(messages, model, temperature, maxTokens) {
|
|||
return {
|
||||
success: true,
|
||||
content: completion.choices[0].message.content,
|
||||
model: process.env.AZURE_DEPLOYMENT_NAME || model,
|
||||
model: model,
|
||||
provider: 'azure',
|
||||
usage: completion.usage || null,
|
||||
finishReason: completion.choices[0].finish_reason || null
|
||||
|
|
@ -256,6 +268,7 @@ async function callBedrock(messages, model, temperature, maxTokens) {
|
|||
return {
|
||||
success: true,
|
||||
content: textContent,
|
||||
finishReason: normalizeFinishReason(responseBody.stop_reason),
|
||||
model: modelId,
|
||||
provider: 'bedrock',
|
||||
usage: {
|
||||
|
|
@ -295,6 +308,7 @@ async function callBedrock(messages, model, temperature, maxTokens) {
|
|||
return {
|
||||
success: true,
|
||||
content: outputText,
|
||||
finishReason: normalizeFinishReason(converseResponse.stopReason),
|
||||
model: modelId,
|
||||
provider: 'bedrock',
|
||||
usage: {
|
||||
|
|
@ -356,6 +370,7 @@ async function callVertex(messages, model, temperature, maxTokens) {
|
|||
return {
|
||||
success: true,
|
||||
content: textContent,
|
||||
finishReason: normalizeFinishReason(response.candidates && response.candidates[0] && response.candidates[0].finishReason),
|
||||
model: vertexModelId,
|
||||
provider: 'vertex',
|
||||
usage: response.usageMetadata ? {
|
||||
|
|
@ -390,32 +405,21 @@ async function callLiteLLM(messages, model, temperature, maxTokens, generation)
|
|||
|
||||
async function assertModelAllowed(requestedModel, options) {
|
||||
options = options || {};
|
||||
if (!requestedModel || options.skipAllowlistCheck) return;
|
||||
try {
|
||||
var db = require('../db/database');
|
||||
var { getAllowedModelIds } = require('./models');
|
||||
var allowed = await getAllowedModelIds(db);
|
||||
if (allowed && allowed.size > 0 && !allowed.has(requestedModel)) {
|
||||
var err = new Error('Model not permitted');
|
||||
err.code = 'model_not_permitted';
|
||||
err.model = requestedModel;
|
||||
throw err;
|
||||
}
|
||||
} catch (e) {
|
||||
if (e && e.code === 'model_not_permitted') throw e;
|
||||
console.warn('[callAI] Allow-list check skipped:', e && e.message);
|
||||
if (options.skipAllowlistCheck === true) return;
|
||||
var db = require('../db/database');
|
||||
var { getAllowedModelIds } = require('./models');
|
||||
var allowed = await getAllowedModelIds(db);
|
||||
if (!allowed.has(requestedModel)) {
|
||||
var err = new Error('Model not permitted');
|
||||
err.code = 'model_not_permitted';
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveModel(requestedModel) {
|
||||
var model = requestedModel && String(requestedModel).trim();
|
||||
if (model) return model;
|
||||
try {
|
||||
var db = require('../db/database');
|
||||
var adminDefault = await db.getSetting('models.default');
|
||||
if (adminDefault && String(adminDefault).trim()) return String(adminDefault).trim();
|
||||
} catch (e) {}
|
||||
return DEFAULT_MODEL;
|
||||
return require('./models').getEffectiveDefaultModel(require('../db/database'));
|
||||
}
|
||||
|
||||
async function callAIStream(messages, options, onToken) {
|
||||
|
|
@ -427,6 +431,10 @@ async function callAIStream(messages, options, onToken) {
|
|||
var maxTokens = generation.maxTokens;
|
||||
var startTime = Date.now();
|
||||
await assertModelAllowed(model, options);
|
||||
if (activeProvider === 'azure') {
|
||||
model = process.env.AZURE_DEPLOYMENT_NAME || 'gpt-4o-mini';
|
||||
await assertModelAllowed(model, options);
|
||||
}
|
||||
|
||||
var client = null;
|
||||
var provider = null;
|
||||
|
|
@ -439,7 +447,6 @@ async function callAIStream(messages, options, onToken) {
|
|||
} else if (activeProvider === 'azure' && azureClient) {
|
||||
client = azureClient;
|
||||
provider = 'azure';
|
||||
model = process.env.AZURE_DEPLOYMENT_NAME || model;
|
||||
}
|
||||
if (!client) throw new Error('Streaming is only configured for OpenAI-compatible providers');
|
||||
|
||||
|
|
@ -480,9 +487,13 @@ async function callAI(messages, options) {
|
|||
// Server-side whitelist: reject any model the operator hasn't enabled.
|
||||
// Prevents a client from passing e.g. model:"openai/o1" and draining
|
||||
// the budget on a reasoning model outside the configured roster.
|
||||
// Falls back to the admin default, then DEFAULT_MODEL, when no model was provided.
|
||||
// Omitted selections use an enabled effective default, never a removed model.
|
||||
// Admin test endpoints pass skipAllowlistCheck to test before adding.
|
||||
await assertModelAllowed(model, options);
|
||||
if (activeProvider === 'azure') {
|
||||
model = process.env.AZURE_DEPLOYMENT_NAME || 'gpt-4o-mini';
|
||||
await assertModelAllowed(model, options);
|
||||
}
|
||||
|
||||
try {
|
||||
var result;
|
||||
|
|
@ -541,6 +552,7 @@ async function callAI(messages, options) {
|
|||
if (activeProvider === 'openrouter' && model !== FALLBACK_MODEL && openrouter) {
|
||||
logger.warn('Trying fallback model: ' + FALLBACK_MODEL);
|
||||
try {
|
||||
await assertModelAllowed(FALLBACK_MODEL, options);
|
||||
var fallbackResult = await callOpenRouter(messages, FALLBACK_MODEL, temperature, maxTokens);
|
||||
fallbackResult.fallback = true;
|
||||
fallbackResult.duration = Date.now() - startTime;
|
||||
|
|
@ -555,6 +567,7 @@ async function callAI(messages, options) {
|
|||
if (activeProvider === 'litellm' && model !== FALLBACK_MODEL && litellmClient) {
|
||||
logger.warn('Trying fallback model on LiteLLM: ' + FALLBACK_MODEL);
|
||||
try {
|
||||
await assertModelAllowed(FALLBACK_MODEL, options);
|
||||
var litellmFallback = await callLiteLLM(messages, FALLBACK_MODEL, temperature, maxTokens, generation);
|
||||
litellmFallback.fallback = true;
|
||||
litellmFallback.duration = Date.now() - startTime;
|
||||
|
|
|
|||
28
src/utils/buildId.js
Normal file
28
src/utils/buildId.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
const isGitRevision = value => typeof value === 'string' && value.length === 40 && /^[0-9a-f]{40}$/.test(value);
|
||||
|
||||
function getBuildId(root) {
|
||||
// The image's baked revision is authoritative; never accept arbitrary HTML/header text.
|
||||
try {
|
||||
const baked = fs.readFileSync(path.join(root, 'BUILD_ID'), 'utf8').trim();
|
||||
return isGitRevision(baked) ? baked : 'unknown';
|
||||
} catch (_) {}
|
||||
// Require this checkout's marker, not an unrelated ancestor repository.
|
||||
if (!fs.existsSync(path.join(root, '.git'))) return 'unknown';
|
||||
try {
|
||||
const revision = execFileSync('git', ['-C', root, 'rev-parse', '--verify', 'HEAD^{commit}'], {
|
||||
encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'],
|
||||
env: Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_'))),
|
||||
}).trim();
|
||||
return isGitRevision(revision) ? revision : 'unknown';
|
||||
} catch (_) {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getBuildId, isGitRevision };
|
||||
|
|
@ -3,10 +3,9 @@ function buildSystemPrompt(behavior) {
|
|||
}
|
||||
|
||||
function buildUserPrompt(question, context, history, searchQuery) {
|
||||
var hist = history.filter(function(m) { return m && (m.role === 'user' || m.role === 'assistant') && m.content; })
|
||||
.map(function(m) { return m.role.toUpperCase() + ': ' + String(m.content).substring(0, 1000); }).join('\n');
|
||||
var hist = history.map(function(m) { return m.role.toUpperCase() + ': ' + String(m.content); }).join('\n');
|
||||
var searchNote = searchQuery && searchQuery !== question ? ('\n\nStandalone retrieval query used:\n' + searchQuery) : '';
|
||||
return 'Question:\n' + question + searchNote + '\n\nRecent conversation, if relevant:\n' + (hist || 'None') + '\n\nRetrieved sources:\n' + context + '\n\nWrite the answer now. If the question is a short misspelled or partial term and the sources point to a likely concept, answer the likely concept rather than asking for clarification.';
|
||||
return 'Question:\n' + question + searchNote + '\n\nFull conversation context (prior AI output is not evidence; use fresh retrieved sources for factual claims):\n' + (hist || 'None') + '\n\nRetrieved sources:\n' + context + '\n\nWrite the answer now. If the question is a short misspelled or partial term and the sources point to a likely concept, answer the likely concept rather than asking for clarification.';
|
||||
}
|
||||
|
||||
function assistantGenerationOptions(overrides) {
|
||||
|
|
|
|||
109
src/utils/clinicalConversation.js
Normal file
109
src/utils/clinicalConversation.js
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
// Conversation text uses an exact character budget, not an estimated model token limit.
|
||||
const DEFAULT_CONVERSATION_CHARS = 120000;
|
||||
const MAX_SAVED_CHAT_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
function failure(message, statusCode, code) {
|
||||
return Object.assign(new Error(message), { statusCode, code });
|
||||
}
|
||||
|
||||
function conversationLimit(value) {
|
||||
if (value == null || value === '') return DEFAULT_CONVERSATION_CHARS;
|
||||
const limit = Number(value);
|
||||
if (!['string', 'number'].includes(typeof value) || !Number.isInteger(limit) || limit < 1000 || limit > 1000000) {
|
||||
throw failure('Conversation budget must be an integer from 1,000 to 1,000,000 characters.', 400, 'INVALID_CONVERSATION_BUDGET');
|
||||
}
|
||||
return limit;
|
||||
}
|
||||
|
||||
function conversationBudget(env = process.env) {
|
||||
const name = 'CLINICAL_ASSISTANT_CONVERSATION_CHARS';
|
||||
const value = env[name];
|
||||
try {
|
||||
return {
|
||||
limit: conversationLimit(value), unit: 'characters', measure: 'UTF-16 code units',
|
||||
env: name, source: value == null || value === '' ? 'default' : 'environment'
|
||||
};
|
||||
} catch (_) {
|
||||
throw failure('Conversation budget environment is invalid. No request was sent to an AI provider.', 503, 'INVALID_CONVERSATION_BUDGET');
|
||||
}
|
||||
}
|
||||
|
||||
function validateMessages(messages) {
|
||||
if (messages === undefined) return [];
|
||||
if (!Array.isArray(messages)) throw failure('Conversation history must be a list.', 400, 'INVALID_CONVERSATION');
|
||||
return messages.map(function(message) {
|
||||
if (!message || !['user', 'assistant'].includes(message.role) || typeof message.content !== 'string') {
|
||||
throw failure('Each conversation turn must have a user/assistant role and text content.', 400, 'INVALID_CONVERSATION');
|
||||
}
|
||||
return { role: message.role, content: message.content };
|
||||
});
|
||||
}
|
||||
|
||||
function checkConversation(history, message, limit, handoff) {
|
||||
history = validateMessages(history);
|
||||
if (typeof message !== 'string' || (!handoff && !message.trim())) {
|
||||
throw failure('Question is required.', 400, 'INVALID_CONVERSATION');
|
||||
}
|
||||
const used = history.reduce((total, turn) => total + turn.content.length, message.length);
|
||||
const budget = { used, limit, unit: 'characters', remaining: Math.max(0, limit - used) };
|
||||
if (used > limit) {
|
||||
const error = failure('Conversation limit reached. Nothing was truncated or sent to an AI provider. Save or download this chat, then start a new chat or explicitly request a handoff summary.', 413, 'CONVERSATION_LIMIT');
|
||||
error.budget = budget;
|
||||
throw error;
|
||||
}
|
||||
return { history, message, budget };
|
||||
}
|
||||
|
||||
function savedSources(sources) {
|
||||
if (sources === undefined) return [];
|
||||
if (!Array.isArray(sources) || sources.some(source => !source || typeof source !== 'object' || Array.isArray(source))) {
|
||||
throw failure('Invalid saved source metadata.', 400, 'INVALID_SAVED_CHAT');
|
||||
}
|
||||
return sources.map(function(source) {
|
||||
const copy = { ...source };
|
||||
delete copy.image_path;
|
||||
delete copy.file_path;
|
||||
return copy;
|
||||
});
|
||||
}
|
||||
|
||||
function savedImage(image) {
|
||||
if (image == null || image === '') return '';
|
||||
if (typeof image !== 'string') throw failure('Invalid saved image.', 400, 'INVALID_SAVED_CHAT');
|
||||
const match = image.match(/^data:image\/(png|jpeg|webp);base64,([A-Za-z0-9+/]+={0,2})$/);
|
||||
if (match && image.length <= MAX_SAVED_CHAT_BYTES) {
|
||||
const bytes = Buffer.from(match[2], 'base64');
|
||||
const headerMatches = match[1] === 'png' ? bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) :
|
||||
match[1] === 'jpeg' ? bytes.subarray(0, 3).equals(Buffer.from([255, 216, 255])) :
|
||||
bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP';
|
||||
if (headerMatches && bytes.toString('base64').replace(/=+$/, '') === match[2].replace(/=+$/, '')) return image;
|
||||
}
|
||||
try {
|
||||
const url = new URL(image);
|
||||
if (url.protocol === 'https:' || url.protocol === 'http:') return image;
|
||||
} catch (_) {}
|
||||
throw failure('Saved images must be PNG, JPEG, WebP or HTTP(S) image URLs.', 400, 'INVALID_SAVED_CHAT');
|
||||
}
|
||||
|
||||
function savedChatPayload(body) {
|
||||
const messages = validateMessages(body.messages).map(function(message, index) {
|
||||
return { ...message, sources: savedSources(body.messages[index].sources) };
|
||||
});
|
||||
if (body.lastAnswer !== undefined && typeof body.lastAnswer !== 'string') {
|
||||
throw failure('Invalid saved answer.', 400, 'INVALID_SAVED_CHAT');
|
||||
}
|
||||
const payload = {
|
||||
version: 2,
|
||||
messages,
|
||||
sources: savedSources(body.sources),
|
||||
lastAnswer: body.lastAnswer || '',
|
||||
generatedImage: savedImage(body.generatedImage),
|
||||
savedAt: new Date().toISOString()
|
||||
};
|
||||
if (Buffer.byteLength(JSON.stringify(payload), 'utf8') > MAX_SAVED_CHAT_BYTES) {
|
||||
throw failure('Saved chat exceeds the 8 MiB storage limit. Nothing was saved or truncated; download the complete transcript instead.', 413, 'SAVED_CHAT_LIMIT');
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
module.exports = { DEFAULT_CONVERSATION_CHARS, MAX_SAVED_CHAT_BYTES, conversationLimit, conversationBudget, checkConversation, savedChatPayload };
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
var axios = require('axios');
|
||||
var sleep = require('node:timers/promises').setTimeout;
|
||||
|
||||
var MCP_URLS = buildMcpUrls();
|
||||
var _lastGoodMcpUrl = MCP_URLS[0];
|
||||
|
|
@ -9,27 +10,57 @@ var _mcpSession = null;
|
|||
var _mcpSessionPromise = null;
|
||||
var _mcpCallQueue = Promise.resolve();
|
||||
var _mcpRequestId = 1;
|
||||
var MCP_CLEANUP_TIMEOUT_MS = 5000;
|
||||
var _closing = false;
|
||||
var _closePromise = null;
|
||||
var _ownedSessions = new Set();
|
||||
var _initializeController = null;
|
||||
|
||||
function positiveInt(value, fallback) {
|
||||
var n = Number(value);
|
||||
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
|
||||
}
|
||||
|
||||
// The MCP server builds a Nextcloud client per session and closes it only when the
|
||||
// session ends. A session is replaced every time the TTL expires, so without this
|
||||
// the abandoned clients hold their Nextcloud connections open in CLOSE-WAIT until
|
||||
// the server runs out of file descriptors and every search fails.
|
||||
// Best effort by design: the session being discarded is already unusable, so a
|
||||
// failed teardown must never surface to the caller.
|
||||
async function endMcpSession(session) {
|
||||
if (!session || !session.sessionId) return;
|
||||
try {
|
||||
await axios.delete(session.mcpUrl || _lastGoodMcpUrl, {
|
||||
headers: { 'Accept': 'application/json, text/event-stream', 'mcp-session-id': session.sessionId },
|
||||
timeout: MCP_INITIALIZE_TIMEOUT_MS,
|
||||
validateStatus: function() { return true; }
|
||||
});
|
||||
} catch (e) { /* the server reaps abandoned sessions on its own schedule */ }
|
||||
// Shared-library sessions can own server-side Nextcloud clients. Explicit
|
||||
// teardown releases those resources promptly on replacement or shutdown.
|
||||
// Best effort: failed cleanup must not replace a successful result or primary error.
|
||||
function endMcpSession(session) {
|
||||
if (!session || !session.sessionId) return Promise.resolve();
|
||||
if (session.cleanup) return session.cleanup;
|
||||
session.cleanup = (async function() {
|
||||
var controller = new AbortController();
|
||||
var timer;
|
||||
try {
|
||||
await Promise.race([
|
||||
axios.delete(session.mcpUrl || _lastGoodMcpUrl, {
|
||||
headers: { 'Accept': 'application/json, text/event-stream', 'mcp-session-id': session.sessionId },
|
||||
timeout: MCP_CLEANUP_TIMEOUT_MS,
|
||||
signal: controller.signal
|
||||
}),
|
||||
new Promise(function(resolve) {
|
||||
timer = setTimeout(function() { controller.abort(); resolve(); }, MCP_CLEANUP_TIMEOUT_MS);
|
||||
})
|
||||
]);
|
||||
} catch (e) { /* best effort; server expiry covers failed/unknown sessions */ }
|
||||
finally { clearTimeout(timer); _ownedSessions.delete(session); }
|
||||
})();
|
||||
return session.cleanup;
|
||||
}
|
||||
|
||||
function closeMcpSession() {
|
||||
if (_closePromise) return _closePromise;
|
||||
_closing = true;
|
||||
_mcpSession = null;
|
||||
if (_initializeController) _initializeController.abort();
|
||||
// Late initialize responses are owned and unwound by initializeMcpSession.
|
||||
// This is best effort, not a guarantee of remote deletion before process exit.
|
||||
var timer;
|
||||
var pending = [_mcpSessionPromise, _mcpCallQueue].concat(Array.from(_ownedSessions, endMcpSession));
|
||||
_closePromise = Promise.race([
|
||||
Promise.allSettled(pending),
|
||||
new Promise(function(resolve) { timer = setTimeout(resolve, MCP_CLEANUP_TIMEOUT_MS); })
|
||||
]).then(function() {}).finally(function() { clearTimeout(timer); });
|
||||
return _closePromise;
|
||||
}
|
||||
|
||||
async function semanticSearch(query, opts) {
|
||||
|
|
@ -66,6 +97,7 @@ function warmMcpSession() {
|
|||
}
|
||||
|
||||
async function callMcpTool(name, args) {
|
||||
if (_closing) throw new Error('MCP client is closing');
|
||||
var queued = _mcpCallQueue.then(function() {
|
||||
return callMcpToolUnlocked(name, args);
|
||||
});
|
||||
|
|
@ -85,16 +117,18 @@ async function callMcpToolUnlocked(name, args) {
|
|||
}
|
||||
};
|
||||
var search;
|
||||
try {
|
||||
search = await mcpRequest(payload, session.sessionId, session.mcpUrl);
|
||||
} catch (e) {
|
||||
if (!isInvalidMcpSessionError(e)) throw e;
|
||||
var rejected = _mcpSession;
|
||||
_mcpSession = null;
|
||||
if (rejected) endMcpSession(rejected);
|
||||
session = await getMcpSession();
|
||||
payload.id = nextMcpRequestId();
|
||||
search = await mcpRequest(payload, session.sessionId, session.mcpUrl);
|
||||
for (var attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
search = await mcpRequest(payload, session.sessionId, session.mcpUrl);
|
||||
break;
|
||||
} catch (e) {
|
||||
if (!isInvalidMcpSessionError(e)) throw e;
|
||||
if (_mcpSession === session) _mcpSession = null;
|
||||
endMcpSession(session);
|
||||
if (attempt === 1) throw e;
|
||||
session = await getMcpSession();
|
||||
payload.id = nextMcpRequestId();
|
||||
}
|
||||
}
|
||||
return search.result || search;
|
||||
}
|
||||
|
|
@ -106,15 +140,19 @@ function nextMcpRequestId() {
|
|||
}
|
||||
|
||||
async function getMcpSession() {
|
||||
if (_closing) throw new Error('MCP client is closing');
|
||||
var now = Date.now();
|
||||
if (_mcpSession && _mcpSession.sessionId && _mcpSession.expiresAt > now) return _mcpSession;
|
||||
if (_mcpSessionPromise) return _mcpSessionPromise;
|
||||
// Captured before the replacement lands so the expired session can be closed.
|
||||
// Discard stale ownership even when the replacement fails.
|
||||
var stale = _mcpSession;
|
||||
_mcpSession = null;
|
||||
if (stale) endMcpSession(stale);
|
||||
_mcpSessionPromise = initializeMcpSession().then(function(session) {
|
||||
if (_closing) {
|
||||
return endMcpSession(session).then(function() { throw new Error('MCP client is closing'); });
|
||||
}
|
||||
_mcpSession = session;
|
||||
if (stale) endMcpSession(stale);
|
||||
return session;
|
||||
}).finally(function() {
|
||||
_mcpSessionPromise = null;
|
||||
|
|
@ -123,57 +161,105 @@ async function getMcpSession() {
|
|||
}
|
||||
|
||||
async function initializeMcpSession() {
|
||||
var session = await mcpRequest({
|
||||
jsonrpc: '2.0',
|
||||
id: nextMcpRequestId(),
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'ped-ai-clinical-assistant', version: '1.0.0' }
|
||||
}
|
||||
});
|
||||
session.expiresAt = Date.now() + MCP_SESSION_TTL_MS;
|
||||
return session;
|
||||
var owned = null;
|
||||
var controller = new AbortController();
|
||||
_initializeController = controller;
|
||||
var timer = setTimeout(function() { controller.abort(); }, MCP_INITIALIZE_TIMEOUT_MS);
|
||||
try {
|
||||
var session = await mcpRequest({
|
||||
jsonrpc: '2.0',
|
||||
id: nextMcpRequestId(),
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'ped-ai-clinical-assistant', version: '1.0.0' }
|
||||
}
|
||||
}, null, null, function(response, url) {
|
||||
var id = response.headers && response.headers['mcp-session-id'];
|
||||
if (id) {
|
||||
owned = { sessionId: id, mcpUrl: url };
|
||||
_ownedSessions.add(owned);
|
||||
if (_closing) endMcpSession(owned);
|
||||
}
|
||||
}, controller.signal);
|
||||
if (!owned || !session.result) throw new Error('MCP initialize failed');
|
||||
if (_closing) throw new Error('MCP client is closing');
|
||||
await mcpRequest({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} },
|
||||
owned.sessionId, owned.mcpUrl, null, controller.signal);
|
||||
if (_closing) throw new Error('MCP client is closing');
|
||||
owned.expiresAt = Date.now() + MCP_SESSION_TTL_MS;
|
||||
return owned;
|
||||
} catch (e) {
|
||||
await endMcpSession(owned);
|
||||
throw e;
|
||||
} finally { clearTimeout(timer); _initializeController = null; }
|
||||
}
|
||||
|
||||
async function mcpRequest(payload, sessionId, preferredUrl) {
|
||||
async function mcpRequest(payload, sessionId, preferredUrl, onResponse, signal) {
|
||||
var headers = {
|
||||
'Accept': 'application/json, text/event-stream',
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
if (sessionId) headers['mcp-session-id'] = sessionId;
|
||||
var resp = await postMcpWithRetry(payload, headers, preferredUrl);
|
||||
var parsed = parseMcpResponse(resp.data);
|
||||
if (parsed.error) throw new Error(parsed.error.message || JSON.stringify(parsed.error));
|
||||
parsed.sessionId = resp.headers['mcp-session-id'] || sessionId || null;
|
||||
parsed.mcpUrl = resp.config && resp.config.url ? resp.config.url : preferredUrl || _lastGoodMcpUrl;
|
||||
return parsed;
|
||||
// A trickling response must not hold the shared call queue indefinitely.
|
||||
signal = signal || AbortSignal.timeout(MCP_REQUEST_TIMEOUT_MS);
|
||||
try {
|
||||
var resp = await postMcpWithRetry(payload, headers, preferredUrl, onResponse, signal);
|
||||
var body = resp.data;
|
||||
// Streaming initialize exposes the session header before a body read can fail.
|
||||
if (body && typeof body[Symbol.asyncIterator] === 'function') {
|
||||
var chunks = [];
|
||||
for await (var chunk of body) chunks.push(Buffer.from(chunk));
|
||||
body = Buffer.concat(chunks).toString('utf8');
|
||||
}
|
||||
var parsed = parseMcpResponse(body);
|
||||
if (parsed.error) throw new Error(parsed.error.message || 'MCP request failed');
|
||||
parsed.sessionId = resp.headers['mcp-session-id'] || sessionId || null;
|
||||
parsed.mcpUrl = resp.config && resp.config.url ? resp.config.url : preferredUrl || _lastGoodMcpUrl;
|
||||
return parsed;
|
||||
} catch (e) {
|
||||
// Callers log messages: never propagate raw upstream bodies, credentials or IDs.
|
||||
var safe = new Error('MCP request failed');
|
||||
safe.invalidSession = isInvalidMcpSessionError(e);
|
||||
throw safe;
|
||||
}
|
||||
}
|
||||
|
||||
async function postMcpWithRetry(payload, headers, preferredUrl) {
|
||||
async function postMcpWithRetry(payload, headers, preferredUrl, onResponse, signal) {
|
||||
var lastErr = null;
|
||||
var urls = orderedMcpUrls(preferredUrl);
|
||||
var isNotification = payload && payload.method === 'notifications/initialized';
|
||||
if (isNotification && !preferredUrl) throw new Error('MCP notification requires its session endpoint');
|
||||
// Session handshake belongs to its initializer, not an alternate server.
|
||||
var urls = isNotification ? [preferredUrl] : orderedMcpUrls(preferredUrl);
|
||||
var isInitialize = payload && payload.method === 'initialize';
|
||||
for (var round = 0; round < 2; round++) {
|
||||
for (var i = 0; i < urls.length; i++) {
|
||||
if (_closing) throw new Error('MCP client is closing');
|
||||
var url = urls[i];
|
||||
try {
|
||||
var resp = await axios.post(url, payload, {
|
||||
headers: headers,
|
||||
timeout: isInitialize ? MCP_INITIALIZE_TIMEOUT_MS : MCP_REQUEST_TIMEOUT_MS,
|
||||
responseType: 'text',
|
||||
signal: signal,
|
||||
responseType: isInitialize ? 'stream' : 'text',
|
||||
transformResponse: [function(data) { return data; }]
|
||||
});
|
||||
if (onResponse) onResponse(resp, url);
|
||||
_lastGoodMcpUrl = url;
|
||||
return resp;
|
||||
} catch (e) {
|
||||
if (onResponse && e.response) {
|
||||
onResponse(e.response, url);
|
||||
if (e.response.data && typeof e.response.data.destroy === 'function') e.response.data.destroy();
|
||||
}
|
||||
lastErr = e;
|
||||
if (!isTransientMcpError(e)) throw e;
|
||||
console.warn('[clinical-assistant] MCP endpoint unavailable:', url, e.code || e.message);
|
||||
// Only connection refusal proves initialize could not have created a session.
|
||||
if (!isTransientMcpError(e) || (isInitialize && (e.code !== 'ECONNREFUSED' || e.response))) throw e;
|
||||
console.warn('[clinical-assistant] MCP endpoint unavailable');
|
||||
}
|
||||
}
|
||||
await sleep(750 * (round + 1));
|
||||
await sleep(750 * (round + 1), undefined, { signal: signal });
|
||||
}
|
||||
throw lastErr;
|
||||
}
|
||||
|
|
@ -195,7 +281,7 @@ function isTransientMcpError(e) {
|
|||
function isInvalidMcpSessionError(e) {
|
||||
var status = e && e.response && e.response.status;
|
||||
var msg = String(e && e.message || '');
|
||||
return status === 400 || status === 404 || status === 410 || /session|mcp-session-id/i.test(msg);
|
||||
return !!(e && e.invalidSession) || status === 400 || status === 404 || status === 410 || /session|mcp-session-id/i.test(msg);
|
||||
}
|
||||
|
||||
async function getMcpHealth() {
|
||||
|
|
@ -233,10 +319,6 @@ function buildMcpUrls() {
|
|||
return out;
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(function(resolve) { setTimeout(resolve, ms); });
|
||||
}
|
||||
|
||||
function parseMcpResponse(body) {
|
||||
var text = String(body || '').trim();
|
||||
if (!text) return {};
|
||||
|
|
@ -255,5 +337,6 @@ module.exports = {
|
|||
multimodalSearch: multimodalSearch,
|
||||
indexedTopicSuggestions: indexedTopicSuggestions,
|
||||
getMcpHealth: getMcpHealth,
|
||||
warmMcpSession: warmMcpSession
|
||||
warmMcpSession: warmMcpSession,
|
||||
closeMcpSession: closeMcpSession
|
||||
};
|
||||
|
|
|
|||
17
src/utils/clinicalPrompts.js
Normal file
17
src/utils/clinicalPrompts.js
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
// Global Clinical Assistant instructions, separate from the Scribe catalogue.
|
||||
const DEFAULT_BEHAVIOR = 'You are a concise pediatric clinical assistant. Use retrieved context only for factual claims. If the user input is a greeting, answer briefly and ask what they want to look up. Synthesize across sources and cite factual claims with the exact provided source numbers like [1]. Do not invent, renumber, merge, or move citations.';
|
||||
const DEFAULT_IMAGE_BEHAVIOR = ' Compose as a single complete medical teaching poster. Keep every element fully inside the canvas with a 10% safe margin on all sides. Do not crop boxes, arrows, labels, legends, or body parts. Use fewer words per box, large readable type, and generous spacing.';
|
||||
|
||||
function imagePromptForCanvas(prompt, behavior = DEFAULT_IMAGE_BEHAVIOR) {
|
||||
var text = String(prompt || '');
|
||||
var guidance = /^\s/.test(behavior) ? behavior : ' ' + behavior;
|
||||
if (/\b(flow\s*chart|flowchart|algorithm|pathway|timeline|vertical|stepwise|decision\s*tree|age\s*group|0-21|22-28|29-60)\b/i.test(text)) {
|
||||
guidance += ' Use a tall portrait layout with top-to-bottom flow, no more than 6-8 main nodes, and ample spacing between decision nodes.';
|
||||
}
|
||||
if (/\b(table|matrix|comparison|wide|landscape|side-by-side)\b/i.test(text)) {
|
||||
guidance += ' Use a wide landscape layout with compact columns, ample horizontal spacing, and no text near the edges.';
|
||||
}
|
||||
return text.trim() + guidance;
|
||||
}
|
||||
|
||||
module.exports = { DEFAULT_BEHAVIOR, DEFAULT_IMAGE_BEHAVIOR, imagePromptForCanvas };
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
var sourceMarkdown = require('markdown-it')({ html: false });
|
||||
var MULTIMODAL_CANDIDATE_LIMIT = 6;
|
||||
var INTERNAL_TITLE_FALLBACKS = {
|
||||
'1586022': 'Respiratory Disease',
|
||||
|
|
@ -19,7 +20,7 @@ function normalizeMcpSearchResponse(result) {
|
|||
}
|
||||
if (!data || !Array.isArray(data.results)) return [];
|
||||
return data.results.map(function(r, idx) {
|
||||
var text = [r.before_context, r.excerpt, r.after_context].filter(Boolean).join('\n').trim() || r.marked_text || r.excerpt || '';
|
||||
var text = sourceExcerptWithContext(r, 1800);
|
||||
return {
|
||||
number: idx + 1,
|
||||
id: r.id,
|
||||
|
|
@ -33,7 +34,7 @@ function normalizeMcpSearchResponse(result) {
|
|||
source_priority: r.source_priority || r.sourcePriority || '',
|
||||
source_boost: r.source_boost || r.sourceBoost || null,
|
||||
tags: Array.isArray(r.tags) ? r.tags : [],
|
||||
excerpt: clip(cleanSourceExcerpt(text), 1800),
|
||||
excerpt: text,
|
||||
score: r.score,
|
||||
chunk_index: r.chunk_index,
|
||||
total_chunks: r.total_chunks
|
||||
|
|
@ -109,8 +110,8 @@ function sourceDedupeKey(source) {
|
|||
var title = cleanTitle(source && source.title || '').toLowerCase();
|
||||
var page = source && source.page ? String(source.page) : '';
|
||||
var kind = source && source.source_type ? source.source_type : 'text';
|
||||
if (title && page) return [kind, title, page].join('|');
|
||||
return [kind, title, source && source.id || source && source.chunk_index || ''].join('|');
|
||||
var identity = source && (source.file_path || source.id) || title;
|
||||
return [kind, source.doc_type || '', identity, page, source.chunk_index == null ? '' : source.chunk_index].join('|');
|
||||
}
|
||||
|
||||
function mergeDuplicateSource(existing, duplicate) {
|
||||
|
|
@ -118,7 +119,7 @@ function mergeDuplicateSource(existing, duplicate) {
|
|||
var existingExcerpt = cleanSourceExcerpt(existing.excerpt || '');
|
||||
var duplicateExcerpt = cleanSourceExcerpt(duplicate.excerpt || '');
|
||||
if (duplicateExcerpt && existingExcerpt.indexOf(duplicateExcerpt.slice(0, 160)) === -1) {
|
||||
merged.excerpt = clip([existingExcerpt, duplicateExcerpt].filter(Boolean).join('\n'), 1800);
|
||||
merged.excerpt = clipSourceExcerpt([existingExcerpt, duplicateExcerpt].filter(Boolean).join('\n\n'), 1800);
|
||||
}
|
||||
merged.score = Math.max(Number(existing.score) || 0, Number(duplicate.score) || 0) || existing.score || duplicate.score;
|
||||
return merged;
|
||||
|
|
@ -256,19 +257,97 @@ function cleanTitle(s) {
|
|||
.trim();
|
||||
}
|
||||
|
||||
// Parse, don't guess from pipes: markdown-it excludes fenced code and handles escapes.
|
||||
function sourceBlocks(text) {
|
||||
var lines = text.split('\n');
|
||||
var tables = sourceMarkdown.parse(text, {}).filter(function(t) { return t.type === 'table_open'; });
|
||||
var blocks = [];
|
||||
var end = 0;
|
||||
tables.forEach(function(t) {
|
||||
if (t.map[0] > end) blocks.push({ text: lines.slice(end, t.map[0]).join('\n'), table: false });
|
||||
blocks.push({ text: lines.slice(t.map[0], t.map[1]).join('\n'), table: true });
|
||||
end = t.map[1];
|
||||
});
|
||||
if (end < lines.length) blocks.push({ text: lines.slice(end).join('\n'), table: false });
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function cleanSourceExcerpt(text) {
|
||||
return String(text || '')
|
||||
text = String(text || '')
|
||||
.replace(/^\[Page-image match\]\s*/i, '')
|
||||
.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
|
||||
.replace(/<br\s*\/?>/gi, ' ')
|
||||
.replace(/\*\*/g, '')
|
||||
.replace(/\|\s*-{2,}\s*/g, ' ')
|
||||
.replace(/\|/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '')
|
||||
.replace(/<\/?[a-z][^>]*>/gi, '')
|
||||
.replace(/(?:file:\/\/)?\/(?:tmp|var|home)\/[^\s)<>|]+/g, '')
|
||||
.replace(/\*\*/g, '');
|
||||
return sourceBlocks(text).map(function(b) {
|
||||
return b.table ? b.text.trim() : b.text.split(/\n\s*\n/).map(function(paragraph) {
|
||||
return paragraph.replace(/\|\s*-{2,}\s*/g, ' ').replace(/\|/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
}).filter(Boolean).join('\n\n');
|
||||
}).filter(Boolean).join('\n\n');
|
||||
}
|
||||
|
||||
function clipSourceExcerpt(text, limit) {
|
||||
text = cleanSourceExcerpt(text);
|
||||
if (text.length <= limit) return text;
|
||||
var marker = '[Content omitted]';
|
||||
if (limit < marker.length) return '';
|
||||
var budget = limit - marker.length - 2;
|
||||
var blocks = sourceBlocks(text);
|
||||
var suffix = '';
|
||||
// Repeated clinical notes/units belong to the rows, even when some rows
|
||||
// are omitted. Never borrow notes across multiple unrelated tables.
|
||||
if (blocks.filter(function(b) { return b.table; }).length === 1) {
|
||||
var last = blocks[blocks.length - 1];
|
||||
if (last && !last.table && /^(?:notes?\b|footnotes?\b|units?\b|source\s*:|[*†‡]|\[\^?\w+\])/i.test(last.text.trim())) {
|
||||
suffix = blocks.pop().text.trim();
|
||||
if (suffix.length + 2 > budget) return marker;
|
||||
budget -= suffix.length + 2;
|
||||
}
|
||||
}
|
||||
var out = [];
|
||||
blocks.some(function(b) {
|
||||
var separator = out.length ? 2 : 0;
|
||||
var available = budget - separator;
|
||||
if (b.text.length <= available) {
|
||||
out.push(b.text);
|
||||
budget -= separator + b.text.length;
|
||||
return false;
|
||||
}
|
||||
if (b.table) {
|
||||
var rows = b.text.split('\n');
|
||||
var kept = rows.slice(0, 2);
|
||||
var size = kept.join('\n').length;
|
||||
for (var i = 2; i < rows.length && size + 1 + rows[i].length <= available; i++) {
|
||||
kept.push(rows[i]);
|
||||
size += 1 + rows[i].length;
|
||||
}
|
||||
// Never emit a partial cell or a header pretending to contain values.
|
||||
if (kept.length > 2) out.push(kept.join('\n'));
|
||||
} else if (available > 0) {
|
||||
out.push(b.text.slice(0, available).trim());
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return out.concat(marker, suffix || []).join('\n\n');
|
||||
}
|
||||
|
||||
function sourceExcerptWithContext(source, limit) {
|
||||
// Allocate the hit first, not the often-long before_context.
|
||||
var hit = clipSourceExcerpt(source.excerpt || source.marked_text || '', limit);
|
||||
var out = hit;
|
||||
[source.before_context, source.after_context].forEach(function(context) {
|
||||
if (!context) return;
|
||||
var extra = clipSourceExcerpt(context, limit - out.length - 2);
|
||||
if (extra) out += (out ? '\n\n' : '') + extra;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
cleanSourceExcerpt: cleanSourceExcerpt,
|
||||
clipSourceExcerpt: clipSourceExcerpt,
|
||||
normalizeMcpSearchResponse: normalizeMcpSearchResponse,
|
||||
normalizeMcpMultimodalResponse: normalizeMcpMultimodalResponse,
|
||||
dedupeSources: dedupeSources,
|
||||
|
|
|
|||
|
|
@ -198,18 +198,32 @@ function createJsonZip(filename, payload) {
|
|||
|
||||
function parseImportBuffer(buffer) {
|
||||
var buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer || '');
|
||||
var maxPayload = 4 * 1024 * 1024;
|
||||
if (buf.length > maxPayload) throw new Error('Import payload exceeds 4 MiB');
|
||||
if (buf.length >= 4 && buf.readUInt32LE(0) === 0x04034b50) {
|
||||
if (buf.length < 30) throw new Error('Invalid zip file');
|
||||
// Only stored/deflated, unencrypted entries with sizes in the local header.
|
||||
var method = buf.readUInt16LE(8);
|
||||
var allowedFlags = 0x800 | (method === 8 ? 0x0006 : 0);
|
||||
if (buf.readUInt16LE(4) > 20 || (buf.readUInt16LE(6) & ~allowedFlags)) throw new Error('Unsupported zip flags/version');
|
||||
if (method !== 0 && method !== 8) throw new Error('Unsupported zip compression');
|
||||
var plainSize = buf.readUInt32LE(22);
|
||||
if (plainSize > maxPayload) throw new Error('Import payload exceeds 4 MiB');
|
||||
var compressedSize = buf.readUInt32LE(18);
|
||||
var nameLength = buf.readUInt16LE(26);
|
||||
var extraLength = buf.readUInt16LE(28);
|
||||
var dataStart = 30 + nameLength + extraLength;
|
||||
var dataEnd = dataStart + compressedSize;
|
||||
if (dataEnd > buf.length) throw new Error('Invalid zip file');
|
||||
if (!nameLength || dataStart > buf.length || dataEnd > buf.length) throw new Error('Invalid zip file');
|
||||
var data = buf.slice(dataStart, dataEnd);
|
||||
if (method === 8) data = zlib.inflateRawSync(data);
|
||||
if (method !== 0 && method !== 8) throw new Error('Unsupported zip compression');
|
||||
if (method === 8) {
|
||||
var inflated = zlib.inflateRawSync(data, { maxOutputLength: maxPayload, info: true });
|
||||
if (inflated.engine.bytesWritten !== compressedSize) throw new Error('Invalid zip compressed size');
|
||||
data = inflated.buffer;
|
||||
}
|
||||
if (data.length > maxPayload || data.length !== plainSize || crc32(data) !== buf.readUInt32LE(14)) {
|
||||
throw new Error('Invalid zip payload');
|
||||
}
|
||||
return JSON.parse(data.toString('utf8'));
|
||||
}
|
||||
return JSON.parse(buf.toString('utf8'));
|
||||
|
|
|
|||
|
|
@ -176,49 +176,39 @@ console.log('🤖 Models available:', AVAILABLE_MODELS.length);
|
|||
// DB-aware model list (used by /api/models endpoint)
|
||||
async function getAvailableModelsWithOverrides(db) {
|
||||
var baseModels = getAvailableModels();
|
||||
try {
|
||||
var disabledRaw = await db.getSetting('models.disabled') || '[]';
|
||||
var customRaw = await db.getSetting('models.custom') || '[]';
|
||||
var disabled, custom;
|
||||
try { disabled = JSON.parse(disabledRaw); } catch(e) { disabled = []; }
|
||||
try { custom = JSON.parse(customRaw); } catch(e) { custom = []; }
|
||||
|
||||
// For LiteLLM: no built-ins exist — the model list IS the custom/discovered list
|
||||
if (activeProvider === 'litellm') return custom;
|
||||
|
||||
var result = baseModels.filter(function(m) { return !disabled.includes(m.id); });
|
||||
custom.forEach(function(m) {
|
||||
if (!result.find(function(r) { return r.id === m.id; })) result.push(m);
|
||||
});
|
||||
return result;
|
||||
} catch(e) {
|
||||
return baseModels;
|
||||
var disabledRaw = await db.getSetting('models.disabled');
|
||||
var customRaw = await db.getSetting('models.custom');
|
||||
var disabled = JSON.parse(disabledRaw == null ? '[]' : disabledRaw);
|
||||
var custom = JSON.parse(customRaw == null ? '[]' : customRaw);
|
||||
if (!Array.isArray(disabled) || disabled.some(function(id) { return typeof id !== 'string' || !id.trim(); }) ||
|
||||
!Array.isArray(custom) || custom.some(function(m) { return !m || typeof m.id !== 'string' || !m.id.trim() || m.id !== m.id.trim(); })) {
|
||||
throw new Error('Invalid model settings');
|
||||
}
|
||||
var result = baseModels.slice();
|
||||
custom.forEach(function(m) {
|
||||
if (!result.some(function(r) { return r.id === m.id; })) result.push(m);
|
||||
});
|
||||
return result.filter(function(m) { return !disabled.includes(m.id); });
|
||||
}
|
||||
|
||||
// Whitelist of allowed model IDs, cached in memory. Refreshed periodically
|
||||
// from the DB so admin changes propagate without restart. Prevents
|
||||
// client-supplied model values (e.g. POST /api/hpi with model="openai/o1")
|
||||
// from calling expensive models outside the configured roster.
|
||||
var ALLOWED_MODELS_CACHE = null;
|
||||
var ALLOWED_MODELS_FETCHED = 0;
|
||||
var ALLOWED_MODELS_TTL_MS = 60 * 1000;
|
||||
|
||||
// Read policy on every call: no stale grants after edits or during a DB outage.
|
||||
async function getAllowedModelIds(db) {
|
||||
var now = Date.now();
|
||||
if (ALLOWED_MODELS_CACHE && (now - ALLOWED_MODELS_FETCHED) < ALLOWED_MODELS_TTL_MS) {
|
||||
return ALLOWED_MODELS_CACHE;
|
||||
}
|
||||
try {
|
||||
var models = await getAvailableModelsWithOverrides(db);
|
||||
var ids = new Set((models || []).map(function(m) { return m.id; }));
|
||||
ALLOWED_MODELS_CACHE = ids;
|
||||
ALLOWED_MODELS_FETCHED = now;
|
||||
return ids;
|
||||
} catch (e) {
|
||||
// Fall back to static list if DB fetch fails
|
||||
var staticIds = new Set(getAvailableModels().map(function(m) { return m.id; }));
|
||||
return staticIds;
|
||||
var models = await getAvailableModelsWithOverrides(db);
|
||||
return new Set(models.map(function(m) { return m.id; }));
|
||||
}
|
||||
|
||||
// Never advertise or use a stale/disabled default, including source/env defaults.
|
||||
async function getEffectiveDefaultModel(db, models) {
|
||||
models = models || await getAvailableModelsWithOverrides(db);
|
||||
var saved = await db.getSetting('models.default');
|
||||
var preferred = saved || getDefaultModel();
|
||||
return models.some(function(m) { return m.id === preferred; }) ? preferred : (models[0] ? models[0].id : '');
|
||||
}
|
||||
|
||||
async function reconcileDefaultModel(db) {
|
||||
var saved = await db.getSetting('models.default');
|
||||
if (saved && !(await getAvailableModelsWithOverrides(db)).some(function(m) { return m.id === saved; })) {
|
||||
await db.setSetting('models.default', '');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -230,11 +220,6 @@ function isStaticAllowedModel(id) {
|
|||
return getAvailableModels().some(function(m) { return m.id === id; });
|
||||
}
|
||||
|
||||
function invalidateAllowedModelsCache() {
|
||||
ALLOWED_MODELS_CACHE = null;
|
||||
ALLOWED_MODELS_FETCHED = 0;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AVAILABLE_MODELS,
|
||||
DEFAULT_MODEL,
|
||||
|
|
@ -247,8 +232,9 @@ module.exports = {
|
|||
getAvailableModels,
|
||||
getAvailableModelsWithOverrides,
|
||||
getAllowedModelIds,
|
||||
getEffectiveDefaultModel,
|
||||
reconcileDefaultModel,
|
||||
isStaticAllowedModel,
|
||||
invalidateAllowedModelsCache,
|
||||
getDefaultModel,
|
||||
getFallbackModel,
|
||||
getBedrockModelId,
|
||||
|
|
|
|||
35
src/utils/policy.js
Normal file
35
src/utils/policy.js
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
var db = require('../db/database');
|
||||
|
||||
async function isSSOOnly() {
|
||||
return await db.getSetting('oidc.enabled') === 'true' && await db.getSetting('oidc.disable_local_auth') === 'true';
|
||||
}
|
||||
|
||||
async function requireLocalAuth(req, res, next) {
|
||||
try {
|
||||
if (await isSSOOnly()) return res.status(403).json({ error: 'Local authentication is disabled. Use SSO.', code: 'sso_only' });
|
||||
next();
|
||||
} catch (e) { res.status(503).json({ error: 'Authentication policy unavailable' }); }
|
||||
}
|
||||
|
||||
// Missing settings retain the seeded defaults; malformed values deny access.
|
||||
async function isFeatureEnabled(name) {
|
||||
var value = await db.getSetting('feature.' + name);
|
||||
return value == null || value === 'true';
|
||||
}
|
||||
|
||||
function requireFeature(name) {
|
||||
return async function(req, res, next) {
|
||||
try {
|
||||
if (!await isFeatureEnabled(name)) return res.status(403).json({ error: 'Feature disabled', code: 'feature_disabled' });
|
||||
next();
|
||||
} catch (e) { res.status(503).json({ error: 'Feature policy unavailable' }); }
|
||||
};
|
||||
}
|
||||
|
||||
async function getUserFeatures() {
|
||||
var features = {};
|
||||
for (var name of ['read_aloud', 'nextcloud', 'memories']) features[name] = await isFeatureEnabled(name);
|
||||
return features;
|
||||
}
|
||||
|
||||
module.exports = { isSSOOnly, requireLocalAuth, isFeatureEnabled, requireFeature, getUserFeatures };
|
||||
62
src/utils/promptCatalog.js
Normal file
62
src/utils/promptCatalog.js
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
// Only global runtime prompts belong here, never private templates or settings secrets.
|
||||
const PROMPTS = require('./prompts');
|
||||
const clinical = require('./clinicalPrompts');
|
||||
const usage = {
|
||||
hpiEncounter: ['Encounter HPI', 'Generate encounter HPI'],
|
||||
hpiDictation: ['Dictated HPI', 'Restructure HPI dictation'],
|
||||
hpiInpatient: ['Inpatient HPI', 'Generate inpatient HPI from encounter or dictation'],
|
||||
hospitalCourseShort: ['Brief hospital course', 'Generate brief hospital course'],
|
||||
hospitalCourseLong: ['Day-by-day hospital course', 'Generate day-by-day hospital course'],
|
||||
hospitalCourseICU: ['Organ-system hospital course', 'Generate ICU hospital course'],
|
||||
hospitalCoursePsych: ['Psychiatric hospital course', 'Generate psychiatric hospital course'],
|
||||
chartReviewOutpatient: ['Outpatient chart review', 'Summarize outpatient records'],
|
||||
chartReviewSubspecialty: ['Subspecialty chart review', 'Summarize subspecialty records'],
|
||||
chartReviewED: ['ED chart review', 'Summarize ED records'],
|
||||
soapFull: ['Full SOAP note', 'Generate SOAP note'],
|
||||
soapSubjective: ['SOAP subjective section', 'Generate subjective section'],
|
||||
milestoneNarrative: ['Developmental narrative', 'Generate milestone narrative'],
|
||||
milestoneList: ['Developmental list', 'Generate milestone list'],
|
||||
milestoneSummary: ['Developmental summary', 'Summarize milestone assessment'],
|
||||
peGuideNarrative: ['Physical examination narrative', 'Generate examination narrative'],
|
||||
peGuideList: ['Physical examination list', 'Generate examination list'],
|
||||
refine: ['Documentation refinement', 'Refine document', 'Refine hospital course'],
|
||||
shortenDocument: ['Documentation shortening', 'Shorten document'],
|
||||
askClarification: ['Documentation clarifications', 'Clarify document', 'Clarify hospital course'],
|
||||
shadessAssessment: ['Adolescent assessment', 'Generate SHADESS assessment'],
|
||||
wellVisitNote: ['Full well visit note', 'Generate full well visit note'],
|
||||
wellVisitShort: ['Brief well visit note', 'Generate brief well visit note'],
|
||||
sickVisitNote: ['Sick visit note', 'Generate sick visit note'],
|
||||
edEncounterStaged: ['ED stage documentation (JSON)', 'Generate staged ED encounter'],
|
||||
edConsolidate: ['Final ED note (plain text)', 'Consolidate ED encounter stages'],
|
||||
edFinalize: ['ED medical decision-making (JSON)', 'Finalize ED billing assessment'],
|
||||
dontMissTooltip: ['Documentation gaps (JSON)', 'Generate post-note do-not-miss points'],
|
||||
patientEducation: ['Parent education (plain text)', 'Generate patient education handout']
|
||||
};
|
||||
const entries = PROMPTS.getAllPrompts().map(({ key }) => ({
|
||||
key, dbKey: 'prompt.' + key, family: 'scribe', purpose: usage[key][0], usedBy: usage[key].slice(1), editable: true
|
||||
})).concat([
|
||||
{ key: 'clinical_assistant.system_behavior', dbKey: 'clinical_assistant.system_behavior', family: 'clinical-text',
|
||||
purpose: 'Retrieved-context answer behavior; fixed citation safeguards remain in the answer builder',
|
||||
usedBy: ['Clinical Assistant chat', 'Clinical Assistant streaming chat'], editable: true },
|
||||
{ key: 'clinical_assistant.image_behavior', dbKey: 'clinical_assistant.image_behavior', family: 'clinical-image',
|
||||
purpose: 'Poster instruction appended to image input, before fixed portrait/landscape layout suffixes',
|
||||
usedBy: ['Clinical Assistant image', 'Clinical Assistant image job'], editable: true }
|
||||
]);
|
||||
entries.forEach(entry => { Object.freeze(entry.usedBy); Object.freeze(entry); });
|
||||
Object.freeze(entries);
|
||||
|
||||
function find(key) {
|
||||
return entries.find(entry => entry.dbKey === key || (entry.family === 'scribe' && entry.key === key));
|
||||
}
|
||||
|
||||
function defaultValue(entry) {
|
||||
if (entry.family === 'scribe') return PROMPTS.getDefaultPrompt(entry.key);
|
||||
return entry.family === 'clinical-text' ? clinical.DEFAULT_BEHAVIOR : clinical.DEFAULT_IMAGE_BEHAVIOR;
|
||||
}
|
||||
|
||||
function effective(entry, value) {
|
||||
const wasDefault = typeof value !== 'string' || value === '' || (entry.family === 'scribe' && !value.trim());
|
||||
return { value: wasDefault ? defaultValue(entry) : value, wasDefault };
|
||||
}
|
||||
|
||||
module.exports = { entries, find, defaultValue, effective };
|
||||
108
src/utils/promptRevisions.js
Normal file
108
src/utils/promptRevisions.js
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
const catalog = require('./promptCatalog');
|
||||
const PROMPTS = require('./prompts');
|
||||
const published = new Map();
|
||||
const columns = 'id, created_at AS "createdAt", created_by AS "createdBy", restored_from AS "restoredFrom", was_default AS "wasDefault"';
|
||||
const failure = (statusCode, message) => Object.assign(new Error(message), { statusCode });
|
||||
|
||||
function entryFor(key) {
|
||||
const entry = catalog.find(key);
|
||||
if (!entry) throw failure(404, 'Prompt not found');
|
||||
return entry;
|
||||
}
|
||||
|
||||
function revisionId(value) {
|
||||
if (!['string', 'number'].includes(typeof value) || !/^[1-9]\d*$/.test(String(value)) || !Number.isSafeInteger(Number(value))) throw failure(400, 'Invalid revision id');
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
async function list(db) {
|
||||
// One statement keeps the effective value and revision in the same read snapshot.
|
||||
const rows = await db.all(`SELECT keys.key, s.value,
|
||||
COALESCE((SELECT MAX(id) FROM prompt_revisions WHERE prompt_key = keys.key), 0) AS revision
|
||||
FROM unnest($1::text[]) AS keys(key) LEFT JOIN app_settings s ON s.key = keys.key`,
|
||||
[catalog.entries.map(entry => entry.dbKey)]);
|
||||
const byKey = new Map(rows.map(row => [row.key, row]));
|
||||
return catalog.entries.map(entry => {
|
||||
const row = byKey.get(entry.dbKey) || {};
|
||||
return { ...entry, value: catalog.effective(entry, row.value).value, revision: Number(row.revision || 0) };
|
||||
});
|
||||
}
|
||||
|
||||
async function history(db, key, limit = 20) {
|
||||
const entry = entryFor(key);
|
||||
if (!['string', 'number'].includes(typeof limit) || !Number.isInteger(Number(limit)) || Number(limit) < 1) throw failure(400, 'Invalid history limit');
|
||||
const revisions = await db.all(`SELECT ${columns} FROM prompt_revisions WHERE prompt_key = $1 ORDER BY id DESC LIMIT $2`,
|
||||
[entry.dbKey, Math.min(Number(limit), 100)]);
|
||||
return { revisions, revision: revisions.length ? revisions[0].id : 0 };
|
||||
}
|
||||
|
||||
async function read(db, key, id) {
|
||||
const entry = entryFor(key);
|
||||
const row = await db.get(`SELECT ${columns}, value FROM prompt_revisions WHERE prompt_key = $1 AND id = $2`, [entry.dbKey, revisionId(id)]);
|
||||
if (!row) throw failure(404, 'Revision not found');
|
||||
return row;
|
||||
}
|
||||
|
||||
async function mutate(db, key, options) {
|
||||
const entry = entryFor(key);
|
||||
const { action, expectedRevision, actor } = options;
|
||||
if (!['save', 'reset', 'restore'].includes(action)) throw failure(400, 'Invalid prompt action');
|
||||
if (expectedRevision !== undefined && (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0)) throw failure(400, 'Invalid expectedRevision');
|
||||
if (action === 'save' && (typeof options.value !== 'string' || !options.value.trim())) throw failure(400, 'Prompt value must be nonempty text');
|
||||
const restoredFrom = action === 'restore' ? revisionId(options.revisionId) : null;
|
||||
const client = await db.pool.connect();
|
||||
let result;
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
// Cross-connection serialization, including the first baseline when no row exists.
|
||||
await client.query('SELECT pg_advisory_xact_lock(hashtext($1))', [entry.dbKey]);
|
||||
const latest = await client.query('SELECT id FROM prompt_revisions WHERE prompt_key = $1 ORDER BY id DESC LIMIT 1', [entry.dbKey]);
|
||||
const current = latest.rows.length ? latest.rows[0].id : 0;
|
||||
if (expectedRevision !== undefined && expectedRevision !== current) throw failure(409, 'Prompt changed; reload its current revision before saving');
|
||||
|
||||
let value = action === 'reset' ? catalog.defaultValue(entry) : options.value;
|
||||
if (action === 'restore') {
|
||||
const restored = await client.query('SELECT value FROM prompt_revisions WHERE prompt_key = $1 AND id = $2', [entry.dbKey, restoredFrom]);
|
||||
if (!restored.rows.length) throw failure(404, 'Revision not found');
|
||||
// Pin the recorded effective text, even when it was an older shipped default.
|
||||
value = restored.rows[0].value;
|
||||
}
|
||||
const append = (text, wasDefault, createdBy, from) => client.query(
|
||||
'INSERT INTO prompt_revisions (prompt_key, value, was_default, created_by, restored_from) VALUES ($1, $2, $3, $4, $5) RETURNING id',
|
||||
[entry.dbKey, text, wasDefault, createdBy, from]);
|
||||
if (!current) {
|
||||
const setting = await client.query('SELECT value FROM app_settings WHERE key = $1', [entry.dbKey]);
|
||||
const baseline = catalog.effective(entry, setting.rows[0] && setting.rows[0].value);
|
||||
// The actor of a pre-history setting is unknown, not the admin making this edit.
|
||||
await append(baseline.value, baseline.wasDefault, null, null);
|
||||
}
|
||||
const added = await append(value, action === 'reset', actor == null ? null : actor, restoredFrom);
|
||||
if (action === 'reset') {
|
||||
await client.query('DELETE FROM app_settings WHERE key = $1', [entry.dbKey]);
|
||||
} else {
|
||||
await client.query('INSERT INTO app_settings (key, value, updated_at) VALUES ($1, $2, NOW()) ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()', [entry.dbKey, value]);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
result = { value, revision: added.rows[0].id };
|
||||
// Never replace the exported object or publish an uncommitted/older value.
|
||||
if (entry.family === 'scribe' && result.revision > (published.get(entry.dbKey) || 0)) {
|
||||
PROMPTS.updatePrompt(entry.key, value);
|
||||
published.set(entry.dbKey, result.revision);
|
||||
}
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK').catch(() => {});
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function respondError(res, error) {
|
||||
const status = error.code === '42P01' ? 503 : (error.statusCode || 500);
|
||||
// SQL/provider errors can echo values: do not expose or log them.
|
||||
return res.status(status).json({ error: status === 503 ? 'Prompt history is unavailable; apply the prompt revisions migration.' :
|
||||
error.statusCode ? error.message : 'Prompt request failed' });
|
||||
}
|
||||
|
||||
module.exports = { list, history, read, mutate, respondError };
|
||||
|
|
@ -586,35 +586,38 @@ List 2-4 practical questions parents can ask if anything is unclear.`
|
|||
// Routes still use PROMPTS.key synchronously — this patches them from DB on startup.
|
||||
// Admin edits call updatePrompt() to update in-memory immediately.
|
||||
|
||||
// Capture only shipped string keys before attaching helpers or loading overrides.
|
||||
const DEFAULTS = Object.freeze({ ...PROMPTS });
|
||||
const changes = Object.create(null);
|
||||
|
||||
async function loadFromDb(db) {
|
||||
try {
|
||||
var keys = Object.keys(PROMPTS);
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
var key = keys[i];
|
||||
var val = await db.getSetting('prompt.' + key);
|
||||
if (val && val.trim()) PROMPTS[key] = val;
|
||||
for (const key of Object.keys(DEFAULTS)) {
|
||||
const before = changes[key];
|
||||
const value = await db.getSetting('prompt.' + key);
|
||||
// A slow startup load must not overwrite an admin edit committed meanwhile.
|
||||
if (changes[key] === before) updatePrompt(key, typeof value === 'string' && value.trim() ? value : DEFAULTS[key]);
|
||||
}
|
||||
console.log('✅ Prompts: DB overrides loaded');
|
||||
} catch (e) {
|
||||
console.warn('[Prompts] DB load failed, using hardcoded defaults:', e.message);
|
||||
} catch (_) {
|
||||
console.warn('[Prompts] DB load failed; current prompts retained');
|
||||
}
|
||||
}
|
||||
|
||||
function updatePrompt(key, value) {
|
||||
if (Object.prototype.hasOwnProperty.call(PROMPTS, key) && value && value.trim()) {
|
||||
PROMPTS[key] = value;
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(DEFAULTS, key) || typeof value !== 'string' || !value.trim()) return false;
|
||||
PROMPTS[key] = value;
|
||||
changes[key] = (changes[key] || 0) + 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
function getAll() {
|
||||
return Object.keys(PROMPTS)
|
||||
.filter(function(k) { return typeof PROMPTS[k] === 'string'; })
|
||||
.map(function(k) { return { key: k, value: PROMPTS[k] }; });
|
||||
return Object.keys(DEFAULTS).map(function(key) { return { key: key, value: PROMPTS[key] }; });
|
||||
}
|
||||
|
||||
PROMPTS.loadFromDb = loadFromDb;
|
||||
PROMPTS.updatePrompt = updatePrompt;
|
||||
PROMPTS.getAllPrompts = getAll;
|
||||
PROMPTS.getDefaultPrompt = key => Object.prototype.hasOwnProperty.call(DEFAULTS, key) ? DEFAULTS[key] : undefined;
|
||||
|
||||
module.exports = PROMPTS;
|
||||
// Note: actual additions below are appended after module.exports
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ function getLiteLLMTTSRequestOptions(model) {
|
|||
}
|
||||
|
||||
function isLiteLLMTTSVoiceCompatible(model, voice) {
|
||||
if (!voice) return true;
|
||||
if (typeof voice !== 'string' || !voice.trim()) return false;
|
||||
var family = getLiteLLMTTSModelFamily(model);
|
||||
if (family === 'kitten') return KITTEN_TTS_VOICES.indexOf(voice) !== -1;
|
||||
if (family === 'supertonic') return SUPERTONIC_TTS_VOICES.indexOf(voice) !== -1;
|
||||
|
|
|
|||
1122
test/account-boundary.test.js
Normal file
1122
test/account-boundary.test.js
Normal file
File diff suppressed because it is too large
Load diff
115
test/admin-clinical-assistant-wiring.test.js
Normal file
115
test/admin-clinical-assistant-wiring.test.js
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { pathToFileURL } = require('node:url');
|
||||
const { JSDOM } = require('jsdom');
|
||||
const root = path.join(__dirname, '..');
|
||||
const read = file => fs.readFileSync(path.join(root, file), 'utf8');
|
||||
const tick = () => new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
function browserGlobals(t, dom, fetch, toasts) {
|
||||
const values = { window: dom.window, document: dom.window.document, fetch, getAuthHeaders: () => ({ 'X-Test': 'synthetic' }), showToast: (...args) => toasts.push(args) };
|
||||
const originals = Object.keys(values).map(key => [key, Object.getOwnPropertyDescriptor(global, key)]);
|
||||
Object.assign(global, values);
|
||||
Object.assign(dom.window, { fetch, getAuthHeaders: values.getAuthHeaders });
|
||||
t.after(() => {
|
||||
for (const [key, descriptor] of originals) { if (descriptor) Object.defineProperty(global, key, descriptor); else delete global[key]; }
|
||||
dom.window.close();
|
||||
});
|
||||
}
|
||||
|
||||
test('native admin initializer preserves lazy navigation, assistant actions and read-only ENV budget metadata', async t => {
|
||||
const dom = new JSDOM('<button class="tab-btn active" data-tab="home">Home</button><button class="tab-btn" data-tab="admin">Admin</button><div id="home-tab" class="tab-content" data-component="home" data-loaded="1"></div><div id="admin-tab" class="tab-content" data-component="admin"></div>', { runScripts: 'outside-only', url: 'https://app.example' });
|
||||
const calls = []; const toasts = [];
|
||||
const fetch = async (url, options = {}) => {
|
||||
calls.push({ url, options });
|
||||
let data = {};
|
||||
if (url.startsWith('/components/admin.html?')) return { ok: true, text: async () => read('public/components/admin.html') };
|
||||
if (url === '/api/models') data = { models: [{ id: 'chat', name: 'Chat' }], defaultModel: 'chat' };
|
||||
if (url === '/api/admin/config') {
|
||||
assert.ok(document.getElementById('assistant-chat-model'), 'tabChanged fires after lazy markup exists');
|
||||
data = { success: true, conversationBudget: { limit: 240000, unit: 'characters', measure: 'UTF-16 code units', env: 'CLINICAL_ASSISTANT_CONVERSATION_CHARS', source: 'environment' }, config: [{ key: 'clinical_assistant.conversation_chars', value: '999999' }, { key: 'clinical_assistant.image_model', value: 'saved-image' }] };
|
||||
}
|
||||
if (url === '/api/admin/config/image-models/discover') data = { models: [{ id: 'image', name: 'Image' }] };
|
||||
if (url === '/api/admin/clinical-assistant/prompt-pool') data = { success: true, meta: { count: 3 }, snapshots: [{ id: 7, count: 3 }] };
|
||||
if (options.method) data = { success: true, duration: 1, response: 'Synthetic', meta: { count: 4 }, snapshots: [{ id: 7, count: 4 }] };
|
||||
return { ok: true, json: async () => data };
|
||||
};
|
||||
browserGlobals(t, dom, fetch, toasts);
|
||||
await import(pathToFileURL(path.join(root, 'public/js/admin.js')).href);
|
||||
assert.equal(calls.length, 0, 'registration must not eagerly load assistant settings');
|
||||
// JSDOM does not execute native script tags. Import the unmodified ESM above,
|
||||
// then execute the real classic app entrypoint and its component loader.
|
||||
await tick();
|
||||
dom.window.eval(read('public/js/app.js'));
|
||||
document.dispatchEvent(new dom.window.Event('DOMContentLoaded'));
|
||||
await tick();
|
||||
assert.equal(document.getElementById('assistant-chat-model'), null);
|
||||
document.querySelector('[data-tab="admin"]').click();
|
||||
await tick(); await tick();
|
||||
assert.equal(document.getElementById('admin-tab').dataset.loaded, '1');
|
||||
assert.equal(document.getElementById('assistant-chat-model').options[0].textContent, 'Use global default (chat)');
|
||||
assert.equal(document.getElementById('assistant-image-model').value, 'saved-image');
|
||||
assert.equal(window._assistantImageModelValue, 'saved-image');
|
||||
assert.match(document.getElementById('assistant-prompt-pool-status').textContent, /3 prompts/);
|
||||
const budget = document.getElementById('assistant-conversation-budget');
|
||||
assert.equal(document.querySelectorAll('#assistant-conversation-chars').length, 0);
|
||||
assert.match(budget.textContent, /240,000 characters \(UTF-16 code units\).*CLINICAL_ASSISTANT_CONVERSATION_CHARS \(environment\)/);
|
||||
assert.doesNotMatch(budget.textContent, /999/);
|
||||
const initialConfigLoads = calls.filter(c => c.url === '/api/admin/config').length;
|
||||
document.querySelector('[data-tab="home"]').click(); await tick();
|
||||
document.querySelector('[data-tab="admin"]').click(); await tick();
|
||||
assert.equal(calls.filter(c => c.url.startsWith('/components/admin.html?')).length, 1);
|
||||
assert.equal(calls.filter(c => c.url === '/api/admin/config').length, initialConfigLoads, 'initializer loads once across revisits');
|
||||
|
||||
const writes = () => calls.filter(c => c.options.method === 'PUT');
|
||||
const save = document.getElementById('btn-save-assistant-config');
|
||||
save.click(); await tick();
|
||||
assert.equal(writes().length, 4);
|
||||
assert.deepEqual(writes().map(c => c.url.split('/').pop()).sort(), [
|
||||
'clinical_assistant.chat_model', 'clinical_assistant.context_chars', 'clinical_assistant.image_model', 'clinical_assistant.search_limit'
|
||||
]);
|
||||
assert.ok(toasts.some(([message, kind]) => message === 'Assistant settings saved' && kind === 'success'));
|
||||
|
||||
document.getElementById('btn-test-assistant-chat-model').click(); await tick();
|
||||
assert.deepEqual(JSON.parse(calls.find(c => c.url === '/api/admin/config/models/test').options.body), { modelId: 'chat' });
|
||||
document.getElementById('btn-regenerate-assistant-prompt-pool').click(); await tick();
|
||||
assert.equal(document.getElementById('btn-regenerate-assistant-prompt-pool').disabled, false);
|
||||
document.getElementById('assistant-prompt-pool-snapshots').value = '7';
|
||||
document.getElementById('btn-restore-assistant-prompt-pool').click(); await tick();
|
||||
assert.deepEqual(JSON.parse(calls.find(c => c.url.endsWith('/prompt-pool/restore')).options.body), { id: 7 });
|
||||
document.getElementById('assistant-custom-image-model').value = '\"><svg onload=alert(1)>';
|
||||
document.getElementById('btn-use-custom-assistant-image-model').click();
|
||||
document.getElementById('btn-test-assistant-image-model').click();
|
||||
assert.equal(document.querySelector('#assistant-image-test-result svg'), null, 'initializer uses passed admin escape helper');
|
||||
await tick();
|
||||
assert.ok(calls.some(c => c.url === '/api/admin/config/image-models/test' && c.options.method === 'POST'));
|
||||
});
|
||||
|
||||
test('real extracted initializer never invents a cap when metadata is missing, invalid or returns 503', async t => {
|
||||
const { initClinicalAssistantAdmin } = await import(pathToFileURL(path.join(root, 'public/js/admin/clinicalAssistant.js')).href);
|
||||
for (const data of [{}, { success: false, error: 'Invalid environment' }, { success: true, conversationBudget: { limit: 1000001 } }]) {
|
||||
await t.test(JSON.stringify(data), async t => {
|
||||
const dom = new JSDOM(read('public/components/admin.html'));
|
||||
browserGlobals(t, dom, async () => ({ status: 503, json: async () => data }), []);
|
||||
initClinicalAssistantAdmin(value => value);
|
||||
document.dispatchEvent(new dom.window.CustomEvent('tabChanged', { detail: { tab: 'admin' } }));
|
||||
await tick();
|
||||
assert.match(document.getElementById('assistant-conversation-budget').textContent, /unavailable/);
|
||||
assert.doesNotMatch(document.getElementById('assistant-conversation-budget').textContent, /120,?000|1,?000,?001/);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('real extracted initializer displays the server default only when returned as metadata', async t => {
|
||||
const dom = new JSDOM(read('public/components/admin.html'));
|
||||
browserGlobals(t, dom, async () => ({ ok: true, json: async () => ({ success: true, config: [], models: [], conversationBudget: {
|
||||
limit: 120000, unit: 'characters', measure: 'UTF-16 code units', env: 'CLINICAL_ASSISTANT_CONVERSATION_CHARS', source: 'default'
|
||||
} }) }), []);
|
||||
const { initClinicalAssistantAdmin } = await import(pathToFileURL(path.join(root, 'public/js/admin/clinicalAssistant.js')).href);
|
||||
initClinicalAssistantAdmin(value => value);
|
||||
document.dispatchEvent(new dom.window.CustomEvent('tabChanged', { detail: { tab: 'admin' } }));
|
||||
await tick();
|
||||
assert.match(document.getElementById('assistant-conversation-budget').textContent, /120,000 characters \(UTF-16 code units\).*server default; environment unset/);
|
||||
});
|
||||
152
test/assistant-component-css.test.js
Normal file
152
test/assistant-component-css.test.js
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { JSDOM, requestInterceptor, VirtualConsole } = require('jsdom');
|
||||
const read = file => fs.readFileSync(path.join(__dirname, '..', file), 'utf8');
|
||||
const css = read('public/css/assistant.css');
|
||||
|
||||
function loaderFixture() {
|
||||
let requested;
|
||||
const pending = [];
|
||||
const requests = [];
|
||||
const dom = new JSDOM('<script src="/js/app.js?v=fixture-build"></script><button class="tab-btn" data-tab="assistant"></button><section id="assistant-tab" class="tab-content" data-component="assistant"></section><button class="tab-btn" data-tab="notes"></button><section id="notes-tab" class="tab-content"></section>', {
|
||||
url: 'https://example.test/', runScripts: 'outside-only', virtualConsole: new VirtualConsole(),
|
||||
resources: { interceptors: [requestInterceptor(request => {
|
||||
assert.equal(request.url, 'https://example.test/css/assistant.css?v=fixture-build');
|
||||
return new Promise(resolve => { pending.push(resolve); if (requested) requested(); });
|
||||
})] }
|
||||
});
|
||||
dom.window.fetch = async url => {
|
||||
requests.push(url);
|
||||
if (url === '/components/assistant.html?v=fixture-build') return new Response(read('public/components/assistant.html'));
|
||||
assert.equal(url, '/api/models');
|
||||
return new Response(JSON.stringify({ models: [] }));
|
||||
};
|
||||
dom.window.eval(read('public/js/app.js'));
|
||||
return {
|
||||
dom, requests,
|
||||
async stylesheetRequested() { if (!pending.length) await new Promise(resolve => { requested = resolve; }); requested = null; },
|
||||
release(status = 200) { pending.shift()(new Response(css, { status, headers: { 'Content-Type': 'text/css' } })); }
|
||||
};
|
||||
}
|
||||
|
||||
function nextTab(dom) {
|
||||
return new Promise(resolve => dom.window.document.addEventListener('tabChanged', resolve, { once: true }));
|
||||
}
|
||||
|
||||
test('actual component loader waits for versioned CSS before first initialization and cached reopen', async () => {
|
||||
const fixture = loaderFixture();
|
||||
const { dom } = fixture;
|
||||
const tab = dom.window.document.getElementById('assistant-tab');
|
||||
let events = 0;
|
||||
dom.window.document.addEventListener('tabChanged', () => { events++; });
|
||||
await fixture.stylesheetRequested();
|
||||
dom.window.activateTab('assistant');
|
||||
assert.equal(events, 0);
|
||||
assert.equal(tab.dataset.loaded, undefined);
|
||||
assert.equal(tab.lastElementChild.tagName, 'LINK', 'Stylesheet stays at the previous style block position');
|
||||
assert.equal(tab.querySelector('style'), null);
|
||||
assert.equal(tab.getAttribute('aria-busy'), 'true');
|
||||
assert.match(tab.querySelector('[role="status"]').textContent, /Loading/);
|
||||
assert.ok(tab.querySelector('#assistant-input').closest('[inert]'), 'Native inert prevents entering a draft before handlers exist');
|
||||
const ready = nextTab(dom);
|
||||
fixture.release();
|
||||
await ready;
|
||||
assert.equal(tab.dataset.loaded, '1');
|
||||
assert.equal(tab.querySelector('#assistant-input').closest('[inert]'), null);
|
||||
assert.equal(tab.hasAttribute('aria-busy'), false);
|
||||
assert.equal(dom.window.getComputedStyle(tab.querySelector('.assistant-messages')).maxHeight, '65vh');
|
||||
const bubble = dom.window.document.createElement('div');
|
||||
bubble.className = 'assistant-msg user';
|
||||
bubble.innerHTML = '<div class="assistant-bubble"> preserve\nline</div>';
|
||||
tab.appendChild(bubble);
|
||||
assert.equal(dom.window.getComputedStyle(bubble.firstChild).whiteSpace, 'pre-wrap');
|
||||
const unchangedLink = tab.querySelector('link');
|
||||
const reactivated = nextTab(dom);
|
||||
dom.window.activateTab('assistant');
|
||||
await reactivated;
|
||||
assert.equal(tab.querySelector('link'), unchangedLink, 'Normal reopen keeps the loaded CSS');
|
||||
|
||||
// Exercise the loader's HTML-cache path too, not only the already-loaded fast path.
|
||||
tab.replaceChildren();
|
||||
delete tab.dataset.loaded;
|
||||
dom.window.activateTab('assistant');
|
||||
await fixture.stylesheetRequested();
|
||||
assert.equal(events, 2);
|
||||
assert.equal(tab.dataset.loaded, undefined);
|
||||
const reopened = nextTab(dom);
|
||||
fixture.release();
|
||||
await reopened;
|
||||
assert.equal(tab.dataset.loaded, '1');
|
||||
assert.equal(dom.window.getComputedStyle(tab.querySelector('.assistant-messages')).maxHeight, '65vh');
|
||||
assert.equal(fixture.requests.filter(url => url.includes('/components/')).length, 1);
|
||||
assert.ok(css.indexOf('@media (max-width: 960px)') < css.indexOf('@media (max-width: 640px)'));
|
||||
dom.window.close();
|
||||
});
|
||||
|
||||
test('stylesheet failure never marks an unstyled assistant ready; reactivation retries', async () => {
|
||||
const fixture = loaderFixture();
|
||||
const { dom } = fixture;
|
||||
const tab = dom.window.document.getElementById('assistant-tab');
|
||||
await fixture.stylesheetRequested();
|
||||
dom.window.activateTab('assistant');
|
||||
assert.ok(tab.querySelector('#assistant-input').closest('[inert]'));
|
||||
let readyEvents = 0;
|
||||
dom.window.document.addEventListener('tabChanged', () => { readyEvents++; });
|
||||
const failed = new Promise(resolve => {
|
||||
const observer = new dom.window.MutationObserver(() => {
|
||||
if (tab.querySelector('[role="alert"]')) { observer.disconnect(); resolve(); }
|
||||
});
|
||||
observer.observe(tab, { childList: true });
|
||||
});
|
||||
fixture.release(503);
|
||||
await failed;
|
||||
assert.equal(readyEvents, 0, 'Failure must not initialize an absent component');
|
||||
assert.equal(tab.hasAttribute('aria-busy'), false);
|
||||
assert.equal(tab.dataset.loaded, undefined);
|
||||
assert.equal(tab.querySelector('#assistant-form'), null);
|
||||
assert.match(tab.textContent, /Failed to load/);
|
||||
dom.window.activateTab('assistant');
|
||||
await fixture.stylesheetRequested();
|
||||
const recovered = nextTab(dom);
|
||||
fixture.release();
|
||||
await recovered;
|
||||
assert.equal(tab.dataset.loaded, '1');
|
||||
assert.ok(tab.querySelector('#assistant-form'));
|
||||
assert.equal(tab.querySelector('#assistant-input').closest('[inert]'), null);
|
||||
dom.window.close();
|
||||
});
|
||||
|
||||
test('out-of-order CSS completion does not initialize an inactive tab; returning binds before enabling', async () => {
|
||||
const fixture = loaderFixture();
|
||||
const { dom } = fixture;
|
||||
const tab = dom.window.document.getElementById('assistant-tab');
|
||||
const events = [];
|
||||
dom.window.document.addEventListener('tabChanged', event => {
|
||||
events.push(event.detail.tab);
|
||||
if (event.detail.tab === 'assistant') {
|
||||
assert.ok(tab.querySelector('#assistant-input').closest('[inert]'), 'Controls remain inert while initialization listeners run');
|
||||
}
|
||||
});
|
||||
await fixture.stylesheetRequested();
|
||||
dom.window.activateTab('assistant');
|
||||
const notesReady = nextTab(dom);
|
||||
dom.window.activateTab('notes');
|
||||
await notesReady;
|
||||
const cssReady = new Promise(resolve => tab.querySelector('link').addEventListener('load', resolve, { once: true }));
|
||||
fixture.release();
|
||||
await cssReady;
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.deepEqual(events, ['notes']);
|
||||
assert.equal(tab.dataset.loaded, '1');
|
||||
assert.equal(tab.classList.contains('active'), false);
|
||||
assert.ok(tab.querySelector('#assistant-input').closest('[inert]'));
|
||||
const assistantReady = nextTab(dom);
|
||||
dom.window.activateTab('assistant');
|
||||
await assistantReady;
|
||||
assert.deepEqual(events, ['notes', 'assistant']);
|
||||
assert.equal(tab.querySelector('#assistant-input').closest('[inert]'), null);
|
||||
assert.equal(tab.querySelector('[role="status"]'), null);
|
||||
dom.window.close();
|
||||
});
|
||||
|
|
@ -2,6 +2,7 @@ const { test } = require('node:test');
|
|||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const { pathToFileURL } = require('node:url');
|
||||
const { normalizeMcpSearchResponse } = require('../src/utils/clinicalRetrieval');
|
||||
|
||||
async function sourcesModule() {
|
||||
return import(pathToFileURL(path.join(__dirname, '..', 'public/js/assistant/sources.js')).href);
|
||||
|
|
@ -9,8 +10,47 @@ async function sourcesModule() {
|
|||
|
||||
test('assistant source excerpts hide image markdown but keep OCR text', async () => {
|
||||
const { renderSourcesList } = await sourcesModule();
|
||||
const html = renderSourcesList([{ title: 'Nelson', excerpt: ' AGE STREAMS OF DEVELOPMENT' }]);
|
||||
const sources = normalizeMcpSearchResponse({ results: [{ title: 'Nelson', excerpt: ' AGE STREAMS OF DEVELOPMENT' }] });
|
||||
const html = renderSourcesList(sources);
|
||||
|
||||
assert.match(html, /AGE STREAMS OF DEVELOPMENT/);
|
||||
assert.doesNotMatch(html, /tmp\/pdf-images|!\[\]/);
|
||||
});
|
||||
|
||||
test('actual source rendering preserves full bounded tables, whitespace and escaping', async () => {
|
||||
const { JSDOM } = require('jsdom');
|
||||
const { renderSourcesList } = await sourcesModule();
|
||||
const rows = Array.from({ length: 70 }, (_, i) => '| Drug ' + i + ' | 2 mg/kg |');
|
||||
const sources = normalizeMcpSearchResponse({ results: [{
|
||||
id: 42, title: 'Fixture', page_number: 7,
|
||||
excerpt: 'Table 1. Dose\n\n| Drug | Dose |\n|---|---|\n' + rows.join('\n') + '\n\nNote: Synthetic values only.'
|
||||
}] });
|
||||
assert.ok(sources[0].excerpt.length > 900);
|
||||
assert.ok(sources[0].excerpt.length <= 1800);
|
||||
const doc = new JSDOM(renderSourcesList(sources)).window.document;
|
||||
const excerpt = doc.querySelector('.assistant-source-excerpt p');
|
||||
assert.equal(excerpt.textContent, sources[0].excerpt);
|
||||
assert.equal(excerpt.style.whiteSpace, 'pre-wrap');
|
||||
assert.match(excerpt.textContent, /\| Drug \| Dose \|\n\|---\|---\|\n/);
|
||||
assert.match(excerpt.textContent, /Note: Synthetic values only\.$/);
|
||||
assert.match(doc.querySelector('.assistant-source-meta').textContent, /page 7/);
|
||||
|
||||
const unsafe = '<img src=x onerror=alert(1)>\n| A & B | <script>x</script> |';
|
||||
const escaped = new JSDOM(renderSourcesList([{ title: '<b>Title</b>', excerpt: unsafe }])).window.document;
|
||||
assert.equal(escaped.querySelector('.assistant-source-excerpt').textContent, unsafe);
|
||||
assert.equal(escaped.querySelector('img, script, b'), null);
|
||||
});
|
||||
|
||||
test('legacy source numbers remain text and cannot inject attributes or markup', async () => {
|
||||
const { JSDOM } = require('jsdom');
|
||||
const { renderSourcesList } = await sourcesModule();
|
||||
for (const number of ['1" onclick="alert(1)', '1"><img src=x onerror=alert(1)>', "1'><script>alert(1)</script>", '1', 2]) {
|
||||
const dom = new JSDOM(renderSourcesList([{ number, title: 'Legacy source' }]));
|
||||
const card = dom.window.document.querySelector('.assistant-source');
|
||||
assert.equal(card.id, 'assistant-source-' + number);
|
||||
assert.equal(card.querySelector('strong').textContent, '[' + number + '] Legacy source');
|
||||
assert.deepEqual(card.getAttributeNames().sort(), ['class', 'id']);
|
||||
assert.equal(dom.window.document.querySelector('img, script, [onclick], [onerror]'), null);
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
92
test/build-id.test.js
Normal file
92
test/build-id.test.js
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
'use strict';
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const { getBuildId, isGitRevision } = require('../src/utils/buildId');
|
||||
|
||||
test('build ID resolves real Git checkouts, packed refs, worktrees, baked images and unknown sources', t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ped-build-id-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const repo = path.join(root, 'repo');
|
||||
fs.mkdirSync(repo);
|
||||
const git = (...args) => execFileSync('git', ['-C', repo, ...args], {
|
||||
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
git('init');
|
||||
git('-c', 'user.name=Build Test', '-c', 'user.email=build@example.invalid',
|
||||
'-c', 'commit.gpgsign=false', 'commit', '--allow-empty', '-m', 'test');
|
||||
const sha = git('rev-parse', 'HEAD');
|
||||
assert.equal(sha.length, 40);
|
||||
assert.equal(getBuildId(repo), sha);
|
||||
|
||||
git('pack-refs', '--all', '--prune');
|
||||
assert.equal(fs.existsSync(path.join(repo, '.git', git('symbolic-ref', 'HEAD'))), false);
|
||||
assert.equal(getBuildId(repo), sha);
|
||||
|
||||
const worktree = path.join(root, 'worktree');
|
||||
git('worktree', 'add', '--detach', worktree, 'HEAD');
|
||||
assert.equal(fs.statSync(path.join(worktree, '.git')).isFile(), true);
|
||||
assert.equal(getBuildId(worktree), sha);
|
||||
git('checkout', '--detach', 'HEAD');
|
||||
assert.equal(getBuildId(repo), sha);
|
||||
|
||||
// No accidental ancestor Git discovery for an unversioned source directory.
|
||||
const plain = path.join(repo, 'plain');
|
||||
fs.mkdirSync(plain);
|
||||
assert.equal(getBuildId(plain), 'unknown');
|
||||
const baked = 'b'.repeat(40);
|
||||
fs.writeFileSync(path.join(plain, 'BUILD_ID'), baked + '\n');
|
||||
assert.equal(getBuildId(plain), baked);
|
||||
fs.writeFileSync(path.join(worktree, 'BUILD_ID'), baked);
|
||||
assert.equal(getBuildId(worktree), baked, 'baked metadata takes precedence');
|
||||
for (const invalid of ['unknown', '', 'abc1234', 'z'.repeat(40), '<script>', sha + '\r\nInjected: yes']) {
|
||||
fs.writeFileSync(path.join(worktree, 'BUILD_ID'), invalid);
|
||||
assert.equal(getBuildId(worktree), 'unknown');
|
||||
assert.equal(isGitRevision(invalid), false);
|
||||
}
|
||||
assert.equal(isGitRevision(sha + '\n'), false);
|
||||
assert.equal(isGitRevision(sha.toUpperCase()), false);
|
||||
fs.writeFileSync(path.join(plain, '.git'), 'gitdir: /nonexistent/ped-build-test\n');
|
||||
fs.unlinkSync(path.join(plain, 'BUILD_ID'));
|
||||
assert.equal(getBuildId(plain), 'unknown');
|
||||
});
|
||||
|
||||
test('manual build script passes the full revision to Compose without starting services', t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ped-build-script-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
fs.mkdirSync(path.join(root, 'scripts'));
|
||||
fs.copyFileSync(path.join(__dirname, '../scripts/build-image.sh'), path.join(root, 'scripts/build-image.sh'));
|
||||
const bin = path.join(root, 'bin');
|
||||
fs.mkdirSync(bin);
|
||||
fs.writeFileSync(path.join(bin, 'docker'), '#!/bin/sh\nprintf "%s\\n" "$GIT_REVISION" "$@"\n', { mode: 0o755 });
|
||||
const build = (env = {}) => execFileSync('/bin/sh', [path.join(root, 'scripts/build-image.sh'), '--no-cache'], {
|
||||
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { ...process.env, PATH: bin + path.delimiter + process.env.PATH, GIT_REVISION: 'bad-override', ...env },
|
||||
}).trim().split('\n');
|
||||
assert.deepEqual(build(), ['unknown', 'compose', 'build', '--no-cache', 'pediatric-scribe']);
|
||||
const git = (...args) => execFileSync('git', ['-C', root, ...args], {
|
||||
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
git('init');
|
||||
git('-c', 'user.name=Build Test', '-c', 'user.email=build@example.invalid',
|
||||
'-c', 'commit.gpgsign=false', 'commit', '--allow-empty', '-m', 'test');
|
||||
const sha = git('rev-parse', 'HEAD');
|
||||
assert.deepEqual(build(), [sha, 'compose', 'build', '--no-cache', 'pediatric-scribe']);
|
||||
|
||||
const foreign = path.join(root, 'foreign');
|
||||
git('init', foreign);
|
||||
git('-C', foreign, '-c', 'user.name=Build Test', '-c', 'user.email=build@example.invalid',
|
||||
'-c', 'commit.gpgsign=false', 'commit', '--allow-empty', '-m', 'foreign');
|
||||
assert.notEqual(git('-C', foreign, 'rev-parse', 'HEAD'), sha);
|
||||
const controls = { GIT_DIR: path.join(foreign, '.git'), GIT_WORK_TREE: foreign,
|
||||
GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'core.bare', GIT_CONFIG_VALUE_0: 'true' };
|
||||
assert.deepEqual(build(controls), [sha, 'compose', 'build', '--no-cache', 'pediatric-scribe']);
|
||||
// A Docker-only source archive still works without Git installed.
|
||||
fs.rmSync(path.join(root, '.git'), { recursive: true });
|
||||
fs.symlinkSync('/usr/bin/dirname', path.join(bin, 'dirname'));
|
||||
assert.deepEqual(build({ ...controls, PATH: bin }), ['unknown', 'compose', 'build', '--no-cache', 'pediatric-scribe']);
|
||||
});
|
||||
|
|
@ -36,7 +36,7 @@ test('clinical assistant starter prompts are Redis or indexed-source backed', ()
|
|||
test('clinical assistant prompt pool can be regenerated by admins', () => {
|
||||
const admin = read('src/routes/adminConfig.js');
|
||||
const page = read('public/components/admin.html');
|
||||
const js = read('public/js/admin.js');
|
||||
const js = read('public/js/admin/clinicalAssistant.js');
|
||||
assert.match(admin, /\/clinical-assistant\/prompt-pool\/regenerate/);
|
||||
assert.match(admin, /\/clinical-assistant\/prompt-pool\/restore/);
|
||||
assert.match(page, /btn-regenerate-assistant-prompt-pool/);
|
||||
|
|
|
|||
414
test/clinical-conversation.test.js
Normal file
414
test/clinical-conversation.test.js
Normal file
|
|
@ -0,0 +1,414 @@
|
|||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const express = require('express');
|
||||
const { JSDOM, requestInterceptor } = require('jsdom');
|
||||
const { pathToFileURL } = require('node:url');
|
||||
const policy = require('../src/utils/clinicalConversation');
|
||||
const answer = require('../src/utils/clinicalAnswer');
|
||||
const root = path.join(__dirname, '..');
|
||||
const read = file => fs.readFileSync(path.join(root, file), 'utf8');
|
||||
const png = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aL9sAAAAASUVORK5CYII=';
|
||||
const quiet = { log() {}, warn() {}, error() {}, info() {} };
|
||||
|
||||
function server(options = {}) {
|
||||
const calls = { ai: [], search: [], writes: [], images: [], health: [] };
|
||||
let saved;
|
||||
const db = {
|
||||
async getSetting(key) {
|
||||
if (options.dbError) throw Error('private diagnostic');
|
||||
if (key.endsWith('conversation_chars')) return options.legacyLimit ?? 'broken';
|
||||
if (key === 'clinical_assistant.image_behavior') return options.imageBehavior ?? null;
|
||||
return options.model || null;
|
||||
},
|
||||
async get(sql, params) {
|
||||
if (sql.includes('COUNT')) return { cnt: 0 };
|
||||
return saved && params[1] === 7 ? { id: 1, title: saved[1], payload: saved[2] } : null;
|
||||
},
|
||||
async run(sql, params) { calls.writes.push(sql); saved = params; return { lastInsertRowid: 1 }; }
|
||||
};
|
||||
const ai = async (messages, settings) => {
|
||||
calls.ai.push({ messages, settings });
|
||||
return { content: options.emptyAI ? '' : 'Complete supported answer. [1]', finishReason: Object.hasOwn(options, 'finishReason') ? options.finishReason : 'stop', model: 'synthetic' };
|
||||
};
|
||||
const source = { number: 1, title: 'Synthetic source', excerpt: 'Synthetic reference.', page: 9 };
|
||||
const mocks = {
|
||||
express, axios: { async post(url, payload) { calls.images.push(payload); return { data: { data: [{ b64_json: 'c3ludGhldGlj' }] } }; } }, crypto: require('node:crypto'), '../db/database': db,
|
||||
'../middleware/auth': { authMiddleware() {} }, '../utils/ai': options.ai || { callAI: ai, callAIStream: ai },
|
||||
'../utils/errors': { gatewayUrl: path => 'http://synthetic.invalid' + path }, '../utils/litellm': { getLiteLLMHeaders: () => ({}) }, '../utils/logger': { audit() {}, error() {} },
|
||||
'../utils/crypto': { encryptString: value => 'encrypted:' + value, decryptString: value => value.replace(/^encrypted:/, '') },
|
||||
'../utils/redis': { async getJson() { return null; }, async setJson() {} }, '../utils/clinicalPromptPool': { createClinicalPromptPool: () => ({}) },
|
||||
'../utils/clinicalPrompts': require('../src/utils/clinicalPrompts'),
|
||||
'../utils/clinicalMcpClient': {
|
||||
async semanticSearch(query) { calls.search.push(query); return {}; }, async getMcpHealth() { calls.health.push('health'); return {}; }
|
||||
},
|
||||
'../utils/clinicalRetrieval': {
|
||||
cleanSourceExcerpt: require('../src/utils/clinicalRetrieval').cleanSourceExcerpt,
|
||||
normalizeMcpSearchResponse: () => options.noSources ? [] : [source],
|
||||
normalizeMcpMultimodalResponse: () => [], dedupeSources: value => value,
|
||||
isVisualSourceQuery: () => false, classifyAndRerankMultimodalResults: async () => []
|
||||
},
|
||||
'../utils/clinicalAnswer': answer, '../utils/clinicalConversation': policy
|
||||
};
|
||||
const module = { exports: {} };
|
||||
vm.runInNewContext(read('src/routes/clinicalAssistant.js'), {
|
||||
module, exports: module.exports, console: quiet, Buffer, Map,
|
||||
process: { env: { CLINICAL_ASSISTANT_MCP_WARMUP: 'false', CLINICAL_ASSISTANT_CONVERSATION_CHARS: options.limit, LITELLM_API_BASE: 'http://synthetic.invalid' } }, setTimeout() {},
|
||||
require(name) { assert.ok(Object.hasOwn(mocks, name), 'Unexpected import: ' + name); return mocks[name]; }
|
||||
});
|
||||
async function request(method, endpoint, body, userId = 7) {
|
||||
const handler = module.exports.stack.find(layer => layer.route && layer.route.path === endpoint && layer.route.methods[method]).route.stack.find(layer => layer.method === method).handle;
|
||||
const res = {
|
||||
statusCode: 200, headers: {}, events: '', body: null,
|
||||
status(code) { this.statusCode = code; return this; }, json(body) { this.body = body; return this; },
|
||||
setHeader(k, v) { this.headers[k] = v; }, write(text) { this.events += text; }, end() {}, flushHeaders() {}
|
||||
};
|
||||
await handler({ body, params: { id: 1 }, user: { id: userId } }, res);
|
||||
return res;
|
||||
}
|
||||
return { calls, request };
|
||||
}
|
||||
|
||||
test('conversation budget validates exact boundaries, Unicode, malformed roles and configuration', () => {
|
||||
assert.equal(policy.conversationLimit(null), 120000);
|
||||
for (const value of ['oops', '999', '1000001', '1.5', NaN, false, [1000], {}]) assert.throws(() => policy.conversationLimit(value));
|
||||
const history = [{ role: 'user', content: '😀\n'.repeat(333) }];
|
||||
assert.equal(policy.checkConversation(history, 'x', 1000).budget.used, 1000);
|
||||
assert.throws(() => policy.checkConversation(history, 'xx', 1000), error => error.statusCode === 413);
|
||||
for (const history of [null, {}, [{ role: 'system', content: 'x' }], [{ role: 'user', content: 123 }]]) {
|
||||
assert.throws(() => policy.checkConversation(history, 'x', 1000), error => error.statusCode === 400);
|
||||
}
|
||||
});
|
||||
|
||||
test('real chat, stream and handoff endpoints reject before all paid/retrieval calls; invalid ENV closes access', async () => {
|
||||
for (const endpoint of ['/clinical-assistant/chat', '/clinical-assistant/chat/stream', '/clinical-assistant/handoff']) {
|
||||
const app = server({ limit: '1000' });
|
||||
const result = await app.request('post', endpoint, { message: 'hello', history: [{ role: 'user', content: 'x'.repeat(1001) }] });
|
||||
assert.equal(result.statusCode, 413);
|
||||
assert.equal(result.body.code, 'CONVERSATION_LIMIT');
|
||||
assert.equal(result.events, '');
|
||||
assert.equal(app.calls.ai.length + app.calls.search.length, 0);
|
||||
}
|
||||
for (const options of [{ limit: '999' }, { limit: 'broken' }, { limit: ' ' }]) {
|
||||
const app = server(options);
|
||||
const result = await app.request('post', '/clinical-assistant/chat/stream', { message: 'Question', history: [] });
|
||||
assert.equal(result.statusCode, 503);
|
||||
assert.equal(app.calls.ai.length + app.calls.search.length, 0);
|
||||
assert.doesNotMatch(JSON.stringify(result.body), /private diagnostic/);
|
||||
}
|
||||
});
|
||||
|
||||
test('real accepted route retains early turns and late corrections in rewrite and answer, with fresh retrieval', async () => {
|
||||
const app = server();
|
||||
const history = Array.from({ length: 12 }, (_, i) => ({ role: i % 2 ? 'assistant' : 'user', content: 'Turn ' + i + '\n' }));
|
||||
history[0].content = 'Original age: 3 years.\n' + 'detail '.repeat(250) + '\nCORRECTION: age 3 months, not years.\n| Dose | Unit |\n|---|---|\n| 0.25 | mg/kg |';
|
||||
const result = await app.request('post', '/clinical-assistant/chat', { message: 'What about monitoring?', history });
|
||||
assert.equal(result.statusCode, 200);
|
||||
assert.equal(app.calls.search.length, 1);
|
||||
assert.equal(app.calls.ai.length, 2);
|
||||
for (const call of app.calls.ai) {
|
||||
assert.ok(call.messages[1].content.includes(history[0].content));
|
||||
assert.ok(call.messages[1].content.includes('Turn 11'));
|
||||
}
|
||||
assert.match(app.calls.ai[1].messages[1].content, /prior AI output is not evidence/);
|
||||
const second = await app.request('post', '/clinical-assistant/chat', { message: 'Explain the differential diagnosis and diagnostic workup in detail for this presentation.', history });
|
||||
assert.equal(second.statusCode, 200);
|
||||
assert.equal(app.calls.search.length, 2);
|
||||
const long = await server({ noSources: true }).request('post', '/clinical-assistant/chat', { message: 'x'.repeat(4001), history: [] });
|
||||
assert.equal(long.statusCode, 200, 'The old independent 4,000-character clipping boundary is gone');
|
||||
});
|
||||
|
||||
test('real save/reopen keeps 101 turns, Markdown, Unicode, full source metadata and generated image', async () => {
|
||||
const app = server();
|
||||
const content = ' Preserve indent\n\n| Item | Unit |\n|---|---|\n| 0.25 | mg/kg |\n' + '保留'.repeat(6500);
|
||||
const sources = Array.from({ length: 31 }, (_, index) => ({ number: index + 1, title: 'Title ' + index, excerpt: content, resource: 'source-' + index, page: index + 1 }));
|
||||
const messages = Array.from({ length: 101 }, (_, index) => ({ role: index % 2 ? 'assistant' : 'user', content: index === 0 ? content : 'Turn ' + index, sources: index === 1 ? sources : [] }));
|
||||
const body = { messages, sources, lastAnswer: content, generatedImage: png };
|
||||
const saved = await app.request('post', '/clinical-assistant/chats', body);
|
||||
assert.equal(saved.statusCode, 200);
|
||||
const reopened = await app.request('get', '/clinical-assistant/chats/:id', {});
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(reopened.body.chat.payload.messages)), messages);
|
||||
assert.equal(reopened.body.chat.payload.lastAnswer, content);
|
||||
assert.equal(reopened.body.chat.payload.generatedImage, png);
|
||||
assert.equal(reopened.body.chat.payload.sources.length, 31);
|
||||
assert.equal((await app.request('get', '/clinical-assistant/chats/:id', {}, 8)).statusCode, 404);
|
||||
const oversized = await app.request('post', '/clinical-assistant/chats', { messages: [{ role: 'user', content: 'x'.repeat(policy.MAX_SAVED_CHAT_BYTES) }] });
|
||||
assert.equal(oversized.statusCode, 413);
|
||||
const invalidImage = await app.request('post', '/clinical-assistant/chats', { messages: [], generatedImage: 'data:image/svg+xml;base64,PHN2Zz4=' });
|
||||
assert.equal(invalidImage.statusCode, 400);
|
||||
for (const generatedImage of ['data:image/png;base64,SGVsbG8=', png.replace('image/png', 'image/jpeg')]) {
|
||||
assert.equal((await app.request('post', '/clinical-assistant/chats', { messages: [], generatedImage })).statusCode, 400);
|
||||
}
|
||||
assert.equal(app.calls.writes.length, 1, 'Rejected saves must not insert truncated records');
|
||||
});
|
||||
|
||||
test('explicit handoff uses full context, does not retrieve/replace a chat, and reports empty model responses', async () => {
|
||||
const app = server();
|
||||
const history = [{ role: 'user', content: 'Known facts.\n' + 'x'.repeat(1500) + '\nCorrection: dose 0.25 mg/kg.' }];
|
||||
const result = await app.request('post', '/clinical-assistant/handoff', { history });
|
||||
assert.equal(result.statusCode, 200);
|
||||
assert.ok(app.calls.ai[0].messages[1].content.includes(history[0].content));
|
||||
assert.match(app.calls.ai[0].messages[0].content, /prior AI output is not evidence/);
|
||||
assert.equal(app.calls.search.length + app.calls.writes.length, 0);
|
||||
assert.equal((await server({ emptyAI: true }).request('post', '/clinical-assistant/handoff', { history })).statusCode, 500);
|
||||
assert.equal((await server({ finishReason: 'length' }).request('post', '/clinical-assistant/handoff', { history })).statusCode, 503);
|
||||
});
|
||||
|
||||
function browserUI(options = {}) {
|
||||
const dom = new JSDOM('<div id="assistant-tab">' + read('public/components/assistant.html') + '</div>', {
|
||||
url: 'https://example.test/',
|
||||
resources: { interceptors: [requestInterceptor(request => {
|
||||
assert.equal(new URL(request.url).pathname, '/css/assistant.css');
|
||||
return new Response(read('public/css/assistant.css'), { headers: { 'Content-Type': 'text/css' } });
|
||||
})] }
|
||||
});
|
||||
const calls = { stream: [], save: [], handoff: [] };
|
||||
const escapeHtml = text => String(text).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
const context = {
|
||||
window: dom.window, document: dom.window.document, navigator: dom.window.navigator,
|
||||
console: quiet, AbortController, TextDecoder, TextEncoder, URL, Blob,
|
||||
setTimeout() {}, showToast() {}, escapeHtml, escapeAttr: escapeHtml,
|
||||
renderAssistantMarkdown: text => escapeHtml(text), renderSourcesList: () => '', ...options.renderers, EMPTY_PROMPT_SETS: [[]],
|
||||
createAssistantExporter: () => ({ invalidate() {}, exportAnswerPdf() {} }),
|
||||
createAssistantImageStore: () => ({ renderGeneratedImage: src => '<img src="' + src + '">', clear() {} }),
|
||||
isImageRequest: () => false,
|
||||
fetchAssistantStatus: async () => ({ success: true, conversationChars: options.limit || 120000 }),
|
||||
fetchAssistantExamples: async () => ({}), fetchSavedAssistantChats: async () => ({ success: true, chats: [] }),
|
||||
openAssistantStream: async payload => {
|
||||
calls.stream.push(payload);
|
||||
if (options.stream) return options.stream(payload);
|
||||
return new Response('event: done\ndata: ' + JSON.stringify({ success: true, answer: 'Complete response.', sources: [] }) + '\n\n');
|
||||
},
|
||||
saveAssistantChat: async payload => { calls.save.push(payload); return { success: true }; },
|
||||
requestAssistantHandoff: async history => { calls.handoff.push(history); return options.handoffError ? { error: 'Summary failed.' } : { success: true, summary: 'Explicit handoff.' }; }
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(read('public/js/clinicalAssistant.js').replace(/^import[\s\S]*?from ['"][^'"]+['"];\s*/gm, ''), context);
|
||||
context.bindEvents();
|
||||
context.conversationChars = options.limit || 120000;
|
||||
return { context, document: dom.window.document, calls, dom };
|
||||
}
|
||||
|
||||
test('actual UI blocks over-budget input without clearing draft/history or automatically requesting a summary', async () => {
|
||||
const ui = browserUI({ limit: 1000 });
|
||||
ui.context.restoreSavedChat({ messages: [{ role: 'user', content: 'x'.repeat(999) }] });
|
||||
ui.document.getElementById('assistant-input').value = 'xx';
|
||||
await ui.context.onAsk();
|
||||
assert.equal(ui.calls.stream.length + ui.calls.handoff.length, 0);
|
||||
assert.equal(ui.context.messages.length, 1);
|
||||
assert.equal(ui.document.getElementById('assistant-input').value, 'xx');
|
||||
assert.equal(ui.document.getElementById('assistant-context-warning').hidden, false);
|
||||
ui.dom.window.close();
|
||||
});
|
||||
|
||||
test('actual UI sends complete prior history exactly once, preserves images on save, and offers explicit handoff', async () => {
|
||||
const ui = browserUI();
|
||||
const messages = Array.from({ length: 20 }, (_, i) => ({ role: i % 2 ? 'assistant' : 'user', content: 'Turn ' + i + (i === 0 ? 'x'.repeat(1500) : ''), sources: [] }));
|
||||
ui.context.restoreSavedChat({ messages, lastAnswer: 'Previous answer.', generatedImage: png });
|
||||
ui.document.getElementById('assistant-input').value = 'New question';
|
||||
await ui.context.onAsk();
|
||||
assert.equal(ui.calls.stream[0].history.length, 20);
|
||||
assert.equal(ui.calls.stream[0].history[0].content, messages[0].content);
|
||||
assert.equal(ui.context.messages.length, 22);
|
||||
assert.equal(ui.context.messages[20].content, 'New question');
|
||||
assert.equal(ui.document.getElementById('assistant-input').value, '');
|
||||
await ui.context.saveCurrentChat();
|
||||
assert.equal(ui.calls.save[0].generatedImage, png);
|
||||
assert.equal(ui.calls.save[0].messages.length, 22);
|
||||
await ui.context.requestHandoff();
|
||||
assert.equal(ui.calls.handoff.length, 1);
|
||||
assert.equal(ui.context.messages.length, 22, 'Handoff must not replace the conversation');
|
||||
assert.equal(ui.document.getElementById('assistant-handoff-text').value, 'Explicit handoff.');
|
||||
ui.dom.window.close();
|
||||
});
|
||||
|
||||
test('actual UI preserves draft on authoritative server rejection and ignores a late cancelled response', async () => {
|
||||
const ui = browserUI({ stream: () => new Response(JSON.stringify({ error: 'Limit reached', code: 'CONVERSATION_LIMIT', budget: { limit: 1000 } }), { status: 413 }) });
|
||||
ui.document.getElementById('assistant-input').value = 'Draft must survive';
|
||||
await ui.context.onAsk();
|
||||
assert.equal(ui.context.messages.length, 0);
|
||||
assert.equal(ui.document.getElementById('assistant-input').value, 'Draft must survive');
|
||||
assert.equal(ui.context.conversationChars, 1000);
|
||||
ui.dom.window.close();
|
||||
let release;
|
||||
const delayed = browserUI({ stream: () => new Promise(resolve => { release = resolve; }) });
|
||||
delayed.document.getElementById('assistant-input').value = 'Pending question';
|
||||
const request = delayed.context.onAsk();
|
||||
delayed.context.clearConversation();
|
||||
release(new Response('event: done\ndata: {"success":true,"answer":"Old answer"}\n\n'));
|
||||
await request;
|
||||
assert.equal(delayed.context.messages.length, 0);
|
||||
assert.doesNotMatch(delayed.document.getElementById('assistant-messages').textContent, /Old answer/);
|
||||
delayed.dom.window.close();
|
||||
});
|
||||
|
||||
test('dose follow-up rewrites from the drug/diagnosis AND a separate late age correction', async () => {
|
||||
for (const endpoint of ['/clinical-assistant/chat', '/clinical-assistant/chat/stream']) {
|
||||
const app = server();
|
||||
const history = [
|
||||
{ role: 'user', content: 'Discuss acyclovir for suspected neonatal herpes in a three-month-old.' },
|
||||
{ role: 'assistant', content: 'Confirm the age before deciding on therapy.' },
|
||||
{ role: 'user', content: 'Correction: three weeks, not three months.' }
|
||||
];
|
||||
const response = await app.request('post', endpoint, { history, message: 'dose?' });
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(app.calls.ai.length, 2, 'Rewrite must not shortcut to only the latest turn');
|
||||
for (const turn of history) assert.ok(app.calls.ai[0].messages[1].content.includes(turn.content));
|
||||
assert.ok(app.calls.ai[0].messages[1].content.includes('dose?'));
|
||||
assert.equal(app.calls.search[0], 'Complete supported answer. [1]', 'Retrieval uses the full-history rewrite result');
|
||||
}
|
||||
});
|
||||
|
||||
test('real save/reopen and UI renderer contain malicious legacy numbers without changing citation identity', async () => {
|
||||
const renderers = {
|
||||
...await import(pathToFileURL(path.join(root, 'public/js/assistant/citations.js')).href),
|
||||
...await import(pathToFileURL(path.join(root, 'public/js/assistant/sources.js')).href)
|
||||
};
|
||||
const ui = browserUI({ renderers });
|
||||
ui.dom.window.DOMPurify = require('dompurify')(ui.dom.window);
|
||||
const app = server();
|
||||
const malicious = '1"><img src=x onerror=alert(1)><span data-injected="yes';
|
||||
const sources = [{ number: '1', title: 'Valid legacy string' }, { number: malicious, title: 'Legacy markup' }];
|
||||
const body = { messages: [{ role: 'assistant', content: 'Retained citation [1].', sources }], sources };
|
||||
assert.equal((await app.request('post', '/clinical-assistant/chats', body)).statusCode, 200);
|
||||
const reopened = await app.request('get', '/clinical-assistant/chats/:id', {});
|
||||
assert.equal(reopened.statusCode, 200);
|
||||
const payload = reopened.body.chat.payload;
|
||||
assert.equal(payload.sources[0].number, '1');
|
||||
assert.equal(payload.sources[1].number, malicious);
|
||||
assert.equal(payload.messages[0].sources[1].number, malicious);
|
||||
ui.context.restoreSavedChat(payload);
|
||||
const cards = ui.document.querySelectorAll('.assistant-source');
|
||||
assert.equal(cards.length, 2);
|
||||
assert.equal(cards[1].id, 'assistant-source-' + malicious);
|
||||
assert.equal(cards[1].querySelector('strong').textContent, '[' + malicious + '] Legacy markup');
|
||||
assert.equal(ui.document.querySelector('img, script, [onerror], [data-injected]'), null);
|
||||
assert.equal(ui.document.querySelector('.assistant-cite').getAttribute('href'), '#assistant-source-1');
|
||||
assert.equal(ui.document.getElementById('assistant-source-1'), cards[0]);
|
||||
await new Promise(resolve => ui.dom.window.addEventListener('load', resolve, { once: true }));
|
||||
assert.equal(ui.dom.window.getComputedStyle(ui.document.getElementById('assistant-messages')).maxHeight, '65vh');
|
||||
ui.dom.window.close();
|
||||
});
|
||||
|
||||
function directAdapter(provider, mode, reason) {
|
||||
const responses = [];
|
||||
const sdkCalls = [];
|
||||
class InvokeModelCommand { constructor(input) { this.input = input; } }
|
||||
class ConverseCommand { constructor(input) { this.input = input; } }
|
||||
const mocks = {
|
||||
openai: { OpenAI: class { constructor() { throw Error('Unexpected OpenAI client'); } } },
|
||||
'./models': { ...require('../src/utils/models'), async getAllowedModelIds() { return new Set(['anthropic.claude-synthetic', 'amazon/nova-lite', 'google/gemini-2.5-flash']); } },
|
||||
'./generationOptions': require('../src/utils/generationOptions'),
|
||||
'./logger': { apiCall() {}, error() {} },
|
||||
'../db/database': { async getSetting() { return null; } },
|
||||
'@aws-sdk/client-bedrock-runtime': {
|
||||
InvokeModelCommand, ConverseCommand,
|
||||
BedrockRuntimeClient: class {
|
||||
async send(command) {
|
||||
sdkCalls.push(command);
|
||||
if (mode === 'invoke') {
|
||||
assert.ok(command instanceof InvokeModelCommand);
|
||||
return { body: Buffer.from(JSON.stringify({ content: [{ type: 'text', text: 'Synthetic handoff.' }], stop_reason: reason })) };
|
||||
}
|
||||
assert.ok(command instanceof ConverseCommand);
|
||||
return { output: { message: { content: [{ text: 'Synthetic handoff.' }] } }, stopReason: reason };
|
||||
}
|
||||
}
|
||||
},
|
||||
'@google-cloud/vertexai': {
|
||||
VertexAI: class {
|
||||
getGenerativeModel() {
|
||||
return { async generateContent(request) {
|
||||
sdkCalls.push(request);
|
||||
return { response: { candidates: [{ content: { parts: [{ text: 'Synthetic handoff.' }] }, finishReason: reason }] } };
|
||||
} };
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const module = { exports: {} };
|
||||
vm.runInNewContext(read('src/utils/ai.js'), {
|
||||
module, exports: module.exports, console: quiet, TextDecoder,
|
||||
process: { env: provider === 'bedrock' ? { AI_PROVIDER: provider, AWS_BEDROCK_REGION: 'synthetic' } : { AI_PROVIDER: provider, GOOGLE_VERTEX_PROJECT: 'synthetic' } },
|
||||
require(name) { assert.ok(Object.hasOwn(mocks, name), 'Unexpected adapter import: ' + name); return mocks[name]; }
|
||||
});
|
||||
return {
|
||||
responses, sdkCalls,
|
||||
async callAI(...args) { const result = await module.exports.callAI(...args); responses.push(result); return result; }
|
||||
};
|
||||
}
|
||||
|
||||
test('actual direct AI adapters propagate completion status through the actual handoff route (fake SDK only)', async () => {
|
||||
for (const [provider, mode, model] of [
|
||||
['bedrock', 'invoke', 'anthropic.claude-synthetic'],
|
||||
['bedrock', 'converse', 'amazon/nova-lite'],
|
||||
['vertex', 'vertex', 'google/gemini-2.5-flash']
|
||||
]) {
|
||||
for (const [reason, normalized] of [
|
||||
['max_tokens', 'length'], ['MAX_TOKENS', 'length'],
|
||||
['end_turn', 'stop'], ['stop_sequence', 'stop'], ['stop', 'stop'], ['STOP', 'stop'],
|
||||
['content_filtered', 'content_filtered'], ['SAFETY', 'SAFETY'], ['tool_use', 'tool_use'],
|
||||
['future_status', 'future_status'], ['', ''], [null, null], [undefined, null]
|
||||
]) {
|
||||
const adapter = directAdapter(provider, mode, reason);
|
||||
const app = server({ ai: adapter, model });
|
||||
const result = await app.request('post', '/clinical-assistant/handoff', { history: [{ role: 'user', content: 'Synthetic facts.' }] });
|
||||
assert.equal(adapter.sdkCalls.length, 1, provider + '/' + mode + '/' + reason);
|
||||
assert.equal(adapter.responses[0].finishReason, normalized);
|
||||
assert.equal(result.statusCode, normalized === 'stop' ? 200 : 503);
|
||||
if (normalized !== 'stop') {
|
||||
assert.equal(result.body.summary, undefined);
|
||||
assert.match(result.body.error, /not accepted.*unchanged/);
|
||||
} else assert.equal(result.body.summary, 'Synthetic handoff.');
|
||||
assert.equal(app.calls.search.length + app.calls.writes.length, 0);
|
||||
}
|
||||
}
|
||||
for (const finishReason of [null, undefined, 'tool_calls', 'unknown']) {
|
||||
const result = await server({ finishReason }).request('post', '/clinical-assistant/handoff', { history: [{ role: 'user', content: 'Facts.' }] });
|
||||
assert.equal(result.statusCode, 503, 'Unknown OpenAI-compatible status cannot be accepted either');
|
||||
}
|
||||
});
|
||||
|
||||
test('ENV/default metadata and exact UTF16 boundary ignore legacy DB budget and DB failures', async () => {
|
||||
for (const [limit, source, expected] of [[undefined, 'default', 120000], ['', 'default', 120000], ['1000', 'environment', 1000], ['1000000', 'environment', 1000000]]) {
|
||||
const app = server({ limit, legacyLimit: '999999', dbError: true });
|
||||
const status = await app.request('get', '/clinical-assistant/status');
|
||||
assert.equal(status.statusCode, 200);
|
||||
assert.equal(status.body.conversationChars, expected);
|
||||
assert.equal(status.body.conversationSource, source);
|
||||
assert.equal(status.body.conversationEnv, 'CLINICAL_ASSISTANT_CONVERSATION_CHARS');
|
||||
assert.equal(status.body.conversationMeasure, 'UTF-16 code units');
|
||||
assert.equal(status.body.conversationUnit, 'characters');
|
||||
}
|
||||
const invalid = server({ limit: 'invalid' });
|
||||
assert.equal((await invalid.request('get', '/clinical-assistant/status')).statusCode, 503);
|
||||
assert.equal(invalid.calls.health.length, 0);
|
||||
for (const endpoint of ['/clinical-assistant/chat', '/clinical-assistant/chat/stream']) {
|
||||
const app = server({ limit: '1000', legacyLimit: '1' });
|
||||
const history = [{ role: 'user', content: '😀'.repeat(499) }];
|
||||
assert.equal((await app.request('post', endpoint, { history, message: '😀' })).statusCode, 200);
|
||||
assert.equal(app.calls.search.length, 1);
|
||||
assert.equal((await app.request('post', endpoint, { history, message: '😀x' })).statusCode, 413);
|
||||
assert.equal(app.calls.search.length, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('both actual image routes use the editable poster instruction before unchanged layout suffixes', async () => {
|
||||
const prompts = require('../src/utils/clinicalPrompts');
|
||||
for (const imageBehavior of [undefined, ' Synthetic override.', 'Override without leading space.']) {
|
||||
const app = server({ imageBehavior });
|
||||
for (const endpoint of ['/clinical-assistant/image', '/clinical-assistant/image/jobs']) {
|
||||
const response = await app.request('post', endpoint, { prompt: ' flowchart comparison ' });
|
||||
assert.equal(response.statusCode, 200);
|
||||
for (let i = 0; i < 8; i++) await new Promise(resolve => setImmediate(resolve));
|
||||
const payload = app.calls.images.at(-1);
|
||||
assert.equal(payload.prompt, prompts.imagePromptForCanvas('flowchart comparison', imageBehavior));
|
||||
assert.match(payload.prompt, /tall portrait layout.*wide landscape layout/);
|
||||
assert.ok(payload.prompt.startsWith('flowchart comparison '));
|
||||
if (imageBehavior) assert.doesNotMatch(payload.prompt, /single complete medical teaching poster/);
|
||||
}
|
||||
assert.equal(app.calls.images.length, 2);
|
||||
}
|
||||
});
|
||||
|
|
@ -1,67 +1,404 @@
|
|||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const Module = require('node:module');
|
||||
const { Readable } = require('node:stream');
|
||||
|
||||
// The MCP server closes its Nextcloud client only when a session ends. These tests
|
||||
// pin the teardown, because without it expired sessions leak sockets until the
|
||||
// server exhausts its file descriptors and every clinical search fails.
|
||||
function loadClientWithFakeAxios(calls) {
|
||||
const axiosStub = {
|
||||
post: async function(url, payload, config) {
|
||||
calls.push({ method: 'POST', url, payload, headers: (config || {}).headers || {} });
|
||||
return {
|
||||
status: 200,
|
||||
config: { url },
|
||||
headers: { 'mcp-session-id': 'session-' + calls.filter(c => c.payload && c.payload.method === 'initialize').length },
|
||||
data: JSON.stringify({ jsonrpc: '2.0', result: { content: [] } })
|
||||
function deferred() {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function loadClient(overrides = {}) {
|
||||
const calls = [];
|
||||
let count = 0;
|
||||
const axios = {
|
||||
post: async (url, payload, config) => {
|
||||
const call = { method: 'POST', url, payload, config };
|
||||
calls.push(call);
|
||||
const init = payload.method === 'initialize';
|
||||
const response = {
|
||||
status: init ? 200 : 202, config: { url },
|
||||
headers: init ? { 'mcp-session-id': 'session-' + (++count) } : {},
|
||||
data: payload.method === 'notifications/initialized' ? '' : JSON.stringify({ jsonrpc: '2.0', result: { content: [] } })
|
||||
};
|
||||
return overrides.post ? overrides.post(call, response) : response;
|
||||
},
|
||||
delete: async function(url, config) {
|
||||
calls.push({ method: 'DELETE', url, headers: (config || {}).headers || {} });
|
||||
return { status: 204 };
|
||||
},
|
||||
get: async function(url) { calls.push({ method: 'GET', url }); return { status: 200, data: {} }; }
|
||||
delete: async (url, config) => {
|
||||
const call = { method: 'DELETE', url, config };
|
||||
calls.push(call);
|
||||
return overrides.delete ? overrides.delete(call) : { status: 204 };
|
||||
}
|
||||
};
|
||||
|
||||
const target = require.resolve('../src/utils/clinicalMcpClient');
|
||||
delete require.cache[target];
|
||||
const original = Module._load;
|
||||
Module._load = function(request, parent, isMain) {
|
||||
if (request === 'axios') return axiosStub;
|
||||
Module._load = function(request) {
|
||||
if (request === 'axios') return axios;
|
||||
return original.apply(this, arguments);
|
||||
};
|
||||
try { return require(target); } finally { Module._load = original; }
|
||||
let client;
|
||||
try { client = require(target); } finally { Module._load = original; }
|
||||
return { client, calls, deletes: () => calls.filter(c => c.method === 'DELETE').map(c => c.config.headers['mcp-session-id']) };
|
||||
}
|
||||
|
||||
test('an expired session is closed when it is replaced, not abandoned', async () => {
|
||||
const calls = [];
|
||||
process.env.CLINICAL_ASSISTANT_MCP_SESSION_TTL_MS = '1';
|
||||
const client = loadClientWithFakeAxios(calls);
|
||||
const method = c => c.payload && c.payload.method;
|
||||
const turn = () => new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
await client.semanticSearch('bronchiolitis', { limit: 4 });
|
||||
await new Promise(resolve => setTimeout(resolve, 5)); // let the 1ms TTL lapse
|
||||
await client.semanticSearch('croup', { limit: 4 });
|
||||
|
||||
const deletes = calls.filter(c => c.method === 'DELETE');
|
||||
assert.equal(deletes.length, 1, 'the expired session should be deleted exactly once');
|
||||
assert.equal(deletes[0].headers['mcp-session-id'], 'session-1');
|
||||
|
||||
const initializes = calls.filter(c => c.payload && c.payload.method === 'initialize');
|
||||
assert.equal(initializes.length, 2, 'a lapsed TTL should open a fresh session');
|
||||
delete process.env.CLINICAL_ASSISTANT_MCP_SESSION_TTL_MS;
|
||||
test('live session reuse, real initialized notification and serialized tool calls', async () => {
|
||||
const held = deferred();
|
||||
const started = deferred();
|
||||
let tools = 0;
|
||||
const { client, calls, deletes } = loadClient({ post: async (call, response) => {
|
||||
if (method(call) === 'initialize') response.data = Readable.from([response.data]);
|
||||
if (method(call) === 'tools/call' && ++tools === 1) { started.resolve(); await held.promise; }
|
||||
return response;
|
||||
} });
|
||||
const first = client.semanticSearch('one', { limit: 4 });
|
||||
await started.promise;
|
||||
const second = client.multimodalSearch('two', { limit: 8 });
|
||||
await turn();
|
||||
assert.equal(tools, 1);
|
||||
held.resolve();
|
||||
await Promise.all([first, second]);
|
||||
await client.indexedTopicSuggestions(12);
|
||||
assert.deepEqual(calls.filter(c => c.method === 'POST').map(method), ['initialize', 'notifications/initialized', 'tools/call', 'tools/call', 'tools/call']);
|
||||
const notice = calls[1];
|
||||
assert.equal(notice.payload.jsonrpc, '2.0');
|
||||
assert.equal(Object.hasOwn(notice.payload, 'id'), false);
|
||||
assert.equal(notice.config.headers['mcp-session-id'], 'session-1');
|
||||
assert.equal(notice.url, calls[0].url);
|
||||
assert.deepEqual(deletes(), []);
|
||||
await client.closeMcpSession();
|
||||
assert.deepEqual(deletes(), ['session-1']);
|
||||
});
|
||||
|
||||
test('a live session is reused and never closed between calls', async () => {
|
||||
const calls = [];
|
||||
process.env.CLINICAL_ASSISTANT_MCP_SESSION_TTL_MS = String(10 * 60 * 1000);
|
||||
const client = loadClientWithFakeAxios(calls);
|
||||
|
||||
await client.semanticSearch('asthma', { limit: 4 });
|
||||
await client.semanticSearch('sepsis', { limit: 4 });
|
||||
|
||||
assert.equal(calls.filter(c => c.method === 'DELETE').length, 0,
|
||||
'deleting a session still in use would force a re-initialize on every search');
|
||||
assert.equal(calls.filter(c => c.payload && c.payload.method === 'initialize').length, 1);
|
||||
delete process.env.CLINICAL_ASSISTANT_MCP_SESSION_TTL_MS;
|
||||
test('expired session is discarded even if replacement initialize fails', async t => {
|
||||
let now = 0;
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
let initializations = 0;
|
||||
const { client, deletes } = loadClient({ post: async (call, response) => {
|
||||
if (method(call) === 'initialize' && ++initializations === 2) throw new Error('failed replacement');
|
||||
return response;
|
||||
} });
|
||||
await client.warmMcpSession();
|
||||
now = 600001;
|
||||
await assert.rejects(client.semanticSearch('two'));
|
||||
assert.deepEqual(deletes(), ['session-1']);
|
||||
await client.closeMcpSession();
|
||||
});
|
||||
|
||||
for (const failure of ['malformed', 'rpc', 'empty', 'body', 'notification', 'notification-rpc', 'http']) {
|
||||
test('known initialize session is unwound on ' + failure + ' failure', async () => {
|
||||
const { client, calls, deletes } = loadClient({ post: async (call, response) => {
|
||||
if (method(call) === 'initialize') {
|
||||
if (failure === 'malformed') response.data = '{broken';
|
||||
if (failure === 'rpc') response.data = '{"error":{"message":"secret-session-1"}}';
|
||||
if (failure === 'empty') response.data = '';
|
||||
if (failure === 'body') response.data = Readable.from((async function*() { throw new Error('secret-body'); })());
|
||||
if (failure === 'http') throw Object.assign(new Error('secret-http'), { response: { ...response, status: 503 } });
|
||||
}
|
||||
if (method(call) === 'notifications/initialized') {
|
||||
if (failure === 'notification') throw new Error('secret-notification');
|
||||
if (failure === 'notification-rpc') response.data = '{"error":{"message":"secret-notification"}}';
|
||||
}
|
||||
return response;
|
||||
} });
|
||||
await assert.rejects(client.warmMcpSession(), e => !/secret/.test(e.message));
|
||||
assert.deepEqual(deletes(), ['session-1']);
|
||||
assert.equal(calls.filter(c => method(c) === 'initialize').length, 1);
|
||||
await client.closeMcpSession();
|
||||
assert.deepEqual(deletes(), ['session-1']);
|
||||
});
|
||||
}
|
||||
|
||||
test('initialized notification never falls back to another session endpoint', async t => {
|
||||
const previous = process.env.CLINICAL_ASSISTANT_MCP_INITIALIZE_TIMEOUT_MS;
|
||||
process.env.CLINICAL_ASSISTANT_MCP_INITIALIZE_TIMEOUT_MS = '40';
|
||||
t.after(() => {
|
||||
if (previous === undefined) delete process.env.CLINICAL_ASSISTANT_MCP_INITIALIZE_TIMEOUT_MS;
|
||||
else process.env.CLINICAL_ASSISTANT_MCP_INITIALIZE_TIMEOUT_MS = previous;
|
||||
});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
let owner;
|
||||
const { client, calls, deletes } = loadClient({ post: async (call, response) => {
|
||||
if (method(call) === 'initialize') owner = call.url;
|
||||
if (method(call) === 'notifications/initialized' && call.url === owner) {
|
||||
throw Object.assign(new Error('temporarily unavailable'), { response: { status: 503 } });
|
||||
}
|
||||
return response; // An alternate endpoint would accept, but must never be contacted.
|
||||
} });
|
||||
await assert.rejects(client.warmMcpSession());
|
||||
const notices = calls.filter(c => method(c) === 'notifications/initialized');
|
||||
assert.ok(notices.length > 0);
|
||||
assert.ok(notices.every(c => c.url === owner));
|
||||
assert.deepEqual(deletes(), ['session-1']);
|
||||
await client.closeMcpSession();
|
||||
});
|
||||
|
||||
test('ambiguous initialize transport failure is not retried', async () => {
|
||||
const { client, calls, deletes } = loadClient({ post: async () => {
|
||||
throw Object.assign(new Error('secret-transport'), { code: 'ECONNRESET' });
|
||||
} });
|
||||
await assert.rejects(client.warmMcpSession(), /MCP request failed/);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.deepEqual(deletes(), []);
|
||||
await client.closeMcpSession();
|
||||
});
|
||||
|
||||
test('invalid-session recovery discards the failed object, not a newer warm session', async t => {
|
||||
let now = 0;
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
const held = deferred();
|
||||
const started = deferred();
|
||||
let tools = 0;
|
||||
const { client, calls, deletes } = loadClient({ post: async (call, response) => {
|
||||
if (method(call) === 'tools/call' && ++tools === 1) {
|
||||
started.resolve(); await held.promise;
|
||||
throw Object.assign(new Error('invalid'), { response: { status: 404 } });
|
||||
}
|
||||
return response;
|
||||
} });
|
||||
const search = client.semanticSearch('one');
|
||||
await started.promise;
|
||||
now = 600001;
|
||||
await client.warmMcpSession();
|
||||
held.resolve();
|
||||
await search;
|
||||
assert.deepEqual(deletes(), ['session-1']);
|
||||
assert.equal(calls.filter(c => method(c) === 'initialize').length, 2);
|
||||
assert.equal(calls.filter(c => method(c) === 'tools/call')[1].config.headers['mcp-session-id'], 'session-2');
|
||||
await client.closeMcpSession();
|
||||
});
|
||||
|
||||
test('both invalid tool attempts are discarded, ordinary tool errors keep the shared session', async () => {
|
||||
let invalid = true;
|
||||
const { client, deletes } = loadClient({ post: async (call, response) => {
|
||||
if (method(call) === 'tools/call') throw Object.assign(new Error('tool failure'), invalid ? { response: { status: 404 } } : {});
|
||||
return response;
|
||||
} });
|
||||
await assert.rejects(client.semanticSearch('one'));
|
||||
assert.deepEqual(deletes(), ['session-1', 'session-2']);
|
||||
invalid = false;
|
||||
await assert.rejects(client.semanticSearch('two'));
|
||||
assert.deepEqual(deletes(), ['session-1', 'session-2']);
|
||||
await client.closeMcpSession();
|
||||
assert.deepEqual(deletes(), ['session-1', 'session-2', 'session-3']);
|
||||
});
|
||||
|
||||
test('duplicate shutdown rejects warm/new/queued calls and unwinds late initialize', async () => {
|
||||
const held = deferred();
|
||||
const started = deferred();
|
||||
const { client, calls, deletes } = loadClient({ post: async (call, response) => {
|
||||
if (method(call) === 'initialize') { started.resolve(); await held.promise; }
|
||||
return response;
|
||||
} });
|
||||
const warm = client.warmMcpSession();
|
||||
const rejectedWarm = assert.rejects(warm);
|
||||
await started.promise;
|
||||
const queued = assert.rejects(client.semanticSearch('queued'));
|
||||
const closing = client.closeMcpSession();
|
||||
assert.equal(client.closeMcpSession(), closing);
|
||||
await assert.rejects(client.warmMcpSession());
|
||||
await assert.rejects(client.semanticSearch('new'));
|
||||
held.resolve();
|
||||
await Promise.all([closing, rejectedWarm, queued]);
|
||||
assert.deepEqual(deletes(), ['session-1']);
|
||||
assert.equal(calls.filter(c => method(c) === 'tools/call').length, 0);
|
||||
});
|
||||
|
||||
test('shutdown during initialized notification cannot cache a late session', async () => {
|
||||
const held = deferred();
|
||||
const started = deferred();
|
||||
const { client, deletes } = loadClient({ post: async (call, response) => {
|
||||
if (method(call) === 'notifications/initialized') { started.resolve(); await held.promise; }
|
||||
return response;
|
||||
} });
|
||||
const warming = assert.rejects(client.warmMcpSession());
|
||||
await started.promise;
|
||||
const closing = client.closeMcpSession();
|
||||
held.resolve();
|
||||
await Promise.all([warming, closing]);
|
||||
assert.deepEqual(deletes(), ['session-1']);
|
||||
await assert.rejects(client.warmMcpSession());
|
||||
});
|
||||
|
||||
test('cleanup rejection does not lose an in-flight successful search', async () => {
|
||||
const held = deferred();
|
||||
const started = deferred();
|
||||
const { client, deletes } = loadClient({ post: async (call, response) => {
|
||||
if (method(call) === 'tools/call') { started.resolve(); await held.promise; }
|
||||
return response;
|
||||
}, delete: async () => { throw new Error('secret-cleanup'); } });
|
||||
const search = client.semanticSearch('one');
|
||||
await started.promise;
|
||||
const queued = assert.rejects(client.semanticSearch('queued'));
|
||||
const closing = client.closeMcpSession();
|
||||
held.resolve();
|
||||
assert.deepEqual(await search, { content: [] });
|
||||
await Promise.all([queued, closing]);
|
||||
assert.deepEqual(deletes(), ['session-1']);
|
||||
});
|
||||
|
||||
test('DELETE and shutdown are bounded even when transport ignores abort or holds a body', async t => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
let config;
|
||||
const { client } = loadClient({ delete: call => { config = call.config; return new Promise(() => {}); } });
|
||||
await client.warmMcpSession();
|
||||
const closing = client.closeMcpSession();
|
||||
assert.equal(config.timeout, 5000);
|
||||
assert.equal(config.signal.aborted, false);
|
||||
t.mock.timers.tick(5000);
|
||||
await closing;
|
||||
assert.equal(config.signal.aborted, true);
|
||||
});
|
||||
|
||||
test('shutdown bound also covers unknown in-flight initialization', async t => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const held = deferred();
|
||||
const started = deferred();
|
||||
const { client, deletes } = loadClient({ post: async (call, response) => {
|
||||
started.resolve(); await held.promise; return response;
|
||||
} });
|
||||
const warm = assert.rejects(client.warmMcpSession());
|
||||
await started.promise;
|
||||
const closing = client.closeMcpSession();
|
||||
t.mock.timers.tick(5000);
|
||||
await closing;
|
||||
held.resolve();
|
||||
await warm;
|
||||
assert.deepEqual(deletes(), ['session-1']);
|
||||
});
|
||||
|
||||
test('expired replacement succeeds even when old-session DELETE fails', async t => {
|
||||
let now = 0;
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
const { client, calls, deletes } = loadClient({ delete: async () => { throw new Error('cleanup failed'); } });
|
||||
await client.semanticSearch('one');
|
||||
now = 600001;
|
||||
assert.deepEqual(await client.semanticSearch('two'), { content: [] });
|
||||
assert.deepEqual(deletes(), ['session-1']);
|
||||
assert.equal(calls.filter(c => method(c) === 'initialize').length, 2);
|
||||
await client.closeMcpSession();
|
||||
});
|
||||
|
||||
test('connection refusal preserves URL fallback and the chosen URL owns handshake/tools/DELETE', async t => {
|
||||
const warnings = [];
|
||||
t.mock.method(console, 'warn', (...args) => warnings.push(args.join(' ')));
|
||||
let first = true;
|
||||
const { client, calls } = loadClient({ post: async (call, response) => {
|
||||
if (first) { first = false; throw Object.assign(new Error('secret endpoint'), { code: 'ECONNREFUSED' }); }
|
||||
return response;
|
||||
} });
|
||||
await client.semanticSearch('one');
|
||||
await client.closeMcpSession();
|
||||
assert.notEqual(calls[0].url, calls[1].url);
|
||||
assert.ok(calls.slice(1).every(call => call.url === calls[1].url));
|
||||
assert.deepEqual(warnings, ['[clinical-assistant] MCP endpoint unavailable']);
|
||||
});
|
||||
|
||||
for (const scenario of ['broken initialize body', 'held DELETE body']) {
|
||||
test('real Axios against synthetic loopback: ' + scenario, { timeout: 10000 }, async t => {
|
||||
const http = require('node:http');
|
||||
const requests = [];
|
||||
const deleteClosed = deferred();
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const chunks = [];
|
||||
for await (const chunk of req) chunks.push(chunk);
|
||||
const payload = chunks.length && JSON.parse(Buffer.concat(chunks).toString());
|
||||
requests.push({ method: req.method, payload, id: req.headers['mcp-session-id'] });
|
||||
if (req.method === 'DELETE') {
|
||||
res.on('close', () => deleteClosed.resolve());
|
||||
if (scenario === 'held DELETE body') { res.writeHead(503); res.write('held'); }
|
||||
else { res.writeHead(204); res.end(); }
|
||||
} else if (payload.method === 'initialize') {
|
||||
res.setHeader('mcp-session-id', 'synthetic-session');
|
||||
if (scenario === 'broken initialize body') {
|
||||
res.write('{');
|
||||
// Simulate a connection breaking after headers have reached the client.
|
||||
setTimeout(() => res.destroy(), 20);
|
||||
} else res.end('{"jsonrpc":"2.0","result":{"protocolVersion":"2024-11-05"}}');
|
||||
} else if (payload.method === 'notifications/initialized') { res.writeHead(202); res.end(); }
|
||||
else res.end('{"result":{"content":[]}}');
|
||||
});
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
const previous = process.env.CLINICAL_ASSISTANT_MCP_URLS;
|
||||
process.env.CLINICAL_ASSISTANT_MCP_URLS = 'http://127.0.0.1:' + server.address().port + '/mcp';
|
||||
t.after(async () => {
|
||||
if (previous === undefined) delete process.env.CLINICAL_ASSISTANT_MCP_URLS;
|
||||
else process.env.CLINICAL_ASSISTANT_MCP_URLS = previous;
|
||||
server.closeAllConnections();
|
||||
await new Promise(resolve => server.close(resolve));
|
||||
});
|
||||
const target = require.resolve('../src/utils/clinicalMcpClient');
|
||||
delete require.cache[target];
|
||||
const client = require(target);
|
||||
if (scenario === 'broken initialize body') {
|
||||
await assert.rejects(client.warmMcpSession());
|
||||
assert.deepEqual(requests.map(r => r.method), ['POST', 'DELETE']);
|
||||
} else {
|
||||
assert.deepEqual(await client.semanticSearch('synthetic'), { content: [] });
|
||||
assert.deepEqual(requests.map(r => r.payload.method), ['initialize', 'notifications/initialized', 'tools/call']);
|
||||
const started = Date.now();
|
||||
await client.closeMcpSession();
|
||||
assert.ok(Date.now() - started < 6500, 'cleanup must not wait for the held body');
|
||||
}
|
||||
await deleteClosed.promise;
|
||||
assert.equal(requests.at(-1).id, 'synthetic-session');
|
||||
await client.closeMcpSession();
|
||||
});
|
||||
}
|
||||
|
||||
test('real Axios tool deadline aborts a continuously trickling response', { timeout: 3000 }, async t => {
|
||||
const http = require('node:http');
|
||||
const bodyClosed = deferred();
|
||||
let completed = false;
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const chunks = [];
|
||||
for await (const chunk of req) chunks.push(chunk);
|
||||
const payload = chunks.length && JSON.parse(Buffer.concat(chunks).toString());
|
||||
if (req.method === 'DELETE') { res.writeHead(204); res.end(); }
|
||||
else if (payload.method === 'initialize') {
|
||||
res.setHeader('mcp-session-id', 'trickle-session');
|
||||
res.end('{"result":{"protocolVersion":"2024-11-05"}}');
|
||||
} else if (payload.method === 'notifications/initialized') { res.writeHead(202); res.end(); }
|
||||
else {
|
||||
res.write('{"result":{"content":[');
|
||||
const interval = setInterval(() => res.write(' '), 15);
|
||||
const finish = setTimeout(() => { completed = true; res.end(']}}'); }, 450);
|
||||
res.on('close', () => { clearInterval(interval); clearTimeout(finish); bodyClosed.resolve(); });
|
||||
}
|
||||
});
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
const keys = ['CLINICAL_ASSISTANT_MCP_URLS', 'CLINICAL_ASSISTANT_MCP_REQUEST_TIMEOUT_MS'];
|
||||
const previous = keys.map(key => process.env[key]);
|
||||
process.env[keys[0]] = 'http://127.0.0.1:' + server.address().port + '/mcp';
|
||||
process.env[keys[1]] = '100';
|
||||
let client;
|
||||
t.after(async () => {
|
||||
keys.forEach((key, i) => {
|
||||
if (previous[i] === undefined) delete process.env[key];
|
||||
else process.env[key] = previous[i];
|
||||
});
|
||||
if (client) await client.closeMcpSession();
|
||||
server.closeAllConnections();
|
||||
await new Promise(resolve => server.close(resolve));
|
||||
});
|
||||
const target = require.resolve('../src/utils/clinicalMcpClient');
|
||||
delete require.cache[target];
|
||||
client = require(target);
|
||||
await assert.rejects(client.semanticSearch('synthetic'), /MCP request failed/);
|
||||
await bodyClosed.promise;
|
||||
assert.equal(completed, false, 'deadline closes the socket before the trickling body completes');
|
||||
});
|
||||
|
||||
test('shutdown between cached-session lookup and tool dispatch prevents a late call', async () => {
|
||||
const { client, calls } = loadClient();
|
||||
await client.warmMcpSession();
|
||||
const search = assert.rejects(client.semanticSearch('late'));
|
||||
await Promise.resolve(); // queued call entered getMcpSession, dispatch has not resumed
|
||||
await client.closeMcpSession();
|
||||
await search;
|
||||
assert.equal(calls.filter(c => method(c) === 'tools/call').length, 0);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ test('saved encounter infrastructure exposes required globals', () => {
|
|||
['wirePauseButton', 'registerEncounterLoadHandler', 'saveEncounter', 'loadSavedEncountersList'].forEach((name) => {
|
||||
assert.match(encounters, new RegExp('window\\.' + name + '\\s*='), `encounters.js should expose window.${name}`);
|
||||
});
|
||||
assert.match(encounters, /sessionStorage\.setItem\('_savedEncId_' \+ type, data\.id\)/, 'saved encounter IDs should survive refresh within the tab');
|
||||
assert.match(encounters, /sessionStorage\.setItem\(boundary\.storageKey\('_savedEncId_' \+ type\), data\.id\)/, 'saved encounter IDs should survive refresh within the tab');
|
||||
});
|
||||
|
||||
test('clinical note generators preserve save/load integration points', () => {
|
||||
|
|
|
|||
131
test/clinical-release-integration.test.js
Normal file
131
test/clinical-release-integration.test.js
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const { pathToFileURL } = require('node:url');
|
||||
const { JSDOM } = require('jsdom');
|
||||
const { normalizeMcpSearchResponse } = require('../src/utils/clinicalRetrieval');
|
||||
const { savedChatPayload } = require('../src/utils/clinicalConversation');
|
||||
const root = path.join(__dirname, '..');
|
||||
const read = file => fs.readFileSync(path.join(root, file), 'utf8');
|
||||
const tick = async () => { for (let i = 0; i < 8; i++) await new Promise(resolve => setImmediate(resolve)); };
|
||||
|
||||
// Execute the actual static-route registration, without starting the application.
|
||||
test('actual markup loads the existing local DOMPurify distribution through its vendor route', async t => {
|
||||
const express = require('express');
|
||||
const app = express();
|
||||
const source = read('server.js');
|
||||
const start = source.indexOf("app.use('/vendor/dompurify',");
|
||||
const end = source.indexOf("app.use('/vendor/markdown-it',", start);
|
||||
assert.ok(start >= 0 && end > start);
|
||||
vm.runInNewContext(source.slice(start, end), { app, express, path, __dirname: root });
|
||||
const server = app.listen(0, '127.0.0.1');
|
||||
t.after(() => server.close());
|
||||
await new Promise(resolve => server.on('listening', resolve));
|
||||
const doc = new JSDOM(read('public/index.html')).window.document;
|
||||
const scripts = [...doc.querySelectorAll('script[src]')].filter(script => /dompurify/i.test(script.src));
|
||||
assert.equal(scripts.length, 1);
|
||||
assert.equal(scripts[0].getAttribute('src'), '/vendor/dompurify/purify.min.js');
|
||||
const response = await fetch('http://127.0.0.1:' + server.address().port + scripts[0].getAttribute('src'));
|
||||
assert.equal(response.status, 200);
|
||||
assert.match(response.headers.get('cache-control'), /max-age=3600/);
|
||||
assert.equal(await response.text(), read('node_modules/dompurify/dist/purify.min.js'));
|
||||
doc.defaultView.close();
|
||||
});
|
||||
|
||||
test('native admin and assistant modules retain budget, table/source identity and safe live/saved/export rendering with or without DOMPurify', async t => {
|
||||
const dom = new JSDOM('<div id="admin-tab">' + read('public/components/admin.html') + '</div><div id="assistant-tab">' + read('public/components/assistant.html') + '</div>', { url: 'https://app.example', runScripts: 'outside-only' });
|
||||
const { window } = dom;
|
||||
const document = window.document;
|
||||
const calls = [];
|
||||
const limit = 2000;
|
||||
let saved;
|
||||
const rows = Array.from({ length: 65 }, (_, i) => '| Drug ' + i + ' | 2 mg/kg |');
|
||||
const sources = normalizeMcpSearchResponse({ results: [{ id: 42, title: 'Synthetic', page_number: 7, excerpt: 'Table 1. Synthetic\n\n| Drug | Dose |\n|---|---|\n' + rows.join('\n') + '\n\nNote: Synthetic only.' }] });
|
||||
assert.ok(sources[0].excerpt.length > 900);
|
||||
const markdown = '| Drug | Dose | Sources |\n|---|---|---|\n| Synthetic | 2 mg/kg | [1] |\n\n<img src=x onerror="alert(1)"><svg onload="alert(2)"></svg><script>alert(3)</script>\n\n<a href="javascript:alert(4)">bad</a>';
|
||||
const fetchMock = async (url, options = {}) => {
|
||||
calls.push({ url, options });
|
||||
if (url === '/api/clinical-assistant/chat/stream') return new Response('event: done\ndata: ' + JSON.stringify({ success: true, answer: markdown, sources }) + '\n\n');
|
||||
let data = { success: true, models: [] };
|
||||
if (url === '/api/admin/config') {
|
||||
data.config = [{ key: 'clinical_assistant.conversation_chars', value: '999999' }];
|
||||
data.conversationBudget = { limit, unit: 'characters', measure: 'UTF-16 code units', env: 'CLINICAL_ASSISTANT_CONVERSATION_CHARS', source: 'environment' };
|
||||
}
|
||||
else if (url === '/api/clinical-assistant/status') data.conversationChars = limit;
|
||||
else if (url === '/api/clinical-assistant/chats' && options.method === 'POST') saved = savedChatPayload(JSON.parse(options.body));
|
||||
else if (url === '/api/clinical-assistant/chats') data.chats = saved ? [{ id: 1, title: 'Synthetic saved chat' }] : [];
|
||||
else if (url === '/api/clinical-assistant/chats/1') data.chat = { payload: saved };
|
||||
else if (url.endsWith('/examples')) data.examples = [];
|
||||
return new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } });
|
||||
};
|
||||
const values = { window, document, fetch: fetchMock, showToast() {}, getAuthHeaders: () => ({ 'Content-Type': 'application/json' }) };
|
||||
const originals = Object.keys(values).map(key => [key, Object.getOwnPropertyDescriptor(global, key)]);
|
||||
Object.assign(global, values);
|
||||
window.getAuthHeaders = values.getAuthHeaders;
|
||||
window.showToast = values.showToast;
|
||||
window.confirm = () => true;
|
||||
window.marked = require('marked').marked;
|
||||
window.matchMedia = () => ({ matches: true }); // Exercise the real inline export, without printing/downloading.
|
||||
t.after(() => {
|
||||
for (const [key, descriptor] of originals) { if (descriptor) Object.defineProperty(global, key, descriptor); else delete global[key]; }
|
||||
window.close();
|
||||
});
|
||||
await import(pathToFileURL(path.join(root, 'public/js/admin.js')).href);
|
||||
await import(pathToFileURL(path.join(root, 'public/js/clinicalAssistant.js')).href);
|
||||
document.dispatchEvent(new window.CustomEvent('tabChanged', { detail: { tab: 'admin' } }));
|
||||
await tick();
|
||||
assert.equal(document.querySelectorAll('#assistant-conversation-chars').length, 0);
|
||||
assert.match(document.getElementById('assistant-conversation-budget').textContent, /2,000 characters \(UTF-16 code units\)/);
|
||||
document.getElementById('btn-save-assistant-config').click();
|
||||
await tick();
|
||||
assert.equal(limit, 2000);
|
||||
assert.equal(calls.filter(call => call.options.method === 'PUT').length, 4, 'one native admin initializer; prompts and ENV budget are not generic setting saves');
|
||||
assert.equal(calls.some(call => call.url.endsWith('/config/clinical_assistant.conversation_chars')), false);
|
||||
document.dispatchEvent(new window.CustomEvent('tabChanged', { detail: { tab: 'assistant' } }));
|
||||
await tick();
|
||||
const input = document.getElementById('assistant-input');
|
||||
input.value = 'x'.repeat(2001);
|
||||
document.getElementById('assistant-form').dispatchEvent(new window.Event('submit', { cancelable: true }));
|
||||
await tick();
|
||||
assert.equal(input.value.length, 2001, 'over-budget draft retained');
|
||||
assert.equal(calls.filter(call => call.url.endsWith('/chat/stream')).length, 0);
|
||||
|
||||
function assertSafe(element, purified) {
|
||||
assert.ok(element);
|
||||
assert.equal(element.querySelector('script, [onerror], [onload], a[href^="javascript:"]'), null);
|
||||
if (purified) {
|
||||
assert.equal(element.querySelectorAll('.assistant-table-scroll table tbody tr').length, 1);
|
||||
assert.equal(element.querySelector('.assistant-cite').getAttribute('href'), '#assistant-source-1');
|
||||
assert.match(element.querySelector('td').textContent, /Synthetic/);
|
||||
} else {
|
||||
assert.equal(element.querySelector('table, a, img, svg'), null, 'fallback is escaped text, not raw HTML');
|
||||
assert.match(element.textContent, /onerror=/, 'unsafe markup remains inert text');
|
||||
}
|
||||
}
|
||||
for (const purified of [false, true]) {
|
||||
if (purified) window.eval(read('node_modules/dompurify/dist/purify.min.js'));
|
||||
else delete window.DOMPurify;
|
||||
document.getElementById('btn-assistant-clear').click();
|
||||
input.value = 'Synthetic table question';
|
||||
document.getElementById('assistant-form').dispatchEvent(new window.Event('submit', { cancelable: true }));
|
||||
await tick();
|
||||
assertSafe(document.querySelector('.assistant-msg.assistant .assistant-bubble'), purified);
|
||||
document.getElementById('btn-assistant-save-confirm').click();
|
||||
await tick();
|
||||
assert.equal(saved.messages[1].content, markdown);
|
||||
assert.equal(saved.sources[0].excerpt, sources[0].excerpt);
|
||||
document.getElementById('btn-assistant-clear').click();
|
||||
document.querySelector('[data-assistant-load-chat="1"]').click();
|
||||
await tick();
|
||||
assertSafe(document.querySelector('.assistant-msg.assistant .assistant-bubble'), purified);
|
||||
const excerpt = document.querySelector('.assistant-source-excerpt p');
|
||||
assert.equal(excerpt.textContent, sources[0].excerpt);
|
||||
assert.equal(excerpt.style.whiteSpace, 'pre-wrap');
|
||||
assert.match(excerpt.textContent, /Note: Synthetic only\.$/);
|
||||
document.getElementById('btn-assistant-export-pdf').click();
|
||||
assertSafe(document.querySelector('#assistant-export-modal'), purified);
|
||||
document.querySelector('#assistant-export-modal #assistant-export-close').click();
|
||||
}
|
||||
});
|
||||
78
test/clinical-table-preservation.test.js
Normal file
78
test/clinical-table-preservation.test.js
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const retrieval = require('../src/utils/clinicalRetrieval');
|
||||
|
||||
// Run the actual route formatter without booting its DB/auth/provider imports.
|
||||
const route = fs.readFileSync(path.join(__dirname, '../src/routes/clinicalAssistant.js'), 'utf8');
|
||||
const formatter = route.slice(route.indexOf('function formatSourcesForPrompt('), route.indexOf('function sanitizeSourcesForClient('));
|
||||
const sandbox = { cleanSourceExcerpt: retrieval.cleanSourceExcerpt };
|
||||
vm.runInNewContext(formatter, sandbox);
|
||||
const formatSourcesForPrompt = sandbox.formatSourcesForPrompt;
|
||||
|
||||
const table = 'Table 1. Dose (mg/kg)\n\n| Drug | Dose (mg/kg) |\n|---|---|\n| α\\|β | 2 mg/kg |\n| Other | 5 mg/kg |\n\nNote: Test fixture only.';
|
||||
|
||||
test('actual normalize and prompt formatter preserve hit tables ahead of long surroundings', () => {
|
||||
const sources = retrieval.normalizeMcpSearchResponse({ results: [{
|
||||
id: 42, title: 'Test.pdf', page_number: 7, chunk_index: 3,
|
||||
before_context: 'Unrelated background '.repeat(100), excerpt: table,
|
||||
after_context: 'Later background '.repeat(100)
|
||||
}] });
|
||||
assert.equal(sources.length, 1);
|
||||
assert.ok(sources[0].excerpt.startsWith('Table 1.'));
|
||||
assert.ok(sources[0].excerpt.length <= 1800);
|
||||
const prompt = formatSourcesForPrompt(sources);
|
||||
assert.match(prompt, /^\[1\] Test, page 7\n/);
|
||||
assert.ok(prompt.includes('| Drug | Dose (mg/kg) |\n|---|---|\n| α\\|β | 2 mg/kg |'));
|
||||
assert.ok(prompt.includes('Note: Test fixture only.'));
|
||||
assert.ok(prompt.includes('[Content omitted]'));
|
||||
assert.equal(retrieval.cleanSourceExcerpt(sources[0].excerpt), sources[0].excerpt);
|
||||
assert.match(route, /cleanSourceExcerpt,[\s\S]*require\('\.\.\/utils\/clinicalRetrieval'\)/);
|
||||
assert.doesNotMatch(route, /function cleanSourceExcerpt\(/);
|
||||
});
|
||||
|
||||
test('large tables clip only whole rows, and oversized rows are explicitly omitted', () => {
|
||||
const header = '| Drug | Dose |\n|---|---|\n';
|
||||
const rows = Array.from({ length: 100 }, (_, i) => '| Drug ' + i + ' | ' + i + ' mg/kg |');
|
||||
const excerpt = retrieval.clipSourceExcerpt(header + rows.join('\n'), 1800);
|
||||
assert.ok(excerpt.length <= 1800);
|
||||
assert.ok(excerpt.startsWith(header));
|
||||
assert.ok(excerpt.endsWith('[Content omitted]'));
|
||||
for (const row of excerpt.split('\n').slice(2).filter(line => line.startsWith('|'))) {
|
||||
assert.ok(rows.includes(row), 'no partial clinical row: ' + row);
|
||||
}
|
||||
const withNotes = retrieval.clipSourceExcerpt('Table 2. Test\n\n' + header + rows.join('\n') + '\n\nNote: Values require adjustment.\n\n† Synthetic fixture.', 1800);
|
||||
assert.ok(withNotes.length <= 1800);
|
||||
assert.ok(withNotes.includes('Note: Values require adjustment.'));
|
||||
assert.ok(withNotes.endsWith('† Synthetic fixture.'));
|
||||
assert.ok(withNotes.includes('[Content omitted]'));
|
||||
const oversized = retrieval.clipSourceExcerpt(header + '| ' + 'x'.repeat(2000) + ' | 2 mg |', 1800);
|
||||
assert.equal(oversized, '[Content omitted]');
|
||||
for (const n of [0, 16, 17, 18, 19, 30]) assert.ok(retrieval.clipSourceExcerpt(table, n).length <= n);
|
||||
});
|
||||
|
||||
test('cleaning keeps safety/prose behavior and does not identify fenced pipes as tables', () => {
|
||||
const clean = retrieval.cleanSourceExcerpt(' <script>bad()</script><b>Useful</b> /var/private/a.png\n normal prose<br>next');
|
||||
assert.equal(clean, 'Useful normal prose next');
|
||||
assert.doesNotMatch(clean, /script|tmp|private|bad|<b>/);
|
||||
const code = retrieval.clipSourceExcerpt('```\n|A|B|\n|---|---|\n|1|2|\n```', 1800);
|
||||
assert.doesNotMatch(code, /\|A\|B\|\n/);
|
||||
assert.equal(retrieval.cleanSourceExcerpt('One\n two three'), 'One two three');
|
||||
});
|
||||
|
||||
test('dedup retains document/page/chunk citation identity rather than merging unrelated tables', () => {
|
||||
const sources = retrieval.normalizeMcpSearchResponse({ results: [
|
||||
{ id: 1, title: 'Same', page_number: 2, chunk_index: 0, excerpt: table },
|
||||
{ id: 2, title: 'Same', page_number: 2, chunk_index: 0, excerpt: table },
|
||||
{ id: 1, title: 'Same', page_number: 2, chunk_index: 1, excerpt: table },
|
||||
{ id: 1, title: 'Same', page_number: 3, chunk_index: 0, excerpt: table },
|
||||
{ id: 1, title: 'Same', page_number: 2, chunk_index: 0, excerpt: table }
|
||||
] });
|
||||
const deduped = retrieval.dedupeSources(sources);
|
||||
assert.equal(deduped.length, 4);
|
||||
assert.deepEqual(deduped.map(s => [s.number, s.id, s.page, s.chunk_index]), [[1, 1, 2, 0], [2, 2, 2, 0], [3, 1, 2, 1], [4, 1, 3, 0]]);
|
||||
assert.equal(deduped[0].excerpt, sources[0].excerpt);
|
||||
assert.ok(formatSourcesForPrompt(deduped).includes('[4] Same, page 3'));
|
||||
});
|
||||
|
|
@ -80,3 +80,74 @@ test('extension import analysis flags loose duplicates within the same import fi
|
|||
assert.equal(analysis.summary.possible, 1);
|
||||
assert.deepEqual(analysis.entries.map(e => e.status), ['new', 'possible_duplicate']);
|
||||
});
|
||||
|
||||
function deflatedZip(payload, flags = 0) {
|
||||
const zlib = require('node:zlib');
|
||||
const stored = transfer.createJsonZip('directory.json', payload);
|
||||
const start = 30 + stored.readUInt16LE(26);
|
||||
const end = start + stored.readUInt32LE(18);
|
||||
const compressed = zlib.deflateRawSync(stored.subarray(start, end));
|
||||
const header = Buffer.from(stored.subarray(0, start));
|
||||
const trailer = Buffer.from(stored.subarray(end));
|
||||
header.writeUInt16LE(flags, 6);
|
||||
header.writeUInt16LE(8, 8);
|
||||
header.writeUInt32LE(compressed.length, 18);
|
||||
trailer.writeUInt16LE(flags, 8);
|
||||
trailer.writeUInt16LE(8, 10);
|
||||
trailer.writeUInt32LE(compressed.length, 20);
|
||||
trailer.writeUInt32LE(start + compressed.length, trailer.length - 6);
|
||||
return Buffer.concat([header, compressed, trailer]);
|
||||
}
|
||||
|
||||
test('actual parser accepts plain/stored/deflated exports and 1000 maximal multibyte entries below 4 MiB', () => {
|
||||
const payload = transfer.exportPayload([{ location: '病棟', name: 'Synthetic', number: '123', notes: '合成' }]);
|
||||
for (const buf of [Buffer.from(JSON.stringify(payload)), transfer.createJsonZip('directory.json', payload), deflatedZip(payload)]) {
|
||||
assert.deepEqual(transfer.parseImportBuffer(buf), payload);
|
||||
}
|
||||
const large = transfer.exportPayload(Array.from({ length: 1000 }, (_, i) => ({
|
||||
location: '病'.repeat(120), name: '名'.repeat(120), number: String(i).padStart(40, '0'), notes: '語'.repeat(500)
|
||||
})));
|
||||
assert.ok(Buffer.byteLength(JSON.stringify(large, null, 2)) < 4 * 1024 * 1024);
|
||||
assert.equal(transfer.importItemsFromBody(transfer.parseImportBuffer(deflatedZip(large))).length, 1000);
|
||||
});
|
||||
|
||||
test('actual parser bounds plain and inflated payloads even when ZIP header lies about output size', () => {
|
||||
const over = 'x'.repeat(4 * 1024 * 1024 + 1);
|
||||
assert.throws(() => transfer.parseImportBuffer(Buffer.from(JSON.stringify(over))), /exceeds 4 MiB/);
|
||||
const bomb = deflatedZip(over);
|
||||
assert.ok(bomb.length < 1024 * 1024);
|
||||
assert.throws(() => transfer.parseImportBuffer(bomb), /exceeds 4 MiB/);
|
||||
bomb.writeUInt32LE(1, 22); // advertised size must never control the actual inflate bound
|
||||
assert.throws(() => transfer.parseImportBuffer(bomb), /larger|length|size|buffer/i);
|
||||
});
|
||||
|
||||
test('actual parser rejects truncated, lying, encrypted, descriptor, ZIP64 and unsupported headers', () => {
|
||||
const zip = deflatedZip({ items: [] });
|
||||
for (const length of [4, 8, 29, 30, 40]) assert.throws(() => transfer.parseImportBuffer(zip.subarray(0, length)));
|
||||
for (const [offset, value, width] of [[4, 45, 2], [6, 1, 2], [6, 8, 2], [8, 99, 2], [18, 0xffffffff, 4], [18, zip.readUInt32LE(18) + 1, 4], [22, 1, 4], [26, 0xffff, 2], [28, 0xffff, 2], [14, 0, 4]]) {
|
||||
const bad = Buffer.from(zip);
|
||||
if (width === 2) bad.writeUInt16LE(value, offset); else bad.writeUInt32LE(value, offset);
|
||||
assert.throws(() => transfer.parseImportBuffer(bad), 'offset ' + offset);
|
||||
}
|
||||
const stored = transfer.createJsonZip('directory.json', { items: [] });
|
||||
stored.writeUInt32LE(1, 22);
|
||||
assert.throws(() => transfer.parseImportBuffer(stored), /Invalid zip payload/);
|
||||
});
|
||||
|
||||
test('Deflate compression-option flags and UTF-8 are valid only for the appropriate methods', () => {
|
||||
const payload = { items: [], name: '病棟' };
|
||||
for (const flags of [0x2, 0x4, 0x6, 0x800, 0x802, 0x804, 0x806]) {
|
||||
assert.deepEqual(transfer.parseImportBuffer(deflatedZip(payload, flags)), payload);
|
||||
}
|
||||
const stored = transfer.createJsonZip('directory.json', payload);
|
||||
const central = 30 + stored.readUInt16LE(26) + stored.readUInt32LE(18);
|
||||
for (const flags of [0x800, 0x2, 0x4, 0x6]) {
|
||||
stored.writeUInt16LE(flags, 6);
|
||||
stored.writeUInt16LE(flags, central + 8);
|
||||
if (flags === 0x800) assert.deepEqual(transfer.parseImportBuffer(stored), payload);
|
||||
else assert.throws(() => transfer.parseImportBuffer(stored), /Unsupported zip flags/);
|
||||
}
|
||||
for (const flags of [0x807, 0x80e, 0x840]) {
|
||||
assert.throws(() => transfer.parseImportBuffer(deflatedZip(payload, flags)), /Unsupported zip flags/);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
454
test/frontend-prompt-env.test.js
Normal file
454
test/frontend-prompt-env.test.js
Normal file
|
|
@ -0,0 +1,454 @@
|
|||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { pathToFileURL } = require('node:url');
|
||||
const { JSDOM } = require('jsdom');
|
||||
const root = path.join(__dirname, '..');
|
||||
const read = file => fs.readFileSync(path.join(root, file), 'utf8');
|
||||
const tick = async () => { for (let i = 0; i < 8; i++) await new Promise(resolve => setImmediate(resolve)); };
|
||||
const json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
||||
const unsafe = ' </textarea><img src=x onerror="alert(1)"><script>alert(2)</script>\n😀 retain spacing ';
|
||||
const catalogue = [
|
||||
...Array.from({ length: 29 }, (_, i) => ({ key: 'SCRIBE_' + i, dbKey: 'prompt.SCRIBE_' + i, family: 'scribe', revision: i ? 0 : 10 })),
|
||||
{ key: 'clinical_assistant.system_behavior', dbKey: 'clinical_assistant.system_behavior', family: 'clinical-text', revision: 10 },
|
||||
{ key: 'clinical_assistant.image_behavior', dbKey: 'clinical_assistant.image_behavior', family: 'clinical-image', revision: 10 }
|
||||
].map(p => ({ ...p, value: unsafe, purpose: unsafe, usedBy: ['Synthetic runtime operation', unsafe], editable: true }));
|
||||
let moduleId = 0;
|
||||
|
||||
async function browser(t, module, handler) {
|
||||
const component = module.startsWith('admin') ? 'admin' : 'assistant';
|
||||
const dom = new JSDOM('<div id="' + component + '-tab">' + read('public/components/' + component + '.html') + '</div>', { url: 'https://synthetic.invalid' });
|
||||
const { window } = dom;
|
||||
const calls = []; const toasts = [];
|
||||
const fetch = async (url, options = {}) => {
|
||||
calls.push({ url, options, body: options.body && JSON.parse(options.body) });
|
||||
const response = await handler?.(url, options);
|
||||
if (response) return response;
|
||||
if (url === '/api/admin/config/prompts') return json({ success: true, prompts: catalogue });
|
||||
if (url === '/api/clinical-assistant/status') return json({ success: true, conversationChars: 1000 });
|
||||
return json({ success: true, models: [], config: [], chats: [], examples: [] });
|
||||
};
|
||||
const values = { window, document: window.document, fetch, getAuthHeaders: () => ({ 'Content-Type': 'application/json' }), showToast: (...args) => toasts.push(args), showConfirm: (message, accept) => accept() };
|
||||
const originals = Object.keys(values).map(key => [key, Object.getOwnPropertyDescriptor(global, key)]);
|
||||
Object.assign(global, values);
|
||||
Object.assign(window, { getAuthHeaders: values.getAuthHeaders, showToast: values.showToast, confirm: () => true });
|
||||
t.after(() => {
|
||||
for (const [key, descriptor] of originals) { if (descriptor) Object.defineProperty(global, key, descriptor); else delete global[key]; }
|
||||
window.close();
|
||||
});
|
||||
if (module === 'admin-settings') {
|
||||
const { initClinicalAssistantAdmin } = await import(pathToFileURL(path.join(root, 'public/js/admin/clinicalAssistant.js')).href);
|
||||
initClinicalAssistantAdmin(value => value);
|
||||
} else {
|
||||
await import(pathToFileURL(path.join(root, 'public/js/' + (module === 'admin' ? 'admin' : 'clinicalAssistant') + '.js')).href + '?synthetic=' + ++moduleId);
|
||||
}
|
||||
window.document.dispatchEvent(new window.CustomEvent('tabChanged', { detail: { tab: component } }));
|
||||
await tick();
|
||||
return { window, document: window.document, calls, toasts };
|
||||
}
|
||||
const editorFor = (ui, key) => ui.document.querySelector('[data-prompt-key="' + key + '"]');
|
||||
const draft = editor => editor.querySelector('.prompt-draft');
|
||||
const action = (editor, name) => editor.querySelector('[data-prompt-action="' + name + '"]');
|
||||
async function click(editor, name) { action(editor, name).click(); await tick(); }
|
||||
|
||||
test('native prompt catalogue separates all families, displays inert exact text, and saves canonical dbKey + expectedRevision', async t => {
|
||||
const ui = await browser(t, 'admin', (url, options) => {
|
||||
if (options.method === 'PUT') return json({ success: true, value: JSON.parse(options.body).value, revision: 11 });
|
||||
});
|
||||
assert.equal(ui.document.querySelectorAll('#cms-scribe-prompts details').length, 29);
|
||||
assert.equal(ui.document.querySelectorAll('#cms-clinical-text-prompts details').length, 1);
|
||||
assert.equal(ui.document.querySelectorAll('#cms-clinical-image-prompts details').length, 1);
|
||||
assert.equal(ui.document.querySelector('script, img, [onerror]'), null);
|
||||
assert.match(ui.document.getElementById('cms-scribe-prompts').parentElement.textContent, /Scribe instructions/);
|
||||
for (const p of catalogue) {
|
||||
const editor = editorFor(ui, p.dbKey);
|
||||
assert.equal(draft(editor).value, unsafe);
|
||||
assert.equal(editor.querySelector('.prompt-purpose').textContent, 'Purpose: ' + unsafe);
|
||||
assert.match(editor.querySelector('.prompt-usage').textContent, /Synthetic runtime operation/);
|
||||
for (const name of ['save', 'history', 'view', 'restore', 'reset']) assert.ok(action(editor, name));
|
||||
}
|
||||
for (const p of [catalogue[0], catalogue[1], ...catalogue.slice(-2)]) {
|
||||
const editor = editorFor(ui, p.dbKey);
|
||||
draft(editor).value = '\n' + unsafe + '\n';
|
||||
await click(editor, 'save');
|
||||
const call = ui.calls.at(-1);
|
||||
assert.equal(call.url, '/api/admin/config/' + p.dbKey);
|
||||
assert.equal(call.options.method, 'PUT');
|
||||
assert.deepEqual(call.body, { value: '\n' + unsafe + '\n', expectedRevision: p.revision });
|
||||
assert.match(editor.querySelector('.prompt-status').textContent, /Saved revision 11/);
|
||||
}
|
||||
assert.equal(draft(editorFor(ui, catalogue[2].dbKey)).value, unsafe, 'other editors unchanged');
|
||||
});
|
||||
|
||||
test('history/view/restore/reset use exact per-key APIs for every family and never replace other drafts', async t => {
|
||||
const historical = { id: 5, value: unsafe, createdAt: '2026-01-02T03:04:05Z', createdBy: '<img onerror=bad>', restoredFrom: 2, wasDefault: true };
|
||||
const ui = await browser(t, 'admin', (url, options) => {
|
||||
if (url.endsWith('/history?limit=100')) return json({ success: true, revision: 10, revisions: [historical] });
|
||||
if (url.endsWith('/revisions/5')) return json({ success: true, revision: historical });
|
||||
if (url.endsWith('/restore')) return json({ success: true, value: unsafe, revision: 11 });
|
||||
if (url.endsWith('/reset')) return json({ success: true, value: 'new shipped default', revision: 12 });
|
||||
});
|
||||
const other = editorFor(ui, catalogue[1].dbKey);
|
||||
draft(other).value = 'Unrelated unsaved Scribe draft';
|
||||
for (const p of [catalogue[0], ...catalogue.slice(-2)]) {
|
||||
const editor = editorFor(ui, p.dbKey);
|
||||
const base = '/api/admin/config/prompts/' + p.dbKey;
|
||||
draft(editor).value = 'Unsaved clinical/Scribe edits';
|
||||
await click(editor, 'history');
|
||||
assert.equal(ui.calls.at(-1).url, base + '/history?limit=100');
|
||||
assert.match(editor.querySelector('option').textContent, /default snapshot.*restored from #2/);
|
||||
await click(editor, 'view');
|
||||
assert.equal(ui.calls.at(-1).url, base + '/revisions/5');
|
||||
assert.equal(editor.querySelector('.prompt-revision-text').value, unsafe);
|
||||
assert.equal(editor.querySelector('.prompt-revision-text').readOnly, true);
|
||||
assert.equal(draft(editor).value, 'Unsaved clinical/Scribe edits');
|
||||
assert.equal(action(editor, 'baseline').disabled, true, 'an old revision cannot rebase a stale save');
|
||||
assert.equal(editor.querySelector('script, img, [onerror]'), null);
|
||||
await click(editor, 'restore');
|
||||
assert.equal(ui.calls.at(-1).url, base + '/restore');
|
||||
assert.equal(ui.calls.at(-1).options.method, 'POST');
|
||||
assert.deepEqual(ui.calls.at(-1).body, { revisionId: 5, expectedRevision: 10 });
|
||||
assert.equal(draft(editor).value, unsafe);
|
||||
await click(editor, 'reset');
|
||||
assert.equal(ui.calls.at(-1).url, base + '/reset');
|
||||
assert.deepEqual(ui.calls.at(-1).body, { expectedRevision: 11 });
|
||||
assert.equal(draft(editor).value, 'new shipped default');
|
||||
assert.equal(draft(other).value, 'Unrelated unsaved Scribe draft');
|
||||
}
|
||||
});
|
||||
|
||||
test('409s preserve drafts; explicit review/rebase resolves conflicts without an automatic overwrite', async t => {
|
||||
let conflicting = true;
|
||||
const ui = await browser(t, 'admin', (url, options) => {
|
||||
if (url.endsWith('/history?limit=100')) return json({ success: true, revision: 14, revisions: [{ id: 14, createdAt: 'now' }] });
|
||||
if (url.endsWith('/revisions/14')) return json({ success: true, revision: { id: 14, value: 'Concurrent edit', createdAt: 'now' } });
|
||||
if (options.method === 'PUT' || url.endsWith('/reset') || url.endsWith('/restore')) {
|
||||
if (conflicting) return json({ error: 'Stale revision' }, 409);
|
||||
return json({ success: true, value: JSON.parse(options.body).value, revision: 15 });
|
||||
}
|
||||
});
|
||||
const editor = editorFor(ui, 'clinical_assistant.system_behavior');
|
||||
draft(editor).value = 'Keep this draft';
|
||||
await click(editor, 'save');
|
||||
assert.match(editor.querySelector('.prompt-status').textContent, /Conflict.*draft is unchanged/);
|
||||
await click(editor, 'reset');
|
||||
assert.equal(draft(editor).value, 'Keep this draft');
|
||||
await click(editor, 'history'); await click(editor, 'view');
|
||||
await click(editor, 'restore');
|
||||
assert.match(editor.querySelector('.prompt-status').textContent, /Conflict/);
|
||||
assert.equal(draft(editor).value, 'Keep this draft');
|
||||
assert.match(editor.querySelector('.prompt-baseline').textContent, /revision 10/);
|
||||
await click(editor, 'baseline');
|
||||
assert.equal(draft(editor).value, 'Keep this draft');
|
||||
assert.match(editor.querySelector('.prompt-baseline').textContent, /revision 14/);
|
||||
conflicting = false;
|
||||
await click(editor, 'save');
|
||||
assert.deepEqual(ui.calls.at(-1).body, { value: 'Keep this draft', expectedRevision: 14 });
|
||||
assert.match(editor.querySelector('.prompt-status').textContent, /Saved revision 15/);
|
||||
});
|
||||
|
||||
test('failed loads can retry; transport failures and in-flight saves preserve newer and unrelated drafts', async t => {
|
||||
let catalogueFailure = true; let failure = true; let release;
|
||||
const ui = await browser(t, 'admin', (url, options) => {
|
||||
if (url === '/api/admin/config/prompts' && catalogueFailure) return json({ error: 'Unavailable catalogue' }, 503);
|
||||
if (url.endsWith('/history?limit=100')) throw Error('Synthetic offline failure');
|
||||
if (options.method === 'PUT') {
|
||||
if (failure) return json({ error: 'Migration unavailable' }, 503);
|
||||
return new Promise(resolve => { release = () => resolve(json({ success: true, revision: 11, value: JSON.parse(options.body).value })); });
|
||||
}
|
||||
});
|
||||
assert.match(ui.document.getElementById('cms-scribe-prompts').textContent, /Unavailable catalogue/);
|
||||
catalogueFailure = false;
|
||||
ui.document.querySelector('#cms-scribe-prompts button').click(); await tick();
|
||||
const editor = editorFor(ui, 'clinical_assistant.image_behavior');
|
||||
draft(editor).value = 'Preserved draft';
|
||||
await click(editor, 'history');
|
||||
assert.match(editor.querySelector('.prompt-status').textContent, /Synthetic offline failure/);
|
||||
await click(editor, 'save');
|
||||
assert.equal(draft(editor).value, 'Preserved draft');
|
||||
assert.match(editor.querySelector('.prompt-status').textContent, /Migration unavailable/);
|
||||
failure = false;
|
||||
action(editor, 'save').click(); await tick();
|
||||
assert.equal(action(editor, 'save').disabled, true);
|
||||
assert.equal(action(editor, 'reset').disabled, true);
|
||||
draft(editor).value = 'Newer draft typed during save';
|
||||
release(); await tick();
|
||||
assert.equal(draft(editor).value, 'Newer draft typed during save');
|
||||
assert.match(editor.querySelector('.prompt-status').textContent, /Newer draft edits are still unsaved/);
|
||||
// Resetting non-prompt CMS settings also must not reload prompt editors.
|
||||
ui.document.getElementById('btn-reset-all-defaults').click(); await tick();
|
||||
assert.equal(draft(editorFor(ui, 'clinical_assistant.image_behavior')).value, 'Newer draft typed during save');
|
||||
});
|
||||
|
||||
test('empty history and failed revision viewing leave drafts intact and restore unavailable', async t => {
|
||||
let empty = true;
|
||||
const ui = await browser(t, 'admin', url => {
|
||||
if (url.endsWith('/history?limit=100')) return json({ success: true, revision: empty ? 0 : 4, revisions: empty ? [] : [{ id: 4, createdAt: 'now' }] });
|
||||
if (url.endsWith('/revisions/4')) return json({ error: 'Revision unavailable' }, 404);
|
||||
});
|
||||
const editor = editorFor(ui, catalogue[1].dbKey);
|
||||
draft(editor).value = 'Keep even when no history is available';
|
||||
await click(editor, 'history');
|
||||
assert.match(editor.querySelector('.prompt-status').textContent, /No saved revisions yet/);
|
||||
assert.equal(action(editor, 'view').disabled, true);
|
||||
assert.equal(action(editor, 'restore').disabled, true);
|
||||
empty = false;
|
||||
await click(editor, 'history'); await click(editor, 'view');
|
||||
assert.match(editor.querySelector('.prompt-status').textContent, /Revision unavailable/);
|
||||
assert.equal(action(editor, 'restore').disabled, true);
|
||||
assert.equal(draft(editor).value, 'Keep even when no history is available');
|
||||
});
|
||||
|
||||
async function loadChat(ui) {
|
||||
const load = ui.document.querySelector('[data-assistant-load-chat="1"]');
|
||||
assert.ok(load); load.click(); await tick();
|
||||
}
|
||||
function enter(ui, value) {
|
||||
const input = ui.document.getElementById('assistant-input');
|
||||
input.value = value; input.dispatchEvent(new ui.window.Event('input')); return input;
|
||||
}
|
||||
async function ask(ui) {
|
||||
ui.document.getElementById('assistant-form').dispatchEvent(new ui.window.Event('submit', { cancelable: true })); await tick();
|
||||
}
|
||||
|
||||
test('native conversation UI counts UTF-16 history + draft, warns at exactly 90%, and refuses only above the cap without paid requests', async t => {
|
||||
const history = [{ role: 'user', content: 'x'.repeat(897) }, { role: 'assistant', content: '😀' }];
|
||||
const ui = await browser(t, 'assistant', (url, options) => {
|
||||
if (url === '/api/clinical-assistant/chats') return json({ success: true, chats: [{ id: 1 } ] });
|
||||
if (url === '/api/clinical-assistant/chats/1') return json({ success: true, chat: { payload: { messages: history } } });
|
||||
if (url.endsWith('/chat/stream')) return new Response('event: done\ndata: {"success":true,"answer":"Done","sources":[]}\n\n');
|
||||
});
|
||||
await loadChat(ui);
|
||||
const warning = ui.document.getElementById('assistant-context-warning');
|
||||
const usage = ui.document.getElementById('assistant-context-budget');
|
||||
enter(ui, ''); assert.match(usage.textContent, /899 \/ 1,000.*UTF-16 code units/); assert.equal(warning.hidden, true);
|
||||
enter(ui, 'x'); assert.equal(warning.hidden, false); assert.match(warning.textContent, /90%/);
|
||||
enter(ui, 'x'.repeat(101)); assert.match(warning.textContent, /At the conversation limit/);
|
||||
const input = enter(ui, 'x'.repeat(102));
|
||||
await ask(ui);
|
||||
assert.equal(ui.calls.filter(c => c.url.endsWith('/chat/stream')).length, 0);
|
||||
assert.equal(ui.calls.filter(c => c.url.endsWith('/handoff')).length, 0);
|
||||
assert.equal(input.value.length, 102);
|
||||
assert.equal(ui.document.querySelectorAll('.assistant-msg').length, 2);
|
||||
assert.match(warning.textContent, /Sending is blocked/);
|
||||
for (const id of ['btn-assistant-save', 'btn-assistant-download-chat', 'btn-assistant-export-pdf', 'btn-assistant-copy']) assert.equal(ui.document.getElementById(id).disabled, false);
|
||||
enter(ui, 'x'.repeat(101)); await ask(ui);
|
||||
const request = ui.calls.find(c => c.url.endsWith('/chat/stream'));
|
||||
assert.deepEqual(request.body.history, history);
|
||||
assert.equal(request.body.message.length, 101);
|
||||
assert.equal(input.value, '');
|
||||
});
|
||||
|
||||
test('over-cap saved history remains viewable/saveable/exportable; explicit handoff refuses before transport', async t => {
|
||||
const history = [{ role: 'user', content: 'x'.repeat(1001) }, { role: 'assistant', content: 'Full saved answer' }];
|
||||
const ui = await browser(t, 'assistant', (url, options) => {
|
||||
if (url === '/api/clinical-assistant/chats' && !options.method) return json({ success: true, chats: [{ id: 1 }] });
|
||||
if (url === '/api/clinical-assistant/chats/1') return json({ success: true, chat: { payload: { version: 2, messages: history } } });
|
||||
});
|
||||
await loadChat(ui); enter(ui, 'Unsent draft');
|
||||
ui.document.getElementById('btn-assistant-handoff').click(); await tick();
|
||||
assert.equal(ui.calls.filter(c => c.url.endsWith('/handoff')).length, 0);
|
||||
assert.match(ui.toasts.at(-1)[0], /History exceeds/);
|
||||
ui.document.getElementById('btn-assistant-save-confirm').click(); await tick();
|
||||
assert.deepEqual(ui.calls.find(c => c.url.endsWith('/chats') && c.options.method === 'POST').body.messages.map(({ role, content }) => ({ role, content })), history);
|
||||
assert.equal(ui.document.getElementById('assistant-input').value, 'Unsent draft');
|
||||
ui.window.matchMedia = () => ({ matches: true });
|
||||
ui.document.getElementById('btn-assistant-export-pdf').click();
|
||||
assert.match(ui.document.getElementById('assistant-export-modal').textContent, /Full saved answer/);
|
||||
assert.equal(ui.document.getElementById('btn-assistant-download-chat').disabled, false);
|
||||
});
|
||||
|
||||
test('explicit handoff at the exact cap excludes but preserves the draft, and keeps full history', async t => {
|
||||
const history = [{ role: 'user', content: '😀'.repeat(500) }];
|
||||
const ui = await browser(t, 'assistant', (url, options) => {
|
||||
if (url === '/api/clinical-assistant/chats') return json({ success: true, chats: [{ id: 1 }] });
|
||||
if (url === '/api/clinical-assistant/chats/1') return json({ success: true, chat: { payload: { messages: history } } });
|
||||
if (url.endsWith('/handoff')) return json({ success: true, summary: 'Explicit synthetic handoff' });
|
||||
});
|
||||
await loadChat(ui); enter(ui, 'Draft not included in handoff');
|
||||
ui.document.getElementById('btn-assistant-handoff').click(); await tick();
|
||||
assert.deepEqual(ui.calls.find(c => c.url.endsWith('/handoff')).body, { history });
|
||||
assert.equal(ui.document.getElementById('assistant-input').value, 'Draft not included in handoff');
|
||||
assert.equal(ui.document.querySelectorAll('.assistant-msg').length, 1);
|
||||
assert.equal(ui.document.getElementById('assistant-handoff-text').value, 'Explicit synthetic handoff');
|
||||
});
|
||||
|
||||
test('missing/invalid metadata never fabricates a cap; authoritative refusal keeps the draft and updates the counter', async t => {
|
||||
for (const metadata of [{ success: false }, { success: true, conversationChars: 1000001 }, { success: true }]) {
|
||||
await t.test(JSON.stringify(metadata), async t => {
|
||||
const ui = await browser(t, 'assistant', url => {
|
||||
if (url.endsWith('/status')) return json(metadata, 503);
|
||||
if (url.endsWith('/chat/stream')) return json({ error: 'Environment budget refused', budget: { limit: 1000 }, code: 'CONVERSATION_LIMIT' }, 413);
|
||||
});
|
||||
enter(ui, '😀'.repeat(501));
|
||||
assert.match(ui.document.getElementById('assistant-context-budget').textContent, /1,002.*Limit unavailable/);
|
||||
assert.doesNotMatch(ui.document.getElementById('assistant-context-budget').textContent, /120,000/);
|
||||
await ask(ui);
|
||||
assert.equal(ui.document.getElementById('assistant-input').value, '😀'.repeat(501));
|
||||
assert.equal(ui.document.querySelectorAll('.assistant-msg').length, 0);
|
||||
assert.match(ui.document.getElementById('assistant-context-budget').textContent, /1,002 \/ 1,000/);
|
||||
assert.match(ui.document.getElementById('assistant-context-warning').textContent, /Sending is blocked/);
|
||||
await ask(ui);
|
||||
assert.equal(ui.calls.filter(c => c.url.endsWith('/chat/stream')).length, 1, 'subsequent refusal is local');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const assistantConfig = () => ({ success: true, config: [
|
||||
{ key: 'clinical_assistant.chat_model', value: 'saved-chat' },
|
||||
{ key: 'clinical_assistant.image_model', value: 'saved-image' },
|
||||
{ key: 'clinical_assistant.search_limit', value: '17' },
|
||||
{ key: 'clinical_assistant.context_chars', value: '2300' }
|
||||
], conversationBudget: { limit: 240000, unit: 'characters', measure: 'UTF-16 code units', env: 'CLINICAL_ASSISTANT_CONVERSATION_CHARS', source: 'environment' } });
|
||||
const adminVisit = ui => ui.document.dispatchEvent(new ui.window.CustomEvent('tabChanged', { detail: { tab: 'admin' } }));
|
||||
const setting = (ui, name) => ui.document.getElementById('assistant-' + name);
|
||||
const writes = ui => ui.calls.filter(c => c.options.method === 'PUT');
|
||||
async function forceAssistantSave(ui) {
|
||||
const save = ui.document.getElementById('btn-save-assistant-config');
|
||||
save.click();
|
||||
// dispatchEvent bypasses the disabled UI: the handler must also guard state.
|
||||
save.dispatchEvent(new ui.window.MouseEvent('click', { bubbles: true }));
|
||||
await tick();
|
||||
}
|
||||
|
||||
test('assistant config GET503 plus Save makes zero PUTs; failed retry preserves drafts and successful revisit restores actual settings', async t => {
|
||||
let available = false;
|
||||
const ui = await browser(t, 'admin-settings', url => {
|
||||
if (url === '/api/admin/config') return available ? json(assistantConfig()) : json({ error: 'Request failed' }, 503);
|
||||
});
|
||||
await forceAssistantSave(ui);
|
||||
assert.equal(writes(ui).length, 0, 'failed configuration must never write defaults');
|
||||
assert.equal(ui.document.getElementById('btn-save-assistant-config').disabled, true);
|
||||
assert.match(setting(ui, 'admin-status').textContent, /failed|unavailable/i);
|
||||
assert.equal(setting(ui, 'chat-model').options.length, 0);
|
||||
assert.equal(setting(ui, 'image-model').options.length, 0);
|
||||
assert.equal(ui.calls.some(c => c.url.endsWith('/image-models/discover')), false);
|
||||
setting(ui, 'search-limit').value = '21';
|
||||
setting(ui, 'context-chars').value = '3100';
|
||||
setting(ui, 'custom-image-model').value = 'Unsent custom draft';
|
||||
setting(ui, 'chat-model').appendChild(new ui.window.Option('Draft chat', 'draft-chat'));
|
||||
const retry = ui.document.getElementById('btn-retry-assistant-config');
|
||||
assert.equal(retry.hidden, false);
|
||||
assert.equal(retry.type, 'button');
|
||||
retry.click(); await tick();
|
||||
assert.equal(setting(ui, 'search-limit').value, '21');
|
||||
assert.equal(setting(ui, 'context-chars').value, '3100');
|
||||
assert.equal(setting(ui, 'custom-image-model').value, 'Unsent custom draft');
|
||||
assert.equal(setting(ui, 'chat-model').value, 'draft-chat');
|
||||
await forceAssistantSave(ui); assert.equal(writes(ui).length, 0);
|
||||
assert.equal(ui.calls.filter(c => c.url === '/api/admin/config').length, 2);
|
||||
available = true;
|
||||
adminVisit(ui); await tick();
|
||||
assert.equal(setting(ui, 'chat-model').value, 'saved-chat');
|
||||
assert.equal(setting(ui, 'image-model').value, 'saved-image');
|
||||
assert.equal(setting(ui, 'search-limit').value, '17');
|
||||
assert.equal(setting(ui, 'context-chars').value, '2300');
|
||||
assert.match(setting(ui, 'conversation-budget').textContent, /240,000 characters \(UTF-16 code units\).*environment/);
|
||||
assert.match(setting(ui, 'admin-status').textContent, /ready/i);
|
||||
assert.equal(retry.hidden, true);
|
||||
adminVisit(ui); adminVisit(ui); await tick();
|
||||
assert.equal(ui.calls.filter(c => c.url === '/api/admin/config').length, 3, 'ready revisits neither reload nor add handlers');
|
||||
setting(ui, 'search-limit').value = '19';
|
||||
ui.document.getElementById('btn-save-assistant-config').click(); await tick();
|
||||
assert.deepEqual(writes(ui).map(c => [decodeURIComponent(c.url.split('/').pop()), c.body.value]), [
|
||||
['clinical_assistant.chat_model', 'saved-chat'], ['clinical_assistant.image_model', 'saved-image'],
|
||||
['clinical_assistant.search_limit', '19'], ['clinical_assistant.context_chars', '2300']
|
||||
]);
|
||||
});
|
||||
|
||||
test('assistant config pending, rejected, HTTP failure and malformed payloads never populate or save settings', async t => {
|
||||
const good = assistantConfig();
|
||||
const failures = [
|
||||
['network', () => { throw Error('offline'); }],
|
||||
['invalid JSON', () => new Response('{')],
|
||||
['HTTP503 with valid-looking body', () => json(good, 503)],
|
||||
...[null, {}, { ...good, success: false }, { ...good, config: undefined }, { ...good, config: {} },
|
||||
{ ...good, config: [null] }, { ...good, config: [{ key: 'clinical_assistant.chat_model', value: 42 }] },
|
||||
{ ...good, config: [{ value: 'missing key' }] }, { ...good, conversationBudget: undefined },
|
||||
{ ...good, conversationBudget: { ...good.conversationBudget, measure: 'tokens' } },
|
||||
{ ...good, conversationBudget: { ...good.conversationBudget, limit: 1000001 } }
|
||||
].map((data, i) => ['malformed ' + i, () => json(data)])
|
||||
];
|
||||
for (const [name, fail] of failures) await t.test(name, async t => {
|
||||
let release;
|
||||
const ui = await browser(t, 'admin-settings', url => {
|
||||
if (url === '/api/admin/config') return new Promise((resolve, reject) => { release = () => { try { resolve(fail()); } catch (error) { reject(error); } }; });
|
||||
});
|
||||
adminVisit(ui); adminVisit(ui);
|
||||
assert.equal(ui.calls.filter(c => c.url === '/api/admin/config').length, 1, 'loading revisits do not duplicate GET');
|
||||
assert.equal(ui.document.getElementById('btn-save-assistant-config').disabled, true);
|
||||
assert.match(setting(ui, 'admin-status').textContent, /loading/i);
|
||||
setting(ui, 'search-limit').value = '23';
|
||||
await forceAssistantSave(ui); assert.equal(writes(ui).length, 0);
|
||||
release(); await tick();
|
||||
await forceAssistantSave(ui); assert.equal(writes(ui).length, 0);
|
||||
assert.equal(ui.document.getElementById('btn-save-assistant-config').disabled, true);
|
||||
assert.equal(setting(ui, 'search-limit').value, '23');
|
||||
assert.equal(setting(ui, 'chat-model').options.length, 0);
|
||||
assert.match(setting(ui, 'conversation-budget').textContent, /unavailable/);
|
||||
});
|
||||
});
|
||||
|
||||
test('image discovery pending/refresh/failure retains saved, custom and explicit default selections without placeholder writes', async t => {
|
||||
let release; let rejectDiscovery;
|
||||
const ui = await browser(t, 'admin-settings', url => {
|
||||
if (url === '/api/admin/config') return json(assistantConfig());
|
||||
if (url === '/api/models') throw Error('chat discovery offline');
|
||||
if (url.endsWith('/image-models/discover')) return new Promise((resolve, reject) => { release = resolve; rejectDiscovery = reject; });
|
||||
});
|
||||
assert.equal(setting(ui, 'chat-model').value, 'saved-chat', 'chat discovery failure retains configured model');
|
||||
assert.equal(setting(ui, 'image-model').value, 'saved-image', 'no loading placeholder replaces configured selection');
|
||||
await forceAssistantSave(ui); assert.equal(writes(ui).length, 0);
|
||||
const refresh = ui.document.getElementById('btn-refresh-assistant-image-models');
|
||||
refresh.dispatchEvent(new ui.window.MouseEvent('click', { bubbles: true }));
|
||||
assert.equal(ui.calls.filter(c => c.url.endsWith('/image-models/discover')).length, 1);
|
||||
release(json({ error: 'unavailable' }, 503)); await tick();
|
||||
assert.equal(setting(ui, 'image-model').value, 'saved-image');
|
||||
assert.equal(ui.document.getElementById('btn-save-assistant-config').disabled, false);
|
||||
refresh.click(); await tick();
|
||||
setting(ui, 'custom-image-model').value = 'new-custom';
|
||||
ui.document.getElementById('btn-use-custom-assistant-image-model').click();
|
||||
await forceAssistantSave(ui); assert.equal(writes(ui).length, 0);
|
||||
release(json({ success: true, models: {} })); await tick();
|
||||
assert.equal(setting(ui, 'image-model').value, 'new-custom', 'late malformed discovery cannot revert a newer selection');
|
||||
ui.document.getElementById('btn-save-assistant-config').click(); await tick();
|
||||
assert.equal(writes(ui).find(c => c.url.endsWith('image_model')).body.value, 'new-custom');
|
||||
setting(ui, 'image-model').value = '';
|
||||
refresh.click(); await tick();
|
||||
const previousWrites = writes(ui).length;
|
||||
await forceAssistantSave(ui); assert.equal(writes(ui).length, previousWrites);
|
||||
release(json({ success: true, models: [{ id: 'discovered', name: 'Discovered' }] })); await tick();
|
||||
assert.equal(setting(ui, 'image-model').value, '', 'explicit default is not replaced by stale saved/custom fallback');
|
||||
ui.document.getElementById('btn-save-assistant-config').click(); await tick();
|
||||
assert.equal(writes(ui).filter(c => c.url.endsWith('image_model')).at(-1).body.value, '');
|
||||
setting(ui, 'custom-image-model').value = 'custom-offline';
|
||||
ui.document.getElementById('btn-use-custom-assistant-image-model').click();
|
||||
refresh.click(); await tick();
|
||||
rejectDiscovery(Error('synthetic discovery transport failure')); await tick();
|
||||
assert.equal(setting(ui, 'image-model').value, 'custom-offline');
|
||||
setting(ui, 'image-model').replaceChildren();
|
||||
await forceAssistantSave(ui); assert.equal(writes(ui).length, previousWrites + 4, 'empty select is not an intentional default');
|
||||
});
|
||||
|
||||
test('assistant settings retries leave global prompt drafts/history and starter snapshots untouched', async t => {
|
||||
let available = false;
|
||||
const ui = await browser(t, 'admin', url => {
|
||||
if (url === '/api/admin/config') return available ? json(assistantConfig()) : json({ error: 'Request failed' }, 503);
|
||||
});
|
||||
const editor = editorFor(ui, catalogue[0].dbKey);
|
||||
draft(editor).value = 'Unsaved global prompt';
|
||||
editor.querySelector('.prompt-revision-text').value = 'Previously viewed revision';
|
||||
setting(ui, 'prompt-pool-snapshots').innerHTML = '<option value="7">Existing snapshot</option>';
|
||||
const before = editor.outerHTML;
|
||||
const promptCalls = ui.calls.filter(c => c.url.includes('/config/prompts')).length;
|
||||
ui.document.getElementById('btn-retry-assistant-config').click(); await tick();
|
||||
assert.equal(setting(ui, 'prompt-pool-snapshots').value, '7');
|
||||
assert.equal(editor.outerHTML, before);
|
||||
available = true;
|
||||
adminVisit(ui); await tick();
|
||||
assert.equal(draft(editor).value, 'Unsaved global prompt');
|
||||
assert.equal(editor.querySelector('.prompt-revision-text').value, 'Previously viewed revision');
|
||||
assert.equal(ui.calls.filter(c => c.url.includes('/config/prompts')).length, promptCalls);
|
||||
assert.equal(ui.calls.some(c => c.options.method === 'POST' || c.options.method === 'PUT'), false);
|
||||
});
|
||||
56
test/mcp-shutdown.test.js
Normal file
56
test/mcp-shutdown.test.js
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
|
||||
// Execute the actual server shutdown registration/handler, without booting DB or routes.
|
||||
for (const failure of [null, 'audit', 'db', 'mcp']) {
|
||||
test('server drains HTTP, audit and DB before MCP/exit: ' + (failure || 'success'), async () => {
|
||||
const source = fs.readFileSync(require.resolve('../server'), 'utf8');
|
||||
const events = [];
|
||||
const signals = {};
|
||||
let httpClosed;
|
||||
let deadline;
|
||||
let deadlineMs;
|
||||
let releaseAudit;
|
||||
const auditGate = new Promise(resolve => { releaseAudit = resolve; });
|
||||
let releaseDb;
|
||||
const dbGate = new Promise(resolve => { releaseDb = resolve; });
|
||||
const context = {
|
||||
console: { log() {}, error() {} },
|
||||
server: { close(callback) { events.push('http.close'); httpClosed = callback; } },
|
||||
process: { on(signal, callback) { signals[signal] = callback; }, exit(code) { events.push('exit:' + code); } },
|
||||
setTimeout(callback, ms) { deadline = callback; deadlineMs = ms; return { unref() { events.push('guard.unref'); } }; },
|
||||
clearInterval(id) { assert.equal(id, 123); events.push('cleanup.clear'); },
|
||||
require(name) {
|
||||
if (name.endsWith('/auditQueue')) return { async drainAll() {
|
||||
events.push('audit.start'); await auditGate; events.push('audit.end');
|
||||
if (failure === 'audit') throw new Error('failure');
|
||||
} };
|
||||
if (name.endsWith('/database')) return { _cleanupInterval: 123, pool: { async end() {
|
||||
events.push('db.start'); await dbGate; events.push('db.end');
|
||||
if (failure === 'db') throw new Error('failure');
|
||||
} } };
|
||||
if (name.endsWith('/clinicalMcpClient')) return { async closeMcpSession() {
|
||||
events.push('mcp.close'); if (failure === 'mcp') throw new Error('secret');
|
||||
} };
|
||||
throw new Error('unexpected dependency');
|
||||
}
|
||||
};
|
||||
vm.runInNewContext(source.slice(source.indexOf('var shuttingDown = false;')), context);
|
||||
signals.SIGTERM();
|
||||
signals.SIGINT();
|
||||
assert.deepEqual(events, ['http.close', 'guard.unref']);
|
||||
assert.equal(deadlineMs, 9000);
|
||||
const done = httpClosed();
|
||||
assert.deepEqual(events.slice(2), ['audit.start']);
|
||||
releaseAudit();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.deepEqual(events.slice(2), ['audit.start', 'audit.end', 'cleanup.clear', 'db.start']);
|
||||
releaseDb();
|
||||
await done;
|
||||
assert.deepEqual(events.slice(2), ['audit.start', 'audit.end', 'cleanup.clear', 'db.start', 'db.end', 'mcp.close', 'exit:0']);
|
||||
deadline();
|
||||
assert.equal(events.at(-1), 'exit:1');
|
||||
});
|
||||
}
|
||||
|
|
@ -9,24 +9,6 @@ function read(relativePath) {
|
|||
return fs.readFileSync(path.join(root, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
test('api models endpoint preserves saved default in model list', () => {
|
||||
const server = read('server.js');
|
||||
assert.match(server, /var defaultOverride = await db\.getSetting\('models\.default'\)/);
|
||||
assert.match(server, /models\.push\(\{ id: defaultOverride, name: defaultOverride \+ ' \(saved default\)'/);
|
||||
assert.match(server, /defaultModel: defaultOverride \|\| DEFAULT_MODEL/);
|
||||
});
|
||||
|
||||
test('lazy-loaded tab model selectors apply admin default', () => {
|
||||
const app = read('public/js/app.js');
|
||||
const index = read('public/index.html');
|
||||
assert.match(app, /window\._defaultModelId = ''/);
|
||||
assert.match(app, /window\._defaultModelId = defaultModelId/);
|
||||
assert.match(app, /window\._buildModelOptions\(sel\)/);
|
||||
assert.match(app, /selectEl\.value = window\._defaultModelId/);
|
||||
assert.match(app, /saved\.textContent = window\._defaultModelId \+ ' \(saved default\)'/);
|
||||
assert.doesNotMatch(index, /global-model-select|model-cost-badge/);
|
||||
});
|
||||
|
||||
test('top bar model selector is removed and omitted selections use backend default', () => {
|
||||
const app = read('public/js/app.js');
|
||||
const selector = app.match(/function getSelectedModel\(\) \{[\s\S]*?\n\}/)[0];
|
||||
|
|
@ -43,14 +25,6 @@ test('learning hub model selector uses shared tab model options', () => {
|
|||
assert.match(learningHub, /if \(model\) formData\.append\('model', model\)/);
|
||||
});
|
||||
|
||||
test('backend AI calls use admin default when request omits model', () => {
|
||||
const ai = read('src/utils/ai.js');
|
||||
assert.match(ai, /async function resolveModel\(requestedModel\)/);
|
||||
assert.match(ai, /await db\.getSetting\('models\.default'\)/);
|
||||
assert.match(ai, /var model = await resolveModel\(requestedModel\)/g);
|
||||
assert.match(ai, /await assertModelAllowed\(model, options\)/g);
|
||||
});
|
||||
|
||||
test('personal notes voice generation sends selected model only when populated', () => {
|
||||
const recorder = read('public/js/notes/recorder.js');
|
||||
assert.match(recorder, /var modelEl = document\.getElementById\('notes-model-select'\)/);
|
||||
|
|
|
|||
556
test/policy-flows.test.js
Normal file
556
test/policy-flows.test.js
Normal file
|
|
@ -0,0 +1,556 @@
|
|||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const crypto = require('node:crypto');
|
||||
const http = require('node:http');
|
||||
const { createRequire } = require('node:module');
|
||||
const express = require('express');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const root = path.join(__dirname, '..');
|
||||
const secret = 'synthetic-policy-test-secret';
|
||||
const password = bcrypt.hashSync('synthetic-password', 4);
|
||||
|
||||
// Execute actual local modules, including server.js and its real routers. Only
|
||||
// infrastructure boundaries (DB, IdP, providers, logs, timers, listener) are fake.
|
||||
function fixture(envOverrides = {}) {
|
||||
const state = {
|
||||
settings: {
|
||||
'models.custom': JSON.stringify([{ id: 'allowed', name: 'Allowed' }, { id: 'other', name: 'Other' }]),
|
||||
'models.disabled': '[]', 'models.default': 'allowed',
|
||||
'oidc.enabled': 'true', 'oidc.disable_local_auth': 'false',
|
||||
'oidc.issuer': 'https://idp.example', 'oidc.client_id': 'synthetic-client',
|
||||
'feature.read_aloud': 'true', 'feature.nextcloud': 'true', 'feature.memories': 'true',
|
||||
'tts.model': 'local-kitten-tts', 'tts.voice': 'Luna', 'stt.model': 'synthetic-stt'
|
||||
},
|
||||
user: { id: 7, email: 'synthetic@example.test', name: 'Synthetic', role: 'admin', password, email_verified: true, disabled: false },
|
||||
writes: [], queries: [], requests: [], grants: [], logs: [], authorizations: new Map(), usedCodes: new Set(),
|
||||
claims: { sub: 'subject-7', email: 'synthetic@example.test', email_verified: true, name: 'Synthetic' }
|
||||
};
|
||||
const env = { JWT_SECRET: secret, DATA_ENCRYPTION_KEY: 'a'.repeat(64), APP_URL: 'https://app.example', AI_PROVIDER: 'litellm', LITELLM_API_BASE: 'https://gateway.example/v1', LITELLM_API_KEY: 'synthetic', ...envOverrides };
|
||||
const db = {
|
||||
async getSetting(key) { if (state.settingsError) throw new Error('synthetic settings failure'); return state.settings[key] ?? null; },
|
||||
async setSetting(key, value) { state.settings[key] = value; },
|
||||
async get(sql, params) {
|
||||
state.queries.push(sql);
|
||||
if (sql.includes('COUNT(*)') && sql.includes('FROM users')) return { count: 1 };
|
||||
if (sql.includes('FROM users')) return state.user ? { ...state.user } : null;
|
||||
if (sql.includes('FROM user_sessions')) return { id: 'session-7', last_activity: new Date() };
|
||||
if (sql.includes('COUNT(*)') && sql.includes('user_memories')) return { cnt: 1 };
|
||||
throw new Error('Unexpected DB get: ' + sql);
|
||||
},
|
||||
async all(sql) {
|
||||
state.queries.push(sql);
|
||||
if (sql.includes('user_memories')) return [{ id: 1, category: 'physical_exam', name: 'Synthetic template', content: 'MEMORY_SENTINEL' }];
|
||||
return [];
|
||||
},
|
||||
async run(sql, params) {
|
||||
state.writes.push({ sql, params });
|
||||
if (sql.includes('INSERT INTO user_sessions') && state.sessionError) throw new Error('synthetic session failure');
|
||||
if (sql.includes('INSERT INTO users')) state.user = { id: 8, email: params[0], password: params[1], name: params[2], role: params[3], email_verified: true };
|
||||
return { lastInsertRowid: 8, changes: 1 };
|
||||
}
|
||||
};
|
||||
const logger = new Proxy({}, { get: () => (...args) => state.logs.push(args) });
|
||||
const fakeOIDC = {
|
||||
discovery: async () => ({}),
|
||||
randomPKCECodeVerifier: () => crypto.randomBytes(32).toString('base64url'),
|
||||
calculatePKCECodeChallenge: async verifier => crypto.createHash('sha256').update(verifier).digest('base64url'),
|
||||
buildAuthorizationUrl(config, params) {
|
||||
state.authorizations.set(params.state, params);
|
||||
return new URL('https://idp.example/authorize?' + new URLSearchParams(params));
|
||||
},
|
||||
async authorizationCodeGrant(config, url, options) {
|
||||
state.grants.push(options);
|
||||
const auth = state.authorizations.get(options.expectedState);
|
||||
assert.ok(auth, 'grant belongs to an initiated transaction');
|
||||
assert.equal(options.expectedNonce, auth.nonce);
|
||||
assert.equal(await fakeOIDC.calculatePKCECodeChallenge(options.pkceCodeVerifier), auth.code_challenge);
|
||||
const code = url.searchParams.get('code');
|
||||
if (!code || state.usedCodes.has(code)) throw new Error('code already consumed');
|
||||
state.usedCodes.add(code);
|
||||
if (state.idpError) throw new Error('sensitive-verifier-' + options.pkceCodeVerifier);
|
||||
return { claims: () => state.claims };
|
||||
}
|
||||
};
|
||||
class FakeOpenAI {
|
||||
constructor() {
|
||||
this.chat = { completions: { create: async payload => {
|
||||
state.requests.push(payload);
|
||||
if (state.providerError) throw new Error('synthetic provider failure');
|
||||
if (payload.stream) return (async function* () { yield { choices: [{ delta: { content: 'generated' }, finish_reason: 'stop' }] }; })();
|
||||
return { choices: [{ message: { content: state.aiContent || 'generated' }, finish_reason: 'stop' }] };
|
||||
} } };
|
||||
}
|
||||
}
|
||||
async function providerFetch(url, options) {
|
||||
state.requests.push({ url: String(url), options });
|
||||
assert.ok(String(url).startsWith('https://gateway.example/') || String(url).startsWith('https://api.pwnedpasswords.com/'), 'Unexpected network URL');
|
||||
return {
|
||||
ok: !state.httpError, status: state.httpError || 200,
|
||||
json: async () => state.httpData || { text: 'synthetic transcript' }, text: async () => '',
|
||||
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
|
||||
headers: { get: () => 'audio/wav' }
|
||||
};
|
||||
}
|
||||
const cache = new Map();
|
||||
function load(file) {
|
||||
const filename = path.resolve(root, file);
|
||||
const rel = path.relative(root, filename).replaceAll(path.sep, '/');
|
||||
if (rel === 'src/db/database.js') return db;
|
||||
if (rel === 'src/utils/logger.js') return logger;
|
||||
if (rel === 'src/utils/notify.js') return new Proxy({}, { get: () => async () => {} });
|
||||
if (rel === 'src/middleware/logging.js') return (req, res, next) => next();
|
||||
if (rel === 'src/utils/metrics.js') return { metricsMiddleware: (req, res, next) => next(), metricsHandler: (req, res) => res.end() };
|
||||
if (cache.has(filename)) return cache.get(filename).exports;
|
||||
const module = { exports: {} };
|
||||
cache.set(filename, module);
|
||||
const nativeRequire = createRequire(filename);
|
||||
function localRequire(name) {
|
||||
if (name.startsWith('.')) {
|
||||
const resolved = nativeRequire.resolve(name);
|
||||
if (resolved.startsWith(root + path.sep) && resolved.endsWith('.js')) return load(resolved);
|
||||
return nativeRequire(name);
|
||||
}
|
||||
if (name === 'openid-client') return fakeOIDC;
|
||||
if (name === 'openai') return { OpenAI: FakeOpenAI };
|
||||
if (name === 'dns') return { promises: { lookup: async () => [{ address: '8.8.8.8' }] } };
|
||||
if (name === 'axios') {
|
||||
const dav = async options => {
|
||||
if (!state.allowDav || !options.url.startsWith('https://cloud.example/')) throw new Error('Unexpected axios request');
|
||||
state.requests.push({ dav: options });
|
||||
return { data: '<d:multistatus></d:multistatus>', headers: { 'content-type': 'text/plain' }, status: 200 };
|
||||
};
|
||||
return Object.assign(dav, { get: (url, options) => dav({ url, ...options }) });
|
||||
}
|
||||
if (name === 'dotenv') return { config() {} };
|
||||
if (name === 'http' && rel === 'server.js') return { createServer(app) { state.app = app; return { listen() {}, close() {} }; } };
|
||||
return nativeRequire(name);
|
||||
}
|
||||
vm.runInNewContext(fs.readFileSync(filename, 'utf8'), {
|
||||
module, exports: module.exports, require: localRequire, __dirname: path.dirname(filename), __filename: filename,
|
||||
process: { env, on() {}, exit(code) { throw new Error('Unexpected process exit ' + code); } },
|
||||
console: logger, Buffer, URL, URLSearchParams, TextEncoder, TextDecoder, AbortController, File, Blob, FormData,
|
||||
fetch: providerFetch, setTimeout: () => ({ unref() {} }), clearTimeout() {}, setInterval: () => ({ unref() {} }), clearInterval() {}
|
||||
}, { filename });
|
||||
return module.exports;
|
||||
}
|
||||
async function serve(t, serverModule = false) {
|
||||
if (serverModule) load('server.js');
|
||||
else {
|
||||
state.app = express();
|
||||
state.app.use(express.json());
|
||||
state.app.use(require('cookie-parser')());
|
||||
state.app.use('/api/auth', load('src/routes/oidc.js'));
|
||||
state.app.use('/api/auth', load('src/routes/auth.js'));
|
||||
}
|
||||
const server = http.createServer(state.app);
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
t.after(() => new Promise(resolve => { server.close(resolve); server.closeAllConnections(); }));
|
||||
const base = 'http://127.0.0.1:' + server.address().port;
|
||||
return async function request(route, { method = 'GET', body, cookie, role, authenticated = false } = {}) {
|
||||
if (role) state.user.role = role;
|
||||
const headers = {};
|
||||
if (cookie) headers.cookie = cookie;
|
||||
if (authenticated || role) headers.authorization = 'Bearer ' + jwt.sign({ userId: 7 }, secret);
|
||||
if (body !== undefined && !(body instanceof FormData)) headers['content-type'] = 'application/json';
|
||||
const response = await fetch(base + route, { method, headers, body: body instanceof FormData ? body : body === undefined ? undefined : JSON.stringify(body), redirect: 'manual' });
|
||||
const text = await response.text();
|
||||
let data; try { data = JSON.parse(text); } catch (_) { data = text; }
|
||||
return { status: response.status, headers: response.headers, data };
|
||||
};
|
||||
}
|
||||
return { state, db, load, serve };
|
||||
}
|
||||
|
||||
async function initiate(request) {
|
||||
const response = await request('/api/auth/oidc');
|
||||
assert.equal(response.status, 302);
|
||||
const url = new URL(response.headers.get('location'));
|
||||
const cookie = response.headers.getSetCookie().find(c => c.startsWith('ped_oidc='));
|
||||
assert.match(cookie, /HttpOnly/); assert.match(cookie, /Secure/); assert.match(cookie, /SameSite=Lax/); assert.match(cookie, /Path=\/api\/auth\/oidc/);
|
||||
assert.match(url.searchParams.get('state'), /^[a-f0-9]{48}$/);
|
||||
assert.equal(url.searchParams.has('code_verifier'), false);
|
||||
return { state: url.searchParams.get('state'), cookie: cookie.split(';')[0] };
|
||||
}
|
||||
function authCookies(response) { return response.headers.getSetCookie().filter(c => c.startsWith('ped_auth=')); }
|
||||
function assertCleared(response) { assert.match(response.headers.getSetCookie().find(c => c.startsWith('ped_oidc=')), /Expires=Thu, 01 Jan 1970/); }
|
||||
function signTransaction(payload) {
|
||||
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||
return 'ped_oidc=' + body + '.' + crypto.createHmac('sha256', secret).update(body).digest('base64url');
|
||||
}
|
||||
|
||||
test('OIDC is browser-bound with opaque state; successful PKCE/nonce exchange creates session before cookie', async t => {
|
||||
const f = fixture(); const request = await f.serve(t);
|
||||
const a = await initiate(request); const b = await initiate(request);
|
||||
const wrong = await request('/api/auth/oidc/callback?state=' + a.state + '&code=one', { cookie: b.cookie });
|
||||
assert.match(wrong.headers.get('location'), /invalid_state/); assertCleared(wrong);
|
||||
assert.equal(f.state.grants.length, 0);
|
||||
const good = await request('/api/auth/oidc/callback?state=' + a.state + '&code=one', { cookie: a.cookie });
|
||||
assert.match(good.headers.get('location'), /sso=ok/); assertCleared(good); assert.equal(authCookies(good).length, 1);
|
||||
assert.ok(f.state.writes.some(w => w.sql.includes('INSERT INTO user_sessions')));
|
||||
assert.ok(f.state.writes.some(w => w.sql.includes('UPDATE users SET oidc_sub')));
|
||||
const replay = await request('/api/auth/oidc/callback?state=' + a.state + '&code=one');
|
||||
assert.match(replay.headers.get('location'), /invalid_state/); assertCleared(replay);
|
||||
const copiedReplay = await request('/api/auth/oidc/callback?state=' + a.state + '&code=one', { cookie: a.cookie });
|
||||
assert.match(copiedReplay.headers.get('location'), /sso_failed/); assert.equal(authCookies(copiedReplay).length, 0);
|
||||
for (const grant of f.state.grants) assert.ok(!JSON.stringify(f.state.logs).includes(grant.pkceCodeVerifier));
|
||||
});
|
||||
|
||||
test('OIDC rejects expired, malformed, forged and missing transactions without reaching IdP', async t => {
|
||||
const f = fixture(); const request = await f.serve(t); const a = await initiate(request);
|
||||
const payload = { s: a.state, n: 'b'.repeat(48), v: 'v'.repeat(43), expires: Date.now() + 60000 };
|
||||
for (const cookie of [undefined, 'ped_oidc=bad.x', 'ped_oidc=' + 'a'.repeat(2100), a.cookie + 'x',
|
||||
signTransaction({ ...payload, expires: Date.now() - 1 }), signTransaction({ ...payload, expires: Date.now() + 600000 }),
|
||||
signTransaction({ ...payload, v: 'short' }), signTransaction({ ...payload, n: [] })]) {
|
||||
const response = await request('/api/auth/oidc/callback?state=' + a.state + '&code=x', { cookie });
|
||||
assert.match(response.headers.get('location'), /invalid_state/); assertCleared(response); assert.equal(authCookies(response).length, 0);
|
||||
}
|
||||
for (const query of ['state=bad', 'state=' + a.state + '&state=' + a.state, 'state[x]=bad']) {
|
||||
const response = await request('/api/auth/oidc/callback?' + query, { cookie: a.cookie });
|
||||
assert.match(response.headers.get('location'), /invalid_state/);
|
||||
}
|
||||
assert.equal(f.state.grants.length, 0);
|
||||
});
|
||||
|
||||
test('OIDC refuses unsafe linking, disabled/mismatched identities, and session failure; keeps linked/new flows', async t => {
|
||||
const f = fixture(); const request = await f.serve(t);
|
||||
const baseline = { ...f.state.user };
|
||||
for (const scenario of [
|
||||
{ user: { email_verified: false }, error: 'account_link_required' },
|
||||
{ user: { disabled: true }, error: 'disabled' },
|
||||
{ user: { oidc_sub: 'other-sub' }, error: 'sub_mismatch' },
|
||||
{ claims: { email_verified: false }, error: 'email_unverified' },
|
||||
{ sessionError: true, error: 'sso_failed' },
|
||||
{ idpError: true, error: 'sso_failed' },
|
||||
{ user: { oidc_sub: 'subject-7', email_verified: false }, success: true },
|
||||
{ newUser: true, success: true }
|
||||
]) {
|
||||
f.state.user = scenario.newUser ? null : { ...baseline, ...scenario.user };
|
||||
f.state.claims.email_verified = scenario.claims ? false : true;
|
||||
f.state.sessionError = !!scenario.sessionError; f.state.idpError = !!scenario.idpError; f.state.writes.length = 0;
|
||||
const a = await initiate(request);
|
||||
const response = await request('/api/auth/oidc/callback?state=' + a.state + '&code=' + a.state, { cookie: a.cookie });
|
||||
assertCleared(response);
|
||||
assert.match(response.headers.get('location'), new RegExp(scenario.success ? 'sso=ok' : scenario.error));
|
||||
assert.equal(authCookies(response).length, scenario.success ? 1 : 0);
|
||||
if (!scenario.success && !scenario.sessionError) assert.equal(f.state.writes.length, 0, 'no account mutation before identity/disabled checks');
|
||||
for (const grant of f.state.grants) assert.ok(!JSON.stringify(f.state.logs).includes(grant.pkceCodeVerifier));
|
||||
}
|
||||
});
|
||||
|
||||
test('active SSO-only policy denies local login/registration/credential creation, but disabled OIDC cannot lock out local auth', async t => {
|
||||
const f = fixture(); const request = await f.serve(t, true);
|
||||
f.state.settings['oidc.disable_local_auth'] = 'true';
|
||||
for (const route of ['/api/auth/login', '/api/auth/register', '/api/auth/forgot-password', '/api/auth/reset-password', '/api/auth/change-password', '/api/auth/setup-2fa', '/api/auth/verify-2fa', '/api/auth/2fa/backup-codes', '/api/admin/users/7/reset-password']) {
|
||||
const response = await request(route, { method: 'POST', body: {}, authenticated: true });
|
||||
assert.equal(response.status, 403, route); assert.equal(response.data.code, 'sso_only'); assert.equal(authCookies(response).length, 0);
|
||||
}
|
||||
assert.equal(f.state.writes.length, 0);
|
||||
assert.equal((await request('/api/auth/oidc-status')).data.disableLocalAuth, true);
|
||||
assert.equal((await request('/api/auth/registration-status')).data.registrationEnabled, false);
|
||||
assert.equal((await request('/api/auth/me', { authenticated: true })).data.user.canLocalAuth, false);
|
||||
f.state.settings['oidc.enabled'] = 'false';
|
||||
assert.equal((await request('/api/auth/oidc-status')).data.disableLocalAuth, false);
|
||||
const local = await request('/api/auth/login', { method: 'POST', body: { email: f.state.user.email, password: 'synthetic-password' } });
|
||||
assert.equal(local.status, 200); assert.equal(authCookies(local).length, 1);
|
||||
f.state.settingsError = true;
|
||||
const unavailable = await request('/api/auth/login', { method: 'POST', body: {} });
|
||||
assert.equal(unavailable.status, 503); assert.equal(authCookies(unavailable).length, 0);
|
||||
});
|
||||
|
||||
test('local login and auto-verified registration never issue success/cookie on session insert failure', async t => {
|
||||
const f = fixture(); const request = await f.serve(t); f.state.sessionError = true;
|
||||
const login = await request('/api/auth/login', { method: 'POST', body: { email: f.state.user.email, password: 'synthetic-password' } });
|
||||
assert.equal(login.status, 500); assert.equal(authCookies(login).length, 0); assert.equal(login.data.success, undefined);
|
||||
f.state.user = null;
|
||||
const registration = await request('/api/auth/register', { method: 'POST', body: { email: 'new@example.test', password: 'synthetic-password', name: 'Synthetic' } });
|
||||
assert.equal(registration.status, 500); assert.equal(authCookies(registration).length, 0); assert.equal(registration.data.success, undefined);
|
||||
assert.equal(f.state.writes.filter(w => w.sql.includes('INSERT INTO user_sessions')).length, 2);
|
||||
});
|
||||
|
||||
test('final model allowlist is enforced for streaming/nonstream, invalid settings/outage, defaults and fallback', async () => {
|
||||
const f = fixture({ LITELLM_DEFAULT_MODEL: 'not-enabled', LITELLM_FALLBACK_MODEL: 'not-enabled' });
|
||||
const ai = f.load('src/utils/ai.js'); const models = f.load('src/utils/models.js');
|
||||
for (const call of [options => ai.callAI([], options), options => ai.callAIStream([], options, () => {})]) {
|
||||
await call({ model: 'allowed' });
|
||||
f.state.settings['models.disabled'] = '["other"]';
|
||||
await assert.rejects(call({ model: 'other' }), /Model not permitted/);
|
||||
f.state.settings['models.custom'] = '[]';
|
||||
await assert.rejects(call({ model: 'allowed' }), /Model not permitted/);
|
||||
await assert.rejects(call({}), /Model not permitted/);
|
||||
for (const invalid of ['', '{', '{}', 'null', '[null]', '[{"id":""}]']) {
|
||||
f.state.settings['models.custom'] = invalid;
|
||||
await assert.rejects(call({ model: 'allowed' }));
|
||||
}
|
||||
f.state.settings['models.custom'] = '[{"id":"allowed"}]';
|
||||
for (const invalid of ['', '{}', 'null', '[null]']) {
|
||||
f.state.settings['models.disabled'] = invalid;
|
||||
await assert.rejects(call({ model: 'allowed' }));
|
||||
}
|
||||
f.state.settings['models.disabled'] = '[]';
|
||||
await call({ model: 'allowed' }); // warm path must not retain grants during outage
|
||||
f.state.settingsError = true;
|
||||
await assert.rejects(call({ model: 'allowed' })); await assert.rejects(call({}));
|
||||
f.state.settingsError = false;
|
||||
}
|
||||
delete f.state.settings['models.custom']; delete f.state.settings['models.disabled'];
|
||||
assert.equal((await models.getAllowedModelIds(f.db)).size, 0);
|
||||
await assert.rejects(ai.callAI([], {}), /Model not permitted/);
|
||||
f.state.settings['models.custom'] = '[{"id":"allowed"}]';
|
||||
f.state.settings['models.default'] = 'removed';
|
||||
assert.equal(await models.getEffectiveDefaultModel(f.db), 'allowed');
|
||||
await ai.callAI([], {}); assert.equal(f.state.requests.at(-1).model, 'allowed');
|
||||
f.state.settings['ai.allow_model_fallback'] = 'true'; f.state.providerError = true;
|
||||
const start = f.state.requests.length;
|
||||
await assert.rejects(ai.callAI([], { model: 'allowed' }));
|
||||
assert.equal(f.state.requests.length - start, 1, 'disabled fallback never reaches provider');
|
||||
f.state.providerError = false;
|
||||
await ai.callAI([], { model: 'admin-probe', skipAllowlistCheck: true });
|
||||
assert.equal(f.state.requests.at(-1).model, 'admin-probe');
|
||||
});
|
||||
|
||||
test('merged static/custom roster filters disabled custom models too', async () => {
|
||||
const f = fixture({ AI_PROVIDER: 'openrouter' }); const models = f.load('src/utils/models.js');
|
||||
f.state.settings['models.disabled'] = '["allowed", "google/gemini-2.5-flash"]';
|
||||
const roster = await models.getAvailableModelsWithOverrides(f.db);
|
||||
assert.ok(roster.length); assert.ok(!roster.some(m => ['allowed', 'google/gemini-2.5-flash'].includes(m.id)));
|
||||
f.state.settings['models.disabled'] = JSON.stringify(models.getAvailableModels().map(m => m.id).concat(['allowed', 'other']));
|
||||
assert.equal((await models.getAllowedModelIds(f.db)).size, 0);
|
||||
});
|
||||
|
||||
test('real server model API/default setters/toggles/removal agree, and admin probe bypass is protected', async t => {
|
||||
const f = fixture({ LITELLM_DEFAULT_MODEL: 'absent' }); const request = await f.serve(t, true);
|
||||
const put = (route, body, role = 'admin') => request(route, { method: 'PUT', body, role });
|
||||
assert.equal((await put('/api/admin/config/models/default', { modelId: 'absent' })).status, 400);
|
||||
assert.equal((await put('/api/admin/config/models/toggle', { modelId: 'allowed', enabled: 'false' })).status, 400);
|
||||
assert.equal((await put('/api/admin/config/models/toggle', { modelId: 'absent', enabled: false })).status, 400);
|
||||
assert.equal((await put('/api/admin/config/models/toggle', { modelId: 'allowed', enabled: false })).status, 200);
|
||||
assert.equal((await put('/api/admin/config/models/default', { modelId: 'allowed' })).status, 400);
|
||||
let advertised = (await request('/api/models')).data;
|
||||
assert.deepEqual(advertised.models.map(m => m.id), ['other']); assert.equal(advertised.defaultModel, 'other');
|
||||
assert.equal(f.state.settings['models.default'], '');
|
||||
assert.equal((await put('/api/admin/config/models/toggle', { modelId: 'allowed', enabled: true })).status, 200);
|
||||
assert.equal((await put('/api/admin/config/models/default', { modelId: 'allowed' })).status, 200);
|
||||
await request('/api/admin/config/models/custom/allowed', { method: 'DELETE', role: 'admin' });
|
||||
advertised = (await request('/api/models')).data;
|
||||
assert.deepEqual(advertised.models.map(m => m.id), ['other']); assert.equal(advertised.defaultModel, 'other');
|
||||
const note = await request('/api/notes/from-voice', { method: 'POST', role: 'user', body: { transcript: 'Synthetic personal note' } });
|
||||
assert.equal(note.status, 200); assert.equal(note.data.model, 'other', 'omitted note model uses final default, not disabled env fallback');
|
||||
assert.equal((await put('/api/admin/config/models.default', { value: 'absent' })).status, 400);
|
||||
assert.equal((await request('/api/admin/config/models/test', { method: 'POST', role: 'user', body: { modelId: 'absent' } })).status, 403);
|
||||
const probe = await request('/api/admin/config/models/test', { method: 'POST', role: 'admin', body: { modelId: 'absent' } });
|
||||
assert.equal(probe.data.success, true); assert.equal(f.state.requests.at(-1).model, 'absent');
|
||||
f.state.settings['models.custom'] = '[]';
|
||||
assert.deepEqual((await request('/api/models')).data.models, []);
|
||||
assert.equal((await request('/api/models')).data.defaultModel, '');
|
||||
f.state.settingsError = true;
|
||||
const error = await request('/api/models'); assert.equal(error.status, 503); assert.deepEqual(error.data.models, []);
|
||||
});
|
||||
|
||||
test('feature routes deny before data/provider access; user status is nonsensitive and announcements/moderator mounts stay protected', async t => {
|
||||
const f = fixture(); const request = await f.serve(t, true);
|
||||
for (const name of ['read_aloud', 'nextcloud', 'memories']) f.state.settings['feature.' + name] = 'false';
|
||||
const endpoints = [
|
||||
['POST', '/api/text-to-speech'], ['POST', '/api/nextcloud/connect'], ['POST', '/api/nextcloud/export'], ['POST', '/api/nextcloud/disconnect'],
|
||||
['GET', '/api/memories'], ['GET', '/api/memories/context'], ['POST', '/api/memories'], ['PUT', '/api/memories/1'], ['DELETE', '/api/memories/1'],
|
||||
['GET', '/api/admin/learning/webdav-browse'], ['POST', '/api/admin/learning/ai-generate'], ['POST', '/api/user/webdav-path'], ['POST', '/api/admin/learning/webdav-path']
|
||||
];
|
||||
for (const [method, route] of endpoints) {
|
||||
const response = await request(route, { method, authenticated: true, body: method === 'GET' ? undefined : { webdavPath: '/synthetic.txt', text: 'Synthetic' } });
|
||||
assert.equal(response.status, 403, route);
|
||||
}
|
||||
assert.equal(f.state.writes.length, 0); assert.equal(f.state.requests.length, 0);
|
||||
assert.ok(!f.state.queries.some(q => q.includes('user_memories') || q.includes('nextcloud_token')));
|
||||
const clinical = await request('/api/clinical-assistant/status', { role: 'user' });
|
||||
assert.equal(clinical.status, 200); assert.equal(clinical.data.success, true, 'personal Nextcloud flag does not block clinical MCP status');
|
||||
const features = await request('/api/user/features', { role: 'user' });
|
||||
assert.deepEqual(features.data, { features: { read_aloud: false, nextcloud: false, memories: false } });
|
||||
assert.equal((await request('/api/user/features')).status, 401);
|
||||
f.state.settings['announcement.enabled'] = 'true'; f.state.settings['announcement.text'] = 'Synthetic announcement';
|
||||
const announcement = await request('/api/admin/config/announcement', { role: 'user' });
|
||||
assert.equal(announcement.status, 200); assert.equal(announcement.data.text, 'Synthetic announcement');
|
||||
assert.equal((await request('/api/admin/config/announcement')).status, 401);
|
||||
assert.equal((await request('/api/admin/config', { role: 'user' })).status, 403);
|
||||
assert.equal((await request('/api/admin/config', { role: 'moderator' })).status, 403);
|
||||
f.state.aiContent = '{"title":"Synthetic","body":"<p>Synthetic</p>","category_name":"General"}';
|
||||
const generated = await request('/api/admin/learning/ai-generate', { method: 'POST', role: 'moderator', body: { topic: 'Synthetic educational topic' } });
|
||||
assert.equal(generated.status, 200); assert.equal(generated.data.success, true);
|
||||
assert.equal((await request('/api/admin/learning/ai-generate', { method: 'POST', role: 'user', body: { topic: 'Synthetic' } })).status, 403);
|
||||
f.state.settings['feature.memories'] = 'true';
|
||||
assert.equal((await request('/api/memories', { role: 'user' })).data.memories.length, 1);
|
||||
assert.match((await request('/api/memories/context', { role: 'user' })).data.context, /MEMORY_SENTINEL/);
|
||||
f.state.settingsError = true;
|
||||
assert.equal((await request('/api/text-to-speech', { method: 'POST', authenticated: true, body: { text: 'Synthetic' } })).status, 503);
|
||||
});
|
||||
|
||||
test('every actual generation memory consumer omits disabled templates and includes enabled templates', async t => {
|
||||
const f = fixture(); const request = await f.serve(t, true);
|
||||
const endpoints = ['/api/generate-hpi-encounter', '/api/generate-hpi-dictation', '/api/generate-soap', '/api/generate-hospital-course', '/api/well-visit/note', '/api/sick-visit/note', '/api/ed-encounters/generate'];
|
||||
for (const enabled of [false, true]) {
|
||||
f.state.settings['feature.memories'] = String(enabled);
|
||||
for (const route of endpoints) {
|
||||
const start = f.state.requests.length;
|
||||
const response = await request(route, { method: 'POST', role: 'user', body: {
|
||||
transcript: 'Synthetic clinical transcript', chiefComplaint: 'Synthetic concern', patientAge: '5 years',
|
||||
notes: [{ date: '2026-01-01', content: 'Synthetic note' }], physicianMemories: 'MEMORY_SENTINEL'
|
||||
} });
|
||||
assert.equal(response.status, 200, route + ': ' + JSON.stringify(response.data));
|
||||
assert.ok(f.state.requests.length > start, route);
|
||||
assert.equal(JSON.stringify(f.state.requests.at(-1).messages).includes('MEMORY_SENTINEL'), enabled, route);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('TTS blank/incompatible user voice falls through to admin/env/family, and STT tests do not claim HTTP errors as success', async t => {
|
||||
const f = fixture({ LITELLM_TTS_VOICE: 'Rosie' }); const request = await f.serve(t, true);
|
||||
for (const [voice, admin, expected] of [[undefined, 'Luna', 'Luna'], ['', '', 'Rosie'], [' ', 'Luna', 'Luna'], ['F1', 'Luna', 'Luna'], ['Bella', 'Luna', 'Bella']]) {
|
||||
f.state.user.tts_voice = voice; f.state.settings['tts.voice'] = admin;
|
||||
const result = await request('/api/text-to-speech', { method: 'POST', role: 'user', body: { text: 'Synthetic' } });
|
||||
assert.equal(result.status, 200); assert.equal(JSON.parse(f.state.requests.at(-1).options.body).voice, expected);
|
||||
}
|
||||
f.state.settings['tts.model'] = 'local-supertonic-tts'; f.state.settings['tts.voice'] = ''; f.state.user.tts_voice = '';
|
||||
await request('/api/text-to-speech', { method: 'POST', role: 'user', body: { text: 'Synthetic' } });
|
||||
assert.equal(JSON.parse(f.state.requests.at(-1).options.body).voice, 'F1');
|
||||
const sttBody = { audioBase64: Buffer.from('synthetic').toString('base64') };
|
||||
f.state.httpError = 401;
|
||||
const bad = await request('/api/admin/config/stt/test', { method: 'POST', role: 'admin', body: sttBody });
|
||||
assert.equal(bad.data.success, false); assert.match(bad.data.error, /401/);
|
||||
f.state.httpError = null;
|
||||
const good = await request('/api/admin/config/stt/test', { method: 'POST', role: 'admin', body: sttBody });
|
||||
assert.equal(good.data.success, true); assert.equal(good.data.text, 'synthetic transcript');
|
||||
});
|
||||
|
||||
test('both actual directory file routes enforce parser bound and retain 1 MiB upload limit', async t => {
|
||||
const f = fixture(); const request = await f.serve(t, true);
|
||||
const transfer = require('../src/utils/extensionTransfer');
|
||||
const payload = transfer.exportPayload([{ location: 'Synthetic', name: 'Desk', number: '123' }]);
|
||||
const compressed = require('node:zlib').deflateRawSync(Buffer.from(JSON.stringify('x'.repeat(4 * 1024 * 1024 + 1))));
|
||||
const header = Buffer.alloc(31);
|
||||
header.writeUInt32LE(0x04034b50, 0); header.writeUInt16LE(20, 4); header.writeUInt16LE(8, 8);
|
||||
header.writeUInt32LE(compressed.length, 18); header.writeUInt32LE(1, 22); header.writeUInt16LE(1, 26); header[30] = 97;
|
||||
const bomb = Buffer.concat([header, compressed]);
|
||||
for (const route of ['/api/extensions/import-file/preview', '/api/extensions/import-file']) {
|
||||
for (const file of [Buffer.from(JSON.stringify(payload)), transfer.createJsonZip('directory.json', payload), bomb, Buffer.alloc(1024 * 1024 + 1, 32)]) {
|
||||
const form = new FormData(); form.append('file', new Blob([file]), 'directory.zip');
|
||||
const before = f.state.writes.length;
|
||||
const response = await request(route, { method: 'POST', authenticated: true, body: form });
|
||||
if (file === bomb || file.length > 1024 * 1024) {
|
||||
assert.ok(response.status >= 400); assert.equal(f.state.writes.length, before);
|
||||
} else { assert.equal(response.status, 200); assert.equal(response.data.success, true); }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('Azure configured deployment cannot bypass final roster through provider URL or streaming model override', async () => {
|
||||
const f = fixture({ AI_PROVIDER: 'azure', AZURE_OPENAI_ENDPOINT: 'https://azure.example', AZURE_OPENAI_API_KEY: 'synthetic', AZURE_DEPLOYMENT_NAME: 'blocked-deployment' });
|
||||
const ai = f.load('src/utils/ai.js');
|
||||
for (const call of [o => ai.callAI([], o), o => ai.callAIStream([], o, () => {})]) {
|
||||
await assert.rejects(call({ model: 'allowed' }), /Model not permitted/);
|
||||
assert.equal(f.state.requests.length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('enabled personal Nextcloud and memory CRUD remain usable through actual handlers with fake storage/WebDAV', async t => {
|
||||
const f = fixture(); const request = await f.serve(t, true); f.state.allowDav = true;
|
||||
f.state.user.nextcloud_url = 'https://cloud.example'; f.state.user.nextcloud_user = 'synthetic'; f.state.user.nextcloud_token = 'synthetic-token';
|
||||
for (const [route, body] of [
|
||||
['/api/nextcloud/connect', { nextcloudUrl: 'https://cloud.example', username: 'synthetic', appPassword: 'synthetic-token' }],
|
||||
['/api/nextcloud/export', { content: 'Synthetic document', type: 'note' }],
|
||||
['/api/user/webdav-path', { path: '/Synthetic' }],
|
||||
['/api/nextcloud/disconnect', {}]
|
||||
]) {
|
||||
const response = await request(route, { method: 'POST', role: 'user', body });
|
||||
assert.equal(response.status, 200, route); assert.equal(response.data.success, true);
|
||||
}
|
||||
const browse = await request('/api/admin/learning/webdav-browse', { role: 'moderator' });
|
||||
assert.equal(browse.status, 200); assert.equal(browse.data.success, true);
|
||||
assert.ok(f.state.requests.some(r => r.dav && r.dav.method === 'PUT'));
|
||||
for (const [method, route] of [['POST', '/api/memories'], ['PUT', '/api/memories/1'], ['DELETE', '/api/memories/1']]) {
|
||||
const response = await request(route, { method, role: 'user', body: { name: 'Synthetic template', category: 'physical_exam', content: 'Synthetic content' } });
|
||||
assert.equal(response.status, 200); assert.equal(response.data.success, true);
|
||||
}
|
||||
});
|
||||
|
||||
test('LearningAI saved WebDAV path requires enabled Nextcloud for admins and moderators, without writes on denial/outage', async t => {
|
||||
const f = fixture(); const request = await f.serve(t, true);
|
||||
for (const role of ['admin', 'moderator']) {
|
||||
for (const unavailable of [false, true]) {
|
||||
f.state.settings['feature.nextcloud'] = 'false';
|
||||
f.state.settingsError = unavailable;
|
||||
const before = f.state.writes.length;
|
||||
const response = await request('/api/admin/learning/webdav-path', { method: 'POST', role, body: { path: '/Synthetic' } });
|
||||
assert.equal(response.status, unavailable ? 503 : 403);
|
||||
assert.equal(f.state.writes.length, before);
|
||||
assert.equal(f.state.requests.length, 0);
|
||||
}
|
||||
f.state.settingsError = false;
|
||||
f.state.settings['feature.nextcloud'] = 'true';
|
||||
const before = f.state.writes.length;
|
||||
const response = await request('/api/admin/learning/webdav-path', { method: 'POST', role, body: { path: '/Synthetic' } });
|
||||
assert.equal(response.status, 200); assert.equal(response.data.success, true);
|
||||
assert.equal(f.state.writes.length, before + 1);
|
||||
assert.match(f.state.writes.at(-1).sql, /UPDATE users SET webdav_learning_path/);
|
||||
assert.deepEqual(Array.from(f.state.writes.at(-1).params), ['/Synthetic', 7]);
|
||||
}
|
||||
const before = f.state.writes.length;
|
||||
assert.equal((await request('/api/admin/learning/webdav-path', { method: 'POST', role: 'user', body: { path: '/Synthetic' } })).status, 403);
|
||||
assert.equal(f.state.writes.length, before);
|
||||
});
|
||||
|
||||
test('actual native admin script disables selected default, displays backend replacement, and saves it; discovery excludes disabled options', async t => {
|
||||
const { JSDOM } = require('jsdom');
|
||||
const { pathToFileURL } = require('node:url');
|
||||
const f = fixture({ AI_PROVIDER: 'openrouter' }); const request = await f.serve(t, true);
|
||||
const models = f.load('src/utils/models.js').getAvailableModels();
|
||||
const selected = models[0].id;
|
||||
f.state.settings['models.default'] = selected;
|
||||
f.state.settings['models.disabled'] = JSON.stringify([models[1].id, 'allowed']);
|
||||
const dom = new JSDOM(fs.readFileSync(path.join(root, 'public/components/admin.html'), 'utf8'), { url: 'https://app.example' });
|
||||
const originals = new Map(['window', 'document', 'fetch', 'getAuthHeaders', 'showToast'].map(key => [key, Object.getOwnPropertyDescriptor(global, key)]));
|
||||
t.after(() => { for (const [key, value] of originals) { if (value) Object.defineProperty(global, key, value); else delete global[key]; } dom.window.close(); });
|
||||
const toasts = []; const calls = [];
|
||||
Object.assign(global, { window: dom.window, document: dom.window.document, getAuthHeaders: () => ({}), showToast: (...args) => toasts.push(args) });
|
||||
global.fetch = async (url, options = {}) => {
|
||||
calls.push(url);
|
||||
if (url === '/api/admin/config/models' || url.startsWith('/api/admin/config/models/')) {
|
||||
if (url.includes('/discover?')) return { json: async () => ({ success: true, count: 1, models: [{ id: 'discovered', name: 'Discovered' }] }) };
|
||||
const response = await request(url, { method: options.method || 'GET', role: 'admin', body: options.body ? JSON.parse(options.body) : undefined });
|
||||
return { json: async () => response.data };
|
||||
}
|
||||
return { json: async () => ({}) }; // unrelated admin panels are outside this regression
|
||||
};
|
||||
await import(pathToFileURL(path.join(root, 'public/js/admin.js')).href);
|
||||
// Native Node imports share global.fetch; preserve loopback transport for the real handlers.
|
||||
const browserFetch = global.fetch;
|
||||
global.fetch = async (url, options) => String(url).startsWith('http://127.0.0.1:')
|
||||
? originals.get('fetch').value(url, options) : browserFetch(url, options);
|
||||
const waitFor = async predicate => {
|
||||
for (let i = 0; i < 100; i++) { if (predicate()) return; await new Promise(resolve => setTimeout(resolve, 5)); }
|
||||
assert.fail('Admin UI did not settle');
|
||||
};
|
||||
document.dispatchEvent(new dom.window.CustomEvent('tabChanged', { detail: { tab: 'admin' } }));
|
||||
const select = document.getElementById('admin-default-model');
|
||||
await waitFor(() => select.value === selected);
|
||||
const assertDisabledAbsent = () => {
|
||||
for (const id of JSON.parse(f.state.settings['models.disabled'])) assert.ok(!Array.from(select.options).some(o => o.value === id), id);
|
||||
};
|
||||
assertDisabledAbsent();
|
||||
const cb = document.querySelector('.admin-model-toggle[data-model-id="' + selected + '"]');
|
||||
cb.checked = false; cb.dispatchEvent(new dom.window.Event('change'));
|
||||
await waitFor(() => select.value && select.value !== selected);
|
||||
const advertised = await request('/api/models');
|
||||
assert.equal(select.value, advertised.data.defaultModel); assertDisabledAbsent();
|
||||
document.getElementById('btn-save-default-model').click();
|
||||
await waitFor(() => toasts.some(([message]) => message.startsWith('Default model set:')));
|
||||
assert.equal(f.state.settings['models.default'], select.value);
|
||||
assert.equal(calls.filter(url => url === '/api/admin/config/models').length, 2, 'successful toggle refreshes models');
|
||||
document.getElementById('btn-discover-models').click();
|
||||
await waitFor(() => document.querySelector('.admin-add-discovered'));
|
||||
document.querySelector('.admin-add-discovered').click();
|
||||
await waitFor(() => select.value === 'discovered');
|
||||
assertDisabledAbsent();
|
||||
assert.ok(Array.from(select.options).some(o => o.value === 'other'), 'enabled custom model retained');
|
||||
});
|
||||
49
test/policy-ui.test.js
Normal file
49
test/policy-ui.test.js
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { JSDOM } = require('jsdom');
|
||||
const read = file => fs.readFileSync(path.join(__dirname, '..', file), 'utf8');
|
||||
const tick = () => new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
test('actual app hides disabled/lazy-loaded feature UI, uses only advertised models, and never browser-falls-back on policy denial', async t => {
|
||||
const dom = new JSDOM('<!doctype html><html><head></head><body><div id="toast-container"></div><div class="card"><div id="synthetic-note">Synthetic text</div><button id="read" data-action="speak" data-target="synthetic-note">Read</button><button id="export" data-action="nc-export">Export</button></div><div id="lazy"></div><select class="tab-model-select"></select></body></html>', { runScripts: 'outside-only', url: 'https://app.example' });
|
||||
t.after(() => dom.window.close());
|
||||
const { window } = dom;
|
||||
const style = window.document.createElement('style'); style.textContent = read('public/css/styles.css'); window.document.head.appendChild(style);
|
||||
let features = { read_aloud: false, nextcloud: false, memories: false };
|
||||
let speechCalls = 0; let ttsCalls = 0;
|
||||
window.getAuthHeaders = () => ({});
|
||||
window.console = { log() {}, warn() {}, error() {} };
|
||||
window.speechSynthesis = { cancel() {}, speak() { speechCalls++; } };
|
||||
window.SpeechSynthesisUtterance = function() {};
|
||||
window.fetch = async url => {
|
||||
if (url === '/api/models') return { json: async () => ({ models: [{ id: 'allowed', name: 'Allowed' }], defaultModel: 'allowed' }) };
|
||||
if (url === '/api/user/features') return { ok: true, json: async () => ({ features }) };
|
||||
if (url === '/api/text-to-speech') { ttsCalls++; return { ok: false, status: 403 }; }
|
||||
if (url === '/api/memories/context') return { json: async () => ({ error: 'Feature disabled' }) };
|
||||
throw new Error('Unexpected browser request ' + url);
|
||||
};
|
||||
window.eval(read('public/js/accountBoundary.js'));
|
||||
assert.equal(window.AccountBoundary.enter({ id: 'synthetic-policy-user' }, true), true);
|
||||
window.eval(read('public/js/authFetch.js'));
|
||||
window.eval(read('public/js/app.js'));
|
||||
await tick();
|
||||
const select = window.document.querySelector('select');
|
||||
assert.equal(select.value, 'allowed'); assert.equal(select.options.length, 1);
|
||||
window._defaultModelId = 'removed'; window._buildModelOptions(select);
|
||||
assert.equal(select.options.length, 1); assert.equal(select.options[0].value, 'allowed');
|
||||
await window.loadUserFeatures();
|
||||
window.document.getElementById('lazy').innerHTML = read('public/components/settings.html') + read('public/components/cms.html');
|
||||
const hidden = selector => assert.equal(window.getComputedStyle(window.document.querySelector(selector)).display, 'none', selector);
|
||||
hidden('#read'); hidden('#export'); hidden('[data-feature="read_aloud"]'); hidden('[data-feature="memories"]'); hidden('[data-feature="nextcloud"]'); hidden('#lh-ai-tab-webdav'); hidden('#lh-ai-tp-webdav');
|
||||
window.speakText('synthetic-note'); assert.equal(ttsCalls, 0);
|
||||
features = { read_aloud: true, nextcloud: true, memories: true };
|
||||
await window.loadUserFeatures();
|
||||
assert.notEqual(window.getComputedStyle(window.document.getElementById('read')).display, 'none');
|
||||
assert.notEqual(window.getComputedStyle(window.document.querySelector('[data-feature="memories"]')).display, 'none');
|
||||
window.speakText('synthetic-note'); await tick(); await tick();
|
||||
assert.equal(ttsCalls, 1); assert.equal(speechCalls, 0, '403 must not bypass policy using browser speech');
|
||||
window.eval(read('public/js/memories.js'));
|
||||
assert.equal(await window.getUserMemoryContext(), '', 'disabled context never reuses cached memories');
|
||||
});
|
||||
318
test/prompt-administration.test.js
Normal file
318
test/prompt-administration.test.js
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const { createRequire } = require('node:module');
|
||||
const express = require('express');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const root = path.join(__dirname, '..');
|
||||
const read = file => fs.readFileSync(path.join(root, file), 'utf8');
|
||||
const quiet = { log() {}, warn() {}, error() {} };
|
||||
|
||||
function load(file, mocks = {}, env = {}) {
|
||||
const filename = path.join(root, file);
|
||||
const native = createRequire(filename);
|
||||
const module = { exports: {} };
|
||||
vm.runInNewContext(read(file), { module, exports: module.exports, console: quiet, Buffer, process: { env },
|
||||
require: name => Object.hasOwn(mocks, name) ? mocks[name] : native(name) }, { filename });
|
||||
return module.exports;
|
||||
}
|
||||
|
||||
// Fake only storage/transport: actual catalogue, revisions, SQL caller and routes run.
|
||||
function storage() {
|
||||
const state = { settings: new Map(), rows: [], log: [], locks: new Map(), nextId: 1, fail: '', missing: false };
|
||||
function select(sql, params, pending = []) {
|
||||
if (sql.includes('prompt_revisions') && state.missing) throw Object.assign(Error('synthetic missing table'), { code: '42P01' });
|
||||
if (sql.includes('unnest')) return params[0].map(key => ({ key, value: state.settings.get(key), revision: Math.max(0, ...state.rows.filter(row => row.prompt_key === key).map(row => row.id)) }));
|
||||
if (sql.includes('FROM prompt_revisions')) {
|
||||
let rows = state.rows.concat(pending).filter(row => row.prompt_key === params[0]);
|
||||
if (sql.includes('AND id = $2')) rows = rows.filter(row => row.id === params[1]);
|
||||
rows = rows.slice().sort((a, b) => b.id - a.id);
|
||||
if (sql.includes('LIMIT 1')) rows = rows.slice(0, 1);
|
||||
if (sql.includes('LIMIT $2')) rows = rows.slice(0, params[1]);
|
||||
return rows.map(row => {
|
||||
if (sql.startsWith('SELECT id FROM')) return { id: row.id };
|
||||
if (sql.startsWith('SELECT value FROM')) return { value: row.value };
|
||||
const { prompt_key, ...result } = row;
|
||||
if (!sql.includes(', value FROM')) delete result.value;
|
||||
return result;
|
||||
});
|
||||
}
|
||||
if (sql.startsWith('SELECT value FROM app_settings')) return state.settings.has(params[0]) ? [{ value: state.settings.get(params[0]) }] : [];
|
||||
if (sql.includes('FROM app_settings')) return [...state.settings].map(([key, value]) => ({ key, value }));
|
||||
throw Error('Unexpected read SQL: ' + sql);
|
||||
}
|
||||
const db = {
|
||||
all: async (sql, params) => select(sql, params),
|
||||
get: async (sql, params) => select(sql, params)[0] || null,
|
||||
getSetting: async key => state.settings.get(key) ?? null,
|
||||
async setSetting(key, value) { state.log.push(['unversioned', key]); state.settings.set(key, value); },
|
||||
pool: { async connect() {
|
||||
const pending = [];
|
||||
let change, unlock;
|
||||
return {
|
||||
async query(sql, params = []) {
|
||||
state.log.push([sql, ...params]);
|
||||
if (state.fail && sql.includes(state.fail)) throw Error('synthetic SQL failure, potentially secret text');
|
||||
if (sql === 'BEGIN') return { rows: [] };
|
||||
if (sql.includes('pg_advisory_xact_lock')) {
|
||||
const previous = state.locks.get(params[0]) || Promise.resolve();
|
||||
const waiting = new Promise(resolve => { unlock = resolve; });
|
||||
state.locks.set(params[0], previous.then(() => waiting));
|
||||
await previous;
|
||||
return { rows: [] };
|
||||
}
|
||||
if (sql === 'COMMIT') {
|
||||
if (state.beforeCommit) state.beforeCommit();
|
||||
state.rows.push(...pending);
|
||||
if (change) { if (change.remove) state.settings.delete(change.key); else state.settings.set(change.key, change.value); }
|
||||
unlock();
|
||||
if (state.afterCommit) await state.afterCommit(pending);
|
||||
return { rows: [] };
|
||||
}
|
||||
if (sql === 'ROLLBACK') { if (unlock) unlock(); return { rows: [] }; }
|
||||
if (sql.startsWith('INSERT INTO prompt_revisions')) {
|
||||
const [prompt_key, value, wasDefault, createdBy, restoredFrom] = params;
|
||||
const id = state.nextId++;
|
||||
pending.push({ id, prompt_key, value, wasDefault, createdBy, restoredFrom, createdAt: new Date().toISOString() });
|
||||
return { rows: [{ id }] };
|
||||
}
|
||||
if (sql.startsWith('INSERT INTO app_settings') || sql.startsWith('DELETE FROM app_settings')) {
|
||||
change = { key: params[0], value: params[1], remove: sql.startsWith('DELETE') };
|
||||
return { rows: [] };
|
||||
}
|
||||
return { rows: select(sql, params, pending) };
|
||||
},
|
||||
release() { state.log.push(['release']); }
|
||||
};
|
||||
} }
|
||||
};
|
||||
return { db, state };
|
||||
}
|
||||
|
||||
function services() {
|
||||
const prompts = load('src/utils/prompts.js');
|
||||
const clinical = { ...require('../src/utils/clinicalPrompts') };
|
||||
const catalog = load('src/utils/promptCatalog.js', { './prompts': prompts, './clinicalPrompts': clinical });
|
||||
const revisions = load('src/utils/promptRevisions.js', { './prompts': prompts, './promptCatalog': catalog });
|
||||
return { prompts, clinical, catalog, revisions, ...storage() };
|
||||
}
|
||||
|
||||
async function application(t, svc, env = {}) {
|
||||
const logs = [];
|
||||
const authDb = { async get(sql, params) {
|
||||
if (sql.includes('FROM users')) return { id: params[0], role: params[0] === 1 ? 'admin' : 'user' };
|
||||
return { id: 1, last_activity: new Date().toISOString() };
|
||||
} };
|
||||
const auth = load('src/middleware/auth.js', { '../db/database': authDb }, { JWT_SECRET: 'synthetic-only-secret' });
|
||||
const router = load('src/routes/adminConfig.js', {
|
||||
'../db/database': svc.db, '../middleware/auth': auth, '../utils/prompts': svc.prompts,
|
||||
'../utils/promptCatalog': svc.catalog, '../utils/promptRevisions': svc.revisions,
|
||||
'../utils/logger': { audit(actor, action, detail, req, meta) { logs.push({ actor, action, detail, meta }); } }, '../utils/errors': {},
|
||||
'../utils/ttsProvider': {}, '../utils/litellm': {}, '../utils/sttProvider': {}, '../utils/embeddings': {}
|
||||
}, env);
|
||||
const app = express();
|
||||
app.use(express.json()); app.use('/api/admin', router);
|
||||
const server = app.listen(0, '127.0.0.1');
|
||||
await new Promise(resolve => server.on('listening', resolve));
|
||||
t.after(() => server.close());
|
||||
return { logs, async request(method, route, body, user = 1) {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (user) headers.Authorization = 'Bearer ' + jwt.sign({ userId: user }, 'synthetic-only-secret');
|
||||
const response = await fetch('http://127.0.0.1:' + server.address().port + '/api/admin' + route, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
|
||||
return { status: response.status, body: await response.json() };
|
||||
} };
|
||||
}
|
||||
|
||||
test('finite catalogue, actual admin gates, revision API, stale edit, cross-key denial and no secret/helper writes', async t => {
|
||||
const svc = services(); const app = await application(t, svc);
|
||||
const routes = [
|
||||
['GET', '/config'], ['GET', '/config/prompts'], ['GET', '/config/prompts/hpiEncounter/history'],
|
||||
['GET', '/config/prompts/hpiEncounter/revisions/1'], ['PUT', '/config/prompt.hpiEncounter', { value: 'x' }],
|
||||
['POST', '/config/prompts/hpiEncounter/reset', {}], ['POST', '/config/prompts/hpiEncounter/restore', { revisionId: 1 }]
|
||||
];
|
||||
for (const [method, url, body] of routes) for (const user of [0, 2]) assert.equal((await app.request(method, url, body, user)).status, user ? 403 : 401);
|
||||
assert.equal(svc.state.log.length, 0);
|
||||
const list = (await app.request('GET', '/config/prompts')).body.prompts;
|
||||
assert.equal(list.length, 31); assert.equal(list.filter(p => p.family === 'scribe').length, 29);
|
||||
assert.equal(list.filter(p => p.family === 'clinical-text').length, 1); assert.equal(list.filter(p => p.family === 'clinical-image').length, 1);
|
||||
for (const prompt of list) { assert.equal(prompt.revision, 0); assert.equal(prompt.editable, true); assert.ok(prompt.purpose && prompt.usedBy.length && prompt.value); }
|
||||
for (const key of ['prompt.unknown', 'prompt.loadFromDb', 'prompt.updatePrompt', 'prompt.getAllPrompts', 'prompt.getDefaultPrompt', 'prompt.__proto__', 'prompt.smtp.pass']) {
|
||||
assert.equal((await app.request('PUT', '/config/' + key, { value: 'never store' })).status, 400);
|
||||
}
|
||||
for (const key of ['smtp.pass', 'memories', 'clinical_assistant.unknown', '__proto__']) {
|
||||
assert.equal((await app.request('POST', '/config/prompts/' + key + '/reset', {})).status, 404);
|
||||
assert.equal((await app.request('GET', '/config/prompts/' + key + '/history')).status, 404);
|
||||
}
|
||||
assert.equal(svc.state.log.length, 0);
|
||||
const value = '<script>inert editor text</script>\n preserve whitespace 😀';
|
||||
const saved = await app.request('PUT', '/config/prompt.hpiEncounter', { value, expectedRevision: 0 });
|
||||
assert.equal(saved.status, 200); assert.equal(saved.body.value, value); assert.equal(saved.body.revision, 2);
|
||||
assert.equal(svc.prompts.hpiEncounter, value);
|
||||
assert.equal(svc.state.rows[0].value, svc.prompts.getDefaultPrompt('hpiEncounter'));
|
||||
assert.equal(svc.state.rows[0].wasDefault, true); assert.equal(svc.state.rows[0].createdBy, null);
|
||||
assert.equal(svc.state.rows[1].createdBy, 1);
|
||||
assert.equal((await app.request('PUT', '/config/prompt.hpiEncounter', { value: 'stale', expectedRevision: 0 })).status, 409);
|
||||
assert.equal((await app.request('PUT', '/config/prompt.hpiEncounter', { value: 'bad', expectedRevision: '2' })).status, 400);
|
||||
assert.equal((await app.request('PUT', '/config/prompt.hpiEncounter', { value: {} })).status, 400);
|
||||
const history = await app.request('GET', '/config/prompts/hpiEncounter/history');
|
||||
assert.equal(history.body.revision, 2); assert.deepEqual(history.body.revisions.map(r => r.id), [2, 1]);
|
||||
assert.equal(history.body.revisions[0].value, undefined);
|
||||
const view = await app.request('GET', '/config/prompts/prompt.hpiEncounter/revisions/2');
|
||||
assert.equal(view.body.revision.value, value);
|
||||
assert.equal((await app.request('GET', '/config/prompts/prompt.refine/revisions/2')).status, 404);
|
||||
assert.equal((await app.request('POST', '/config/prompts/prompt.refine/restore', { revisionId: 2 })).status, 404);
|
||||
for (const revisionId of [[], [2], {}, true, 0, -1, '2.5', '2x']) {
|
||||
assert.equal((await app.request('POST', '/config/prompts/hpiEncounter/restore', { revisionId })).status, 400);
|
||||
}
|
||||
assert.equal((await app.request('GET', '/config/prompts/hpiEncounter/history?limit[]=20')).status, 400);
|
||||
assert.equal((await app.request('POST', '/config/prompts/prompt.hpiEncounter/reset', { expectedRevision: 2 })).body.revision, 3);
|
||||
assert.equal(svc.prompts.hpiEncounter, svc.prompts.getDefaultPrompt('hpiEncounter'));
|
||||
assert.equal(svc.state.settings.has('prompt.hpiEncounter'), false);
|
||||
const restored = await app.request('POST', '/config/prompts/hpiEncounter/restore', { revisionId: 2, expectedRevision: 3 });
|
||||
assert.equal(restored.body.value, value); assert.equal(restored.body.revision, 4);
|
||||
assert.equal(svc.state.rows.at(-1).restoredFrom, 2);
|
||||
assert.equal((await app.request('PUT', '/config/prompt.hpiEncounter', { value: 'legacy compatible' })).status, 200);
|
||||
assert.equal((await app.request('GET', '/config/prompts')).body.prompts[0].revision, 5);
|
||||
for (const key of ['clinical_assistant.system_behavior', 'clinical_assistant.image_behavior']) {
|
||||
const clinicalSave = await app.request('PUT', '/config/' + key, { value, expectedRevision: 0 });
|
||||
assert.equal(clinicalSave.status, 200);
|
||||
assert.ok(clinicalSave.body.revision > 5);
|
||||
assert.equal(svc.state.settings.get(key), value);
|
||||
const item = (await app.request('GET', '/config/prompts')).body.prompts.find(prompt => prompt.dbKey === key);
|
||||
assert.equal(item.value, value); assert.equal(item.revision, clinicalSave.body.revision);
|
||||
assert.equal((await app.request('GET', '/config/prompts/' + key + '/history')).body.revisions.length, 2);
|
||||
}
|
||||
assert.equal(svc.state.log.some(row => row[0] === 'unversioned'), false);
|
||||
assert.doesNotMatch(JSON.stringify(app.logs), /inert editor|legacy compatible/);
|
||||
});
|
||||
|
||||
test('first legacy baseline, reset and exact restore after shipped default changes', async () => {
|
||||
const { db, state, revisions, catalog, clinical } = services();
|
||||
const key = 'clinical_assistant.system_behavior';
|
||||
state.settings.set(key, 'Existing global override');
|
||||
await revisions.mutate(db, key, { action: 'reset', actor: 7, expectedRevision: 0 });
|
||||
assert.equal(state.rows[0].value, 'Existing global override'); assert.equal(state.rows[0].wasDefault, false);
|
||||
const original = state.rows[1].value;
|
||||
clinical.DEFAULT_BEHAVIOR = 'Synthetic later shipped default';
|
||||
await revisions.mutate(db, key, { action: 'reset' });
|
||||
assert.equal(state.rows.at(-1).value, clinical.DEFAULT_BEHAVIOR);
|
||||
const restored = await revisions.mutate(db, key, { action: 'restore', revisionId: 2 });
|
||||
assert.equal(restored.value, original);
|
||||
assert.equal(state.settings.get(key), original, 'Restored historical default is pinned as an override');
|
||||
assert.equal(catalog.effective(catalog.find(key), state.settings.get(key)).value, original);
|
||||
assert.equal(state.rows.at(-1).wasDefault, false);
|
||||
});
|
||||
|
||||
test('real transaction calls rollback every partial write, never publish before commit, and missing schema is safe', async t => {
|
||||
for (const fail of ['INSERT INTO prompt_revisions', 'INSERT INTO app_settings', 'COMMIT']) {
|
||||
const svc = services(); const original = svc.prompts.refine;
|
||||
svc.state.fail = fail;
|
||||
await assert.rejects(svc.revisions.mutate(svc.db, 'prompt.refine', { action: 'save', value: 'Not committed' }));
|
||||
assert.equal(svc.prompts.refine, original); assert.equal(svc.state.rows.length, 0); assert.equal(svc.state.settings.size, 0);
|
||||
assert.deepEqual(svc.state.log.slice(-2).map(row => row[0]), ['ROLLBACK', 'release']);
|
||||
}
|
||||
const svc = services(); const before = svc.prompts.refine;
|
||||
svc.state.beforeCommit = () => assert.equal(svc.prompts.refine, before);
|
||||
await svc.revisions.mutate(svc.db, 'refine', { action: 'save', value: 'Committed' });
|
||||
assert.equal(svc.prompts.refine, 'Committed');
|
||||
delete svc.state.beforeCommit;
|
||||
svc.state.fail = 'DELETE FROM app_settings';
|
||||
await assert.rejects(svc.revisions.mutate(svc.db, 'refine', { action: 'reset' }));
|
||||
assert.equal(svc.state.rows.length, 2); assert.equal(svc.prompts.refine, 'Committed');
|
||||
svc.state.fail = ''; svc.state.missing = true;
|
||||
const app = await application(t, svc);
|
||||
assert.equal((await app.request('PUT', '/config/prompt.refine', { value: 'No unversioned fallback' })).status, 503);
|
||||
assert.equal(svc.state.rows.length, 2); assert.equal(svc.state.settings.get('prompt.refine'), 'Committed');
|
||||
assert.equal(svc.state.log.some(row => row[0] === 'unversioned'), false);
|
||||
});
|
||||
|
||||
test('concurrent edits serialize one baseline, stale optimistic edits fail and delayed commit cannot regress memory', async () => {
|
||||
const svc = services();
|
||||
const edit = options => svc.revisions.mutate(svc.db, 'prompt.refine', { action: 'save', ...options });
|
||||
const attempts = await Promise.allSettled([edit({ value: 'A', expectedRevision: 0 }), edit({ value: 'B', expectedRevision: 0 })]);
|
||||
assert.equal(attempts.filter(item => item.status === 'fulfilled').length, 1);
|
||||
assert.equal(attempts.find(item => item.status === 'rejected').reason.statusCode, 409);
|
||||
assert.equal(svc.state.rows.length, 2);
|
||||
let release, committed;
|
||||
const wait = new Promise(resolve => { committed = resolve; });
|
||||
svc.state.afterCommit = async rows => { if (rows.at(-1).value === 'C') { committed(); await new Promise(resolve => { release = resolve; }); } };
|
||||
const c = edit({ value: 'C' }); await wait;
|
||||
await edit({ value: 'D' }); release(); await c;
|
||||
assert.equal(svc.state.rows.length, 4); assert.equal(svc.state.settings.get('prompt.refine'), 'D'); assert.equal(svc.prompts.refine, 'D');
|
||||
assert.equal(svc.state.log.filter(row => row[0].includes('pg_advisory_xact_lock')).length, 4);
|
||||
});
|
||||
|
||||
test('Scribe object stays shared across consumers; defaults/helpers resist overrides and a slow startup load', async () => {
|
||||
const svc = services(); const captured = [];
|
||||
const mocks = { '../utils/prompts': svc.prompts, '../utils/ai': { async callAI(messages) { captured.push(messages[0].content); return { content: 'synthetic' }; } },
|
||||
'../middleware/auth': { authMiddleware() {} }, '../utils/logger': { audit() {} } };
|
||||
const consumer1 = load('src/routes/refine.js', mocks);
|
||||
const consumer2 = load('src/routes/refine.js', mocks);
|
||||
const call = async router => {
|
||||
const route = router.stack.find(layer => layer.route.path === '/refine').route;
|
||||
await route.stack.at(-1).handle({ body: { currentDocument: 'Synthetic', instructions: 'Synthetic' }, user: { id: 1 } }, { json() {}, status() { return this; } });
|
||||
};
|
||||
await svc.revisions.mutate(svc.db, 'refine', { action: 'save', value: 'First edit' });
|
||||
await call(consumer1); await call(consumer2);
|
||||
assert.ok(captured.every(value => value.startsWith('First edit')));
|
||||
await svc.revisions.mutate(svc.db, 'refine', { action: 'reset' });
|
||||
await call(consumer1); await call(consumer2);
|
||||
assert.ok(captured.slice(2).every(value => value.startsWith(svc.prompts.getDefaultPrompt('refine'))));
|
||||
const originalHelper = svc.prompts.loadFromDb;
|
||||
for (const key of ['loadFromDb', 'getAllPrompts', 'getDefaultPrompt', '__proto__', 'missing']) assert.equal(svc.prompts.updatePrompt(key, 'poison'), false);
|
||||
let resume; const keys = [];
|
||||
const loading = svc.prompts.loadFromDb({ async getSetting(key) { keys.push(key); if (key === 'prompt.hpiEncounter') return new Promise(resolve => { resume = resolve; }); return null; } });
|
||||
svc.prompts.updatePrompt('hpiEncounter', 'Concurrent edit'); resume('Outdated DB value'); await loading;
|
||||
assert.equal(svc.prompts.hpiEncounter, 'Concurrent edit'); assert.equal(keys.length, 29); assert.equal(svc.prompts.loadFromDb, originalHelper);
|
||||
assert.notEqual(svc.prompts.getDefaultPrompt('hpiEncounter'), 'Concurrent edit');
|
||||
});
|
||||
|
||||
test('history is newest first, bounded 20/100, and admin budget uses ENV only without any legacy write', async t => {
|
||||
const svc = services();
|
||||
for (let i = 0; i < 105; i++) await svc.revisions.mutate(svc.db, 'clinical_assistant.image_behavior', { action: 'save', value: 'synthetic ' + i });
|
||||
assert.equal((await svc.revisions.history(svc.db, 'clinical_assistant.image_behavior')).revisions.length, 20);
|
||||
assert.equal((await svc.revisions.history(svc.db, 'clinical_assistant.image_behavior', 1000)).revisions.length, 100);
|
||||
assert.equal((await svc.revisions.history(svc.db, 'clinical_assistant.image_behavior', 1)).revision, 106);
|
||||
svc.state.settings.set('clinical_assistant.conversation_chars', '999999');
|
||||
const app = await application(t, svc, { CLINICAL_ASSISTANT_CONVERSATION_CHARS: '1000' });
|
||||
const config = await app.request('GET', '/config');
|
||||
assert.deepEqual(config.body.conversationBudget, { limit: 1000, unit: 'characters', measure: 'UTF-16 code units', env: 'CLINICAL_ASSISTANT_CONVERSATION_CHARS', source: 'environment' });
|
||||
assert.equal((await app.request('PUT', '/config/clinical_assistant.conversation_chars', { value: '5000' })).status, 400);
|
||||
assert.equal(svc.state.settings.get('clinical_assistant.conversation_chars'), '999999');
|
||||
const invalid = await application(t, svc, { CLINICAL_ASSISTANT_CONVERSATION_CHARS: 'not-a-number' });
|
||||
const unavailable = await invalid.request('GET', '/config');
|
||||
assert.equal(unavailable.status, 503); assert.equal(unavailable.body.conversationBudget, undefined);
|
||||
const defaults = await application(t, svc);
|
||||
assert.equal((await defaults.request('GET', '/config')).body.conversationBudget.limit, 120000);
|
||||
assert.equal((await defaults.request('GET', '/config')).body.conversationBudget.source, 'default');
|
||||
});
|
||||
|
||||
test('migration owns finite append-only schema and emits reversible SQL without connecting to a database', async () => {
|
||||
const migration = require('../migrations/1777700000000_add-prompt-revisions');
|
||||
const { Migration } = require('node-pg-migrate');
|
||||
async function dryRun(direction) {
|
||||
const sql = [];
|
||||
const engine = new Migration({ query() { throw Error('Dry run must not query a database'); } },
|
||||
path.join(root, 'migrations/1777700000000_add-prompt-revisions.js'), migration,
|
||||
{ dryRun: true, singleTransaction: true, migrationsTable: 'pgmigrations' }, undefined,
|
||||
{ ...quiet, info() {}, debug: text => sql.push(text) });
|
||||
await engine.apply(direction);
|
||||
return sql;
|
||||
}
|
||||
const up = await dryRun('up'); const down = await dryRun('down');
|
||||
const keys = [...up[0].matchAll(/'(prompt\.[^']+|clinical_assistant\.[^']+)'/g)].map(match => match[1]);
|
||||
assert.deepEqual(keys.sort(), Array.from(services().catalog.entries, entry => entry.dbKey).sort());
|
||||
assert.match(up[0], /BEFORE UPDATE OR DELETE/); assert.match(up[0], /FOREIGN KEY \(prompt_key, restored_from\)/);
|
||||
assert.match(down[0], /DROP TABLE prompt_revisions/);
|
||||
});
|
||||
|
||||
test('approved inherited Scribe and clinical default bytes remain unchanged', () => {
|
||||
const hash = value => require('node:crypto').createHash('sha256').update(value).digest('hex');
|
||||
const svc = services();
|
||||
// Hashes captured from the protected input patch, before overrides or helpers.
|
||||
assert.equal(hash(JSON.stringify(svc.prompts.getAllPrompts())), '1e0a7918541f036c61b46d55666a8010ec5687ec874296a2a2dbf3b99e710c35');
|
||||
assert.equal(hash(svc.clinical.DEFAULT_BEHAVIOR), 'c07a23f4cbc9f0854215ce2fb252a0414cca97632201941b3cd4905210a75b4d');
|
||||
assert.equal(hash(svc.clinical.DEFAULT_IMAGE_BEHAVIOR), 'c4ce8605cee9f55dce4fe755da8efc9bde43f8026a69ea724ec417bb265cd010');
|
||||
});
|
||||
62
test/release.test.js
Normal file
62
test/release.test.js
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
'use strict';
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
test('release commits manifest and atomic lock metadata together without dependency resolution or push', t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ped-release-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const run = (command, args) => execFileSync(command, args, {
|
||||
cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { ...process.env, npm_config_offline: 'true' },
|
||||
}).trim();
|
||||
const write = (file, data) => fs.writeFileSync(path.join(root, file), JSON.stringify(data, null, 2) + '\n');
|
||||
const read = file => JSON.parse(fs.readFileSync(path.join(root, file), 'utf8'));
|
||||
fs.mkdirSync(path.join(root, 'scripts'));
|
||||
fs.mkdirSync(path.join(root, 'mobile/android/app'), { recursive: true });
|
||||
fs.copyFileSync(path.join(__dirname, '../scripts/release.sh'), path.join(root, 'scripts/release.sh'));
|
||||
const manifest = { nested: { version: 'leave-alone' }, version: '1.0.0', dependencies: { example: '^1.0.0' } };
|
||||
const lock = { name: 'test', version: '0.9.0', lockfileVersion: 3, packages: {
|
||||
'': { name: 'test', version: '0.9.0', dependencies: manifest.dependencies },
|
||||
'node_modules/example': { version: '1.2.3', resolved: 'https://example.invalid/never-fetch.tgz', integrity: 'unchanged' },
|
||||
} };
|
||||
write('package.json', manifest);
|
||||
write('mobile/package.json', manifest);
|
||||
write('package-lock.json', lock);
|
||||
fs.writeFileSync(path.join(root, 'mobile/android/app/build.gradle'), 'versionCode 100000\nversionName "1.0.0"\n');
|
||||
run('git', ['init']);
|
||||
run('git', ['config', 'user.name', 'Release Test']);
|
||||
run('git', ['config', 'user.email', 'release@example.invalid']);
|
||||
run('git', ['config', 'commit.gpgsign', 'false']);
|
||||
run('git', ['config', 'tag.gpgsign', 'false']);
|
||||
run('git', ['add', '.']);
|
||||
run('git', ['commit', '-m', 'fixture']);
|
||||
// No remote exists: a successful run cannot have pushed anything.
|
||||
run('bash', ['scripts/release.sh', '2.3.4']);
|
||||
const expected = { ...manifest, version: '2.3.4' };
|
||||
assert.deepEqual(read('package.json'), expected);
|
||||
assert.deepEqual(read('mobile/package.json'), expected);
|
||||
lock.version = lock.packages[''].version = '2.3.4';
|
||||
assert.deepEqual(read('package-lock.json'), lock);
|
||||
assert.match(fs.readFileSync(path.join(root, 'mobile/android/app/build.gradle'), 'utf8'), /versionCode 203004\nversionName "2.3.4"/);
|
||||
assert.equal(run('git', ['status', '--porcelain']), '');
|
||||
assert.deepEqual(run('git', ['show', '--format=', '--name-only', 'HEAD']).split('\n').sort(),
|
||||
['mobile/android/app/build.gradle', 'mobile/package.json', 'package-lock.json', 'package.json']);
|
||||
assert.equal(run('git', ['rev-parse', 'v2.3.4^{commit}']), run('git', ['rev-parse', 'HEAD']));
|
||||
assert.equal(fs.existsSync(path.join(root, 'node_modules')), false);
|
||||
|
||||
// An invalid lock fails before either manifest changes or a release commit is made.
|
||||
fs.writeFileSync(path.join(root, 'package-lock.json'), '{invalid');
|
||||
run('git', ['add', 'package-lock.json']);
|
||||
run('git', ['commit', '-m', 'invalid lock fixture']);
|
||||
const head = run('git', ['rev-parse', 'HEAD']);
|
||||
assert.throws(() => run('bash', ['scripts/release.sh', '2.3.5']));
|
||||
assert.deepEqual(read('package.json'), expected);
|
||||
assert.deepEqual(read('mobile/package.json'), expected);
|
||||
assert.equal(run('git', ['status', '--porcelain']), '');
|
||||
assert.equal(run('git', ['rev-parse', 'HEAD']), head);
|
||||
});
|
||||
|
|
@ -212,6 +212,8 @@ function edBrowser(server, storage = {}) {
|
|||
if (opts.method !== 'POST') return Promise.resolve({ json: async () => ({ encounters: server.rows }) });
|
||||
return new Promise((resolve, reject) => pending.push({ url, body: JSON.parse(opts.body), resolve, reject }));
|
||||
};
|
||||
w.eval(fs.readFileSync(path.join(root, 'public/js/accountBoundary.js'), 'utf8'));
|
||||
w.AccountBoundary.enter({ id: 7 }, true);
|
||||
w.eval(fs.readFileSync(path.join(root, 'public/js/encounters.js'), 'utf8'));
|
||||
// ED is a module in production: isolate its lexical state, keeping real event handlers.
|
||||
w.eval('(function() {\n' + fs.readFileSync(path.join(root, 'public/js/ed-encounters.js'), 'utf8') + '\n})();');
|
||||
|
|
@ -235,7 +237,7 @@ function edBrowser(server, storage = {}) {
|
|||
};
|
||||
}
|
||||
|
||||
const draftStorage = { local: { ped_ed_draft_v1: JSON.stringify({
|
||||
const draftStorage = { local: { 'ped_ed_draft_v1:owner:7': JSON.stringify({
|
||||
label: 'Patient A', state: { stage: 1, stages: [{ transcript: 'First transcript', note: 'First stage note' }], finalized: false }
|
||||
}) } };
|
||||
|
||||
|
|
@ -291,12 +293,12 @@ test('delayed old ED save cannot assign its ID or unlock a new patient save', as
|
|||
assert.equal(browser.pending.length, 2, 'New identity can save while old request is pending');
|
||||
await browser.complete(0);
|
||||
assert.equal(browser.w._savedEncId_ed, null);
|
||||
assert.equal(browser.w.sessionStorage.getItem('_savedEncId_ed'), null);
|
||||
assert.equal(browser.w.sessionStorage.getItem('_savedEncId_ed:owner:7'), null);
|
||||
browser.save('New patient');
|
||||
assert.equal(browser.pending.length, 1, 'Old callback must not release new save lock');
|
||||
await browser.complete();
|
||||
assert.equal(browser.w._savedEncId_ed, 2);
|
||||
assert.equal(browser.w.sessionStorage.getItem('_savedEncId_ed'), '2');
|
||||
assert.equal(browser.w.sessionStorage.getItem('_savedEncId_ed:owner:7'), '2');
|
||||
} finally { browser.close(); }
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue