@aichatbot-saas/react 0.1.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/LICENSE +21 -0
- package/README.md +114 -0
- package/dist/index.cjs +408 -0
- package/dist/index.d.cts +104 -0
- package/dist/index.d.ts +104 -0
- package/dist/index.js +386 -0
- package/package.json +66 -0
- package/src/AIChatbot.tsx +248 -0
- package/src/components/MessageBubble.tsx +107 -0
- package/src/components/SuggestionChips.tsx +58 -0
- package/src/components/TypingIndicator.tsx +26 -0
- package/src/index.ts +47 -0
- package/src/useChatController.ts +83 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useId,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
type CSSProperties,
|
|
8
|
+
type FormEvent,
|
|
9
|
+
} from 'react';
|
|
10
|
+
import type { ChatTheme } from '@aichatbot-saas/core';
|
|
11
|
+
import { useChatController } from './useChatController';
|
|
12
|
+
import { MessageBubble } from './components/MessageBubble';
|
|
13
|
+
import { SuggestionChips } from './components/SuggestionChips';
|
|
14
|
+
import { TypingIndicator } from './components/TypingIndicator';
|
|
15
|
+
|
|
16
|
+
export interface AIChatbotProps {
|
|
17
|
+
/** Publishable API key (required). */
|
|
18
|
+
apiKey: string;
|
|
19
|
+
/** Backend base URL. Defaults to same-origin. */
|
|
20
|
+
apiUrl?: string;
|
|
21
|
+
/** Force a language; otherwise config.languages[0] -> "en". */
|
|
22
|
+
language?: string;
|
|
23
|
+
/** Local theme overrides (highest precedence: client -> backend -> default). */
|
|
24
|
+
theme?: Partial<ChatTheme>;
|
|
25
|
+
/** Stable device/session id forwarded to the backend for threading. */
|
|
26
|
+
sessionId?: string;
|
|
27
|
+
/** Extra class on the root panel. */
|
|
28
|
+
className?: string;
|
|
29
|
+
/** Extra inline styles merged onto the root panel (e.g. width/height). */
|
|
30
|
+
style?: CSSProperties;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* `<AIChatbot>` — a native, inline, embeddable chat panel for the web.
|
|
35
|
+
*
|
|
36
|
+
* It fills its container, so the host controls size and placement (drop it into
|
|
37
|
+
* a sized `<div>`). All behavior (config load, greeting, suggestions, send flow,
|
|
38
|
+
* conversation persistence, feedback, error handling) lives in the shared
|
|
39
|
+
* headless controller; this component only renders that state and wires events.
|
|
40
|
+
*
|
|
41
|
+
* Styling is 100% inline and derived from the resolved theme — no global CSS, no
|
|
42
|
+
* external UI libs — so it never clobbers host styles and is SSR-safe.
|
|
43
|
+
*
|
|
44
|
+
* In Next.js / RSC, render it from a Client Component (add `"use client"`).
|
|
45
|
+
*/
|
|
46
|
+
export function AIChatbot({
|
|
47
|
+
apiKey,
|
|
48
|
+
apiUrl,
|
|
49
|
+
language,
|
|
50
|
+
theme,
|
|
51
|
+
sessionId,
|
|
52
|
+
className,
|
|
53
|
+
style,
|
|
54
|
+
}: AIChatbotProps) {
|
|
55
|
+
const { state, controller } = useChatController({
|
|
56
|
+
apiKey,
|
|
57
|
+
apiUrl,
|
|
58
|
+
language,
|
|
59
|
+
theme,
|
|
60
|
+
sessionId,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const [draft, setDraft] = useState('');
|
|
64
|
+
const scrollRef = useRef<HTMLDivElement | null>(null);
|
|
65
|
+
const headingId = useId();
|
|
66
|
+
|
|
67
|
+
const t = state.theme;
|
|
68
|
+
const botName = state.config?.bot_name || 'Assistant';
|
|
69
|
+
const iconUrl = state.config?.bot_icon_url;
|
|
70
|
+
|
|
71
|
+
// Auto-scroll to the newest message whenever the list or typing state changes.
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
const node = scrollRef.current;
|
|
74
|
+
if (node) node.scrollTop = node.scrollHeight;
|
|
75
|
+
}, [state.messages, state.isTyping]);
|
|
76
|
+
|
|
77
|
+
const handleSubmit = useCallback(
|
|
78
|
+
(e: FormEvent) => {
|
|
79
|
+
e.preventDefault();
|
|
80
|
+
const value = draft;
|
|
81
|
+
setDraft('');
|
|
82
|
+
void controller.send(value);
|
|
83
|
+
},
|
|
84
|
+
[controller, draft]
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const handleSuggestion = useCallback(
|
|
88
|
+
(text: string) => {
|
|
89
|
+
void controller.sendSuggestion(text);
|
|
90
|
+
},
|
|
91
|
+
[controller]
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
// ---- styles (all derived from theme) ----------------------------------
|
|
95
|
+
const panelStyle: CSSProperties = {
|
|
96
|
+
display: 'flex',
|
|
97
|
+
flexDirection: 'column',
|
|
98
|
+
width: '100%',
|
|
99
|
+
height: '100%',
|
|
100
|
+
minHeight: 0,
|
|
101
|
+
boxSizing: 'border-box',
|
|
102
|
+
background: t.backgroundColor,
|
|
103
|
+
color: t.botBubbleTextColor,
|
|
104
|
+
fontFamily: t.fontFamily,
|
|
105
|
+
fontSize: 14,
|
|
106
|
+
overflow: 'hidden',
|
|
107
|
+
...style,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const headerStyle: CSSProperties = {
|
|
111
|
+
background: t.primaryColor,
|
|
112
|
+
color: t.onPrimaryColor,
|
|
113
|
+
padding: '14px 16px',
|
|
114
|
+
fontWeight: 600,
|
|
115
|
+
display: 'flex',
|
|
116
|
+
alignItems: 'center',
|
|
117
|
+
gap: 10,
|
|
118
|
+
flex: '0 0 auto',
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const avatarStyle: CSSProperties = {
|
|
122
|
+
width: 28,
|
|
123
|
+
height: 28,
|
|
124
|
+
borderRadius: '50%',
|
|
125
|
+
objectFit: 'cover',
|
|
126
|
+
flex: '0 0 auto',
|
|
127
|
+
background: 'rgba(255,255,255,0.2)',
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const bodyStyle: CSSProperties = {
|
|
131
|
+
flex: '1 1 auto',
|
|
132
|
+
minHeight: 0,
|
|
133
|
+
overflowY: 'auto',
|
|
134
|
+
padding: 14,
|
|
135
|
+
background: t.surfaceColor,
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const footerStyle: CSSProperties = {
|
|
139
|
+
display: 'flex',
|
|
140
|
+
gap: 6,
|
|
141
|
+
borderTop: `1px solid ${t.botBubbleBorderColor}`,
|
|
142
|
+
padding: 8,
|
|
143
|
+
background: t.backgroundColor,
|
|
144
|
+
flex: '0 0 auto',
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const inputStyle: CSSProperties = {
|
|
148
|
+
flex: 1,
|
|
149
|
+
minWidth: 0,
|
|
150
|
+
border: `1px solid ${t.botBubbleBorderColor}`,
|
|
151
|
+
borderRadius: t.inputRadius,
|
|
152
|
+
padding: '9px 14px',
|
|
153
|
+
fontSize: 14,
|
|
154
|
+
fontFamily: 'inherit',
|
|
155
|
+
outline: 'none',
|
|
156
|
+
color: t.botBubbleTextColor,
|
|
157
|
+
background: t.backgroundColor,
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const sendDisabled = state.sending || state.status !== 'ready' || draft.trim().length === 0;
|
|
161
|
+
|
|
162
|
+
const sendStyle: CSSProperties = {
|
|
163
|
+
background: t.primaryColor,
|
|
164
|
+
color: t.onPrimaryColor,
|
|
165
|
+
border: 'none',
|
|
166
|
+
borderRadius: t.inputRadius,
|
|
167
|
+
padding: '0 16px',
|
|
168
|
+
cursor: sendDisabled ? 'default' : 'pointer',
|
|
169
|
+
opacity: sendDisabled ? 0.6 : 1,
|
|
170
|
+
fontSize: 14,
|
|
171
|
+
fontFamily: 'inherit',
|
|
172
|
+
flex: '0 0 auto',
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<div
|
|
177
|
+
className={className}
|
|
178
|
+
style={panelStyle}
|
|
179
|
+
role="region"
|
|
180
|
+
aria-label={`${botName} chat`}
|
|
181
|
+
aria-labelledby={headingId}
|
|
182
|
+
>
|
|
183
|
+
<header style={headerStyle}>
|
|
184
|
+
{iconUrl ? <img src={iconUrl} alt="" style={avatarStyle} /> : null}
|
|
185
|
+
<span id={headingId}>{botName}</span>
|
|
186
|
+
</header>
|
|
187
|
+
|
|
188
|
+
<div style={bodyStyle} ref={scrollRef}>
|
|
189
|
+
{state.status === 'loading' && state.messages.length === 0 ? (
|
|
190
|
+
<div style={{ color: t.mutedTextColor, fontSize: 13, padding: 4 }}>Loading…</div>
|
|
191
|
+
) : null}
|
|
192
|
+
|
|
193
|
+
{state.messages.map((message) => (
|
|
194
|
+
<MessageBubble
|
|
195
|
+
key={message.id}
|
|
196
|
+
message={message}
|
|
197
|
+
theme={t}
|
|
198
|
+
onFeedback={controller.submitFeedback.bind(controller)}
|
|
199
|
+
/>
|
|
200
|
+
))}
|
|
201
|
+
|
|
202
|
+
{state.isTyping ? <TypingIndicator theme={t} botName={botName} /> : null}
|
|
203
|
+
|
|
204
|
+
{state.status === 'error' ? (
|
|
205
|
+
<div
|
|
206
|
+
role="alert"
|
|
207
|
+
style={{
|
|
208
|
+
background: t.botBubbleColor,
|
|
209
|
+
color: t.botBubbleTextColor,
|
|
210
|
+
border: `1px solid ${t.botBubbleBorderColor}`,
|
|
211
|
+
borderRadius: t.bubbleRadius,
|
|
212
|
+
padding: '9px 13px',
|
|
213
|
+
fontSize: 14,
|
|
214
|
+
margin: '8px 0',
|
|
215
|
+
}}
|
|
216
|
+
>
|
|
217
|
+
{state.error || 'Sorry, something went wrong loading the chat.'}
|
|
218
|
+
</div>
|
|
219
|
+
) : null}
|
|
220
|
+
|
|
221
|
+
<SuggestionChips
|
|
222
|
+
suggestions={state.suggestions}
|
|
223
|
+
theme={t}
|
|
224
|
+
disabled={state.sending}
|
|
225
|
+
onSelect={handleSuggestion}
|
|
226
|
+
/>
|
|
227
|
+
</div>
|
|
228
|
+
|
|
229
|
+
<form style={footerStyle} onSubmit={handleSubmit}>
|
|
230
|
+
<input
|
|
231
|
+
style={inputStyle}
|
|
232
|
+
type="text"
|
|
233
|
+
value={draft}
|
|
234
|
+
maxLength={4000}
|
|
235
|
+
placeholder="Type your message…"
|
|
236
|
+
aria-label="Message"
|
|
237
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
238
|
+
disabled={state.status === 'error'}
|
|
239
|
+
/>
|
|
240
|
+
<button type="submit" style={sendStyle} disabled={sendDisabled}>
|
|
241
|
+
Send
|
|
242
|
+
</button>
|
|
243
|
+
</form>
|
|
244
|
+
</div>
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export default AIChatbot;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { CSSProperties } from 'react';
|
|
2
|
+
import type { ChatMessage, ChatTheme, FeedbackValue } from '@aichatbot-saas/core';
|
|
3
|
+
|
|
4
|
+
export interface MessageBubbleProps {
|
|
5
|
+
message: ChatMessage;
|
|
6
|
+
theme: ChatTheme;
|
|
7
|
+
/**
|
|
8
|
+
* Called when the user rates this (bot) message. Only rendered for bot
|
|
9
|
+
* messages that carry a `serverId` (i.e. not the greeting and not error
|
|
10
|
+
* bubbles).
|
|
11
|
+
*/
|
|
12
|
+
onFeedback?: (message: ChatMessage, value: FeedbackValue) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A single chat bubble. User messages align right with the primary color; bot
|
|
17
|
+
* messages align left with a bordered white surface. Bot messages eligible for
|
|
18
|
+
* feedback render thumbs up/down beneath the bubble (replaced by "Thanks!" once
|
|
19
|
+
* rated). All styling is inline and derived from the theme.
|
|
20
|
+
*/
|
|
21
|
+
export function MessageBubble({ message, theme, onFeedback }: MessageBubbleProps) {
|
|
22
|
+
const isUser = message.role === 'user';
|
|
23
|
+
const canRate = !isUser && !!message.serverId && !message.error;
|
|
24
|
+
|
|
25
|
+
const rowStyle: CSSProperties = {
|
|
26
|
+
display: 'flex',
|
|
27
|
+
flexDirection: 'column',
|
|
28
|
+
alignItems: isUser ? 'flex-end' : 'flex-start',
|
|
29
|
+
margin: '8px 0',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const bubbleStyle: CSSProperties = {
|
|
33
|
+
maxWidth: '80%',
|
|
34
|
+
padding: '9px 13px',
|
|
35
|
+
borderRadius: theme.bubbleRadius,
|
|
36
|
+
fontSize: 14,
|
|
37
|
+
lineHeight: 1.45,
|
|
38
|
+
whiteSpace: 'pre-wrap',
|
|
39
|
+
wordWrap: 'break-word',
|
|
40
|
+
overflowWrap: 'anywhere',
|
|
41
|
+
...(isUser
|
|
42
|
+
? {
|
|
43
|
+
background: theme.userBubbleColor,
|
|
44
|
+
color: theme.userBubbleTextColor,
|
|
45
|
+
borderBottomRightRadius: 4,
|
|
46
|
+
}
|
|
47
|
+
: {
|
|
48
|
+
background: theme.botBubbleColor,
|
|
49
|
+
color: theme.botBubbleTextColor,
|
|
50
|
+
border: `1px solid ${theme.botBubbleBorderColor}`,
|
|
51
|
+
borderBottomLeftRadius: 4,
|
|
52
|
+
}),
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div style={rowStyle}>
|
|
57
|
+
<div style={bubbleStyle}>{message.text}</div>
|
|
58
|
+
{canRate &&
|
|
59
|
+
(message.feedback ? (
|
|
60
|
+
<div style={{ marginTop: 4, fontSize: 13, color: theme.mutedTextColor }}>Thanks!</div>
|
|
61
|
+
) : (
|
|
62
|
+
<FeedbackButtons message={message} theme={theme} onFeedback={onFeedback} />
|
|
63
|
+
))}
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface FeedbackButtonsProps {
|
|
69
|
+
message: ChatMessage;
|
|
70
|
+
theme: ChatTheme;
|
|
71
|
+
onFeedback?: (message: ChatMessage, value: FeedbackValue) => void;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function FeedbackButtons({ message, theme, onFeedback }: FeedbackButtonsProps) {
|
|
75
|
+
const btnStyle: CSSProperties = {
|
|
76
|
+
background: 'none',
|
|
77
|
+
border: 'none',
|
|
78
|
+
cursor: 'pointer',
|
|
79
|
+
fontSize: 14,
|
|
80
|
+
padding: '0 2px',
|
|
81
|
+
color: theme.mutedTextColor,
|
|
82
|
+
opacity: 0.7,
|
|
83
|
+
lineHeight: 1,
|
|
84
|
+
};
|
|
85
|
+
return (
|
|
86
|
+
<div style={{ marginTop: 4, display: 'flex', gap: 2 }}>
|
|
87
|
+
<button
|
|
88
|
+
type="button"
|
|
89
|
+
title="Helpful"
|
|
90
|
+
aria-label="Helpful"
|
|
91
|
+
style={btnStyle}
|
|
92
|
+
onClick={() => onFeedback?.(message, 'positive')}
|
|
93
|
+
>
|
|
94
|
+
👍
|
|
95
|
+
</button>
|
|
96
|
+
<button
|
|
97
|
+
type="button"
|
|
98
|
+
title="Not helpful"
|
|
99
|
+
aria-label="Not helpful"
|
|
100
|
+
style={btnStyle}
|
|
101
|
+
onClick={() => onFeedback?.(message, 'negative')}
|
|
102
|
+
>
|
|
103
|
+
👎
|
|
104
|
+
</button>
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { CSSProperties } from 'react';
|
|
2
|
+
import type { ChatTheme } from '@aichatbot-saas/core';
|
|
3
|
+
|
|
4
|
+
export interface SuggestionChipsProps {
|
|
5
|
+
suggestions: string[];
|
|
6
|
+
theme: ChatTheme;
|
|
7
|
+
disabled?: boolean;
|
|
8
|
+
onSelect: (text: string) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A wrapping row of tappable suggestion chips, themed from the primary color.
|
|
13
|
+
* Renders nothing when there are no suggestions.
|
|
14
|
+
*/
|
|
15
|
+
export function SuggestionChips({
|
|
16
|
+
suggestions,
|
|
17
|
+
theme,
|
|
18
|
+
disabled = false,
|
|
19
|
+
onSelect,
|
|
20
|
+
}: SuggestionChipsProps) {
|
|
21
|
+
if (!suggestions.length) return null;
|
|
22
|
+
|
|
23
|
+
const rowStyle: CSSProperties = {
|
|
24
|
+
display: 'flex',
|
|
25
|
+
flexWrap: 'wrap',
|
|
26
|
+
gap: 6,
|
|
27
|
+
margin: '6px 0 2px',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const chipStyle: CSSProperties = {
|
|
31
|
+
background: theme.backgroundColor,
|
|
32
|
+
border: `1px solid ${theme.primaryColor}`,
|
|
33
|
+
color: theme.primaryColor,
|
|
34
|
+
borderRadius: 16,
|
|
35
|
+
padding: '6px 12px',
|
|
36
|
+
fontSize: 13,
|
|
37
|
+
cursor: disabled ? 'default' : 'pointer',
|
|
38
|
+
opacity: disabled ? 0.6 : 1,
|
|
39
|
+
fontFamily: 'inherit',
|
|
40
|
+
lineHeight: 1.2,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div style={rowStyle}>
|
|
45
|
+
{suggestions.map((label, i) => (
|
|
46
|
+
<button
|
|
47
|
+
key={`${i}-${label}`}
|
|
48
|
+
type="button"
|
|
49
|
+
style={chipStyle}
|
|
50
|
+
disabled={disabled}
|
|
51
|
+
onClick={() => onSelect(label)}
|
|
52
|
+
>
|
|
53
|
+
{label}
|
|
54
|
+
</button>
|
|
55
|
+
))}
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { CSSProperties } from 'react';
|
|
2
|
+
import type { ChatTheme } from '@aichatbot-saas/core';
|
|
3
|
+
|
|
4
|
+
export interface TypingIndicatorProps {
|
|
5
|
+
theme: ChatTheme;
|
|
6
|
+
/** Bot name to personalize the label, e.g. "Assistant is typing…". */
|
|
7
|
+
botName?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The "<bot> is typing…" indicator shown while the controller is awaiting a
|
|
12
|
+
* reply. Themed via mutedTextColor.
|
|
13
|
+
*/
|
|
14
|
+
export function TypingIndicator({ theme, botName }: TypingIndicatorProps) {
|
|
15
|
+
const style: CSSProperties = {
|
|
16
|
+
fontSize: 13,
|
|
17
|
+
color: theme.mutedTextColor,
|
|
18
|
+
fontStyle: 'italic',
|
|
19
|
+
margin: '6px 2px',
|
|
20
|
+
};
|
|
21
|
+
return (
|
|
22
|
+
<div style={style} aria-live="polite">
|
|
23
|
+
{(botName || 'Assistant') + ' is typing…'}
|
|
24
|
+
</div>
|
|
25
|
+
);
|
|
26
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @aichatbot-saas/react — native in-page chat UI component for React (web).
|
|
3
|
+
*
|
|
4
|
+
* A thin, themeable binding over the framework-agnostic
|
|
5
|
+
* {@link https://npmjs.com/package/@aichatbot-saas/core | @aichatbot-saas/core}.
|
|
6
|
+
* Drop `<AIChatbot apiKey="..." apiUrl="..." />` into any sized container.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export { AIChatbot, default } from './AIChatbot';
|
|
10
|
+
export type { AIChatbotProps } from './AIChatbot';
|
|
11
|
+
|
|
12
|
+
export { useChatController } from './useChatController';
|
|
13
|
+
export type {
|
|
14
|
+
UseChatControllerOptions,
|
|
15
|
+
UseChatControllerResult,
|
|
16
|
+
} from './useChatController';
|
|
17
|
+
|
|
18
|
+
// Presentational building blocks (advanced/custom layouts).
|
|
19
|
+
export { MessageBubble } from './components/MessageBubble';
|
|
20
|
+
export type { MessageBubbleProps } from './components/MessageBubble';
|
|
21
|
+
export { SuggestionChips } from './components/SuggestionChips';
|
|
22
|
+
export type { SuggestionChipsProps } from './components/SuggestionChips';
|
|
23
|
+
export { TypingIndicator } from './components/TypingIndicator';
|
|
24
|
+
export type { TypingIndicatorProps } from './components/TypingIndicator';
|
|
25
|
+
|
|
26
|
+
// Convenience re-exports from the core: the headless client/controller and the
|
|
27
|
+
// key models, so consumers don't need to also depend on @aichatbot-saas/core.
|
|
28
|
+
export {
|
|
29
|
+
AIChatbotClient,
|
|
30
|
+
ChatController,
|
|
31
|
+
createChatController,
|
|
32
|
+
resolveTheme,
|
|
33
|
+
DEFAULT_THEME,
|
|
34
|
+
} from '@aichatbot-saas/core';
|
|
35
|
+
export type {
|
|
36
|
+
ChatTheme,
|
|
37
|
+
ChatMessage,
|
|
38
|
+
ChatConfig,
|
|
39
|
+
ChatResponse,
|
|
40
|
+
Suggestion,
|
|
41
|
+
SuggestionInput,
|
|
42
|
+
FeedbackValue,
|
|
43
|
+
ChatRole,
|
|
44
|
+
ChatState,
|
|
45
|
+
ChatStatus,
|
|
46
|
+
ChatControllerOptions,
|
|
47
|
+
} from '@aichatbot-saas/core';
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
createChatController,
|
|
4
|
+
type ChatController,
|
|
5
|
+
type ChatState,
|
|
6
|
+
type ChatTheme,
|
|
7
|
+
} from '@aichatbot-saas/core';
|
|
8
|
+
|
|
9
|
+
/** Options accepted by {@link useChatController}. Mirrors ChatControllerOptions. */
|
|
10
|
+
export interface UseChatControllerOptions {
|
|
11
|
+
apiKey: string;
|
|
12
|
+
apiUrl?: string;
|
|
13
|
+
language?: string;
|
|
14
|
+
theme?: Partial<ChatTheme>;
|
|
15
|
+
sessionId?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface UseChatControllerResult {
|
|
19
|
+
state: ChatState;
|
|
20
|
+
controller: ChatController;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Creates and drives a headless {@link ChatController}, exposing its immutable
|
|
25
|
+
* state to React via `useSyncExternalStore` (React 18+, concurrent-safe).
|
|
26
|
+
*
|
|
27
|
+
* The controller is memoized on the identity-defining inputs (apiKey, apiUrl,
|
|
28
|
+
* language, serialized theme, sessionId); when any of those change a fresh
|
|
29
|
+
* controller is created, started, and the previous one is destroyed.
|
|
30
|
+
*
|
|
31
|
+
* SSR-safe: the controller is constructed lazily and never touches
|
|
32
|
+
* window/document during render. `start()` (which performs network/storage I/O)
|
|
33
|
+
* runs only inside an effect, so it never executes on the server.
|
|
34
|
+
*/
|
|
35
|
+
export function useChatController(
|
|
36
|
+
options: UseChatControllerOptions
|
|
37
|
+
): UseChatControllerResult {
|
|
38
|
+
const { apiKey, apiUrl, language, theme, sessionId } = options;
|
|
39
|
+
|
|
40
|
+
// Stable serialization of the theme so object identity doesn't churn the memo.
|
|
41
|
+
const themeKey = useMemo(() => JSON.stringify(theme ?? {}), [theme]);
|
|
42
|
+
|
|
43
|
+
const controller = useMemo(
|
|
44
|
+
() =>
|
|
45
|
+
createChatController({
|
|
46
|
+
apiKey,
|
|
47
|
+
apiUrl,
|
|
48
|
+
language,
|
|
49
|
+
theme,
|
|
50
|
+
sessionId,
|
|
51
|
+
}),
|
|
52
|
+
// theme is captured via its serialized key; eslint can't see that.
|
|
53
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
54
|
+
[apiKey, apiUrl, language, themeKey, sessionId]
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
// Capture the controller's initial state once per controller instance so
|
|
58
|
+
// getServerSnapshot is referentially stable across renders (required to avoid
|
|
59
|
+
// infinite render loops in useSyncExternalStore on the server).
|
|
60
|
+
const serverSnapshotRef = useRef<{ controller: ChatController; state: ChatState } | null>(
|
|
61
|
+
null
|
|
62
|
+
);
|
|
63
|
+
if (serverSnapshotRef.current?.controller !== controller) {
|
|
64
|
+
serverSnapshotRef.current = { controller, state: controller.getState() };
|
|
65
|
+
}
|
|
66
|
+
const getServerSnapshot = (): ChatState => serverSnapshotRef.current!.state;
|
|
67
|
+
|
|
68
|
+
const state = useSyncExternalStore(
|
|
69
|
+
controller.subscribe,
|
|
70
|
+
controller.getState,
|
|
71
|
+
getServerSnapshot
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
// Start (client only) once per controller, and tear down on unmount / re-key.
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
void controller.start();
|
|
77
|
+
return () => {
|
|
78
|
+
controller.destroy();
|
|
79
|
+
};
|
|
80
|
+
}, [controller]);
|
|
81
|
+
|
|
82
|
+
return { state, controller };
|
|
83
|
+
}
|