@smooai/smooth-operator 0.1.0 → 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.
Files changed (81) hide show
  1. package/README.md +18 -4
  2. package/dist/react/components/SmoothChat.d.ts +35 -0
  3. package/dist/react/components/SmoothChat.d.ts.map +1 -0
  4. package/dist/react/components/SmoothChat.js +26 -0
  5. package/dist/react/components/SmoothChat.js.map +1 -0
  6. package/dist/react/components/parts.d.ts +30 -0
  7. package/dist/react/components/parts.d.ts.map +1 -0
  8. package/dist/react/components/parts.js +67 -0
  9. package/dist/react/components/parts.js.map +1 -0
  10. package/dist/react/index.d.ts +27 -0
  11. package/dist/react/index.d.ts.map +1 -0
  12. package/dist/react/index.js +26 -0
  13. package/dist/react/index.js.map +1 -0
  14. package/dist/react/provider.d.ts +27 -0
  15. package/dist/react/provider.d.ts.map +1 -0
  16. package/dist/react/provider.js +25 -0
  17. package/dist/react/provider.js.map +1 -0
  18. package/dist/react/response.d.ts +19 -0
  19. package/dist/react/response.d.ts.map +1 -0
  20. package/dist/react/response.js +53 -0
  21. package/dist/react/response.js.map +1 -0
  22. package/dist/react/theme.d.ts +64 -0
  23. package/dist/react/theme.d.ts.map +1 -0
  24. package/dist/react/theme.js +38 -0
  25. package/dist/react/theme.js.map +1 -0
  26. package/dist/react/types.d.ts +24 -0
  27. package/dist/react/types.d.ts.map +1 -0
  28. package/dist/react/types.js +2 -0
  29. package/dist/react/types.js.map +1 -0
  30. package/dist/react/use-conversation.d.ts +59 -0
  31. package/dist/react/use-conversation.d.ts.map +1 -0
  32. package/dist/react/use-conversation.js +154 -0
  33. package/dist/react/use-conversation.js.map +1 -0
  34. package/dist/widget/chat-widget.iife.js +1301 -0
  35. package/dist/widget/chat-widget.iife.js.map +1 -0
  36. package/dist/widget/config.d.ts +73 -0
  37. package/dist/widget/config.d.ts.map +1 -0
  38. package/dist/widget/config.js +30 -0
  39. package/dist/widget/config.js.map +1 -0
  40. package/dist/widget/conversation.d.ts +50 -0
  41. package/dist/widget/conversation.d.ts.map +1 -0
  42. package/dist/widget/conversation.js +173 -0
  43. package/dist/widget/conversation.js.map +1 -0
  44. package/dist/widget/element.d.ts +94 -0
  45. package/dist/widget/element.d.ts.map +1 -0
  46. package/dist/widget/element.js +377 -0
  47. package/dist/widget/element.js.map +1 -0
  48. package/dist/widget/index.d.ts +25 -0
  49. package/dist/widget/index.d.ts.map +1 -0
  50. package/dist/widget/index.js +24 -0
  51. package/dist/widget/index.js.map +1 -0
  52. package/dist/widget/logo.d.ts +9 -0
  53. package/dist/widget/logo.d.ts.map +1 -0
  54. package/dist/widget/logo.js +10 -0
  55. package/dist/widget/logo.js.map +1 -0
  56. package/dist/widget/standalone.d.ts +25 -0
  57. package/dist/widget/standalone.d.ts.map +1 -0
  58. package/dist/widget/standalone.js +15 -0
  59. package/dist/widget/standalone.js.map +1 -0
  60. package/dist/widget/styles.d.ts +12 -0
  61. package/dist/widget/styles.d.ts.map +1 -0
  62. package/dist/widget/styles.js +262 -0
  63. package/dist/widget/styles.js.map +1 -0
  64. package/package.json +44 -6
  65. package/src/react/components/SmoothChat.tsx +57 -0
  66. package/src/react/components/parts.tsx +141 -0
  67. package/src/react/index.ts +30 -0
  68. package/src/react/provider.tsx +40 -0
  69. package/src/react/response.ts +57 -0
  70. package/src/react/styles.css +231 -0
  71. package/src/react/theme.ts +92 -0
  72. package/src/react/types.ts +27 -0
  73. package/src/react/use-conversation.ts +203 -0
  74. package/src/widget/config.ts +102 -0
  75. package/src/widget/conversation.ts +212 -0
  76. package/src/widget/element.ts +420 -0
  77. package/src/widget/index.ts +37 -0
  78. package/src/widget/logo.ts +9 -0
  79. package/src/widget/smooth-logo.svg +32 -0
  80. package/src/widget/standalone.ts +33 -0
  81. package/src/widget/styles.ts +265 -0
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Defensive readers for the terminal `eventual_response` payload.
3
+ *
4
+ * These mirror the widget's internal helpers: the server's response envelope and
5
+ * citation list are optional and back-compatible, so we tolerate their total
6
+ * absence, non-array shapes, and missing fields rather than letting a slightly
7
+ * different server break rendering.
8
+ */
9
+ import type { Citation } from '../types.js';
10
+
11
+ /** Pull the final assistant text out of an `eventual_response` data payload. */
12
+ export function extractFinalText(response: unknown): string | null {
13
+ if (!response || typeof response !== 'object') return null;
14
+ const r = response as { responseParts?: unknown };
15
+ if (Array.isArray(r.responseParts)) {
16
+ return r.responseParts.filter((p): p is string => typeof p === 'string').join('\n\n');
17
+ }
18
+ return null;
19
+ }
20
+
21
+ /** Pull the grounding {@link Citation}s out of a terminal `eventual_response`'s inner data. */
22
+ export function extractCitations(inner: unknown): Citation[] {
23
+ if (!inner || typeof inner !== 'object') return [];
24
+ const raw = (inner as { citations?: unknown }).citations;
25
+ if (!Array.isArray(raw)) return [];
26
+ const out: Citation[] = [];
27
+ for (const c of raw) {
28
+ if (!c || typeof c !== 'object') continue;
29
+ const obj = c as Record<string, unknown>;
30
+ const id = typeof obj.id === 'string' ? obj.id : '';
31
+ const title = typeof obj.title === 'string' ? obj.title : id || 'Source';
32
+ const snippet = typeof obj.snippet === 'string' ? obj.snippet : '';
33
+ const url = typeof obj.url === 'string' && obj.url ? obj.url : undefined;
34
+ const score = typeof obj.score === 'number' ? obj.score : 0;
35
+ out.push({ id, title, snippet, score, url });
36
+ }
37
+ return out;
38
+ }
39
+
40
+ /**
41
+ * Only `http(s)` URLs become anchor hrefs. Mirrors the widget's `safeHttpUrl`
42
+ * XSS guard so a `javascript:`/`data:` citation URL can never become a live link.
43
+ */
44
+ export function safeHttpUrl(url: string | undefined): string | undefined {
45
+ if (!url) return undefined;
46
+ try {
47
+ const parsed = new URL(url, 'http://invalid.local');
48
+ if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
49
+ // Reject the sentinel base — only keep genuinely absolute http(s) URLs.
50
+ if (!/^https?:/i.test(url)) return undefined;
51
+ return url;
52
+ }
53
+ } catch {
54
+ return undefined;
55
+ }
56
+ return undefined;
57
+ }
@@ -0,0 +1,231 @@
1
+ /*
2
+ * smooth-operator-react default stylesheet.
3
+ *
4
+ * Import once in your app:
5
+ * import '@smooai/smooth-operator/react/styles.css';
6
+ *
7
+ * EVERYTHING is driven by `--smooth-*` CSS custom properties declared on the
8
+ * `.smooth-chat` root (NOT `:root`, so nothing leaks into your page). Retheme by
9
+ * overriding any of them — in plain CSS, in a Tailwind `@layer base`, with
10
+ * `theme()` tokens, via a `prefers-color-scheme` block, or per-instance with the
11
+ * `<SmoothChat theme={…}>` prop (which sets these inline). You can also restyle
12
+ * the `.smooth-chat__*` classes directly; treat this file as a starting point.
13
+ */
14
+
15
+ .smooth-chat {
16
+ /* ---- Themeable tokens (override these) ---- */
17
+ --smooth-color-text: #0f172a;
18
+ --smooth-color-bg: #ffffff;
19
+ --smooth-color-surface: #f8fafc;
20
+ --smooth-color-primary: #4f46e5;
21
+ --smooth-color-primary-text: #ffffff;
22
+ --smooth-color-assistant-bubble: #eef2ff;
23
+ --smooth-color-assistant-bubble-text: #0f172a;
24
+ --smooth-color-user-bubble: var(--smooth-color-primary);
25
+ --smooth-color-user-bubble-text: var(--smooth-color-primary-text);
26
+ --smooth-color-border: #e2e8f0;
27
+ --smooth-color-muted: #64748b;
28
+ --smooth-radius: 12px;
29
+ --smooth-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
30
+
31
+ display: flex;
32
+ flex-direction: column;
33
+ background: var(--smooth-color-bg);
34
+ color: var(--smooth-color-text);
35
+ border: 1px solid var(--smooth-color-border);
36
+ border-radius: var(--smooth-radius);
37
+ overflow: hidden;
38
+ font-family: var(--smooth-font);
39
+ }
40
+
41
+ .smooth-chat *,
42
+ .smooth-chat *::before,
43
+ .smooth-chat *::after {
44
+ box-sizing: border-box;
45
+ }
46
+
47
+ .smooth-chat--panel {
48
+ width: 100%;
49
+ max-width: 420px;
50
+ height: 560px;
51
+ }
52
+
53
+ .smooth-chat--fullpage {
54
+ width: 100%;
55
+ height: 100%;
56
+ min-height: 100%;
57
+ max-width: none;
58
+ border: none;
59
+ border-radius: 0;
60
+ }
61
+
62
+ /* ---- Header ---- */
63
+ .smooth-chat__header {
64
+ display: flex;
65
+ align-items: center;
66
+ justify-content: space-between;
67
+ gap: 12px;
68
+ padding: 12px 16px;
69
+ background: var(--smooth-color-primary);
70
+ color: var(--smooth-color-primary-text);
71
+ }
72
+ .smooth-chat__title {
73
+ font-weight: 600;
74
+ font-size: 15px;
75
+ }
76
+ .smooth-chat__status {
77
+ font-size: 11px;
78
+ opacity: 0.85;
79
+ }
80
+ .smooth-chat__status--error {
81
+ opacity: 1;
82
+ font-weight: 600;
83
+ }
84
+
85
+ /* ---- Messages ---- */
86
+ .smooth-chat__messages {
87
+ flex: 1;
88
+ overflow-y: auto;
89
+ padding: 16px;
90
+ display: flex;
91
+ flex-direction: column;
92
+ gap: 10px;
93
+ background: var(--smooth-color-surface);
94
+ }
95
+
96
+ .smooth-chat__bubble {
97
+ max-width: 82%;
98
+ padding: 9px 12px;
99
+ border-radius: var(--smooth-radius);
100
+ font-size: 14px;
101
+ line-height: 1.45;
102
+ white-space: pre-wrap;
103
+ word-break: break-word;
104
+ }
105
+ .smooth-chat__bubble--assistant {
106
+ align-self: flex-start;
107
+ background: var(--smooth-color-assistant-bubble);
108
+ color: var(--smooth-color-assistant-bubble-text);
109
+ border-bottom-left-radius: 4px;
110
+ }
111
+ .smooth-chat__bubble--user {
112
+ align-self: flex-end;
113
+ background: var(--smooth-color-user-bubble);
114
+ color: var(--smooth-color-user-bubble-text);
115
+ border-bottom-right-radius: 4px;
116
+ }
117
+ .smooth-chat__greeting {
118
+ opacity: 0.85;
119
+ font-style: italic;
120
+ }
121
+
122
+ .smooth-chat__cursor::after {
123
+ content: '▋';
124
+ margin-left: 1px;
125
+ animation: smooth-chat-blink 1s steps(2, start) infinite;
126
+ }
127
+ @keyframes smooth-chat-blink {
128
+ to {
129
+ visibility: hidden;
130
+ }
131
+ }
132
+
133
+ /* ---- Sources / citations ---- */
134
+ .smooth-chat__sources {
135
+ align-self: flex-start;
136
+ max-width: 82%;
137
+ margin-top: -4px;
138
+ font-size: 12.5px;
139
+ color: var(--smooth-color-muted);
140
+ }
141
+ .smooth-chat__sources summary {
142
+ cursor: pointer;
143
+ font-weight: 600;
144
+ list-style: none;
145
+ user-select: none;
146
+ padding: 2px 0;
147
+ }
148
+ .smooth-chat__sources summary::-webkit-details-marker {
149
+ display: none;
150
+ }
151
+ .smooth-chat__sources summary::before {
152
+ content: '▸';
153
+ display: inline-block;
154
+ margin-right: 6px;
155
+ transition: transform 0.15s ease;
156
+ }
157
+ .smooth-chat__sources details[open] summary::before {
158
+ transform: rotate(90deg);
159
+ }
160
+ .smooth-chat__sources ol {
161
+ margin: 6px 0 0;
162
+ padding-left: 0;
163
+ list-style: none;
164
+ display: flex;
165
+ flex-direction: column;
166
+ gap: 8px;
167
+ }
168
+ .smooth-chat__sources li {
169
+ border-left: 2px solid var(--smooth-color-primary);
170
+ padding-left: 10px;
171
+ }
172
+ .smooth-chat__source-title {
173
+ color: var(--smooth-color-primary);
174
+ text-decoration: none;
175
+ font-weight: 600;
176
+ word-break: break-word;
177
+ }
178
+ a.smooth-chat__source-title:hover {
179
+ text-decoration: underline;
180
+ }
181
+ span.smooth-chat__source-title {
182
+ color: var(--smooth-color-text);
183
+ opacity: 0.95;
184
+ }
185
+ .smooth-chat__source-snippet {
186
+ display: block;
187
+ margin-top: 2px;
188
+ opacity: 0.75;
189
+ line-height: 1.4;
190
+ white-space: normal;
191
+ }
192
+
193
+ /* ---- Composer ---- */
194
+ .smooth-chat__composer {
195
+ display: flex;
196
+ gap: 8px;
197
+ padding: 10px;
198
+ border-top: 1px solid var(--smooth-color-border);
199
+ background: var(--smooth-color-bg);
200
+ }
201
+ .smooth-chat__input {
202
+ flex: 1;
203
+ resize: none;
204
+ border: 1px solid var(--smooth-color-border);
205
+ border-radius: calc(var(--smooth-radius) - 4px);
206
+ padding: 8px 10px;
207
+ font-family: inherit;
208
+ font-size: 14px;
209
+ background: var(--smooth-color-bg);
210
+ color: var(--smooth-color-text);
211
+ max-height: 120px;
212
+ line-height: 1.4;
213
+ }
214
+ .smooth-chat__input:focus {
215
+ outline: 2px solid var(--smooth-color-primary);
216
+ outline-offset: -1px;
217
+ }
218
+ .smooth-chat__send {
219
+ border: none;
220
+ border-radius: calc(var(--smooth-radius) - 4px);
221
+ padding: 0 16px;
222
+ cursor: pointer;
223
+ background: var(--smooth-color-primary);
224
+ color: var(--smooth-color-primary-text);
225
+ font-weight: 600;
226
+ font-size: 14px;
227
+ }
228
+ .smooth-chat__send:disabled {
229
+ opacity: 0.5;
230
+ cursor: default;
231
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Theming via CSS custom properties.
3
+ *
4
+ * Every color/shape the components use is a `--smooth-*` CSS variable with a
5
+ * default declared in `styles.css` (scoped to `.smooth-chat`, never `:root`, so
6
+ * it can't leak into the host page). There are three ways to retheme, in order
7
+ * of precedence (later wins):
8
+ *
9
+ * 1. **Your own CSS / Tailwind.** Override the variables on `.smooth-chat`
10
+ * (or any ancestor), e.g. in a Tailwind `@layer base` block:
11
+ * .smooth-chat { --smooth-color-primary: theme(colors.indigo.600); }
12
+ * Because they're plain CSS variables, `theme()`, design tokens, and
13
+ * light/dark media queries all just work — no build coupling to this package.
14
+ *
15
+ * 2. **A `theme` prop** on `<SmoothChat>` (or `themeToStyle(theme)` spread onto
16
+ * any element you render the hook into). This sets the variables inline, so
17
+ * it wins over stylesheet rules and is the easiest per-instance override.
18
+ *
19
+ * 3. **Inline `style`** on the element, for one-off tweaks.
20
+ *
21
+ * Keys intentionally match `@smooai/chat-widget`'s `ChatWidgetTheme` so a brand
22
+ * palette ports between the web-component widget and these React components
23
+ * unchanged.
24
+ */
25
+ import type { CSSProperties } from 'react';
26
+
27
+ export interface ChatTheme {
28
+ /** Foreground text color. → `--smooth-color-text` */
29
+ text?: string;
30
+ /** Outer surface / page background. → `--smooth-color-bg` */
31
+ background?: string;
32
+ /** Panel (message column) background. → `--smooth-color-surface` */
33
+ surface?: string;
34
+ /** Primary accent (header, send button, user bubble). → `--smooth-color-primary` */
35
+ primary?: string;
36
+ /** Text rendered on top of `primary`. → `--smooth-color-primary-text` */
37
+ primaryText?: string;
38
+ /** Assistant bubble background. → `--smooth-color-assistant-bubble` */
39
+ assistantBubble?: string;
40
+ /** Assistant bubble text. → `--smooth-color-assistant-bubble-text` */
41
+ assistantBubbleText?: string;
42
+ /** User bubble background (defaults to `primary`). → `--smooth-color-user-bubble` */
43
+ userBubble?: string;
44
+ /** User bubble text (defaults to `primaryText`). → `--smooth-color-user-bubble-text` */
45
+ userBubbleText?: string;
46
+ /** Border / divider color. → `--smooth-color-border` */
47
+ border?: string;
48
+ /** Secondary / muted text (status, snippets). → `--smooth-color-muted` */
49
+ muted?: string;
50
+ /** Corner radius for bubbles, inputs, the panel. → `--smooth-radius` */
51
+ radius?: string;
52
+ /** Font family for the whole surface. → `--smooth-font` */
53
+ fontFamily?: string;
54
+ }
55
+
56
+ /** Map each `ChatTheme` key to its CSS custom property name. */
57
+ const VAR_BY_KEY: Record<keyof ChatTheme, string> = {
58
+ text: '--smooth-color-text',
59
+ background: '--smooth-color-bg',
60
+ surface: '--smooth-color-surface',
61
+ primary: '--smooth-color-primary',
62
+ primaryText: '--smooth-color-primary-text',
63
+ assistantBubble: '--smooth-color-assistant-bubble',
64
+ assistantBubbleText: '--smooth-color-assistant-bubble-text',
65
+ userBubble: '--smooth-color-user-bubble',
66
+ userBubbleText: '--smooth-color-user-bubble-text',
67
+ border: '--smooth-color-border',
68
+ muted: '--smooth-color-muted',
69
+ radius: '--smooth-radius',
70
+ fontFamily: '--smooth-font',
71
+ };
72
+
73
+ /**
74
+ * Turn a {@link ChatTheme} into a `style` object of CSS custom properties.
75
+ * Only keys you set are emitted; everything else falls back to the defaults in
76
+ * `styles.css`. Spread it onto the root element:
77
+ *
78
+ * ```tsx
79
+ * <div className="smooth-chat" style={themeToStyle({ primary: '#4f46e5' })}>…</div>
80
+ * ```
81
+ */
82
+ export function themeToStyle(theme: ChatTheme | undefined): CSSProperties {
83
+ const style: Record<string, string> = {};
84
+ if (!theme) return style as CSSProperties;
85
+ for (const key of Object.keys(theme) as (keyof ChatTheme)[]) {
86
+ const value = theme[key];
87
+ if (typeof value === 'string' && value.length > 0) {
88
+ style[VAR_BY_KEY[key]] = value;
89
+ }
90
+ }
91
+ return style as CSSProperties;
92
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Shared types for the React bindings.
3
+ *
4
+ * The message + citation shapes mirror `@smooai/chat-widget`'s
5
+ * `ConversationController` exactly, so the two presentation layers stay
6
+ * interchangeable over the same protocol client.
7
+ */
8
+ import type { Citation } from '../types.js';
9
+
10
+ export type { Citation };
11
+
12
+ export type Role = 'user' | 'assistant';
13
+
14
+ /** A single rendered chat message. Assistant messages grow as tokens stream in. */
15
+ export interface ChatMessage {
16
+ id: string;
17
+ role: Role;
18
+ /** Accumulated text (assistant messages grow as `stream_token` events arrive). */
19
+ text: string;
20
+ /** True while an assistant message is still streaming. */
21
+ streaming: boolean;
22
+ /** Grounding sources, present only when the terminal `eventual_response` carried any. */
23
+ citations?: Citation[];
24
+ }
25
+
26
+ /** Connection lifecycle of a conversation. */
27
+ export type ConnectionStatus = 'idle' | 'connecting' | 'ready' | 'error' | 'closed';
@@ -0,0 +1,203 @@
1
+ /**
2
+ * useConversation — the headless core of the React bindings.
3
+ *
4
+ * This hook owns the entire conversation lifecycle (connect → session → stream)
5
+ * and exposes nothing but state + actions, so you can render a completely custom
6
+ * UI with zero styling opinions from this package. `<SmoothChat>` and the parts
7
+ * components are thin views over exactly this hook.
8
+ *
9
+ * It is the React analogue of the widget's `ConversationController`: same wire
10
+ * flow, same defensive payload reading, but message state lives in React so your
11
+ * components re-render on every streamed token.
12
+ */
13
+ import { ProtocolError, SmoothAgentClient } from '../client.js';
14
+ import { useCallback, useEffect, useRef, useState } from 'react';
15
+ import { extractCitations, extractFinalText } from './response.js';
16
+ import type { ChatMessage, ConnectionStatus } from './types.js';
17
+
18
+ export interface UseConversationOptions {
19
+ /** WebSocket endpoint, e.g. `wss://your-host/ws`. Ignored if `client` is given. */
20
+ url?: string;
21
+ /**
22
+ * Bring your own pre-constructed {@link SmoothAgentClient} (e.g. one created by
23
+ * a {@link SmoothOperatorProvider} and shared, or one with a custom transport
24
+ * for tests). When provided, the hook does NOT own the client's lifecycle —
25
+ * it won't disconnect it on unmount.
26
+ */
27
+ client?: SmoothAgentClient;
28
+ /** UUID of the agent to converse with. */
29
+ agentId: string;
30
+ /** Optional display name for the user participant. */
31
+ userName?: string;
32
+ /** Optional email for the user participant. */
33
+ userEmail?: string;
34
+ /**
35
+ * Short-lived auth token for BYO-auth deployments. Appended to the WS URL as
36
+ * `?token=…` (browsers can't set WebSocket headers), which the server reads
37
+ * into the request `Principal` / `AccessContext`. Ignored if `client` is given.
38
+ */
39
+ authToken?: string;
40
+ /** Connect automatically on mount. Default `true`. */
41
+ autoConnect?: boolean;
42
+ /** Message shown in the assistant bubble when the connection fails mid-turn. */
43
+ connectionErrorMessage?: string;
44
+ }
45
+
46
+ export interface UseConversationResult {
47
+ /** Current connection lifecycle state. */
48
+ status: ConnectionStatus;
49
+ /** The ordered message list (re-renders on every streamed token). */
50
+ messages: ChatMessage[];
51
+ /** Last error message, or `null`. */
52
+ error: string | null;
53
+ /** The active session id once a session has been created. */
54
+ sessionId: string | null;
55
+ /** Open the transport + create a conversation session. Idempotent. */
56
+ connect: () => Promise<void>;
57
+ /** Submit a user message and stream the assistant reply into `messages`. */
58
+ send: (text: string) => Promise<void>;
59
+ /** Tear down the client (only if this hook owns it) and reset to `closed`. */
60
+ disconnect: () => void;
61
+ }
62
+
63
+ /** Append `?token=` / `&token=` to a WS URL for BYO-auth. */
64
+ function withToken(url: string, token: string | undefined): string {
65
+ if (!token) return url;
66
+ const sep = url.includes('?') ? '&' : '?';
67
+ return `${url}${sep}token=${encodeURIComponent(token)}`;
68
+ }
69
+
70
+ export function useConversation(options: UseConversationOptions): UseConversationResult {
71
+ const { url, client: externalClient, agentId, userName, userEmail, authToken, autoConnect = true, connectionErrorMessage } = options;
72
+
73
+ const [status, setStatus] = useState<ConnectionStatus>('idle');
74
+ const [messages, setMessages] = useState<ChatMessage[]>([]);
75
+ const [error, setError] = useState<string | null>(null);
76
+
77
+ // Mutable mirrors so streaming token deltas don't fight React's batching: we
78
+ // mutate in place, then publish an immutable snapshot via setMessages.
79
+ const messagesRef = useRef<ChatMessage[]>([]);
80
+ const clientRef = useRef<SmoothAgentClient | null>(null);
81
+ const ownsClientRef = useRef(false);
82
+ const sessionIdRef = useRef<string | null>(null);
83
+ const seqRef = useRef(0);
84
+ const statusRef = useRef<ConnectionStatus>('idle');
85
+
86
+ const setStatusBoth = useCallback((next: ConnectionStatus, detail?: string) => {
87
+ statusRef.current = next;
88
+ setStatus(next);
89
+ if (next === 'error' && detail) setError(detail);
90
+ if (next === 'ready' || next === 'connecting') setError(null);
91
+ }, []);
92
+
93
+ const nextId = useCallback((prefix: string) => {
94
+ seqRef.current += 1;
95
+ return `${prefix}-${seqRef.current}-${Date.now().toString(36)}`;
96
+ }, []);
97
+
98
+ const publish = useCallback(() => {
99
+ setMessages(messagesRef.current.map((m) => ({ ...m })));
100
+ }, []);
101
+
102
+ const connect = useCallback(async () => {
103
+ if (statusRef.current === 'connecting' || statusRef.current === 'ready') return;
104
+ setStatusBoth('connecting');
105
+ try {
106
+ let client = externalClient ?? clientRef.current;
107
+ if (!client) {
108
+ if (!url) throw new Error('useConversation requires either `url` or `client`');
109
+ client = new SmoothAgentClient({ url: withToken(url, authToken) });
110
+ ownsClientRef.current = true;
111
+ await client.connect();
112
+ } else if (client === externalClient) {
113
+ ownsClientRef.current = false;
114
+ }
115
+ clientRef.current = client;
116
+ const session = await client.createConversationSession({ agentId, userName, userEmail });
117
+ sessionIdRef.current = session.sessionId;
118
+ setStatusBoth('ready');
119
+ } catch (err) {
120
+ setStatusBoth('error', err instanceof Error ? err.message : String(err));
121
+ throw err;
122
+ }
123
+ }, [externalClient, url, authToken, agentId, userName, userEmail, setStatusBoth]);
124
+
125
+ const send = useCallback(
126
+ async (text: string) => {
127
+ const trimmed = text.trim();
128
+ if (!trimmed) return;
129
+ if (!clientRef.current || !sessionIdRef.current || statusRef.current !== 'ready') {
130
+ await connect();
131
+ }
132
+ const client = clientRef.current;
133
+ const sessionId = sessionIdRef.current;
134
+ if (!client || !sessionId) throw new Error('Conversation is not connected');
135
+
136
+ messagesRef.current.push({ id: nextId('u'), role: 'user', text: trimmed, streaming: false });
137
+ const assistant: ChatMessage = { id: nextId('a'), role: 'assistant', text: '', streaming: true };
138
+ messagesRef.current.push(assistant);
139
+ publish();
140
+
141
+ try {
142
+ const turn = client.sendMessage({ sessionId, message: trimmed, stream: true });
143
+ for await (const event of turn) {
144
+ if (event.type === 'stream_token') {
145
+ const token = event.token ?? event.data?.token ?? '';
146
+ if (token) {
147
+ assistant.text += token;
148
+ publish();
149
+ }
150
+ }
151
+ }
152
+ const final = await turn;
153
+ const inner = final.data?.data;
154
+ const finalText = extractFinalText(inner?.response);
155
+ if (finalText && finalText.length > assistant.text.length) assistant.text = finalText;
156
+ if (!assistant.text) assistant.text = '(no response)';
157
+ const citations = extractCitations(inner);
158
+ if (citations.length > 0) assistant.citations = citations;
159
+ assistant.streaming = false;
160
+ publish();
161
+ } catch (err) {
162
+ assistant.streaming = false;
163
+ const message =
164
+ err instanceof ProtocolError
165
+ ? `Error: ${err.message}`
166
+ : (connectionErrorMessage ?? "We couldn't reach the chat. Please try again in a moment.");
167
+ assistant.text = assistant.text ? `${assistant.text}\n\n${message}` : message;
168
+ publish();
169
+ setStatusBoth('error', err instanceof Error ? err.message : String(err));
170
+ }
171
+ },
172
+ [connect, nextId, publish, connectionErrorMessage, setStatusBoth],
173
+ );
174
+
175
+ const disconnect = useCallback(() => {
176
+ if (ownsClientRef.current) clientRef.current?.disconnect('conversation closed');
177
+ clientRef.current = externalClient ?? null;
178
+ sessionIdRef.current = null;
179
+ setStatusBoth('closed');
180
+ }, [externalClient, setStatusBoth]);
181
+
182
+ // Auto-connect once on mount; tear down only the client we own.
183
+ useEffect(() => {
184
+ let cancelled = false;
185
+ if (autoConnect) {
186
+ connect().catch(() => {
187
+ /* surfaced via status/error */
188
+ });
189
+ }
190
+ return () => {
191
+ cancelled = true;
192
+ void cancelled;
193
+ if (ownsClientRef.current) {
194
+ clientRef.current?.disconnect('component unmounted');
195
+ clientRef.current = null;
196
+ ownsClientRef.current = false;
197
+ }
198
+ };
199
+ // Connect identity changes when its inputs change; re-running re-establishes the session.
200
+ }, [autoConnect, connect]);
201
+
202
+ return { status, messages, error, sessionId: sessionIdRef.current, connect, send, disconnect };
203
+ }