@hibira-io/chat-ui 0.0.0-stage → 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/markdown.js ADDED
@@ -0,0 +1,320 @@
1
+ // Markdown for chat messages, as data. parseMarkdown() turns a message into
2
+ // a small tree of blocks and inline nodes that the components render with
3
+ // their framework's elements, so a message never becomes HTML: raw HTML in a
4
+ // message stays visible text.
5
+ //
6
+ // Blocks: paragraphs (single line breaks kept, as typed in a chat), ```
7
+ // fenced code, # headings, > quotes, - * + and 1. lists (nested by indent),
8
+ // pipe tables and --- rules. Inline: `code`, **bold**, *italic*, ~~strike~~,
9
+ // [links](url), bare https:// and www. links, and ![images](url).
10
+ //
11
+ // Only http(s), mailto and same-site paths become links (no javascript:,
12
+ // data:, //host). Images are kept inline only from the allowed origins
13
+ // (by default the page's own); any other image becomes a link to it, so a
14
+ // message cannot make the reader's browser fetch a third-party URL.
15
+
16
+ const FENCE = /^ {0,3}(`{3,}|~{3,})\s*([\w+#.-]*)\s*$/;
17
+ const HEADING = /^ {0,3}#{1,6}\s+(.*?)\s*#*\s*$/;
18
+ const QUOTE = /^ {0,3}>\s?/;
19
+ const ITEM = /^(\s*)([-*+]|\d{1,9}[.)])\s+(.*)$/;
20
+ const RULE = /^ {0,3}([-*_])(\s*\1){2,}\s*$/;
21
+ const TABLE_SEP = /^(?=.*\|)\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/;
22
+ const MAX_DEPTH = 4;
23
+
24
+ const cells = (line) => {
25
+ const s = line.trim().replace(/^\|/, '').replace(/(?<!\\)\|$/, '');
26
+ return s.split(/(?<!\\)\|/).map((c) => c.trim().replace(/\\\|/g, '|'));
27
+ };
28
+
29
+ const startsBlock = (line, next) =>
30
+ FENCE.test(line) ||
31
+ HEADING.test(line) ||
32
+ QUOTE.test(line) ||
33
+ ITEM.test(line) ||
34
+ RULE.test(line) ||
35
+ (line.includes('|') && next !== undefined && TABLE_SEP.test(next));
36
+
37
+ /**
38
+ * Whether a link target is safe to follow: http(s), mailto, or a path on
39
+ * the same site. Returns the normalized target, or null.
40
+ */
41
+ export function safeHref(url) {
42
+ const u = String(url).trim();
43
+ if (/^\/(?!\/)/.test(u) || u.startsWith('#')) return u;
44
+ if (/^(https?:\/\/|mailto:)/i.test(u)) {
45
+ try {
46
+ const parsed = new URL(u);
47
+ return ['http:', 'https:', 'mailto:'].includes(parsed.protocol) ? parsed.href : null;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+ return null;
53
+ }
54
+
55
+ function pageOrigin() {
56
+ return typeof location === 'undefined' ? '' : location.origin;
57
+ }
58
+
59
+ /** The address an image may load from, or null when it must stay a link. */
60
+ export function imageSrc(url, options = {}) {
61
+ const href = safeHref(url);
62
+ if (!href || href.startsWith('mailto:') || href.startsWith('#')) return null;
63
+ const origin = options.origin ?? pageOrigin();
64
+ const allowed = [origin, ...(options.imageOrigins ?? [])].filter(Boolean);
65
+ if (!allowed.length) return null;
66
+ try {
67
+ const parsed = new URL(href, origin || undefined);
68
+ return allowed.includes(parsed.origin) || allowed.includes('*') ? parsed.href : null;
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+
74
+ function hostOf(href) {
75
+ try {
76
+ return new URL(href).host || href;
77
+ } catch {
78
+ return href;
79
+ }
80
+ }
81
+
82
+ const LINK_TARGET = String.raw`<?([^\s()<>]+(?:\([^\s()]*\)[^\s()<>]*)*)>?(?:\s+"[^"\n]*")?\s*\)`;
83
+ const BARE_URL = /^(?:https?:\/\/|www\.)[^\s<>]*[^\s<>.,:;!?'")\]}。、,!?)」』]/i;
84
+
85
+ function autolink(url) {
86
+ // Keep a closing parenthesis only when the URL opened one.
87
+ let u = url;
88
+ while (u.endsWith(')') && (u.match(/\(/g)?.length ?? 0) < (u.match(/\)/g)?.length ?? 0)) u = u.slice(0, -1);
89
+ const href = safeHref(/^www\./i.test(u) ? 'https://' + u : u);
90
+ return { node: href ? { type: 'link', href, children: [u] } : u, length: u.length };
91
+ }
92
+
93
+ function inlineRules(options) {
94
+ return [
95
+ [/^\\([\\`*_{}[\]()#+\-.!|~>])/, (m) => m[1]],
96
+ [/^(`+)([\s\S]+?)\1(?!`)/, (m) => ({ type: 'code', text: m[2].replace(/^ (.*) $/, '$1') })],
97
+ [
98
+ new RegExp(String.raw`^!\[([^\]\n]*)\]\(\s*` + LINK_TARGET),
99
+ (m) => {
100
+ const src = imageSrc(m[2], options);
101
+ if (src) return { type: 'image', src, alt: m[1] };
102
+ const href = safeHref(m[2]);
103
+ return href ? { type: 'imagelink', href, alt: m[1] || hostOf(href) } : m[0];
104
+ },
105
+ ],
106
+ [
107
+ new RegExp(String.raw`^\[((?:\\.|` + '`[^`]*`' + String.raw`|[^\]\\])+)\]\(\s*` + LINK_TARGET),
108
+ (m) => {
109
+ const href = safeHref(m[2]);
110
+ const children = parseInline(m[1], options);
111
+ return href ? { type: 'link', href, children } : { type: 'span', children };
112
+ },
113
+ ],
114
+ [/^<((?:https?:\/\/|mailto:)[^\s<>]+)>/i, (m) => autolink(m[1]).node],
115
+ [/^(\*\*|__)(?=\S)([\s\S]*?\S)\1(?![*_])/, (m) => ({ type: 'strong', children: parseInline(m[2], options) })],
116
+ [/^~~(?=\S)([\s\S]*?\S)~~/, (m) => ({ type: 'del', children: parseInline(m[1], options) })],
117
+ [/^\*(?=[^\s*])((?:\*\*[\s\S]+?\*\*|[^*])*?[^\s*])\*(?!\*)/, (m) => ({ type: 'em', children: parseInline(m[1], options) })],
118
+ [/^_(?=\S)([\s\S]*?\S)_(?![\p{L}\p{N}_])/u, (m) => ({ type: 'em', children: parseInline(m[1], options) })],
119
+ ];
120
+ }
121
+
122
+ /** Inline Markdown in one line of text, as nodes (strings are plain text). */
123
+ export function parseInline(text, options = {}) {
124
+ const rules = inlineRules(options);
125
+ const out = [];
126
+ let plain = '';
127
+ let i = 0;
128
+ const flush = () => {
129
+ if (plain) out.push(plain);
130
+ plain = '';
131
+ };
132
+ while (i < text.length) {
133
+ const rest = text.slice(i);
134
+ const c = rest[0];
135
+ if ('\\`![<*_~'.includes(c)) {
136
+ // An underscore inside a word (snake_case) is just text.
137
+ if (c === '_' && i > 0 && /[\p{L}\p{N}]/u.test(text[i - 1])) {
138
+ plain += c;
139
+ i++;
140
+ continue;
141
+ }
142
+ let matched = false;
143
+ for (const [re, make] of rules) {
144
+ const m = re.exec(rest);
145
+ if (m) {
146
+ flush();
147
+ out.push(make(m));
148
+ i += m[0].length;
149
+ matched = true;
150
+ break;
151
+ }
152
+ }
153
+ if (matched) continue;
154
+ } else if ('hHwW'.includes(c) && (i === 0 || /[\s((「『【[*_~]/.test(text[i - 1]))) {
155
+ const m = BARE_URL.exec(rest);
156
+ if (m) {
157
+ flush();
158
+ const { node, length } = autolink(m[0]);
159
+ out.push(node);
160
+ i += length;
161
+ continue;
162
+ }
163
+ }
164
+ plain += c;
165
+ i++;
166
+ }
167
+ flush();
168
+ return out;
169
+ }
170
+
171
+ /** Inline text that keeps the message's own line breaks. */
172
+ function withBreaks(text, options) {
173
+ const out = [];
174
+ text.split('\n').forEach((line, j) => {
175
+ if (j > 0) out.push({ type: 'br' });
176
+ out.push(...parseInline(line.replace(/ {2,}$/, ''), options));
177
+ });
178
+ return out;
179
+ }
180
+
181
+ /**
182
+ * A chat message as blocks.
183
+ * @param {string} src
184
+ * @param {{ origin?: string, imageOrigins?: string[] }} [options]
185
+ */
186
+ export function parseMarkdown(src, options = {}, depth = 0) {
187
+ const lines = String(src ?? '').replace(/\r\n?/g, '\n').split('\n');
188
+ const out = [];
189
+ let i = 0;
190
+ while (i < lines.length) {
191
+ const line = lines[i];
192
+ if (!line.trim()) {
193
+ i++;
194
+ continue;
195
+ }
196
+ const fence = FENCE.exec(line);
197
+ if (fence) {
198
+ const close = fence[1][0];
199
+ const body = [];
200
+ i++;
201
+ while (i < lines.length) {
202
+ const t = lines[i].trim();
203
+ if (t.startsWith(close.repeat(fence[1].length)) && !t.replace(/[`~]/g, '')) break;
204
+ body.push(lines[i++]);
205
+ }
206
+ i++;
207
+ out.push({ type: 'code', lang: fence[2], text: body.join('\n') });
208
+ continue;
209
+ }
210
+ if (RULE.test(line)) {
211
+ out.push({ type: 'hr' });
212
+ i++;
213
+ continue;
214
+ }
215
+ const h = HEADING.exec(line);
216
+ if (h) {
217
+ out.push({ type: 'heading', children: parseInline(h[1], options) });
218
+ i++;
219
+ continue;
220
+ }
221
+ if (QUOTE.test(line)) {
222
+ const body = [];
223
+ while (i < lines.length && lines[i].trim() && QUOTE.test(lines[i])) body.push(lines[i++].replace(QUOTE, ''));
224
+ const text = body.join('\n');
225
+ out.push({ type: 'quote', children: depth < MAX_DEPTH ? parseMarkdown(text, options, depth + 1) : [{ type: 'p', children: [text] }] });
226
+ continue;
227
+ }
228
+ if (line.includes('|') && i + 1 < lines.length && TABLE_SEP.test(lines[i + 1])) {
229
+ const head = cells(line);
230
+ const align = cells(lines[i + 1]).map((c) =>
231
+ c.startsWith(':') && c.endsWith(':') ? 'center' : c.endsWith(':') ? 'right' : c.startsWith(':') ? 'left' : null,
232
+ );
233
+ const rows = [];
234
+ i += 2;
235
+ while (i < lines.length && lines[i].trim() && lines[i].includes('|')) rows.push(cells(lines[i++]));
236
+ out.push({
237
+ type: 'table',
238
+ align,
239
+ head: head.map((c) => parseInline(c, options)),
240
+ rows: rows.map((r) => head.map((_, x) => parseInline(r[x] ?? '', options))),
241
+ });
242
+ continue;
243
+ }
244
+ const first = ITEM.exec(line);
245
+ if (first) {
246
+ const base = first[1].length;
247
+ const ordered = /\d/.test(first[2]);
248
+ const items = [];
249
+ while (i < lines.length) {
250
+ const l = lines[i];
251
+ if (!l.trim()) {
252
+ // A blank line ends the list unless the list goes on after it.
253
+ const n = lines[i + 1];
254
+ const m = n !== undefined ? ITEM.exec(n) : null;
255
+ if (m && m[1].length >= base) {
256
+ i++;
257
+ continue;
258
+ }
259
+ if (n !== undefined && /^\s+\S/.test(n) && n.search(/\S/) > base && items.length) {
260
+ items[items.length - 1] += '\n';
261
+ i++;
262
+ continue;
263
+ }
264
+ break;
265
+ }
266
+ const m = ITEM.exec(l);
267
+ if (m && m[1].length <= base + 1 && /\d/.test(m[2]) === ordered) items.push(m[3]);
268
+ else if (m && m[1].length <= base + 1) break;
269
+ else if (items.length && (m || /^\s/.test(l) || !startsBlock(l))) items[items.length - 1] += '\n' + l.slice(Math.min(base + 2, l.search(/\S/)));
270
+ else break;
271
+ i++;
272
+ }
273
+ out.push({
274
+ type: 'list',
275
+ ordered,
276
+ start: ordered ? parseInt(first[2], 10) : 1,
277
+ items: items.map((it) =>
278
+ depth < MAX_DEPTH && it.includes('\n') ? parseMarkdown(it, options, depth + 1) : [{ type: 'p', children: withBreaks(it, options) }],
279
+ ),
280
+ });
281
+ continue;
282
+ }
283
+ const para = [];
284
+ while (i < lines.length && lines[i].trim() && (!para.length || !startsBlock(lines[i], lines[i + 1]))) para.push(lines[i++]);
285
+ out.push({ type: 'p', children: withBreaks(para.join('\n'), options) });
286
+ }
287
+ return out;
288
+ }
289
+
290
+ /** The message as plain text without Markdown marks (for previews and notifications). */
291
+ export function plainText(src) {
292
+ const walk = (nodes) =>
293
+ nodes
294
+ .map((n) => {
295
+ if (typeof n === 'string') return n;
296
+ if (n.type === 'br') return ' ';
297
+ if (n.type === 'code') return n.text;
298
+ if (n.type === 'image' || n.type === 'imagelink') return n.alt;
299
+ return walk(n.children ?? []);
300
+ })
301
+ .join('');
302
+ const block = (b) => {
303
+ switch (b.type) {
304
+ case 'p':
305
+ case 'heading':
306
+ return walk(b.children);
307
+ case 'code':
308
+ return b.text;
309
+ case 'quote':
310
+ return b.children.map(block).join(' ');
311
+ case 'list':
312
+ return b.items.map((it) => it.map(block).join(' ')).join(' · ');
313
+ case 'table':
314
+ return [b.head, ...b.rows].map((r) => r.map(walk).join(' | ')).join(' · ');
315
+ default:
316
+ return '';
317
+ }
318
+ };
319
+ return parseMarkdown(src, { origin: '' }).map(block).filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
320
+ }
package/package.json CHANGED
@@ -1,6 +1,90 @@
1
1
  {
2
2
  "name": "@hibira-io/chat-ui",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Agentic chat UI for support and AI agents over Hibira realtime channels: Markdown messages, agent steps, typed replies, follow-up suggestions and human handoff, for React",
5
+ "keywords": [
6
+ "chat",
7
+ "chat-ui",
8
+ "ai",
9
+ "agent",
10
+ "ai-chat",
11
+ "assistant",
12
+ "human-handoff",
13
+ "human-in-the-loop",
14
+ "live-chat",
15
+ "support",
16
+ "markdown",
17
+ "react",
18
+ "pusher",
19
+ "realtime",
20
+ "hibira"
21
+ ],
22
+ "homepage": "https://hibira.io",
23
+ "bugs": {
24
+ "url": "https://github.com/hibira-io/chat-ui/issues"
25
+ },
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/hibira-io/chat-ui.git"
29
+ },
30
+ "license": "MIT",
31
+ "author": "Hibira (https://hibira.io)",
32
+ "type": "module",
33
+ "main": "index.js",
34
+ "types": "index.d.ts",
35
+ "exports": {
36
+ ".": {
37
+ "types": "./index.d.ts",
38
+ "default": "./index.js"
39
+ },
40
+ "./react": {
41
+ "types": "./react.d.ts",
42
+ "default": "./react.js"
43
+ },
44
+ "./server": {
45
+ "types": "./server.d.ts",
46
+ "default": "./server.js"
47
+ },
48
+ "./styles.css": "./styles.css",
49
+ "./package.json": "./package.json"
50
+ },
51
+ "sideEffects": [
52
+ "*.css"
53
+ ],
54
+ "files": [
55
+ "index.js",
56
+ "index.d.ts",
57
+ "markdown.js",
58
+ "conversation.js",
59
+ "locales.js",
60
+ "types.d.ts",
61
+ "react.js",
62
+ "react.d.ts",
63
+ "server.js",
64
+ "server.d.ts",
65
+ "styles.css",
66
+ "PROTOCOL.md"
67
+ ],
68
+ "engines": {
69
+ "node": ">=20"
70
+ },
71
+ "scripts": {
72
+ "test": "node --test test/*.test.mjs",
73
+ "typecheck": "tsc -p test/tsconfig.json"
74
+ },
75
+ "peerDependencies": {
76
+ "react": ">=18"
77
+ },
78
+ "peerDependenciesMeta": {
79
+ "react": {
80
+ "optional": true
81
+ }
82
+ },
83
+ "devDependencies": {
84
+ "@types/node": "^26.6.6",
85
+ "@types/react": "^19.3.0",
86
+ "react": "^19.3.1",
87
+ "react-dom": "^19.3.1",
88
+ "typescript": "^7.0.2"
89
+ }
90
+ }
package/react.d.ts ADDED
@@ -0,0 +1,36 @@
1
+ import type { ReactElement, ReactNode } from 'react';
2
+ import type { AgentStep, ChatLabels, ChatMessage, Conversation, ConversationState, LabelOverrides } from './types.js';
3
+
4
+ export type * from './types.js';
5
+ export { createConversation } from './index.js';
6
+
7
+ export interface ChatThreadProps {
8
+ messages: ChatMessage[];
9
+ typing?: boolean;
10
+ step?: AgentStep;
11
+ /** Sends a message; reject with an Error to show its message. Without it there is no reply box. */
12
+ onSend?: (text: string) => Promise<unknown>;
13
+ /** The sender value of the person using this page (default "customer"). */
14
+ me?: string;
15
+ /** en, zh-TW, zh-CN, ja, ko, th or vi (others fall back to English). */
16
+ locale?: string;
17
+ labels?: LabelOverrides;
18
+ /** Most characters in one message (default 4000). */
19
+ maxLength?: number;
20
+ /** The other side has read up to this seq: the last own message shows "Seen". */
21
+ readSeq?: number;
22
+ disabled?: boolean;
23
+ footer?: ReactNode;
24
+ origin?: string;
25
+ imageOrigins?: string[];
26
+ renderName?: (message: ChatMessage) => ReactNode;
27
+ }
28
+
29
+ export function ChatThread(props: ChatThreadProps): ReactElement;
30
+ export function Chat(props: Omit<ChatThreadProps, 'messages' | 'typing' | 'step' | 'onSend'> & { conversation: Conversation | null }): ReactElement;
31
+ export function useConversation(conversation: Conversation | null): ConversationState;
32
+ export function Markdown(props: { text: string; locale?: string; labels?: LabelOverrides; origin?: string; imageOrigins?: string[] }): ReactElement;
33
+ export function AgentSteps(props: { typing: boolean; step?: AgentStep; locale?: string; labels?: ChatLabels }): ReactElement | null;
34
+ export function Suggestions(props: { items: string[]; onPick: (text: string) => void; disabled?: boolean; locale?: string; labels?: ChatLabels }): ReactElement | null;
35
+ export function CopyButton(props: { text: string; label?: string; labels?: ChatLabels; locale?: string; className?: string }): ReactElement;
36
+ export function useReveal(text: string, animate: boolean): { shown: string; done: boolean };