Back to Body Journal
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.

src/ai-ui.ts
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 &amp; 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 &amp; 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>`;
}