@smooai/chat-widget 0.3.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.
@@ -0,0 +1,321 @@
1
+ /**
2
+ * ConversationController — the bridge between the widget UI and the
3
+ * `@smooai/smooth-operator` protocol client.
4
+ *
5
+ * This is the piece that was rewired: the original smooai widget spoke to
6
+ * `@smooai/realtime`; here every protocol action goes through {@link SmoothAgentClient}.
7
+ * The wire shapes are identical (the protocol was lifted from `@smooai/realtime`),
8
+ * so the swap is purely at the client-library boundary.
9
+ *
10
+ * Flow:
11
+ * 1. `connect()` → opens the WebSocket transport and `create_conversation_session`.
12
+ * 2. `send(text)` → `send_message`, streaming `stream_token` deltas into the
13
+ * in-progress assistant message, then the terminal
14
+ * `eventual_response`.
15
+ *
16
+ * The controller is UI-agnostic: it emits typed events and the view renders them.
17
+ */
18
+ import { type Citation, ProtocolError, type ServerEvent, SmoothAgentClient } from '@smooai/smooth-operator';
19
+ import type { ChatWidgetConfig } from './config.js';
20
+
21
+ export type { Citation };
22
+
23
+ export type Role = 'user' | 'assistant';
24
+
25
+ export interface ChatMessage {
26
+ id: string;
27
+ role: Role;
28
+ /** Accumulated text (assistant messages grow as tokens stream in). */
29
+ text: string;
30
+ /** True while an assistant message is still streaming. */
31
+ streaming: boolean;
32
+ /**
33
+ * Sources that grounded an assistant answer, when the terminal
34
+ * `eventual_response` carried any. Optional + back-compatible: absent when
35
+ * the turn used no knowledge sources (or for user messages). Read
36
+ * defensively off the terminal event — see {@link extractCitations}.
37
+ */
38
+ citations?: Citation[];
39
+ }
40
+
41
+ export type ConnectionStatus = 'idle' | 'connecting' | 'ready' | 'error' | 'closed';
42
+
43
+ /**
44
+ * A mid-turn pause that needs the visitor to act before the agent can continue:
45
+ *
46
+ * - `otp` — the agent requested OTP verification before an authenticated action.
47
+ * Resume with {@link ConversationController.verifyOtp}.
48
+ * - `confirm` — the agent wants to run a state-mutating tool and needs approval.
49
+ * Resume with {@link ConversationController.confirmTool}.
50
+ */
51
+ export type Interrupt =
52
+ | {
53
+ kind: 'otp';
54
+ toolId?: string;
55
+ actionDescription?: string;
56
+ availableChannels: ('email' | 'sms')[];
57
+ /** Set once the server confirms an OTP was dispatched. */
58
+ sent?: { channel?: string; maskedDestination?: string };
59
+ /** Set when a submitted code was rejected. */
60
+ error?: string;
61
+ attemptsRemaining?: number;
62
+ }
63
+ | { kind: 'confirm'; toolId?: string; actionDescription?: string };
64
+
65
+ export interface UserInfo {
66
+ name?: string;
67
+ email?: string;
68
+ phone?: string;
69
+ }
70
+
71
+ export interface ConversationEvents {
72
+ /** Fired whenever the message list changes (append, token delta, finalize). */
73
+ onMessages: (messages: ChatMessage[]) => void;
74
+ /** Fired on connection-status transitions. */
75
+ onStatus: (status: ConnectionStatus, detail?: string) => void;
76
+ /** Fired when a turn pauses for OTP / tool-confirmation, and `null` when it clears. */
77
+ onInterrupt?: (interrupt: Interrupt | null) => void;
78
+ }
79
+
80
+ /** Pull the final assistant text out of an `eventual_response` data payload. */
81
+ function extractFinalText(response: unknown): string | null {
82
+ if (!response || typeof response !== 'object') return null;
83
+ const r = response as { responseParts?: unknown };
84
+ if (Array.isArray(r.responseParts)) {
85
+ return r.responseParts.filter((p): p is string => typeof p === 'string').join('\n\n');
86
+ }
87
+ return null;
88
+ }
89
+
90
+ /**
91
+ * Pull the grounding {@link Citation}s out of a terminal `eventual_response`.
92
+ *
93
+ * The protocol client types these (`eventual_response.data.data.citations`),
94
+ * but they're optional and back-compatible — absent when the turn used no
95
+ * knowledge sources. We read them defensively (tolerating their total absence,
96
+ * non-array shapes, and missing fields) so a server that doesn't emit them, or
97
+ * an older client, can't break rendering. Each citation always carries
98
+ * `id`/`title`/`snippet`/`score`; `url` is present only for web-sourced docs.
99
+ */
100
+ function extractCitations(inner: unknown): Citation[] {
101
+ if (!inner || typeof inner !== 'object') return [];
102
+ const raw = (inner as { citations?: unknown }).citations;
103
+ if (!Array.isArray(raw)) return [];
104
+ const out: Citation[] = [];
105
+ for (const c of raw) {
106
+ if (!c || typeof c !== 'object') continue;
107
+ const obj = c as Record<string, unknown>;
108
+ const id = typeof obj.id === 'string' ? obj.id : '';
109
+ const title = typeof obj.title === 'string' ? obj.title : id || 'Source';
110
+ const snippet = typeof obj.snippet === 'string' ? obj.snippet : '';
111
+ const url = typeof obj.url === 'string' && obj.url ? obj.url : undefined;
112
+ const score = typeof obj.score === 'number' ? obj.score : 0;
113
+ out.push({ id, title, snippet, score, url });
114
+ }
115
+ return out;
116
+ }
117
+
118
+ export class ConversationController {
119
+ private readonly config: ChatWidgetConfig;
120
+ private readonly events: ConversationEvents;
121
+ private client: SmoothAgentClient | null = null;
122
+ private sessionId: string | null = null;
123
+ private readonly messages: ChatMessage[] = [];
124
+ private status: ConnectionStatus = 'idle';
125
+ private seq = 0;
126
+ /** Visitor identity, seeded from config and updated by the pre-chat form. */
127
+ private identity: UserInfo;
128
+ /** requestId of the in-flight turn — used to resume OTP / tool confirmations. */
129
+ private activeRequestId: string | null = null;
130
+ private interrupt: Interrupt | null = null;
131
+
132
+ constructor(config: ChatWidgetConfig, events: ConversationEvents) {
133
+ this.config = config;
134
+ this.events = events;
135
+ this.identity = { name: config.userName, email: config.userEmail, phone: config.userPhone };
136
+ }
137
+
138
+ get connectionStatus(): ConnectionStatus {
139
+ return this.status;
140
+ }
141
+
142
+ /** Merge in visitor identity (from the pre-chat form). Applied on next connect. */
143
+ setUserInfo(info: UserInfo): void {
144
+ this.identity = { ...this.identity, ...info };
145
+ }
146
+
147
+ private setInterrupt(interrupt: Interrupt | null): void {
148
+ this.interrupt = interrupt;
149
+ this.events.onInterrupt?.(interrupt);
150
+ }
151
+
152
+ /** Submit an OTP code to resume the paused turn. No-op if not awaiting OTP. */
153
+ verifyOtp(code: string): void {
154
+ if (!this.client || !this.sessionId || !this.activeRequestId || this.interrupt?.kind !== 'otp') return;
155
+ this.client.verifyOtp({ sessionId: this.sessionId, requestId: this.activeRequestId, code });
156
+ }
157
+
158
+ /** Approve or reject a pending tool write to resume the paused turn. */
159
+ confirmTool(approved: boolean): void {
160
+ if (!this.client || !this.sessionId || !this.activeRequestId || this.interrupt?.kind !== 'confirm') return;
161
+ this.client.confirmToolAction({ sessionId: this.sessionId, requestId: this.activeRequestId, approved });
162
+ this.setInterrupt(null);
163
+ }
164
+
165
+ private nextId(prefix: string): string {
166
+ this.seq += 1;
167
+ return `${prefix}-${this.seq}-${Date.now().toString(36)}`;
168
+ }
169
+
170
+ private setStatus(status: ConnectionStatus, detail?: string): void {
171
+ this.status = status;
172
+ this.events.onStatus(status, detail);
173
+ }
174
+
175
+ private emitMessages(): void {
176
+ // Hand out a shallow copy so the view can't mutate internal state.
177
+ this.events.onMessages(this.messages.map((m) => ({ ...m })));
178
+ }
179
+
180
+ /** Open the transport and create a conversation session. Idempotent. */
181
+ async connect(): Promise<void> {
182
+ if (this.status === 'connecting' || this.status === 'ready') return;
183
+ this.setStatus('connecting');
184
+ try {
185
+ this.client = new SmoothAgentClient({ url: this.config.endpoint });
186
+ await this.client.connect();
187
+ const session = await this.client.createConversationSession({
188
+ agentId: this.config.agentId,
189
+ userName: this.identity.name,
190
+ userEmail: this.identity.email,
191
+ // Phone has no first-class field yet; carry it in session metadata.
192
+ ...(this.identity.phone ? { metadata: { userPhone: this.identity.phone } } : {}),
193
+ });
194
+ this.sessionId = session.sessionId;
195
+ this.setStatus('ready');
196
+ } catch (err) {
197
+ this.setStatus('error', err instanceof Error ? err.message : String(err));
198
+ throw err;
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Submit a user message. Appends the user bubble immediately, then streams the
204
+ * assistant reply token-by-token, finalizing on `eventual_response`.
205
+ */
206
+ async send(text: string): Promise<void> {
207
+ const trimmed = text.trim();
208
+ if (!trimmed) return;
209
+ if (!this.client || !this.sessionId || this.status !== 'ready') {
210
+ await this.connect();
211
+ }
212
+ if (!this.client || !this.sessionId) {
213
+ throw new Error('Conversation is not connected');
214
+ }
215
+
216
+ // 1. User bubble.
217
+ this.messages.push({ id: this.nextId('u'), role: 'user', text: trimmed, streaming: false });
218
+
219
+ // 2. Placeholder assistant bubble we grow as tokens arrive.
220
+ const assistant: ChatMessage = { id: this.nextId('a'), role: 'assistant', text: '', streaming: true };
221
+ this.messages.push(assistant);
222
+ this.emitMessages();
223
+
224
+ try {
225
+ const turn = this.client.sendMessage({ sessionId: this.sessionId, message: trimmed, stream: true });
226
+ this.activeRequestId = turn.requestId;
227
+
228
+ for await (const event of turn) {
229
+ if (event.type === 'stream_token') {
230
+ const token = event.token ?? event.data?.token ?? '';
231
+ if (token) {
232
+ assistant.text += token;
233
+ this.emitMessages();
234
+ }
235
+ } else {
236
+ // OTP / tool-confirmation pauses surface here; the loop keeps
237
+ // iterating once the visitor resumes via verifyOtp/confirmTool.
238
+ this.handleTurnEvent(event);
239
+ }
240
+ }
241
+
242
+ const final = await turn;
243
+ const inner = final.data?.data;
244
+ const finalText = extractFinalText(inner?.response);
245
+ if (finalText && finalText.length > assistant.text.length) {
246
+ assistant.text = finalText;
247
+ }
248
+ if (!assistant.text) {
249
+ assistant.text = '(no response)';
250
+ }
251
+ // Attach grounding sources from the terminal event, when present.
252
+ const citations = extractCitations(inner);
253
+ if (citations.length > 0) {
254
+ assistant.citations = citations;
255
+ }
256
+ assistant.streaming = false;
257
+ this.emitMessages();
258
+ } catch (err) {
259
+ assistant.streaming = false;
260
+ const message =
261
+ err instanceof ProtocolError
262
+ ? `Error: ${err.message}`
263
+ : (this.config.connectionErrorMessage ?? "We couldn't reach the chat.");
264
+ assistant.text = assistant.text ? `${assistant.text}\n\n${message}` : message;
265
+ this.emitMessages();
266
+ this.setStatus('error', err instanceof Error ? err.message : String(err));
267
+ } finally {
268
+ this.activeRequestId = null;
269
+ this.setInterrupt(null);
270
+ }
271
+ }
272
+
273
+ /** Map a non-token turn event (OTP / tool-confirmation lifecycle) to interrupt state. */
274
+ private handleTurnEvent(event: ServerEvent): void {
275
+ const inner = ((event as { data?: { data?: Record<string, unknown> } }).data?.data ?? {}) as Record<string, unknown>;
276
+ const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined);
277
+ const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined);
278
+ switch (event.type) {
279
+ case 'otp_verification_required': {
280
+ const channels: ('email' | 'sms')[] = Array.isArray(inner.availableChannels)
281
+ ? inner.availableChannels.filter((c): c is 'email' | 'sms' => c === 'email' || c === 'sms')
282
+ : ['email'];
283
+ this.setInterrupt({
284
+ kind: 'otp',
285
+ toolId: str(inner.toolId),
286
+ actionDescription: str(inner.actionDescription),
287
+ availableChannels: channels.length > 0 ? channels : ['email'],
288
+ });
289
+ break;
290
+ }
291
+ case 'otp_sent':
292
+ if (this.interrupt?.kind === 'otp') {
293
+ this.setInterrupt({ ...this.interrupt, sent: { channel: str(inner.channel), maskedDestination: str(inner.maskedDestination) }, error: undefined });
294
+ }
295
+ break;
296
+ case 'otp_verified':
297
+ if (this.interrupt?.kind === 'otp') this.setInterrupt(null);
298
+ break;
299
+ case 'otp_invalid':
300
+ if (this.interrupt?.kind === 'otp') {
301
+ this.setInterrupt({ ...this.interrupt, error: str(inner.message) ?? 'That code was incorrect.', attemptsRemaining: num(inner.attemptsRemaining) });
302
+ }
303
+ break;
304
+ case 'write_confirmation_required':
305
+ this.setInterrupt({ kind: 'confirm', toolId: str(inner.toolId), actionDescription: str(inner.actionDescription) });
306
+ break;
307
+ default:
308
+ break;
309
+ }
310
+ }
311
+
312
+ /** Tear down the underlying client. */
313
+ disconnect(): void {
314
+ this.client?.disconnect('widget closed');
315
+ this.client = null;
316
+ this.sessionId = null;
317
+ this.activeRequestId = null;
318
+ this.setInterrupt(null);
319
+ this.setStatus('closed');
320
+ }
321
+ }