@hibira-io/chat-ui 0.0.0-stage → 0.2.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/PROTOCOL.md +71 -0
- package/README.md +156 -2
- package/conversation.js +188 -0
- package/hibira.d.ts +107 -0
- package/hibira.js +187 -0
- package/index.d.ts +22 -0
- package/index.js +7 -0
- package/locales.js +173 -0
- package/markdown.js +320 -0
- package/package.json +95 -4
- package/react.d.ts +38 -0
- package/react.js +386 -0
- package/server.d.ts +14 -0
- package/server.js +94 -0
- package/styles.css +119 -0
- package/types.d.ts +108 -0
package/react.js
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
// React components: a chat thread with Markdown messages, the agent's steps
|
|
2
|
+
// while it writes, replies that appear as typed, follow-up suggestions, copy
|
|
3
|
+
// buttons and a reply box. Import '@hibira-io/chat-ui/styles.css' for the
|
|
4
|
+
// default look.
|
|
5
|
+
|
|
6
|
+
import { createElement as h, Fragment, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
|
7
|
+
import { parseMarkdown } from './markdown.js';
|
|
8
|
+
import { isAutomatic, STEPS } from './conversation.js';
|
|
9
|
+
import { format, labelsFor } from './locales.js';
|
|
10
|
+
import { chatConversation } from './hibira.js';
|
|
11
|
+
|
|
12
|
+
export { createConversation } from './conversation.js';
|
|
13
|
+
|
|
14
|
+
const LINK = { target: '_blank', rel: 'noopener noreferrer nofollow ugc' };
|
|
15
|
+
|
|
16
|
+
// Icons (Lucide shapes, inlined so the package needs no icon library).
|
|
17
|
+
const PATHS = {
|
|
18
|
+
copy: ['M8 8h12v12H8z', 'M4 16V4h12'],
|
|
19
|
+
check: ['M20 6 9 17l-5-5'],
|
|
20
|
+
spinner: ['M21 12a9 9 0 1 1-6.22-8.56'],
|
|
21
|
+
sparkles: ['M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z', 'M19 3v4', 'M21 5h-4'],
|
|
22
|
+
user: ['M20 21a8 8 0 0 0-16 0', 'M12 13a4 4 0 1 0 0-8 4 4 0 0 0 0 8z'],
|
|
23
|
+
send: ['M22 2 11 13', 'M22 2l-7 20-4-9-9-4z'],
|
|
24
|
+
image: ['M3 3h18v18H3z', 'M21 15l-5-5L5 21', 'M9 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2z'],
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function Icon({ name, size = 14, className }) {
|
|
28
|
+
return h(
|
|
29
|
+
'svg',
|
|
30
|
+
{ width: size, height: size, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': 'true', className },
|
|
31
|
+
PATHS[name].map((d, i) => h('path', { key: i, d })),
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ------------------------------------------------------------ Markdown
|
|
36
|
+
|
|
37
|
+
function inline(nodes, key = 'i') {
|
|
38
|
+
return nodes.map((n, i) => {
|
|
39
|
+
const k = `${key}.${i}`;
|
|
40
|
+
if (typeof n === 'string') return h(Fragment, { key: k }, n);
|
|
41
|
+
switch (n.type) {
|
|
42
|
+
case 'br':
|
|
43
|
+
return h('br', { key: k });
|
|
44
|
+
case 'code':
|
|
45
|
+
return h('code', { key: k }, n.text);
|
|
46
|
+
case 'strong':
|
|
47
|
+
case 'em':
|
|
48
|
+
case 'del':
|
|
49
|
+
return h(n.type, { key: k }, inline(n.children, k));
|
|
50
|
+
case 'link':
|
|
51
|
+
return h('a', { key: k, href: n.href, ...LINK }, inline(n.children, k));
|
|
52
|
+
case 'image':
|
|
53
|
+
return h('a', { key: k, href: n.src, ...LINK, className: 'hb-chat-img' }, h('img', { src: n.src, alt: n.alt, loading: 'lazy', decoding: 'async' }));
|
|
54
|
+
case 'imagelink':
|
|
55
|
+
return h('a', { key: k, href: n.href, ...LINK, className: 'hb-chat-imglink' }, h(Icon, { name: 'image' }), n.alt);
|
|
56
|
+
default:
|
|
57
|
+
return h(Fragment, { key: k }, inline(n.children ?? [], k));
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function blocks(list, labels, key = 'b') {
|
|
63
|
+
return list.map((b, i) => {
|
|
64
|
+
const k = `${key}.${i}`;
|
|
65
|
+
switch (b.type) {
|
|
66
|
+
case 'p':
|
|
67
|
+
return h('p', { key: k }, inline(b.children, k));
|
|
68
|
+
case 'heading':
|
|
69
|
+
return h('p', { key: k, className: 'hb-chat-h' }, h('strong', null, inline(b.children, k)));
|
|
70
|
+
case 'code':
|
|
71
|
+
return h(
|
|
72
|
+
'div',
|
|
73
|
+
{ key: k, className: 'hb-chat-pre' },
|
|
74
|
+
h('pre', { 'data-lang': b.lang || undefined }, h('code', null, b.text)),
|
|
75
|
+
h(CopyButton, { text: b.text, label: labels.copyCode, labels, className: 'hb-chat-code-copy' }),
|
|
76
|
+
);
|
|
77
|
+
case 'quote':
|
|
78
|
+
return h('blockquote', { key: k }, blocks(b.children, labels, k));
|
|
79
|
+
case 'list': {
|
|
80
|
+
const items = b.items.map((item, j) => {
|
|
81
|
+
const [first, ...rest] = item;
|
|
82
|
+
return h('li', { key: j }, first?.type === 'p' ? [h(Fragment, { key: 'f' }, inline(first.children, `${k}.${j}`)), ...blocks(rest, labels, `${k}.${j}`)] : blocks(item, labels, `${k}.${j}`));
|
|
83
|
+
});
|
|
84
|
+
return b.ordered ? h('ol', { key: k, start: b.start !== 1 ? b.start : undefined }, items) : h('ul', { key: k }, items);
|
|
85
|
+
}
|
|
86
|
+
case 'table':
|
|
87
|
+
return h(
|
|
88
|
+
'div',
|
|
89
|
+
{ key: k, className: 'hb-chat-table' },
|
|
90
|
+
h(
|
|
91
|
+
'table',
|
|
92
|
+
null,
|
|
93
|
+
h('thead', null, h('tr', null, b.head.map((c, j) => h('th', { key: j, style: b.align[j] ? { textAlign: b.align[j] } : undefined }, inline(c, `${k}.h${j}`))))),
|
|
94
|
+
h(
|
|
95
|
+
'tbody',
|
|
96
|
+
null,
|
|
97
|
+
b.rows.map((r, j) => h('tr', { key: j }, r.map((c, x) => h('td', { key: x, style: b.align[x] ? { textAlign: b.align[x] } : undefined }, inline(c, `${k}.${j}.${x}`))))),
|
|
98
|
+
),
|
|
99
|
+
),
|
|
100
|
+
);
|
|
101
|
+
case 'hr':
|
|
102
|
+
return h('hr', { key: k });
|
|
103
|
+
default:
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A message's Markdown, rendered safely (no HTML from the text).
|
|
111
|
+
* @param {{ text: string, locale?: string, labels?: object, origin?: string, imageOrigins?: string[] }} props
|
|
112
|
+
*/
|
|
113
|
+
export function Markdown({ text, locale, labels, origin, imageOrigins }) {
|
|
114
|
+
const l = labelsFor(locale, labels);
|
|
115
|
+
return h('div', { className: 'hb-chat-md' }, blocks(parseMarkdown(text, { origin, imageOrigins }), l));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ------------------------------------------------------------ pieces
|
|
119
|
+
|
|
120
|
+
/** Copies text, showing a check for a moment. */
|
|
121
|
+
export function CopyButton({ text, label, labels, locale, className }) {
|
|
122
|
+
const l = labels ?? labelsFor(locale);
|
|
123
|
+
const [done, setDone] = useState(false);
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
if (!done) return undefined;
|
|
126
|
+
const id = setTimeout(() => setDone(false), 1500);
|
|
127
|
+
return () => clearTimeout(id);
|
|
128
|
+
}, [done]);
|
|
129
|
+
const tip = done ? l.copied : label ?? l.copy;
|
|
130
|
+
return h(
|
|
131
|
+
'button',
|
|
132
|
+
{
|
|
133
|
+
type: 'button',
|
|
134
|
+
className: 'hb-chat-icon-btn' + (className ? ' ' + className : ''),
|
|
135
|
+
'aria-label': tip,
|
|
136
|
+
title: tip,
|
|
137
|
+
onClick: () => {
|
|
138
|
+
globalThis.navigator?.clipboard?.writeText(text).then(
|
|
139
|
+
() => setDone(true),
|
|
140
|
+
() => undefined,
|
|
141
|
+
);
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
h(Icon, { name: done ? 'check' : 'copy' }),
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The agent's progress while it writes: steps done so far with a check, the
|
|
150
|
+
* current one with a spinner. Without a step, a plain typing line.
|
|
151
|
+
*/
|
|
152
|
+
export function AgentSteps({ typing, step, locale, labels }) {
|
|
153
|
+
const l = labels ?? labelsFor(locale);
|
|
154
|
+
const [seen, setSeen] = useState([]);
|
|
155
|
+
const known = !!step && !!l.steps[step];
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
if (!typing) setSeen([]);
|
|
158
|
+
else if (known) setSeen((s) => (s.includes(step) ? s : [...s, step]));
|
|
159
|
+
}, [typing, step, known]);
|
|
160
|
+
if (!typing) return null;
|
|
161
|
+
const order = (s) => (STEPS.includes(s) ? STEPS.indexOf(s) : STEPS.length);
|
|
162
|
+
const list = [...new Set(known ? [...seen, step] : seen)].sort((a, b) => order(a) - order(b));
|
|
163
|
+
if (!list.length) return h('p', { className: 'hb-chat-typing', 'aria-live': 'polite' }, l.typing);
|
|
164
|
+
return h(
|
|
165
|
+
'div',
|
|
166
|
+
{ className: 'hb-chat-steps', 'aria-live': 'polite' },
|
|
167
|
+
h('span', { className: 'hb-chat-avatar' }, h(Icon, { name: 'sparkles' })),
|
|
168
|
+
h(
|
|
169
|
+
'ol',
|
|
170
|
+
null,
|
|
171
|
+
list.map((s) =>
|
|
172
|
+
h('li', { key: s, className: s === step ? 'is-current' : 'is-done', 'data-step': s }, h(Icon, { name: s === step ? 'spinner' : 'check', className: s === step ? 'hb-chat-spin' : undefined }), h('span', null, l.steps[s])),
|
|
173
|
+
),
|
|
174
|
+
),
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Follow-up questions to send with one tap. */
|
|
179
|
+
export function Suggestions({ items, onPick, disabled, locale, labels }) {
|
|
180
|
+
const l = labels ?? labelsFor(locale);
|
|
181
|
+
if (!items?.length) return null;
|
|
182
|
+
return h(
|
|
183
|
+
'div',
|
|
184
|
+
{ className: 'hb-chat-suggestions' },
|
|
185
|
+
h('span', null, l.suggestions),
|
|
186
|
+
h(
|
|
187
|
+
'div',
|
|
188
|
+
null,
|
|
189
|
+
items.map((s) => h('button', { key: s, type: 'button', className: 'hb-chat-chip', disabled, onClick: () => onPick(s) }, s)),
|
|
190
|
+
),
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const reducedMotion = () => typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Shows text as if typed while `animate` is set: about 1.2 s at most,
|
|
198
|
+
* whatever the length, by whole characters. Reduced motion shows it at once.
|
|
199
|
+
*/
|
|
200
|
+
export function useReveal(text, animate) {
|
|
201
|
+
const chars = useRef([]);
|
|
202
|
+
const [n, setN] = useState(() => (animate && !reducedMotion() ? 0 : Infinity));
|
|
203
|
+
useEffect(() => {
|
|
204
|
+
if (!animate || reducedMotion()) {
|
|
205
|
+
setN(Infinity);
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
chars.current = Array.from(text);
|
|
209
|
+
const total = chars.current.length;
|
|
210
|
+
const per = Math.max(1, Math.ceil(total / 60));
|
|
211
|
+
let i = 0;
|
|
212
|
+
const id = setInterval(() => {
|
|
213
|
+
i += per;
|
|
214
|
+
setN(i);
|
|
215
|
+
if (i >= total) clearInterval(id);
|
|
216
|
+
}, 20);
|
|
217
|
+
return () => clearInterval(id);
|
|
218
|
+
}, [text, animate]);
|
|
219
|
+
if (n === Infinity || n >= Array.from(text).length) return { shown: text, done: true };
|
|
220
|
+
return { shown: chars.current.slice(0, n).join(''), done: false };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function ago(iso, locale) {
|
|
224
|
+
const t = Date.parse(iso);
|
|
225
|
+
if (Number.isNaN(t)) return '';
|
|
226
|
+
const s = Math.round((t - Date.now()) / 1000);
|
|
227
|
+
const fmt = new Intl.RelativeTimeFormat(locale || 'en', { numeric: 'auto' });
|
|
228
|
+
const abs = Math.abs(s);
|
|
229
|
+
if (abs < 45) return fmt.format(0, 'second');
|
|
230
|
+
if (abs < 3600) return fmt.format(Math.round(s / 60), 'minute');
|
|
231
|
+
if (abs < 86400) return fmt.format(Math.round(s / 3600), 'hour');
|
|
232
|
+
return fmt.format(Math.round(s / 86400), 'day');
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function Bubble({ m, mine, animate, seen, labels, locale, origin, imageOrigins, renderName }) {
|
|
236
|
+
const auto = isAutomatic(m);
|
|
237
|
+
const { shown, done } = useReveal(m.text, animate && auto);
|
|
238
|
+
return h(
|
|
239
|
+
'li',
|
|
240
|
+
{ className: 'hb-chat-msg ' + (mine ? 'is-mine' : 'is-theirs') + (auto ? ' is-agent' : ''), 'data-sender': m.sender },
|
|
241
|
+
h('div', { className: 'hb-chat-bubble' + (done ? '' : ' is-revealing') }, h('div', { className: 'hb-chat-md' }, blocks(parseMarkdown(shown, { origin, imageOrigins }), labels))),
|
|
242
|
+
h(
|
|
243
|
+
'div',
|
|
244
|
+
{ className: 'hb-chat-meta' },
|
|
245
|
+
!mine && h('span', null, renderName ? renderName(m) : m.name),
|
|
246
|
+
auto &&
|
|
247
|
+
(m.metadata?.handoff
|
|
248
|
+
? h('span', { className: 'hb-chat-badge is-handoff' }, h(Icon, { name: 'user', size: 12 }), labels.handoff)
|
|
249
|
+
: h('span', { className: 'hb-chat-badge' }, h(Icon, { name: 'sparkles', size: 12 }), labels.automatic)),
|
|
250
|
+
m.created_at && h('time', { dateTime: m.created_at, title: new Date(m.created_at).toLocaleString(locale) }, ago(m.created_at, locale)),
|
|
251
|
+
seen && h('span', null, labels.seen),
|
|
252
|
+
auto && done && !m.metadata?.handoff && h(CopyButton, { text: m.text, labels }),
|
|
253
|
+
),
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* A conversation: messages, the agent's steps, follow-up suggestions and a
|
|
259
|
+
* reply box. Give it the state from useConversation(), or your own.
|
|
260
|
+
*/
|
|
261
|
+
export function ChatThread({
|
|
262
|
+
messages,
|
|
263
|
+
typing = false,
|
|
264
|
+
step,
|
|
265
|
+
onSend,
|
|
266
|
+
me = 'customer',
|
|
267
|
+
locale,
|
|
268
|
+
labels: overrides,
|
|
269
|
+
maxLength = 4000,
|
|
270
|
+
readSeq,
|
|
271
|
+
disabled = false,
|
|
272
|
+
footer,
|
|
273
|
+
origin,
|
|
274
|
+
imageOrigins,
|
|
275
|
+
renderName,
|
|
276
|
+
}) {
|
|
277
|
+
const labels = labelsFor(locale, overrides);
|
|
278
|
+
const [text, setText] = useState('');
|
|
279
|
+
const [busy, setBusy] = useState(false);
|
|
280
|
+
const [error, setError] = useState('');
|
|
281
|
+
const end = useRef(null);
|
|
282
|
+
const last = messages.length ? messages[messages.length - 1].seq : 0;
|
|
283
|
+
// Only messages that arrive while the thread is open are revealed as typed.
|
|
284
|
+
const firstSeq = useRef(null);
|
|
285
|
+
if (firstSeq.current === null && messages.length) firstSeq.current = last;
|
|
286
|
+
useEffect(() => {
|
|
287
|
+
end.current?.scrollIntoView?.({ block: 'nearest' });
|
|
288
|
+
}, [last, typing]);
|
|
289
|
+
const lastMine = [...messages].reverse().find((m) => m.sender === me);
|
|
290
|
+
const lastMsg = messages[messages.length - 1];
|
|
291
|
+
const suggestions = lastMsg && isAutomatic(lastMsg) && !typing ? lastMsg.metadata?.suggestions ?? [] : [];
|
|
292
|
+
const tooLong = [...text.trim()].length > maxLength;
|
|
293
|
+
const canSend = !!onSend && !disabled;
|
|
294
|
+
|
|
295
|
+
async function send(e, picked) {
|
|
296
|
+
e?.preventDefault?.();
|
|
297
|
+
const v = (picked ?? text).trim();
|
|
298
|
+
if (!v || [...v].length > maxLength || busy) return;
|
|
299
|
+
setBusy(true);
|
|
300
|
+
setError('');
|
|
301
|
+
try {
|
|
302
|
+
await onSend(v);
|
|
303
|
+
if (picked === undefined) setText('');
|
|
304
|
+
} catch (err) {
|
|
305
|
+
setError(err?.message ?? String(err));
|
|
306
|
+
} finally {
|
|
307
|
+
setBusy(false);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return h(
|
|
312
|
+
'div',
|
|
313
|
+
{ className: 'hb-chat' },
|
|
314
|
+
h(
|
|
315
|
+
'ol',
|
|
316
|
+
{ className: 'hb-chat-thread', 'aria-live': 'polite' },
|
|
317
|
+
!messages.length && h('li', { className: 'hb-chat-empty' }, labels.empty),
|
|
318
|
+
messages.map((m) =>
|
|
319
|
+
h(Bubble, {
|
|
320
|
+
key: m.seq,
|
|
321
|
+
m,
|
|
322
|
+
mine: m.sender === me,
|
|
323
|
+
animate: firstSeq.current !== null && m.seq > firstSeq.current,
|
|
324
|
+
seen: m === lastMine && readSeq !== undefined && readSeq >= m.seq,
|
|
325
|
+
labels,
|
|
326
|
+
locale,
|
|
327
|
+
origin,
|
|
328
|
+
imageOrigins,
|
|
329
|
+
renderName,
|
|
330
|
+
}),
|
|
331
|
+
),
|
|
332
|
+
h('li', { ref: end, 'aria-hidden': 'true' }),
|
|
333
|
+
),
|
|
334
|
+
h(AgentSteps, { typing, step, labels }),
|
|
335
|
+
canSend && h(Suggestions, { items: suggestions, labels, disabled: busy, onPick: (v) => void send(undefined, v) }),
|
|
336
|
+
canSend &&
|
|
337
|
+
h(
|
|
338
|
+
'form',
|
|
339
|
+
{ className: 'hb-chat-compose', onSubmit: send, noValidate: true },
|
|
340
|
+
h('textarea', {
|
|
341
|
+
rows: 2,
|
|
342
|
+
value: text,
|
|
343
|
+
onChange: (e) => setText(e.target.value),
|
|
344
|
+
onKeyDown: (e) => {
|
|
345
|
+
// Enter sends; Shift+Enter starts a new line (not while composing CJK text).
|
|
346
|
+
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent?.isComposing) {
|
|
347
|
+
e.preventDefault();
|
|
348
|
+
void send();
|
|
349
|
+
}
|
|
350
|
+
},
|
|
351
|
+
'aria-label': labels.placeholder,
|
|
352
|
+
placeholder: labels.placeholder,
|
|
353
|
+
'aria-invalid': tooLong || undefined,
|
|
354
|
+
}),
|
|
355
|
+
h('button', { type: 'submit', className: 'hb-chat-send', 'aria-label': labels.send, title: labels.send, disabled: busy || !text.trim() || tooLong }, h(Icon, { name: 'send', size: 16 })),
|
|
356
|
+
),
|
|
357
|
+
(error || tooLong) && h('p', { className: 'hb-chat-error', role: 'alert' }, tooLong ? format(labels.tooLong, { n: maxLength }) : error),
|
|
358
|
+
footer,
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** The state of a conversation from createConversation(), started while mounted. */
|
|
363
|
+
export function useConversation(conversation) {
|
|
364
|
+
useEffect(() => conversation?.start(), [conversation]);
|
|
365
|
+
return useSyncExternalStore(
|
|
366
|
+
conversation?.subscribe ?? noop,
|
|
367
|
+
conversation?.get ?? emptyState,
|
|
368
|
+
conversation?.get ?? emptyState,
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const EMPTY = { messages: [], loading: false, error: null, typing: false, step: undefined, extra: {} };
|
|
373
|
+
const emptyState = () => EMPTY;
|
|
374
|
+
const noop = () => () => {};
|
|
375
|
+
|
|
376
|
+
/** ChatThread wired to a conversation from createConversation(). */
|
|
377
|
+
export function Chat({ conversation, ...props }) {
|
|
378
|
+
const state = useConversation(conversation);
|
|
379
|
+
return h(ChatThread, { messages: state.messages, typing: state.typing, step: state.step, onSend: conversation ? (t) => conversation.send(t) : undefined, ...props });
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** A Hibira Chat API conversation (createChatClient) for <Chat>, made once per client, id and pusher. */
|
|
383
|
+
export function useChatConversation(client, id, options = {}) {
|
|
384
|
+
const { pusher, me } = options;
|
|
385
|
+
return useMemo(() => (client && id ? chatConversation(client, id, options) : null), [client, id, pusher, me]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
386
|
+
}
|
package/server.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { AgentMetadata, AgentStep } from './types.js';
|
|
2
|
+
|
|
3
|
+
export const STEPS: readonly string[];
|
|
4
|
+
export function cleanSuggestions(list: unknown): string[];
|
|
5
|
+
export function agentMetadata(options?: { handoff?: boolean; suggestions?: unknown }): AgentMetadata;
|
|
6
|
+
|
|
7
|
+
export interface ChatServer {
|
|
8
|
+
message(channel: string, data: { seq: number; from?: string }): Promise<unknown>;
|
|
9
|
+
typing(channel: string, data?: { from?: string; step?: AgentStep }): Promise<unknown>;
|
|
10
|
+
hint(channel: string, event: string, data?: Record<string, unknown>): Promise<unknown>;
|
|
11
|
+
agentTyping(channel: string, options?: { from?: string; step?: AgentStep }): { step(name: AgentStep): void; stop(): void };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createChatServer(options: { pusher: { trigger(channel: string, event: string, data: unknown): unknown }; typingEvery?: number }): ChatServer;
|
package/server.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Server side of the protocol (PROTOCOL.md), for Node: tell a conversation's
|
|
2
|
+
// channel that a message arrived, that someone is typing, or what your agent
|
|
3
|
+
// is doing, with the standard `pusher` library pointed at your Hibira app.
|
|
4
|
+
// Your backend stores the messages; the channel only carries hints.
|
|
5
|
+
|
|
6
|
+
export { STEPS } from './conversation.js';
|
|
7
|
+
|
|
8
|
+
const MAX_SUGGESTIONS = 3;
|
|
9
|
+
const MAX_SUGGESTION_CHARS = 80;
|
|
10
|
+
const LINK_LIKE = /https?:\/\/|www\.|\w\.(com|io|net|org|dev|app)\b/i;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Keeps up to three short, distinct follow-up questions without links,
|
|
14
|
+
* line breaks or markup: the customer sends one as their message.
|
|
15
|
+
* @param {unknown} list
|
|
16
|
+
*/
|
|
17
|
+
export function cleanSuggestions(list) {
|
|
18
|
+
if (!Array.isArray(list)) return [];
|
|
19
|
+
const out = [];
|
|
20
|
+
const seen = new Set();
|
|
21
|
+
for (const raw of list) {
|
|
22
|
+
if (typeof raw !== 'string') continue;
|
|
23
|
+
const v = raw.trim();
|
|
24
|
+
const n = [...v].length;
|
|
25
|
+
const key = v.toLowerCase();
|
|
26
|
+
if (n < 2 || n > MAX_SUGGESTION_CHARS || /[\n\r<>]/.test(v) || LINK_LIKE.test(v) || seen.has(key)) continue;
|
|
27
|
+
seen.add(key);
|
|
28
|
+
out.push(v);
|
|
29
|
+
if (out.length === MAX_SUGGESTIONS) break;
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The metadata of an automatic reply: the components show it as automatic,
|
|
36
|
+
* with its follow-up suggestions, or as handed to a person.
|
|
37
|
+
* @param {{ handoff?: boolean, suggestions?: unknown }} [options]
|
|
38
|
+
*/
|
|
39
|
+
export function agentMetadata(options = {}) {
|
|
40
|
+
const suggestions = options.handoff ? [] : cleanSuggestions(options.suggestions);
|
|
41
|
+
return {
|
|
42
|
+
assistant: true,
|
|
43
|
+
...(options.handoff ? { handoff: true } : {}),
|
|
44
|
+
...(suggestions.length ? { suggestions } : {}),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {{ pusher: { trigger: (channel: string, event: string, data: any) => Promise<any> },
|
|
50
|
+
* typingEvery?: number }} options
|
|
51
|
+
*/
|
|
52
|
+
export function createChatServer(options) {
|
|
53
|
+
if (!options?.pusher?.trigger) throw new TypeError('createChatServer() needs a pusher instance.');
|
|
54
|
+
const { pusher } = options;
|
|
55
|
+
const every = options.typingEvery ?? 4_000;
|
|
56
|
+
const trigger = (channel, event, data) =>
|
|
57
|
+
Promise.resolve(pusher.trigger(channel, event, data)).catch(() => {
|
|
58
|
+
// Hints are best effort: the page polls.
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
/** A message was stored: pages read what they have not seen. */
|
|
63
|
+
message: (channel, { seq, from }) => trigger(channel, 'message', { seq, ...(from ? { from } : {}) }),
|
|
64
|
+
/** Someone on `from`'s side is typing (pages send this every few seconds while a person types). */
|
|
65
|
+
typing: (channel, { from = 'agent', step } = {}) => trigger(channel, 'typing', { from, ...(step ? { step } : {}) }),
|
|
66
|
+
/** Any other hint the page handles itself (status, read, ...). */
|
|
67
|
+
hint: (channel, event, data = {}) => trigger(channel, event, data),
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Shows the agent at work until the returned stop() is called: a typing
|
|
71
|
+
* hint now, again every few seconds, and at once on each step().
|
|
72
|
+
* @returns {{ step: (name: string) => void, stop: () => void }}
|
|
73
|
+
*/
|
|
74
|
+
agentTyping(channel, { from = 'agent', step = 'reading' } = {}) {
|
|
75
|
+
let current = step;
|
|
76
|
+
let stopped = false;
|
|
77
|
+
const send = () => (stopped ? Promise.resolve() : trigger(channel, 'typing', { from, step: current }));
|
|
78
|
+
void send();
|
|
79
|
+
const timer = setInterval(send, every);
|
|
80
|
+
timer.unref?.();
|
|
81
|
+
return {
|
|
82
|
+
step(name) {
|
|
83
|
+
if (name === current || stopped) return;
|
|
84
|
+
current = name;
|
|
85
|
+
void send();
|
|
86
|
+
},
|
|
87
|
+
stop() {
|
|
88
|
+
stopped = true;
|
|
89
|
+
clearInterval(timer);
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
package/styles.css
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/* Default look for @hibira-io/chat-ui components. Every color is a variable
|
|
2
|
+
on .hb-chat, so restyling is a few overrides. Dark mode follows
|
|
3
|
+
prefers-color-scheme, or set data-theme="dark" / "light" on any ancestor. */
|
|
4
|
+
|
|
5
|
+
.hb-chat {
|
|
6
|
+
--hb-chat-bg: #ffffff;
|
|
7
|
+
--hb-chat-surface: #f4f3f1;
|
|
8
|
+
--hb-chat-border: #e6e4df;
|
|
9
|
+
--hb-chat-ink: #111110;
|
|
10
|
+
--hb-chat-muted: #6f6d66;
|
|
11
|
+
--hb-chat-accent: #e5484d;
|
|
12
|
+
--hb-chat-accent-ink: #ffffff;
|
|
13
|
+
--hb-chat-radius: 14px;
|
|
14
|
+
--hb-chat-font: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
|
|
15
|
+
--hb-chat-mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
|
16
|
+
--hb-chat-max-height: 60vh;
|
|
17
|
+
color: var(--hb-chat-ink);
|
|
18
|
+
background: var(--hb-chat-bg);
|
|
19
|
+
font: 14px/1.5 var(--hb-chat-font);
|
|
20
|
+
display: flex;
|
|
21
|
+
flex-direction: column;
|
|
22
|
+
gap: 8px;
|
|
23
|
+
box-sizing: border-box;
|
|
24
|
+
}
|
|
25
|
+
.hb-chat * { box-sizing: border-box; }
|
|
26
|
+
|
|
27
|
+
@media (prefers-color-scheme: dark) {
|
|
28
|
+
:root:not([data-theme='light']) .hb-chat {
|
|
29
|
+
--hb-chat-bg: #191918;
|
|
30
|
+
--hb-chat-surface: #222221;
|
|
31
|
+
--hb-chat-border: #3b3a37;
|
|
32
|
+
--hb-chat-ink: #eeeeec;
|
|
33
|
+
--hb-chat-muted: #b5b3ad;
|
|
34
|
+
--hb-chat-accent: #ff6369;
|
|
35
|
+
--hb-chat-accent-ink: #191918;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
[data-theme='dark'] .hb-chat {
|
|
39
|
+
--hb-chat-bg: #191918;
|
|
40
|
+
--hb-chat-surface: #222221;
|
|
41
|
+
--hb-chat-border: #3b3a37;
|
|
42
|
+
--hb-chat-ink: #eeeeec;
|
|
43
|
+
--hb-chat-muted: #b5b3ad;
|
|
44
|
+
--hb-chat-accent: #ff6369;
|
|
45
|
+
--hb-chat-accent-ink: #191918;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.hb-chat-thread { list-style: none; margin: 0; padding: 4px 0; display: flex; flex-direction: column; gap: 10px; max-height: var(--hb-chat-max-height); overflow-y: auto; }
|
|
49
|
+
.hb-chat-empty { color: var(--hb-chat-muted); text-align: center; padding: 16px 0; }
|
|
50
|
+
.hb-chat-msg { display: flex; flex-direction: column; max-width: 85%; min-width: 0; }
|
|
51
|
+
.hb-chat-msg.is-mine { align-self: flex-end; align-items: flex-end; }
|
|
52
|
+
.hb-chat-bubble { padding: 8px 12px; border-radius: var(--hb-chat-radius); background: var(--hb-chat-surface); overflow-wrap: anywhere; min-width: 0; max-width: 100%; }
|
|
53
|
+
.is-mine .hb-chat-bubble { background: var(--hb-chat-accent); color: var(--hb-chat-accent-ink); }
|
|
54
|
+
.hb-chat-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 2px; font-size: 0.75rem; color: var(--hb-chat-muted); }
|
|
55
|
+
.hb-chat-badge { display: inline-flex; align-items: center; gap: 3px; padding: 0 6px; border: 1px solid var(--hb-chat-border); border-radius: 999px; }
|
|
56
|
+
.hb-chat-badge.is-handoff { border-style: dashed; }
|
|
57
|
+
|
|
58
|
+
/* Markdown */
|
|
59
|
+
.hb-chat-md { white-space: normal; overflow-wrap: anywhere; min-width: 0; }
|
|
60
|
+
.hb-chat-md > * { margin: 0; }
|
|
61
|
+
.hb-chat-md > * + * { margin-top: 6px; }
|
|
62
|
+
.hb-chat-md ul, .hb-chat-md ol { margin: 0; padding-left: 1.3em; }
|
|
63
|
+
.hb-chat-md li + li { margin-top: 2px; }
|
|
64
|
+
.hb-chat-md li > * { margin-top: 2px; }
|
|
65
|
+
.hb-chat-md a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
|
|
66
|
+
.hb-chat-md code { font-family: var(--hb-chat-mono); font-size: 0.88em; padding: 0 4px; border-radius: 4px; background: color-mix(in srgb, currentColor 12%, transparent); }
|
|
67
|
+
.hb-chat-pre { position: relative; }
|
|
68
|
+
.hb-chat-md pre { margin: 0; padding: 8px 36px 8px 10px; border-radius: 8px; overflow-x: auto; white-space: pre; background: color-mix(in srgb, currentColor 10%, transparent); max-width: 100%; }
|
|
69
|
+
.hb-chat-md pre code { padding: 0; background: none; font-size: 0.85em; }
|
|
70
|
+
.hb-chat-code-copy { position: absolute; top: 4px; right: 4px; opacity: 0.7; }
|
|
71
|
+
.hb-chat-pre:hover .hb-chat-code-copy, .hb-chat-code-copy:focus-visible { opacity: 1; }
|
|
72
|
+
.hb-chat-md blockquote { margin: 0; padding-left: 10px; border-left: 3px solid color-mix(in srgb, currentColor 30%, transparent); opacity: 0.9; }
|
|
73
|
+
.hb-chat-md hr { border: 0; border-top: 1px solid color-mix(in srgb, currentColor 25%, transparent); }
|
|
74
|
+
.hb-chat-md .hb-chat-img { display: inline-block; text-decoration: none; }
|
|
75
|
+
.hb-chat-md .hb-chat-img img { display: block; max-width: min(100%, 320px); max-height: 240px; height: auto; border-radius: 8px; }
|
|
76
|
+
.hb-chat-imglink svg { display: inline-block; vertical-align: -2px; margin-right: 3px; }
|
|
77
|
+
.hb-chat-table { overflow-x: auto; max-width: 100%; }
|
|
78
|
+
.hb-chat-md table { border-collapse: collapse; font-size: 0.9em; }
|
|
79
|
+
.hb-chat-md th, .hb-chat-md td { padding: 3px 8px; border: 1px solid color-mix(in srgb, currentColor 22%, transparent); text-align: left; vertical-align: top; }
|
|
80
|
+
.hb-chat-md th { font-weight: 600; }
|
|
81
|
+
.is-revealing .hb-chat-md > :last-child::after { content: ''; display: inline-block; width: 0.5em; height: 1em; margin-left: 2px; vertical-align: -2px; background: currentColor; opacity: 0.5; animation: hb-chat-blink 0.9s steps(2) infinite; }
|
|
82
|
+
|
|
83
|
+
/* Buttons */
|
|
84
|
+
.hb-chat-icon-btn { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; padding: 0; border: 0; border-radius: 6px; background: transparent; color: inherit; cursor: pointer; }
|
|
85
|
+
.hb-chat-icon-btn:hover { background: color-mix(in srgb, currentColor 12%, transparent); }
|
|
86
|
+
.hb-chat-icon-btn:focus-visible, .hb-chat-chip:focus-visible, .hb-chat-send:focus-visible { outline: 2px solid var(--hb-chat-accent); outline-offset: 1px; }
|
|
87
|
+
|
|
88
|
+
/* Agent steps */
|
|
89
|
+
.hb-chat-typing { margin: 0; font-size: 0.85rem; color: var(--hb-chat-muted); font-style: italic; }
|
|
90
|
+
.hb-chat-steps { display: flex; gap: 8px; align-items: flex-start; font-size: 0.85rem; color: var(--hb-chat-muted); }
|
|
91
|
+
.hb-chat-avatar { display: inline-flex; align-items: center; justify-content: center; flex: none; width: 24px; height: 24px; border-radius: 50%; background: var(--hb-chat-surface); border: 1px solid var(--hb-chat-border); }
|
|
92
|
+
.hb-chat-steps ol { list-style: none; margin: 0; padding: 2px 0 0; display: flex; flex-direction: column; gap: 3px; }
|
|
93
|
+
.hb-chat-steps li { display: flex; align-items: center; gap: 6px; animation: hb-chat-in 0.25s ease-out; }
|
|
94
|
+
.hb-chat-steps li.is-current { color: var(--hb-chat-ink); font-weight: 500; }
|
|
95
|
+
.hb-chat-steps li.is-done { opacity: 0.75; }
|
|
96
|
+
.hb-chat-spin { animation: hb-chat-spin 0.9s linear infinite; }
|
|
97
|
+
|
|
98
|
+
/* Suggestions */
|
|
99
|
+
.hb-chat-suggestions { display: flex; flex-direction: column; gap: 4px; }
|
|
100
|
+
.hb-chat-suggestions > span { font-size: 0.8rem; color: var(--hb-chat-muted); }
|
|
101
|
+
.hb-chat-suggestions > div { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
102
|
+
.hb-chat-chip { font: inherit; font-size: 0.85rem; padding: 4px 10px; border: 1px solid var(--hb-chat-border); border-radius: 999px; background: transparent; color: inherit; cursor: pointer; text-align: left; animation: hb-chat-in 0.25s ease-out; }
|
|
103
|
+
.hb-chat-chip:hover:not(:disabled) { background: var(--hb-chat-surface); }
|
|
104
|
+
.hb-chat-chip:disabled { opacity: 0.5; cursor: default; }
|
|
105
|
+
|
|
106
|
+
/* Reply box */
|
|
107
|
+
.hb-chat-compose { display: flex; gap: 8px; align-items: flex-end; }
|
|
108
|
+
.hb-chat-compose textarea { flex: 1; min-height: 44px; resize: vertical; font: inherit; color: inherit; background: var(--hb-chat-bg); padding: 8px 10px; border: 1px solid var(--hb-chat-border); border-radius: 10px; }
|
|
109
|
+
.hb-chat-compose textarea:focus { outline: 2px solid var(--hb-chat-accent); outline-offset: -1px; }
|
|
110
|
+
.hb-chat-send { display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; border: 0; border-radius: 10px; background: var(--hb-chat-accent); color: var(--hb-chat-accent-ink); cursor: pointer; }
|
|
111
|
+
.hb-chat-send:disabled { opacity: 0.5; cursor: default; }
|
|
112
|
+
.hb-chat-error { margin: 0; font-size: 0.85rem; color: var(--hb-chat-accent); }
|
|
113
|
+
|
|
114
|
+
@keyframes hb-chat-spin { to { transform: rotate(360deg); } }
|
|
115
|
+
@keyframes hb-chat-blink { to { opacity: 0; } }
|
|
116
|
+
@keyframes hb-chat-in { from { opacity: 0; transform: translateY(2px); } }
|
|
117
|
+
@media (prefers-reduced-motion: reduce) {
|
|
118
|
+
.hb-chat-spin, .hb-chat-steps li, .hb-chat-chip, .is-revealing .hb-chat-md > :last-child::after { animation: none; }
|
|
119
|
+
}
|