@fiodos/web-core 0.1.15 → 0.1.18

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.
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Stable per-browser ANONYMOUS caller id, sent as `x-user-id` when the host
3
+ * app provides no getUserId (public storefronts, marketing sites…).
4
+ *
5
+ * Why: the backend's fairness rate-limit buckets anonymous callers by source
6
+ * IP. Behind shopper NATs / proxies that means MANY real visitors share one
7
+ * bucket and an active session can starve itself into 429 ("Too many
8
+ * requests"). A random per-browser id gives each visitor their own fairness
9
+ * bucket without identifying anyone: it is random, local, never derived from
10
+ * personal data, and never used for conversation identity (that stays 'anon').
11
+ */
12
+ /** Returns the stable anonymous caller id for this browser (creates it once). */
13
+ export declare function getAnonCallerId(): string;
14
+ /**
15
+ * Wraps the host's getUserId so anonymous visitors still send a stable
16
+ * per-browser caller id for rate-limit fairness. The host's real user id
17
+ * always wins when present.
18
+ */
19
+ export declare function withAnonCallerFallback(getUserId?: () => string | null): () => string | null;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Stable per-browser ANONYMOUS caller id, sent as `x-user-id` when the host
3
+ * app provides no getUserId (public storefronts, marketing sites…).
4
+ *
5
+ * Why: the backend's fairness rate-limit buckets anonymous callers by source
6
+ * IP. Behind shopper NATs / proxies that means MANY real visitors share one
7
+ * bucket and an active session can starve itself into 429 ("Too many
8
+ * requests"). A random per-browser id gives each visitor their own fairness
9
+ * bucket without identifying anyone: it is random, local, never derived from
10
+ * personal data, and never used for conversation identity (that stays 'anon').
11
+ */
12
+ const STORAGE_KEY = 'fyodos:anon-caller:v1';
13
+ let inMemoryId = null;
14
+ function randomId() {
15
+ try {
16
+ const c = globalThis.crypto;
17
+ if (c?.randomUUID)
18
+ return `anon-${c.randomUUID()}`;
19
+ }
20
+ catch {
21
+ /* fall through */
22
+ }
23
+ return `anon-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
24
+ }
25
+ /** Returns the stable anonymous caller id for this browser (creates it once). */
26
+ export function getAnonCallerId() {
27
+ if (inMemoryId)
28
+ return inMemoryId;
29
+ try {
30
+ const stored = window.localStorage.getItem(STORAGE_KEY);
31
+ if (stored) {
32
+ inMemoryId = stored;
33
+ return stored;
34
+ }
35
+ const fresh = randomId();
36
+ window.localStorage.setItem(STORAGE_KEY, fresh);
37
+ inMemoryId = fresh;
38
+ return fresh;
39
+ }
40
+ catch {
41
+ // Private mode / storage blocked: keep a per-page-load id (still better
42
+ // than sharing the IP bucket with every other visitor).
43
+ inMemoryId = inMemoryId ?? randomId();
44
+ return inMemoryId;
45
+ }
46
+ }
47
+ /**
48
+ * Wraps the host's getUserId so anonymous visitors still send a stable
49
+ * per-browser caller id for rate-limit fairness. The host's real user id
50
+ * always wins when present.
51
+ */
52
+ export function withAnonCallerFallback(getUserId) {
53
+ return () => {
54
+ const real = getUserId?.();
55
+ if (real)
56
+ return real;
57
+ return getAnonCallerId();
58
+ };
59
+ }
@@ -141,6 +141,10 @@ export function createFiodosBackendClient(options) {
141
141
  intent: request.lastStep.intent,
142
142
  ...(request.lastStep.label ? { label: request.lastStep.label } : {}),
143
143
  success: request.lastStep.success,
144
+ // Bounded result payload of the executed step (product search
145
+ // results…): the backend renders it so the model can read real
146
+ // ids for the NEXT step's parameters.
147
+ ...(request.lastStep.data ? { data: request.lastStep.data } : {}),
144
148
  };
145
149
  }
146
150
  }
@@ -33,6 +33,7 @@ export declare function fetchClientManifest(opts: {
33
33
  baseUrl: string;
34
34
  apiKey: string;
35
35
  }): Promise<ManifestBootstrapResult>;
36
+ export declare function markEmbedDistribution(): void;
36
37
  /**
37
38
  * Best-effort heartbeat proving the orb booted. Never throws and never blocks
38
39
  * the orb — failures are swallowed (the dashboard simply won't flip to ✓).
@@ -37,11 +37,21 @@ export async function fetchClientManifest(opts) {
37
37
  }
38
38
  return { status: 'ready', manifest: data.manifest };
39
39
  }
40
+ /**
41
+ * Set by the embed bundle entrypoint (dist/embed/fiodos-embed.js). Embeds are
42
+ * loaded from a CDN `@latest` URL (Shopify themes, Dynamics…), so they update
43
+ * THEMSELVES — the heartbeat marks them so the dashboard never shows an
44
+ * "update available / npm install" notice the developer cannot act on.
45
+ */
46
+ let embedDistribution = false;
47
+ export function markEmbedDistribution() {
48
+ embedDistribution = true;
49
+ }
40
50
  function detectPlatform() {
41
- if (typeof navigator !== 'undefined' && /Mobi|Android|iPhone|iPad/i.test(navigator.userAgent)) {
42
- return 'web-mobile';
43
- }
44
- return 'web';
51
+ const mobile = typeof navigator !== 'undefined' && /Mobi|Android|iPhone|iPad/i.test(navigator.userAgent);
52
+ if (embedDistribution)
53
+ return mobile ? 'web-embed-mobile' : 'web-embed';
54
+ return mobile ? 'web-mobile' : 'web';
45
55
  }
46
56
  /**
47
57
  * Best-effort heartbeat proving the orb booted. Never throws and never blocks
@@ -15,7 +15,7 @@
15
15
  * deliberate modal tap; a loose "yes" never confirms them.
16
16
  * - The agent only ever executes actions declared in the manifest.
17
17
  */
18
- import { combineContexts, createActionExecutor, createConsentManager, createSafeTelemetry, createSanitizer, buildBubbleExchangeWindow, BUBBLE_PAGE_EXCHANGES, clearPersistedConversation, conversationIdentityKey, conversationRetentionOptions, createConversationSession, persistConversationSession, restoreConversationSession, defaultRouteContextText, formatMessage, prepareConversationForTurn, recordConversationExchange, resolveConfirmationLexicon, resolveMessages, validateManifest, } from '@fiodos/core';
18
+ import { combineContexts, createActionExecutor, createConsentManager, createSafeTelemetry, createSanitizer, buildBubbleExchangeWindow, BUBBLE_PAGE_EXCHANGES, clearPersistedConversation, conversationIdentityKey, conversationRetentionOptions, createConversationSession, persistConversationSession, restoreConversationSession, defaultRouteContextText, formatMessage, prepareConversationForTurn, recordConversationExchange, resolveConfirmationLexicon, resolveMessages, trimChainStepData, validateManifest, } from '@fiodos/core';
19
19
  import { createWebCredentialAutofillAdapter } from '../adapters/webCredentialAutofillAdapter.js';
20
20
  import { AgentApiError } from '../api/errors.js';
21
21
  import { formatAgentTurnError } from '../api/formatTurnError.js';
@@ -713,9 +713,22 @@ export class AgentController {
713
713
  /* keep the assistant's guiding reply; never speak a refusal. */
714
714
  }
715
715
  else if (r.message) {
716
+ // FEEDBACK GUARANTEE: the handler's outcome message (success,
717
+ // idempotent or failure) is the ground truth that the action ran;
718
+ // speak it instead of the LLM's optimistic reply.
716
719
  outReply = r.message;
717
720
  outAudio = null;
718
721
  }
722
+ else if (!r.success) {
723
+ // Same guarantee for a handler that failed WITHOUT setting `message`
724
+ // (e.g. a declarative Shopify/OData `execution` call that hit a
725
+ // network/HTTP/template error — apiActions.ts never throws, so
726
+ // runManifestAction's catch never runs either). Without this, the
727
+ // LLM's pre-action narration ("Adding X to your cart…") stays on
728
+ // screen as if it had happened — a silent false success.
729
+ outReply = this.messages.actionFailed;
730
+ outAudio = null;
731
+ }
719
732
  // Chain signal: only a cleanly EXECUTED, non-recoverable step may advance,
720
733
  // and only when the backend asked to continue (task_status=in_progress).
721
734
  if (turn.action &&
@@ -723,11 +736,16 @@ export class AgentController {
723
736
  r.success &&
724
737
  !r.recoverable) {
725
738
  const intent = turn.action.intent ?? '';
739
+ // Feed the step's RESULT DATA back to the next chain turn (bounded):
740
+ // it is how the model reads real technical values (variant ids, line
741
+ // numbers) out of a data action instead of inventing them.
742
+ const chainData = trimChainStepData(r.data);
726
743
  lastStep = {
727
744
  type: turn.action.type,
728
745
  intent,
729
746
  label: turn.action.type === 'navigate' ? this.routeLabel(intent) : this.actionLabel(intent),
730
747
  success: true,
748
+ ...(chainData ? { data: chainData } : {}),
731
749
  };
732
750
  actionKey = `${turn.action.type}:${intent}`;
733
751
  canContinue = turn.taskStatus === 'in_progress';
@@ -21,6 +21,7 @@ import { AgentController } from '../controller/AgentController.js';
21
21
  import { mountOrb } from '../orb/mountOrb.js';
22
22
  import { showOrbDevError } from '../orb/devErrorBadge.js';
23
23
  import { DEFAULT_WEB_API_URL, fetchClientManifest, sendOrbSeen } from '../api/clientBootstrap.js';
24
+ import { withAnonCallerFallback } from '../api/anonUserId.js';
24
25
  function defaultNavigate(route) {
25
26
  if (typeof window !== 'undefined' && window.location)
26
27
  window.location.assign(route);
@@ -60,15 +61,18 @@ function assemble(options, manifest, baseUrl) {
60
61
  const fallback = browserLocale();
61
62
  const locale = resolveAgentLocale(options);
62
63
  const sttLocale = options.sttLocale ?? options.locale ?? fallback.sttLocale;
64
+ // Rate-limit fairness: anonymous visitors still send a stable per-browser
65
+ // caller id (never used for conversation identity — that stays 'anon').
66
+ const callerId = withAnonCallerFallback(options.getUserId);
63
67
  const backend = createFiodosBackendClient({
64
68
  baseUrl,
65
69
  apiKey: options.apiKey,
66
- getUserId: options.getUserId,
70
+ getUserId: callerId,
67
71
  });
68
72
  const telemetry = createFiodosTelemetry({
69
73
  baseUrl,
70
74
  apiKey: options.apiKey,
71
- getUserId: options.getUserId,
75
+ getUserId: callerId,
72
76
  });
73
77
  const config = {
74
78
  manifest,
@@ -27,7 +27,7 @@ import { createScreenContextStore } from '../context/screenContextStore.js';
27
27
  import { connectDynamicsFormContext, dynamicsFormSnapshot, normalizeDynamicsId } from './dynamics.js';
28
28
  import { buildApiActionRegistries, validateManifest } from '@fiodos/core';
29
29
  declare const api: {
30
- readonly version: "0.1.15";
30
+ readonly version: "0.1.18";
31
31
  readonly createFiodosAgent: typeof createFiodosAgent;
32
32
  readonly mountOrb: typeof mountOrb;
33
33
  readonly AgentController: typeof AgentController;
@@ -26,7 +26,11 @@ import { AgentController } from '../controller/AgentController.js';
26
26
  import { createScreenContextStore } from '../context/screenContextStore.js';
27
27
  import { connectDynamicsFormContext, dynamicsFormSnapshot, normalizeDynamicsId, } from './dynamics.js';
28
28
  import { SDK_VERSION } from '../version.js';
29
+ import { markEmbedDistribution } from '../api/clientBootstrap.js';
29
30
  import { buildApiActionRegistries, validateManifest } from '@fiodos/core';
31
+ // This bundle is served from a CDN `@latest` URL and updates itself: mark the
32
+ // heartbeat so the dashboard never shows an npm update command for it.
33
+ markEmbedDistribution();
30
34
  const api = {
31
35
  version: SDK_VERSION,
32
36
  createFiodosAgent,
@@ -707,13 +707,44 @@ export function mountOrb(controller, options = {}) {
707
707
  // Live-activity line: white (reply color), italic — not the faint gray.
708
708
  busyStep.style.color = t.titleColor;
709
709
  }
710
+ // The panel's open state survives FULL PAGE LOADS within the tab session:
711
+ // on multi-page hosts (Shopify storefronts…) every agent navigation reloads
712
+ // the document, and without this the conversation panel closed mid-dialogue
713
+ // on every step. sessionStorage is per-tab and cleared when the tab closes,
714
+ // so a fresh visit never starts with the panel open.
715
+ const panelOpenKey = `fyodos:panel-open:${controller.config.manifest.appId}`;
716
+ function persistPanelOpen(open) {
717
+ try {
718
+ if (open)
719
+ window.sessionStorage.setItem(panelOpenKey, '1');
720
+ else
721
+ window.sessionStorage.removeItem(panelOpenKey);
722
+ }
723
+ catch {
724
+ /* storage unavailable (private mode) — persistence is best-effort */
725
+ }
726
+ }
727
+ function wasPanelOpen() {
728
+ try {
729
+ return window.sessionStorage.getItem(panelOpenKey) === '1';
730
+ }
731
+ catch {
732
+ return false;
733
+ }
734
+ }
710
735
  function setBubble(open) {
711
736
  bubbleOpen = open;
712
737
  bubble.style.display = open ? 'flex' : 'none';
738
+ persistPanelOpen(open);
713
739
  if (open) {
714
740
  applyBubbleTheme();
715
741
  positionBubble();
716
742
  textInput.focus();
743
+ // Paint the CURRENT conversation immediately: exchanges are only
744
+ // rendered inside render() while the panel is open, so without this
745
+ // the restored history stayed invisible (an apparently stuck panel)
746
+ // until the next controller event — i.e. until the user typed.
747
+ render(controller.getState());
717
748
  }
718
749
  else {
719
750
  bubbleDictation.stop();
@@ -1156,6 +1187,13 @@ export function mountOrb(controller, options = {}) {
1156
1187
  }
1157
1188
  const unsubscribe = controller.subscribe(render);
1158
1189
  render(controller.getState());
1190
+ // Re-open the conversation panel after a full page load when it was open
1191
+ // before (MPA hosts reload on every navigation): the dialogue visually
1192
+ // continues where it was — the persisted conversation restores async in the
1193
+ // controller and repaints via emit → render once loaded.
1194
+ if (enableTextInput && wasPanelOpen()) {
1195
+ setBubble(true);
1196
+ }
1159
1197
  // Track the current route AND the app's live locale so the thought bubble
1160
1198
  // re-resolves its CTA on navigation and on language switches. No universal
1161
1199
  // navigation/i18n event exists, so poll + listen to popstate / visibility
@@ -5,5 +5,5 @@
5
5
  * Auto-generated by scripts/sync-sdk-version.mjs from package.json. Do not edit
6
6
  * by hand — change package.json `version` and re-run the sync/release script.
7
7
  */
8
- export declare const SDK_VERSION = "0.1.15";
8
+ export declare const SDK_VERSION = "0.1.18";
9
9
  export declare const SDK_PACKAGE = "@fiodos/web-core";
@@ -5,5 +5,5 @@
5
5
  * Auto-generated by scripts/sync-sdk-version.mjs from package.json. Do not edit
6
6
  * by hand — change package.json `version` and re-run the sync/release script.
7
7
  */
8
- export const SDK_VERSION = '0.1.15';
8
+ export const SDK_VERSION = '0.1.18';
9
9
  export const SDK_PACKAGE = '@fiodos/web-core';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fiodos/web-core",
3
- "version": "0.1.15",
3
+ "version": "0.1.18",
4
4
  "description": "Framework-agnostic browser layer for the Fiodos agent: a vanilla AgentController (orchestrator), DOM adapters (navigation/voice/storage), HTTP client and decision engine over @fiodos/core. Shared base for @fiodos/vue, @fiodos/svelte and @fiodos/angular (no framework imports).",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "publishConfig": {
@@ -30,7 +30,7 @@
30
30
  "prepublishOnly": "node ../../scripts/sync-sdk-version.mjs && npm run build"
31
31
  },
32
32
  "dependencies": {
33
- "@fiodos/core": "^0.1.10"
33
+ "@fiodos/core": "^0.1.13"
34
34
  },
35
35
  "devDependencies": {
36
36
  "@types/node": "^22.0.0",