@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/LICENSE +21 -0
- package/PROTOCOL.md +71 -0
- package/README.md +128 -2
- package/conversation.js +188 -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 +88 -4
- package/react.d.ts +36 -0
- package/react.js +379 -0
- package/server.d.ts +14 -0
- package/server.js +94 -0
- package/styles.css +119 -0
- package/types.d.ts +108 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hibira
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/PROTOCOL.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Chat protocol v1
|
|
2
|
+
|
|
3
|
+
How the `@hibira-io/chat-ui` components talk to your backend and to a
|
|
4
|
+
[Hibira](https://hibira.io) channel. Any backend can implement it; the
|
|
5
|
+
`server` helpers do the channel side for Node.
|
|
6
|
+
|
|
7
|
+
## Principles
|
|
8
|
+
|
|
9
|
+
- **Your backend stores messages.** Pages read them over HTTPS from your
|
|
10
|
+
routes (the `load` and `send` functions you pass to `createConversation`).
|
|
11
|
+
- **The channel carries hints only**: small events that say "something
|
|
12
|
+
changed". A page that misses one catches up on its next poll, so a lost
|
|
13
|
+
event never loses a message, and message text never crosses the socket.
|
|
14
|
+
- One channel per conversation. Use a private channel (for example
|
|
15
|
+
`private-chat.{conversationId}`) and authorize only the people in it.
|
|
16
|
+
|
|
17
|
+
## Message
|
|
18
|
+
|
|
19
|
+
```json
|
|
20
|
+
{
|
|
21
|
+
"id": "msg_01",
|
|
22
|
+
"seq": 7,
|
|
23
|
+
"sender": "agent",
|
|
24
|
+
"name": "Support",
|
|
25
|
+
"text": "Markdown text",
|
|
26
|
+
"created_at": "2026-10-31T12:00:00Z",
|
|
27
|
+
"metadata": { "assistant": true, "suggestions": ["Is there a free plan?"] }
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
| Field | Meaning |
|
|
32
|
+
| --- | --- |
|
|
33
|
+
| `seq` | Position in the conversation, increasing by one. Pages ask for messages after the last `seq` they have. |
|
|
34
|
+
| `sender` | `customer` for the person using the chat (the page's `me`), anything else for your side (`agent`, `operator`, ...). |
|
|
35
|
+
| `text` | Markdown. Pages render it without HTML (see the README). |
|
|
36
|
+
| `metadata.assistant` | `true` on a reply your agent wrote: shown as an automatic reply. |
|
|
37
|
+
| `metadata.handoff` | `true` when the agent passed the conversation to a person. |
|
|
38
|
+
| `metadata.suggestions` | Up to 3 follow-up questions; the customer sends one with a tap. Clean them with `cleanSuggestions()`. |
|
|
39
|
+
|
|
40
|
+
## Routes (yours)
|
|
41
|
+
|
|
42
|
+
- `load(after)`: return `{ messages: Message[] }` with the messages whose
|
|
43
|
+
`seq > after`, oldest first. Anything else you return (status, read
|
|
44
|
+
marks) shows up as `state.extra`.
|
|
45
|
+
- `send(text)`: store the customer's message and return it (or
|
|
46
|
+
`{ message }`). Then send the `message` hint.
|
|
47
|
+
|
|
48
|
+
## Events on the conversation's channel
|
|
49
|
+
|
|
50
|
+
| Event | Data | Sent when |
|
|
51
|
+
| --- | --- | --- |
|
|
52
|
+
| `message` | `{ "seq": 7, "from": "agent" }` | A message was stored. Pages read what they have not seen. |
|
|
53
|
+
| `typing` | `{ "from": "agent", "step": "writing" }` | Someone is writing. Repeat every ~4 s while it lasts; pages hide it after 8 s without one, or when that side's message arrives. |
|
|
54
|
+
| anything else | your data | Passed to `onEvent` (status changes, read marks, ...). |
|
|
55
|
+
|
|
56
|
+
### Agent steps
|
|
57
|
+
|
|
58
|
+
While your agent writes a reply, send `typing` with a `step` each time it
|
|
59
|
+
moves on. Pages show the steps so far with a check and the current one with
|
|
60
|
+
a spinner. Send only steps your agent really takes:
|
|
61
|
+
|
|
62
|
+
| Step | Shown as (en) |
|
|
63
|
+
| --- | --- |
|
|
64
|
+
| `reading` | Reading your question |
|
|
65
|
+
| `account` | Looking at your account |
|
|
66
|
+
| `searching` | Searching our docs |
|
|
67
|
+
| `writing` | Writing a reply |
|
|
68
|
+
| `checking` | Checking the reply |
|
|
69
|
+
|
|
70
|
+
Other step names show as plain typing unless you add labels for them
|
|
71
|
+
(`labels.steps`).
|
package/README.md
CHANGED
|
@@ -1,3 +1,129 @@
|
|
|
1
|
-
#
|
|
1
|
+
# chat-ui
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A small, open-source chat interface for support conversations where an AI agent answers first and a person steps in when needed. It shows what the agent is doing while it writes, renders safe Markdown, offers follow-up questions, and marks when a conversation is handed to your team.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
| Agent steps (zh-TW, mobile) | Handed to a person (ja, dark) |
|
|
8
|
+
| --- | --- |
|
|
9
|
+
|  |  |
|
|
10
|
+
|
|
11
|
+
- **Agent steps**: "Reading your question", "Looking at your account", "Writing a reply"… shown live, from hints your backend sends.
|
|
12
|
+
- **Safe Markdown**: parsed into elements, never HTML. Only http(s), mailto and same-site links; images show inline only from origins you allow.
|
|
13
|
+
- **Follow-up suggestions**: up to three short questions the customer can send with one tap.
|
|
14
|
+
- **Handoff**: a reply can say it was passed to a person, and the badge shows it.
|
|
15
|
+
- **Typewriter reveal**, copy buttons on replies and code blocks, a "Seen" marker.
|
|
16
|
+
- **Seven languages** built in: en, zh-TW, zh-CN, ja, ko, th, vi.
|
|
17
|
+
- **Small**: about 9 KB of JavaScript and 2 KB of CSS gzipped, no runtime dependencies. React is an optional peer.
|
|
18
|
+
|
|
19
|
+
It works with any backend. Messages live in your database and are read over HTTPS; a realtime channel on [Hibira](https://hibira.io) (or any Pusher-compatible service) only carries hints, so a missed event costs a poll, never a message. See [PROTOCOL.md](PROTOCOL.md).
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
npm install @hibira-io/chat-ui pusher-js
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## React
|
|
28
|
+
|
|
29
|
+
```jsx
|
|
30
|
+
import { useEffect } from 'react';
|
|
31
|
+
import Pusher from 'pusher-js';
|
|
32
|
+
import { Chat, createConversation } from '@hibira-io/chat-ui/react';
|
|
33
|
+
import '@hibira-io/chat-ui/styles.css';
|
|
34
|
+
|
|
35
|
+
const pusher = new Pusher('YOUR_APP_KEY', { wsHost: 'ws.hibira.io', forceTLS: true, cluster: '' });
|
|
36
|
+
|
|
37
|
+
const conversation = createConversation({
|
|
38
|
+
pusher,
|
|
39
|
+
channel: `private-conversation-${conversationId}`,
|
|
40
|
+
load: (after, signal) => fetch(`/api/conversations/${conversationId}/messages?after=${after}`, { signal }).then((r) => r.json()),
|
|
41
|
+
send: (text) =>
|
|
42
|
+
fetch(`/api/conversations/${conversationId}/messages`, {
|
|
43
|
+
method: 'POST',
|
|
44
|
+
headers: { 'Content-Type': 'application/json' },
|
|
45
|
+
body: JSON.stringify({ text }),
|
|
46
|
+
}).then((r) => r.json()),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export function SupportChat() {
|
|
50
|
+
useEffect(() => conversation.start(), []);
|
|
51
|
+
return <Chat conversation={conversation} locale="ja" />;
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`load(after)` returns `{ messages: [...] }` with every message whose `seq` is greater than `after`. A message looks like:
|
|
56
|
+
|
|
57
|
+
```js
|
|
58
|
+
{
|
|
59
|
+
seq: 12,
|
|
60
|
+
sender: 'agent', // or 'customer'; `me` (default "customer") is the person on this page
|
|
61
|
+
text: 'Here is how to **rotate** a key…',
|
|
62
|
+
created_at: '2026-10-31T09:00:00Z',
|
|
63
|
+
metadata: { assistant: true, suggestions: ['How do I revoke the old key?'] },
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The pieces are exported on their own too: `ChatThread` (controlled: pass `messages`, `typing`, `step`, `onSend`), `Markdown`, `AgentSteps`, `Suggestions`, `CopyButton` and `useReveal`.
|
|
68
|
+
|
|
69
|
+
## Without a framework
|
|
70
|
+
|
|
71
|
+
```js
|
|
72
|
+
import { createConversation, parseMarkdown } from '@hibira-io/chat-ui';
|
|
73
|
+
|
|
74
|
+
const conversation = createConversation({ pusher, channel, load, send });
|
|
75
|
+
conversation.subscribe((state) => render(state)); // { messages, loading, error, typing, step }
|
|
76
|
+
conversation.start();
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`parseMarkdown(text, { origin, imageOrigins })` returns a plain block tree you can render with anything.
|
|
80
|
+
|
|
81
|
+
## Server (Node)
|
|
82
|
+
|
|
83
|
+
```js
|
|
84
|
+
import Pusher from 'pusher';
|
|
85
|
+
import { createChatServer, agentMetadata } from '@hibira-io/chat-ui/server';
|
|
86
|
+
|
|
87
|
+
const pusher = new Pusher({ appId, key, secret, host: 'api.hibira.io', useTLS: true });
|
|
88
|
+
const chat = createChatServer({ pusher });
|
|
89
|
+
|
|
90
|
+
const typing = chat.agentTyping(channel); // "Reading your question", repeated every 4 s
|
|
91
|
+
typing.step('account'); // "Looking at your account"
|
|
92
|
+
const answer = await yourAgent(question);
|
|
93
|
+
typing.step('writing');
|
|
94
|
+
typing.step('checking');
|
|
95
|
+
const msg = await db.saveMessage({
|
|
96
|
+
sender: 'agent',
|
|
97
|
+
text: answer.text,
|
|
98
|
+
metadata: agentMetadata({ handoff: answer.handoff, suggestions: answer.suggestions }),
|
|
99
|
+
});
|
|
100
|
+
typing.stop();
|
|
101
|
+
await chat.message(channel, { seq: msg.seq, from: 'agent' });
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`agentMetadata` keeps at most three short suggestions and drops any with links, line breaks or markup, since the customer sends them as their own words. Only send the steps your agent really goes through.
|
|
105
|
+
|
|
106
|
+
## Styling
|
|
107
|
+
|
|
108
|
+
Every color is a CSS variable on `.hb-chat`:
|
|
109
|
+
|
|
110
|
+
```css
|
|
111
|
+
.hb-chat {
|
|
112
|
+
--hb-chat-accent: #3e63dd;
|
|
113
|
+
--hb-chat-radius: 10px;
|
|
114
|
+
--hb-chat-max-height: 480px;
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Dark mode follows `prefers-color-scheme`, or set `data-theme="dark"` / `"light"` on any ancestor. Labels can be overridden one by one with `labels={{ handoff: 'With our support team' }}`.
|
|
119
|
+
|
|
120
|
+
## Safety
|
|
121
|
+
|
|
122
|
+
- Message text is never inserted as HTML.
|
|
123
|
+
- Links open only for `http:`, `https:`, `mailto:` and same-site paths, with `rel="noopener noreferrer"`.
|
|
124
|
+
- Images render inline only from `origin` and `imageOrigins`; any other image becomes a plain link, so a message cannot make the page load third-party content.
|
|
125
|
+
- Treat replies from your agent as untrusted text, like any customer message.
|
|
126
|
+
|
|
127
|
+
## License
|
|
128
|
+
|
|
129
|
+
MIT © Hibira
|
package/conversation.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// A conversation kept up to date in the browser, without a UI framework.
|
|
2
|
+
//
|
|
3
|
+
// Messages are read from your backend (`load`), never from the socket:
|
|
4
|
+
// events on the conversation's channel are hints (PROTOCOL.md). A `message`
|
|
5
|
+
// hint makes it read what it has not seen; a `typing` hint shows that the
|
|
6
|
+
// other side (a person or your agent) is writing, with the agent's `step`.
|
|
7
|
+
// A missed event costs a poll, never a message: it polls slowly while the
|
|
8
|
+
// socket is up and every few seconds without one.
|
|
9
|
+
|
|
10
|
+
/** What an agent can be doing while it writes a reply, in order (PROTOCOL.md). */
|
|
11
|
+
export const STEPS = Object.freeze(['reading', 'account', 'searching', 'writing', 'checking']);
|
|
12
|
+
|
|
13
|
+
/** Polling intervals, with the socket connected (a safety net) and without. */
|
|
14
|
+
export const POLL_LIVE_MS = 60_000;
|
|
15
|
+
export const POLL_MS = 10_000;
|
|
16
|
+
/** How long a typing hint shows without another one. */
|
|
17
|
+
export const TYPING_SHOW_MS = 8_000;
|
|
18
|
+
|
|
19
|
+
/** Merges a page of messages into a list ordered by seq, without repeats. */
|
|
20
|
+
export function mergeMessages(list, page) {
|
|
21
|
+
if (!page?.length) return list;
|
|
22
|
+
const bySeq = new Map(list.map((m) => [m.seq, m]));
|
|
23
|
+
for (const m of page) bySeq.set(m.seq, m);
|
|
24
|
+
return [...bySeq.values()].sort((a, b) => a.seq - b.seq);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const lastSeq = (list) => (list.length ? list[list.length - 1].seq : 0);
|
|
28
|
+
|
|
29
|
+
/** An automatic reply (your agent marks it in metadata). */
|
|
30
|
+
export const isAutomatic = (m) => !!m?.metadata?.assistant;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {{
|
|
34
|
+
* load: (after: number, signal: AbortSignal) => Promise<{ messages: any[] } & Record<string, any>>,
|
|
35
|
+
* send?: (text: string) => Promise<any>,
|
|
36
|
+
* pusher?: any,
|
|
37
|
+
* channel?: string,
|
|
38
|
+
* me?: string,
|
|
39
|
+
* pollMs?: number,
|
|
40
|
+
* livePollMs?: number,
|
|
41
|
+
* onEvent?: (event: string, data: any) => void,
|
|
42
|
+
* }} options
|
|
43
|
+
*/
|
|
44
|
+
export function createConversation(options) {
|
|
45
|
+
if (typeof options?.load !== 'function') throw new TypeError('createConversation() needs a load(after, signal) function.');
|
|
46
|
+
const me = options.me ?? 'customer';
|
|
47
|
+
const pollMs = options.pollMs ?? POLL_MS;
|
|
48
|
+
const livePollMs = options.livePollMs ?? POLL_LIVE_MS;
|
|
49
|
+
let state = { messages: [], loading: true, error: null, typing: false, step: undefined, extra: {} };
|
|
50
|
+
const listeners = new Set();
|
|
51
|
+
let timer = null;
|
|
52
|
+
let typingTimer = null;
|
|
53
|
+
let abort = null;
|
|
54
|
+
let inflight = null;
|
|
55
|
+
let again = false;
|
|
56
|
+
let stopped = true;
|
|
57
|
+
let unbind = [];
|
|
58
|
+
|
|
59
|
+
const set = (patch) => {
|
|
60
|
+
state = { ...state, ...patch };
|
|
61
|
+
listeners.forEach((fn) => fn(state));
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const connected = () => options.pusher?.connection?.state === 'connected';
|
|
65
|
+
|
|
66
|
+
function schedule() {
|
|
67
|
+
if (stopped) return;
|
|
68
|
+
clearTimeout(timer);
|
|
69
|
+
timer = setTimeout(() => void refresh(), connected() ? livePollMs : pollMs);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function setTyping(on, step) {
|
|
73
|
+
clearTimeout(typingTimer);
|
|
74
|
+
typingTimer = on ? setTimeout(() => setTyping(false), TYPING_SHOW_MS) : null;
|
|
75
|
+
const next = on ? step : undefined;
|
|
76
|
+
if (state.typing !== on || state.step !== next) set({ typing: on, step: next });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function onHint(event, data) {
|
|
80
|
+
const d = data && typeof data === 'object' ? data : {};
|
|
81
|
+
if (event === 'typing') {
|
|
82
|
+
if (d.from !== me) setTyping(true, typeof d.step === 'string' ? d.step : undefined);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (event === 'message') {
|
|
86
|
+
if (d.from !== me) setTyping(false);
|
|
87
|
+
if (typeof d.seq === 'number' && d.seq <= lastSeq(state.messages)) return;
|
|
88
|
+
void refresh();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
options.onEvent?.(event, d);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Reads what is new; calls made while one runs fold into one more. */
|
|
95
|
+
function refresh() {
|
|
96
|
+
if (stopped) return Promise.resolve();
|
|
97
|
+
if (inflight) {
|
|
98
|
+
again = true;
|
|
99
|
+
return inflight;
|
|
100
|
+
}
|
|
101
|
+
const run = async () => {
|
|
102
|
+
do {
|
|
103
|
+
again = false;
|
|
104
|
+
abort = new AbortController();
|
|
105
|
+
try {
|
|
106
|
+
const { messages = [], ...extra } = await options.load(lastSeq(state.messages), abort.signal);
|
|
107
|
+
if (stopped) return;
|
|
108
|
+
set({ messages: mergeMessages(state.messages, messages), extra: { ...state.extra, ...extra }, error: null, loading: false });
|
|
109
|
+
} catch (err) {
|
|
110
|
+
if (stopped || err?.name === 'AbortError') return;
|
|
111
|
+
set({ error: err, loading: false });
|
|
112
|
+
}
|
|
113
|
+
} while (again && !stopped);
|
|
114
|
+
};
|
|
115
|
+
inflight = run().finally(() => {
|
|
116
|
+
inflight = null;
|
|
117
|
+
schedule();
|
|
118
|
+
});
|
|
119
|
+
return inflight;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function start() {
|
|
123
|
+
if (!stopped) return stop;
|
|
124
|
+
stopped = false;
|
|
125
|
+
const { pusher, channel } = options;
|
|
126
|
+
if (pusher && channel) {
|
|
127
|
+
const ch = pusher.subscribe(channel);
|
|
128
|
+
const global = (event, data) => {
|
|
129
|
+
if (event.startsWith('pusher:') || event.startsWith('pusher_internal:')) return;
|
|
130
|
+
onHint(event, data);
|
|
131
|
+
};
|
|
132
|
+
const subscribed = () => void refresh();
|
|
133
|
+
ch.bind_global(global);
|
|
134
|
+
ch.bind('pusher:subscription_succeeded', subscribed);
|
|
135
|
+
const state = () => schedule();
|
|
136
|
+
pusher.connection?.bind?.('state_change', state);
|
|
137
|
+
unbind.push(() => {
|
|
138
|
+
ch.unbind_global(global);
|
|
139
|
+
ch.unbind('pusher:subscription_succeeded', subscribed);
|
|
140
|
+
pusher.connection?.unbind?.('state_change', state);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
if (typeof document !== 'undefined') {
|
|
144
|
+
const vis = () => document.visibilityState === 'visible' && void refresh();
|
|
145
|
+
document.addEventListener('visibilitychange', vis);
|
|
146
|
+
unbind.push(() => document.removeEventListener('visibilitychange', vis));
|
|
147
|
+
}
|
|
148
|
+
void refresh();
|
|
149
|
+
return stop;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function stop() {
|
|
153
|
+
stopped = true;
|
|
154
|
+
clearTimeout(timer);
|
|
155
|
+
clearTimeout(typingTimer);
|
|
156
|
+
abort?.abort();
|
|
157
|
+
unbind.forEach((fn) => fn());
|
|
158
|
+
unbind = [];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Adds a message the page just sent (the server's copy). */
|
|
162
|
+
function added(message) {
|
|
163
|
+
if (message && typeof message.seq === 'number') set({ messages: mergeMessages(state.messages, [message]) });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Sends through options.send and adds the message it returns. */
|
|
167
|
+
async function send(text) {
|
|
168
|
+
if (!options.send) throw new Error('createConversation() has no send(text) function.');
|
|
169
|
+
const out = await options.send(text);
|
|
170
|
+
added(out?.message ?? out);
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
get: () => state,
|
|
176
|
+
subscribe(fn) {
|
|
177
|
+
listeners.add(fn);
|
|
178
|
+
return () => listeners.delete(fn);
|
|
179
|
+
},
|
|
180
|
+
start,
|
|
181
|
+
stop,
|
|
182
|
+
refresh,
|
|
183
|
+
added,
|
|
184
|
+
send,
|
|
185
|
+
/** For tests and custom transports: deliver a hint as if from the channel. */
|
|
186
|
+
hint: onHint,
|
|
187
|
+
};
|
|
188
|
+
}
|
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));
|