@smooai/chat-widget 0.5.2 → 0.7.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/dist/chat-widget.global.js +1633 -71
- package/dist/chat-widget.global.js.map +1 -1
- package/dist/index.d.ts +381 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1478 -72
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/src/config.ts +31 -1
- package/src/conversation.ts +531 -18
- package/src/element.ts +455 -57
- package/src/fingerprint.ts +122 -0
- package/src/index.ts +14 -0
- package/src/markdown.ts +368 -0
- package/src/persistence.ts +271 -0
- package/src/styles.ts +110 -0
|
@@ -18,6 +18,7 @@ var SmoothAgentChat = (function(exports) {
|
|
|
18
18
|
userName: config.userName,
|
|
19
19
|
userEmail: config.userEmail,
|
|
20
20
|
userPhone: config.userPhone,
|
|
21
|
+
authContext: config.authContext,
|
|
21
22
|
placeholder: config.placeholder ?? "Type a message…",
|
|
22
23
|
greeting: config.greeting ?? "Hi! How can I help you today?",
|
|
23
24
|
connectionErrorMessage: config.connectionErrorMessage ?? "We couldn't reach the chat. Please try again in a moment.",
|
|
@@ -26,6 +27,9 @@ var SmoothAgentChat = (function(exports) {
|
|
|
26
27
|
requireName: config.requireName ?? false,
|
|
27
28
|
requireEmail: config.requireEmail ?? false,
|
|
28
29
|
requirePhone: config.requirePhone ?? false,
|
|
30
|
+
collectPhone: config.collectPhone ?? true,
|
|
31
|
+
collectConsent: config.collectConsent ?? true,
|
|
32
|
+
allowChatRestore: config.allowChatRestore ?? true,
|
|
29
33
|
allowAnonymous: config.allowAnonymous ?? false,
|
|
30
34
|
theme: {
|
|
31
35
|
text: theme.text ?? "#f8fafc",
|
|
@@ -609,6 +613,451 @@ var SmoothAgentChat = (function(exports) {
|
|
|
609
613
|
throw new ProtocolError("UNEXPECTED_EVENT", `Expected immediate_response, got "${event.type}"`, event.requestId);
|
|
610
614
|
}
|
|
611
615
|
//#endregion
|
|
616
|
+
//#region src/fingerprint.ts
|
|
617
|
+
/**
|
|
618
|
+
* Browser fingerprint — a stable, privacy-light identifier used by the
|
|
619
|
+
* smooth-operator server to correlate an anonymous visitor's sessions across
|
|
620
|
+
* page loads (and, server-side, to match an anonymous fingerprint to a known
|
|
621
|
+
* CRM contact). It rides every `create_conversation_session` as
|
|
622
|
+
* `browserFingerprint` (see ADR-048).
|
|
623
|
+
*
|
|
624
|
+
* ## Why not ThumbmarkJS
|
|
625
|
+
*
|
|
626
|
+
* The ADR floats ThumbmarkJS as the reference implementation. For an *embed*
|
|
627
|
+
* that is injected onto arbitrary host pages, ThumbmarkJS is too heavy: its
|
|
628
|
+
* full build pulls in extensive device-detection tables and async
|
|
629
|
+
* component collection, adding tens of kilobytes (and async surface) to a
|
|
630
|
+
* bundle whose whole selling point is staying out of the host page's
|
|
631
|
+
* LCP/TBT budget. The fingerprint here is a few hundred bytes.
|
|
632
|
+
*
|
|
633
|
+
* ## What this is (and the tradeoff)
|
|
634
|
+
*
|
|
635
|
+
* The correlation that actually matters — "is this the same browser as last
|
|
636
|
+
* time?" — is carried by a **persisted random UUID** (the `browserFingerprint`
|
|
637
|
+
* field in the persisted store). That UUID is generated once and reused for
|
|
638
|
+
* the life of the localStorage entry, so same-browser resume is exact and
|
|
639
|
+
* deterministic. The signal hash below is a best-effort entropy *supplement*
|
|
640
|
+
* mixed into that UUID's derivation seed on first generation — it gives the
|
|
641
|
+
* server-side resolver a soft signal for the (rare) cross-storage case where
|
|
642
|
+
* the UUID was cleared but the device is unchanged. It is intentionally NOT a
|
|
643
|
+
* high-entropy device fingerprint: no canvas/WebGL/audio probes, no font
|
|
644
|
+
* enumeration, nothing that reads as invasive tracking. The tradeoff is weaker
|
|
645
|
+
* cross-storage matching in exchange for a tiny, transparent, no-network,
|
|
646
|
+
* XSS-safe implementation. The server's resolver (SMOODEV-2129d) is the source
|
|
647
|
+
* of truth for any fuzzy matching; the client just supplies a stable token.
|
|
648
|
+
*/
|
|
649
|
+
/** Collect a small set of stable, non-invasive browser signals. */
|
|
650
|
+
function collectSignals() {
|
|
651
|
+
const parts = [];
|
|
652
|
+
try {
|
|
653
|
+
const nav = typeof navigator !== "undefined" ? navigator : void 0;
|
|
654
|
+
if (nav) {
|
|
655
|
+
parts.push(`ua:${nav.userAgent ?? ""}`);
|
|
656
|
+
parts.push(`lang:${nav.language ?? ""}`);
|
|
657
|
+
parts.push(`langs:${Array.isArray(nav.languages) ? nav.languages.join(",") : ""}`);
|
|
658
|
+
parts.push(`plat:${nav.platform ?? ""}`);
|
|
659
|
+
parts.push(`hc:${nav.hardwareConcurrency ?? ""}`);
|
|
660
|
+
parts.push(`dm:${nav.deviceMemory ?? ""}`);
|
|
661
|
+
}
|
|
662
|
+
if (typeof screen !== "undefined") parts.push(`scr:${screen.width}x${screen.height}x${screen.colorDepth}`);
|
|
663
|
+
if (typeof Intl !== "undefined") try {
|
|
664
|
+
parts.push(`tz:${Intl.DateTimeFormat().resolvedOptions().timeZone ?? ""}`);
|
|
665
|
+
} catch {}
|
|
666
|
+
parts.push(`tzo:${(/* @__PURE__ */ new Date()).getTimezoneOffset()}`);
|
|
667
|
+
} catch {}
|
|
668
|
+
return parts.join("|");
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* A small, fast, non-cryptographic string hash (FNV-1a, 32-bit) rendered as
|
|
672
|
+
* an unsigned hex string. Stable across runs for the same input. Not used for
|
|
673
|
+
* any security decision — only to derive a deterministic seed from the signal
|
|
674
|
+
* string above.
|
|
675
|
+
*/
|
|
676
|
+
function fnv1a(input) {
|
|
677
|
+
let h = 2166136261;
|
|
678
|
+
for (let i = 0; i < input.length; i++) {
|
|
679
|
+
h ^= input.charCodeAt(i);
|
|
680
|
+
h = Math.imul(h, 16777619);
|
|
681
|
+
}
|
|
682
|
+
return (h >>> 0).toString(16).padStart(8, "0");
|
|
683
|
+
}
|
|
684
|
+
/** Generate a UUID, preferring `crypto.randomUUID`, falling back to a v4-shaped string. */
|
|
685
|
+
function generateUuid() {
|
|
686
|
+
try {
|
|
687
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
|
|
688
|
+
} catch {}
|
|
689
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
|
690
|
+
const r = Math.random() * 16 | 0;
|
|
691
|
+
return (c === "x" ? r : r & 3 | 8).toString(16);
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Compute a fresh fingerprint token: a stable random UUID, suffixed with the
|
|
696
|
+
* FNV hash of the device signals so the server can recover a soft device
|
|
697
|
+
* signal even if it only has the token. Called ONCE per browser (the result is
|
|
698
|
+
* persisted and reused) — see {@link getOrCreateFingerprint}.
|
|
699
|
+
*/
|
|
700
|
+
function computeFingerprint() {
|
|
701
|
+
const signalHash = fnv1a(collectSignals());
|
|
702
|
+
return `${generateUuid()}.${signalHash}`;
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* Return the cached fingerprint if one was already computed for this browser,
|
|
706
|
+
* otherwise compute + persist a new one via the provided accessors. The
|
|
707
|
+
* persistence layer owns storage; this function owns the "compute once" policy.
|
|
708
|
+
*/
|
|
709
|
+
function getOrCreateFingerprint(get, set) {
|
|
710
|
+
const existing = get();
|
|
711
|
+
if (existing) return existing;
|
|
712
|
+
const fp = computeFingerprint();
|
|
713
|
+
set(fp);
|
|
714
|
+
return fp;
|
|
715
|
+
}
|
|
716
|
+
//#endregion
|
|
717
|
+
//#region node_modules/.pnpm/zustand@5.0.14/node_modules/zustand/esm/vanilla.mjs
|
|
718
|
+
const createStoreImpl = (createState) => {
|
|
719
|
+
let state;
|
|
720
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
721
|
+
const setState = (partial, replace) => {
|
|
722
|
+
const nextState = typeof partial === "function" ? partial(state) : partial;
|
|
723
|
+
if (!Object.is(nextState, state)) {
|
|
724
|
+
const previousState = state;
|
|
725
|
+
state = (replace != null ? replace : typeof nextState !== "object" || nextState === null) ? nextState : Object.assign({}, state, nextState);
|
|
726
|
+
listeners.forEach((listener) => listener(state, previousState));
|
|
727
|
+
}
|
|
728
|
+
};
|
|
729
|
+
const getState = () => state;
|
|
730
|
+
const getInitialState = () => initialState;
|
|
731
|
+
const subscribe = (listener) => {
|
|
732
|
+
listeners.add(listener);
|
|
733
|
+
return () => listeners.delete(listener);
|
|
734
|
+
};
|
|
735
|
+
const api = {
|
|
736
|
+
setState,
|
|
737
|
+
getState,
|
|
738
|
+
getInitialState,
|
|
739
|
+
subscribe
|
|
740
|
+
};
|
|
741
|
+
const initialState = state = createState(setState, getState, api);
|
|
742
|
+
return api;
|
|
743
|
+
};
|
|
744
|
+
const createStore = ((createState) => createState ? createStoreImpl(createState) : createStoreImpl);
|
|
745
|
+
//#endregion
|
|
746
|
+
//#region node_modules/.pnpm/zustand@5.0.14/node_modules/zustand/esm/middleware.mjs
|
|
747
|
+
function createJSONStorage(getStorage, options) {
|
|
748
|
+
let storage;
|
|
749
|
+
try {
|
|
750
|
+
storage = getStorage();
|
|
751
|
+
} catch (e) {
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
return {
|
|
755
|
+
getItem: (name) => {
|
|
756
|
+
var _a;
|
|
757
|
+
const parse = (str2) => {
|
|
758
|
+
if (str2 === null) return null;
|
|
759
|
+
return JSON.parse(str2, options == null ? void 0 : options.reviver);
|
|
760
|
+
};
|
|
761
|
+
const str = (_a = storage.getItem(name)) != null ? _a : null;
|
|
762
|
+
if (str instanceof Promise) return str.then(parse);
|
|
763
|
+
return parse(str);
|
|
764
|
+
},
|
|
765
|
+
setItem: (name, newValue) => storage.setItem(name, JSON.stringify(newValue, options == null ? void 0 : options.replacer)),
|
|
766
|
+
removeItem: (name) => storage.removeItem(name)
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
const toThenable = (fn) => (input) => {
|
|
770
|
+
try {
|
|
771
|
+
const result = fn(input);
|
|
772
|
+
if (result instanceof Promise) return result;
|
|
773
|
+
return {
|
|
774
|
+
then(onFulfilled) {
|
|
775
|
+
return toThenable(onFulfilled)(result);
|
|
776
|
+
},
|
|
777
|
+
catch(_onRejected) {
|
|
778
|
+
return this;
|
|
779
|
+
}
|
|
780
|
+
};
|
|
781
|
+
} catch (e) {
|
|
782
|
+
return {
|
|
783
|
+
then(_onFulfilled) {
|
|
784
|
+
return this;
|
|
785
|
+
},
|
|
786
|
+
catch(onRejected) {
|
|
787
|
+
return toThenable(onRejected)(e);
|
|
788
|
+
}
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
};
|
|
792
|
+
const persistImpl = (config, baseOptions) => (set, get, api) => {
|
|
793
|
+
let options = {
|
|
794
|
+
storage: createJSONStorage(() => window.localStorage),
|
|
795
|
+
partialize: (state) => state,
|
|
796
|
+
version: 0,
|
|
797
|
+
merge: (persistedState, currentState) => ({
|
|
798
|
+
...currentState,
|
|
799
|
+
...persistedState
|
|
800
|
+
}),
|
|
801
|
+
...baseOptions
|
|
802
|
+
};
|
|
803
|
+
let hasHydrated = false;
|
|
804
|
+
let hydrationVersion = 0;
|
|
805
|
+
const hydrationListeners = /* @__PURE__ */ new Set();
|
|
806
|
+
const finishHydrationListeners = /* @__PURE__ */ new Set();
|
|
807
|
+
let storage = options.storage;
|
|
808
|
+
if (!storage) return config((...args) => {
|
|
809
|
+
console.warn(`[zustand persist middleware] Unable to update item '${options.name}', the given storage is currently unavailable.`);
|
|
810
|
+
set(...args);
|
|
811
|
+
}, get, api);
|
|
812
|
+
const setItem = () => {
|
|
813
|
+
const state = options.partialize({ ...get() });
|
|
814
|
+
return storage.setItem(options.name, {
|
|
815
|
+
state,
|
|
816
|
+
version: options.version
|
|
817
|
+
});
|
|
818
|
+
};
|
|
819
|
+
const savedSetState = api.setState;
|
|
820
|
+
api.setState = (state, replace) => {
|
|
821
|
+
savedSetState(state, replace);
|
|
822
|
+
return setItem();
|
|
823
|
+
};
|
|
824
|
+
const configResult = config((...args) => {
|
|
825
|
+
set(...args);
|
|
826
|
+
return setItem();
|
|
827
|
+
}, get, api);
|
|
828
|
+
api.getInitialState = () => configResult;
|
|
829
|
+
let stateFromStorage;
|
|
830
|
+
const hydrate = () => {
|
|
831
|
+
var _a, _b;
|
|
832
|
+
if (!storage) return;
|
|
833
|
+
const currentVersion = ++hydrationVersion;
|
|
834
|
+
hasHydrated = false;
|
|
835
|
+
hydrationListeners.forEach((cb) => {
|
|
836
|
+
var _a2;
|
|
837
|
+
return cb((_a2 = get()) != null ? _a2 : configResult);
|
|
838
|
+
});
|
|
839
|
+
const postRehydrationCallback = ((_b = options.onRehydrateStorage) == null ? void 0 : _b.call(options, (_a = get()) != null ? _a : configResult)) || void 0;
|
|
840
|
+
return toThenable(storage.getItem.bind(storage))(options.name).then((deserializedStorageValue) => {
|
|
841
|
+
if (deserializedStorageValue) if (typeof deserializedStorageValue.version === "number" && deserializedStorageValue.version !== options.version) {
|
|
842
|
+
if (options.migrate) {
|
|
843
|
+
const migration = options.migrate(deserializedStorageValue.state, deserializedStorageValue.version);
|
|
844
|
+
if (migration instanceof Promise) return migration.then((result) => [true, result]);
|
|
845
|
+
return [true, migration];
|
|
846
|
+
}
|
|
847
|
+
console.error(`State loaded from storage couldn't be migrated since no migrate function was provided`);
|
|
848
|
+
} else return [false, deserializedStorageValue.state];
|
|
849
|
+
return [false, void 0];
|
|
850
|
+
}).then((migrationResult) => {
|
|
851
|
+
var _a2;
|
|
852
|
+
if (currentVersion !== hydrationVersion) return;
|
|
853
|
+
const [migrated, migratedState] = migrationResult;
|
|
854
|
+
stateFromStorage = options.merge(migratedState, (_a2 = get()) != null ? _a2 : configResult);
|
|
855
|
+
set(stateFromStorage, true);
|
|
856
|
+
if (migrated) return setItem();
|
|
857
|
+
}).then(() => {
|
|
858
|
+
if (currentVersion !== hydrationVersion) return;
|
|
859
|
+
postRehydrationCallback?.(get(), void 0);
|
|
860
|
+
stateFromStorage = get();
|
|
861
|
+
hasHydrated = true;
|
|
862
|
+
finishHydrationListeners.forEach((cb) => cb(stateFromStorage));
|
|
863
|
+
}).catch((e) => {
|
|
864
|
+
if (currentVersion !== hydrationVersion) return;
|
|
865
|
+
postRehydrationCallback?.(void 0, e);
|
|
866
|
+
});
|
|
867
|
+
};
|
|
868
|
+
api.persist = {
|
|
869
|
+
setOptions: (newOptions) => {
|
|
870
|
+
options = {
|
|
871
|
+
...options,
|
|
872
|
+
...newOptions
|
|
873
|
+
};
|
|
874
|
+
if (newOptions.storage) storage = newOptions.storage;
|
|
875
|
+
},
|
|
876
|
+
clearStorage: () => {
|
|
877
|
+
storage?.removeItem(options.name);
|
|
878
|
+
},
|
|
879
|
+
getOptions: () => options,
|
|
880
|
+
rehydrate: () => hydrate(),
|
|
881
|
+
hasHydrated: () => hasHydrated,
|
|
882
|
+
onHydrate: (cb) => {
|
|
883
|
+
hydrationListeners.add(cb);
|
|
884
|
+
return () => {
|
|
885
|
+
hydrationListeners.delete(cb);
|
|
886
|
+
};
|
|
887
|
+
},
|
|
888
|
+
onFinishHydration: (cb) => {
|
|
889
|
+
finishHydrationListeners.add(cb);
|
|
890
|
+
return () => {
|
|
891
|
+
finishHydrationListeners.delete(cb);
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
};
|
|
895
|
+
if (!options.skipHydration) hydrate();
|
|
896
|
+
return stateFromStorage || configResult;
|
|
897
|
+
};
|
|
898
|
+
const persist = persistImpl;
|
|
899
|
+
const EMPTY_CONSENT = {
|
|
900
|
+
emailOptIn: false,
|
|
901
|
+
smsOptIn: false
|
|
902
|
+
};
|
|
903
|
+
function initialPersisted() {
|
|
904
|
+
return {
|
|
905
|
+
version: 1,
|
|
906
|
+
sessionId: null,
|
|
907
|
+
identity: {},
|
|
908
|
+
consent: { ...EMPTY_CONSENT },
|
|
909
|
+
verifiedEmail: null,
|
|
910
|
+
verifiedEmailSessionId: null,
|
|
911
|
+
browserFingerprint: null
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
/** localStorage key for an agent's persisted widget state. */
|
|
915
|
+
function storageKey(agentId) {
|
|
916
|
+
return `smoo-chat-widget:${agentId}`;
|
|
917
|
+
}
|
|
918
|
+
/**
|
|
919
|
+
* An explicit in-memory (Map-backed) `PersistStorage`. Used as the fallback when
|
|
920
|
+
* real localStorage is unavailable.
|
|
921
|
+
*
|
|
922
|
+
* IMPORTANT: we must NOT return `undefined` from {@link safeStorage} in that case.
|
|
923
|
+
* zustand v5's `persist` treats a missing `storage` option by falling back to its
|
|
924
|
+
* OWN `createJSONStorage(() => localStorage)` — i.e. it re-engages the very
|
|
925
|
+
* localStorage the guard was trying to avoid (throwing again in privacy mode).
|
|
926
|
+
* Handing it this no-op storage keeps the store working purely in memory and
|
|
927
|
+
* guarantees the fallback can't touch real localStorage.
|
|
928
|
+
*/
|
|
929
|
+
function memoryStorage() {
|
|
930
|
+
const mem = /* @__PURE__ */ new Map();
|
|
931
|
+
return {
|
|
932
|
+
getItem: (name) => {
|
|
933
|
+
const raw = mem.get(name);
|
|
934
|
+
if (!raw) return null;
|
|
935
|
+
try {
|
|
936
|
+
return JSON.parse(raw);
|
|
937
|
+
} catch {
|
|
938
|
+
return null;
|
|
939
|
+
}
|
|
940
|
+
},
|
|
941
|
+
setItem: (name, value) => {
|
|
942
|
+
mem.set(name, JSON.stringify(value));
|
|
943
|
+
},
|
|
944
|
+
removeItem: (name) => {
|
|
945
|
+
mem.delete(name);
|
|
946
|
+
}
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
/**
|
|
950
|
+
* A `persist` storage adapter that tolerates the *absence* of localStorage
|
|
951
|
+
* (SSR, privacy-mode throwing on access, sandboxed iframes). When storage is
|
|
952
|
+
* unavailable the store still works in-memory; nothing is persisted, but the
|
|
953
|
+
* widget never throws on boot.
|
|
954
|
+
*/
|
|
955
|
+
function safeStorage() {
|
|
956
|
+
let ls = null;
|
|
957
|
+
try {
|
|
958
|
+
ls = typeof localStorage !== "undefined" ? localStorage : null;
|
|
959
|
+
if (ls) {
|
|
960
|
+
const probe = "__smoo_probe__";
|
|
961
|
+
ls.setItem(probe, "1");
|
|
962
|
+
ls.removeItem(probe);
|
|
963
|
+
}
|
|
964
|
+
} catch {
|
|
965
|
+
ls = null;
|
|
966
|
+
}
|
|
967
|
+
if (!ls) return memoryStorage();
|
|
968
|
+
const storage = ls;
|
|
969
|
+
return {
|
|
970
|
+
getItem: (name) => {
|
|
971
|
+
try {
|
|
972
|
+
const raw = storage.getItem(name);
|
|
973
|
+
return raw ? JSON.parse(raw) : null;
|
|
974
|
+
} catch {
|
|
975
|
+
return null;
|
|
976
|
+
}
|
|
977
|
+
},
|
|
978
|
+
setItem: (name, value) => {
|
|
979
|
+
try {
|
|
980
|
+
storage.setItem(name, JSON.stringify(value));
|
|
981
|
+
} catch {}
|
|
982
|
+
},
|
|
983
|
+
removeItem: (name) => {
|
|
984
|
+
try {
|
|
985
|
+
storage.removeItem(name);
|
|
986
|
+
} catch {}
|
|
987
|
+
}
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
/**
|
|
991
|
+
* Migrate a persisted blob from an older `version` to the current shape. Today
|
|
992
|
+
* v1 is the only version, so this just backfills any missing fields onto an
|
|
993
|
+
* unknown old blob; future versions add `case` branches here.
|
|
994
|
+
*/
|
|
995
|
+
function migrate(persisted) {
|
|
996
|
+
const base = initialPersisted();
|
|
997
|
+
if (!persisted || typeof persisted !== "object") return base;
|
|
998
|
+
const p = persisted;
|
|
999
|
+
return {
|
|
1000
|
+
version: 1,
|
|
1001
|
+
sessionId: typeof p.sessionId === "string" ? p.sessionId : null,
|
|
1002
|
+
identity: {
|
|
1003
|
+
name: typeof p.identity?.name === "string" ? p.identity.name : void 0,
|
|
1004
|
+
email: typeof p.identity?.email === "string" ? p.identity.email : void 0,
|
|
1005
|
+
phone: typeof p.identity?.phone === "string" ? p.identity.phone : void 0
|
|
1006
|
+
},
|
|
1007
|
+
consent: {
|
|
1008
|
+
emailOptIn: p.consent?.emailOptIn === true,
|
|
1009
|
+
smsOptIn: p.consent?.smsOptIn === true,
|
|
1010
|
+
consentSource: typeof p.consent?.consentSource === "string" ? p.consent.consentSource : void 0,
|
|
1011
|
+
consentAt: typeof p.consent?.consentAt === "string" ? p.consent.consentAt : void 0
|
|
1012
|
+
},
|
|
1013
|
+
verifiedEmail: typeof p.verifiedEmail === "string" ? p.verifiedEmail : null,
|
|
1014
|
+
verifiedEmailSessionId: typeof p.verifiedEmailSessionId === "string" ? p.verifiedEmailSessionId : null,
|
|
1015
|
+
browserFingerprint: typeof p.browserFingerprint === "string" ? p.browserFingerprint : null
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
/**
|
|
1019
|
+
* Create the per-agent persisted Zustand store. The `partialize` step ensures
|
|
1020
|
+
* only the persisted shape (never any future transient action/UI state) is
|
|
1021
|
+
* written to localStorage.
|
|
1022
|
+
*/
|
|
1023
|
+
function createWidgetStore(agentId) {
|
|
1024
|
+
return createStore()(persist((set) => ({
|
|
1025
|
+
...initialPersisted(),
|
|
1026
|
+
setSessionId: (sessionId) => set({ sessionId }),
|
|
1027
|
+
mergeIdentity: (identity) => set((state) => ({ identity: {
|
|
1028
|
+
...state.identity,
|
|
1029
|
+
...identity.name !== void 0 ? { name: identity.name } : {},
|
|
1030
|
+
...identity.email !== void 0 ? { email: identity.email } : {},
|
|
1031
|
+
...identity.phone !== void 0 ? { phone: identity.phone } : {}
|
|
1032
|
+
} })),
|
|
1033
|
+
setConsent: (consent) => set({ consent: { ...consent } }),
|
|
1034
|
+
setVerifiedEmail: (verifiedEmail, sessionId) => set((state) => ({
|
|
1035
|
+
verifiedEmail,
|
|
1036
|
+
verifiedEmailSessionId: verifiedEmail === null ? null : sessionId ?? state.sessionId
|
|
1037
|
+
})),
|
|
1038
|
+
setBrowserFingerprint: (browserFingerprint) => set({ browserFingerprint }),
|
|
1039
|
+
clearSession: () => set({
|
|
1040
|
+
sessionId: null,
|
|
1041
|
+
verifiedEmail: null,
|
|
1042
|
+
verifiedEmailSessionId: null
|
|
1043
|
+
})
|
|
1044
|
+
}), {
|
|
1045
|
+
name: storageKey(agentId),
|
|
1046
|
+
version: 1,
|
|
1047
|
+
storage: safeStorage(),
|
|
1048
|
+
migrate,
|
|
1049
|
+
partialize: (state) => ({
|
|
1050
|
+
version: 1,
|
|
1051
|
+
sessionId: state.sessionId,
|
|
1052
|
+
identity: state.identity,
|
|
1053
|
+
consent: state.consent,
|
|
1054
|
+
verifiedEmail: state.verifiedEmail,
|
|
1055
|
+
verifiedEmailSessionId: state.verifiedEmailSessionId,
|
|
1056
|
+
browserFingerprint: state.browserFingerprint
|
|
1057
|
+
})
|
|
1058
|
+
}));
|
|
1059
|
+
}
|
|
1060
|
+
//#endregion
|
|
612
1061
|
//#region src/conversation.ts
|
|
613
1062
|
/**
|
|
614
1063
|
* ConversationController — the bridge between the widget UI and the
|
|
@@ -620,13 +1069,41 @@ var SmoothAgentChat = (function(exports) {
|
|
|
620
1069
|
* so the swap is purely at the client-library boundary.
|
|
621
1070
|
*
|
|
622
1071
|
* Flow:
|
|
623
|
-
* 1. `connect()` → opens the WebSocket transport and `create_conversation_session
|
|
1072
|
+
* 1. `connect()` → opens the WebSocket transport and `create_conversation_session`
|
|
1073
|
+
* (or RESUMES a persisted session via `get_session`/`get_messages`).
|
|
624
1074
|
* 2. `send(text)` → `send_message`, streaming `stream_token` deltas into the
|
|
625
1075
|
* in-progress assistant message, then the terminal
|
|
626
1076
|
* `eventual_response`.
|
|
627
1077
|
*
|
|
1078
|
+
* 0.7.0 (SMOODEV-2129e) adds the identity / persistence / consent client layer:
|
|
1079
|
+
* - `browserFingerprint` computed once + sent on every `create_conversation_session`.
|
|
1080
|
+
* - identity + marketing consent threaded into the session `metadata`.
|
|
1081
|
+
* - same-session RESUME on load (no engine change — `get_session` + `get_messages`).
|
|
1082
|
+
* - returning-visitor RESUME via `POST /internal/resume-by-fingerprint`, and
|
|
1083
|
+
* cross-device "restore my chats" via the `POST /internal/identity/{request-otp,
|
|
1084
|
+
* verify-otp,resolve}` routes on the chat-ws wrapper. The engine (smooth-operator
|
|
1085
|
+
* 1.8.0) owns the `/ws` dispatch and REJECTS unknown verbs, so these are HTTP
|
|
1086
|
+
* `fetch()` calls (origin-allowlisted + optional authContext, per ADR-046/048) —
|
|
1087
|
+
* NOT WS frames.
|
|
1088
|
+
*
|
|
628
1089
|
* The controller is UI-agnostic: it emits typed events and the view renders them.
|
|
629
1090
|
*/
|
|
1091
|
+
/**
|
|
1092
|
+
* Derive the HTTP base for the chat-ws wrapper's `/internal/*` REST routes from
|
|
1093
|
+
* the WS endpoint: `wss://ai.smoo.ai/ws` → `https://ai.smoo.ai`. The engine
|
|
1094
|
+
* (smooth-operator 1.8.0) owns the `/ws` dispatch and REJECTS unknown verbs, so
|
|
1095
|
+
* the cross-device identity flow + fingerprint resume are HTTP POST routes on the
|
|
1096
|
+
* wrapper (origin-allowlisted + authContext, per ADR-046/ADR-048) — NOT WS frames.
|
|
1097
|
+
*/
|
|
1098
|
+
function httpBaseFromWsEndpoint(endpoint) {
|
|
1099
|
+
try {
|
|
1100
|
+
const u = new URL(endpoint);
|
|
1101
|
+
u.protocol = u.protocol === "ws:" ? "http:" : u.protocol === "wss:" ? "https:" : u.protocol;
|
|
1102
|
+
return `${u.protocol}//${u.host}`;
|
|
1103
|
+
} catch {
|
|
1104
|
+
return null;
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
630
1107
|
/** Pull the final assistant text out of an `eventual_response` data payload. */
|
|
631
1108
|
function extractFinalText(response) {
|
|
632
1109
|
if (!response || typeof response !== "object") return null;
|
|
@@ -667,40 +1144,89 @@ var SmoothAgentChat = (function(exports) {
|
|
|
667
1144
|
}
|
|
668
1145
|
return out;
|
|
669
1146
|
}
|
|
1147
|
+
/** Convert a server message row into a finalized {@link ChatMessage}. */
|
|
1148
|
+
function wireMessageToChat(m, idx) {
|
|
1149
|
+
const text = typeof m.content?.text === "string" ? m.content.text : "";
|
|
1150
|
+
if (!text) return null;
|
|
1151
|
+
const role = m.direction === "outbound" ? "assistant" : "user";
|
|
1152
|
+
return {
|
|
1153
|
+
id: typeof m.id === "string" ? m.id : `hist-${idx}`,
|
|
1154
|
+
role,
|
|
1155
|
+
text,
|
|
1156
|
+
streaming: false
|
|
1157
|
+
};
|
|
1158
|
+
}
|
|
670
1159
|
var ConversationController = class {
|
|
671
|
-
constructor(config, events) {
|
|
1160
|
+
constructor(config, events, store) {
|
|
672
1161
|
_defineProperty(this, "config", void 0);
|
|
673
1162
|
_defineProperty(this, "events", void 0);
|
|
1163
|
+
_defineProperty(this, "store", void 0);
|
|
674
1164
|
_defineProperty(this, "client", null);
|
|
675
1165
|
_defineProperty(this, "sessionId", null);
|
|
676
1166
|
_defineProperty(this, "messages", []);
|
|
677
1167
|
_defineProperty(this, "status", "idle");
|
|
678
1168
|
_defineProperty(this, "seq", 0);
|
|
679
|
-
_defineProperty(this, "identity", void 0);
|
|
680
1169
|
_defineProperty(this, "activeRequestId", null);
|
|
681
1170
|
_defineProperty(this, "interrupt", null);
|
|
1171
|
+
_defineProperty(this, "identityRestore", { phase: "idle" });
|
|
1172
|
+
_defineProperty(this, "resumeAttempted", false);
|
|
1173
|
+
_defineProperty(this, "httpBase", void 0);
|
|
682
1174
|
this.config = config;
|
|
683
1175
|
this.events = events;
|
|
684
|
-
this.
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
1176
|
+
this.httpBase = httpBaseFromWsEndpoint(config.endpoint);
|
|
1177
|
+
if (this.httpBase === null) queueMicrotask(() => this.setStatus("error", `Invalid chat endpoint: ${config.endpoint}`));
|
|
1178
|
+
this.store = store ?? createWidgetStore(config.agentId);
|
|
1179
|
+
const seed = {};
|
|
1180
|
+
if (config.userName) seed.name = config.userName;
|
|
1181
|
+
if (config.userEmail) seed.email = config.userEmail;
|
|
1182
|
+
if (config.userPhone) seed.phone = config.userPhone;
|
|
1183
|
+
if (Object.keys(seed).length > 0) this.store.getState().mergeIdentity(seed);
|
|
689
1184
|
}
|
|
690
1185
|
get connectionStatus() {
|
|
691
1186
|
return this.status;
|
|
692
1187
|
}
|
|
693
|
-
/**
|
|
1188
|
+
/** The persisted store, exposed so the view can read identity for the pre-chat gate. */
|
|
1189
|
+
getStore() {
|
|
1190
|
+
return this.store;
|
|
1191
|
+
}
|
|
1192
|
+
/** True when a persisted session pointer exists (drives the resume path). */
|
|
1193
|
+
hasPersistedSession() {
|
|
1194
|
+
return !!this.store.getState().sessionId;
|
|
1195
|
+
}
|
|
1196
|
+
/** True when persisted identity exists (lets the view skip the pre-chat form). */
|
|
1197
|
+
hasPersistedIdentity() {
|
|
1198
|
+
const id = this.store.getState().identity;
|
|
1199
|
+
return !!(id.name || id.email || id.phone);
|
|
1200
|
+
}
|
|
1201
|
+
/** Merge in visitor identity + consent (from the pre-chat form). Applied on next connect. */
|
|
694
1202
|
setUserInfo(info) {
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
1203
|
+
const { name, email, phone, consent } = info;
|
|
1204
|
+
this.store.getState().mergeIdentity({
|
|
1205
|
+
name,
|
|
1206
|
+
email,
|
|
1207
|
+
phone
|
|
1208
|
+
});
|
|
1209
|
+
if (consent) {
|
|
1210
|
+
const consentAt = consent.emailOptIn || consent.smsOptIn ? (/* @__PURE__ */ new Date()).toISOString() : void 0;
|
|
1211
|
+
this.store.getState().setConsent({
|
|
1212
|
+
emailOptIn: consent.emailOptIn,
|
|
1213
|
+
smsOptIn: consent.smsOptIn,
|
|
1214
|
+
consentSource: "chat-widget-prechat",
|
|
1215
|
+
consentAt
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
699
1218
|
}
|
|
700
1219
|
setInterrupt(interrupt) {
|
|
701
1220
|
this.interrupt = interrupt;
|
|
702
1221
|
this.events.onInterrupt?.(interrupt);
|
|
703
1222
|
}
|
|
1223
|
+
setIdentityRestore(state) {
|
|
1224
|
+
this.identityRestore = state;
|
|
1225
|
+
this.events.onIdentityRestore?.(state);
|
|
1226
|
+
}
|
|
1227
|
+
get currentIdentityRestore() {
|
|
1228
|
+
return this.identityRestore;
|
|
1229
|
+
}
|
|
704
1230
|
/** Submit an OTP code to resume the paused turn. No-op if not awaiting OTP. */
|
|
705
1231
|
verifyOtp(code) {
|
|
706
1232
|
if (!this.client || !this.sessionId || !this.activeRequestId || this.interrupt?.kind !== "otp") return;
|
|
@@ -731,20 +1257,95 @@ var SmoothAgentChat = (function(exports) {
|
|
|
731
1257
|
emitMessages() {
|
|
732
1258
|
this.events.onMessages(this.messages.map((m) => ({ ...m })));
|
|
733
1259
|
}
|
|
734
|
-
/**
|
|
1260
|
+
/** Compute (once) + return the persisted browser fingerprint. */
|
|
1261
|
+
fingerprint() {
|
|
1262
|
+
const state = this.store.getState();
|
|
1263
|
+
return getOrCreateFingerprint(() => state.browserFingerprint, (fp) => this.store.getState().setBrowserFingerprint(fp));
|
|
1264
|
+
}
|
|
1265
|
+
/**
|
|
1266
|
+
* Build the `metadata` payload threaded into `create_conversation_session`:
|
|
1267
|
+
* phone (no first-class engine field) and consent.
|
|
1268
|
+
*
|
|
1269
|
+
* NOTE: `verifiedEmail` is deliberately NOT stamped here. It is a per-session
|
|
1270
|
+
* OTP proof bound to the session it was verified against
|
|
1271
|
+
* (`verifiedEmailSessionId`), and the server only treats an actual OTP `verify`
|
|
1272
|
+
* as proof — metadata `verifiedEmail` is just a hint. Auto-stamping it onto
|
|
1273
|
+
* every brand-new `create_conversation_session` would mislabel a fresh
|
|
1274
|
+
* visitor's session with a prior (possibly different) visitor's email on a
|
|
1275
|
+
* shared browser. The verified email is only used when RESUMING the exact
|
|
1276
|
+
* session it was proven for — see {@link verifiedEmailForSession}.
|
|
1277
|
+
*/
|
|
1278
|
+
sessionMetadata() {
|
|
1279
|
+
const state = this.store.getState();
|
|
1280
|
+
const meta = {};
|
|
1281
|
+
if (state.identity.phone) meta.userPhone = state.identity.phone;
|
|
1282
|
+
const consent = state.consent;
|
|
1283
|
+
if (consent.emailOptIn || consent.smsOptIn || consent.consentAt) {
|
|
1284
|
+
const c = {
|
|
1285
|
+
emailOptIn: consent.emailOptIn,
|
|
1286
|
+
smsOptIn: consent.smsOptIn,
|
|
1287
|
+
consentSource: consent.consentSource ?? "chat-widget-prechat"
|
|
1288
|
+
};
|
|
1289
|
+
if (consent.consentAt) c.consentAt = consent.consentAt;
|
|
1290
|
+
meta.consent = c;
|
|
1291
|
+
}
|
|
1292
|
+
return Object.keys(meta).length > 0 ? meta : void 0;
|
|
1293
|
+
}
|
|
1294
|
+
/**
|
|
1295
|
+
* The verified-email hint, but ONLY when the OTP proof is bound to the session
|
|
1296
|
+
* being resumed (`verifiedEmailSessionId === sessionId`). Returns null
|
|
1297
|
+
* otherwise so a stale/cross-visitor proof is never threaded onto a session it
|
|
1298
|
+
* wasn't proven for.
|
|
1299
|
+
*/
|
|
1300
|
+
verifiedEmailForSession(sessionId) {
|
|
1301
|
+
const state = this.store.getState();
|
|
1302
|
+
if (state.verifiedEmail && state.verifiedEmailSessionId === sessionId) return state.verifiedEmail;
|
|
1303
|
+
return null;
|
|
1304
|
+
}
|
|
1305
|
+
/** Lazily open the WS client (default transport). Idempotent within a connect. */
|
|
1306
|
+
async ensureClient() {
|
|
1307
|
+
if (this.client) return;
|
|
1308
|
+
this.client = new SmoothAgentClient({ url: this.config.endpoint });
|
|
1309
|
+
await this.client.connect();
|
|
1310
|
+
}
|
|
1311
|
+
/**
|
|
1312
|
+
* Open the connection and either RESUME or create a session.
|
|
1313
|
+
*
|
|
1314
|
+
* 1. Persisted pointer (ADR-048 §b): `get_session` → if not `ended`, reuse +
|
|
1315
|
+
* hydrate from `get_messages` (newest-first, reversed). On ended/404 clear
|
|
1316
|
+
* ONLY the pointer (identity/consent survive).
|
|
1317
|
+
* 2. No persisted pointer: POST `/internal/resume-by-fingerprint` FIRST; if
|
|
1318
|
+
* `resumable`, adopt the returned session (the wrapper has primed the
|
|
1319
|
+
* operator registry), reuse the sessionId, and hydrate via get_session/
|
|
1320
|
+
* get_messages — rather than relying on createConversationSession to resume.
|
|
1321
|
+
* 3. Otherwise create a fresh session.
|
|
1322
|
+
*/
|
|
735
1323
|
async connect() {
|
|
736
1324
|
if (this.status === "connecting" || this.status === "ready") return;
|
|
737
1325
|
this.setStatus("connecting");
|
|
738
1326
|
try {
|
|
739
|
-
this.
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
1327
|
+
await this.ensureClient();
|
|
1328
|
+
if (!this.resumeAttempted) {
|
|
1329
|
+
this.resumeAttempted = true;
|
|
1330
|
+
const persistedSessionId = this.store.getState().sessionId;
|
|
1331
|
+
if (persistedSessionId) {
|
|
1332
|
+
if (await this.tryResume(persistedSessionId)) {
|
|
1333
|
+
this.setStatus("ready");
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
1336
|
+
this.store.getState().clearSession();
|
|
1337
|
+
} else {
|
|
1338
|
+
const fpSessionId = await this.resumeByFingerprint();
|
|
1339
|
+
if (fpSessionId) {
|
|
1340
|
+
if (await this.tryResume(fpSessionId)) {
|
|
1341
|
+
this.store.getState().setSessionId(fpSessionId);
|
|
1342
|
+
this.setStatus("ready");
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
await this.createSession();
|
|
748
1349
|
this.setStatus("ready");
|
|
749
1350
|
} catch (err) {
|
|
750
1351
|
this.setStatus("error", err instanceof Error ? err.message : String(err));
|
|
@@ -752,6 +1353,105 @@ var SmoothAgentChat = (function(exports) {
|
|
|
752
1353
|
}
|
|
753
1354
|
}
|
|
754
1355
|
/**
|
|
1356
|
+
* Build the auth fields every `/internal/*` route shares: `agentId` (required
|
|
1357
|
+
* for the agent-policy lookup), `agentName` (used as the OTP email sender), and
|
|
1358
|
+
* the optional pre-auth `authContext` the host page may have configured. The
|
|
1359
|
+
* `Origin` header is sent automatically by the browser and checked server-side.
|
|
1360
|
+
*/
|
|
1361
|
+
authBody() {
|
|
1362
|
+
const body = { agentId: this.config.agentId };
|
|
1363
|
+
if (this.config.agentName) body.agentName = this.config.agentName;
|
|
1364
|
+
if (this.config.authContext) body.authContext = this.config.authContext;
|
|
1365
|
+
return body;
|
|
1366
|
+
}
|
|
1367
|
+
/** POST JSON to a `/internal/*` route; returns the parsed body (or throws). */
|
|
1368
|
+
async postInternal(path, payload) {
|
|
1369
|
+
if (this.httpBase === null) throw new Error(`Cannot reach ${path}: the chat endpoint is not an absolute URL.`);
|
|
1370
|
+
const res = await fetch(`${this.httpBase}${path}`, {
|
|
1371
|
+
method: "POST",
|
|
1372
|
+
headers: { "content-type": "application/json" },
|
|
1373
|
+
credentials: "omit",
|
|
1374
|
+
body: JSON.stringify({
|
|
1375
|
+
...this.authBody(),
|
|
1376
|
+
...payload
|
|
1377
|
+
})
|
|
1378
|
+
});
|
|
1379
|
+
let json = {};
|
|
1380
|
+
try {
|
|
1381
|
+
json = await res.json();
|
|
1382
|
+
} catch {
|
|
1383
|
+
json = {};
|
|
1384
|
+
}
|
|
1385
|
+
if (!res.ok) {
|
|
1386
|
+
const err = json.error;
|
|
1387
|
+
throw new Error(err?.message ?? `${path} failed (${res.status})`);
|
|
1388
|
+
}
|
|
1389
|
+
return json;
|
|
1390
|
+
}
|
|
1391
|
+
/**
|
|
1392
|
+
* POST `/internal/resume-by-fingerprint`. Returns the resumable sessionId when
|
|
1393
|
+
* the wrapper found (and primed) a recent session for this fingerprint, else
|
|
1394
|
+
* null. Network/route failures are swallowed → null (fall through to create).
|
|
1395
|
+
*/
|
|
1396
|
+
async resumeByFingerprint() {
|
|
1397
|
+
try {
|
|
1398
|
+
const json = await this.postInternal("/internal/resume-by-fingerprint", { browserFingerprint: this.fingerprint() });
|
|
1399
|
+
if (json.resumable === true && typeof json.sessionId === "string") return json.sessionId;
|
|
1400
|
+
} catch {}
|
|
1401
|
+
return null;
|
|
1402
|
+
}
|
|
1403
|
+
/** `create_conversation_session` with fingerprint + identity + consent metadata. */
|
|
1404
|
+
async createSession() {
|
|
1405
|
+
if (!this.client) throw new Error("Conversation is not connected");
|
|
1406
|
+
const state = this.store.getState();
|
|
1407
|
+
const metadata = this.sessionMetadata();
|
|
1408
|
+
const session = await this.client.createConversationSession({
|
|
1409
|
+
agentId: this.config.agentId,
|
|
1410
|
+
userName: state.identity.name,
|
|
1411
|
+
userEmail: state.identity.email,
|
|
1412
|
+
browserFingerprint: this.fingerprint(),
|
|
1413
|
+
...metadata ? { metadata } : {}
|
|
1414
|
+
});
|
|
1415
|
+
this.sessionId = session.sessionId;
|
|
1416
|
+
this.store.getState().setSessionId(session.sessionId);
|
|
1417
|
+
}
|
|
1418
|
+
/**
|
|
1419
|
+
* Attempt to resume `sessionId`: returns true and hydrates the transcript when
|
|
1420
|
+
* the session is live, false when it has ended / can't be fetched.
|
|
1421
|
+
*/
|
|
1422
|
+
async tryResume(sessionId) {
|
|
1423
|
+
if (!this.client) return false;
|
|
1424
|
+
let snap;
|
|
1425
|
+
try {
|
|
1426
|
+
snap = await this.client.getSession({ sessionId });
|
|
1427
|
+
} catch {
|
|
1428
|
+
return false;
|
|
1429
|
+
}
|
|
1430
|
+
if (snap.status === "ended") return false;
|
|
1431
|
+
this.sessionId = sessionId;
|
|
1432
|
+
await this.hydrateHistory(sessionId);
|
|
1433
|
+
return true;
|
|
1434
|
+
}
|
|
1435
|
+
/** Page recent history (newest-first), reverse to chronological, and render. */
|
|
1436
|
+
async hydrateHistory(sessionId) {
|
|
1437
|
+
if (!this.client) return;
|
|
1438
|
+
try {
|
|
1439
|
+
const page = await this.client.getMessages({
|
|
1440
|
+
sessionId,
|
|
1441
|
+
limit: 50
|
|
1442
|
+
});
|
|
1443
|
+
const chronological = [...Array.isArray(page.messages) ? page.messages : []].reverse();
|
|
1444
|
+
const hydrated = [];
|
|
1445
|
+
chronological.forEach((m, i) => {
|
|
1446
|
+
const chat = wireMessageToChat(m, i);
|
|
1447
|
+
if (chat) hydrated.push(chat);
|
|
1448
|
+
});
|
|
1449
|
+
this.messages.length = 0;
|
|
1450
|
+
this.messages.push(...hydrated);
|
|
1451
|
+
this.emitMessages();
|
|
1452
|
+
} catch {}
|
|
1453
|
+
}
|
|
1454
|
+
/**
|
|
755
1455
|
* Submit a user message. Appends the user bubble immediately, then streams the
|
|
756
1456
|
* assistant reply token-by-token, finalizing on `eventual_response`.
|
|
757
1457
|
*/
|
|
@@ -853,12 +1553,170 @@ var SmoothAgentChat = (function(exports) {
|
|
|
853
1553
|
default: break;
|
|
854
1554
|
}
|
|
855
1555
|
}
|
|
1556
|
+
/**
|
|
1557
|
+
* Begin the cross-device restore: POST `/internal/identity/request-otp` for
|
|
1558
|
+
* `email` over `channel`. The view collects the email via an explicit affordance.
|
|
1559
|
+
*/
|
|
1560
|
+
async requestIdentityOtp(email, channel = "email") {
|
|
1561
|
+
const trimmed = email.trim();
|
|
1562
|
+
if (!trimmed) return;
|
|
1563
|
+
this.setIdentityRestore({
|
|
1564
|
+
phase: "requesting",
|
|
1565
|
+
email: trimmed,
|
|
1566
|
+
channel
|
|
1567
|
+
});
|
|
1568
|
+
if (!this.sessionId) try {
|
|
1569
|
+
await this.connect();
|
|
1570
|
+
} catch {}
|
|
1571
|
+
if (!this.sessionId) {
|
|
1572
|
+
this.setIdentityRestore({
|
|
1573
|
+
phase: "error",
|
|
1574
|
+
message: "No active session to verify against."
|
|
1575
|
+
});
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
1578
|
+
try {
|
|
1579
|
+
const json = await this.postInternal("/internal/identity/request-otp", {
|
|
1580
|
+
sessionId: this.sessionId,
|
|
1581
|
+
email: trimmed,
|
|
1582
|
+
channel
|
|
1583
|
+
});
|
|
1584
|
+
const masked = typeof json.maskedDestination === "string" ? json.maskedDestination : void 0;
|
|
1585
|
+
this.setIdentityRestore({
|
|
1586
|
+
phase: "awaiting_code",
|
|
1587
|
+
email: trimmed,
|
|
1588
|
+
channel,
|
|
1589
|
+
maskedDestination: masked
|
|
1590
|
+
});
|
|
1591
|
+
} catch (err) {
|
|
1592
|
+
this.setIdentityRestore({
|
|
1593
|
+
phase: "error",
|
|
1594
|
+
message: err instanceof Error ? err.message : "Could not send a verification code."
|
|
1595
|
+
});
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
/** Submit the code: POST `/internal/identity/verify-otp`, then resolve on success. */
|
|
1599
|
+
async verifyIdentityOtp(code) {
|
|
1600
|
+
const state = this.identityRestore;
|
|
1601
|
+
const trimmed = code.trim();
|
|
1602
|
+
if (!trimmed || state.phase !== "awaiting_code") return;
|
|
1603
|
+
const { email, channel } = state;
|
|
1604
|
+
if (!this.sessionId) {
|
|
1605
|
+
this.setIdentityRestore({
|
|
1606
|
+
phase: "error",
|
|
1607
|
+
message: "No active session to verify against."
|
|
1608
|
+
});
|
|
1609
|
+
return;
|
|
1610
|
+
}
|
|
1611
|
+
this.setIdentityRestore({
|
|
1612
|
+
phase: "verifying",
|
|
1613
|
+
email,
|
|
1614
|
+
channel
|
|
1615
|
+
});
|
|
1616
|
+
try {
|
|
1617
|
+
const json = await this.postInternal("/internal/identity/verify-otp", {
|
|
1618
|
+
sessionId: this.sessionId,
|
|
1619
|
+
email,
|
|
1620
|
+
code: trimmed
|
|
1621
|
+
});
|
|
1622
|
+
if (json.event === "otp_verified") {
|
|
1623
|
+
this.store.getState().setVerifiedEmail(email, this.sessionId);
|
|
1624
|
+
await this.resolveIdentity(email);
|
|
1625
|
+
} else if (json.event === "otp_invalid") {
|
|
1626
|
+
const remaining = typeof json.attemptsRemaining === "number" ? json.attemptsRemaining : void 0;
|
|
1627
|
+
this.setIdentityRestore({
|
|
1628
|
+
phase: "awaiting_code",
|
|
1629
|
+
email,
|
|
1630
|
+
channel,
|
|
1631
|
+
error: "That code was incorrect.",
|
|
1632
|
+
attemptsRemaining: remaining
|
|
1633
|
+
});
|
|
1634
|
+
} else this.setIdentityRestore({
|
|
1635
|
+
phase: "error",
|
|
1636
|
+
message: "Verification failed."
|
|
1637
|
+
});
|
|
1638
|
+
} catch (err) {
|
|
1639
|
+
this.setIdentityRestore({
|
|
1640
|
+
phase: "error",
|
|
1641
|
+
message: err instanceof Error ? err.message : "Verification failed."
|
|
1642
|
+
});
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
/** Resolve the verified identity → restorable conversations via POST `/internal/identity/resolve`. */
|
|
1646
|
+
async resolveIdentity(email) {
|
|
1647
|
+
if (!this.sessionId) return;
|
|
1648
|
+
this.setIdentityRestore({
|
|
1649
|
+
phase: "resolving",
|
|
1650
|
+
email
|
|
1651
|
+
});
|
|
1652
|
+
try {
|
|
1653
|
+
const json = await this.postInternal("/internal/identity/resolve", {
|
|
1654
|
+
sessionId: this.sessionId,
|
|
1655
|
+
email
|
|
1656
|
+
});
|
|
1657
|
+
if (json.resolved !== true) {
|
|
1658
|
+
this.setIdentityRestore({
|
|
1659
|
+
phase: "resolved",
|
|
1660
|
+
email,
|
|
1661
|
+
conversations: []
|
|
1662
|
+
});
|
|
1663
|
+
return;
|
|
1664
|
+
}
|
|
1665
|
+
const raw = json.conversations;
|
|
1666
|
+
const conversations = Array.isArray(raw) ? raw.map((c) => {
|
|
1667
|
+
if (!c || typeof c !== "object") return null;
|
|
1668
|
+
const o = c;
|
|
1669
|
+
const conversationId = typeof o.conversationId === "string" ? o.conversationId : "";
|
|
1670
|
+
const sessionId = typeof o.sessionId === "string" ? o.sessionId : "";
|
|
1671
|
+
if (!sessionId) return null;
|
|
1672
|
+
return {
|
|
1673
|
+
conversationId,
|
|
1674
|
+
sessionId,
|
|
1675
|
+
lastActivityAt: typeof o.lastActivityAt === "string" ? o.lastActivityAt : void 0,
|
|
1676
|
+
preview: typeof o.preview === "string" ? o.preview : void 0
|
|
1677
|
+
};
|
|
1678
|
+
}).filter((c) => c !== null) : [];
|
|
1679
|
+
this.setIdentityRestore({
|
|
1680
|
+
phase: "resolved",
|
|
1681
|
+
email,
|
|
1682
|
+
conversations
|
|
1683
|
+
});
|
|
1684
|
+
} catch (err) {
|
|
1685
|
+
this.setIdentityRestore({
|
|
1686
|
+
phase: "error",
|
|
1687
|
+
message: err instanceof Error ? err.message : "Could not load your chats."
|
|
1688
|
+
});
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
/**
|
|
1692
|
+
* Replay a chosen restorable conversation: point the live session at its
|
|
1693
|
+
* sessionId, hydrate its transcript (get_session + get_messages), and persist
|
|
1694
|
+
* the new pointer so the next `sendMessage` continues it.
|
|
1695
|
+
*/
|
|
1696
|
+
async restoreConversation(sessionId) {
|
|
1697
|
+
if (!this.client) await this.ensureClient();
|
|
1698
|
+
const proven = this.sessionId ? this.verifiedEmailForSession(this.sessionId) : null;
|
|
1699
|
+
if (await this.tryResume(sessionId)) {
|
|
1700
|
+
this.store.getState().setSessionId(sessionId);
|
|
1701
|
+
if (proven) this.store.getState().setVerifiedEmail(proven, sessionId);
|
|
1702
|
+
this.setIdentityRestore({ phase: "idle" });
|
|
1703
|
+
this.setStatus("ready");
|
|
1704
|
+
} else this.setIdentityRestore({
|
|
1705
|
+
phase: "error",
|
|
1706
|
+
message: "That conversation is no longer available."
|
|
1707
|
+
});
|
|
1708
|
+
}
|
|
1709
|
+
/** Dismiss the cross-device restore panel. */
|
|
1710
|
+
cancelIdentityRestore() {
|
|
1711
|
+
this.setIdentityRestore({ phase: "idle" });
|
|
1712
|
+
}
|
|
856
1713
|
/** Tear down the underlying client. */
|
|
857
1714
|
disconnect() {
|
|
858
1715
|
this.client?.disconnect("widget closed");
|
|
859
1716
|
this.client = null;
|
|
860
1717
|
this.sessionId = null;
|
|
861
1718
|
this.activeRequestId = null;
|
|
1719
|
+
this.resumeAttempted = false;
|
|
862
1720
|
this.setInterrupt(null);
|
|
863
1721
|
this.setStatus("closed");
|
|
864
1722
|
}
|
|
@@ -874,6 +1732,305 @@ var SmoothAgentChat = (function(exports) {
|
|
|
874
1732
|
*/
|
|
875
1733
|
const SMOOTH_LOGO_SVG = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<svg id=\"Layer_1\" data-name=\"Layer 1\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" viewBox=\"0 0 550 135\">\n <defs>\n <style>\n .cls-1 {\n fill: url(#linear-gradient-3);\n }\n\n .cls-2 {\n fill: url(#linear-gradient-2);\n }\n\n .cls-3 {\n fill: url(#linear-gradient);\n fill-rule: evenodd;\n }\n </style>\n <linearGradient id=\"linear-gradient\" x1=\"115.59\" y1=\"112.81\" x2=\"25.08\" y2=\"22.3\" gradientUnits=\"userSpaceOnUse\">\n <stop offset=\".3\" stop-color=\"#f49f0a\"/>\n <stop offset=\".79\" stop-color=\"#fb7a4d\"/>\n <stop offset=\"1\" stop-color=\"#ff6b6c\"/>\n </linearGradient>\n <linearGradient id=\"linear-gradient-2\" x1=\"360.91\" y1=\"152.01\" x2=\"202.32\" y2=\"-6.59\" xlink:href=\"#linear-gradient\"/>\n <linearGradient id=\"linear-gradient-3\" x1=\"443.91\" y1=\"30.15\" x2=\"531.36\" y2=\"117.59\" gradientUnits=\"userSpaceOnUse\">\n <stop offset=\".43\" stop-color=\"#00a6a6\"/>\n <stop offset=\"1\" stop-color=\"#1238dd\"/>\n </linearGradient>\n </defs>\n <path class=\"cls-3\" d=\"M48.28,14.96c-12.39,5.21-22.54,14.64-28.65,26.61-6.12,11.97-7.8,25.72-4.77,38.81,3.04,13.09,10.6,24.69,21.36,32.75,10.76,8.06,24.02,12.05,37.44,11.28,13.42-.77,26.13-6.26,35.9-15.5,9.76-9.24,15.95-21.63,17.46-34.99,1.51-13.36-1.74-26.82-9.19-38.01-1.07-1.61-.64-3.78.97-4.85,1.61-1.07,3.78-.64,4.85.97,8.36,12.56,12.02,27.68,10.32,42.67-1.7,15-8.64,28.91-19.61,39.28-10.96,10.37-25.24,16.54-40.31,17.4-15.07.87-29.96-3.62-42.04-12.66-12.08-9.05-20.58-22.07-23.99-36.77-3.41-14.7-1.51-30.14,5.35-43.58,6.87-13.44,18.26-24.02,32.17-29.87,13.91-5.85,29.44-6.6,43.85-2.11,1.85.57,2.88,2.54,2.3,4.38-.57,1.85-2.54,2.88-4.38,2.3-12.83-4-26.67-3.33-39.06,1.88ZM111.39,19.75c0,2.07-1.68,3.75-3.75,3.75s-3.75-1.68-3.75-3.75,1.68-3.75,3.75-3.75,3.75,1.68,3.75,3.75ZM64.64,59.93c0,1.91,2.39,2.56,7.69,3.88,3.89.97,6.6,2.18,8.15,3.63,1.53,1.45,2.29,3.53,2.29,6.25,0,3.57-1.03,6.26-3.11,8.08-2.07,1.82-5.09,2.73-9.09,2.73h-9.6c-1.97,0-3.57-1.6-3.59-3.57-.01-1.99,1.6-3.61,3.59-3.61h9.41c3.15-.12,4.79-.95,4.91-2.47,0-1.3-1.03-2.21-3.07-2.73-6.91-1.72-11.11-3.44-12.6-5.15-1.48-1.71-2.23-3.77-2.23-6.19,0-6.59,3.2-9.85,9.59-9.8h10.77c1.99,0,3.6,1.61,3.6,3.59s-1.61,3.59-3.6,3.59h-9.69c-1.83,0-3.43.06-3.43,1.77Z\"/>\n <path class=\"cls-2\" d=\"M205.52,48.44h-8.86c-.44-3.75-2.23-6.65-5.38-8.72-3.16-2.07-7.03-3.1-11.6-3.1h0c-3.35,0-6.27.54-8.78,1.62-2.49,1.09-4.44,2.59-5.84,4.48-1.39,1.89-2.08,4.05-2.08,6.46h0c0,2.01.49,3.75,1.46,5.2.97,1.44,2.22,2.63,3.74,3.58,1.53.95,3.13,1.72,4.8,2.32,1.68.6,3.22,1.09,4.62,1.46h0l7.68,2.06c1.97.52,4.17,1.23,6.6,2.14,2.43.92,4.75,2.16,6.98,3.72,2.23,1.56,4.07,3.56,5.52,6,1.45,2.44,2.18,5.43,2.18,8.98h0c0,4.08-1.07,7.77-3.2,11.08-2.12,3.29-5.22,5.91-9.3,7.86-4.08,1.95-9.02,2.92-14.82,2.92h0c-5.43,0-10.11-.87-14.06-2.62-3.95-1.75-7.05-4.19-9.3-7.32-2.25-3.12-3.53-6.75-3.84-10.88h9.46c.25,2.85,1.22,5.21,2.9,7.06,1.69,1.87,3.83,3.25,6.42,4.14,2.6.89,5.41,1.34,8.42,1.34h0c3.49,0,6.63-.57,9.4-1.72,2.79-1.13,4.99-2.73,6.62-4.8,1.63-2.05,2.44-4.46,2.44-7.22h0c0-2.51-.7-4.55-2.1-6.12-1.41-1.57-3.26-2.85-5.54-3.84-2.29-.99-4.77-1.85-7.44-2.58h0l-9.3-2.66c-5.91-1.71-10.59-4.13-14.04-7.28-3.44-3.16-5.16-7.29-5.16-12.38h0c0-4.23,1.15-7.93,3.46-11.1,2.29-3.16,5.39-5.62,9.3-7.38,3.91-1.76,8.27-2.64,13.08-2.64h0c4.88,0,9.21.87,13,2.6,3.8,1.73,6.81,4.11,9.04,7.12,2.23,3,3.4,6.41,3.52,10.22h0ZM229.16,105.18h-8.72v-56.74h8.42v8.86h.74c1.19-3.03,3.1-5.38,5.74-7.06,2.63-1.69,5.79-2.54,9.48-2.54h0c3.75,0,6.87.85,9.36,2.54,2.51,1.68,4.46,4.03,5.86,7.06h.58c1.45-2.92,3.63-5.25,6.54-7,2.91-1.73,6.39-2.6,10.46-2.6h0c5.07,0,9.21,1.58,12.44,4.74,3.23,3.17,4.84,8.09,4.84,14.76h0v37.98h-8.72v-37.98c0-4.19-1.14-7.18-3.42-8.98-2.29-1.79-4.99-2.68-8.1-2.68h0c-3.99,0-7.07,1.2-9.26,3.6-2.2,2.4-3.3,5.43-3.3,9.1h0v36.94h-8.86v-38.86c0-3.23-1.05-5.83-3.14-7.82-2.09-1.97-4.79-2.96-8.08-2.96h0c-2.27,0-4.38.6-6.34,1.8-1.96,1.21-3.53,2.88-4.72,5-1.2,2.13-1.8,4.59-1.8,7.38h0v35.46ZM333.9,106.36h0c-5.12,0-9.61-1.22-13.46-3.66-3.85-2.44-6.86-5.85-9.02-10.24-2.15-4.37-3.22-9.49-3.22-15.36h0c0-5.91,1.07-11.07,3.22-15.48,2.16-4.4,5.17-7.82,9.02-10.26,3.85-2.44,8.34-3.66,13.46-3.66h0c5.12,0,9.61,1.22,13.46,3.66,3.85,2.44,6.86,5.86,9.02,10.26,2.15,4.41,3.22,9.57,3.22,15.48h0c0,5.87-1.07,10.99-3.22,15.36-2.16,4.39-5.17,7.8-9.02,10.24-3.85,2.44-8.34,3.66-13.46,3.66ZM333.9,98.52h0c3.89,0,7.09-.99,9.6-2.98,2.52-2,4.38-4.63,5.58-7.88,1.21-3.25,1.82-6.77,1.82-10.56h0c0-3.79-.61-7.32-1.82-10.6-1.2-3.27-3.06-5.91-5.58-7.94-2.51-2.01-5.71-3.02-9.6-3.02h0c-3.89,0-7.09,1.01-9.6,3.02-2.51,2.03-4.37,4.67-5.58,7.94-1.2,3.28-1.8,6.81-1.8,10.6h0c0,3.79.6,7.31,1.8,10.56,1.21,3.25,3.07,5.88,5.58,7.88,2.51,1.99,5.71,2.98,9.6,2.98ZM395.94,106.36h0c-5.12,0-9.61-1.22-13.46-3.66-3.85-2.44-6.85-5.85-9-10.24-2.16-4.37-3.24-9.49-3.24-15.36h0c0-5.91,1.08-11.07,3.24-15.48,2.15-4.4,5.15-7.82,9-10.26,3.85-2.44,8.34-3.66,13.46-3.66h0c5.12,0,9.61,1.22,13.46,3.66,3.85,2.44,6.86,5.86,9.02,10.26,2.16,4.41,3.24,9.57,3.24,15.48h0c0,5.87-1.08,10.99-3.24,15.36-2.16,4.39-5.17,7.8-9.02,10.24-3.85,2.44-8.34,3.66-13.46,3.66ZM395.94,98.52h0c3.89,0,7.09-.99,9.6-2.98,2.52-2,4.38-4.63,5.58-7.88,1.21-3.25,1.82-6.77,1.82-10.56h0c0-3.79-.61-7.32-1.82-10.6-1.2-3.27-3.06-5.91-5.58-7.94-2.51-2.01-5.71-3.02-9.6-3.02h0c-3.88,0-7.08,1.01-9.6,3.02-2.51,2.03-4.37,4.67-5.58,7.94-1.2,3.28-1.8,6.81-1.8,10.6h0c0,3.79.6,7.31,1.8,10.56,1.21,3.25,3.07,5.88,5.58,7.88,2.52,1.99,5.72,2.98,9.6,2.98Z\"/>\n <path class=\"cls-1\" d=\"M467.88,48.02v13.28h-35.79v-13.28h35.79ZM439.68,34.38h17.89v53.42c0,1.5.36,2.62,1.08,3.36.72.74,1.88,1.1,3.49,1.1.62,0,1.48-.07,2.59-.21,1.11-.14,1.91-.27,2.38-.41l2.31,13.02c-2.02.58-3.97.97-5.84,1.18-1.88.21-3.66.31-5.33.31-6.08,0-10.7-1.43-13.84-4.28-3.15-2.85-4.72-7.01-4.72-12.48v-55.01ZM506.59,72.63v32.71h-17.89V28.95h17.53v33.53h-1.13c1.4-4.55,3.6-8.21,6.59-11,2.99-2.79,7.01-4.18,12.07-4.18,4,0,7.48.89,10.46,2.67,2.97,1.78,5.28,4.29,6.92,7.54,1.64,3.25,2.46,7.02,2.46,11.33v36.5h-17.89v-33.02c0-3.21-.82-5.73-2.46-7.56-1.64-1.83-3.93-2.74-6.87-2.74-1.92,0-3.62.42-5.1,1.26-1.49.84-2.64,2.04-3.46,3.61-.82,1.57-1.23,3.49-1.23,5.74Z\"/>\n</svg>";
|
|
876
1734
|
//#endregion
|
|
1735
|
+
//#region src/markdown.ts
|
|
1736
|
+
/**
|
|
1737
|
+
* A tiny, safe-by-default Markdown → HTML renderer for the chat widget.
|
|
1738
|
+
*
|
|
1739
|
+
* ## Why a hand-rolled renderer (and not markdown-it / snarkdown)?
|
|
1740
|
+
*
|
|
1741
|
+
* The widget renders **untrusted** text in two places: the assistant's reply
|
|
1742
|
+
* (LLM output, which can echo attacker-supplied content) and citation snippets
|
|
1743
|
+
* (raw scraped page chunks). Today both are written via `textContent`, so
|
|
1744
|
+
* `**bold**`, numbered lists, and `[links](url)` show up literally. We want
|
|
1745
|
+
* them rendered — without re-opening the XSS hole that `textContent` was
|
|
1746
|
+
* guarding against.
|
|
1747
|
+
*
|
|
1748
|
+
* markdown-it with `html:false` is safe-by-default but ships ~30 kB min into
|
|
1749
|
+
* what is an embeddable **global** bundle, where every kilobyte is on the host
|
|
1750
|
+
* page's critical path. snarkdown is ~1 kB but emits raw HTML, so it would
|
|
1751
|
+
* require bolting on a separate sanitizer. Instead, this renderer is
|
|
1752
|
+
* **safe-by-construction**:
|
|
1753
|
+
*
|
|
1754
|
+
* 1. It is a *tokenizer*, not an HTML passthrough. It only ever emits a
|
|
1755
|
+
* fixed allowlist of tags (`p`, `br`, `strong`, `em`, `ul`/`ol`/`li`,
|
|
1756
|
+
* `code`/`pre`, `a`, `blockquote`). There is no code path that copies a
|
|
1757
|
+
* tag out of the input — a literal `<script>` in the input is treated as
|
|
1758
|
+
* plain text.
|
|
1759
|
+
* 2. **Every** text run is HTML-escaped via {@link escapeHtml} before it
|
|
1760
|
+
* reaches the output. Raw `<`, `>`, `&`, `"`, `'` can never become markup.
|
|
1761
|
+
* 3. **Images are dropped entirely** — `` renders as its alt text,
|
|
1762
|
+
* no `<img>` is ever produced (a scraped tracking pixel must not load).
|
|
1763
|
+
* 4. **Links** are gated through {@link safeHttpUrl}: only absolute `http(s)`
|
|
1764
|
+
* URLs become anchors (with `target="_blank"` + a hardened `rel`);
|
|
1765
|
+
* `javascript:`/`data:`/relative/etc. fall back to plain (escaped) text.
|
|
1766
|
+
* 5. **Headings** (`#`..`######`) are *downgraded* to bold lines — a full
|
|
1767
|
+
* `<h1>` is far too large inside a chat bubble or citation card.
|
|
1768
|
+
*
|
|
1769
|
+
* The output is a string of HTML that is only ever assigned to `innerHTML` of
|
|
1770
|
+
* an element the caller controls; because of (1)–(4) it can only contain the
|
|
1771
|
+
* allowlisted, attribute-sanitized tags.
|
|
1772
|
+
*
|
|
1773
|
+
* Supported subset (deliberately small):
|
|
1774
|
+
* - Paragraphs (blank-line separated) and hard/soft line breaks
|
|
1775
|
+
* - `**bold**` / `__bold__`, `*italic*` / `_italic_`
|
|
1776
|
+
* - `` `inline code` `` and fenced ``` ```code blocks``` ```
|
|
1777
|
+
* - `- ` / `* ` / `+ ` unordered lists, `1.` ordered lists
|
|
1778
|
+
* - `> ` blockquotes
|
|
1779
|
+
* - `[text](http(s)://url)` links (images dropped to alt text)
|
|
1780
|
+
* - `#`..`######` headings → bold line
|
|
1781
|
+
*/
|
|
1782
|
+
/** Escape the five HTML-significant characters so a text run can never be markup. */
|
|
1783
|
+
function escapeHtml(value) {
|
|
1784
|
+
return value.replace(/[&<>"']/g, (c) => {
|
|
1785
|
+
switch (c) {
|
|
1786
|
+
case "&": return "&";
|
|
1787
|
+
case "<": return "<";
|
|
1788
|
+
case ">": return ">";
|
|
1789
|
+
case "\"": return """;
|
|
1790
|
+
default: return "'";
|
|
1791
|
+
}
|
|
1792
|
+
});
|
|
1793
|
+
}
|
|
1794
|
+
/**
|
|
1795
|
+
* Return `url` only if it is a valid absolute `http(s)` URL, else `null`.
|
|
1796
|
+
*
|
|
1797
|
+
* SECURITY: link targets here originate from untrusted content (LLM output /
|
|
1798
|
+
* scraped citation chunks). Allowing an arbitrary string as an `href` permits
|
|
1799
|
+
* `javascript:`/`data:`/`vbscript:` URLs that execute on click — a stored-XSS
|
|
1800
|
+
* vector. Only absolute http(s) links are rendered as anchors; anything else
|
|
1801
|
+
* falls back to plain text upstream.
|
|
1802
|
+
*/
|
|
1803
|
+
function safeHttpUrl(url) {
|
|
1804
|
+
if (!url) return null;
|
|
1805
|
+
try {
|
|
1806
|
+
const parsed = new URL(url);
|
|
1807
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.href : null;
|
|
1808
|
+
} catch {
|
|
1809
|
+
return null;
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
/**
|
|
1813
|
+
* Render the inline span grammar of a single line/segment to safe HTML.
|
|
1814
|
+
*
|
|
1815
|
+
* Order matters: code spans are extracted first (their contents are *not*
|
|
1816
|
+
* further parsed), then images are stripped to alt text, then links, then
|
|
1817
|
+
* emphasis. Every literal text run is escaped on the way out.
|
|
1818
|
+
*/
|
|
1819
|
+
function renderInline(input) {
|
|
1820
|
+
let out = "";
|
|
1821
|
+
let i = 0;
|
|
1822
|
+
const n = input.length;
|
|
1823
|
+
let buf = "";
|
|
1824
|
+
const flush = () => {
|
|
1825
|
+
if (buf) {
|
|
1826
|
+
out += escapeHtml(buf);
|
|
1827
|
+
buf = "";
|
|
1828
|
+
}
|
|
1829
|
+
};
|
|
1830
|
+
while (i < n) {
|
|
1831
|
+
const ch = input[i];
|
|
1832
|
+
if (ch === "`") {
|
|
1833
|
+
const end = input.indexOf("`", i + 1);
|
|
1834
|
+
if (end > i) {
|
|
1835
|
+
flush();
|
|
1836
|
+
out += `<code>${escapeHtml(input.slice(i + 1, end))}</code>`;
|
|
1837
|
+
i = end + 1;
|
|
1838
|
+
continue;
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
if (ch === "!" && input[i + 1] === "[") {
|
|
1842
|
+
const m = imageAt(input, i);
|
|
1843
|
+
if (m) {
|
|
1844
|
+
flush();
|
|
1845
|
+
out += renderInline(m.alt);
|
|
1846
|
+
i = m.end;
|
|
1847
|
+
continue;
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
if (ch === "[") {
|
|
1851
|
+
const m = linkAt(input, i);
|
|
1852
|
+
if (m) {
|
|
1853
|
+
flush();
|
|
1854
|
+
const safe = safeHttpUrl(m.href);
|
|
1855
|
+
const inner = renderInline(m.text);
|
|
1856
|
+
if (safe) out += `<a href="${escapeHtml(safe)}" target="_blank" rel="noopener noreferrer nofollow">${inner}</a>`;
|
|
1857
|
+
else out += inner;
|
|
1858
|
+
i = m.end;
|
|
1859
|
+
continue;
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
if (ch === "*" && input[i + 1] === "*" || ch === "_" && input[i + 1] === "_") {
|
|
1863
|
+
const marker = ch + ch;
|
|
1864
|
+
const end = input.indexOf(marker, i + 2);
|
|
1865
|
+
if (end > i + 1) {
|
|
1866
|
+
flush();
|
|
1867
|
+
out += `<strong>${renderInline(input.slice(i + 2, end))}</strong>`;
|
|
1868
|
+
i = end + 2;
|
|
1869
|
+
continue;
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
if (ch === "*" || ch === "_") {
|
|
1873
|
+
const end = input.indexOf(ch, i + 1);
|
|
1874
|
+
if (end > i + 1 && input[i + 1] !== ch) {
|
|
1875
|
+
flush();
|
|
1876
|
+
out += `<em>${renderInline(input.slice(i + 1, end))}</em>`;
|
|
1877
|
+
i = end + 1;
|
|
1878
|
+
continue;
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
buf += ch;
|
|
1882
|
+
i++;
|
|
1883
|
+
}
|
|
1884
|
+
flush();
|
|
1885
|
+
return out;
|
|
1886
|
+
}
|
|
1887
|
+
/** Parse a `[text](href)` link starting at `start` (`input[start] === '['`). */
|
|
1888
|
+
function linkAt(input, start) {
|
|
1889
|
+
const close = matchBracket(input, start);
|
|
1890
|
+
if (close < 0 || input[close + 1] !== "(") return null;
|
|
1891
|
+
const paren = input.indexOf(")", close + 2);
|
|
1892
|
+
if (paren < 0) return null;
|
|
1893
|
+
return {
|
|
1894
|
+
text: input.slice(start + 1, close),
|
|
1895
|
+
href: input.slice(close + 2, paren).trim().split(/\s+/)[0] ?? "",
|
|
1896
|
+
end: paren + 1
|
|
1897
|
+
};
|
|
1898
|
+
}
|
|
1899
|
+
/** Parse a `` image starting at `start` (`input[start] === '!'`). */
|
|
1900
|
+
function imageAt(input, start) {
|
|
1901
|
+
const link = linkAt(input, start + 1);
|
|
1902
|
+
if (!link) return null;
|
|
1903
|
+
return {
|
|
1904
|
+
alt: link.text,
|
|
1905
|
+
end: link.end
|
|
1906
|
+
};
|
|
1907
|
+
}
|
|
1908
|
+
/** Find the matching `]` for a `[` at `open`, honoring one level of nesting. */
|
|
1909
|
+
function matchBracket(input, open) {
|
|
1910
|
+
let depth = 0;
|
|
1911
|
+
for (let i = open; i < input.length; i++) {
|
|
1912
|
+
const c = input[i];
|
|
1913
|
+
if (c === "[") depth++;
|
|
1914
|
+
else if (c === "]") {
|
|
1915
|
+
depth--;
|
|
1916
|
+
if (depth === 0) return i;
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
return -1;
|
|
1920
|
+
}
|
|
1921
|
+
const UL_RE = /^\s*[-*+]\s+(.*)$/;
|
|
1922
|
+
const OL_RE = /^\s*\d+[.)]\s+(.*)$/;
|
|
1923
|
+
const HEADING_RE = /^\s{0,3}(#{1,6})\s+(.*)$/;
|
|
1924
|
+
const QUOTE_RE = /^\s*>\s?(.*)$/;
|
|
1925
|
+
const FENCE_RE = /^\s*(`{3,}|~{3,})\s*(.*)$/;
|
|
1926
|
+
/**
|
|
1927
|
+
* Render a full Markdown string to safe HTML.
|
|
1928
|
+
*
|
|
1929
|
+
* @returns a string containing only the allowlisted tags described in the
|
|
1930
|
+
* module doc. Safe to assign to `innerHTML` of a caller-owned element.
|
|
1931
|
+
*/
|
|
1932
|
+
function renderMarkdown(src) {
|
|
1933
|
+
const lines = src.replace(/\r\n?/g, "\n").split("\n");
|
|
1934
|
+
const out = [];
|
|
1935
|
+
let i = 0;
|
|
1936
|
+
while (i < lines.length) {
|
|
1937
|
+
const line = lines[i];
|
|
1938
|
+
const fence = FENCE_RE.exec(line);
|
|
1939
|
+
if (fence) {
|
|
1940
|
+
const marker = fence[1];
|
|
1941
|
+
const body = [];
|
|
1942
|
+
i++;
|
|
1943
|
+
while (i < lines.length && !lines[i].trimStart().startsWith(marker)) {
|
|
1944
|
+
body.push(lines[i]);
|
|
1945
|
+
i++;
|
|
1946
|
+
}
|
|
1947
|
+
if (i < lines.length) i++;
|
|
1948
|
+
out.push(`<pre><code>${escapeHtml(body.join("\n"))}</code></pre>`);
|
|
1949
|
+
continue;
|
|
1950
|
+
}
|
|
1951
|
+
if (line.trim() === "") {
|
|
1952
|
+
i++;
|
|
1953
|
+
continue;
|
|
1954
|
+
}
|
|
1955
|
+
const heading = HEADING_RE.exec(line);
|
|
1956
|
+
if (heading) {
|
|
1957
|
+
out.push(`<p><strong>${renderInline(heading[2])}</strong></p>`);
|
|
1958
|
+
i++;
|
|
1959
|
+
continue;
|
|
1960
|
+
}
|
|
1961
|
+
if (UL_RE.test(line) || OL_RE.test(line)) {
|
|
1962
|
+
const ordered = OL_RE.test(line) && !UL_RE.test(line);
|
|
1963
|
+
const re = ordered ? OL_RE : UL_RE;
|
|
1964
|
+
const items = [];
|
|
1965
|
+
while (i < lines.length) {
|
|
1966
|
+
const m = re.exec(lines[i]);
|
|
1967
|
+
if (!m) break;
|
|
1968
|
+
items.push(`<li>${renderInline(m[1])}</li>`);
|
|
1969
|
+
i++;
|
|
1970
|
+
}
|
|
1971
|
+
out.push(`<${ordered ? "ol" : "ul"}>${items.join("")}</${ordered ? "ol" : "ul"}>`);
|
|
1972
|
+
continue;
|
|
1973
|
+
}
|
|
1974
|
+
if (QUOTE_RE.test(line)) {
|
|
1975
|
+
const quoted = [];
|
|
1976
|
+
while (i < lines.length) {
|
|
1977
|
+
const m = QUOTE_RE.exec(lines[i]);
|
|
1978
|
+
if (!m) break;
|
|
1979
|
+
quoted.push(m[1]);
|
|
1980
|
+
i++;
|
|
1981
|
+
}
|
|
1982
|
+
out.push(`<blockquote>${renderInline(quoted.join("\n")).replace(/\n/g, "<br>")}</blockquote>`);
|
|
1983
|
+
continue;
|
|
1984
|
+
}
|
|
1985
|
+
const para = [];
|
|
1986
|
+
while (i < lines.length) {
|
|
1987
|
+
const l = lines[i];
|
|
1988
|
+
if (l.trim() === "" || FENCE_RE.test(l) || HEADING_RE.test(l) || UL_RE.test(l) || OL_RE.test(l) || QUOTE_RE.test(l)) break;
|
|
1989
|
+
para.push(l);
|
|
1990
|
+
i++;
|
|
1991
|
+
}
|
|
1992
|
+
out.push(`<p>${renderInline(para.join("\n")).replace(/\n/g, "<br>")}</p>`);
|
|
1993
|
+
}
|
|
1994
|
+
return out.join("");
|
|
1995
|
+
}
|
|
1996
|
+
const SNIPPET_MAX = 260;
|
|
1997
|
+
/**
|
|
1998
|
+
* Clean a raw scraped citation snippet into a short, readable excerpt.
|
|
1999
|
+
*
|
|
2000
|
+
* Scraped chunks frequently begin with page boilerplate — a logo image wrapped
|
|
2001
|
+
* in a link, standalone nav, repeated whitespace — e.g.
|
|
2002
|
+
* `[](…) # Our Work We build…`. The source itself is already linked
|
|
2003
|
+
* from the citation card, so the snippet only needs to be a clean teaser.
|
|
2004
|
+
*
|
|
2005
|
+
* Steps:
|
|
2006
|
+
* 1. Strip a leading image / logo-link (`[](…)` or ``).
|
|
2007
|
+
* 2. Drop a leading standalone heading marker (`#`/`##`).
|
|
2008
|
+
* 3. Collapse all runs of whitespace to single spaces.
|
|
2009
|
+
* 4. Truncate to ~{@link SNIPPET_MAX} chars at a word boundary, adding `…`.
|
|
2010
|
+
*
|
|
2011
|
+
* The result is still rendered through {@link renderMarkdown} downstream, so any
|
|
2012
|
+
* remaining inline markup (bold/links) stays safe.
|
|
2013
|
+
*/
|
|
2014
|
+
function cleanCitationSnippet(raw) {
|
|
2015
|
+
let s = raw ?? "";
|
|
2016
|
+
let changed = true;
|
|
2017
|
+
while (changed) {
|
|
2018
|
+
changed = false;
|
|
2019
|
+
const before = s;
|
|
2020
|
+
s = s.replace(/^\s*\[!\[[^\]]*\]\([^)]*\)\]\([^)]*\)\s*/, "");
|
|
2021
|
+
s = s.replace(/^\s*!\[[^\]]*\]\([^)]*\)\s*/, "");
|
|
2022
|
+
s = s.replace(/^\s*#{1,6}\s+/, "");
|
|
2023
|
+
if (s !== before) changed = true;
|
|
2024
|
+
}
|
|
2025
|
+
s = s.replace(/\s+/g, " ").trim();
|
|
2026
|
+
if (s.length > SNIPPET_MAX) {
|
|
2027
|
+
const cut = s.slice(0, SNIPPET_MAX);
|
|
2028
|
+
const lastSpace = cut.lastIndexOf(" ");
|
|
2029
|
+
s = (lastSpace > SNIPPET_MAX * .6 ? cut.slice(0, lastSpace) : cut).trimEnd() + "…";
|
|
2030
|
+
}
|
|
2031
|
+
return s;
|
|
2032
|
+
}
|
|
2033
|
+
//#endregion
|
|
877
2034
|
//#region src/styles.ts
|
|
878
2035
|
/**
|
|
879
2036
|
* Render the widget's scoped stylesheet — the "Aurora Glass" design system.
|
|
@@ -1198,6 +2355,50 @@ var SmoothAgentChat = (function(exports) {
|
|
|
1198
2355
|
}
|
|
1199
2356
|
@keyframes sac-blink { to { opacity: 0 } }
|
|
1200
2357
|
|
|
2358
|
+
/* ─────────────── Rendered markdown (assistant bubbles / snippets) ─────────── */
|
|
2359
|
+
/* The renderer (markdown.ts) emits a small allowlisted set of tags; these rules
|
|
2360
|
+
keep them legible inside the tight Aurora-Glass bubble + citation card. */
|
|
2361
|
+
/* Block-level markdown drives its own spacing/wrapping, so opt out of the
|
|
2362
|
+
bubble's pre-wrap (which would otherwise add stray blank lines). */
|
|
2363
|
+
.bubble.md { white-space: normal; }
|
|
2364
|
+
.md > :first-child { margin-top: 0; }
|
|
2365
|
+
.md > :last-child { margin-bottom: 0; }
|
|
2366
|
+
.md p { margin: 0 0 8px; }
|
|
2367
|
+
.md ul, .md ol { margin: 6px 0 8px; padding-left: 20px; }
|
|
2368
|
+
.md li { margin: 2px 0; }
|
|
2369
|
+
.md li::marker { color: color-mix(in srgb, var(--sac-primary) 75%, transparent); }
|
|
2370
|
+
.md a {
|
|
2371
|
+
color: color-mix(in srgb, var(--sac-primary) 92%, #fff);
|
|
2372
|
+
text-decoration: underline;
|
|
2373
|
+
text-underline-offset: 2px;
|
|
2374
|
+
word-break: break-word;
|
|
2375
|
+
}
|
|
2376
|
+
.md a:hover { text-decoration: none; }
|
|
2377
|
+
.md strong { font-weight: 700; }
|
|
2378
|
+
.md em { font-style: italic; }
|
|
2379
|
+
.md code {
|
|
2380
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
2381
|
+
font-size: .9em;
|
|
2382
|
+
padding: 1px 5px;
|
|
2383
|
+
border-radius: 5px;
|
|
2384
|
+
background: color-mix(in srgb, var(--sac-text) 10%, transparent);
|
|
2385
|
+
}
|
|
2386
|
+
.md pre {
|
|
2387
|
+
margin: 6px 0 8px;
|
|
2388
|
+
padding: 9px 11px;
|
|
2389
|
+
border-radius: 9px;
|
|
2390
|
+
overflow-x: auto;
|
|
2391
|
+
background: color-mix(in srgb, var(--sac-text) 9%, transparent);
|
|
2392
|
+
border: 1px solid color-mix(in srgb, var(--sac-text) 8%, transparent);
|
|
2393
|
+
}
|
|
2394
|
+
.md pre code { padding: 0; background: none; font-size: 12px; line-height: 1.45; }
|
|
2395
|
+
.md blockquote {
|
|
2396
|
+
margin: 6px 0;
|
|
2397
|
+
padding: 2px 0 2px 11px;
|
|
2398
|
+
border-left: 2px solid color-mix(in srgb, var(--sac-primary) 55%, transparent);
|
|
2399
|
+
color: color-mix(in srgb, var(--sac-text) 78%, transparent);
|
|
2400
|
+
}
|
|
2401
|
+
|
|
1201
2402
|
/* Full-page: center the conversation in a readable column. */
|
|
1202
2403
|
.panel.fullpage .messages { padding: 26px 20px; }
|
|
1203
2404
|
.panel.fullpage .row { max-width: 760px; width: 100%; margin-left: auto; margin-right: auto; }
|
|
@@ -1357,6 +2558,17 @@ var SmoothAgentChat = (function(exports) {
|
|
|
1357
2558
|
}
|
|
1358
2559
|
.pc-submit:hover { transform: translateY(-1px); }
|
|
1359
2560
|
.pc-submit:active { transform: scale(.98); }
|
|
2561
|
+
.pc-consents { display: flex; flex-direction: column; gap: 9px; margin-top: 2px; }
|
|
2562
|
+
.pc-consent { display: flex; align-items: flex-start; gap: 9px; cursor: pointer; }
|
|
2563
|
+
.pc-consent input {
|
|
2564
|
+
margin-top: 2px;
|
|
2565
|
+
width: 16px;
|
|
2566
|
+
height: 16px;
|
|
2567
|
+
flex: 0 0 auto;
|
|
2568
|
+
accent-color: var(--sac-primary);
|
|
2569
|
+
cursor: pointer;
|
|
2570
|
+
}
|
|
2571
|
+
.pc-consent span { font-size: 12px; line-height: 1.4; color: color-mix(in srgb, var(--sac-text) 72%, transparent); }
|
|
1360
2572
|
|
|
1361
2573
|
/* ─────────────────── Starter-prompt chips ─────────────────────────── */
|
|
1362
2574
|
.prompts { display: flex; flex-wrap: wrap; gap: 8px; margin: 2px 0 2px 35px; }
|
|
@@ -1435,6 +2647,61 @@ var SmoothAgentChat = (function(exports) {
|
|
|
1435
2647
|
.int-row .int-btn { flex: 1; }
|
|
1436
2648
|
.int-row .int-input + .int-btn { flex: 0 0 auto; }
|
|
1437
2649
|
.int-error { margin-top: 8px; font-size: 12px; color: #f87171; }
|
|
2650
|
+
.int-card { position: relative; }
|
|
2651
|
+
.int-close {
|
|
2652
|
+
position: absolute;
|
|
2653
|
+
top: 8px;
|
|
2654
|
+
right: 9px;
|
|
2655
|
+
border: none;
|
|
2656
|
+
background: transparent;
|
|
2657
|
+
color: color-mix(in srgb, var(--sac-text) 55%, transparent);
|
|
2658
|
+
font-size: 18px;
|
|
2659
|
+
line-height: 1;
|
|
2660
|
+
cursor: pointer;
|
|
2661
|
+
padding: 2px 4px;
|
|
2662
|
+
border-radius: 6px;
|
|
2663
|
+
transition: color .2s ease, background .2s ease;
|
|
2664
|
+
}
|
|
2665
|
+
.int-close:hover { color: var(--sac-text); background: color-mix(in srgb, var(--sac-text) 8%, transparent); }
|
|
2666
|
+
|
|
2667
|
+
/* ─────────────── Cross-device "Restore my chats" ──────────────────── */
|
|
2668
|
+
.restore-link {
|
|
2669
|
+
border: none;
|
|
2670
|
+
background: none;
|
|
2671
|
+
padding: 0;
|
|
2672
|
+
font: inherit;
|
|
2673
|
+
font-size: 10.5px;
|
|
2674
|
+
letter-spacing: .04em;
|
|
2675
|
+
color: color-mix(in srgb, var(--sac-primary) 80%, var(--sac-text));
|
|
2676
|
+
cursor: pointer;
|
|
2677
|
+
text-decoration: underline;
|
|
2678
|
+
text-underline-offset: 2px;
|
|
2679
|
+
}
|
|
2680
|
+
.restore-link:hover { color: var(--sac-primary); }
|
|
2681
|
+
.restore-list { display: flex; flex-direction: column; gap: 7px; margin-top: 9px; }
|
|
2682
|
+
.restore-item {
|
|
2683
|
+
display: flex;
|
|
2684
|
+
align-items: center;
|
|
2685
|
+
justify-content: space-between;
|
|
2686
|
+
gap: 10px;
|
|
2687
|
+
text-align: left;
|
|
2688
|
+
border: 1px solid color-mix(in srgb, var(--sac-border) 80%, transparent);
|
|
2689
|
+
background: var(--sac-bg);
|
|
2690
|
+
color: var(--sac-text);
|
|
2691
|
+
border-radius: 10px;
|
|
2692
|
+
padding: 9px 11px;
|
|
2693
|
+
font-family: inherit;
|
|
2694
|
+
font-size: 12.5px;
|
|
2695
|
+
cursor: pointer;
|
|
2696
|
+
transition: border-color .2s ease, background .2s ease, transform .2s ease;
|
|
2697
|
+
}
|
|
2698
|
+
.restore-item:hover {
|
|
2699
|
+
border-color: color-mix(in srgb, var(--sac-primary) 50%, transparent);
|
|
2700
|
+
background: color-mix(in srgb, var(--sac-primary) 8%, var(--sac-bg));
|
|
2701
|
+
transform: translateY(-1px);
|
|
2702
|
+
}
|
|
2703
|
+
.restore-preview { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
2704
|
+
.restore-when { flex: 0 0 auto; font-size: 11px; color: color-mix(in srgb, var(--sac-text) 55%, transparent); }
|
|
1438
2705
|
|
|
1439
2706
|
.hidden { display: none !important; }
|
|
1440
2707
|
|
|
@@ -1476,24 +2743,6 @@ var SmoothAgentChat = (function(exports) {
|
|
|
1476
2743
|
/** Tool-confirmation interrupt — a shield. */
|
|
1477
2744
|
shield: `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 3 5 6v5c0 4.4 3 7.2 7 8.5 4-1.3 7-4.1 7-8.5V6l-7-3Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/><path d="m9 11.5 2 2 4-4" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>`
|
|
1478
2745
|
};
|
|
1479
|
-
/**
|
|
1480
|
-
* Return `url` only if it is a valid absolute `http(s)` URL, else `null`.
|
|
1481
|
-
*
|
|
1482
|
-
* SECURITY: citation URLs originate from indexed content (web / GitHub
|
|
1483
|
-
* connectors), which can be attacker-influenceable. Assigning an arbitrary
|
|
1484
|
-
* string to `<a>.href` allows `javascript:`/`data:`/`vbscript:` URLs that
|
|
1485
|
-
* execute on click — a stored-XSS vector. Only http(s) links are rendered as
|
|
1486
|
-
* anchors; anything else falls back to plain text.
|
|
1487
|
-
*/
|
|
1488
|
-
function safeHttpUrl(url) {
|
|
1489
|
-
if (!url) return null;
|
|
1490
|
-
try {
|
|
1491
|
-
const parsed = new URL(url);
|
|
1492
|
-
return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.href : null;
|
|
1493
|
-
} catch {
|
|
1494
|
-
return null;
|
|
1495
|
-
}
|
|
1496
|
-
}
|
|
1497
2746
|
var SmoothAgentChatElement = class extends HTMLElement {
|
|
1498
2747
|
static get observedAttributes() {
|
|
1499
2748
|
return OBSERVED;
|
|
@@ -1510,8 +2759,12 @@ var SmoothAgentChat = (function(exports) {
|
|
|
1510
2759
|
_defineProperty(this, "userInfoSatisfied", false);
|
|
1511
2760
|
_defineProperty(this, "hasSent", false);
|
|
1512
2761
|
_defineProperty(this, "examplePrompts", []);
|
|
2762
|
+
_defineProperty(this, "greeting", "");
|
|
1513
2763
|
_defineProperty(this, "interrupt", null);
|
|
1514
2764
|
_defineProperty(this, "interruptEl", null);
|
|
2765
|
+
_defineProperty(this, "identityRestore", { phase: "idle" });
|
|
2766
|
+
_defineProperty(this, "allowChatRestore", true);
|
|
2767
|
+
_defineProperty(this, "gating", false);
|
|
1515
2768
|
_defineProperty(this, "panelEl", null);
|
|
1516
2769
|
_defineProperty(this, "launcherEl", null);
|
|
1517
2770
|
_defineProperty(this, "messagesEl", null);
|
|
@@ -1519,6 +2772,11 @@ var SmoothAgentChat = (function(exports) {
|
|
|
1519
2772
|
_defineProperty(this, "dotEl", null);
|
|
1520
2773
|
_defineProperty(this, "inputEl", null);
|
|
1521
2774
|
_defineProperty(this, "sendBtn", null);
|
|
2775
|
+
_defineProperty(this, "streamBubbleEl", null);
|
|
2776
|
+
_defineProperty(this, "streamMsgId", null);
|
|
2777
|
+
_defineProperty(this, "streamTarget", "");
|
|
2778
|
+
_defineProperty(this, "displayedLength", 0);
|
|
2779
|
+
_defineProperty(this, "rafId", 0);
|
|
1522
2780
|
this.root = this.attachShadow({ mode: "open" });
|
|
1523
2781
|
}
|
|
1524
2782
|
connectedCallback() {
|
|
@@ -1527,6 +2785,7 @@ var SmoothAgentChat = (function(exports) {
|
|
|
1527
2785
|
}
|
|
1528
2786
|
disconnectedCallback() {
|
|
1529
2787
|
this.mounted = false;
|
|
2788
|
+
this.resetReveal();
|
|
1530
2789
|
this.controller?.disconnect();
|
|
1531
2790
|
this.controller = null;
|
|
1532
2791
|
}
|
|
@@ -1552,7 +2811,7 @@ var SmoothAgentChat = (function(exports) {
|
|
|
1552
2811
|
openChat() {
|
|
1553
2812
|
this.open = true;
|
|
1554
2813
|
this.syncOpenState();
|
|
1555
|
-
this.controller?.connect().catch(() => {});
|
|
2814
|
+
if (!this.gating) this.controller?.connect().catch(() => {});
|
|
1556
2815
|
}
|
|
1557
2816
|
/** Collapse the chat panel back to the launcher. */
|
|
1558
2817
|
closeChat() {
|
|
@@ -1573,6 +2832,7 @@ var SmoothAgentChat = (function(exports) {
|
|
|
1573
2832
|
userName: this.overrides.userName,
|
|
1574
2833
|
userEmail: this.overrides.userEmail,
|
|
1575
2834
|
userPhone: this.overrides.userPhone,
|
|
2835
|
+
authContext: this.overrides.authContext,
|
|
1576
2836
|
placeholder: this.overrides.placeholder ?? this.getAttribute("placeholder") ?? void 0,
|
|
1577
2837
|
greeting: this.overrides.greeting ?? this.getAttribute("greeting") ?? void 0,
|
|
1578
2838
|
connectionErrorMessage: this.overrides.connectionErrorMessage,
|
|
@@ -1581,6 +2841,9 @@ var SmoothAgentChat = (function(exports) {
|
|
|
1581
2841
|
requireName: this.overrides.requireName,
|
|
1582
2842
|
requireEmail: this.overrides.requireEmail,
|
|
1583
2843
|
requirePhone: this.overrides.requirePhone,
|
|
2844
|
+
collectPhone: this.overrides.collectPhone,
|
|
2845
|
+
collectConsent: this.overrides.collectConsent,
|
|
2846
|
+
allowChatRestore: this.overrides.allowChatRestore,
|
|
1584
2847
|
allowAnonymous: this.overrides.allowAnonymous,
|
|
1585
2848
|
theme
|
|
1586
2849
|
};
|
|
@@ -1592,11 +2855,11 @@ var SmoothAgentChat = (function(exports) {
|
|
|
1592
2855
|
return;
|
|
1593
2856
|
}
|
|
1594
2857
|
const resolved = resolveConfig(config);
|
|
2858
|
+
this.allowChatRestore = resolved.allowChatRestore;
|
|
1595
2859
|
if (!this.controller) {
|
|
1596
2860
|
this.controller = new ConversationController(config, {
|
|
1597
2861
|
onMessages: (messages) => {
|
|
1598
|
-
this.messages
|
|
1599
|
-
this.renderMessages(resolved.greeting);
|
|
2862
|
+
this.handleMessages(messages, resolved.greeting);
|
|
1600
2863
|
},
|
|
1601
2864
|
onStatus: (status) => {
|
|
1602
2865
|
this.status = status;
|
|
@@ -1606,9 +2869,14 @@ var SmoothAgentChat = (function(exports) {
|
|
|
1606
2869
|
onInterrupt: (interrupt) => {
|
|
1607
2870
|
this.interrupt = interrupt;
|
|
1608
2871
|
this.renderInterrupt();
|
|
2872
|
+
},
|
|
2873
|
+
onIdentityRestore: (state) => {
|
|
2874
|
+
this.identityRestore = state;
|
|
2875
|
+
this.renderInterrupt();
|
|
1609
2876
|
}
|
|
1610
2877
|
});
|
|
1611
2878
|
if (resolved.startOpen) this.open = true;
|
|
2879
|
+
if (this.controller.hasPersistedSession() || this.controller.hasPersistedIdentity()) this.userInfoSatisfied = true;
|
|
1612
2880
|
}
|
|
1613
2881
|
const fullpage = resolved.mode === "fullpage";
|
|
1614
2882
|
if (fullpage) this.open = true;
|
|
@@ -1631,8 +2899,16 @@ var SmoothAgentChat = (function(exports) {
|
|
|
1631
2899
|
<button class="close" aria-label="Close chat">${ICON.close}</button>
|
|
1632
2900
|
</div>`;
|
|
1633
2901
|
this.examplePrompts = resolved.examplePrompts;
|
|
2902
|
+
this.greeting = resolved.greeting;
|
|
1634
2903
|
const gating = needsUserInfo(resolved) && !this.userInfoSatisfied;
|
|
1635
|
-
|
|
2904
|
+
this.gating = gating;
|
|
2905
|
+
const showPhone = resolved.requirePhone || resolved.collectPhone;
|
|
2906
|
+
const field = (name, type, label, autocomplete, required) => `<label class="pc-field"><span>${escapeHtml(label)}</span><input name="${name}" type="${type}" autocomplete="${autocomplete}"${required ? " required" : ""} /></label>`;
|
|
2907
|
+
const consentBox = (name, label) => `<label class="pc-consent"><input name="${name}" type="checkbox" /><span>${escapeHtml(label)}</span></label>`;
|
|
2908
|
+
const consentHtml = resolved.collectConsent ? `<div class="pc-consents">
|
|
2909
|
+
${consentBox("emailOptIn", "Email me product news and offers.")}
|
|
2910
|
+
${consentBox("smsOptIn", "Text me updates by SMS. Message/data rates may apply.")}
|
|
2911
|
+
</div>` : "";
|
|
1636
2912
|
const prechatHtml = `
|
|
1637
2913
|
<div class="prechat">
|
|
1638
2914
|
<div class="pc-head">
|
|
@@ -1640,12 +2916,14 @@ var SmoothAgentChat = (function(exports) {
|
|
|
1640
2916
|
<div class="pc-sub">A couple details so ${escapeHtml(resolved.agentName)} can help.</div>
|
|
1641
2917
|
</div>
|
|
1642
2918
|
<form class="pc-form" novalidate>
|
|
1643
|
-
${resolved.requireName ? field("name", "text", "Name", "name") : ""}
|
|
1644
|
-
${resolved.requireEmail ? field("email", "email", "Email", "email") : ""}
|
|
1645
|
-
${
|
|
2919
|
+
${resolved.requireName ? field("name", "text", "Name", "name", true) : ""}
|
|
2920
|
+
${resolved.requireEmail ? field("email", "email", "Email", "email", true) : ""}
|
|
2921
|
+
${showPhone ? field("phone", "tel", "Phone", "tel", resolved.requirePhone) : ""}
|
|
2922
|
+
${consentHtml}
|
|
1646
2923
|
<button type="submit" class="pc-submit">Start chat</button>
|
|
1647
2924
|
</form>
|
|
1648
2925
|
</div>`;
|
|
2926
|
+
const restoreLink = this.allowChatRestore ? ` · <button type="button" class="restore-link">Restore my chats</button>` : "";
|
|
1649
2927
|
const chatHtml = `
|
|
1650
2928
|
<div class="messages"></div>
|
|
1651
2929
|
<div class="interrupt hidden"></div>
|
|
@@ -1654,7 +2932,7 @@ var SmoothAgentChat = (function(exports) {
|
|
|
1654
2932
|
<textarea rows="1" placeholder="${escapeHtml(resolved.placeholder)}"></textarea>
|
|
1655
2933
|
<button class="send" type="button" aria-label="Send message">${ICON.send}</button>
|
|
1656
2934
|
</div>
|
|
1657
|
-
<div class="footer">powered by <b>smooth‑operator</b
|
|
2935
|
+
<div class="footer">powered by <b>smooth‑operator</b>${restoreLink}</div>
|
|
1658
2936
|
</div>`;
|
|
1659
2937
|
const container = document.createElement("div");
|
|
1660
2938
|
container.innerHTML = `
|
|
@@ -1667,6 +2945,7 @@ var SmoothAgentChat = (function(exports) {
|
|
|
1667
2945
|
`;
|
|
1668
2946
|
const logoSvg = container.querySelector(".logo-wrap svg");
|
|
1669
2947
|
if (logoSvg) logoSvg.setAttribute("class", "logo");
|
|
2948
|
+
this.resetReveal();
|
|
1670
2949
|
this.root.replaceChildren(style, container);
|
|
1671
2950
|
this.launcherEl = container.querySelector(".launcher");
|
|
1672
2951
|
this.panelEl = container.querySelector(".panel");
|
|
@@ -1691,9 +2970,16 @@ var SmoothAgentChat = (function(exports) {
|
|
|
1691
2970
|
ev.preventDefault();
|
|
1692
2971
|
this.handlePrechatSubmit(pcForm);
|
|
1693
2972
|
});
|
|
2973
|
+
container.querySelector(".restore-link")?.addEventListener("click", () => {
|
|
2974
|
+
(async () => {
|
|
2975
|
+
this.identityRestore = { phase: "awaiting_email" };
|
|
2976
|
+
this.renderInterrupt();
|
|
2977
|
+
await this.controller?.connect().catch(() => {});
|
|
2978
|
+
})();
|
|
2979
|
+
});
|
|
1694
2980
|
if (fullpage && !gating) this.controller?.connect().catch(() => {});
|
|
1695
2981
|
this.syncOpenState();
|
|
1696
|
-
if (!gating) this.renderMessages(
|
|
2982
|
+
if (!gating) this.renderMessages();
|
|
1697
2983
|
this.renderStatus();
|
|
1698
2984
|
this.renderComposerState();
|
|
1699
2985
|
this.renderInterrupt();
|
|
@@ -1709,6 +2995,11 @@ var SmoothAgentChat = (function(exports) {
|
|
|
1709
2995
|
el.replaceChildren();
|
|
1710
2996
|
const it = this.interrupt;
|
|
1711
2997
|
if (!it) {
|
|
2998
|
+
if (this.identityRestore.phase !== "idle") {
|
|
2999
|
+
el.classList.remove("hidden");
|
|
3000
|
+
el.appendChild(this.buildRestoreCard());
|
|
3001
|
+
return;
|
|
3002
|
+
}
|
|
1712
3003
|
el.classList.add("hidden");
|
|
1713
3004
|
return;
|
|
1714
3005
|
}
|
|
@@ -1788,15 +3079,186 @@ var SmoothAgentChat = (function(exports) {
|
|
|
1788
3079
|
}
|
|
1789
3080
|
el.appendChild(card);
|
|
1790
3081
|
}
|
|
1791
|
-
/**
|
|
3082
|
+
/**
|
|
3083
|
+
* Build the cross-device "Restore my chats" card (ADR-048 §c). Reuses the
|
|
3084
|
+
* same overlay slot + visual language as the OTP interrupt. All server-supplied
|
|
3085
|
+
* strings (masked destination, conversation previews) are set via `textContent`
|
|
3086
|
+
* — never innerHTML — so they can't inject markup; only the static lock icon
|
|
3087
|
+
* uses innerHTML.
|
|
3088
|
+
*/
|
|
3089
|
+
buildRestoreCard() {
|
|
3090
|
+
const state = this.identityRestore;
|
|
3091
|
+
const card = document.createElement("div");
|
|
3092
|
+
card.className = "int-card";
|
|
3093
|
+
const head = document.createElement("div");
|
|
3094
|
+
head.className = "int-head";
|
|
3095
|
+
const ico = document.createElement("span");
|
|
3096
|
+
ico.className = "int-ico";
|
|
3097
|
+
ico.innerHTML = ICON.lock;
|
|
3098
|
+
const title = document.createElement("span");
|
|
3099
|
+
title.className = "int-title";
|
|
3100
|
+
title.textContent = "Restore your chats";
|
|
3101
|
+
head.append(ico, title);
|
|
3102
|
+
card.appendChild(head);
|
|
3103
|
+
const close = document.createElement("button");
|
|
3104
|
+
close.className = "int-close";
|
|
3105
|
+
close.type = "button";
|
|
3106
|
+
close.setAttribute("aria-label", "Cancel");
|
|
3107
|
+
close.textContent = "×";
|
|
3108
|
+
close.addEventListener("click", () => {
|
|
3109
|
+
this.controller?.cancelIdentityRestore();
|
|
3110
|
+
this.identityRestore = { phase: "idle" };
|
|
3111
|
+
this.renderInterrupt();
|
|
3112
|
+
});
|
|
3113
|
+
card.appendChild(close);
|
|
3114
|
+
if (state.phase === "awaiting_email") {
|
|
3115
|
+
const desc = document.createElement("div");
|
|
3116
|
+
desc.className = "int-desc";
|
|
3117
|
+
desc.textContent = "Enter your email and we'll send a code to find your previous chats.";
|
|
3118
|
+
card.appendChild(desc);
|
|
3119
|
+
const row = document.createElement("div");
|
|
3120
|
+
row.className = "int-row";
|
|
3121
|
+
const input = document.createElement("input");
|
|
3122
|
+
input.className = "int-input";
|
|
3123
|
+
input.type = "email";
|
|
3124
|
+
input.autocomplete = "email";
|
|
3125
|
+
input.placeholder = "you@example.com";
|
|
3126
|
+
const go = () => {
|
|
3127
|
+
const email = input.value.trim();
|
|
3128
|
+
if (email) this.controller?.requestIdentityOtp(email, "email");
|
|
3129
|
+
};
|
|
3130
|
+
input.addEventListener("keydown", (ev) => {
|
|
3131
|
+
if (ev.key === "Enter") {
|
|
3132
|
+
ev.preventDefault();
|
|
3133
|
+
go();
|
|
3134
|
+
}
|
|
3135
|
+
});
|
|
3136
|
+
const send = document.createElement("button");
|
|
3137
|
+
send.className = "int-btn primary";
|
|
3138
|
+
send.type = "button";
|
|
3139
|
+
send.textContent = "Send code";
|
|
3140
|
+
send.addEventListener("click", go);
|
|
3141
|
+
row.append(input, send);
|
|
3142
|
+
card.appendChild(row);
|
|
3143
|
+
if (state.error) {
|
|
3144
|
+
const err = document.createElement("div");
|
|
3145
|
+
err.className = "int-error";
|
|
3146
|
+
err.textContent = state.error;
|
|
3147
|
+
card.appendChild(err);
|
|
3148
|
+
}
|
|
3149
|
+
queueMicrotask(() => input.focus());
|
|
3150
|
+
} else if (state.phase === "requesting" || state.phase === "verifying" || state.phase === "resolving") {
|
|
3151
|
+
const msg = document.createElement("div");
|
|
3152
|
+
msg.className = "int-sent";
|
|
3153
|
+
msg.textContent = state.phase === "requesting" ? "Sending a code…" : state.phase === "verifying" ? "Verifying…" : "Finding your chats…";
|
|
3154
|
+
card.appendChild(msg);
|
|
3155
|
+
} else if (state.phase === "awaiting_code") {
|
|
3156
|
+
if (state.maskedDestination) {
|
|
3157
|
+
const sent = document.createElement("div");
|
|
3158
|
+
sent.className = "int-sent";
|
|
3159
|
+
sent.textContent = `Code sent to ${state.maskedDestination}.`;
|
|
3160
|
+
card.appendChild(sent);
|
|
3161
|
+
}
|
|
3162
|
+
const row = document.createElement("div");
|
|
3163
|
+
row.className = "int-row";
|
|
3164
|
+
const input = document.createElement("input");
|
|
3165
|
+
input.className = "int-input";
|
|
3166
|
+
input.type = "text";
|
|
3167
|
+
input.inputMode = "numeric";
|
|
3168
|
+
input.autocomplete = "one-time-code";
|
|
3169
|
+
input.placeholder = "Enter code";
|
|
3170
|
+
const submit = () => {
|
|
3171
|
+
const code = input.value.trim();
|
|
3172
|
+
if (code) this.controller?.verifyIdentityOtp(code);
|
|
3173
|
+
};
|
|
3174
|
+
input.addEventListener("keydown", (ev) => {
|
|
3175
|
+
if (ev.key === "Enter") {
|
|
3176
|
+
ev.preventDefault();
|
|
3177
|
+
submit();
|
|
3178
|
+
}
|
|
3179
|
+
});
|
|
3180
|
+
const verify = document.createElement("button");
|
|
3181
|
+
verify.className = "int-btn primary";
|
|
3182
|
+
verify.type = "button";
|
|
3183
|
+
verify.textContent = "Verify";
|
|
3184
|
+
verify.addEventListener("click", submit);
|
|
3185
|
+
row.append(input, verify);
|
|
3186
|
+
card.appendChild(row);
|
|
3187
|
+
if (state.error) {
|
|
3188
|
+
const err = document.createElement("div");
|
|
3189
|
+
err.className = "int-error";
|
|
3190
|
+
err.textContent = state.attemptsRemaining != null ? `${state.error} (${state.attemptsRemaining} left)` : state.error;
|
|
3191
|
+
card.appendChild(err);
|
|
3192
|
+
}
|
|
3193
|
+
queueMicrotask(() => input.focus());
|
|
3194
|
+
} else if (state.phase === "resolved") if (state.conversations.length === 0) {
|
|
3195
|
+
const none = document.createElement("div");
|
|
3196
|
+
none.className = "int-desc";
|
|
3197
|
+
none.textContent = "No previous chats found for that email.";
|
|
3198
|
+
card.appendChild(none);
|
|
3199
|
+
} else {
|
|
3200
|
+
const pick = document.createElement("div");
|
|
3201
|
+
pick.className = "int-desc";
|
|
3202
|
+
pick.textContent = "Pick a conversation to continue:";
|
|
3203
|
+
card.appendChild(pick);
|
|
3204
|
+
const list = document.createElement("div");
|
|
3205
|
+
list.className = "restore-list";
|
|
3206
|
+
for (const conv of state.conversations) {
|
|
3207
|
+
const btn = document.createElement("button");
|
|
3208
|
+
btn.type = "button";
|
|
3209
|
+
btn.className = "restore-item";
|
|
3210
|
+
const preview = document.createElement("span");
|
|
3211
|
+
preview.className = "restore-preview";
|
|
3212
|
+
preview.textContent = conv.preview || "Conversation";
|
|
3213
|
+
btn.appendChild(preview);
|
|
3214
|
+
if (conv.lastActivityAt) {
|
|
3215
|
+
const when = document.createElement("span");
|
|
3216
|
+
when.className = "restore-when";
|
|
3217
|
+
when.textContent = this.formatWhen(conv.lastActivityAt);
|
|
3218
|
+
btn.appendChild(when);
|
|
3219
|
+
}
|
|
3220
|
+
btn.addEventListener("click", () => {
|
|
3221
|
+
this.controller?.restoreConversation(conv.sessionId);
|
|
3222
|
+
});
|
|
3223
|
+
list.appendChild(btn);
|
|
3224
|
+
}
|
|
3225
|
+
card.appendChild(list);
|
|
3226
|
+
}
|
|
3227
|
+
else if (state.phase === "error") {
|
|
3228
|
+
const err = document.createElement("div");
|
|
3229
|
+
err.className = "int-error";
|
|
3230
|
+
err.textContent = state.message;
|
|
3231
|
+
card.appendChild(err);
|
|
3232
|
+
}
|
|
3233
|
+
return card;
|
|
3234
|
+
}
|
|
3235
|
+
/** Format an ISO timestamp as a short, locale-aware label (best-effort). */
|
|
3236
|
+
formatWhen(iso) {
|
|
3237
|
+
const d = new Date(iso);
|
|
3238
|
+
if (Number.isNaN(d.getTime())) return "";
|
|
3239
|
+
try {
|
|
3240
|
+
return d.toLocaleDateString(void 0, {
|
|
3241
|
+
month: "short",
|
|
3242
|
+
day: "numeric"
|
|
3243
|
+
});
|
|
3244
|
+
} catch {
|
|
3245
|
+
return "";
|
|
3246
|
+
}
|
|
3247
|
+
}
|
|
3248
|
+
/** Collect identity + consent from the pre-chat form, then drop into the chat view. */
|
|
1792
3249
|
handlePrechatSubmit(form) {
|
|
1793
3250
|
if (!form.reportValidity()) return;
|
|
1794
3251
|
const data = new FormData(form);
|
|
1795
3252
|
const val = (k) => data.get(k)?.trim() || void 0;
|
|
3253
|
+
const checked = (k) => data.get(k) === "on";
|
|
1796
3254
|
this.controller?.setUserInfo({
|
|
1797
3255
|
name: val("name"),
|
|
1798
3256
|
email: val("email"),
|
|
1799
|
-
phone: val("phone")
|
|
3257
|
+
phone: val("phone"),
|
|
3258
|
+
consent: {
|
|
3259
|
+
emailOptIn: checked("emailOptIn"),
|
|
3260
|
+
smsOptIn: checked("smsOptIn")
|
|
3261
|
+
}
|
|
1800
3262
|
});
|
|
1801
3263
|
this.userInfoSatisfied = true;
|
|
1802
3264
|
this.render();
|
|
@@ -1824,9 +3286,35 @@ var SmoothAgentChat = (function(exports) {
|
|
|
1824
3286
|
ta.style.height = "auto";
|
|
1825
3287
|
ta.style.height = `${ta.scrollHeight}px`;
|
|
1826
3288
|
}
|
|
1827
|
-
|
|
3289
|
+
/**
|
|
3290
|
+
* Receive a new message snapshot from the controller and update the view.
|
|
3291
|
+
*
|
|
3292
|
+
* `onMessages` fires on *every* `stream_token` delta. Rebuilding the whole
|
|
3293
|
+
* list each frame is wasteful and fights the smooth reveal, so we take the
|
|
3294
|
+
* cheap path when the only change is the trailing streaming assistant message
|
|
3295
|
+
* growing: just bump the reveal *target* (the rAF loop drains it). Any
|
|
3296
|
+
* structural change (new message, finalize, citations) triggers a full
|
|
3297
|
+
* rebuild via {@link renderMessages}.
|
|
3298
|
+
*/
|
|
3299
|
+
handleMessages(messages, greeting) {
|
|
3300
|
+
this.greeting = greeting;
|
|
3301
|
+
const prev = this.messages;
|
|
3302
|
+
const last = messages[messages.length - 1];
|
|
3303
|
+
const prevLast = prev[prev.length - 1];
|
|
3304
|
+
const structural = messages.length !== prev.length || !last || !prevLast || last.id !== prevLast.id || last.role !== prevLast.role || last.streaming !== prevLast.streaming || !!last.streaming && !prevLast.text && !!last.text;
|
|
3305
|
+
this.messages = messages;
|
|
3306
|
+
if (!structural && last && last.streaming && last.id === this.streamMsgId) {
|
|
3307
|
+
this.streamTarget = last.text;
|
|
3308
|
+
this.ensureRevealLoop();
|
|
3309
|
+
return;
|
|
3310
|
+
}
|
|
3311
|
+
this.renderMessages();
|
|
3312
|
+
}
|
|
3313
|
+
renderMessages() {
|
|
1828
3314
|
if (!this.messagesEl) return;
|
|
3315
|
+
this.resetReveal();
|
|
1829
3316
|
this.messagesEl.replaceChildren();
|
|
3317
|
+
const greeting = this.greeting;
|
|
1830
3318
|
if (this.messages.length === 0 && greeting) this.messagesEl.appendChild(this.buildRow("assistant", this.greetingBubble(greeting)));
|
|
1831
3319
|
if (!this.hasSent && this.messages.length === 0 && this.examplePrompts.length > 0) {
|
|
1832
3320
|
const chips = document.createElement("div");
|
|
@@ -1849,12 +3337,94 @@ var SmoothAgentChat = (function(exports) {
|
|
|
1849
3337
|
bubble.append(this.typingDot(), this.typingDot(), this.typingDot());
|
|
1850
3338
|
} else if (msg.streaming) {
|
|
1851
3339
|
bubble.classList.add("cursor");
|
|
1852
|
-
|
|
3340
|
+
this.bindReveal(msg, bubble);
|
|
3341
|
+
} else if (msg.role === "assistant") {
|
|
3342
|
+
bubble.classList.add("md");
|
|
3343
|
+
bubble.innerHTML = renderMarkdown(msg.text);
|
|
1853
3344
|
} else bubble.textContent = msg.text;
|
|
1854
3345
|
this.messagesEl.appendChild(this.buildRow(msg.role, bubble));
|
|
1855
3346
|
if (msg.role === "assistant" && !msg.streaming && msg.citations && msg.citations.length > 0) this.messagesEl.appendChild(this.renderSources(msg.citations));
|
|
1856
3347
|
}
|
|
1857
|
-
this.
|
|
3348
|
+
this.scrollToBottom(true);
|
|
3349
|
+
}
|
|
3350
|
+
/** True when the host/user prefers reduced motion (snap, no typewriter). */
|
|
3351
|
+
prefersReducedMotion() {
|
|
3352
|
+
return typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
3353
|
+
}
|
|
3354
|
+
/**
|
|
3355
|
+
* Bind the rAF typewriter to a freshly built streaming bubble. Preserves the
|
|
3356
|
+
* already-revealed prefix across rebuilds (so a structural rebuild mid-stream
|
|
3357
|
+
* doesn't restart the reveal from zero), then resumes the loop.
|
|
3358
|
+
*/
|
|
3359
|
+
bindReveal(msg, bubble) {
|
|
3360
|
+
const carryOver = msg.id === this.streamMsgId ? Math.min(this.displayedLength, msg.text.length) : 0;
|
|
3361
|
+
this.streamBubbleEl = bubble;
|
|
3362
|
+
this.streamMsgId = msg.id;
|
|
3363
|
+
this.streamTarget = msg.text;
|
|
3364
|
+
this.displayedLength = carryOver;
|
|
3365
|
+
if (this.prefersReducedMotion()) {
|
|
3366
|
+
this.displayedLength = msg.text.length;
|
|
3367
|
+
bubble.textContent = msg.text;
|
|
3368
|
+
return;
|
|
3369
|
+
}
|
|
3370
|
+
bubble.textContent = msg.text.slice(0, this.displayedLength);
|
|
3371
|
+
this.ensureRevealLoop();
|
|
3372
|
+
}
|
|
3373
|
+
/** Start the rAF loop if it isn't already running. */
|
|
3374
|
+
ensureRevealLoop() {
|
|
3375
|
+
if (this.prefersReducedMotion() || typeof requestAnimationFrame !== "function") {
|
|
3376
|
+
this.snapReveal();
|
|
3377
|
+
return;
|
|
3378
|
+
}
|
|
3379
|
+
if (this.rafId) return;
|
|
3380
|
+
this.rafId = requestAnimationFrame(() => this.tickReveal());
|
|
3381
|
+
}
|
|
3382
|
+
/**
|
|
3383
|
+
* One animation frame of the reveal. Advances `displayedLength` toward the
|
|
3384
|
+
* buffered target at an ADAPTIVE rate — the deeper the backlog, the more
|
|
3385
|
+
* chars per frame — so the reveal stays smooth yet never falls behind the
|
|
3386
|
+
* network. Updates ONLY the bound bubble's textContent (no list rebuild).
|
|
3387
|
+
*/
|
|
3388
|
+
tickReveal() {
|
|
3389
|
+
this.rafId = 0;
|
|
3390
|
+
const bubble = this.streamBubbleEl;
|
|
3391
|
+
if (!bubble) return;
|
|
3392
|
+
const target = this.streamTarget;
|
|
3393
|
+
const remaining = target.length - this.displayedLength;
|
|
3394
|
+
if (remaining <= 0) return;
|
|
3395
|
+
const step = Math.max(2, Math.min(remaining, Math.ceil(remaining / 8)));
|
|
3396
|
+
this.displayedLength = Math.min(target.length, this.displayedLength + step);
|
|
3397
|
+
bubble.textContent = target.slice(0, this.displayedLength);
|
|
3398
|
+
this.scrollToBottom(false);
|
|
3399
|
+
this.rafId = requestAnimationFrame(() => this.tickReveal());
|
|
3400
|
+
}
|
|
3401
|
+
/** Snap the bound bubble to the full buffered text immediately (reduced-motion / no-rAF). */
|
|
3402
|
+
snapReveal() {
|
|
3403
|
+
if (this.streamBubbleEl) {
|
|
3404
|
+
this.displayedLength = this.streamTarget.length;
|
|
3405
|
+
this.streamBubbleEl.textContent = this.streamTarget;
|
|
3406
|
+
}
|
|
3407
|
+
}
|
|
3408
|
+
/** Cancel any in-flight reveal loop and clear its state (called on full rebuild). */
|
|
3409
|
+
resetReveal() {
|
|
3410
|
+
if (this.rafId && typeof cancelAnimationFrame === "function") cancelAnimationFrame(this.rafId);
|
|
3411
|
+
this.rafId = 0;
|
|
3412
|
+
this.streamBubbleEl = null;
|
|
3413
|
+
}
|
|
3414
|
+
/**
|
|
3415
|
+
* Auto-scroll the message list to the bottom — but don't fight a visitor who
|
|
3416
|
+
* has scrolled up to read history. When `force` (a structural rebuild) we
|
|
3417
|
+
* always pin to bottom; during the streaming reveal we only follow if the
|
|
3418
|
+
* viewport is already near the bottom.
|
|
3419
|
+
*/
|
|
3420
|
+
scrollToBottom(force) {
|
|
3421
|
+
const el = this.messagesEl;
|
|
3422
|
+
if (!el) return;
|
|
3423
|
+
if (force) {
|
|
3424
|
+
el.scrollTop = el.scrollHeight;
|
|
3425
|
+
return;
|
|
3426
|
+
}
|
|
3427
|
+
if (el.scrollHeight - el.scrollTop - el.clientHeight < 80) el.scrollTop = el.scrollHeight;
|
|
1858
3428
|
}
|
|
1859
3429
|
/** Wrap a bubble in a `.row`, prefixing assistant rows with a mini avatar. */
|
|
1860
3430
|
buildRow(role, bubble) {
|
|
@@ -1911,7 +3481,7 @@ var SmoothAgentChat = (function(exports) {
|
|
|
1911
3481
|
a.className = "src-title";
|
|
1912
3482
|
a.href = safeUrl;
|
|
1913
3483
|
a.target = "_blank";
|
|
1914
|
-
a.rel = "noopener noreferrer";
|
|
3484
|
+
a.rel = "noopener noreferrer nofollow";
|
|
1915
3485
|
titleEl = a;
|
|
1916
3486
|
} else {
|
|
1917
3487
|
titleEl = document.createElement("span");
|
|
@@ -1920,10 +3490,13 @@ var SmoothAgentChat = (function(exports) {
|
|
|
1920
3490
|
titleEl.textContent = c.title || c.id || "Source";
|
|
1921
3491
|
li.appendChild(titleEl);
|
|
1922
3492
|
if (c.snippet) {
|
|
1923
|
-
const
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
3493
|
+
const cleaned = cleanCitationSnippet(c.snippet);
|
|
3494
|
+
if (cleaned) {
|
|
3495
|
+
const snip = document.createElement("span");
|
|
3496
|
+
snip.className = "src-snippet md";
|
|
3497
|
+
snip.innerHTML = renderMarkdown(cleaned);
|
|
3498
|
+
li.appendChild(snip);
|
|
3499
|
+
}
|
|
1927
3500
|
}
|
|
1928
3501
|
list.appendChild(li);
|
|
1929
3502
|
}
|
|
@@ -1960,17 +3533,6 @@ var SmoothAgentChat = (function(exports) {
|
|
|
1960
3533
|
this.controller.send(text);
|
|
1961
3534
|
}
|
|
1962
3535
|
};
|
|
1963
|
-
function escapeHtml(value) {
|
|
1964
|
-
return value.replace(/[&<>"']/g, (c) => {
|
|
1965
|
-
switch (c) {
|
|
1966
|
-
case "&": return "&";
|
|
1967
|
-
case "<": return "<";
|
|
1968
|
-
case ">": return ">";
|
|
1969
|
-
case "\"": return """;
|
|
1970
|
-
default: return "'";
|
|
1971
|
-
}
|
|
1972
|
-
});
|
|
1973
|
-
}
|
|
1974
3536
|
/** Register the custom element once. Safe to call multiple times. */
|
|
1975
3537
|
function defineChatWidget() {
|
|
1976
3538
|
if (typeof customElements !== "undefined" && !customElements.get("smooth-agent-chat")) customElements.define(ELEMENT_TAG, SmoothAgentChatElement);
|