@elqnt/chat 3.1.0 → 3.5.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/README.md +6 -0
- package/SKILL.md +531 -0
- package/TIER2_AUTH.md +258 -0
- package/dist/api/index.d.mts +11 -0
- package/dist/api/index.d.ts +11 -0
- package/dist/api/index.js +27 -9
- package/dist/api/index.js.map +1 -1
- package/dist/api/index.mjs +27 -10
- package/dist/api/index.mjs.map +1 -1
- package/dist/hooks/index.d.mts +75 -37
- package/dist/hooks/index.d.ts +75 -37
- package/dist/hooks/index.js +116 -16
- package/dist/hooks/index.js.map +1 -1
- package/dist/hooks/index.mjs +115 -15
- package/dist/hooks/index.mjs.map +1 -1
- package/dist/index.d.mts +2 -7
- package/dist/index.d.ts +2 -7
- package/dist/index.js +14 -1420
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +9 -1413
- package/dist/index.mjs.map +1 -1
- package/dist/models/index.d.mts +89 -4
- package/dist/models/index.d.ts +89 -4
- package/dist/models/index.js +12 -1
- package/dist/models/index.js.map +1 -1
- package/dist/models/index.mjs +8 -2
- package/dist/models/index.mjs.map +1 -1
- package/dist/transport/index.d.mts +2 -2
- package/dist/transport/index.d.ts +2 -2
- package/dist/transport/index.js +100 -11
- package/dist/transport/index.js.map +1 -1
- package/dist/transport/index.mjs +100 -12
- package/dist/transport/index.mjs.map +1 -1
- package/dist/{types-CQHtUQ6p.d.mts → types-CLtQA6Qq.d.mts} +16 -0
- package/dist/{types-7UNI1iYv.d.ts → types-CxibhkqW.d.ts} +16 -0
- package/package.json +8 -6
package/README.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @elqnt/chat
|
|
2
2
|
|
|
3
|
+
> **Building a chat UI with an Eloquent agent? Read [`SKILL.md`](./SKILL.md) first.**
|
|
4
|
+
> It is the agent-facing guide: the two auth models (realtime origin-allow-list vs.
|
|
5
|
+
> history bearer JWT), the POST + SSE wire contract, the `ChatEvent` catalog, and the
|
|
6
|
+
> exact `useChat` / `useChatHistory` typed return surfaces. The contract is the
|
|
7
|
+
> package's shipped `.d.ts` — import the named return + model types and let `tsc` enforce it.
|
|
8
|
+
|
|
3
9
|
Platform-agnostic chat SDK for React and React Native. Uses HTTP + SSE for reliable real-time communication.
|
|
4
10
|
|
|
5
11
|
**Key Features:**
|
package/SKILL.md
ADDED
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: chat
|
|
3
|
+
description: Build a custom chat UI on the Eloquent chat backend using the @elqnt/chat hooks. Covers the realtime path (custom app → useChat → POST /create /send + SSE /stream → chat Go service) and the history path (useChatHistory → /api/v1/chats), the TWO different auth models (realtime is tier-2 direct: a short-lived signed token on connect/create/send via getToken — origin is only defense-in-depth; history is a bearer JWT through the gateway), the tier-2 token handshake (getToken option, ?token= on the SSE stream, Authorization: Bearer on POSTs, claim shape), the POST endpoints + bodies, the SSE ChatEvent catalog, the thread/chatKey model, and the EXACT input/output spec of useChat + useChatHistory plus the Chat/ChatMessage/ChatEvent types.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Chat — Building a Custom Chat UI
|
|
7
|
+
|
|
8
|
+
Eloquent's **chat** service runs conversational threads: it owns chat state,
|
|
9
|
+
takes the LLM turn for a bound agent, and fans replies back over SSE. This skill
|
|
10
|
+
is **only** about one thing: building a custom app that talks to it through the
|
|
11
|
+
`@elqnt/chat` package.
|
|
12
|
+
|
|
13
|
+
> Package is `@elqnt/chat`. It exposes a **realtime** hook (`useChat` — POST +
|
|
14
|
+
> SSE, no bearer) and a **history** hook (`useChatHistory` — `/api/v1/chats`,
|
|
15
|
+
> bearer JWT). **Designing/saving the agent** a thread binds to is a *different*
|
|
16
|
+
> package (`@elqnt/agents`) with its own skill — cross-linked below, not
|
|
17
|
+
> duplicated here.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## ⛔ The contract — read this before writing any code
|
|
22
|
+
|
|
23
|
+
This skill ships **inside the package**, so the contract is not a separate file
|
|
24
|
+
to keep in sync — it **is** the package's own published type declarations
|
|
25
|
+
(`@elqnt/chat` → `dist/**/*.d.ts`). Because your app *imports the real hooks*,
|
|
26
|
+
the TypeScript compiler enforces the contract for you: a wrong param or a misused
|
|
27
|
+
return value won't compile.
|
|
28
|
+
|
|
29
|
+
The single source of truth, in order:
|
|
30
|
+
|
|
31
|
+
1. `import { useChat, useChatHistory } from "@elqnt/chat/hooks"` — the two hooks
|
|
32
|
+
this skill is about.
|
|
33
|
+
2. `import type { UseChatOptions, UseChatReturn, UseChatHistoryOptions, UseChatHistoryReturn, ChatHistoryResult } from "@elqnt/chat/hooks"`
|
|
34
|
+
— the **named, exact** surface of each hook (every method, its params, its
|
|
35
|
+
return type). The tables below are a human-readable mirror of these.
|
|
36
|
+
3. `import type { Chat, ChatMessage, ChatEvent, ChatEventTypeTS, ChatSummary, ChatRoleTS, MessageStatusTS } from "@elqnt/chat/models"`
|
|
37
|
+
— the DTOs on the wire.
|
|
38
|
+
4. `import type { TransportState, TransportError, ConnectionMetrics, ChatTransport } from "@elqnt/chat/transport"`
|
|
39
|
+
— connection state/error/metrics + the transport interface (only if you swap
|
|
40
|
+
transports).
|
|
41
|
+
|
|
42
|
+
**Rules (do not drift):**
|
|
43
|
+
|
|
44
|
+
1. **Use only the exported hooks** and only the methods they expose. Do **not**
|
|
45
|
+
invent hooks, methods, params, or return shapes — if it's not on
|
|
46
|
+
`UseChatReturn` / `UseChatHistoryReturn`, it doesn't exist.
|
|
47
|
+
2. **Let the compiler check you.** Build with `tsc`; never `as any` /
|
|
48
|
+
`@ts-ignore` your way around a hook's types to force a call that isn't there.
|
|
49
|
+
3. **Never bypass the hooks for state.** No NATS, no WebSocket, no direct chat
|
|
50
|
+
service calls. The realtime layer's *only* raw `fetch` is the package's own
|
|
51
|
+
transport (`POST /create|/send|...`, `GET /stream`) — you call `useChat`, not
|
|
52
|
+
`fetch`.
|
|
53
|
+
4. **Wrap, don't scatter.** Mount one `useChat` instance per thread subtree (a
|
|
54
|
+
context), so every component shares one `messages`/`connectionState`.
|
|
55
|
+
5. If a requirement seems to need a call the package doesn't define, **stop and
|
|
56
|
+
flag it** — do not improvise an endpoint.
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Architecture — TWO auth models, one path each
|
|
61
|
+
|
|
62
|
+
The realtime hook and the history hook authorize **differently**, and realtime
|
|
63
|
+
is **tier 2** (direct to the chat service, bypassing the gateway — see
|
|
64
|
+
`packages/@elqnt/CONVENTIONS.md`). Getting this wrong is the #1 chat bug.
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
┌──────────────────────────────────────────────────────────────────────────────┐
|
|
68
|
+
│ YOUR CUSTOM APP (Next.js / React) │
|
|
69
|
+
│ │
|
|
70
|
+
│ REALTIME — useChat (tier 2) HISTORY — useChatHistory (tier 1) │
|
|
71
|
+
│ (@elqnt/chat/hooks) (@elqnt/chat/hooks) │
|
|
72
|
+
│ │ raw fetch (POST) + EventSource │ browserApiRequest │
|
|
73
|
+
│ │ signed token: ?token= on /stream, │ Authorization: Bearer <jwt>│
|
|
74
|
+
│ │ Authorization: Bearer on POSTs │ (@elqnt/api-client) │
|
|
75
|
+
│ │ (token from getToken) │ baseUrl = gateway ROOT │
|
|
76
|
+
│ │ baseUrl = CHAT_SERVER_BASE_URL │ │
|
|
77
|
+
└────────┼──────────────────────────────────────────┼─────────────────────────────┘
|
|
78
|
+
▼ (direct, no gateway) ▼
|
|
79
|
+
┌───────────────┐ validate TOKEN, derive ┌───────────────┐ verify JWT,
|
|
80
|
+
│ chat (Go) │ orgId/userId from claims │ API GATEWAY │ stamp X-Org-ID
|
|
81
|
+
│ SSE/REST/WS │ /create /send /stream │ (JWT verify) │ /api/v1/chats**
|
|
82
|
+
└──────┬────────┘ (origin = secondary) └──────┬────────┘
|
|
83
|
+
▼ ▼
|
|
84
|
+
┌────────────────────────────────────────────────────────────┐
|
|
85
|
+
│ chat (Go) — threads, LLM turn for the bound agent, │
|
|
86
|
+
│ SSE fan-out, history/list, memory │
|
|
87
|
+
└────────────────────────────────────────────────────────────┘
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
| | **Realtime** (`useChat`, transport, stream-api) — tier 2 | **History** (`useChatHistory`) — tier 1 |
|
|
91
|
+
|---|---|---|
|
|
92
|
+
| Transport | raw `fetch` POST + `EventSource` SSE, **direct to the chat service** | `browserApiRequest` (`@elqnt/api-client`) via gateway |
|
|
93
|
+
| Auth | **signed token** — `?token=` on `/stream`, `Authorization: Bearer` on POSTs (from `getToken`). Service derives `orgId/userId/product` from the token's claims; `?orgId&userId&clientType` are routing hints only. Origin is defense-in-depth, **not** authorization. | **bearer JWT** (`Authorization: Bearer …`) via the gateway |
|
|
94
|
+
| `baseUrl` | the **chat service endpoint** (`CHAT_SERVER_BASE_URL`), e.g. `…/api/v1/chat` | gateway **root** (e.g. `https://api…`) |
|
|
95
|
+
| Endpoints | `POST /create /send /load /typing /end /event`, `GET /stream` | `/api/v1/chats**` |
|
|
96
|
+
|
|
97
|
+
> **Two common mistakes:**
|
|
98
|
+
> 1. Passing the gateway root as `useChat`'s `baseUrl`. The realtime layer
|
|
99
|
+
> appends `/create`, `/send`, `/stream` directly to `baseUrl` — so `baseUrl`
|
|
100
|
+
> **must** be the full chat prefix (e.g. `${CHAT_SERVER_BASE_URL}` →
|
|
101
|
+
> `…/api/v1/chat`).
|
|
102
|
+
> 2. **Omitting the token.** The chat service now **requires** a valid
|
|
103
|
+
> short-lived signed token on every connect/create/send (tier-2 security
|
|
104
|
+
> rule). Pass `getToken` (see next section). Origin is no longer
|
|
105
|
+
> authorization — a token-less connection is rejected (HTTP 401) when
|
|
106
|
+
> enforcement is on (`CHAT_AUTH_ENFORCE=true`, the default).
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## Tier-2 authorization — the token handshake (REQUIRED)
|
|
111
|
+
|
|
112
|
+
> Full reference (backend validator, claim shape, env/kill-switch, rollout,
|
|
113
|
+
> file map): [`TIER2_AUTH.md`](./TIER2_AUTH.md).
|
|
114
|
+
|
|
115
|
+
Because realtime traffic reaches the chat service **directly** (the gateway —
|
|
116
|
+
and its JWT check — is not in the path), the **service authorizes each
|
|
117
|
+
connection itself** from a short-lived signed token. You supply it via
|
|
118
|
+
`getToken`; the package attaches it for you:
|
|
119
|
+
|
|
120
|
+
- **SSE `/stream`** → as `?token=<jwt>` (EventSource can't set headers).
|
|
121
|
+
- **POST `/create` `/send` `/load` `/typing` `/end` `/event`** → as
|
|
122
|
+
`Authorization: Bearer <jwt>`.
|
|
123
|
+
|
|
124
|
+
The service validates the token (HS256, the shared `JWT_SECRET`; audience
|
|
125
|
+
`eloquent-api`; issuer `eloquent-gateway`; not expired) and **derives
|
|
126
|
+
`orgId` / `userId` / `product` from its claims** — the body/query identity is
|
|
127
|
+
not trusted. So the token MUST be scoped to the same org/user the hook runs as.
|
|
128
|
+
|
|
129
|
+
**Claim shape** (same as the gateway / `@elqnt/api-client/server`
|
|
130
|
+
`generateServerToken` — one signing path serves tier 1 and tier 2):
|
|
131
|
+
|
|
132
|
+
```jsonc
|
|
133
|
+
{
|
|
134
|
+
"org_id": "<org-uuid>", // tenant — the security boundary
|
|
135
|
+
"user_id": "<user/visitor id>", // conversation identity
|
|
136
|
+
"product": "eloquent", // routing
|
|
137
|
+
"scopes": ["chat:connect"],
|
|
138
|
+
"iss": "eloquent-gateway", "aud": "eloquent-api", "exp": <+1h>
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
**Where the token comes from:**
|
|
143
|
+
|
|
144
|
+
| Surface | `getToken` source |
|
|
145
|
+
|---|---|
|
|
146
|
+
| Authenticated app (logged-in user) | `getGatewayToken` from `@elqnt/api-client/browser` — the session token from `/api/gateway-token`, scoped to the user's verified org/user. |
|
|
147
|
+
| Anonymous (public widget, marketing demo) | A server route / **bound server action** that mints a token whose **org is resolved server-side** (from the widgetId or a trusted env), with the visitor id as `user_id`. Never let the browser pick the org. |
|
|
148
|
+
|
|
149
|
+
```tsx
|
|
150
|
+
import { getGatewayToken } from "@elqnt/api-client/browser";
|
|
151
|
+
|
|
152
|
+
// Authenticated: pass the session token provider directly.
|
|
153
|
+
const chat = useChat({ baseUrl, orgId, userId, getToken: getGatewayToken });
|
|
154
|
+
|
|
155
|
+
// Anonymous: a server action bound to the TRUSTED org (Next encrypts bound
|
|
156
|
+
// args, so the browser can't substitute a different org).
|
|
157
|
+
// actions: "use server"; export async function mintAnonChatToken(orgId, product, userId) {…}
|
|
158
|
+
// page: <Widget getChatToken={mintAnonChatToken.bind(null, widget.orgId, "eloquent")} />
|
|
159
|
+
// client: useChat({ …, getToken: () => getChatToken(visitorId) })
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
`getToken` is awaited **before every connect and every REST send**, so a
|
|
163
|
+
long-lived stream keeps working as short-lived tokens roll over. A static
|
|
164
|
+
`token` string is also accepted but does not refresh. If `getToken` resolves to
|
|
165
|
+
nothing, the request goes out token-less and the service rejects it (when
|
|
166
|
+
enforcing).
|
|
167
|
+
|
|
168
|
+
> **Migration:** the service has a kill-switch `CHAT_AUTH_ENFORCE` (default
|
|
169
|
+
> `true`). Set it to `false` for a short, coordinated rollout window — tokens
|
|
170
|
+
> are still validated when present, but token-less connections are allowed
|
|
171
|
+
> through and logged, so already-deployed clients keep working until they ship
|
|
172
|
+
> the handshake. Flip back to `true` (the secure default) once clients are
|
|
173
|
+
> updated.
|
|
174
|
+
|
|
175
|
+
The mental model for a thread: **a thread = a `Chat`, identified by a `chatKey`
|
|
176
|
+
string.** You (1) create a chat (POST `/create`), (2) open the SSE stream (GET
|
|
177
|
+
`/stream`), (3) send messages (POST `/send`). The user message echoes
|
|
178
|
+
optimistically; the **AI reply arrives over SSE as a complete `message` event**,
|
|
179
|
+
not in the POST response.
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
## POST endpoints (realtime, via gateway `/api/v1/chat`)
|
|
184
|
+
|
|
185
|
+
`useChat` drives these for you; the table is the wire contract behind each method.
|
|
186
|
+
|
|
187
|
+
| Op | Path | Method | Body | Response |
|
|
188
|
+
|---|---|---|---|---|
|
|
189
|
+
| Create thread | `/api/v1/chat/create` | POST | `{ orgId, userId, metadata? }` | `{ chatKey }` |
|
|
190
|
+
| Send message | `/api/v1/chat/send` | POST | `{ orgId, chatKey, userId, message: ChatMessage, data? }` | `{ success: true }` (AI reply via SSE) |
|
|
191
|
+
| Load thread | `/api/v1/chat/load` | POST | `{ orgId, chatKey, userId }` | `{ chat: Chat }` |
|
|
192
|
+
| Typing | `/api/v1/chat/typing` | POST | `{ orgId, chatKey, userId, typing }` | `{ success }` |
|
|
193
|
+
| End | `/api/v1/chat/end` | POST | `{ orgId, chatKey, userId, data? }` | `{ success }` |
|
|
194
|
+
| Generic event | `/api/v1/chat/event` | POST | `{ type, orgId, chatKey, userId, data? }` | `{ success }` |
|
|
195
|
+
| SSE stream | `/api/v1/chat/stream` | GET | query `orgId, userId, clientType, chatKey?` | `text/event-stream` |
|
|
196
|
+
|
|
197
|
+
`/send` returns immediately and processes the LLM turn **asynchronously** — the
|
|
198
|
+
assistant reply comes back over the SSE stream as a `message` event. Bind the
|
|
199
|
+
thread to your saved agent by passing `{ agentId }` (or `{ agentName }`) in
|
|
200
|
+
`startChat`'s `metadata` — it's stored on `Chat.metadata`.
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
## The message wire shape (`ChatMessage`)
|
|
205
|
+
|
|
206
|
+
From `@elqnt/chat/models`:
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
type ChatRoleTS = 'user'|'ai'|'event'|'humanAgent'|'observer'|'dataQuery'|'system'|'tool';
|
|
210
|
+
type MessageStatusTS = 'sending'|'sent'|'delivered'|'read'|'failed';
|
|
211
|
+
|
|
212
|
+
interface ChatMessage {
|
|
213
|
+
id: string; role: ChatRoleTS; content: string;
|
|
214
|
+
time: number; status: MessageStatusTS;
|
|
215
|
+
senderId: string; senderName?: string;
|
|
216
|
+
createdAt: number; updatedAt?: number;
|
|
217
|
+
replyTo?: string; threadId?: string; mentions?: string[];
|
|
218
|
+
attachments?: Attachment[]; reactions?: EmojiReaction[];
|
|
219
|
+
variables?: Record<string, Variable>;
|
|
220
|
+
name?: string; toolCallId?: string; toolCalls?: ToolCall[];
|
|
221
|
+
llmUsage?: { inputTokens: number; outputTokens: number; totalTokens: number };
|
|
222
|
+
error?: { code?: string; retryable?: boolean }; // set on a failed-turn message
|
|
223
|
+
}
|
|
224
|
+
interface ToolCall { Name: string; Arguments: Record<string, any>; ID: string; Description: string }
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
The AI reply arrives as a `ChatMessage` with `role === "ai"` inside a `message`
|
|
228
|
+
SSE event.
|
|
229
|
+
|
|
230
|
+
---
|
|
231
|
+
|
|
232
|
+
## The SSE flow + `ChatEvent`
|
|
233
|
+
|
|
234
|
+
The stream is opened with native **`EventSource`** in the browser (`fetch` +
|
|
235
|
+
`ReadableStream` fallback for React Native — auto-selected). URL:
|
|
236
|
+
`${baseUrl}/stream?orgId=…&userId=…&clientType=customer[&chatKey=…]`. Wire format:
|
|
237
|
+
|
|
238
|
+
```
|
|
239
|
+
event: <eventType>\n
|
|
240
|
+
data: <JSON of ChatEvent>\n
|
|
241
|
+
\n
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Heartbeat every ~30s is an SSE comment (`: heartbeat`). Every data line is a
|
|
245
|
+
`ChatEvent`:
|
|
246
|
+
|
|
247
|
+
```ts
|
|
248
|
+
interface ChatEvent {
|
|
249
|
+
type: ChatEventTypeTS;
|
|
250
|
+
orgId: string; chatKey: string; userId: string;
|
|
251
|
+
timestamp: number;
|
|
252
|
+
data?: Record<string, any>;
|
|
253
|
+
message?: ChatMessage;
|
|
254
|
+
}
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
> **Delivery = whole messages, not token deltas.** There is **no `token`/`delta`
|
|
258
|
+
> event** and **no `isStreaming`/`stop()`**. The backend buffers the LLM turn and
|
|
259
|
+
> pushes one complete `message` event (`message.role === "ai"`). Progress is
|
|
260
|
+
> conveyed by named lifecycle events — drive a "thinking" indicator off
|
|
261
|
+
> `agent_execution_started` / `_ended`.
|
|
262
|
+
|
|
263
|
+
Key server→client event types (`ChatEventTypeTS` is the full union — these are
|
|
264
|
+
the ones a custom UI usually handles):
|
|
265
|
+
|
|
266
|
+
| `type` | Meaning |
|
|
267
|
+
|---|---|
|
|
268
|
+
| `message` | A complete message; `event.message` holds the `ChatMessage` (AI reply / tool results). **"The assistant said something."** `useChat` auto-appends it to `messages`. |
|
|
269
|
+
| `reconnected` | Initial connect handshake (`data.connectionType`, `data.connId`). |
|
|
270
|
+
| `new_chat_created` | Another tab created a chat (multi-tab sync); `useChat` adopts `data.chatKey`. |
|
|
271
|
+
| `load_chat_response` | A thread was loaded; `data.chat` is a `Chat` (multi-tab sync). |
|
|
272
|
+
| `agent_execution_started` / `agent_execution_ended` | Agent turn boundaries → "thinking" indicator. |
|
|
273
|
+
| `step_started` / `step_completed` / `step_failed`, `plan_pending_approval` / `plan_approved` / `plan_rejected` / `plan_completed` | Plan → Approve → Execute progress. |
|
|
274
|
+
| `agent_context_update`, `summary_update`, `skills_changed` | Context / skill changes mid-session. |
|
|
275
|
+
| `typing` / `stopped_typing`, `human_agent_joined` / `_left`, `chat_ended`, `show_csat_survey` | Presence / lifecycle. |
|
|
276
|
+
| `attachment_processing_started` / `_progress` / `_complete` / `_error` | Deferred document processing on an attachment. |
|
|
277
|
+
| `client_action` / `client_action_callback` | Client-side tool round-trip (see platform client-functions skill). |
|
|
278
|
+
| `error` | Failed turn; `data.message`. `useChat` surfaces it on `error`. |
|
|
279
|
+
| `transport_reconnected` | **Client-synthetic** — emitted by the transport after a dropped stream re-establishes, so you can re-sync. Never sent by the server. |
|
|
280
|
+
|
|
281
|
+
`useChat` handles `message`, `new_chat_created`, `load_chat_response`,
|
|
282
|
+
`chat_ended`, and `error` internally; for everything else subscribe with
|
|
283
|
+
`on(type, handler)` or the `onMessage` callback.
|
|
284
|
+
|
|
285
|
+
---
|
|
286
|
+
|
|
287
|
+
## Hook: `useChat`
|
|
288
|
+
|
|
289
|
+
```ts
|
|
290
|
+
import { useChat } from "@elqnt/chat/hooks";
|
|
291
|
+
|
|
292
|
+
import { getGatewayToken } from "@elqnt/api-client/browser";
|
|
293
|
+
|
|
294
|
+
const chat = useChat({
|
|
295
|
+
baseUrl: `${process.env.NEXT_PUBLIC_API_GATEWAY_URL}/api/v1/chat`, // MUST be the chat prefix
|
|
296
|
+
orgId, userId, autoConnect: true,
|
|
297
|
+
getToken: getGatewayToken, // REQUIRED — tier-2 token (see handshake section)
|
|
298
|
+
});
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
### Options — `UseChatOptions`
|
|
302
|
+
|
|
303
|
+
```ts
|
|
304
|
+
interface UseChatOptions {
|
|
305
|
+
baseUrl: string; // MUST be the chat prefix, e.g. ".../api/v1/chat"
|
|
306
|
+
orgId: string;
|
|
307
|
+
userId: string;
|
|
308
|
+
clientType?: "customer" | "humanAgent" | "observer"; // default "customer"
|
|
309
|
+
transport?: ChatTransport | "sse" | "sse-fetch"; // auto-selected by default
|
|
310
|
+
onMessage?: (event: ChatEvent) => void; // ALL incoming events
|
|
311
|
+
onError?: (error: TransportError) => void;
|
|
312
|
+
onConnectionChange?: (state: TransportState) => void;
|
|
313
|
+
autoConnect?: boolean; // default false
|
|
314
|
+
retryConfig?: RetryConfig;
|
|
315
|
+
debug?: boolean;
|
|
316
|
+
// Tier-2 authorization (REQUIRED in production). See the handshake section.
|
|
317
|
+
getToken?: () => Promise<string | null | undefined> | string | null | undefined; // awaited per connect + per send
|
|
318
|
+
token?: string; // static alternative to getToken (does NOT refresh)
|
|
319
|
+
}
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
### Return — `UseChatReturn`
|
|
323
|
+
|
|
324
|
+
```ts
|
|
325
|
+
interface UseChatReturn {
|
|
326
|
+
// Connection
|
|
327
|
+
connect: () => Promise<void>;
|
|
328
|
+
disconnect: () => void;
|
|
329
|
+
connectionState: TransportState; // 'disconnected'|'connecting'|'connected'|'reconnecting'
|
|
330
|
+
isConnected: boolean;
|
|
331
|
+
|
|
332
|
+
// Chat operations (POST under the hood, resolve from the HTTP response)
|
|
333
|
+
startChat: (metadata?: Record<string, unknown>) => Promise<string>; // → chatKey (POST /create)
|
|
334
|
+
loadChat: (chatKey: string) => Promise<Chat>; // POST /load
|
|
335
|
+
sendMessage: (content: string, attachments?: unknown[], data?: Record<string, unknown>) => Promise<void>; // POST /send
|
|
336
|
+
sendEvent: (event: Omit<ChatEvent, "timestamp">) => Promise<void>; // POST /event
|
|
337
|
+
endChat: (reason?: string) => Promise<void>; // POST /end
|
|
338
|
+
|
|
339
|
+
// Typing indicators (fire-and-forget POST /typing)
|
|
340
|
+
startTyping: () => void;
|
|
341
|
+
stopTyping: () => void;
|
|
342
|
+
|
|
343
|
+
// State (auto-maintained from SSE)
|
|
344
|
+
currentChat: Chat | null;
|
|
345
|
+
chatKey: string | null;
|
|
346
|
+
messages: ChatMessage[]; // appended from `message` SSE events + optimistic user echo
|
|
347
|
+
error: TransportError | null;
|
|
348
|
+
metrics: ConnectionMetrics;
|
|
349
|
+
|
|
350
|
+
// Event subscription
|
|
351
|
+
on: (eventType: string, handler: (event: ChatEvent) => void) => Unsubscribe;
|
|
352
|
+
clearError: () => void;
|
|
353
|
+
}
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
Free behavior: `startChat` POSTs `/create` and stores `chatKey`; the SSE
|
|
357
|
+
`message` handler appends to `messages`; `sendMessage` optimistically appends the
|
|
358
|
+
user message then POSTs `/send`. There is **no `isStreaming`/`stop()`** — use
|
|
359
|
+
`on("agent_execution_started"/"_ended", …)` for a typing indicator. `startChat`
|
|
360
|
+
and `loadChat` read directly from the HTTP response, so you don't wait on
|
|
361
|
+
`new_chat_created` / `load_chat_response` SSE events (those are multi-tab sync).
|
|
362
|
+
|
|
363
|
+
> **`baseUrl` must be the chat prefix** (`.../api/v1/chat`) and **no bearer is
|
|
364
|
+
> sent** — the stream is authorized by origin allow-list + `?orgId&userId&clientType`.
|
|
365
|
+
|
|
366
|
+
---
|
|
367
|
+
|
|
368
|
+
## Hook: `useChatHistory` (separate auth — bearer JWT)
|
|
369
|
+
|
|
370
|
+
```ts
|
|
371
|
+
import { useChatHistory } from "@elqnt/chat/hooks";
|
|
372
|
+
|
|
373
|
+
const history = useChatHistory({ baseUrl: apiGatewayUrl, orgId }); // gateway ROOT, JWT'd
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
`useChatHistory(options: UseChatHistoryOptions)` (`= ApiClientOptions`) returns
|
|
377
|
+
`UseChatHistoryReturn`:
|
|
378
|
+
|
|
379
|
+
| Method | Signature | Resolves to | Endpoint |
|
|
380
|
+
|---|---|---|---|
|
|
381
|
+
| `getChatHistory` | `(params?: { limit?: number; offset?: number; skipCache?: boolean }) => Promise<ChatHistoryResult>` | `{ chats: [], total: 0, hasMore: false }` on error | `POST /api/v1/chats` |
|
|
382
|
+
| `getChat` | `(chatKey: string) => Promise<ChatSummary \| null>` | `null` | `GET /api/v1/chats/{chatKey}` |
|
|
383
|
+
| `updateChat` | `(chatKey: string, updates: { title?: string; pinned?: boolean }) => Promise<boolean>` | `true`/`false` | `PATCH /api/v1/chats/{chatKey}` |
|
|
384
|
+
| `deleteChat` | `(chatKey: string) => Promise<boolean>` | `true`/`false` | `DELETE /api/v1/chats/{chatKey}` |
|
|
385
|
+
| `getChatsByUser` | `(userEmail: string) => Promise<ChatSummary[]>` | `[]` | `GET /api/v1/chats/user/{email}` |
|
|
386
|
+
|
|
387
|
+
```ts
|
|
388
|
+
interface ChatHistoryResult { chats: ChatSummary[]; total: number; hasMore: boolean; }
|
|
389
|
+
interface ChatSummary {
|
|
390
|
+
chatKey: string; title: string; userId?: string;
|
|
391
|
+
status: string; lastUpdated: number;
|
|
392
|
+
waitingSince?: number; pinned?: boolean;
|
|
393
|
+
metadata?: Record<string, any>; isBackground?: boolean;
|
|
394
|
+
}
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
> **History verbs don't throw.** Like the entity hooks, they're built on
|
|
398
|
+
> `useApiAsync`: each resolves to its default and sets the aggregate `error` on
|
|
399
|
+
> failure. Inspect `history.error` / the returned value — don't wrap in
|
|
400
|
+
> try/catch expecting a throw. `getChat` / `getChatsByUser` return a
|
|
401
|
+
> **`ChatSummary`** (lightweight list shape), not a full `Chat` — load the full
|
|
402
|
+
> thread (with `messages`) via `useChat().loadChat(chatKey)`.
|
|
403
|
+
|
|
404
|
+
---
|
|
405
|
+
|
|
406
|
+
## Thread / conversation model (`Chat`)
|
|
407
|
+
|
|
408
|
+
```ts
|
|
409
|
+
interface Chat {
|
|
410
|
+
orgId: string;
|
|
411
|
+
key: string; // key = chatKey, the thread id
|
|
412
|
+
title: string;
|
|
413
|
+
messages: ChatMessage[];
|
|
414
|
+
startTime: number; lastUpdated: number;
|
|
415
|
+
users: ChatUser[];
|
|
416
|
+
status: 'active'|'disconnected'|'abandoned'|'closed'|'archived'|'completed';
|
|
417
|
+
aiEngaged: boolean; humanAgentEngaged: boolean;
|
|
418
|
+
isWaiting: boolean; isWaitingForAgent: boolean;
|
|
419
|
+
metadata?: Record<string, any>; // holds the agentId/agentName binding
|
|
420
|
+
activeSkillIds?: string[]; deactivatedSkillIds?: string[]; // mid-session skill toggles
|
|
421
|
+
agentContextKey?: string; // "{agentId}:{chatKey}" in NATS KV
|
|
422
|
+
pinned?: boolean;
|
|
423
|
+
chatType?: 'customer_support'|'public_room'|'private_room'|'direct'|'group';
|
|
424
|
+
// …plus grading/flow/context/csat fields
|
|
425
|
+
}
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
Created via `POST /api/v1/chat/create` → `chatKey` (`useChat().startChat`).
|
|
429
|
+
Referenced by `chatKey` thereafter. The lightweight list shape returned by the
|
|
430
|
+
history hook is `ChatSummary` (above); the full thread with `messages` comes from
|
|
431
|
+
`useChat().loadChat(chatKey)` (or `POST /api/v1/chat/load`).
|
|
432
|
+
|
|
433
|
+
---
|
|
434
|
+
|
|
435
|
+
## Minimal end-to-end chat
|
|
436
|
+
|
|
437
|
+
```tsx
|
|
438
|
+
"use client";
|
|
439
|
+
import { useChat } from "@elqnt/chat/hooks";
|
|
440
|
+
import { getGatewayToken } from "@elqnt/api-client/browser";
|
|
441
|
+
import { useEffect, useState } from "react";
|
|
442
|
+
|
|
443
|
+
export function ChatPanel({ orgId, userId, agentName }: {
|
|
444
|
+
orgId: string; userId: string; agentName: string;
|
|
445
|
+
}) {
|
|
446
|
+
const chat = useChat({
|
|
447
|
+
baseUrl: `${process.env.NEXT_PUBLIC_API_GATEWAY_URL}/api/v1/chat`, // chat prefix, NOT gateway root
|
|
448
|
+
orgId, userId, autoConnect: true,
|
|
449
|
+
getToken: getGatewayToken, // tier-2 token — the service rejects token-less connects
|
|
450
|
+
});
|
|
451
|
+
const [thinking, setThinking] = useState(false);
|
|
452
|
+
|
|
453
|
+
useEffect(() => {
|
|
454
|
+
chat.connect();
|
|
455
|
+
return () => chat.disconnect();
|
|
456
|
+
}, []); // eslint-disable-line
|
|
457
|
+
|
|
458
|
+
useEffect(() => chat.on("agent_execution_started", () => setThinking(true)), [chat.on]);
|
|
459
|
+
useEffect(() => chat.on("agent_execution_ended", () => setThinking(false)), [chat.on]);
|
|
460
|
+
|
|
461
|
+
async function begin() {
|
|
462
|
+
await chat.startChat({ agentName }); // binds thread → a saved agent (see @elqnt/agents)
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return (
|
|
466
|
+
<div>
|
|
467
|
+
<button onClick={begin} disabled={!chat.isConnected}>Start</button>
|
|
468
|
+
<ul>
|
|
469
|
+
{chat.messages.map((m) => (
|
|
470
|
+
<li key={m.id}><b>{m.role}:</b> {m.content}</li>
|
|
471
|
+
))}
|
|
472
|
+
</ul>
|
|
473
|
+
{thinking && <p>…thinking</p>}
|
|
474
|
+
<button onClick={() => chat.sendMessage("What's your refund policy?")}>Ask</button>
|
|
475
|
+
</div>
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
Wrap one `useChat` instance per thread subtree in a context so every component
|
|
481
|
+
shares one `messages` / `connectionState` rather than re-connecting N times —
|
|
482
|
+
the same "one shared hook instance" pattern the entities skill uses for its
|
|
483
|
+
domain context.
|
|
484
|
+
|
|
485
|
+
---
|
|
486
|
+
|
|
487
|
+
## Gotchas
|
|
488
|
+
|
|
489
|
+
- **Two auth models — don't cross the wires.** Realtime (`useChat`, transport,
|
|
490
|
+
stream-api) is **tier 2 (direct to the chat service)** → raw `fetch` +
|
|
491
|
+
`EventSource`, authorized by a **signed token from `getToken`** (`?token=` on
|
|
492
|
+
`/stream`, `Authorization: Bearer` on POSTs), `baseUrl` = the chat service
|
|
493
|
+
endpoint (**`.../api/v1/chat`**). History (`useChatHistory`) is **tier 1** →
|
|
494
|
+
`@elqnt/api-client` bearer JWT through the gateway, `baseUrl` = gateway
|
|
495
|
+
**root**. Passing the gateway root to `useChat` (or the chat prefix to
|
|
496
|
+
`useChatHistory`) is the classic failure.
|
|
497
|
+
- **`getToken` is required.** The chat service derives `orgId/userId/product`
|
|
498
|
+
from the token's claims and rejects a token-less connect with **401** (when
|
|
499
|
+
`CHAT_AUTH_ENFORCE=true`, the default). The query `?orgId&userId&clientType`
|
|
500
|
+
are routing hints, **not** identity. Origin is defense-in-depth only — it no
|
|
501
|
+
longer authorizes, and a missing `Origin` is no longer auto-allowed. Scope the
|
|
502
|
+
token to the same org/user the hook runs as. See the handshake section.
|
|
503
|
+
- **No token streaming.** Replies arrive as complete `message` SSE events. No
|
|
504
|
+
`delta`/`token`, no `isStreaming`, no `stop()`. Use
|
|
505
|
+
`agent_execution_started`/`_ended` for a "thinking" UI.
|
|
506
|
+
- **`/send` is async.** It returns `{ success: true }` immediately; the AI reply
|
|
507
|
+
lands later as a `message` event over SSE. Don't read the reply from the POST.
|
|
508
|
+
- **`startChat`/`loadChat` resolve from HTTP.** You don't need to await
|
|
509
|
+
`new_chat_created` / `load_chat_response` SSE events — those are multi-tab
|
|
510
|
+
sync only.
|
|
511
|
+
- **History returns `ChatSummary`, not `Chat`.** `getChat` / `getChatsByUser`
|
|
512
|
+
give the lightweight list shape (no `messages`). Load the full thread with
|
|
513
|
+
`useChat().loadChat(chatKey)`.
|
|
514
|
+
- **History verbs don't throw.** They resolve to defaults and set `error`
|
|
515
|
+
(`useApiAsync`). Check `error` / the result; don't try/catch for throws.
|
|
516
|
+
- **Bind agent → thread** via `startChat({ agentId })` (or `agentName` for
|
|
517
|
+
environment portability — ids differ per environment). The binding lives on
|
|
518
|
+
`Chat.metadata`.
|
|
519
|
+
- **One `useChat` per thread.** Mount it once in a context; don't call it in
|
|
520
|
+
every component (each call opens its own connection + `messages`).
|
|
521
|
+
|
|
522
|
+
## Related skills
|
|
523
|
+
|
|
524
|
+
- `agents` (`@elqnt/agents`) — **design/save the agent** a thread binds to (LLM
|
|
525
|
+
config, skills, tools, csat/handoff). This skill is chat-only; the agent half
|
|
526
|
+
lives there.
|
|
527
|
+
- `entities` (`@elqnt/entity`) — drive the entities backend the same way (hooks
|
|
528
|
+
→ gateway), including the shared "domain layer + one shared hook instance"
|
|
529
|
+
pattern.
|
|
530
|
+
- (Platform) client-functions — the client-side tool round-trip (`client_action`
|
|
531
|
+
SSE event → widget runs a flow → POST a `role:"tool"` result to resume the turn).
|