@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.
- package/README.md +18 -4
- package/dist/react/components/SmoothChat.d.ts +35 -0
- package/dist/react/components/SmoothChat.d.ts.map +1 -0
- package/dist/react/components/SmoothChat.js +26 -0
- package/dist/react/components/SmoothChat.js.map +1 -0
- package/dist/react/components/parts.d.ts +30 -0
- package/dist/react/components/parts.d.ts.map +1 -0
- package/dist/react/components/parts.js +67 -0
- package/dist/react/components/parts.js.map +1 -0
- package/dist/react/index.d.ts +27 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +26 -0
- package/dist/react/index.js.map +1 -0
- package/dist/react/provider.d.ts +27 -0
- package/dist/react/provider.d.ts.map +1 -0
- package/dist/react/provider.js +25 -0
- package/dist/react/provider.js.map +1 -0
- package/dist/react/response.d.ts +19 -0
- package/dist/react/response.d.ts.map +1 -0
- package/dist/react/response.js +53 -0
- package/dist/react/response.js.map +1 -0
- package/dist/react/theme.d.ts +64 -0
- package/dist/react/theme.d.ts.map +1 -0
- package/dist/react/theme.js +38 -0
- package/dist/react/theme.js.map +1 -0
- package/dist/react/types.d.ts +24 -0
- package/dist/react/types.d.ts.map +1 -0
- package/dist/react/types.js +2 -0
- package/dist/react/types.js.map +1 -0
- package/dist/react/use-conversation.d.ts +59 -0
- package/dist/react/use-conversation.d.ts.map +1 -0
- package/dist/react/use-conversation.js +154 -0
- package/dist/react/use-conversation.js.map +1 -0
- package/dist/widget/chat-widget.iife.js +1301 -0
- package/dist/widget/chat-widget.iife.js.map +1 -0
- package/dist/widget/config.d.ts +73 -0
- package/dist/widget/config.d.ts.map +1 -0
- package/dist/widget/config.js +30 -0
- package/dist/widget/config.js.map +1 -0
- package/dist/widget/conversation.d.ts +50 -0
- package/dist/widget/conversation.d.ts.map +1 -0
- package/dist/widget/conversation.js +173 -0
- package/dist/widget/conversation.js.map +1 -0
- package/dist/widget/element.d.ts +94 -0
- package/dist/widget/element.d.ts.map +1 -0
- package/dist/widget/element.js +377 -0
- package/dist/widget/element.js.map +1 -0
- package/dist/widget/index.d.ts +25 -0
- package/dist/widget/index.d.ts.map +1 -0
- package/dist/widget/index.js +24 -0
- package/dist/widget/index.js.map +1 -0
- package/dist/widget/logo.d.ts +9 -0
- package/dist/widget/logo.d.ts.map +1 -0
- package/dist/widget/logo.js +10 -0
- package/dist/widget/logo.js.map +1 -0
- package/dist/widget/standalone.d.ts +25 -0
- package/dist/widget/standalone.d.ts.map +1 -0
- package/dist/widget/standalone.js +15 -0
- package/dist/widget/standalone.js.map +1 -0
- package/dist/widget/styles.d.ts +12 -0
- package/dist/widget/styles.d.ts.map +1 -0
- package/dist/widget/styles.js +262 -0
- package/dist/widget/styles.js.map +1 -0
- package/package.json +44 -6
- package/src/react/components/SmoothChat.tsx +57 -0
- package/src/react/components/parts.tsx +141 -0
- package/src/react/index.ts +30 -0
- package/src/react/provider.tsx +40 -0
- package/src/react/response.ts +57 -0
- package/src/react/styles.css +231 -0
- package/src/react/theme.ts +92 -0
- package/src/react/types.ts +27 -0
- package/src/react/use-conversation.ts +203 -0
- package/src/widget/config.ts +102 -0
- package/src/widget/conversation.ts +212 -0
- package/src/widget/element.ts +420 -0
- package/src/widget/index.ts +37 -0
- package/src/widget/logo.ts +9 -0
- package/src/widget/smooth-logo.svg +32 -0
- package/src/widget/standalone.ts +33 -0
- package/src/widget/styles.ts +265 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public configuration surface for the chat widget.
|
|
3
|
+
*
|
|
4
|
+
* A host page configures the widget either declaratively (HTML attributes on the
|
|
5
|
+
* `<smooth-agent-chat>` element) or programmatically (passing this object to
|
|
6
|
+
* {@link mountChatWidget} / `element.configure(...)`).
|
|
7
|
+
*/
|
|
8
|
+
export interface ChatWidgetTheme {
|
|
9
|
+
/** Foreground text color for the widget chrome. */
|
|
10
|
+
text?: string;
|
|
11
|
+
/** Panel background color. */
|
|
12
|
+
background?: string;
|
|
13
|
+
/** Primary accent (launcher button, send button, outbound bubble). */
|
|
14
|
+
primary?: string;
|
|
15
|
+
/** Text color rendered on top of `primary`. */
|
|
16
|
+
primaryText?: string;
|
|
17
|
+
/** Inbound (assistant) chat bubble background. */
|
|
18
|
+
assistantBubble?: string;
|
|
19
|
+
/** Inbound (assistant) chat bubble text color. */
|
|
20
|
+
assistantBubbleText?: string;
|
|
21
|
+
/** Outbound (user) chat bubble background. Defaults to `primary`. */
|
|
22
|
+
userBubble?: string;
|
|
23
|
+
/** Outbound (user) chat bubble text color. Defaults to `primaryText`. */
|
|
24
|
+
userBubbleText?: string;
|
|
25
|
+
/** Border color for the panel and input. */
|
|
26
|
+
border?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Layout mode for the widget.
|
|
31
|
+
*
|
|
32
|
+
* - `"popover"` (default) — the embeddable launcher bubble + floating panel.
|
|
33
|
+
* - `"fullpage"` — no launcher; the chat fills its container/viewport with a
|
|
34
|
+
* branded header, a scrollable message list, and an input bar. Ideal for a
|
|
35
|
+
* dedicated support page (`/chat`, a docs site sidebar, an iframe, …).
|
|
36
|
+
*/
|
|
37
|
+
export type ChatWidgetMode = 'popover' | 'fullpage';
|
|
38
|
+
|
|
39
|
+
export interface ChatWidgetConfig {
|
|
40
|
+
/**
|
|
41
|
+
* smooth-operator WebSocket endpoint, e.g.
|
|
42
|
+
* `ws://localhost:8787/ws` (local dev) or your deployed `wss://…/ws` URL.
|
|
43
|
+
*/
|
|
44
|
+
endpoint: string;
|
|
45
|
+
/**
|
|
46
|
+
* Layout mode — `"popover"` (default, launcher + floating panel) or
|
|
47
|
+
* `"fullpage"` (chat fills its container; no launcher). See {@link ChatWidgetMode}.
|
|
48
|
+
*/
|
|
49
|
+
mode?: ChatWidgetMode;
|
|
50
|
+
/** UUID of the agent to start a conversation session with. */
|
|
51
|
+
agentId: string;
|
|
52
|
+
/** Display name for the agent (header label). Defaults to "Assistant". */
|
|
53
|
+
agentName?: string;
|
|
54
|
+
/** Optional display name for the user participant. */
|
|
55
|
+
userName?: string;
|
|
56
|
+
/** Optional email address for the user participant. */
|
|
57
|
+
userEmail?: string;
|
|
58
|
+
/** Placeholder text for the message input. */
|
|
59
|
+
placeholder?: string;
|
|
60
|
+
/** Greeting rendered when the conversation opens (before any messages). */
|
|
61
|
+
greeting?: string;
|
|
62
|
+
/** Message shown when the connection cannot be (re)established. */
|
|
63
|
+
connectionErrorMessage?: string;
|
|
64
|
+
/** Start the panel open instead of collapsed to the launcher. */
|
|
65
|
+
startOpen?: boolean;
|
|
66
|
+
/** Theme overrides. */
|
|
67
|
+
theme?: ChatWidgetTheme;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Resolve a partial config against the built-in defaults. */
|
|
71
|
+
export function resolveConfig(config: ChatWidgetConfig): Required<Omit<ChatWidgetConfig, 'theme' | 'userName' | 'userEmail'>> & {
|
|
72
|
+
theme: Required<ChatWidgetTheme>;
|
|
73
|
+
userName?: string;
|
|
74
|
+
userEmail?: string;
|
|
75
|
+
} {
|
|
76
|
+
const theme = config.theme ?? {};
|
|
77
|
+
const primary = theme.primary ?? '#00a6a6';
|
|
78
|
+
const primaryText = theme.primaryText ?? '#f8fafc';
|
|
79
|
+
return {
|
|
80
|
+
endpoint: config.endpoint,
|
|
81
|
+
mode: config.mode ?? 'popover',
|
|
82
|
+
agentId: config.agentId,
|
|
83
|
+
agentName: config.agentName ?? 'Assistant',
|
|
84
|
+
userName: config.userName,
|
|
85
|
+
userEmail: config.userEmail,
|
|
86
|
+
placeholder: config.placeholder ?? 'Type a message…',
|
|
87
|
+
greeting: config.greeting ?? 'Hi! How can I help you today?',
|
|
88
|
+
connectionErrorMessage: config.connectionErrorMessage ?? "We couldn't reach the chat. Please try again in a moment.",
|
|
89
|
+
startOpen: config.startOpen ?? false,
|
|
90
|
+
theme: {
|
|
91
|
+
text: theme.text ?? '#f8fafc',
|
|
92
|
+
background: theme.background ?? '#040d30',
|
|
93
|
+
primary,
|
|
94
|
+
primaryText,
|
|
95
|
+
assistantBubble: theme.assistantBubble ?? '#06134b',
|
|
96
|
+
assistantBubbleText: theme.assistantBubbleText ?? '#f8fafc',
|
|
97
|
+
userBubble: theme.userBubble ?? primary,
|
|
98
|
+
userBubbleText: theme.userBubbleText ?? primaryText,
|
|
99
|
+
border: theme.border ?? '#0a1f7a',
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
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 { ProtocolError, SmoothAgentClient } from '../client.js';
|
|
19
|
+
import type { Citation } from '../types.js';
|
|
20
|
+
import type { ChatWidgetConfig } from './config.js';
|
|
21
|
+
|
|
22
|
+
export type { Citation };
|
|
23
|
+
|
|
24
|
+
export type Role = 'user' | 'assistant';
|
|
25
|
+
|
|
26
|
+
export interface ChatMessage {
|
|
27
|
+
id: string;
|
|
28
|
+
role: Role;
|
|
29
|
+
/** Accumulated text (assistant messages grow as tokens stream in). */
|
|
30
|
+
text: string;
|
|
31
|
+
/** True while an assistant message is still streaming. */
|
|
32
|
+
streaming: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Sources that grounded an assistant answer, when the terminal
|
|
35
|
+
* `eventual_response` carried any. Optional + back-compatible: absent when
|
|
36
|
+
* the turn used no knowledge sources (or for user messages). Read
|
|
37
|
+
* defensively off the terminal event — see {@link extractCitations}.
|
|
38
|
+
*/
|
|
39
|
+
citations?: Citation[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type ConnectionStatus = 'idle' | 'connecting' | 'ready' | 'error' | 'closed';
|
|
43
|
+
|
|
44
|
+
export interface ConversationEvents {
|
|
45
|
+
/** Fired whenever the message list changes (append, token delta, finalize). */
|
|
46
|
+
onMessages: (messages: ChatMessage[]) => void;
|
|
47
|
+
/** Fired on connection-status transitions. */
|
|
48
|
+
onStatus: (status: ConnectionStatus, detail?: string) => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Pull the final assistant text out of an `eventual_response` data payload. */
|
|
52
|
+
function extractFinalText(response: unknown): string | null {
|
|
53
|
+
if (!response || typeof response !== 'object') return null;
|
|
54
|
+
const r = response as { responseParts?: unknown };
|
|
55
|
+
if (Array.isArray(r.responseParts)) {
|
|
56
|
+
return r.responseParts.filter((p): p is string => typeof p === 'string').join('\n\n');
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Pull the grounding {@link Citation}s out of a terminal `eventual_response`.
|
|
63
|
+
*
|
|
64
|
+
* The protocol client types these (`eventual_response.data.data.citations`),
|
|
65
|
+
* but they're optional and back-compatible — absent when the turn used no
|
|
66
|
+
* knowledge sources. We read them defensively (tolerating their total absence,
|
|
67
|
+
* non-array shapes, and missing fields) so a server that doesn't emit them, or
|
|
68
|
+
* an older client, can't break rendering. Each citation always carries
|
|
69
|
+
* `id`/`title`/`snippet`/`score`; `url` is present only for web-sourced docs.
|
|
70
|
+
*/
|
|
71
|
+
function extractCitations(inner: unknown): Citation[] {
|
|
72
|
+
if (!inner || typeof inner !== 'object') return [];
|
|
73
|
+
const raw = (inner as { citations?: unknown }).citations;
|
|
74
|
+
if (!Array.isArray(raw)) return [];
|
|
75
|
+
const out: Citation[] = [];
|
|
76
|
+
for (const c of raw) {
|
|
77
|
+
if (!c || typeof c !== 'object') continue;
|
|
78
|
+
const obj = c as Record<string, unknown>;
|
|
79
|
+
const id = typeof obj.id === 'string' ? obj.id : '';
|
|
80
|
+
const title = typeof obj.title === 'string' ? obj.title : id || 'Source';
|
|
81
|
+
const snippet = typeof obj.snippet === 'string' ? obj.snippet : '';
|
|
82
|
+
const url = typeof obj.url === 'string' && obj.url ? obj.url : undefined;
|
|
83
|
+
const score = typeof obj.score === 'number' ? obj.score : 0;
|
|
84
|
+
out.push({ id, title, snippet, score, url });
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export class ConversationController {
|
|
90
|
+
private readonly config: ChatWidgetConfig;
|
|
91
|
+
private readonly events: ConversationEvents;
|
|
92
|
+
private client: SmoothAgentClient | null = null;
|
|
93
|
+
private sessionId: string | null = null;
|
|
94
|
+
private readonly messages: ChatMessage[] = [];
|
|
95
|
+
private status: ConnectionStatus = 'idle';
|
|
96
|
+
private seq = 0;
|
|
97
|
+
|
|
98
|
+
constructor(config: ChatWidgetConfig, events: ConversationEvents) {
|
|
99
|
+
this.config = config;
|
|
100
|
+
this.events = events;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
get connectionStatus(): ConnectionStatus {
|
|
104
|
+
return this.status;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private nextId(prefix: string): string {
|
|
108
|
+
this.seq += 1;
|
|
109
|
+
return `${prefix}-${this.seq}-${Date.now().toString(36)}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private setStatus(status: ConnectionStatus, detail?: string): void {
|
|
113
|
+
this.status = status;
|
|
114
|
+
this.events.onStatus(status, detail);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private emitMessages(): void {
|
|
118
|
+
// Hand out a shallow copy so the view can't mutate internal state.
|
|
119
|
+
this.events.onMessages(this.messages.map((m) => ({ ...m })));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Open the transport and create a conversation session. Idempotent. */
|
|
123
|
+
async connect(): Promise<void> {
|
|
124
|
+
if (this.status === 'connecting' || this.status === 'ready') return;
|
|
125
|
+
this.setStatus('connecting');
|
|
126
|
+
try {
|
|
127
|
+
this.client = new SmoothAgentClient({ url: this.config.endpoint });
|
|
128
|
+
await this.client.connect();
|
|
129
|
+
const session = await this.client.createConversationSession({
|
|
130
|
+
agentId: this.config.agentId,
|
|
131
|
+
userName: this.config.userName,
|
|
132
|
+
userEmail: this.config.userEmail,
|
|
133
|
+
});
|
|
134
|
+
this.sessionId = session.sessionId;
|
|
135
|
+
this.setStatus('ready');
|
|
136
|
+
} catch (err) {
|
|
137
|
+
this.setStatus('error', err instanceof Error ? err.message : String(err));
|
|
138
|
+
throw err;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Submit a user message. Appends the user bubble immediately, then streams the
|
|
144
|
+
* assistant reply token-by-token, finalizing on `eventual_response`.
|
|
145
|
+
*/
|
|
146
|
+
async send(text: string): Promise<void> {
|
|
147
|
+
const trimmed = text.trim();
|
|
148
|
+
if (!trimmed) return;
|
|
149
|
+
if (!this.client || !this.sessionId || this.status !== 'ready') {
|
|
150
|
+
await this.connect();
|
|
151
|
+
}
|
|
152
|
+
if (!this.client || !this.sessionId) {
|
|
153
|
+
throw new Error('Conversation is not connected');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 1. User bubble.
|
|
157
|
+
this.messages.push({ id: this.nextId('u'), role: 'user', text: trimmed, streaming: false });
|
|
158
|
+
|
|
159
|
+
// 2. Placeholder assistant bubble we grow as tokens arrive.
|
|
160
|
+
const assistant: ChatMessage = { id: this.nextId('a'), role: 'assistant', text: '', streaming: true };
|
|
161
|
+
this.messages.push(assistant);
|
|
162
|
+
this.emitMessages();
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
const turn = this.client.sendMessage({ sessionId: this.sessionId, message: trimmed, stream: true });
|
|
166
|
+
|
|
167
|
+
for await (const event of turn) {
|
|
168
|
+
if (event.type === 'stream_token') {
|
|
169
|
+
const token = event.token ?? event.data?.token ?? '';
|
|
170
|
+
if (token) {
|
|
171
|
+
assistant.text += token;
|
|
172
|
+
this.emitMessages();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const final = await turn;
|
|
178
|
+
const inner = final.data?.data;
|
|
179
|
+
const finalText = extractFinalText(inner?.response);
|
|
180
|
+
if (finalText && finalText.length > assistant.text.length) {
|
|
181
|
+
assistant.text = finalText;
|
|
182
|
+
}
|
|
183
|
+
if (!assistant.text) {
|
|
184
|
+
assistant.text = '(no response)';
|
|
185
|
+
}
|
|
186
|
+
// Attach grounding sources from the terminal event, when present.
|
|
187
|
+
const citations = extractCitations(inner);
|
|
188
|
+
if (citations.length > 0) {
|
|
189
|
+
assistant.citations = citations;
|
|
190
|
+
}
|
|
191
|
+
assistant.streaming = false;
|
|
192
|
+
this.emitMessages();
|
|
193
|
+
} catch (err) {
|
|
194
|
+
assistant.streaming = false;
|
|
195
|
+
const message =
|
|
196
|
+
err instanceof ProtocolError
|
|
197
|
+
? `Error: ${err.message}`
|
|
198
|
+
: (this.config.connectionErrorMessage ?? "We couldn't reach the chat.");
|
|
199
|
+
assistant.text = assistant.text ? `${assistant.text}\n\n${message}` : message;
|
|
200
|
+
this.emitMessages();
|
|
201
|
+
this.setStatus('error', err instanceof Error ? err.message : String(err));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Tear down the underlying client. */
|
|
206
|
+
disconnect(): void {
|
|
207
|
+
this.client?.disconnect('widget closed');
|
|
208
|
+
this.client = null;
|
|
209
|
+
this.sessionId = null;
|
|
210
|
+
this.setStatus('closed');
|
|
211
|
+
}
|
|
212
|
+
}
|