@wix/himalaya-cli 0.1519.0 → 0.1521.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/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/webview-page/src/webTransport.d.ts +21 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/webview-page.mjs +223 -1
- package/dist/cli.mjs +1052 -554
- package/dist/preview-assets.generated.bin +0 -0
- package/package.json +2 -2
- package/templates/branded-lite/tier5-src/_shared/business-chat-history.ts +48 -0
- package/templates/branded-lite/tier5-src/_shared/business-chat-model.ts +2 -2
- package/templates/branded-lite/tier5-src/_shared/business-chat-presenter.ts +8 -5
- package/templates/branded-lite/tier5-src/_shared/business-chat-transport.ts +33 -3
- package/templates/branded-lite/tier5-src/business-chat/index.ts +56 -18
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wix/himalaya-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1521.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "himi — the Himalaya authoring CLI. Build a Tier-5 content package on your own machine, push it to the production serve as a draft, preview it on web + Himi Player, and read worker logs back — the remote dev loop. Wraps @himalaya/serve-cli for transport.",
|
|
6
6
|
"keywords": [
|
|
@@ -71,5 +71,5 @@
|
|
|
71
71
|
"artifactId": "himalaya-cli"
|
|
72
72
|
}
|
|
73
73
|
},
|
|
74
|
-
"falconPackageHash": "
|
|
74
|
+
"falconPackageHash": "11acc374606758fb4e5bab3b978baa2f4a7cbd34e9fface50245e42d"
|
|
75
75
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** Smart Chat resolves its conversation from the caller's Wix identity. Its history must not
|
|
2
|
+
* be replaced by a different conversation returned by the Members chat room list. */
|
|
3
|
+
type Raw = Record<string, unknown>;
|
|
4
|
+
const object = (value: unknown): Raw => value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Raw : {};
|
|
5
|
+
const text = (value: unknown): string => typeof value === 'string' ? value : '';
|
|
6
|
+
|
|
7
|
+
// Positive lists: navigation, cart events, owner notifications and training messages are not a transcript.
|
|
8
|
+
const VISITOR_TYPES = new Set(['QUESTION', 'VISITOR_MESSAGE', 'VISITOR_OPTION_SELECTED', 'FAQ_QUESTION']);
|
|
9
|
+
const ASSISTANT_TYPES = new Set(['ANSWER', 'INTRO', 'LEGAL', 'OFFLINE', 'OUT_OF_QUOTA', 'FAQ_ANSWER']);
|
|
10
|
+
const STAFF_TYPES = new Set(['USER_ANSWER', 'WIX_USER_CARD', 'WIX_USER_MINIMAL_BODY', 'WIX_USER_MEDIA', 'WIX_USER_FORM']);
|
|
11
|
+
|
|
12
|
+
/** Adapt the current identity-scoped Smart Chat conversation to the existing presentation pipeline.
|
|
13
|
+
* `inboxId` identifies an Inbox MESSAGE, never a room. Where that exact message exists in the
|
|
14
|
+
* member page, keep its rich content; otherwise use the persisted Smart Chat body. No text/id guesses. */
|
|
15
|
+
export function smartChatHistory(aiMessages: unknown[], memberPage: unknown[], participantId: string): { conversationId: string; messages: Raw[] } | null {
|
|
16
|
+
const dated = aiMessages.map(object)
|
|
17
|
+
.filter(message => text(message.id) && text(message.conversationId) && Number.isFinite(Date.parse(text(message.createdDate))))
|
|
18
|
+
.sort((a, b) => Date.parse(text(a.createdDate)) - Date.parse(text(b.createdDate)));
|
|
19
|
+
const conversationId = text(dated.at(-1)?.conversationId);
|
|
20
|
+
if (!conversationId) return null;
|
|
21
|
+
const memberRows = new Map(memberPage.map(object).map(row => [text(row.id), row]));
|
|
22
|
+
const messages: Raw[] = [];
|
|
23
|
+
for (const message of dated) {
|
|
24
|
+
if (message.conversationId !== conversationId) continue;
|
|
25
|
+
const sender = text(message.sender);
|
|
26
|
+
const kind = text(message.messageType);
|
|
27
|
+
const mine = sender === 'SITE_VISITOR' && VISITOR_TYPES.has(kind);
|
|
28
|
+
if (!mine && !(sender === 'ASSISTANT' && ASSISTANT_TYPES.has(kind)) && !(sender === 'USER' && STAFF_TYPES.has(kind))) continue;
|
|
29
|
+
const body = object(message.body);
|
|
30
|
+
const bodyText = [text(body.mainText), text(body.footerText)].filter(Boolean).join('\n');
|
|
31
|
+
const rich = memberRows.get(text(message.inboxId));
|
|
32
|
+
// A quick-reply-only metadata answer is not an empty bubble (the options are shown by the composer).
|
|
33
|
+
if (!bodyText && !rich) continue;
|
|
34
|
+
messages.push({
|
|
35
|
+
...rich,
|
|
36
|
+
id: `smart-chat:${message.id}`,
|
|
37
|
+
// Member-room sequence numbers cannot order a different Smart Chat conversation.
|
|
38
|
+
sequence: undefined,
|
|
39
|
+
createdDate: message.createdDate,
|
|
40
|
+
sender: { userId: mine ? participantId : `smart-chat:${sender}` },
|
|
41
|
+
aiSiteChatSender: sender,
|
|
42
|
+
messageType: rich?.messageType ?? 'message',
|
|
43
|
+
data: rich && (Array.isArray(rich.data) && rich.data.length || rich.templateData)
|
|
44
|
+
? rich.data : [{ mimeType: 'text/plain', data: { text: bodyText } }],
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return messages.length ? { conversationId, messages } : null;
|
|
48
|
+
}
|
|
@@ -6,7 +6,7 @@ import { mergeMessages, normalizeMessages, type MessageRow } from './business-ch
|
|
|
6
6
|
* new one is recognised by its text, among the messages after `afterId` (the newest stored message on screen when it
|
|
7
7
|
* was sent), or among all of them when the room was loaded and empty (`fromStart`); `priorCount` is how many earlier
|
|
8
8
|
* sends with the same text were still waiting for their stored copy. */
|
|
9
|
-
export interface BusinessChatIntent { id:string; text:string; roomId:string; principal:string; createdDate:string; delivery:'sending'|'sent'|'unconfirmed'; senderName?:string; afterId?:string; fromStart?:boolean; priorCount?:number }
|
|
9
|
+
export interface BusinessChatIntent { id:string; text:string; roomId:string; principal:string; createdDate:string; delivery:'sending'|'sent'|'unconfirmed'; senderName?:string; storedId?:string; afterId?:string; fromStart?:boolean; priorCount?:number }
|
|
10
10
|
|
|
11
11
|
export function newBusinessChatIntent(text:string,roomId:string,principal:string,random=Math.random):BusinessChatIntent{
|
|
12
12
|
return {id:'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g,c=>{const r=Math.floor(random()*16);return(c==='x'?r:(r&3)|8).toString(16);}),text,roomId,principal,createdDate:new Date().toISOString(),delivery:'sending'};
|
|
@@ -35,7 +35,7 @@ export function sendAnchor(messages:Row[],text:string,roomLoaded:boolean):{after
|
|
|
35
35
|
export function pendingIntentWasObserved(messages:Row[],intent:BusinessChatIntent|null):boolean{
|
|
36
36
|
if(!intent)return false;
|
|
37
37
|
const stored=messages.filter(message=>!message.pending);
|
|
38
|
-
if(stored.some(message=>message.id===intent.id))return true;
|
|
38
|
+
if(stored.some(message=>message.id===(intent.storedId||intent.id)))return true;
|
|
39
39
|
let newer:Row[];
|
|
40
40
|
if(intent.fromStart)newer=stored;
|
|
41
41
|
else{
|
|
@@ -161,7 +161,7 @@ export function businessVisitorOptionSuggestions(options: BusinessVisitorOptions
|
|
|
161
161
|
}
|
|
162
162
|
|
|
163
163
|
/** Wix Chat realtime currently sends `{type, timestamp, summary, message?}` events. */
|
|
164
|
-
export function isBusinessMessageEvent(envelope: unknown, roomId: string): boolean {
|
|
164
|
+
export function isBusinessMessageEvent(envelope: unknown, roomId: string, smartChat = false): boolean {
|
|
165
165
|
const event = object(object(envelope).event);
|
|
166
166
|
if (!roomId || typeof event.type !== "string" || !/^(message|messagesent)$/i.test(event.type)) return false;
|
|
167
167
|
const message = object(event.message);
|
|
@@ -169,16 +169,19 @@ export function isBusinessMessageEvent(envelope: unknown, roomId: string): boole
|
|
|
169
169
|
const nested = object(data.message);
|
|
170
170
|
const eventRoomId = [event.chatroomId, event.chatRoomId, message.chatroomId, message.chatRoomId, data.chatroomId, nested.chatroomId]
|
|
171
171
|
.find((value): value is string => typeof value === "string" && value.length > 0);
|
|
172
|
-
|
|
172
|
+
// Smart Chat's room can differ from the Members room. The subsequent read is scoped by
|
|
173
|
+
// the current member/site identity, never by the event's untrusted room id.
|
|
174
|
+
return smartChat || !eventRoomId || eventRoomId === roomId;
|
|
173
175
|
}
|
|
174
176
|
|
|
175
177
|
export function shouldRefreshBusinessHistory(
|
|
176
178
|
envelope: unknown,
|
|
177
179
|
roomId: string,
|
|
178
180
|
session: { member: boolean; principal: string; currentPrincipal: string; scope: string; currentScope: string },
|
|
181
|
+
smartChat = false,
|
|
179
182
|
): boolean {
|
|
180
183
|
return !!session.member && !!roomId && !!session.principal && session.principal === session.currentPrincipal &&
|
|
181
|
-
session.scope === session.currentScope && isBusinessMessageEvent(envelope, roomId);
|
|
184
|
+
session.scope === session.currentScope && isBusinessMessageEvent(envelope, roomId, smartChat);
|
|
182
185
|
}
|
|
183
186
|
|
|
184
187
|
function safeAvatar(value: unknown): string {
|
|
@@ -468,8 +471,8 @@ export function presentBusinessMessages<T extends Pick<BusinessMessage, "id" | "
|
|
|
468
471
|
: standardEventText || row.plainText;
|
|
469
472
|
const senderAvatar = safeAvatar(image.url);
|
|
470
473
|
const mine = participantId && typeof sender.userId === "string" ? sender.userId === participantId : row.mine;
|
|
471
|
-
const isAiGenerated = !!participantId && !mine && !!message.appId && typeof message.originMethod === "string" && /:AI_SITE_CHAT$/i.test(message.originMethod) &&
|
|
472
|
-
(message.messageType === "message" || message.messageType === "template/standard-event" || message.messageType === "template/card");
|
|
474
|
+
const isAiGenerated = message.aiSiteChatSender === "ASSISTANT" || (message.aiSiteChatSender !== "USER" && !!participantId && !mine && !!message.appId && typeof message.originMethod === "string" && /:AI_SITE_CHAT$/i.test(message.originMethod) &&
|
|
475
|
+
(message.messageType === "message" || message.messageType === "template/standard-event" || message.messageType === "template/card"));
|
|
473
476
|
const rawSenderName = typeof display.name === "string" && display.name.trim()
|
|
474
477
|
? display.name.trim()
|
|
475
478
|
: typeof display.displayName === "string" && display.displayName.trim() ? display.displayName.trim() : "";
|
|
@@ -78,12 +78,42 @@ function bridge(): Bridge {
|
|
|
78
78
|
}
|
|
79
79
|
/** A typed message goes through Smart Chat, exactly as the site's chat widget sends it. The Inbox then files it under
|
|
80
80
|
* Wix Smart Chat and the AI can answer it; a message posted straight into the room has no channel, so the AI never
|
|
81
|
-
* saw it and the owner's reply went out by email.
|
|
81
|
+
* saw it and the owner's reply went out by email. Smart Chat chooses the conversation and stored message id.
|
|
82
82
|
* The bridge resolves once the AI has finished answering, not when the message is stored. */
|
|
83
|
-
export const sendMessage = (identity: ChatIdentity, text: string): Promise<
|
|
84
|
-
await bridge().call("POST", "/wix-assistant-widget/v1/messages/stream", {
|
|
83
|
+
export const sendMessage = (identity: ChatIdentity, text: string): Promise<string | undefined> => fenced(identity, async () => {
|
|
84
|
+
const response = await bridge().call("POST", "/wix-assistant-widget/v1/messages/stream", {
|
|
85
85
|
message: { sender: "SITE_VISITOR", messageType: "QUESTION", body: { mainText: text } },
|
|
86
86
|
});
|
|
87
|
+
// The first stream frame names the persisted question. Keep its exact id so a send
|
|
88
|
+
// that moves from an old Members transcript to Smart Chat never relies on a text match.
|
|
89
|
+
let frames: unknown = response;
|
|
90
|
+
if (typeof frames === "string") {
|
|
91
|
+
const raw = frames;
|
|
92
|
+
try { frames = JSON.parse(raw); }
|
|
93
|
+
catch {
|
|
94
|
+
frames = [];
|
|
95
|
+
// The bridge buffers SSE verbatim. Ignore event/comment lines and decode the
|
|
96
|
+
// data of each frame; some hosts instead hand over NDJSON or parsed frames.
|
|
97
|
+
for (const block of raw.split(/\r?\n\r?\n/)) {
|
|
98
|
+
const lines = block.split(/\r?\n/);
|
|
99
|
+
const event = lines.find(line => line.startsWith('event:'))?.slice(6).trim();
|
|
100
|
+
const data = lines.filter(line => line.startsWith('data:')).map(line => line.slice(5).trimStart()).join('\n');
|
|
101
|
+
try {
|
|
102
|
+
const value = JSON.parse(data || block);
|
|
103
|
+
(frames as unknown[]).push(event === 'inputMessage' && !value?.inputMessage ? { inputMessage: value } : value);
|
|
104
|
+
} catch { /* Comments and other non-JSON frames carry no stored question id. */ }
|
|
105
|
+
}
|
|
106
|
+
// A buffered NDJSON response has no SSE separators.
|
|
107
|
+
if (!(frames as unknown[]).length) {
|
|
108
|
+
try { frames = [JSON.parse(raw.split('\n', 1)[0]!)]; } catch { return undefined; }
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
for (const frame of Array.isArray(frames) ? frames : [frames]) {
|
|
113
|
+
const input = (frame as { inputMessage?: { id?: unknown; sender?: unknown; messageType?: unknown } } | null)?.inputMessage;
|
|
114
|
+
if (input?.sender === "SITE_VISITOR" && input.messageType === "QUESTION" && typeof input.id === "string" && input.id) return `smart-chat:${input.id}`;
|
|
115
|
+
}
|
|
116
|
+
return undefined;
|
|
87
117
|
});
|
|
88
118
|
/** A tapped button is a silent, ephemeral message carrying the button's chat action. */
|
|
89
119
|
export const sendButton = (identity: ChatIdentity, roomId: string, input: { id: string; text: string; appId: string; interactionId: string }): Promise<void> => fenced(identity, async () => {
|
|
@@ -6,6 +6,7 @@ import { businessChatSendRejected, messageTimeLabel, sendAnchor, pendingIntentWa
|
|
|
6
6
|
import { subscribeRealtime, unsubscribeRealtime, getRealtimeStatus, type RealtimeStatus, type RealtimeEventEnvelope } from '@himalaya/wix-chat';
|
|
7
7
|
import { businessInAppRoute, type BusinessArticleAction, dispatchBusinessSuggestion, businessRealtimeStatusLabel, businessVisitorOptionSuggestions, presentBusinessMessages, selectExistingBusinessRoom, shouldRefreshBusinessHistory, type BusinessSuggestion, type BusinessVisitorOptions, businessVisitorOptionRequest, businessVisitorOptionsFromAiMessages, newBusinessSelectionId} from '../_shared/business-chat-presenter.js';
|
|
8
8
|
import { listMessages, streamMessages } from '@himalaya/wix-ai-site-chat';
|
|
9
|
+
import { smartChatHistory } from '../_shared/business-chat-history.js';
|
|
9
10
|
import { hostSiteFromInput } from '../_shared/branded-lite-hosted.js';
|
|
10
11
|
import { productImage, searchStoreProducts } from '../_shared/himi-store.js';
|
|
11
12
|
import { queryServices } from '@himalaya/wix-bookings';
|
|
@@ -23,6 +24,7 @@ type BusinessMessageRow = Pick<MessageRow,'id'|'sender'|'when'|'createdDate'|'mi
|
|
|
23
24
|
type State = {
|
|
24
25
|
phase:'loading'|'ready'|'error'|'guest'; error:string; notice:string; member:boolean;
|
|
25
26
|
roomId:string; principal:string; scope:string; messages:BusinessMessageRow[]; draft:string; busy:boolean;
|
|
27
|
+
historySource:'members'|'smart-chat';
|
|
26
28
|
liveState:string; liveReason:string; realtimeEventCount:number; lastRealtimeEventType:string; pendingIntent:PendingIntent|null; scrollAnchor:string; scrollSend:number; usedSuggestionIds:string[]; visitorOptions:BusinessVisitorOptions|null; quickReplies:BusinessSuggestion[]; hasQuickReplies:boolean; noQuickReplies:boolean;
|
|
27
29
|
liveLabel:string; hasActiveRoom:boolean; noActiveRoom:boolean; isGuest:boolean; notGuest:boolean; realtimeDiagnostic:string; noError:boolean; noNotice:boolean; noMessages:boolean; hasMessages:boolean; noEmpty:boolean; notLoading:boolean;
|
|
28
30
|
hostedName:string; hasHostedName:boolean; noHostedName:boolean;
|
|
@@ -45,7 +47,7 @@ function initialsOf(name:string):string{
|
|
|
45
47
|
const words=name.trim().split(/\s+/).filter(Boolean);
|
|
46
48
|
return (words.length>1?`${first(words[0]!)}${first(words[words.length-1]!)}`:first(words[0]??'')).toUpperCase();
|
|
47
49
|
}
|
|
48
|
-
const initial:State={hostedName,businessName:hostedName,businessInitials:initialsOf(hostedName),hasHostedName:!!hostedName,noHostedName:!hostedName,phase:'loading',error:'',notice:'',member:false,roomId:'',principal:'',scope:'',messages:[],draft:'',busy:false,
|
|
50
|
+
const initial:State={hostedName,businessName:hostedName,businessInitials:initialsOf(hostedName),hasHostedName:!!hostedName,noHostedName:!hostedName,phase:'loading',error:'',notice:'',member:false,roomId:'',principal:'',scope:'',messages:[],draft:'',busy:false,historySource:'members',
|
|
49
51
|
liveState:'unavailable',liveReason:'',realtimeEventCount:0,lastRealtimeEventType:'',liveLabel:'',hasActiveRoom:false,noActiveRoom:true,isGuest:false,notGuest:true,realtimeDiagnostic:'',pendingIntent:null,scrollAnchor:'',scrollSend:0,usedSuggestionIds:[],visitorOptions:null,quickReplies:[],hasQuickReplies:false,noQuickReplies:true,noError:true,noNotice:true,noMessages:true,hasMessages:false,noEmpty:true,notLoading:false,sendDisabled:true,sendEnabled:false,noLive:true,loadingHidden:false,errorText:'',loadFailure:''};
|
|
50
52
|
let currentIdentity:ChatIdentity|null=null;
|
|
51
53
|
let subscriptionId='';
|
|
@@ -65,11 +67,34 @@ let businessParticipantId='';
|
|
|
65
67
|
let loadStage:'room_selection'|'history_request'|'history_normalization'|'ai_enrichment'='room_selection';
|
|
66
68
|
let readServiceState:()=>State=()=>shape(initial);
|
|
67
69
|
|
|
68
|
-
/**
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
/** Follow the identity-scoped history cursor: the first page need not contain the latest reply.
|
|
71
|
+
* Refuse an incomplete transcript rather than silently returning another conversation. */
|
|
72
|
+
async function loadSmartChatMessages():Promise<unknown[]>{
|
|
73
|
+
const messages:unknown[]=[];
|
|
74
|
+
const seen=new Set<string>();
|
|
75
|
+
let cursor:string|undefined;
|
|
76
|
+
const incomplete=()=>Object.assign(new Error('smart_chat_history_incomplete'),{code:'smart_chat_history_incomplete'});
|
|
77
|
+
for(let page=0;page<20;page++){
|
|
78
|
+
let response:Awaited<ReturnType<typeof listMessages>>;
|
|
79
|
+
try{response=await chat.fence(me(),()=>listMessages({cursorPaging:{limit:100,...(cursor?{cursor}:{})}}));}
|
|
80
|
+
catch(error){
|
|
81
|
+
if(/member_sign_in_required|MemberAccessChanged|\b401\b/i.test(String(error))||(page>0&&/\b403\b/.test(String(error))))throw Object.assign(new Error('member_sign_in_required'),{code:'smart_chat_history_incomplete'});
|
|
82
|
+
// Only an explicit unavailable feature permits the legacy fallback. A timeout or
|
|
83
|
+
// 5xx says nothing about whether a Smart Chat conversation exists.
|
|
84
|
+
if(!page&&/\b403\b|\b404\b/.test(String(error)))throw error;
|
|
85
|
+
throw incomplete();
|
|
86
|
+
}
|
|
87
|
+
if(!Array.isArray(response?.messages))throw incomplete();
|
|
88
|
+
messages.push(...response.messages);
|
|
89
|
+
const paging=response.pagingMetadata;
|
|
90
|
+
const next=paging?.cursors?.next;
|
|
91
|
+
if(paging?.hasNext===false||(!paging?.hasNext&&!next))return messages;
|
|
92
|
+
if(typeof next!=='string'||!next.trim()||seen.has(next))throw incomplete();
|
|
93
|
+
seen.add(next);cursor=next;
|
|
94
|
+
}
|
|
95
|
+
throw incomplete();
|
|
72
96
|
}
|
|
97
|
+
|
|
73
98
|
/** Submit a displayed quick reply through the AI endpoint; a label counts only while it is still a current option. */
|
|
74
99
|
async function sendBusinessVisitorOption(options:BusinessVisitorOptions|null|undefined,label:string):Promise<boolean>{
|
|
75
100
|
const request=businessVisitorOptionRequest(options,label,newBusinessSelectionId());
|
|
@@ -260,11 +285,22 @@ async function refreshHistory(s:State):Promise<State|undefined>{
|
|
|
260
285
|
const stale=()=>startEpoch!==epoch||(currentIdentity?.userId||currentIdentity?.email)!==principal;
|
|
261
286
|
try{
|
|
262
287
|
loadStage='history_request';
|
|
263
|
-
// The room
|
|
264
|
-
|
|
265
|
-
const
|
|
266
|
-
|
|
288
|
+
// The Members room and Smart Chat may be different conversations. Read Smart Chat's
|
|
289
|
+
// persisted transcript, not only its quick replies; retain Members history for sites without it.
|
|
290
|
+
const [memberRead,aiRead]=await Promise.all([
|
|
291
|
+
chat.messagesPage(me(),roomId).then(page=>({page,error:null})).catch(error=>({page:[],error})),
|
|
292
|
+
loadSmartChatMessages().then(messages=>({messages,error:null})).catch(error=>({messages:[],error})),
|
|
293
|
+
]);
|
|
267
294
|
if(stale())return undefined;
|
|
295
|
+
// Once this screen has followed Smart Chat, an unavailable read must not switch it back
|
|
296
|
+
// to the unrelated Members thread.
|
|
297
|
+
if(aiRead.error&&(s.historySource==='smart-chat'||aiRead.error.code==='smart_chat_history_incomplete'))throw aiRead.error;
|
|
298
|
+
const smart=smartChatHistory(aiRead.messages,memberRead.page,businessParticipantId);
|
|
299
|
+
const useSmart=!!smart||s.historySource==='smart-chat';
|
|
300
|
+
if(!useSmart&&memberRead.error)throw memberRead.error;
|
|
301
|
+
const page=useSmart?smart?.messages??[]:memberRead.page;
|
|
302
|
+
const historySource=useSmart?'smart-chat' as const:'members' as const;
|
|
303
|
+
const visitorOptions=businessVisitorOptionsFromAiMessages(aiRead.messages);
|
|
268
304
|
loadStage='history_normalization';
|
|
269
305
|
// Presented from the page alone: a row kept from an earlier page would be re-presented without the
|
|
270
306
|
// metadata that only the page carries (sender, avatar, AI badge, article buttons).
|
|
@@ -283,7 +319,7 @@ async function refreshHistory(s:State):Promise<State|undefined>{
|
|
|
283
319
|
// An unconfirmed one (its request is over) no longer holds Send once its words show up; its notice stays.
|
|
284
320
|
const shown=!intentObserved&&base.pendingIntent?[...messages,pendingRow(base.pendingIntent)]:messages;
|
|
285
321
|
const pendingIntent=intentObserved&&base.pendingIntent?.delivery==='unconfirmed'?null:base.pendingIntent;
|
|
286
|
-
return shape({...base,messages:shown,pendingIntent,visitorOptions,quickReplies:businessVisitorOptionSuggestions(visitorOptions),draft,phase:'ready',error:'',loadFailure:''});
|
|
322
|
+
return shape({...base,historySource,messages:shown,pendingIntent,visitorOptions,quickReplies:businessVisitorOptionSuggestions(visitorOptions),draft,phase:'ready',error:'',loadFailure:''});
|
|
287
323
|
}catch(error){
|
|
288
324
|
const wrapped=error instanceof Error?error:new Error('load_failed');
|
|
289
325
|
Object.assign(wrapped,{businessLoadFailure:safeLoadFailure(error)});
|
|
@@ -300,7 +336,7 @@ function withLiveFields(st:State,latest:State,draft:string):State{
|
|
|
300
336
|
scrollSend:Math.max(st.scrollSend,latest.scrollSend)};
|
|
301
337
|
}
|
|
302
338
|
function withLiveDraft(st:State):State{const latest=readServiceState();return withLiveFields(st,latest,latest.draft);}
|
|
303
|
-
type SendResult={id:string;outcome:'sent'|'rejected'|'unconfirmed'|'signed_out';draft:string;error?:unknown};
|
|
339
|
+
type SendResult={id:string;outcome:'sent'|'rejected'|'unconfirmed'|'signed_out';draft:string;storedId?:string;error?:unknown};
|
|
304
340
|
/** Posts one message and reports how it ended; never throws. The caller already showed the message. The call lasts
|
|
305
341
|
* until the AI has answered: history usually shows the stored copy first, which replaces the placeholder, while Send
|
|
306
342
|
* stays held until this settles (the site's widget also waits for the answer). */
|
|
@@ -309,7 +345,7 @@ async function deliver(pending:BusinessChatIntent,draft:string):Promise<void>{
|
|
|
309
345
|
let result:SendResult={id:pending.id,outcome:'sent',draft};
|
|
310
346
|
try{
|
|
311
347
|
await Promise.resolve();// the action that started this has returned its state by now
|
|
312
|
-
await chat.sendMessage(me(),pending.text);
|
|
348
|
+
result.storedId=await chat.sendMessage(me(),pending.text);
|
|
313
349
|
}catch(error){
|
|
314
350
|
const lost=/member_sign_in_required|MemberAccessChanged|\b401\b|\b403\b/i.test(String(error));
|
|
315
351
|
result={id:pending.id,draft,error,outcome:lost?'signed_out':businessChatSendRejected(error)?'rejected':'unconfirmed'};
|
|
@@ -408,7 +444,7 @@ const svc=defineService<State,Record<string,(s:State,a?:unknown)=>State|undefine
|
|
|
408
444
|
const seen=pendingIntentWasObserved(s.messages,pending);
|
|
409
445
|
return shape({...s,pendingIntent:seen?null:unconfirmed,messages:replaceRow(unconfirmed),error:safeError(result.error,'send'),notice:'No automatic retry was made. Reopen the conversation to check whether it arrived.'});
|
|
410
446
|
}
|
|
411
|
-
const sent={...pending,delivery:'sent' as const};
|
|
447
|
+
const sent={...pending,storedId:result.storedId,delivery:'sent' as const};
|
|
412
448
|
const accepted=shape({...s,pendingIntent:sent,messages:replaceRow(sent),error:'',notice:''});
|
|
413
449
|
const sendEpoch=epoch;
|
|
414
450
|
// The server accepted it, so Send is free again whatever history says; the row stays until history returns it.
|
|
@@ -416,11 +452,13 @@ const svc=defineService<State,Record<string,(s:State,a?:unknown)=>State|undefine
|
|
|
416
452
|
// History that already holds the message under the server's id needs no placeholder for it. Only this send's intent
|
|
417
453
|
// is cleared: a newer one the live state carried in is that send's to settle.
|
|
418
454
|
const ownIntent=(st:State)=>st.pendingIntent&&st.pendingIntent.id!==sent.id?st.pendingIntent:null;
|
|
419
|
-
const settled=(st:State):State=>pendingIntentWasObserved(st.messages,sent)
|
|
455
|
+
const settled=(st:State):State=>pendingIntentWasObserved(st.messages,sent)?shape({...st,pendingIntent:ownIntent(st),notice:'',messages:st.messages.filter(row=>!(row.id===sent.id&&row.pending))}):
|
|
420
456
|
shape({...st,pendingIntent:ownIntent(st),notice:'',messages:st.messages.some(row=>row.id===sent.id)?st.messages.map(row=>row.id===sent.id&&row.pending?pendingRow(sent):row):[...st.messages,pendingRow(sent)]});
|
|
421
|
-
//
|
|
422
|
-
//
|
|
423
|
-
|
|
457
|
+
// The stream finishes after the AI has persisted its answer. Reload even while connected:
|
|
458
|
+
// the Members-room subscription may never see the separate Smart Chat room's event.
|
|
459
|
+
// A refresh already running owns the next read. Queue one more pass without keeping Send
|
|
460
|
+
// locked behind that request (or allowing its old snapshot to restore this intent).
|
|
461
|
+
if(refreshFlight){refreshQueued=true;return settled(accepted);}
|
|
424
462
|
try{const loaded=await loadLatest(accepted,true);return loaded?settled(loaded):undefined;}catch(error){
|
|
425
463
|
// A session lost by an older operation says nothing about the member who is signed in now.
|
|
426
464
|
if(sendEpoch!==epoch)return undefined;
|
|
@@ -461,7 +499,7 @@ const svc=defineService<State,Record<string,(s:State,a?:unknown)=>State|undefine
|
|
|
461
499
|
const envelope=a as RealtimeEventEnvelope|undefined;
|
|
462
500
|
const eventType=typeof envelope?.event?.type==='string'?envelope.event.type:'';
|
|
463
501
|
s=shape({...s,realtimeEventCount:s.realtimeEventCount+1,lastRealtimeEventType:eventType});
|
|
464
|
-
if(!currentIdentity||!shouldRefreshBusinessHistory(envelope,s.roomId,{member:s.member,principal:s.principal,currentPrincipal:currentIdentity.userId||currentIdentity.email,scope:s.scope,currentScope:currentIdentity.scope}))return s;
|
|
502
|
+
if(!currentIdentity||!shouldRefreshBusinessHistory(envelope,s.roomId,{member:s.member,principal:s.principal,currentPrincipal:currentIdentity.userId||currentIdentity.email,scope:s.scope,currentScope:currentIdentity.scope},s.historySource==='smart-chat'))return s;
|
|
465
503
|
if(refreshFlight){refreshQueued=true;return withLiveDraft(s);}
|
|
466
504
|
try{return await loadLatest(s);}catch(error){
|
|
467
505
|
if(startEpoch!==epoch)return undefined;
|