@laintern/chat-sdk 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/README.md +121 -0
- package/dist/chunk-WMKNNVMS.js +266 -0
- package/dist/chunk-WMKNNVMS.js.map +1 -0
- package/dist/conversation-7VzVDT95.d.cts +284 -0
- package/dist/conversation-7VzVDT95.d.ts +284 -0
- package/dist/index.cjs +538 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +43 -0
- package/dist/index.d.ts +43 -0
- package/dist/index.js +270 -0
- package/dist/index.js.map +1 -0
- package/dist/react/index.cjs +268 -0
- package/dist/react/index.cjs.map +1 -0
- package/dist/react/index.d.cts +25 -0
- package/dist/react/index.d.ts +25 -0
- package/dist/react/index.js +30 -0
- package/dist/react/index.js.map +1 -0
- package/package.json +67 -0
package/README.md
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# @laintern/chat-sdk
|
|
2
|
+
|
|
3
|
+
Client SDK for talking to a Laintern chat agent from your own application: token lifecycle, streaming, citations, sources, guardrails and feedback — without any UI.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install @laintern/chat-sdk
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Requires Node 18+ or a modern browser (`fetch`, `ReadableStream`). React is an optional peer dependency, only for the `/react` entry point.
|
|
10
|
+
|
|
11
|
+
## The one rule
|
|
12
|
+
|
|
13
|
+
**A Laintern API key never goes to the browser.** The key mints tokens for *any* user, so it belongs on your server, behind your own session check. Your backend exchanges it for a short-lived chat token:
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
POST https://api.laintern.com/v1/auth/token
|
|
17
|
+
x-api-key: <your project key>
|
|
18
|
+
|
|
19
|
+
{ "user_id": "<your stable user id>", "metadata": { ... } }
|
|
20
|
+
→ { "data": { "token": "<chat JWT>", "expires_at": "..." } }
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The SDK calls *your* endpoint for that token and refreshes it before it expires.
|
|
24
|
+
|
|
25
|
+
## Quick start
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import { createLainternClient, createConversation } from "@laintern/chat-sdk"
|
|
29
|
+
|
|
30
|
+
const client = createLainternClient({
|
|
31
|
+
apiBase: "https://api.laintern.com",
|
|
32
|
+
// Your endpoint. Returns { token, expires_at } (or { data: { ... } }).
|
|
33
|
+
token: { endpoint: "/api/chat/token" },
|
|
34
|
+
storage: sessionStorage, // optional: survive a page reload
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const conversation = createConversation(client)
|
|
38
|
+
|
|
39
|
+
conversation.subscribe((state) => render(state.turns))
|
|
40
|
+
await conversation.send("Hoe meld ik me aan voor een traject?")
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`send()` resolves when the answer is complete; subscribers see every token as it arrives.
|
|
44
|
+
|
|
45
|
+
### React
|
|
46
|
+
|
|
47
|
+
```tsx
|
|
48
|
+
import { createLainternClient } from "@laintern/chat-sdk"
|
|
49
|
+
import { useLainternChat } from "@laintern/chat-sdk/react"
|
|
50
|
+
|
|
51
|
+
// Create the client once — module scope or a memo in your provider.
|
|
52
|
+
const client = createLainternClient({
|
|
53
|
+
apiBase: process.env.NEXT_PUBLIC_LAINTERN_API!,
|
|
54
|
+
token: { endpoint: "/api/chat/token" },
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
export function Chat() {
|
|
58
|
+
const { turns, isStreaming, send, abort, setFeedback } = useLainternChat(client)
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<>
|
|
62
|
+
{turns.map((turn) => (
|
|
63
|
+
<article key={turn.id}>
|
|
64
|
+
<p>{turn.userMessage}</p>
|
|
65
|
+
{turn.blocked ? (
|
|
66
|
+
<p role="alert">{turn.blocked.message}</p>
|
|
67
|
+
) : turn.error ? (
|
|
68
|
+
<p role="alert">{turn.error.message}</p>
|
|
69
|
+
) : (
|
|
70
|
+
<p>{turn.assistantContent}</p>
|
|
71
|
+
)}
|
|
72
|
+
</article>
|
|
73
|
+
))}
|
|
74
|
+
<button onClick={() => send("Vertel me meer")} disabled={isStreaming}>Verstuur</button>
|
|
75
|
+
</>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## What a turn holds
|
|
81
|
+
|
|
82
|
+
| Field | Meaning |
|
|
83
|
+
|---|---|
|
|
84
|
+
| `assistantContent` | The answer so far. Markdown, with `[1]`-style citation markers. |
|
|
85
|
+
| `citations` | `Map<number, CitationSource>` — the `[1]` in the text → the source it points at. A source with an empty `url` (e.g. a knowledge-base passage) is not navigable: render `title` + `section`, not a link. |
|
|
86
|
+
| `sources` | Everything the agent cited or consulted, server-ranked. |
|
|
87
|
+
| `externalSources` | Suggestions per content type, for a sidebar. Not necessarily cited. Keep the server's order — it is ranked, not alphabetical. |
|
|
88
|
+
| `steps` | Tool activity, for a progress indicator. A completed step replaces its own "started" entry. |
|
|
89
|
+
| `advisory` | A soft notice next to a normal answer. |
|
|
90
|
+
| `blocked` | A guardrail refused the turn; there is no answer. Render `blocked.message` in place of the answer. |
|
|
91
|
+
| `error` | The turn failed. See the error codes below. |
|
|
92
|
+
| `messageId` | Set once the turn completes; required for `setFeedback`. |
|
|
93
|
+
|
|
94
|
+
## Errors
|
|
95
|
+
|
|
96
|
+
`send()` never throws — a failed turn is part of the transcript and lands on `turn.error`. Direct client calls (`getConfig`, `submitFeedback`) throw a `LainternError` with:
|
|
97
|
+
|
|
98
|
+
| `code` | Meaning |
|
|
99
|
+
|---|---|
|
|
100
|
+
| `RATE_LIMIT_EXCEEDED` | Too many messages. `rateLimitScope` / `rateLimitWindow` say which limit. |
|
|
101
|
+
| `GUARDRAIL_TRIGGERED` | Surfaces as `turn.blocked`, not `turn.error`. |
|
|
102
|
+
| `MCP_UNAVAILABLE` | A dependency of the agent failed. `reason: "unauthenticated"` means the user's session expired — reload and re-mint. |
|
|
103
|
+
| `TOKEN_FETCH_FAILED` / `TOKEN_INVALID` | Your token endpoint failed or returned no token. |
|
|
104
|
+
| `NETWORK_ERROR` | Transport failure. |
|
|
105
|
+
|
|
106
|
+
`error.isRetryable` is true for a rate limit or a transport failure; an expired session is not — mint a new token first.
|
|
107
|
+
|
|
108
|
+
## Token lifetime
|
|
109
|
+
|
|
110
|
+
The SDK reads the JWT's `exp` and refreshes 60 seconds before it, via your endpoint. That margin matters: when the API mints a token around a credential of your own (an upstream session token passed in `metadata`), it clamps the chat token's expiry to that credential. The refresh therefore lands back at *your* endpoint while the credential is still alive — so re-check the user's session there and mint a **fresh** upstream credential rather than returning the old one.
|
|
111
|
+
|
|
112
|
+
Pass `storage: sessionStorage` (or your own `{ getItem, setItem, removeItem }`) to survive reloads. Storage that throws — private mode, blocked cookies — is handled: the SDK just refetches.
|
|
113
|
+
|
|
114
|
+
## API
|
|
115
|
+
|
|
116
|
+
- `createLainternClient(options)` → `sendMessage()` (async iterable of raw events), `getConfig()`, `submitFeedback()`, `tokens`
|
|
117
|
+
- `createConversation(client, options?)` → `getState()`, `subscribe()`, `send()`, `abort()`, `reset()`, `setFeedback()`
|
|
118
|
+
- `useLainternChat(client, options?)` — React binding, `@laintern/chat-sdk/react`
|
|
119
|
+
- `parseSSE(response)`, `createTokenManager(options)` — the building blocks, if you want to assemble your own
|
|
120
|
+
|
|
121
|
+
`getConfig()` returns the agent's transparency disclaimer. If it is enabled, show it at the start of every conversation — that is an EU AI Act obligation on the deployer, not a decoration.
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// src/errors.ts
|
|
2
|
+
var LainternError = class extends Error {
|
|
3
|
+
code;
|
|
4
|
+
status;
|
|
5
|
+
/** Set on RATE_LIMIT_EXCEEDED. */
|
|
6
|
+
rateLimitScope;
|
|
7
|
+
rateLimitWindow;
|
|
8
|
+
/** Set on MCP_UNAVAILABLE: which dependency failed, and how. */
|
|
9
|
+
reason;
|
|
10
|
+
constructor(code, message, options = {}) {
|
|
11
|
+
super(message, options.cause !== void 0 ? { cause: options.cause } : void 0);
|
|
12
|
+
this.name = "LainternError";
|
|
13
|
+
this.code = code;
|
|
14
|
+
this.status = options.status ?? null;
|
|
15
|
+
if (options.rateLimitScope) this.rateLimitScope = options.rateLimitScope;
|
|
16
|
+
if (options.rateLimitWindow) this.rateLimitWindow = options.rateLimitWindow;
|
|
17
|
+
if (options.reason) this.reason = options.reason;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* True when retrying later could plausibly succeed: a rate limit, or a
|
|
21
|
+
* dependency the server could not reach. An expired session is NOT retryable
|
|
22
|
+
* — the host has to mint a new token first.
|
|
23
|
+
*/
|
|
24
|
+
get isRetryable() {
|
|
25
|
+
return this.code === "RATE_LIMIT_EXCEEDED" || this.reason === "transport";
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
function pick(value, allowed) {
|
|
29
|
+
return typeof value === "string" && allowed.includes(value) ? value : void 0;
|
|
30
|
+
}
|
|
31
|
+
async function errorFromResponse(response) {
|
|
32
|
+
let code = `HTTP_${response.status}`;
|
|
33
|
+
let message = "An unexpected error occurred.";
|
|
34
|
+
let rateLimitScope;
|
|
35
|
+
let rateLimitWindow;
|
|
36
|
+
let reason;
|
|
37
|
+
try {
|
|
38
|
+
const body = await response.json();
|
|
39
|
+
if (body?.error?.code) {
|
|
40
|
+
code = body.error.code;
|
|
41
|
+
message = body.error.message ?? message;
|
|
42
|
+
rateLimitScope = pick(body.error.scope, ["user", "agent"]);
|
|
43
|
+
rateLimitWindow = pick(body.error.window, ["minute", "hour", "day"]);
|
|
44
|
+
if (typeof body.error.reason === "string") reason = body.error.reason;
|
|
45
|
+
} else if (typeof body?.message === "string") {
|
|
46
|
+
message = body.message;
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
}
|
|
50
|
+
return new LainternError(code, message, {
|
|
51
|
+
status: response.status,
|
|
52
|
+
rateLimitScope,
|
|
53
|
+
rateLimitWindow,
|
|
54
|
+
reason
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// src/conversation.ts
|
|
59
|
+
function newId() {
|
|
60
|
+
const c = globalThis.crypto;
|
|
61
|
+
if (c && typeof c.randomUUID === "function") return c.randomUUID();
|
|
62
|
+
return `turn-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
63
|
+
}
|
|
64
|
+
function createTurn(message, images) {
|
|
65
|
+
return {
|
|
66
|
+
id: newId(),
|
|
67
|
+
userMessage: message,
|
|
68
|
+
...images?.length ? { userImages: images } : {},
|
|
69
|
+
assistantContent: "",
|
|
70
|
+
citations: /* @__PURE__ */ new Map(),
|
|
71
|
+
sources: [],
|
|
72
|
+
externalSources: {},
|
|
73
|
+
mode: null,
|
|
74
|
+
steps: [],
|
|
75
|
+
isStreaming: true
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
function createConversation(client, options = {}) {
|
|
79
|
+
let state = {
|
|
80
|
+
turns: options.initialTurns ?? [],
|
|
81
|
+
sessionId: options.sessionId ?? null,
|
|
82
|
+
isStreaming: false
|
|
83
|
+
};
|
|
84
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
85
|
+
let controller = null;
|
|
86
|
+
function emit(next) {
|
|
87
|
+
state = next;
|
|
88
|
+
for (const listener of listeners) listener(state);
|
|
89
|
+
}
|
|
90
|
+
function patchLast(patch) {
|
|
91
|
+
const lastIndex = state.turns.length - 1;
|
|
92
|
+
if (lastIndex < 0) return;
|
|
93
|
+
const turns = [...state.turns];
|
|
94
|
+
turns[lastIndex] = patch(turns[lastIndex]);
|
|
95
|
+
emit({ ...state, turns });
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
getState: () => state,
|
|
99
|
+
subscribe(listener) {
|
|
100
|
+
listeners.add(listener);
|
|
101
|
+
return () => listeners.delete(listener);
|
|
102
|
+
},
|
|
103
|
+
abort() {
|
|
104
|
+
controller?.abort();
|
|
105
|
+
},
|
|
106
|
+
reset() {
|
|
107
|
+
controller?.abort();
|
|
108
|
+
emit({ turns: [], sessionId: null, isStreaming: false });
|
|
109
|
+
},
|
|
110
|
+
async send(message, sendOptions = {}) {
|
|
111
|
+
controller?.abort();
|
|
112
|
+
controller = new AbortController();
|
|
113
|
+
const signal = controller.signal;
|
|
114
|
+
emit({
|
|
115
|
+
...state,
|
|
116
|
+
turns: [...state.turns, createTurn(message, sendOptions.images)],
|
|
117
|
+
isStreaming: true
|
|
118
|
+
});
|
|
119
|
+
try {
|
|
120
|
+
const stream = client.sendMessage(message, {
|
|
121
|
+
sessionId: state.sessionId,
|
|
122
|
+
...sendOptions.images ? { images: sendOptions.images } : {},
|
|
123
|
+
...sendOptions.forceTool ? { forceTool: sendOptions.forceTool } : {},
|
|
124
|
+
signal
|
|
125
|
+
});
|
|
126
|
+
for await (const event of stream) {
|
|
127
|
+
if (signal.aborted) break;
|
|
128
|
+
switch (event.type) {
|
|
129
|
+
case "session":
|
|
130
|
+
emit({ ...state, sessionId: event.session_id });
|
|
131
|
+
options.onSessionId?.(event.session_id);
|
|
132
|
+
break;
|
|
133
|
+
case "mode":
|
|
134
|
+
patchLast((turn) => ({ ...turn, mode: event.mode }));
|
|
135
|
+
break;
|
|
136
|
+
case "step":
|
|
137
|
+
patchLast((turn) => ({ ...turn, steps: mergeStep(turn.steps, event.step) }));
|
|
138
|
+
break;
|
|
139
|
+
case "delta":
|
|
140
|
+
patchLast((turn) => ({
|
|
141
|
+
...turn,
|
|
142
|
+
assistantContent: turn.assistantContent + event.content
|
|
143
|
+
}));
|
|
144
|
+
break;
|
|
145
|
+
case "citation":
|
|
146
|
+
patchLast((turn) => {
|
|
147
|
+
const citations = new Map(turn.citations);
|
|
148
|
+
citations.set(event.index, event.source);
|
|
149
|
+
return { ...turn, citations };
|
|
150
|
+
});
|
|
151
|
+
break;
|
|
152
|
+
case "sources":
|
|
153
|
+
patchLast((turn) => ({ ...turn, sources: event.items }));
|
|
154
|
+
break;
|
|
155
|
+
case "external_sources":
|
|
156
|
+
patchLast((turn) => ({
|
|
157
|
+
...turn,
|
|
158
|
+
externalSources: {
|
|
159
|
+
...turn.externalSources,
|
|
160
|
+
[event.source_type]: event.items
|
|
161
|
+
}
|
|
162
|
+
}));
|
|
163
|
+
break;
|
|
164
|
+
case "advisory":
|
|
165
|
+
patchLast((turn) => ({
|
|
166
|
+
...turn,
|
|
167
|
+
advisory: { redirect: event.redirect ?? null, message: event.message }
|
|
168
|
+
}));
|
|
169
|
+
break;
|
|
170
|
+
case "done":
|
|
171
|
+
patchLast((turn) => ({
|
|
172
|
+
...turn,
|
|
173
|
+
isStreaming: false,
|
|
174
|
+
...event.message_id ? { messageId: event.message_id } : {}
|
|
175
|
+
}));
|
|
176
|
+
emit({ ...state, isStreaming: false });
|
|
177
|
+
break;
|
|
178
|
+
case "error":
|
|
179
|
+
applyFailure(patchLast, {
|
|
180
|
+
code: event.code,
|
|
181
|
+
message: event.message,
|
|
182
|
+
guardrailSlug: event.guardrail_slug ?? null
|
|
183
|
+
});
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
} catch (err) {
|
|
188
|
+
if (err?.name === "AbortError") {
|
|
189
|
+
patchLast((turn) => ({ ...turn, isStreaming: false }));
|
|
190
|
+
} else if (err instanceof LainternError) {
|
|
191
|
+
applyFailure(patchLast, {
|
|
192
|
+
code: err.code,
|
|
193
|
+
message: err.message,
|
|
194
|
+
guardrailSlug: null,
|
|
195
|
+
...err.rateLimitScope ? { rateLimitScope: err.rateLimitScope } : {},
|
|
196
|
+
...err.rateLimitWindow ? { rateLimitWindow: err.rateLimitWindow } : {}
|
|
197
|
+
});
|
|
198
|
+
} else {
|
|
199
|
+
applyFailure(patchLast, {
|
|
200
|
+
code: "NETWORK_ERROR",
|
|
201
|
+
message: err instanceof Error ? err.message : "An unknown error occurred.",
|
|
202
|
+
guardrailSlug: null
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
} finally {
|
|
206
|
+
const last = state.turns.at(-1);
|
|
207
|
+
if (last?.isStreaming) patchLast((turn) => ({ ...turn, isStreaming: false }));
|
|
208
|
+
if (state.isStreaming) emit({ ...state, isStreaming: false });
|
|
209
|
+
controller = null;
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
async setFeedback(turnId, feedback) {
|
|
213
|
+
const turn = state.turns.find((t) => t.id === turnId);
|
|
214
|
+
if (!turn?.messageId) {
|
|
215
|
+
throw new LainternError(
|
|
216
|
+
"FEEDBACK_UNAVAILABLE",
|
|
217
|
+
"This turn has no message id yet \u2014 feedback is only possible once a turn completes."
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
await client.submitFeedback(turn.messageId, feedback);
|
|
221
|
+
emit({
|
|
222
|
+
...state,
|
|
223
|
+
turns: state.turns.map((t) => t.id === turnId ? { ...t, feedback } : t)
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
function mergeStep(steps, step) {
|
|
229
|
+
if (step.status !== "completed") return [...steps, step];
|
|
230
|
+
const startedIndex = steps.findIndex((s) => s.tool === step.tool && s.status === "started");
|
|
231
|
+
if (startedIndex === -1) return [...steps, step];
|
|
232
|
+
const next = [...steps];
|
|
233
|
+
next[startedIndex] = step;
|
|
234
|
+
return next;
|
|
235
|
+
}
|
|
236
|
+
function applyFailure(patchLast, failure) {
|
|
237
|
+
if (failure.code === "GUARDRAIL_TRIGGERED") {
|
|
238
|
+
patchLast((turn) => ({
|
|
239
|
+
...turn,
|
|
240
|
+
isStreaming: false,
|
|
241
|
+
assistantContent: "",
|
|
242
|
+
citations: /* @__PURE__ */ new Map(),
|
|
243
|
+
sources: [],
|
|
244
|
+
externalSources: {},
|
|
245
|
+
steps: [],
|
|
246
|
+
mode: null,
|
|
247
|
+
advisory: void 0,
|
|
248
|
+
blocked: { slug: failure.guardrailSlug, message: failure.message }
|
|
249
|
+
}));
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
patchLast((turn) => ({
|
|
253
|
+
...turn,
|
|
254
|
+
isStreaming: false,
|
|
255
|
+
error: {
|
|
256
|
+
code: failure.code,
|
|
257
|
+
message: failure.message,
|
|
258
|
+
...failure.rateLimitScope ? { rateLimitScope: failure.rateLimitScope } : {},
|
|
259
|
+
...failure.rateLimitWindow ? { rateLimitWindow: failure.rateLimitWindow } : {}
|
|
260
|
+
}
|
|
261
|
+
}));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export { LainternError, createConversation, errorFromResponse };
|
|
265
|
+
//# sourceMappingURL=chunk-WMKNNVMS.js.map
|
|
266
|
+
//# sourceMappingURL=chunk-WMKNNVMS.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/errors.ts","../src/conversation.ts"],"names":[],"mappings":"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","file":"chunk-WMKNNVMS.js","sourcesContent":["/**\n * Every failure the SDK surfaces, with the API's own error code attached.\n *\n * Callers switch on `code` rather than parsing messages: the server's copy is\n * user-facing prose that may change, while the code is part of the contract.\n */\nexport class LainternError extends Error {\n readonly code: string\n readonly status: number | null\n /** Set on RATE_LIMIT_EXCEEDED. */\n readonly rateLimitScope?: \"user\" | \"agent\"\n readonly rateLimitWindow?: \"minute\" | \"hour\" | \"day\"\n /** Set on MCP_UNAVAILABLE: which dependency failed, and how. */\n readonly reason?: string\n\n constructor(\n code: string,\n message: string,\n options: {\n status?: number | null\n rateLimitScope?: \"user\" | \"agent\"\n rateLimitWindow?: \"minute\" | \"hour\" | \"day\"\n reason?: string\n cause?: unknown\n } = {},\n ) {\n super(message, options.cause !== undefined ? { cause: options.cause } : undefined)\n this.name = \"LainternError\"\n this.code = code\n this.status = options.status ?? null\n if (options.rateLimitScope) this.rateLimitScope = options.rateLimitScope\n if (options.rateLimitWindow) this.rateLimitWindow = options.rateLimitWindow\n if (options.reason) this.reason = options.reason\n }\n\n /**\n * True when retrying later could plausibly succeed: a rate limit, or a\n * dependency the server could not reach. An expired session is NOT retryable\n * — the host has to mint a new token first.\n */\n get isRetryable(): boolean {\n return this.code === \"RATE_LIMIT_EXCEEDED\" || this.reason === \"transport\"\n }\n}\n\nfunction pick<T extends string>(value: unknown, allowed: readonly T[]): T | undefined {\n return typeof value === \"string\" && (allowed as readonly string[]).includes(value)\n ? (value as T)\n : undefined\n}\n\n/** Turn a non-2xx API response into a `LainternError`. Never throws itself. */\nexport async function errorFromResponse(response: Response): Promise<LainternError> {\n let code = `HTTP_${response.status}`\n let message = \"An unexpected error occurred.\"\n let rateLimitScope: \"user\" | \"agent\" | undefined\n let rateLimitWindow: \"minute\" | \"hour\" | \"day\" | undefined\n let reason: string | undefined\n\n try {\n const body = (await response.json()) as {\n error?: {\n code?: string\n message?: string\n scope?: unknown\n window?: unknown\n reason?: unknown\n }\n message?: string\n }\n if (body?.error?.code) {\n code = body.error.code\n message = body.error.message ?? message\n rateLimitScope = pick(body.error.scope, [\"user\", \"agent\"] as const)\n rateLimitWindow = pick(body.error.window, [\"minute\", \"hour\", \"day\"] as const)\n if (typeof body.error.reason === \"string\") reason = body.error.reason\n } else if (typeof body?.message === \"string\") {\n message = body.message\n }\n } catch {\n // Not JSON — keep the HTTP-derived defaults.\n }\n\n return new LainternError(code, message, {\n status: response.status,\n rateLimitScope,\n rateLimitWindow,\n reason,\n })\n}\n","import type {\n CitationSource,\n ExternalSourceType,\n SearchResult,\n StepEvent,\n} from \"@laintern-headless/types\"\nimport type { LainternClient } from \"./client.js\"\nimport { LainternError } from \"./errors.js\"\n\n/** Soft advisory alongside an answer — the agent flagged the topic, but answered. */\nexport interface TurnAdvisory {\n redirect: string | null\n message: string\n}\n\n/** A guardrail refused the turn. The agent produced no answer. */\nexport interface TurnBlock {\n /** Which guardrail fired, when the server names one. */\n slug: string | null\n /** Copy meant for the end user, written by the guardrail's configuration. */\n message: string\n}\n\nexport interface TurnError {\n code: string\n message: string\n rateLimitScope?: \"user\" | \"agent\"\n rateLimitWindow?: \"minute\" | \"hour\" | \"day\"\n}\n\nexport interface Turn {\n id: string\n userMessage: string\n userImages?: string[]\n assistantContent: string\n /** Citation number (the `[1]` in the text) → the source it points at. */\n citations: Map<number, CitationSource>\n /** Everything the agent cited or consulted, in the order the server ranked it. */\n sources: SearchResult[]\n /** Sidebar suggestions per content type — not necessarily cited. */\n externalSources: Partial<Record<ExternalSourceType, SearchResult[]>>\n mode: string | null\n steps: StepEvent[]\n isStreaming: boolean\n error?: TurnError\n blocked?: TurnBlock\n advisory?: TurnAdvisory\n messageId?: string\n feedback?: \"positive\" | \"negative\" | null\n}\n\nexport interface ConversationState {\n turns: Turn[]\n sessionId: string | null\n isStreaming: boolean\n}\n\nexport interface SendOptions {\n images?: string[]\n forceTool?: string\n}\n\nexport interface Conversation {\n getState(): ConversationState\n /** Subscribe to state changes. Returns an unsubscribe function. */\n subscribe(listener: (state: ConversationState) => void): () => void\n /** Send a message and stream the answer into state. Never throws. */\n send(message: string, options?: SendOptions): Promise<void>\n /** Stop the current stream. The partial answer stays in state. */\n abort(): void\n /** Drop all turns and start a new server-side session on the next send. */\n reset(): void\n setFeedback(turnId: string, feedback: \"positive\" | \"negative\"): Promise<void>\n}\n\nexport interface ConversationOptions {\n /** Resume an existing server-side session. */\n sessionId?: string | null\n /** Restore turns from your own storage. */\n initialTurns?: Turn[]\n /** Called whenever the session id changes, so you can persist it. */\n onSessionId?: (sessionId: string) => void\n}\n\nfunction newId(): string {\n const c = globalThis.crypto\n if (c && typeof c.randomUUID === \"function\") return c.randomUUID()\n return `turn-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`\n}\n\nfunction createTurn(message: string, images?: string[]): Turn {\n return {\n id: newId(),\n userMessage: message,\n ...(images?.length ? { userImages: images } : {}),\n assistantContent: \"\",\n citations: new Map(),\n sources: [],\n externalSources: {},\n mode: null,\n steps: [],\n isStreaming: true,\n }\n}\n\n/**\n * Stateful conversation on top of {@link LainternClient}.\n *\n * Holds the turn list, applies each stream event to the turn in flight, and\n * notifies subscribers. Framework-agnostic on purpose: the React binding in\n * `@laintern/chat-sdk/react` is a thin `useSyncExternalStore` wrapper, and any\n * other UI layer can subscribe the same way.\n *\n * `send()` never throws. A failed turn is part of the conversation — it lands\n * on `turn.error` (or `turn.blocked` for a guardrail) so the UI can render it\n * in place, which is what you want in a chat transcript.\n */\nexport function createConversation(\n client: LainternClient,\n options: ConversationOptions = {},\n): Conversation {\n let state: ConversationState = {\n turns: options.initialTurns ?? [],\n sessionId: options.sessionId ?? null,\n isStreaming: false,\n }\n const listeners = new Set<(state: ConversationState) => void>()\n let controller: AbortController | null = null\n\n function emit(next: ConversationState): void {\n state = next\n for (const listener of listeners) listener(state)\n }\n\n /** Apply a change to the turn currently streaming (always the last one). */\n function patchLast(patch: (turn: Turn) => Turn): void {\n const lastIndex = state.turns.length - 1\n if (lastIndex < 0) return\n const turns = [...state.turns]\n turns[lastIndex] = patch(turns[lastIndex])\n emit({ ...state, turns })\n }\n\n return {\n getState: () => state,\n\n subscribe(listener) {\n listeners.add(listener)\n return () => listeners.delete(listener)\n },\n\n abort() {\n controller?.abort()\n },\n\n reset() {\n controller?.abort()\n emit({ turns: [], sessionId: null, isStreaming: false })\n },\n\n async send(message, sendOptions = {}) {\n controller?.abort()\n controller = new AbortController()\n const signal = controller.signal\n\n emit({\n ...state,\n turns: [...state.turns, createTurn(message, sendOptions.images)],\n isStreaming: true,\n })\n\n try {\n const stream = client.sendMessage(message, {\n sessionId: state.sessionId,\n ...(sendOptions.images ? { images: sendOptions.images } : {}),\n ...(sendOptions.forceTool ? { forceTool: sendOptions.forceTool } : {}),\n signal,\n })\n\n for await (const event of stream) {\n if (signal.aborted) break\n\n switch (event.type) {\n case \"session\":\n emit({ ...state, sessionId: event.session_id })\n options.onSessionId?.(event.session_id)\n break\n case \"mode\":\n patchLast((turn) => ({ ...turn, mode: event.mode }))\n break\n case \"step\":\n patchLast((turn) => ({ ...turn, steps: mergeStep(turn.steps, event.step) }))\n break\n case \"delta\":\n patchLast((turn) => ({\n ...turn,\n assistantContent: turn.assistantContent + event.content,\n }))\n break\n case \"citation\":\n patchLast((turn) => {\n const citations = new Map(turn.citations)\n citations.set(event.index, event.source)\n return { ...turn, citations }\n })\n break\n case \"sources\":\n patchLast((turn) => ({ ...turn, sources: event.items }))\n break\n case \"external_sources\":\n patchLast((turn) => ({\n ...turn,\n externalSources: {\n ...turn.externalSources,\n [event.source_type]: event.items,\n },\n }))\n break\n case \"advisory\":\n patchLast((turn) => ({\n ...turn,\n advisory: { redirect: event.redirect ?? null, message: event.message },\n }))\n break\n case \"done\":\n patchLast((turn) => ({\n ...turn,\n isStreaming: false,\n ...(event.message_id ? { messageId: event.message_id } : {}),\n }))\n emit({ ...state, isStreaming: false })\n break\n case \"error\":\n applyFailure(patchLast, {\n code: event.code,\n message: event.message,\n guardrailSlug: event.guardrail_slug ?? null,\n })\n break\n }\n }\n } catch (err) {\n if ((err as Error)?.name === \"AbortError\") {\n patchLast((turn) => ({ ...turn, isStreaming: false }))\n } else if (err instanceof LainternError) {\n applyFailure(patchLast, {\n code: err.code,\n message: err.message,\n guardrailSlug: null,\n ...(err.rateLimitScope ? { rateLimitScope: err.rateLimitScope } : {}),\n ...(err.rateLimitWindow ? { rateLimitWindow: err.rateLimitWindow } : {}),\n })\n } else {\n applyFailure(patchLast, {\n code: \"NETWORK_ERROR\",\n message: err instanceof Error ? err.message : \"An unknown error occurred.\",\n guardrailSlug: null,\n })\n }\n } finally {\n // A turn always stops streaming, however it ended — including an abort\n // that broke the loop without throwing. Leaving the flag set strands a\n // typing indicator on a turn that will never produce another token.\n const last = state.turns.at(-1)\n if (last?.isStreaming) patchLast((turn) => ({ ...turn, isStreaming: false }))\n if (state.isStreaming) emit({ ...state, isStreaming: false })\n controller = null\n }\n },\n\n async setFeedback(turnId, feedback) {\n const turn = state.turns.find((t) => t.id === turnId)\n if (!turn?.messageId) {\n throw new LainternError(\n \"FEEDBACK_UNAVAILABLE\",\n \"This turn has no message id yet — feedback is only possible once a turn completes.\",\n )\n }\n await client.submitFeedback(turn.messageId, feedback)\n emit({\n ...state,\n turns: state.turns.map((t) => (t.id === turnId ? { ...t, feedback } : t)),\n })\n },\n }\n}\n\n/**\n * A completed step replaces its own \"started\" entry rather than appending, so\n * the UI shows one row per tool call that fills in, not two.\n */\nfunction mergeStep(steps: StepEvent[], step: StepEvent): StepEvent[] {\n if (step.status !== \"completed\") return [...steps, step]\n const startedIndex = steps.findIndex((s) => s.tool === step.tool && s.status === \"started\")\n if (startedIndex === -1) return [...steps, step]\n const next = [...steps]\n next[startedIndex] = step\n return next\n}\n\n/**\n * A failed or blocked turn.\n *\n * A guardrail block discards whatever streamed before it fired: the partial\n * answer is exactly the content the guardrail decided the user should not see,\n * and a hard block always wins over a soft advisory that arrived a moment\n * earlier.\n */\nfunction applyFailure(\n patchLast: (patch: (turn: Turn) => Turn) => void,\n failure: {\n code: string\n message: string\n guardrailSlug: string | null\n rateLimitScope?: \"user\" | \"agent\"\n rateLimitWindow?: \"minute\" | \"hour\" | \"day\"\n },\n): void {\n if (failure.code === \"GUARDRAIL_TRIGGERED\") {\n patchLast((turn) => ({\n ...turn,\n isStreaming: false,\n assistantContent: \"\",\n citations: new Map(),\n sources: [],\n externalSources: {},\n steps: [],\n mode: null,\n advisory: undefined,\n blocked: { slug: failure.guardrailSlug, message: failure.message },\n }))\n return\n }\n\n patchLast((turn) => ({\n ...turn,\n isStreaming: false,\n error: {\n code: failure.code,\n message: failure.message,\n ...(failure.rateLimitScope ? { rateLimitScope: failure.rateLimitScope } : {}),\n ...(failure.rateLimitWindow ? { rateLimitWindow: failure.rateLimitWindow } : {}),\n },\n }))\n}\n"]}
|