@noodleseed/assistant 1.19.0 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -202,6 +202,81 @@ claims, or tool input. The endpoint key must match the authored name. Noodle val
202
202
  only in private short-lived session state, and an omitted route leaves only dependent tools unavailable.
203
203
  Mint a new session when the user's selected account or cluster changes.
204
204
 
205
+ #### Public website embeds
206
+
207
+ Everything above serves a signed-in user through your backend. A `publicWebsite(...)` surface serves the
208
+ same assistant to anonymous visitors — a marketing site, docs, or landing page — with a **required**
209
+ capability allowlist, per-surface daily budgets, and a kill switch. `noodle deploy` provisions a stable
210
+ embed id and prints a one-line snippet; the embed id is deliberately not a credential:
211
+
212
+ ```html
213
+ <script src="https://cloud.noodleseed.dev/v1/assistant/embed.js"
214
+ data-embed-id="pub_7f2q4k9x" async></script>
215
+ ```
216
+
217
+ In a bundled app, the same public surface mounts through the element or React wrapper with `embedId`
218
+ instead of `sessionEndpoint` — the two are mutually exclusive, and a public embed needs no backend route:
219
+
220
+ ```tsx
221
+ <AssistantWidget embedId="pub_7f2q4k9x" serviceUrl="https://cloud.noodleseed.dev" />
222
+ ```
223
+
224
+ A public embed opens its session on **first open**, never on mount — every mount would otherwise spend
225
+ one of the surface's daily admission budget. Add `signIn: true` to the surface and it becomes **mixed**:
226
+ anonymous visitors start immediately, and identity-dependent capabilities raise the sign-in flow below.
227
+ Before shipping, preflight the host with `noodle assistant embed --check --surface public` (or `mixed`) —
228
+ it verifies `script-src` too, the one CSP directive whose failure runs no widget code at all.
229
+
230
+ #### Mid-conversation sign-in (mixed surfaces)
231
+
232
+ On a `publicWebsite({ signIn: true })` surface, an anonymous visitor who reaches an identity-dependent
233
+ capability sees a "Sign in to continue" card, and the widget raises `assistant-sign-in-requested` with a
234
+ single-use `signInTicket` in its detail. The page signs the visitor in however it already does, then its
235
+ backend spends the ticket with the **same** helper and its own client credentials — same endpoint, same
236
+ `user` shape, plus the ticket:
237
+
238
+ ```ts
239
+ const session = await createAssistantSession({
240
+ serviceUrl: process.env.NOODLE_SERVICE_URL!,
241
+ clientId: process.env.NOODLE_ASSISTANT_CLIENT_ID!,
242
+ clientSecret: process.env.NOODLE_ASSISTANT_CLIENT_SECRET!,
243
+ origin: process.env.PUBLIC_APP_ORIGIN!,
244
+ user: { id: user.id, email: user.email, roles: user.roles },
245
+ signInTicket, // from the widget's assistant-sign-in-requested event, via your page
246
+ });
247
+ ```
248
+
249
+ The visitor keeps the same conversation with a new token; the anonymous token dies at that moment —
250
+ and by default **the assistant answers the pending question itself**: the service re-attempts the
251
+ intercepted tool under the signed-in principal and streams the result as the session's first turn,
252
+ so the panel's first content after sign-in is the answer the visitor asked for. It is one-shot
253
+ (consumed by that turn, or mooted the moment the visitor types first), confirm-gated tools stop at
254
+ their normal confirmation card — sign-in is never implicit consent for a write — and the turn counts
255
+ against the surface's daily budget like any other. Pass `resume: false` beside the ticket if your
256
+ application provides its own post-login affordance.
257
+ `signInTicket` and `context` are mutually exclusive — an elevation continues an existing session.
258
+ `routing` IS accepted here, and this is the moment to send it: elevation is the first authenticated
259
+ exchange, so it is the only chance a routed connector's session gets its backend-verified customer
260
+ routes. Possession of a ticket alone elevates nothing:
261
+ spending it also requires your client credentials, and the service checks your tenant owns that
262
+ conversation. (The ticket is deliberately **not** called a continuation: the server-held interaction
263
+ continuation described below must never reach browser code, while this value's whole job is to travel
264
+ through the page.)
265
+
266
+ A refused spend throws `AssistantSessionExchangeError`; branch on `error.elevationRefusal`:
267
+
268
+ | Code | What happened | What to do |
269
+ | :-- | :-- | :-- |
270
+ | `elevation_ticket_expired` | The visitor took too long (tickets live 10 minutes) | Re-prompt; the widget raises a fresh ticket when they retry the action |
271
+ | `elevation_ticket_invalid` | Stale, spent, or double-submitted ticket | Same recovery; log it — a burst is a replay signal |
272
+ | `elevation_tenant_mismatch` | Your credentials do not own that conversation | Alert someone; never retry |
273
+ | `elevation_already_signed_in` / `elevation_session_unavailable` | The conversation moved on | Ask the visitor to refresh |
274
+
275
+ `error.detail.serviceCode === "elevation_unavailable"` (a 503) means the deployment has no elevation
276
+ store configured — page the operator, not the visitor. Failures without a code (fresh-mint refusals,
277
+ proxy errors) carry `error.detail.status`, and `error.detail.retryable` is `true` only for
278
+ infrastructure 5xx responses.
279
+
205
280
  Then add the framework-neutral element:
206
281
 
207
282
  ```ts
@@ -426,6 +501,21 @@ element.addEventListener("assistant-view-available", (event) => {
426
501
  });
427
502
  ```
428
503
 
504
+ #### Readiness and call ordering
505
+
506
+ `assistant-ready` fires **once per element lifetime**, when the element's shadow DOM is rendered and its
507
+ imperative API (`sendMessage`, `confirmTool`, …) is callable. It does **not** mean a session exists — that
508
+ is the `assistant-event` with `event: 'session_started'`. A DOM move (router reparent, portal) re-runs the
509
+ element's connection lifecycle but never re-fires the event. The React wrapper's `onReady` mirrors this:
510
+ at most once per component instance, whether the element upgraded before or after the component mounted;
511
+ the first callback identity wins, and a `console.debug('[noodle-assistant] ready')` breadcrumb marks the
512
+ moment for field diagnosis.
513
+
514
+ `sendMessage` awaits an in-flight eager session exchange and never opens a second session — a message sent
515
+ while the panel is still connecting is delivered on the session that exchange produces. It does **not**
516
+ queue: a `sendMessage` issued while another send or response is in flight rejects with a retryable
517
+ `request_in_progress` error before touching the transcript.
518
+
429
519
  Assistant text renders as provider deltas arrive. If a turn finds an expired session, the component calls
430
520
  the same authenticated session endpoint and retries that unprocessed message once. Confirmations never
431
521
  replay across sessions. React applications may observe recovery and structured failures:
@@ -4,7 +4,7 @@ import {
4
4
  copyAssistantPageContext,
5
5
  createAssistantClient,
6
6
  sessionSourceKey
7
- } from "./chunk-S373FCAI.js";
7
+ } from "./chunk-ZPZHPXIQ.js";
8
8
  import {
9
9
  _enum,
10
10
  _null,
@@ -6824,6 +6824,11 @@ var AssistantElementEventController = class {
6824
6824
  this.#completedTool = void 0;
6825
6825
  return;
6826
6826
  }
6827
+ if (name === "resume_started") {
6828
+ this.#startStream("message");
6829
+ this.#host.element.dispatchEvent(new CustomEvent("assistant-message-started"));
6830
+ return;
6831
+ }
6827
6832
  if (name === "interaction_completed") {
6828
6833
  if (this.#streamKind !== "interaction") return;
6829
6834
  this.#finishInteraction(String(data.id ?? ""), String(data.action ?? "accept"));
@@ -6844,11 +6849,11 @@ var AssistantElementEventController = class {
6844
6849
  );
6845
6850
  return;
6846
6851
  }
6847
- if (name === "auth_requested" && typeof data.continuation === "string") {
6852
+ if (name === "auth_requested" && typeof data.signInTicket === "string") {
6848
6853
  this.#appendSignInRequest(
6849
6854
  String(data.id ?? ""),
6850
6855
  String(data.tool ?? "this"),
6851
- data.continuation,
6856
+ data.signInTicket,
6852
6857
  String(data.expiresAt ?? "")
6853
6858
  );
6854
6859
  return;
@@ -6941,15 +6946,15 @@ var AssistantElementEventController = class {
6941
6946
  /**
6942
6947
  * The host application owns the login. This renders the prompt and raises
6943
6948
  * `assistant-sign-in-requested`; the page signs the visitor in however it already does, then its
6944
- * backend spends the continuation. Nothing here talks to an identity provider.
6949
+ * backend spends the sign-in ticket. Nothing here talks to an identity provider.
6945
6950
  */
6946
- #appendSignInRequest(id, tool, continuation, expiresAt) {
6951
+ #appendSignInRequest(id, tool, signInTicket, expiresAt) {
6947
6952
  const messages = this.#host.messages();
6948
6953
  if (!messages) return;
6949
6954
  this.#proposalCards.get(id)?.remove();
6950
6955
  const card = createSignInCard({
6951
6956
  tool,
6952
- continuation,
6957
+ signInTicket,
6953
6958
  expiresAt,
6954
6959
  labels: {
6955
6960
  heading: "Sign in to continue",
@@ -6957,7 +6962,7 @@ var AssistantElementEventController = class {
6957
6962
  },
6958
6963
  onSignIn: () => this.#host.element.dispatchEvent(
6959
6964
  new CustomEvent("assistant-sign-in-requested", {
6960
- detail: { id, tool, continuation, expiresAt },
6965
+ detail: { id, tool, signInTicket, expiresAt },
6961
6966
  bubbles: true,
6962
6967
  composed: true
6963
6968
  })
@@ -6968,7 +6973,7 @@ var AssistantElementEventController = class {
6968
6973
  this.#host.revealLatest();
6969
6974
  this.#host.element.dispatchEvent(
6970
6975
  new CustomEvent("assistant-sign-in-required", {
6971
- detail: { id, tool, continuation, expiresAt }
6976
+ detail: { id, tool, signInTicket, expiresAt }
6972
6977
  })
6973
6978
  );
6974
6979
  }
@@ -7042,6 +7047,36 @@ function queryRequired(root, selector) {
7042
7047
  if (!element) throw new Error(`assistant renderer is missing ${selector}`);
7043
7048
  return element;
7044
7049
  }
7050
+ function assistantElementMarkup(appearance, open, styles) {
7051
+ return `${styles}
7052
+ <button class="launcher" type="button" aria-label="${escapeAttribute(appearance.labels.open)}" aria-controls="assistant-panel" aria-expanded="${String(open)}"><slot name="launcher-icon"></slot><span class="visually-hidden" data-session-status aria-live="polite" hidden></span></button>
7053
+ <section class="panel" id="assistant-panel" role="dialog" aria-label="${escapeAttribute(appearance.brand.name)}">
7054
+ <header><slot name="header-leading"></slot><img class="brand-logo" alt=""><strong></strong><slot name="header-actions"></slot><button class="close" type="button" aria-label="${escapeAttribute(appearance.labels.close)}"></button></header>
7055
+ <div class="welcome"><slot name="empty-state"></slot></div>
7056
+ <div class="suggested-prompts"></div>
7057
+ <div class="messages-region">
7058
+ <div class="messages" aria-live="polite"></div>
7059
+ <button class="new-messages" type="button" aria-label="${escapeAttribute(appearance.labels.newMessages)}" hidden></button>
7060
+ </div>
7061
+ <form><slot name="composer-leading"></slot><textarea rows="1"></textarea><button class="send" type="submit"></button><slot name="composer-trailing"></slot></form>
7062
+ <nav class="legal" aria-label="Legal"></nav>
7063
+ <slot name="conversation-footer"></slot>
7064
+ </section>`;
7065
+ }
7066
+ function appendLegalLinks(legal, appearance) {
7067
+ for (const [label, href] of [
7068
+ ["Privacy", appearance.privacyUrl],
7069
+ ["Terms", appearance.termsUrl]
7070
+ ]) {
7071
+ if (!href) continue;
7072
+ const link = document.createElement("a");
7073
+ link.href = href;
7074
+ link.target = "_blank";
7075
+ link.rel = "noopener noreferrer";
7076
+ link.textContent = label;
7077
+ legal.append(link);
7078
+ }
7079
+ }
7045
7080
 
7046
7081
  // src/element-page-context-controller.ts
7047
7082
  var AssistantElementPageContextController = class {
@@ -7829,6 +7864,8 @@ var NoodleAssistantElement = class extends HTMLElementBase {
7829
7864
  #pendingUserEcho;
7830
7865
  #sessionState = "idle";
7831
7866
  #startOpenApplied = false;
7867
+ #readyDispatched = false;
7868
+ #turnInFlight = false;
7832
7869
  #hostAppearance;
7833
7870
  #appearanceWarningKeys = /* @__PURE__ */ new Set();
7834
7871
  #appearanceStyleSnapshot = /* @__PURE__ */ new Map();
@@ -7900,7 +7937,10 @@ var NoodleAssistantElement = class extends HTMLElementBase {
7900
7937
  document.addEventListener("pointerdown", this.#handleDocumentPointerDown);
7901
7938
  if (this.#appearance.behavior.startOpen) this.#applyStartOpenPolicy();
7902
7939
  this.#render();
7903
- this.dispatchEvent(new CustomEvent("assistant-ready"));
7940
+ if (!this.#readyDispatched) {
7941
+ this.#readyDispatched = true;
7942
+ this.dispatchEvent(new CustomEvent("assistant-ready"));
7943
+ }
7904
7944
  if (this.hasAttribute("start-open")) {
7905
7945
  this.setAttribute("open", "");
7906
7946
  this.#startOpenApplied = true;
@@ -8014,7 +8054,14 @@ var NoodleAssistantElement = class extends HTMLElementBase {
8014
8054
  async sendMessage(text2) {
8015
8055
  const message = text2.trim();
8016
8056
  if (!message) return;
8057
+ if (this.#turnInFlight) {
8058
+ throw new AssistantClientError(
8059
+ { code: "request_in_progress", retryable: true },
8060
+ "an assistant request is already in progress"
8061
+ );
8062
+ }
8017
8063
  const generation = this.#conversationGeneration;
8064
+ this.#turnInFlight = true;
8018
8065
  this.#pendingUserEcho = message;
8019
8066
  this.#appendMessage("user", message);
8020
8067
  this.#setBusy(true);
@@ -8031,6 +8078,7 @@ var NoodleAssistantElement = class extends HTMLElementBase {
8031
8078
  this.#dispatchClientError(error, "turn_failed");
8032
8079
  throw error;
8033
8080
  } finally {
8081
+ this.#turnInFlight = false;
8034
8082
  if (this.#pendingUserEcho === message) this.#pendingUserEcho = void 0;
8035
8083
  if (generation === this.#conversationGeneration) this.#setBusy(false);
8036
8084
  }
@@ -8099,6 +8147,7 @@ var NoodleAssistantElement = class extends HTMLElementBase {
8099
8147
  #primeSession() {
8100
8148
  if (!this.sessionEndpoint && !this.embedId) return;
8101
8149
  if (this.#sessionBootstrap || this.#client?.hasSession()) return;
8150
+ if (this.#client?.isBusy?.() === true) return;
8102
8151
  if (this.embedId && !this.#pageContext) void this.#automaticPageContext.refresh();
8103
8152
  const client = this.#ensureClient();
8104
8153
  const generation = this.#conversationGeneration;
@@ -8128,6 +8177,8 @@ var NoodleAssistantElement = class extends HTMLElementBase {
8128
8177
  if (event.event === "content" || event.event === "message_completed" || event.event === "interaction_completed" || event.event === "error") {
8129
8178
  this.#removeThinking();
8130
8179
  }
8180
+ if (event.event === "resume_started") this.#setBusy(true);
8181
+ if (event.event === "message_completed" || event.event === "error") this.#setBusy(false);
8131
8182
  }
8132
8183
  #setBusy(busy) {
8133
8184
  this.toggleAttribute("busy", busy);
@@ -8336,7 +8387,7 @@ var NoodleAssistantElement = class extends HTMLElementBase {
8336
8387
  queryRequired(this.shadowRoot, "header").hidden = !appearance.behavior.showHeader;
8337
8388
  const legal = queryRequired(this.shadowRoot, ".legal");
8338
8389
  legal.replaceChildren();
8339
- this.#appendLegalLinks(legal);
8390
+ appendLegalLinks(legal, this.#appearance);
8340
8391
  this.#applyTheme();
8341
8392
  }
8342
8393
  #render() {
@@ -8352,20 +8403,11 @@ var NoodleAssistantElement = class extends HTMLElementBase {
8352
8403
  this.dataset.position = appearance.layout.position;
8353
8404
  this.dataset.density = appearance.layout.density;
8354
8405
  this.toggleAttribute("mobile-fullscreen", appearance.layout.mobileFullscreen);
8355
- this.shadowRoot.innerHTML = `${ASSISTANT_ELEMENT_STYLES}${presentationStyles}
8356
- <button class="launcher" type="button" aria-label="${escapeAttribute(appearance.labels.open)}" aria-controls="assistant-panel" aria-expanded="${String(this.hasAttribute("open"))}"><slot name="launcher-icon"></slot><span class="visually-hidden" data-session-status aria-live="polite" hidden></span></button>
8357
- <section class="panel" id="assistant-panel" role="dialog" aria-label="${escapeAttribute(appearance.brand.name)}">
8358
- <header><slot name="header-leading"></slot><img class="brand-logo" alt=""><strong></strong><slot name="header-actions"></slot><button class="close" type="button" aria-label="${escapeAttribute(appearance.labels.close)}"></button></header>
8359
- <div class="welcome"><slot name="empty-state"></slot></div>
8360
- <div class="suggested-prompts"></div>
8361
- <div class="messages-region">
8362
- <div class="messages" aria-live="polite"></div>
8363
- <button class="new-messages" type="button" aria-label="${escapeAttribute(appearance.labels.newMessages)}" hidden></button>
8364
- </div>
8365
- <form><slot name="composer-leading"></slot><textarea rows="1"></textarea><button class="send" type="submit"></button><slot name="composer-trailing"></slot></form>
8366
- <nav class="legal" aria-label="Legal"></nav>
8367
- <slot name="conversation-footer"></slot>
8368
- </section>`;
8406
+ this.shadowRoot.innerHTML = assistantElementMarkup(
8407
+ appearance,
8408
+ this.hasAttribute("open"),
8409
+ `${ASSISTANT_ELEMENT_STYLES}${presentationStyles}`
8410
+ );
8369
8411
  queryRequired(this.shadowRoot, "strong").textContent = appearance.brand.name;
8370
8412
  const logo = queryRequired(this.shadowRoot, ".brand-logo");
8371
8413
  const logoUrl = appearance.brand.logo[this.#resolvedMode()];
@@ -8392,7 +8434,7 @@ var NoodleAssistantElement = class extends HTMLElementBase {
8392
8434
  if (!appearance.behavior.showHeader)
8393
8435
  queryRequired(this.shadowRoot, "header").hidden = true;
8394
8436
  const legal = queryRequired(this.shadowRoot, ".legal");
8395
- this.#appendLegalLinks(legal);
8437
+ appendLegalLinks(legal, this.#appearance);
8396
8438
  this.#messages = this.shadowRoot.querySelector(".messages") ?? void 0;
8397
8439
  this.#messages?.append(...conversation);
8398
8440
  this.#thinking = this.#messages?.querySelector(".thinking") ?? void 0;
@@ -8431,20 +8473,6 @@ var NoodleAssistantElement = class extends HTMLElementBase {
8431
8473
  });
8432
8474
  this.#applyTheme();
8433
8475
  }
8434
- #appendLegalLinks(legal) {
8435
- for (const [label, href] of [
8436
- ["Privacy", this.#appearance.privacyUrl],
8437
- ["Terms", this.#appearance.termsUrl]
8438
- ]) {
8439
- if (!href) continue;
8440
- const link = document.createElement("a");
8441
- link.href = href;
8442
- link.target = "_blank";
8443
- link.rel = "noopener noreferrer";
8444
- link.textContent = label;
8445
- legal.append(link);
8446
- }
8447
- }
8448
8476
  };
8449
8477
  function registerNoodleAssistant() {
8450
8478
  if (globalThis.customElements && !customElements.get(ASSISTANT_TAG_NAME)) {
@@ -8465,4 +8493,4 @@ export {
8465
8493
  dompurify/dist/purify.es.mjs:
8466
8494
  (*! @license DOMPurify 3.4.11 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.4.11/LICENSE *)
8467
8495
  */
8468
- //# sourceMappingURL=chunk-RFETV5KQ.js.map
8496
+ //# sourceMappingURL=chunk-LEKEIHJV.js.map