SOURCE / PINNED RELEASE
Made of little things.
Body Journal
- Release
- e8049f8da4e2…
- Author-recorded commit
- 61c04bf0e8aa…
- License
- LICENSE
- Author’s source reference
- nostr://npub182jczunncwe0jn6frpqwq3e0qjws7yqqnc3auccqv9nte2dnd63scjm4rf/wss%3A%2F%2Fgit.napplet.soy%2F/n-6c4554844c7
Archive hash verified: 4369d778da046a8b…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
import { storage, type CvmServerRef } from "@napplet/sdk";
import { IdentityUi } from "./identity-ui.js";
import QRCode from "qrcode";
import { escape as esc } from "./case.js";
import { reportMarkup } from "./report-view.js";
import { REPORT_INSTRUCTIONS } from "./ai-contract.js";
import type { CompletedReport } from "./session-record.js";
import type { CasePhoto } from "./photo-record.js";
import type { VisualEvidence } from "./vision-contract.js";
import { runtimeHasDomain } from "./domain-availability.js";
import {
llm,
shellReport,
validateAnalysis,
PaidClient,
ORDER_KEY,
rememberOrder,
type Model,
type PaidOrder,
} from "./inference-adapter.js";
type Ui = {
modal: (html: string) => void;
toast: (message: string) => void;
changed: () => void;
reportChanged: () => void;
caseText: () => string;
photos: () => CasePhoto[];
photoSelection: (id: string, included: boolean) => void;
evidence: () => VisualEvidence;
};
const orderLabel = (state: string) =>
({
"awaiting-payment": "Waiting for payment",
generating: "Payment received. Preparing your report…",
complete: "Report ready",
creating: "Saving the report order",
prepared: "Ready for shell checkout",
staging: "Preparing images",
attention: "This order needs operator recovery",
})[state] ?? state;
const DEFAULT_SERVICE =
"02f43ef61bd1be1a1887b6f638db169ce5461f72a18dcf11afece1588539f8e5";
const SERVICE_KEY = "body-journal:paid-service:v1";
export class AiWorkspace {
private model?: Model;
private mode: "shell" | "paid" = "shell";
private ref: CvmServerRef = {
pubkey: import.meta.env.VITE_PAID_CVM_PUBKEY || DEFAULT_SERVICE,
relays: (
import.meta.env.VITE_PAID_CVM_RELAYS || "wss://relay.contextvm.org"
).split(","),
};
private paid = new PaidClient();
private identity: IdentityUi;
private identitySupported = false;
private order?: PaidOrder;
private completed?: CompletedReport;
private pending = false;
private checkingPayment = false;
private reviewing = false;
private checkingOut = false;
private paymentCheckMessage = "";
private disposed = false;
private error = "";
private progress = "";
private abort?: AbortController;
private reviewed?: {
caseText: string;
evidence: VisualEvidence;
price?: number;
};
constructor(private ui: Ui) {
this.identity = new IdentityUi({ modal: ui.modal, toast: ui.toast, ref: () => this.ref, recover: async raw => {
if (this.pending || (this.order && !["complete", "staging"].includes(this.order.status || "") && this.order.receipt !== raw.receipt)) throw new Error("Recover your current pending order first.");
const order = this.parseOrder(raw); await rememberOrder(order); this.order = order; await this.recover();
} });
document.addEventListener("click", this.onClick);
document.addEventListener("change", this.onChange);
}
get hasReport() {
return !!this.completed;
}
get savedReport() {
return this.completed;
}
restoreReport(report: CompletedReport | undefined) {
this.clearReport();
this.completed = report;
this.ui.changed();
}
async init() {
await this.identity.init();
if (!runtimeHasDomain("storage")) return;
try {
const ref = await storage.getItem(SERVICE_KEY),
order = await storage.getItem(ORDER_KEY);
if (ref) this.ref = this.parseRef(JSON.parse(ref));
if (order) {
const parsed = JSON.parse(order);
this.parseRef(parsed.ref);
if (
!/^[a-f0-9]{64}$/.test(parsed.receipt) ||
typeof parsed.caseText !== "string"
)
throw new Error("Invalid receipt");
this.order = this.parseOrder(parsed);
this.mode = "paid";
}
this.ui.changed();
} catch {
this.ui.toast(
"Saved service or order could not be loaded. Keep any exported recovery receipt.",
);
}
}
private parseRef(ref: CvmServerRef) {
if (
!/^[a-f0-9]{64}$/.test(ref.pubkey) ||
!Array.isArray(ref.relays) ||
!ref.relays.length
)
throw new Error("Enter the paid service’s public key and relays.");
for (const r of ref.relays) {
const u = new URL(r);
if (
u.protocol !== "wss:" ||
u.username ||
u.password ||
u.search ||
u.hash
)
throw new Error("Use WSS relay URLs.");
}
return ref;
}
private parseOrder(raw: unknown): PaidOrder {
const o = raw as PaidOrder;
if (
!o ||
!/^[a-f0-9]{64}$/.test(o.receipt) ||
typeof o.caseText !== "string" ||
o.caseText.length > 200000 ||
!Number.isSafeInteger(o.expectedMsat) ||
o.expectedMsat! < 1 ||
!Number.isSafeInteger(o.imageCount) ||
o.imageCount < 0 ||
!Number.isSafeInteger(o.photoCount) ||
o.photoCount < 0
)
throw new Error("Invalid recovery receipt.");
this.parseRef(o.ref);
JSON.parse(o.caseText);
return o;
}
open() {
const shell = this.mode === "shell";
this.ui.modal(`
<span class="section-caption">YOUR AI, YOUR CHOICE</span>
<h2>How would you like a report?</h2>
<div class="dialog-actions">
<button class="${shell ? "primary" : "outline"}-button" data-ai="shell" aria-pressed="${shell}">Your shell connection</button>
<button class="${shell ? "outline" : "primary"}-button" data-ai="paid" aria-pressed="${!shell}">Paid report service</button>
</div>
${shell ? `
<p>Use your own AI-provider account. Your shell keeps its API key outside this app; the provider can associate the reviewed medical data with that account.</p>
<p>${this.model ? esc(this.model.name + " · " + this.model.model) : "Choose a saved connection in your shell."}</p>
<div class="dialog-actions">
<button class="${this.model ? "outline" : "primary"}-button" data-ai="select" ${llm() ? "" : "disabled"}>${this.model ? "Change model" : "Choose a model"}</button>
${this.model ? '<button class="primary-button" data-ai="request">Review report request</button>' : ""}
</div>
<p id="aiOptionsStatus" role="status" aria-live="polite"></p>
${llm() ? "" : "<p>This shell does not yet offer the experimental NAP-LLM interface. The paid CVM alternative works independently.</p>"}
` : `
<h3>${this.ref.pubkey === DEFAULT_SERVICE ? "AI Doc report service" : "Your custom report service"}</h3>
<p>${this.ref.pubkey === DEFAULT_SERVICE ? "Pay per report with DeepSeek." : "Pay per report with your selected service."} No personal API key required.</p>
<button class="primary-button" data-ai="request">Review report & price</button>
<p id="aiOptionsStatus" role="status" aria-live="polite"></p>
<p class="field-hint">View the price and data handling before sharing medical data or paying.</p>
<p>This keeps the request off your personal AI-provider account.</p><button class="outline-button" data-ai="identity">Your report identity & history</button>
<details><summary>Privacy and data handling</summary><p>The service and its provider still receive the medical data you approve. The service links your report with its payment for settlement and recovery. This does not guarantee anonymity.</p></details>
<details><summary>Change service (advanced)</summary>
<label class="field">Service public key<input id="paidKey" value="${esc(this.ref.pubkey)}" spellcheck="false" /></label>
<label class="field">Relay URLs<input id="paidRelays" value="${esc(this.ref.relays?.join(", ") || "")}" /></label>
<button class="outline-button" data-ai="save-service">Save service</button>
</details>
`}
<details><summary>Restore a paid report receipt</summary><p>A receipt grants access to its report. Only restore one from your own order.</p><textarea id="importReceipt" placeholder="Paste your saved recovery receipt"></textarea><button class="outline-button" data-ai="import-receipt">Restore receipt</button></details>
<details><summary>Previous connection keys</summary><p>Earlier versions stored optional API keys inside this app. They are no longer read or sent. Configure your connection in the shell, then remove the old saved copy here.</p><button class="outline-button" data-ai="remove-old">Remove legacy saved keys</button></details>
`);
}
analysisMarkup() {
return `<section class="analysis-card"><div class="analysis-symbol">✳</div><div><h3>A first perspective.</h3><p>${this.mode === "shell" ? (this.model ? esc(this.model.name + " · " + this.model.model) : "Use your shell’s AI connection") : "One paid report through your selected CVM service"}</p><p class="field-hint">Preliminary AI information, not a diagnosis. Review all details and selected photos before sending.</p>${this.pending ? `<p role="status">${esc(this.progress)}</p><button class="outline-button" data-ai="stop">Stop waiting</button>` : `<button class="primary-button" data-ai="request">Review AI request ↗</button><button class="text-button" data-ai="open">AI options</button>`}${this.order ? `<button class="outline-button" data-ai="recover">${this.order.status === "complete" ? "Open saved paid report" : "Resume paid report"}</button>` : ""}${this.error ? `<p class="inline-error" role="alert">${esc(this.error)}</p>` : ""}</div></section>${this.completed ? `<section class="ai-report" data-napplet-select="text"><div class="report-heading"><span class="section-caption">AI-GENERATED · PRELIMINARY</span><button data-ai="clear" class="text-button">Clear report</button></div>${this.completed.caseText !== this.ui.caseText() ? '<p class="stale-report">Your case changed. This report describes the earlier reviewed input.</p>' : ""}<p class="field-hint">${esc(this.completed.provider)} · ${esc(this.completed.model)} · ${esc(this.completed.at)}</p>${reportMarkup(this.completed.report, this.completed.caseText)}</section>` : ""}`;
}
async reviewRequest() {
if (this.pending || this.reviewing) return;
const mode = this.mode;
const optionsStatus = document.querySelector<HTMLElement>("dialog[open] #aiOptionsStatus");
const optionsDialog = optionsStatus?.closest<HTMLDialogElement>("dialog");
const reviewButton = optionsDialog?.querySelector<HTMLButtonElement>("[data-ai=request]");
try {
if (!JSON.parse(this.ui.caseText()).concerns.length)
throw new Error("Add at least one concern first.");
let price: number | undefined,
serviceInfo = "";
if (this.mode === "shell" && !this.model) {
this.open();
return;
}
if (this.mode === "paid") {
this.reviewing = true;
if (optionsStatus) optionsStatus.textContent = "Loading service price and data handling… Approve the connection in your shell if asked.";
if (reviewButton) reviewButton.disabled = true;
const info = await this.paid.info(this.parseRef(this.ref));
if (this.disposed || this.mode !== mode ||
(optionsStatus && (!optionsStatus.isConnected || !optionsDialog?.open))) return;
if (
info.version !== 2 ||
info.paymentProtocol !== "cep8" ||
!Number.isSafeInteger(info.priceSats) ||
Number(info.priceSats) < 1 ||
!Number.isSafeInteger(Number(info.priceSats) * 1000)
)
throw new Error("Unsupported service price or contract.");
if (info.checkoutEnabled === false)
throw new Error(
"This service is not accepting paid reports right now. Your case has not been sent.",
);
if (info.encryptionRequired !== true)
throw new Error(
"This service does not confirm encrypted checkout. Your case has not been sent.",
);
this.identitySupported = info.reportIdentity === true;
if (this.identitySupported && !this.identity.ready) {
this.identity.open(() => { void this.reviewRequest(); }); return;
}
price = Number(info.priceSats);
serviceInfo = `<p>${price} sats · ${esc(String(info.provider))} / ${esc(String(info.model))}</p><p>${esc(String(info.retention))}</p><p>${esc(String(info.refundPolicy))}</p>`;
}
// Connecting can show a shell permission dialog and flush pending view
// state. Snapshot the case after that wait so the review is current.
const caseText = this.ui.caseText();
const evidence = this.ui.evidence();
this.reviewed = { caseText, evidence, price };
this.ui.modal(
`<span class="section-caption">REVIEW BEFORE SENDING</span><h2>Choose what you share.</h2><p>${this.mode === "shell" ? esc(this.model!.route) : "Your case → encrypted NAP-CVM → paid report service → its inference provider"}</p>${serviceInfo}<p>Includes your entered profile, background, concern descriptions, locations and selected photos. Body-map illustrations are not medical imaging. Text-only sending excludes all image pixels.</p>${requestPhotos(this.ui.photos())}${evidenceMarkup(evidence)}<details><summary>Exact case text</summary><pre>${esc(caseText)}</pre></details>${this.mode === "shell" ? `<details><summary>Instructions sent to the model</summary><pre>${esc(REPORT_INSTRUCTIONS)}</pre></details>` : "<p>The service owns its report prompt. Preparing an order shares this reviewed input. Your shell then requests approval for the service’s CEP-8 invoice; no automatic payment.</p>"}<p id="aiRequestError" role="alert"></p><div class="dialog-actions">${this.mode === "paid" || this.model?.images ? `<button class="primary-button" data-ai="send">${this.mode === "paid" ? "Prepare report and open checkout" : "Send reviewed text and images"}</button>` : "<p>The selected connection does not offer image input. Choose another model or explicitly send text only.</p>"}<button class="outline-button" data-ai="send-text">${this.mode === "paid" ? "Text-only report and checkout" : "Send text only"}</button></div>`,
);
} catch (e) {
if (optionsStatus?.isConnected) optionsStatus.textContent = (e as Error).message;
this.ui.toast((e as Error).message);
} finally {
this.reviewing = false;
if (reviewButton?.isConnected) reviewButton.disabled = false;
}
}
private async send(textOnly: boolean) {
const request = this.reviewed;
if (!request || this.pending) return;
if (request.caseText !== this.ui.caseText())
throw new Error("Your case changed; review it again.");
const images = textOnly ? [] : request.evidence.images;
this.reviewed = undefined;
this.pending = true;
this.error = "";
this.abort = new AbortController();
this.progress = "Waiting for your shell or service…";
document.querySelector<HTMLDialogElement>("#modal")?.close();
this.ui.changed();
try {
if (this.mode === "shell") {
const result = await shellReport(
this.model!,
request.caseText,
images,
this.abort.signal,
);
if (this.abort.signal.aborted) return;
this.completed = {
...result,
provider: this.model!.name,
caseText: request.caseText,
imageCount: images.length,
photoCount: images.filter((i) => i.kind === "patient-photo").length,
at: new Date().toISOString(),
};
this.ui.reportChanged();
} else {
if (this.order && this.order.status !== "complete")
throw new Error(
"Resume your existing paid order before creating another.",
);
if (!runtimeHasDomain("storage"))
throw new Error(
"Paid reports need shell storage to retain a recovery receipt.",
);
const receipt = Array.from(
crypto.getRandomValues(new Uint8Array(32)),
(n) => n.toString(16).padStart(2, "0"),
).join("");
this.order = {
receipt,
ref: structuredClone(this.ref),
caseText: request.caseText,
imageCount: images.length,
photoCount: images.filter((i) => i.kind === "patient-photo").length,
expectedMsat: request.price! * 1000,
};
const quote = await this.paid.prepare(
this.order,
images,
this.abort.signal,
);
if (quote.amountMsat !== request.price! * 1000)
throw new Error(
"The service price changed. Do not pay; review the order.",
);
if (this.identitySupported) await this.identity.attach(this.order);
this.abort.signal.throwIfAborted();
await this.runPaid();
}
} catch (e) {
this.error = (e as Error).message;
} finally {
this.pending = false;
this.ui.changed();
}
}
private confirmationMarkup() {
const order = this.order;
if (order?.paymentReceived)
return "<p>Payment confirmed. Continue to prepare your report; you will not be charged again.</p>";
if (
order?.status !== "awaiting-payment" ||
order.paymentConfirmation !== "manual"
)
return "";
return `<p>This service checks your payment only when you ask. Report progress does not contact the receiving wallet.</p><button class="outline-button" data-ai="confirm-payment" ${this.checkingPayment ? "disabled" : ""}>${this.checkingPayment ? "Checking payment once…" : "I’ve paid — check payment"}</button><p role="status">${esc(this.paymentCheckMessage)}</p>`;
}
private updateCheckout() {
const order = this.order;
if (!order) return;
const status = document.querySelector("#paidStatus");
if (status) status.textContent = orderLabel(order.status || "creating");
if (
order.status !== "awaiting-payment" ||
(order.expiresAt && order.expiresAt <= Date.now() / 1000)
)
document.querySelector("#paidInvoice")?.remove();
const confirmation = document.querySelector("#paidConfirmation");
const markup = this.confirmationMarkup();
if (confirmation && confirmation.innerHTML !== markup)
confirmation.innerHTML = markup;
}
private async confirmPayment() {
if (!this.order || this.checkingPayment) return;
const order = this.order;
this.checkingPayment = true;
const error = document.querySelector("#paidError");
if (error) error.textContent = "";
this.paymentCheckMessage = "Checking payment once. No automatic retries.";
this.updateCheckout();
try {
const state = await this.paid.confirm(order);
this.paymentCheckMessage =
state.status === "awaiting-payment" && state.paymentReceived !== true
? "Payment is not confirmed yet. If your wallet still shows pending, wait for it to finish before checking again. Do not pay twice."
: "Payment confirmed. Continuing your report…";
} catch (error) {
this.paymentCheckMessage =
"The check did not complete. No automatic retry was scheduled. Keep your receipt and do not pay again.";
throw error;
} finally {
this.checkingPayment = false;
this.updateCheckout();
}
if (
order.paymentProtocol === "cep8" &&
order.paymentReceived &&
!this.pending
)
await this.runPaid();
else if (!this.pending) await this.recover();
}
private async runPaid() {
const order = this.order;
if (
!order ||
order.paymentProtocol !== "cep8" ||
this.checkingPayment ||
this.checkingOut
)
return;
this.checkingOut = true;
this.pending = true;
if (!this.abort || this.abort.signal.aborted)
this.abort = new AbortController();
this.progress = "Review the service invoice in your shell…";
document.querySelector<HTMLDialogElement>("#modal")?.close();
this.ui.changed();
let message = "";
try {
await this.paid.run(order);
} catch (error) {
message = (error as Error).message;
// Read-only recovery: never repeat the native gate or check the wallet.
try {
await this.paid.status(order);
} catch {
/* Keep the original actionable error. */
}
}
try {
if (this.disposed || this.abort.signal.aborted) return;
await this.checkout();
const target = document.querySelector("#paidError");
if (target) target.textContent = message;
if (!message) await this.watch();
} finally {
this.checkingOut = false;
this.pending = false;
this.ui.changed();
}
}
private async checkout() {
const order = this.order;
if (!order) return;
const invoice =
order.paymentProtocol !== "cep8" &&
order.status === "awaiting-payment" &&
(!order.expiresAt || order.expiresAt > Date.now() / 1000)
? order.invoice
: undefined;
const qr = invoice
? await QRCode.toDataURL(invoice, { width: 280, margin: 2 })
: "";
if (this.disposed) return;
this.ui.modal(
`<div data-paid-checkout><span class="section-caption">YOUR REPORT ORDER</span><h2>${invoice ? "Pay for your report" : "Report status"}</h2><p id="paidStatus" role="status">${esc(orderLabel(order.status || "creating"))}</p><p>${(order.amountMsat || 0) / 1000} sats · Order ${esc(order.orderId ? order.orderId.slice(0, 10) + "…" + order.orderId.slice(-6) : "pending")}</p>${invoice ? `<div id="paidInvoice"><img src="${qr}" alt="Lightning invoice QR" style="max-width:100%;width:280px"/><label class="field">Invoice string<textarea readonly data-napplet-select="text">${esc(invoice)}</textarea></label><p>Or scan/copy this invoice into another wallet. ${order.paymentConfirmation === "manual" ? "After paying, use the check payment button below." : "Settlement is checked automatically."} Do not pay it twice.</p></div>` : ""}${order.paymentProtocol === "cep8" && ["prepared", "awaiting-payment"].includes(order.status || "") ? '<p>Your shell handles the invoice, QR and wallet approval. If you already paid, check payment below before reopening checkout.</p><button class="primary-button" data-ai="pay">' + (order.paymentReceived ? "Continue paid report" : "Open shell checkout") + "</button>" : ""}<div id="paidConfirmation">${this.confirmationMarkup()}</div><p id="paidError" role="alert"></p><button class="outline-button" data-ai="attach-identity">Attach to my report identity</button><details><summary>Recovery receipt — keep private</summary><p>Keep this if you clear browser data. It grants access to this report.</p><textarea readonly data-napplet-select="text">${esc(JSON.stringify(order))}</textarea></details>${order.status === "staging" ? '<p>Image staging stopped before any invoice request was sent.</p><button class="outline-button" data-ai="discard-staging">Discard unsent order</button>' : '<button class="outline-button" data-ai="recover">Check / resume report</button>'}</div>`,
);
}
private async watch() {
const order = this.order!;
this.abort ??= new AbortController();
const result = await this.paid.poll(order, this.abort.signal, (state) => {
this.progress = orderLabel(String(state.status));
this.updateCheckout();
this.ui.changed();
});
if (!result) return;
this.completed = {
report: validateAnalysis(result.report, order.caseText, order.photoCount),
model: String(result.model),
provider: "Paid CVM service",
caseText: order.caseText,
imageCount: order.imageCount,
photoCount: order.photoCount,
at: new Date().toISOString(),
};
if (document.querySelector("[data-paid-checkout]"))
document.querySelector<HTMLDialogElement>("#modal")?.close();
this.ui.reportChanged();
this.ui.toast("Your paid report is ready.");
}
private async recover() {
if (!this.order || this.pending) return;
this.pending = true;
this.abort = new AbortController();
this.error = "";
this.ui.changed();
try {
if (this.order.status !== "staging") await this.paid.status(this.order);
this.abort.signal.throwIfAborted();
await this.checkout();
if (this.order.status !== "staging") await this.watch();
} catch (e) {
this.error = (e as Error).message;
} finally {
this.pending = false;
this.ui.changed();
}
}
private onChange = (event: Event) => {
const input = event.target as HTMLInputElement;
if (input.dataset.requestPhoto) {
this.ui.photoSelection(input.dataset.requestPhoto, input.checked);
void this.reviewRequest();
}
};
private onClick = (event: Event) => {
const button = (event.target as Element).closest<HTMLElement>("[data-ai]");
if (!button) return;
void this.action(button.dataset.ai!).catch((e) => {
this.ui.toast((e as Error).message);
const el = document.querySelector("#paidError, #aiRequestError, #aiOptionsStatus");
if (el) el.textContent = (e as Error).message;
});
};
private async action(action: string) {
if (action === "open") this.open();
if (action === "identity") this.identity.open();
if (action === "attach-identity") {
if (!this.order) return;
if (!this.identity.ready) { this.identity.open(); return; }
await this.identity.attach(this.order); this.ui.toast("Report attached to your private identity.");
}
if (action === "import-receipt") {
if (this.pending || (this.order && this.order.status !== "complete"))
throw new Error("Finish or recover the current order first.");
const order = this.parseOrder(
JSON.parse(
(document.querySelector("#importReceipt") as HTMLTextAreaElement)
.value,
),
);
await rememberOrder(order);
this.order = order;
await this.recover();
}
if (action === "shell" || action === "paid") {
this.mode = action;
this.open();
this.ui.changed();
}
if (action === "select") {
this.model = await llm()!.select({});
this.open();
this.ui.changed();
}
if (action === "save-service") {
const ref = this.parseRef({
pubkey: (
document.querySelector("#paidKey") as HTMLInputElement
).value.trim(),
relays: (
document.querySelector("#paidRelays") as HTMLInputElement
).value
.split(",")
.map((s) => s.trim()),
});
await storage.setItem(SERVICE_KEY, JSON.stringify(ref));
this.ref = ref;
this.open();
this.ui.toast("Paid service saved.");
}
if (action === "remove-old") {
await storage.removeItem("body-journal-connections-v1");
this.ui.toast("Legacy saved API keys removed.");
}
if (action === "request") await this.reviewRequest();
if (action === "send" || action === "send-text")
await this.send(action === "send-text");
if (action === "stop") {
this.abort?.abort();
this.progress = "Stopped waiting. Paid orders remain recoverable.";
if (this.order && this.checkingOut) await this.paid.close(this.order);
}
if (action === "recover") await this.recover();
if (action === "confirm-payment") await this.confirmPayment();
if (action === "discard-staging") {
if (this.pending || this.order?.status !== "staging")
throw new Error("Only an unsent order can be discarded.");
await storage.removeItem(ORDER_KEY);
this.order = undefined;
this.ui.changed();
document.querySelector<HTMLDialogElement>("#modal")?.close();
}
if (action === "pay") await this.runPaid();
if (action === "clear") {
this.clearReport();
this.ui.reportChanged();
this.ui.changed();
}
}
clearReport() {
this.abort?.abort();
this.completed = undefined;
this.reviewed = undefined;
this.error = "";
}
dispose() {
this.disposed = true;
this.identity.dispose();
this.abort?.abort();
document.removeEventListener("click", this.onClick);
document.removeEventListener("change", this.onChange);
}
}
function evidenceMarkup(evidence: VisualEvidence): string {
return `<div class="evidence-gallery">${evidence.images
.filter((i) => i.kind !== "patient-photo")
.map(
(i) =>
`<figure><img src="${i.dataUrl}" alt="Body-map illustration for observation ${i.concernId}" /><figcaption>${esc(i.caption)}</figcaption></figure>`,
)
.join(
"",
)}</div>${evidence.notes.map((n) => `<p class="evidence-status">${esc(n)}</p>`).join("")}${evidence.images.length ? "" : '<p class="evidence-status">No mapped images were prepared. General symptoms and named locations are described in text.</p>'}`;
}
function requestPhotos(photos: CasePhoto[]): string {
if (!photos.length) return "";
return `<section class="request-photos"><h3>Patient photos</h3><p class="field-hint">Only checked photos are included when sending images. Unchecked photos and their captions stay out of the request.</p><div class="evidence-gallery">${photos.map((p) => `<figure><img src="${p.dataUrl}" alt="Patient photo: ${esc(p.caption || p.name)}" /><figcaption><label class="check-field"><input type="checkbox" data-request-photo="${p.id}" ${p.includeInAi ? "checked" : ""} /><span>Include ${esc(p.name)}</span></label><p>${esc(p.caption || "No caption")}</p><small>${p.concernId === null ? "Whole case" : `Observation ID ${p.concernId}`} · ${p.width} × ${p.height} · ${esc(p.takenOn || "Date unknown")}</small></figcaption></figure>`).join("")}</div></section>`;
}
