@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/hibira.js ADDED
@@ -0,0 +1,187 @@
1
+ // A client for the Hibira Chat API's user routes (/apps/{appId}/chat/me),
2
+ // and an adapter that turns one of its conversations into a
3
+ // createConversation() for the components. The user token comes from your
4
+ // server (an HS256 JWT signed with the app secret, { sub, exp }); messages
5
+ // are stored by Hibira, so you write no load/send routes.
6
+
7
+ import { createConversation } from './conversation.js';
8
+
9
+ export const DEFAULT_HOST = 'api.hibira.io';
10
+
11
+ /** A user token's claims, read without checking them ({} when unreadable). */
12
+ function claims(token) {
13
+ try {
14
+ const part = String(token).split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
15
+ const bin = atob(part.padEnd(part.length + ((4 - (part.length % 4)) % 4), '='));
16
+ return JSON.parse(new TextDecoder().decode(Uint8Array.from(bin, (c) => c.charCodeAt(0)))) ?? {};
17
+ } catch {
18
+ return {};
19
+ }
20
+ }
21
+
22
+ /** The user ID (sub) inside a user token, or '' when it cannot be read. */
23
+ export const tokenUser = (token) => String(claims(token).sub ?? '');
24
+
25
+ /** An error answered by the Chat API, with its HTTP status. */
26
+ export class ChatApiError extends Error {
27
+ constructor(status, message) {
28
+ super(message || `Chat API request failed (${status})`);
29
+ this.name = 'ChatApiError';
30
+ this.status = status;
31
+ }
32
+ }
33
+
34
+ /**
35
+ * The Chat API as the signed-in user.
36
+ *
37
+ * options:
38
+ * appId the Hibira app ID (required)
39
+ * token the user token: a string, or a function returning one (or a
40
+ * promise of one), called again before it expires and after a 401
41
+ * tokenUrl instead of token: a URL on your site that answers {token}
42
+ * host the HTTP API host (default api.hibira.io); may include http://
43
+ * fetch a fetch implementation (default globalThis.fetch)
44
+ */
45
+ export function createChatClient(options = {}) {
46
+ if (!options.appId) throw new TypeError('createChatClient: appId is required');
47
+ if (!options.token && !options.tokenUrl) throw new TypeError('createChatClient: token or tokenUrl is required');
48
+ const doFetch = options.fetch ?? ((...a) => globalThis.fetch(...a));
49
+ let host = String(options.host ?? DEFAULT_HOST);
50
+ while (host.endsWith('/')) host = host.slice(0, -1);
51
+ const base = `${/^https?:\/\//.test(host) ? host : `https://${host}`}/apps/${encodeURIComponent(options.appId)}/chat/me`;
52
+ let token = typeof options.token === 'string' ? options.token : null;
53
+ let pending = null;
54
+
55
+ async function fetchToken() {
56
+ if (typeof options.token === 'string') return options.token;
57
+ if (typeof options.token === 'function') return options.token();
58
+ const res = await doFetch(options.tokenUrl, { credentials: 'include', headers: { Accept: 'application/json' } });
59
+ if (!res.ok) throw new ChatApiError(res.status, `token endpoint answered ${res.status}`);
60
+ return (await res.json()).token;
61
+ }
62
+
63
+ function currentToken(force) {
64
+ const exp = token ? claims(token).exp ?? 0 : 0;
65
+ if (!force && token && (exp === 0 || exp - Date.now() / 1000 > 30)) return Promise.resolve(token);
66
+ pending ??= Promise.resolve(fetchToken())
67
+ .then((t) => (token = t))
68
+ .finally(() => (pending = null));
69
+ return pending;
70
+ }
71
+
72
+ async function call(method, path, { body, query, signal } = {}) {
73
+ const params = Object.entries(query ?? {}).filter(([, v]) => v !== undefined && v !== null && v !== '');
74
+ const qs = new URLSearchParams(params.map(([k, v]) => [k, String(v)])).toString();
75
+ const url = base + path + (qs ? `?${qs}` : '');
76
+ for (let attempt = 0; ; attempt++) {
77
+ const t = await currentToken(attempt > 0);
78
+ const res = await doFetch(url, {
79
+ method,
80
+ signal,
81
+ headers: { Authorization: `Bearer ${t}`, ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}) },
82
+ body: body !== undefined ? JSON.stringify(body) : undefined,
83
+ });
84
+ if (res.status === 401 && attempt === 0 && typeof options.token !== 'string') continue;
85
+ const data = await res.json().catch(() => ({}));
86
+ if (!res.ok) throw new ChatApiError(res.status, data?.error);
87
+ return data;
88
+ }
89
+ }
90
+
91
+ const conv = (id) => `/conversations/${encodeURIComponent(id)}`;
92
+
93
+ return {
94
+ /** The token's user, once a token is known. */
95
+ user: () => (token ? tokenUser(token) : ''),
96
+ conversations: ({ limit, before, signal } = {}) => call('GET', '/conversations', { query: { limit, before }, signal }),
97
+ conversation: (id, { signal } = {}) => call('GET', conv(id), { signal }).then((d) => d.conversation),
98
+ messages: (id, { after, last, limit, signal } = {}) => call('GET', `${conv(id)}/messages`, { query: { after, last, limit }, signal }),
99
+ search: (id, q, { before, limit, signal } = {}) => call('GET', `${conv(id)}/search`, { query: { q, before, limit }, signal }),
100
+ send: (id, message) => call('POST', `${conv(id)}/messages`, { body: typeof message === 'string' ? { text: message } : message }),
101
+ edit: (id, seq, change) => call('PUT', `${conv(id)}/messages/${seq}`, { body: typeof change === 'string' ? { text: change } : change }).then((d) => d.message),
102
+ remove: (id, seq) => call('DELETE', `${conv(id)}/messages/${seq}`).then((d) => d.message),
103
+ react: (id, seq, emoji, on = true) =>
104
+ (on
105
+ ? call('POST', `${conv(id)}/messages/${seq}/reactions`, { body: { emoji } })
106
+ : call('DELETE', `${conv(id)}/messages/${seq}/reactions`, { query: { emoji } })
107
+ ).then((d) => d.message),
108
+ report: (id, seq, reason) => call('POST', `${conv(id)}/messages/${seq}/reports`, { body: { reason } }).then((d) => d.report),
109
+ read: (id, seq) => call('POST', `${conv(id)}/read`, { body: { seq } }),
110
+ typing: (id, typing = true) => call('POST', `${conv(id)}/typing`, { body: { typing } }),
111
+ /** Uploads a File or Blob; send its result in a message's attachments. */
112
+ async upload(id, file, { name } = {}) {
113
+ const fileName = name ?? file?.name ?? 'file';
114
+ const { attachment, upload } = await call('POST', `${conv(id)}/attachments`, {
115
+ body: { name: fileName, content_type: file.type || 'application/octet-stream', size: file.size },
116
+ });
117
+ const res = await doFetch(upload.url, { method: upload.method ?? 'PUT', headers: upload.headers ?? {}, body: file });
118
+ if (!res.ok) throw new ChatApiError(res.status, 'Upload failed');
119
+ return attachment;
120
+ },
121
+ };
122
+ }
123
+
124
+ /** Chat API events, sent to each member's user channel (pusher.signin). */
125
+ export const CHAT_EVENTS = Object.freeze(['chat.message', 'chat.message_updated', 'chat.typing', 'chat.read', 'chat.conversation', 'chat.left', 'chat.deleted']);
126
+
127
+ /**
128
+ * Binds Chat API events on a signed-in pusher-js client; returns unbind.
129
+ * @param {any} pusher
130
+ * @param {(event: string, data: any) => void} handler
131
+ */
132
+ export function onChatEvents(pusher, handler) {
133
+ const user = pusher?.user;
134
+ if (!user?.bind) return () => {};
135
+ const fns = CHAT_EVENTS.map((e) => {
136
+ const fn = (data) => handler(e, data);
137
+ user.bind(e, fn);
138
+ return [e, fn];
139
+ });
140
+ return () => fns.forEach(([e, fn]) => user.unbind?.(e, fn));
141
+ }
142
+
143
+ /**
144
+ * One Chat API conversation as a createConversation(), for <Chat> and
145
+ * useConversation(). Live events come from pusher (signed in with
146
+ * pusher.signin()); without it, it polls.
147
+ * @param {ReturnType<typeof createChatClient>} client
148
+ * @param {string} id
149
+ * @param {{ pusher?: any, me?: string, pollMs?: number, livePollMs?: number, onEvent?: (event: string, data: any) => void }} [options]
150
+ */
151
+ export function chatConversation(client, id, options = {}) {
152
+ const conversation = createConversation({
153
+ me: options.me ?? client.user(),
154
+ pusher: options.pusher,
155
+ pollMs: options.pollMs,
156
+ livePollMs: options.livePollMs,
157
+ onEvent: options.onEvent,
158
+ load: (after, signal) => client.messages(id, after ? { after, signal } : { last: 50, signal }),
159
+ send: (text) => client.send(id, { text }),
160
+ });
161
+ const start = conversation.start;
162
+ let unbind = () => {};
163
+ conversation.start = () => {
164
+ start();
165
+ unbind();
166
+ unbind = onChatEvents(options.pusher, (event, data) => {
167
+ if (data?.conversation_id !== id) return;
168
+ const me = options.me ?? client.user();
169
+ if (event === 'chat.message' || event === 'chat.message_updated') {
170
+ conversation.added(data.message);
171
+ if (event === 'chat.message') conversation.hint('message', { from: data.message?.sender, seq: data.message?.seq });
172
+ } else if (event === 'chat.typing') {
173
+ if (data.user_id !== me && data.typing !== false) conversation.hint('typing', { from: data.user_id });
174
+ } else {
175
+ options.onEvent?.(event, data);
176
+ }
177
+ });
178
+ return () => conversation.stop();
179
+ };
180
+ const stop = conversation.stop;
181
+ conversation.stop = () => {
182
+ unbind();
183
+ unbind = () => {};
184
+ stop();
185
+ };
186
+ return conversation;
187
+ }
package/index.d.ts ADDED
@@ -0,0 +1,22 @@
1
+ import type { Block, ChatLabels, ChatMessage, Conversation, ConversationOptions, InlineNode, LabelOverrides, MarkdownOptions } from './types.js';
2
+
3
+ export type * from './types.js';
4
+
5
+ export function parseMarkdown(text: string, options?: MarkdownOptions): Block[];
6
+ export function parseInline(text: string, options?: MarkdownOptions): InlineNode[];
7
+ export function plainText(text: string): string;
8
+ export function safeHref(url: string): string | null;
9
+ export function imageSrc(url: string, options?: MarkdownOptions): string | null;
10
+
11
+ export function createConversation(options: ConversationOptions): Conversation;
12
+ export function mergeMessages(list: ChatMessage[], page: ChatMessage[]): ChatMessage[];
13
+ export function lastSeq(list: ChatMessage[]): number;
14
+ export function isAutomatic(message: ChatMessage | undefined | null): boolean;
15
+ export const STEPS: readonly string[];
16
+ export const POLL_MS: number;
17
+ export const POLL_LIVE_MS: number;
18
+ export const TYPING_SHOW_MS: number;
19
+
20
+ export const LOCALES: Record<'en' | 'zh-TW' | 'zh-CN' | 'ja' | 'ko' | 'th' | 'vi', ChatLabels>;
21
+ export function labelsFor(locale?: string, overrides?: LabelOverrides): ChatLabels;
22
+ export function format(text: string, vars?: Record<string, string | number>): string;
package/index.js ADDED
@@ -0,0 +1,7 @@
1
+ // Framework-free core: Markdown as data, the conversation store, labels.
2
+ // React components: '@hibira-io/chat-ui/react'. Server helpers:
3
+ // '@hibira-io/chat-ui/server'.
4
+
5
+ export { parseMarkdown, parseInline, plainText, safeHref, imageSrc } from './markdown.js';
6
+ export { createConversation, mergeMessages, lastSeq, isAutomatic, STEPS, POLL_MS, POLL_LIVE_MS, TYPING_SHOW_MS } from './conversation.js';
7
+ export { labelsFor, LOCALES, format } from './locales.js';
package/locales.js ADDED
@@ -0,0 +1,173 @@
1
+ // Labels for the bundled components. Pass `locale` to pick one, or `labels`
2
+ // to override single strings.
3
+
4
+ const en = {
5
+ placeholder: 'Write a message…',
6
+ send: 'Send',
7
+ typing: 'Typing…',
8
+ automatic: 'Automatic reply',
9
+ handoff: 'Passed to our team',
10
+ suggestions: 'You might also ask',
11
+ copy: 'Copy',
12
+ copied: 'Copied',
13
+ copyCode: 'Copy code',
14
+ seen: 'Seen',
15
+ empty: 'Ask us anything.',
16
+ tooLong: 'Keep a message under {n} characters.',
17
+ steps: {
18
+ reading: 'Reading your question',
19
+ account: 'Looking at your account',
20
+ searching: 'Searching our docs',
21
+ writing: 'Writing a reply',
22
+ checking: 'Checking the reply',
23
+ },
24
+ };
25
+
26
+ const zhTW = {
27
+ placeholder: '輸入訊息…',
28
+ send: '送出',
29
+ typing: '正在輸入…',
30
+ automatic: '自動回覆',
31
+ handoff: '已轉交團隊',
32
+ suggestions: '你可能還想問',
33
+ copy: '複製',
34
+ copied: '已複製',
35
+ copyCode: '複製程式碼',
36
+ seen: '已讀',
37
+ empty: '有任何問題都可以問我們。',
38
+ tooLong: '每則訊息請少於 {n} 個字元。',
39
+ steps: {
40
+ reading: '正在閱讀你的問題',
41
+ account: '正在查看你的帳戶',
42
+ searching: '正在搜尋文件',
43
+ writing: '正在撰寫回覆',
44
+ checking: '正在檢查回覆',
45
+ },
46
+ };
47
+
48
+ const zhCN = {
49
+ placeholder: '输入消息…',
50
+ send: '发送',
51
+ typing: '正在输入…',
52
+ automatic: '自动回复',
53
+ handoff: '已转交团队',
54
+ suggestions: '你可能还想问',
55
+ copy: '复制',
56
+ copied: '已复制',
57
+ copyCode: '复制代码',
58
+ seen: '已读',
59
+ empty: '有任何问题都可以问我们。',
60
+ tooLong: '每条消息请少于 {n} 个字符。',
61
+ steps: {
62
+ reading: '正在阅读你的问题',
63
+ account: '正在查看你的账户',
64
+ searching: '正在搜索文档',
65
+ writing: '正在撰写回复',
66
+ checking: '正在检查回复',
67
+ },
68
+ };
69
+
70
+ const ja = {
71
+ placeholder: 'メッセージを入力…',
72
+ send: '送信',
73
+ typing: '入力中…',
74
+ automatic: '自動応答',
75
+ handoff: '担当チームに引き継ぎ済み',
76
+ suggestions: 'こちらもよく聞かれます',
77
+ copy: 'コピー',
78
+ copied: 'コピーしました',
79
+ copyCode: 'コードをコピー',
80
+ seen: '既読',
81
+ empty: 'お気軽にご質問ください。',
82
+ tooLong: 'メッセージは {n} 文字未満にしてください。',
83
+ steps: {
84
+ reading: 'ご質問を確認しています',
85
+ account: 'アカウントを確認しています',
86
+ searching: 'ドキュメントを検索しています',
87
+ writing: '回答を作成しています',
88
+ checking: '回答をチェックしています',
89
+ },
90
+ };
91
+
92
+ const ko = {
93
+ placeholder: '메시지를 입력하세요…',
94
+ send: '보내기',
95
+ typing: '입력 중…',
96
+ automatic: '자동 답변',
97
+ handoff: '담당 팀에 전달됨',
98
+ suggestions: '이런 질문도 있어요',
99
+ copy: '복사',
100
+ copied: '복사됨',
101
+ copyCode: '코드 복사',
102
+ seen: '읽음',
103
+ empty: '무엇이든 물어보세요.',
104
+ tooLong: '메시지는 {n}자 미만으로 써 주세요.',
105
+ steps: {
106
+ reading: '질문을 읽고 있어요',
107
+ account: '계정을 확인하고 있어요',
108
+ searching: '문서를 찾고 있어요',
109
+ writing: '답변을 작성하고 있어요',
110
+ checking: '답변을 확인하고 있어요',
111
+ },
112
+ };
113
+
114
+ const th = {
115
+ placeholder: 'พิมพ์ข้อความ…',
116
+ send: 'ส่ง',
117
+ typing: 'กำลังพิมพ์…',
118
+ automatic: 'ตอบกลับอัตโนมัติ',
119
+ handoff: 'ส่งต่อให้ทีมงานแล้ว',
120
+ suggestions: 'คุณอาจอยากถามต่อ',
121
+ copy: 'คัดลอก',
122
+ copied: 'คัดลอกแล้ว',
123
+ copyCode: 'คัดลอกโค้ด',
124
+ seen: 'อ่านแล้ว',
125
+ empty: 'ถามเราได้ทุกเรื่อง',
126
+ tooLong: 'โปรดเขียนข้อความไม่เกิน {n} ตัวอักษร',
127
+ steps: {
128
+ reading: 'กำลังอ่านคำถามของคุณ',
129
+ account: 'กำลังดูบัญชีของคุณ',
130
+ searching: 'กำลังค้นหาในเอกสาร',
131
+ writing: 'กำลังเขียนคำตอบ',
132
+ checking: 'กำลังตรวจสอบคำตอบ',
133
+ },
134
+ };
135
+
136
+ const vi = {
137
+ placeholder: 'Nhập tin nhắn…',
138
+ send: 'Gửi',
139
+ typing: 'Đang nhập…',
140
+ automatic: 'Trả lời tự động',
141
+ handoff: 'Đã chuyển cho đội ngũ',
142
+ suggestions: 'Bạn cũng có thể hỏi',
143
+ copy: 'Sao chép',
144
+ copied: 'Đã sao chép',
145
+ copyCode: 'Sao chép mã',
146
+ seen: 'Đã xem',
147
+ empty: 'Hãy hỏi chúng tôi bất cứ điều gì.',
148
+ tooLong: 'Tin nhắn cần ít hơn {n} ký tự.',
149
+ steps: {
150
+ reading: 'Đang đọc câu hỏi của bạn',
151
+ account: 'Đang xem tài khoản của bạn',
152
+ searching: 'Đang tìm trong tài liệu',
153
+ writing: 'Đang viết câu trả lời',
154
+ checking: 'Đang kiểm tra câu trả lời',
155
+ },
156
+ };
157
+
158
+ export const LOCALES = { en, 'zh-TW': zhTW, 'zh-CN': zhCN, ja, ko, th, vi };
159
+
160
+ /** The labels for a locale ("ja", "zh-Hant-TW", "pt" falls back to English), with overrides. */
161
+ export function labelsFor(locale, overrides) {
162
+ const tag = String(locale ?? 'en');
163
+ let base = LOCALES[tag];
164
+ if (!base) {
165
+ const lower = tag.toLowerCase();
166
+ if (lower.startsWith('zh')) base = /hant|tw|hk|mo/.test(lower) ? zhTW : zhCN;
167
+ else base = LOCALES[lower.split('-')[0]] ?? en;
168
+ }
169
+ return { ...base, ...overrides, steps: { ...base.steps, ...overrides?.steps } };
170
+ }
171
+
172
+ /** Fills {name} placeholders. */
173
+ export const format = (s, vars = {}) => String(s).replace(/\{(\w+)\}/g, (m, k) => (k in vars ? String(vars[k]) : m));