juneau 0.7.2 → 0.8.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 +1066 -1051
- package/dist/components/AiChatHeader/AiChatHeader.d.ts +6 -1
- package/dist/components/AiChatHeader/AiChatHeader.d.ts.map +1 -1
- package/dist/components/AiMessageBubble/AiMessageBubble.d.ts.map +1 -1
- package/dist/components/AiSidebar/AiSidebar.d.ts +6 -1
- package/dist/components/AiSidebar/AiSidebar.d.ts.map +1 -1
- package/dist/index.cjs +26 -26
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +1160 -1150
- package/dist/index.js.map +1 -1
- package/dist/style.css +1 -1
- package/package.json +86 -86
package/README.md
CHANGED
|
@@ -1,1051 +1,1066 @@
|
|
|
1
|
-
# Juneau
|
|
2
|
-
|
|
3
|
-
React component library for building AI chat interfaces. Streaming-first, adapter-based, fully themeable.
|
|
4
|
-
|
|
5
|
-
```tsx
|
|
6
|
-
import { JuneauProvider, AiChatProvider, AiSidebar, createSseAdapter } from 'juneau';
|
|
7
|
-
import 'juneau/dist/style.css';
|
|
8
|
-
|
|
9
|
-
const adapter = createSseAdapter('/api/chat');
|
|
10
|
-
|
|
11
|
-
<JuneauProvider>
|
|
12
|
-
<AiChatProvider adapter={adapter}>
|
|
13
|
-
<App />
|
|
14
|
-
<AiSidebar />
|
|
15
|
-
</AiChatProvider>
|
|
16
|
-
</JuneauProvider>
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
---
|
|
20
|
-
|
|
21
|
-
## Install
|
|
22
|
-
|
|
23
|
-
```bash
|
|
24
|
-
npm install juneau
|
|
25
|
-
```
|
|
26
|
-
|
|
27
|
-
**Peer dependencies:** `react ^18 || ^19`, `react-dom ^18 || ^19`
|
|
28
|
-
|
|
29
|
-
---
|
|
30
|
-
|
|
31
|
-
## Core concept — the adapter
|
|
32
|
-
|
|
33
|
-
Juneau never calls any AI API directly. **You own the network layer.** Juneau provides the UI, state, and streaming machinery — you provide an adapter that connects it to your backend.
|
|
34
|
-
|
|
35
|
-
An adapter is just one method:
|
|
36
|
-
|
|
37
|
-
```ts
|
|
38
|
-
interface AiBackendAdapter {
|
|
39
|
-
sendMessage(input: AiAdapterInput): AsyncIterable<AiStreamEvent>;
|
|
40
|
-
}
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
It receives the conversation history and streams back typed events:
|
|
44
|
-
|
|
45
|
-
```ts
|
|
46
|
-
type AiStreamEvent =
|
|
47
|
-
| { type: 'text'; text: string } // streamed text chunk — append to current bubble
|
|
48
|
-
| { type: 'part'; part: AiMessagePart } // rich UI block (table, entity, entity-list, proposal, activity, error)
|
|
49
|
-
| { type: 'done' } // stream finished cleanly
|
|
50
|
-
| { type: 'error'; message: string } // stream failed
|
|
51
|
-
```
|
|
52
|
-
|
|
53
|
-
This means your API keys stay on your backend, you control auth, rate limiting, model selection — Juneau just renders whatever comes back.
|
|
54
|
-
|
|
55
|
-
---
|
|
56
|
-
|
|
57
|
-
## Built-in adapters
|
|
58
|
-
|
|
59
|
-
Juneau ships two factory functions so you don't have to write boilerplate streaming code.
|
|
60
|
-
|
|
61
|
-
### `createSseAdapter(url, options?)` — recommended
|
|
62
|
-
|
|
63
|
-
For backends that stream **Server-Sent Events (SSE)** — the format used by OpenAI, Anthropic, and most AI API proxies.
|
|
64
|
-
|
|
65
|
-
The default parser understands OpenAI's streaming format out of the box (`choices[0].delta.content`).
|
|
66
|
-
|
|
67
|
-
```ts
|
|
68
|
-
import { createSseAdapter } from 'juneau';
|
|
69
|
-
|
|
70
|
-
// OpenAI-compatible backend — zero config needed:
|
|
71
|
-
const adapter = createSseAdapter('/api/chat');
|
|
72
|
-
|
|
73
|
-
// With dynamic auth header:
|
|
74
|
-
const adapter = createSseAdapter('/api/chat', {
|
|
75
|
-
getHeaders: async () => ({
|
|
76
|
-
Authorization: `Bearer ${await getSessionToken()}`,
|
|
77
|
-
}),
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
// Custom request body:
|
|
81
|
-
const adapter = createSseAdapter('/api/chat', {
|
|
82
|
-
getBody: ({ messages, context }) => ({
|
|
83
|
-
messages,
|
|
84
|
-
model: 'gpt-4o',
|
|
85
|
-
stream: true,
|
|
86
|
-
temperature: 0.7,
|
|
87
|
-
}),
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
// Custom SSE event schema (backend streams { text: "..." } instead of OpenAI format):
|
|
91
|
-
const adapter = createSseAdapter('/api/chat', {
|
|
92
|
-
parseEvent: (data) => {
|
|
93
|
-
try {
|
|
94
|
-
const json = JSON.parse(data);
|
|
95
|
-
return json.text ? [{ type: 'text', text: json.text }] : [];
|
|
96
|
-
} catch {
|
|
97
|
-
return [];
|
|
98
|
-
}
|
|
99
|
-
},
|
|
100
|
-
});
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
| Option | Type | Default | Description |
|
|
104
|
-
|---|---|---|---|
|
|
105
|
-
| `method` | `string` | `'POST'` | HTTP method |
|
|
106
|
-
| `headers` | `Record<string, string>` | `{}` | Static headers, merged with Content-Type |
|
|
107
|
-
| `getHeaders` | `(input) => Record<string, string>` | — | Dynamic headers, called per request. Merged on top of `headers`. |
|
|
108
|
-
| `getBody` | `(input) => unknown` | `{ messages, context }` | Override request body |
|
|
109
|
-
| `parseEvent` | `(data: string) => AiStreamEvent[]` | OpenAI parser | Parse each `data: ...` SSE line into events |
|
|
110
|
-
|
|
111
|
-
---
|
|
112
|
-
|
|
113
|
-
### `createFetchStreamAdapter(url, options?)` — for non-SSE backends
|
|
114
|
-
|
|
115
|
-
For backends that stream **newline-delimited JSON (NDJSON)** or plain text — raw chunked HTTP without SSE formatting.
|
|
116
|
-
|
|
117
|
-
The default parser expects `{ "text": "..." }` or `{ "done": true }` JSON lines.
|
|
118
|
-
|
|
119
|
-
```ts
|
|
120
|
-
import { createFetchStreamAdapter } from 'juneau';
|
|
121
|
-
|
|
122
|
-
// NDJSON backend (streams { text: "..." } lines):
|
|
123
|
-
const adapter = createFetchStreamAdapter('/api/chat');
|
|
124
|
-
|
|
125
|
-
// Plain text — treat every chunk as raw text:
|
|
126
|
-
const adapter = createFetchStreamAdapter('/api/chat', {
|
|
127
|
-
parseChunk: (chunk) => chunk ? [{ type: 'text', text: chunk }] : [],
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
// Custom JSON lines schema:
|
|
131
|
-
const adapter = createFetchStreamAdapter('/api/chat', {
|
|
132
|
-
parseChunk: (chunk) => {
|
|
133
|
-
try {
|
|
134
|
-
const json = JSON.parse(chunk);
|
|
135
|
-
if (json.error) return [{ type: 'error', message: json.error }];
|
|
136
|
-
if (json.done) return [{ type: 'done' }];
|
|
137
|
-
if (json.delta) return [{ type: 'text', text: json.delta }];
|
|
138
|
-
return [];
|
|
139
|
-
} catch { return []; }
|
|
140
|
-
},
|
|
141
|
-
});
|
|
142
|
-
```
|
|
143
|
-
|
|
144
|
-
| Option | Type | Default | Description |
|
|
145
|
-
|---|---|---|---|
|
|
146
|
-
| `method` | `string` | `'POST'` | HTTP method |
|
|
147
|
-
| `headers` | `Record<string, string>` | `{}` | Static headers |
|
|
148
|
-
| `getHeaders` | `(input) => Record<string, string>` | — | Dynamic headers, called per request |
|
|
149
|
-
| `getBody` | `(input) => unknown` | `{ messages, context }` | Override request body |
|
|
150
|
-
| `parseChunk` | `(chunk: string) => AiStreamEvent[]` | NDJSON parser | Parse each newline-delimited chunk into events |
|
|
151
|
-
|
|
152
|
-
---
|
|
153
|
-
|
|
154
|
-
### Writing your own adapter
|
|
155
|
-
|
|
156
|
-
If neither factory fits, implementing the interface directly takes about 10 lines:
|
|
157
|
-
|
|
158
|
-
```ts
|
|
159
|
-
import type { AiBackendAdapter } from 'juneau';
|
|
160
|
-
|
|
161
|
-
export const myAdapter: AiBackendAdapter = {
|
|
162
|
-
async *sendMessage({ messages, context }) {
|
|
163
|
-
const res = await fetch('/api/chat', {
|
|
164
|
-
method: 'POST',
|
|
165
|
-
headers: { 'Content-Type': 'application/json' },
|
|
166
|
-
body: JSON.stringify({ messages }),
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
const reader = res.body!.getReader();
|
|
170
|
-
const decoder = new TextDecoder();
|
|
171
|
-
|
|
172
|
-
while (true) {
|
|
173
|
-
const { done, value } = await reader.read();
|
|
174
|
-
if (done) break;
|
|
175
|
-
yield { type: 'text', text: decoder.decode(value) };
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
yield { type: 'done' };
|
|
179
|
-
},
|
|
180
|
-
};
|
|
181
|
-
```
|
|
182
|
-
|
|
183
|
-
---
|
|
184
|
-
|
|
185
|
-
### Backend utilities — `juneau/server`
|
|
186
|
-
|
|
187
|
-
If your backend uses ai-sdk, Juneau ships server-side helpers that eliminate the boilerplate of history mapping, activity streaming, and tool failure recovery. Import from the `/server` subpath — zod stays out of the browser bundle.
|
|
188
|
-
|
|
189
|
-
```ts
|
|
190
|
-
import { toSdkMessages, createSkillSet, buildSkillIndex, selectSkills, selectSkillsById, withToolRecovery, withSkillDispatch } from 'juneau/server';
|
|
191
|
-
```
|
|
192
|
-
|
|
193
|
-
**Full integration in ~15 lines:**
|
|
194
|
-
|
|
195
|
-
```ts
|
|
196
|
-
import { toSdkMessages, createSkillSet, withToolRecovery } from 'juneau/server';
|
|
197
|
-
import { streamText } from 'ai';
|
|
198
|
-
import { z } from 'zod';
|
|
199
|
-
|
|
200
|
-
const skillSet = createSkillSet({
|
|
201
|
-
search: {
|
|
202
|
-
title: 'Record Search',
|
|
203
|
-
description: 'Search for records.',
|
|
204
|
-
instructions: 'Present results concisely. If cards are shown, write one sentence only.',
|
|
205
|
-
input: z.object({ query: z.string() }),
|
|
206
|
-
labels: {
|
|
207
|
-
running: { en: 'Searching…', cs: 'Vyhledávám…' },
|
|
208
|
-
done: { en: 'Results found', cs: 'Nalezeno' },
|
|
209
|
-
},
|
|
210
|
-
execute: async ({ query }) => db.search(query),
|
|
211
|
-
},
|
|
212
|
-
}, { language: context.language });
|
|
213
|
-
|
|
214
|
-
for await (const chunk of withToolRecovery({
|
|
215
|
-
phase1: () => streamText({ model, system, messages: toSdkMessages(input.messages), tools: skillSet.tools, maxSteps: 1 }),
|
|
216
|
-
phase2: (ctx) => streamText({ model, system, messages: [...toSdkMessages(input.messages), { role: 'assistant', content: ctx }] }),
|
|
217
|
-
skillSet,
|
|
218
|
-
})) {
|
|
219
|
-
res.write(chunk);
|
|
220
|
-
}
|
|
221
|
-
```
|
|
222
|
-
|
|
223
|
-
#### `toSdkMessages(messages)`
|
|
224
|
-
|
|
225
|
-
Converts `AiMessage[]` to `CoreMessage[]` for ai-sdk. Extracts text from parts, fixes conversation alternation (no two consecutive user turns), filters empty turns.
|
|
226
|
-
|
|
227
|
-
#### `createSkillSet(skills, options?)`
|
|
228
|
-
|
|
229
|
-
Wraps skill definitions into ai-sdk `tools` with a built-in activity buffer. Each tool call automatically emits `running` / `done` / `failed` Juneau wire SSE strings — no manual activity handling needed.
|
|
230
|
-
|
|
231
|
-
`execute` receives a `SkillExecuteContext` as its second argument with an `emit(part)` callback — push custom part wire events into the stream alongside the activities, e.g. an invoice card widget the frontend renders via `renderPart`:
|
|
232
|
-
|
|
233
|
-
```ts
|
|
234
|
-
execute: async ({ query }, { emit }) => {
|
|
235
|
-
const inv = await db.findInvoice(query);
|
|
236
|
-
emit({ type: 'invoice-card', invoiceNumber: inv.number, amount: inv.total });
|
|
237
|
-
return { found: 1, invoice: inv }; // returned to the model as the tool result
|
|
238
|
-
},
|
|
239
|
-
```
|
|
240
|
-
|
|
241
|
-
Emitted parts share the activity buffer and are drained by `streamToWire` at the same points — they appear in the stream in emit order, before the model's text response.
|
|
242
|
-
|
|
243
|
-
Skill metadata: `title` (human-readable name), `id` (numeric, required — chosen by the consumer, must be unique across the skill map; `createSkillSet` throws at startup on duplicates), `instructions?` (agent workflow text — lazily loaded via `selectSkills` / `selectSkillsById`), `readOnly?` (default `true`), `requiresConfirmation?` (default `false`), `tools?` (skill composition — validated at startup, `createSkillSet` throws on a reference to an unknown skill).
|
|
244
|
-
|
|
245
|
-
`SkillSet` members: `tools`, `skills`, `skillsById` (Map<number, SkillDefinition>), `calledSkillNames`, `calledSkillIds`, `drainActivities()`, `hadFailure`, `failureContext`.
|
|
246
|
-
|
|
247
|
-
`SkillSetOptions`: `language?` — selects label variant (`'en'` default). `debug?` — emit `console.debug` logs per skill execution (default: `false`).
|
|
248
|
-
|
|
249
|
-
#### `buildSkillIndex(skillSet)`
|
|
250
|
-
|
|
251
|
-
Generates a compact one-liner-per-skill index for the system prompt — registry-driven, so the prompt never drifts from the actual skills. Each line includes a numeric ID so the model can request skills by ID rather than by name:
|
|
252
|
-
|
|
253
|
-
```
|
|
254
|
-
[1] invoiceSearch (read): Find invoices by number, supplier, date, or status.
|
|
255
|
-
[2] invoiceApprove (write, requires confirmation): Approve an invoice.
|
|
256
|
-
```
|
|
257
|
-
|
|
258
|
-
#### `selectSkills(skillSet, skillNames)`
|
|
259
|
-
|
|
260
|
-
Returns concatenated `instructions` for the given skill names — typically `skillSet.calledSkillNames` after phase 1. Workflow instructions load lazily, only for skills the model actually used.
|
|
261
|
-
|
|
262
|
-
#### `selectSkillsById(skillSet, skillIds)`
|
|
263
|
-
|
|
264
|
-
Same as `selectSkills` but resolves by numeric ID — use with `skillSet.calledSkillIds` to avoid string comparisons entirely. Unknown IDs and skills without instructions are skipped silently.
|
|
265
|
-
|
|
266
|
-
#### `streamToWire(fullStream, skillSet?, options?)`
|
|
267
|
-
|
|
268
|
-
Converts ai-sdk `fullStream` to Juneau wire SSE strings. Handles all ai-sdk v7 text chunk types (`text-delta` and `text`), drains activity buffer at the right moment, emits `done` at the end. Pass `{ debug: true }` to log every chunk received from ai-sdk.
|
|
269
|
-
|
|
270
|
-
#### `withSkillDispatch(options)`
|
|
271
|
-
|
|
272
|
-
Deliberate 2-phase skill dispatch — the clean alternative to sending every skill's full instructions on every request.
|
|
273
|
-
|
|
274
|
-
**Phase 1:** stream with `buildSkillIndex` as the system prompt and thin tool definitions. The model picks a skill by calling a tool — `calledSkillIds` is populated as tools fire.
|
|
275
|
-
|
|
276
|
-
**Phase 2:** always runs when at least one skill was called. Receives the full workflow instructions for the called skills (via `selectSkillsById`) and the complete phase 1 message history including tool-call and tool-result turns. The model is called again without tools so it reads the instructions and produces a text response.
|
|
277
|
-
|
|
278
|
-
If no skill was called (model answered directly), the phase 1 text is emitted as-is and the stream ends cleanly — no phase 2 needed.
|
|
279
|
-
|
|
280
|
-
```ts
|
|
281
|
-
for await (const chunk of withSkillDispatch({
|
|
282
|
-
phase1: () => streamText({
|
|
283
|
-
model,
|
|
284
|
-
system: buildSkillIndex(skillSet), // compact index: [1] search (read): ...
|
|
285
|
-
messages: sdkMessages,
|
|
286
|
-
tools: skillSet.tools,
|
|
287
|
-
maxSteps: 1,
|
|
288
|
-
}),
|
|
289
|
-
phase2: (instructions, history) => streamText({
|
|
290
|
-
model,
|
|
291
|
-
system: instructions, // full workflow text for the chosen skill only
|
|
292
|
-
messages: history, // full phase 1 history incl. tool-call + tool-result
|
|
293
|
-
}),
|
|
294
|
-
skillSet,
|
|
295
|
-
onFinish: ({ text }) => saveAssistantReply(text),
|
|
296
|
-
})) {
|
|
297
|
-
res.write(chunk);
|
|
298
|
-
}
|
|
299
|
-
```
|
|
300
|
-
|
|
301
|
-
| Option | Type | Description |
|
|
302
|
-
|---|---|---|
|
|
303
|
-
| `phase1` | `() => ToolRecoveryStreamResult` | Phase 1 stream — model picks a skill via tool call |
|
|
304
|
-
| `phase2` | `(instructions, messages) => { fullStream }` | Phase 2 stream — model executes with full instructions |
|
|
305
|
-
| `skillSet` | `SkillSet` | The skill set used in phase 1 |
|
|
306
|
-
| `onFinish` | `({ text }) => void` | Called once before `done` with accumulated text. Not called on error paths. |
|
|
307
|
-
| `debug` | `boolean` | Log phase decisions to `console.debug`. Default: `false`. |
|
|
308
|
-
|
|
309
|
-
#### `withToolRecovery(options)`
|
|
310
|
-
|
|
311
|
-
Multi-phase pattern for Gemini-style tool calls. Phase 1 streams with tools. Phase 2 triggers only when a tool failed **and** no text was produced — it calls the model without tools and injects the failure context to force a text response. Phase 3 (optional) handles the silent-success case — Gemini 2.5 Flash often treats a successful tool call as its complete response and never writes text. When the tool succeeded but no text was produced, `phase3` receives the full phase 1 message history (resolved from the ai-sdk result's `messages` promise — includes tool-call and tool-result turns, which Gemini requires to accept the history) so a second model call without tools can summarise the result. Errors thrown by phase 2/3 are emitted as wire `error` events instead of a silent `done`. Pass `debug: true` to log phase decisions, the resolved phase 3 history, and the `textProduced` / `hadFailure` state at each decision point.
|
|
312
|
-
|
|
313
|
-
Pass `onFinish` to receive the assistant text accumulated across all phases — called exactly once, right before the final `done` event (never on error paths). Use it to persist the response server-side.
|
|
314
|
-
|
|
315
|
-
```ts
|
|
316
|
-
yield* withToolRecovery({
|
|
317
|
-
phase1: () => streamText({ model, system, messages, tools: skillSet.tools, maxSteps: 2 }),
|
|
318
|
-
phase2: (ctx) => streamText({ model, system, messages: [...messages, { role: 'assistant', content: ctx }] }),
|
|
319
|
-
phase3: (fullMessages) => streamText({ model, system, messages: fullMessages }), // no tools — forced text
|
|
320
|
-
skillSet,
|
|
321
|
-
onFinish: ({ text }) => saveAssistantReply(text),
|
|
322
|
-
});
|
|
323
|
-
```
|
|
324
|
-
|
|
325
|
-
#### Type re-exports
|
|
326
|
-
|
|
327
|
-
The wire/message types shared with the client — `AiMessage`, `AiMessageRole`, `AiMessagePart`, `AiTextPart`, `AiSerializedMessage`, `AiStreamEvent`, `JuneauWireEvent` (and its member types) — are also re-exported from `juneau/server`, so backend code never needs to import from the client entry.
|
|
328
|
-
|
|
329
|
-
Server-only types: `SkillSet`, `SkillDefinition`, `SkillExecuteContext`, `SkillSetOptions`, `SkillDispatchOptions`, `ToolRecoveryOptions`, `ToolRecoveryStreamResult`, `StreamToWireOptions`, `CoreMessage`.
|
|
330
|
-
|
|
331
|
-
---
|
|
332
|
-
|
|
333
|
-
### Juneau wire protocol — for Juneau-compatible backends
|
|
334
|
-
|
|
335
|
-
If your backend is built specifically for Juneau (e.g. Tappeer), stream newline-delimited JSON where each line is one of these shapes. Both `createSseAdapter` and `createFetchStreamAdapter` parse this automatically — no custom `parseEvent` or `parseChunk` needed.
|
|
336
|
-
|
|
337
|
-
```ts
|
|
338
|
-
// Text chunk — appended to the current assistant bubble
|
|
339
|
-
{ "type": "text", "text": "Here is what I found:" }
|
|
340
|
-
|
|
341
|
-
// Activity — shows AI progress (skill selection, tool calls, etc.)
|
|
342
|
-
// Send the same `id` with a new status to update in-place
|
|
343
|
-
{ "type": "activity", "id": "skill-select", "title": "Selecting skill", "description": "Finding the best skill for this request.", "status": "running" }
|
|
344
|
-
{ "type": "activity", "id": "skill-select", "title": "Skill selected", "description": "Using Document Search.", "status": "done" }
|
|
345
|
-
{ "type": "activity", "id": "doc-search", "title": "Searching document", "status": "running" }
|
|
346
|
-
{ "type": "activity", "id": "doc-search", "title": "Document found", "description": "INV-2024-0894", "status": "done" }
|
|
347
|
-
|
|
348
|
-
// Rich block — table, entity, entity list, or proposal
|
|
349
|
-
{ "type": "part", "part": { "type": "table", "columns": ["Name", "Amount"], "rows": [...] } }
|
|
350
|
-
{ "type": "part", "part": { "type": "entity", "entityType": "invoice", "entity": { "id": "...", "title": "...", "subtitle": "...", "fields": [{ "label": "Status", "value": "Approved", "badge": "success" }] } } }
|
|
351
|
-
{ "type": "part", "part": { "type": "entity-list", "title": "3 results", "entities": [...] } }
|
|
352
|
-
{ "type": "part", "part": { "type": "proposal", "proposal": { "id": "...", "title": "..." } } }
|
|
353
|
-
|
|
354
|
-
// Stream finished cleanly
|
|
355
|
-
{ "type": "done" }
|
|
356
|
-
|
|
357
|
-
// Stream error — ends the stream
|
|
358
|
-
{ "type": "error", "message": "Something went wrong." }
|
|
359
|
-
```
|
|
360
|
-
|
|
361
|
-
**Activity `status` values:** `running` | `done` | `failed`
|
|
362
|
-
|
|
363
|
-
**Activity `id` behaviour:**
|
|
364
|
-
- With `id` — a later event with the same `id` updates the existing row in-place (running → done)
|
|
365
|
-
- Without `id` — each activity appends as a new timeline row
|
|
366
|
-
|
|
367
|
-
**Activity `metadata`** is an optional opaque object — not rendered by Juneau, available for logging or custom renderers. Use it for internal data (tool name, duration, skill ID) that should not be shown to the user.
|
|
368
|
-
|
|
369
|
-
`createSseAdapter` also retains OpenAI-format fallback parsing (`choices[0].delta.content`) so it works with both Juneau-compatible backends and standard OpenAI proxies.
|
|
370
|
-
|
|
371
|
-
---
|
|
372
|
-
|
|
373
|
-
### `mockAdapter` — for development
|
|
374
|
-
|
|
375
|
-
Shipped for local development. No backend needed — responds to keywords in the message:
|
|
376
|
-
|
|
377
|
-
| Say... | Gets you... |
|
|
378
|
-
|---|---|
|
|
379
|
-
| `"show"`, `"list"`, `"data"`, `"table"` | A rendered data table |
|
|
380
|
-
| `"suggest"`, `"recommend"`, `"proposal"` | A proposal card with confirm/cancel |
|
|
381
|
-
| `"entity"`, `"card"`, `"detail"`, `"find"` | An entity list + entity detail card |
|
|
382
|
-
| `"activity"`, `"progress"`, `"document"` | An activity timeline with running/done states |
|
|
383
|
-
| `"help"`, `"what can you do"` | Capability overview |
|
|
384
|
-
| `"error"`, `"fail"` | Simulated error response |
|
|
385
|
-
| anything else | Explains the available triggers |
|
|
386
|
-
|
|
387
|
-
```ts
|
|
388
|
-
import { mockAdapter } from 'juneau';
|
|
389
|
-
// Pass to AiChatProvider — see below
|
|
390
|
-
```
|
|
391
|
-
|
|
392
|
-
---
|
|
393
|
-
|
|
394
|
-
## Providers
|
|
395
|
-
|
|
396
|
-
### `<JuneauProvider>`
|
|
397
|
-
|
|
398
|
-
Wrap your app once. Provides theme tokens and UI labels to all Juneau components below it.
|
|
399
|
-
|
|
400
|
-
```tsx
|
|
401
|
-
import { JuneauProvider, juneauCs } from 'juneau';
|
|
402
|
-
|
|
403
|
-
<JuneauProvider
|
|
404
|
-
theme={{ colorPrimary: '#0f766e', colorAccent: '#14b8a6' }}
|
|
405
|
-
labels={juneauCs}
|
|
406
|
-
>
|
|
407
|
-
{children}
|
|
408
|
-
</JuneauProvider>
|
|
409
|
-
```
|
|
410
|
-
|
|
411
|
-
| Prop | Type | Description |
|
|
412
|
-
|---|---|---|
|
|
413
|
-
| `theme` | `JuneauTheme` | Override design tokens. Only specified keys are applied. |
|
|
414
|
-
| `labels` | `Partial<JuneauLabels>` | Override UI strings. Omitted keys fall back to English. |
|
|
415
|
-
| `className` | `string` | Added to the root `<div>`. |
|
|
416
|
-
| `style` | `CSSProperties` | Inline styles on the root `<div>`. |
|
|
417
|
-
|
|
418
|
-
---
|
|
419
|
-
|
|
420
|
-
### `<AiChatProvider>`
|
|
421
|
-
|
|
422
|
-
Holds the shared conversation state. Wrap your app (or layout) once — any page can then render `<AiSidebar />` without losing conversation history on navigation.
|
|
423
|
-
|
|
424
|
-
```tsx
|
|
425
|
-
import { AiChatProvider } from 'juneau';
|
|
426
|
-
|
|
427
|
-
<AiChatProvider
|
|
428
|
-
adapter={myAdapter}
|
|
429
|
-
onProposalConfirm={(id, payload) => handleAction(id, payload)}
|
|
430
|
-
onProposalCancel={(id) => handleDismiss(id)}
|
|
431
|
-
>
|
|
432
|
-
<App />
|
|
433
|
-
</AiChatProvider>
|
|
434
|
-
```
|
|
435
|
-
|
|
436
|
-
| Prop | Type | Description |
|
|
437
|
-
|---|---|---|
|
|
438
|
-
| `adapter` | `AiBackendAdapter` | **Required.** Your adapter. |
|
|
439
|
-
| `context` | `Record<string, unknown>` | Initial context forwarded to every adapter call. Update per-page via `setContext()`. |
|
|
440
|
-
| `onProposalConfirm` | `(id, payload) => void` | Called when user confirms a proposal card. |
|
|
441
|
-
| `onProposalCancel` | `(id) => void` | Called when user cancels a proposal card. |
|
|
442
|
-
| `initialMessages` | `AiMessage[]` | Restored conversation to start with (see Chat history). |
|
|
443
|
-
| `historyLimit` | `number` | Max messages sent to the adapter per request. Rendering never trimmed. |
|
|
444
|
-
| `onMessagesChange` | `(messages) => void` | Called when the conversation settles. Use to persist. |
|
|
445
|
-
|
|
446
|
-
**Updating context per page:**
|
|
447
|
-
|
|
448
|
-
Use `setContext()` from `useAiChatContext()` to tell the AI where the user is on each page. The context is forwarded opaquely to every `adapter.sendMessage` call as `input.context`.
|
|
449
|
-
|
|
450
|
-
```tsx
|
|
451
|
-
import { useAiChatContext } from 'juneau';
|
|
452
|
-
|
|
453
|
-
function InvoicesPage() {
|
|
454
|
-
const { setContext } = useAiChatContext();
|
|
455
|
-
|
|
456
|
-
useEffect(() => {
|
|
457
|
-
setContext({
|
|
458
|
-
page: 'invoices',
|
|
459
|
-
availableTools: ['search', 'export'],
|
|
460
|
-
userRole: 'admin',
|
|
461
|
-
});
|
|
462
|
-
}, []);
|
|
463
|
-
|
|
464
|
-
return <main>...</main>;
|
|
465
|
-
}
|
|
466
|
-
```
|
|
467
|
-
|
|
468
|
-
**Replace vs merge:** passing an object to `setContext` **replaces** the whole context. To keep existing keys (e.g. a `sessionId` set elsewhere) while updating others, use the updater form:
|
|
469
|
-
|
|
470
|
-
```tsx
|
|
471
|
-
setContext(prev => ({ ...prev, page: 'invoices' }));
|
|
472
|
-
```
|
|
473
|
-
|
|
474
|
-
**Consuming chat state outside a guaranteed provider:**
|
|
475
|
-
|
|
476
|
-
`useAiChatContext()` throws when called outside `<AiChatProvider>` — fail fast is right for components that require it. For components that may render outside the provider (e.g. during sign-out transitions), use `useAiChatContextSafe()`, which returns `null` instead of throwing:
|
|
477
|
-
|
|
478
|
-
```tsx
|
|
479
|
-
import { useAiChatContextSafe } from 'juneau';
|
|
480
|
-
|
|
481
|
-
function OptionalChatButton() {
|
|
482
|
-
const chat = useAiChatContextSafe(); // AiChatContextValue | null
|
|
483
|
-
if (!chat) return null;
|
|
484
|
-
return <button onClick={() => chat.sendMessageWithText('Help')}>Ask AI</button>;
|
|
485
|
-
}
|
|
486
|
-
```
|
|
487
|
-
|
|
488
|
-
The context value is memoized — consumers re-render only when chat state actually changes, not on every provider render.
|
|
489
|
-
|
|
490
|
-
---
|
|
491
|
-
|
|
492
|
-
## Components
|
|
493
|
-
|
|
494
|
-
### `<AiSidebar>`
|
|
495
|
-
|
|
496
|
-
Fixed-position chat panel. Reads all state from the nearest `<AiChatProvider>` — renders wherever you place it, conversation persists across navigation.
|
|
497
|
-
|
|
498
|
-
The sidebar anchors to the bottom-right of the viewport and opens at 60% screen height by default. Users can minimize it to a compact header bar.
|
|
499
|
-
|
|
500
|
-
```tsx
|
|
501
|
-
<AiSidebar
|
|
502
|
-
title="AI Assistant"
|
|
503
|
-
height="70vh"
|
|
504
|
-
/>
|
|
505
|
-
```
|
|
506
|
-
|
|
507
|
-
| Prop | Type | Description |
|
|
508
|
-
|---|---|---|
|
|
509
|
-
| `title` | `string` | Overrides the `sidebarTitle` label for this instance. |
|
|
510
|
-
| `icon` | `ReactNode` | Override the header + avatar icon. Defaults to a wand icon. |
|
|
511
|
-
| `actions` | `AiInputAction[]` | Toolbar buttons left of send. Pass `[]` to hide entirely. |
|
|
512
|
-
| `sendIcon` | `ReactNode` | Override the send button icon. |
|
|
513
|
-
| `height` | `string` | Height when open. Any CSS value. Defaults to `'60vh'`. |
|
|
514
|
-
| `className` | `string` | Added to the `<aside>` element. |
|
|
515
|
-
| `style` | `CSSProperties` | Inline styles on the `<aside>`. |
|
|
516
|
-
| `renderPart` | `RenderPartFn` | Custom part renderer — see below. |
|
|
517
|
-
| `onEntityClick` | `(entity, entityType?) => void` | Makes entity cards clickable — e.g. navigate to the record. |
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
type
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
//
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
**
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
```
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
}, [
|
|
679
|
-
```
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
```
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
{
|
|
806
|
-
|
|
807
|
-
},
|
|
808
|
-
};
|
|
809
|
-
```
|
|
810
|
-
|
|
811
|
-
**Emitting
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
```
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
```tsx
|
|
897
|
-
import {
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
|
953
|
-
|
|
954
|
-
| `
|
|
955
|
-
| `
|
|
956
|
-
| `
|
|
957
|
-
| `
|
|
958
|
-
| `
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
|
995
|
-
|
|
996
|
-
| `
|
|
997
|
-
| `
|
|
998
|
-
| `
|
|
999
|
-
| `
|
|
1000
|
-
| `
|
|
1001
|
-
| `
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1
|
+
# Juneau
|
|
2
|
+
|
|
3
|
+
React component library for building AI chat interfaces. Streaming-first, adapter-based, fully themeable.
|
|
4
|
+
|
|
5
|
+
```tsx
|
|
6
|
+
import { JuneauProvider, AiChatProvider, AiSidebar, createSseAdapter } from 'juneau';
|
|
7
|
+
import 'juneau/dist/style.css';
|
|
8
|
+
|
|
9
|
+
const adapter = createSseAdapter('/api/chat');
|
|
10
|
+
|
|
11
|
+
<JuneauProvider>
|
|
12
|
+
<AiChatProvider adapter={adapter}>
|
|
13
|
+
<App />
|
|
14
|
+
<AiSidebar />
|
|
15
|
+
</AiChatProvider>
|
|
16
|
+
</JuneauProvider>
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install juneau
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
**Peer dependencies:** `react ^18 || ^19`, `react-dom ^18 || ^19`
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Core concept — the adapter
|
|
32
|
+
|
|
33
|
+
Juneau never calls any AI API directly. **You own the network layer.** Juneau provides the UI, state, and streaming machinery — you provide an adapter that connects it to your backend.
|
|
34
|
+
|
|
35
|
+
An adapter is just one method:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
interface AiBackendAdapter {
|
|
39
|
+
sendMessage(input: AiAdapterInput): AsyncIterable<AiStreamEvent>;
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
It receives the conversation history and streams back typed events:
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
type AiStreamEvent =
|
|
47
|
+
| { type: 'text'; text: string } // streamed text chunk — append to current bubble
|
|
48
|
+
| { type: 'part'; part: AiMessagePart } // rich UI block (table, entity, entity-list, proposal, activity, error)
|
|
49
|
+
| { type: 'done' } // stream finished cleanly
|
|
50
|
+
| { type: 'error'; message: string } // stream failed
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
This means your API keys stay on your backend, you control auth, rate limiting, model selection — Juneau just renders whatever comes back.
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## Built-in adapters
|
|
58
|
+
|
|
59
|
+
Juneau ships two factory functions so you don't have to write boilerplate streaming code.
|
|
60
|
+
|
|
61
|
+
### `createSseAdapter(url, options?)` — recommended
|
|
62
|
+
|
|
63
|
+
For backends that stream **Server-Sent Events (SSE)** — the format used by OpenAI, Anthropic, and most AI API proxies.
|
|
64
|
+
|
|
65
|
+
The default parser understands OpenAI's streaming format out of the box (`choices[0].delta.content`).
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
import { createSseAdapter } from 'juneau';
|
|
69
|
+
|
|
70
|
+
// OpenAI-compatible backend — zero config needed:
|
|
71
|
+
const adapter = createSseAdapter('/api/chat');
|
|
72
|
+
|
|
73
|
+
// With dynamic auth header:
|
|
74
|
+
const adapter = createSseAdapter('/api/chat', {
|
|
75
|
+
getHeaders: async () => ({
|
|
76
|
+
Authorization: `Bearer ${await getSessionToken()}`,
|
|
77
|
+
}),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Custom request body:
|
|
81
|
+
const adapter = createSseAdapter('/api/chat', {
|
|
82
|
+
getBody: ({ messages, context }) => ({
|
|
83
|
+
messages,
|
|
84
|
+
model: 'gpt-4o',
|
|
85
|
+
stream: true,
|
|
86
|
+
temperature: 0.7,
|
|
87
|
+
}),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Custom SSE event schema (backend streams { text: "..." } instead of OpenAI format):
|
|
91
|
+
const adapter = createSseAdapter('/api/chat', {
|
|
92
|
+
parseEvent: (data) => {
|
|
93
|
+
try {
|
|
94
|
+
const json = JSON.parse(data);
|
|
95
|
+
return json.text ? [{ type: 'text', text: json.text }] : [];
|
|
96
|
+
} catch {
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
| Option | Type | Default | Description |
|
|
104
|
+
|---|---|---|---|
|
|
105
|
+
| `method` | `string` | `'POST'` | HTTP method |
|
|
106
|
+
| `headers` | `Record<string, string>` | `{}` | Static headers, merged with Content-Type |
|
|
107
|
+
| `getHeaders` | `(input) => Record<string, string>` | — | Dynamic headers, called per request. Merged on top of `headers`. |
|
|
108
|
+
| `getBody` | `(input) => unknown` | `{ messages, context }` | Override request body |
|
|
109
|
+
| `parseEvent` | `(data: string) => AiStreamEvent[]` | OpenAI parser | Parse each `data: ...` SSE line into events |
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
### `createFetchStreamAdapter(url, options?)` — for non-SSE backends
|
|
114
|
+
|
|
115
|
+
For backends that stream **newline-delimited JSON (NDJSON)** or plain text — raw chunked HTTP without SSE formatting.
|
|
116
|
+
|
|
117
|
+
The default parser expects `{ "text": "..." }` or `{ "done": true }` JSON lines.
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
import { createFetchStreamAdapter } from 'juneau';
|
|
121
|
+
|
|
122
|
+
// NDJSON backend (streams { text: "..." } lines):
|
|
123
|
+
const adapter = createFetchStreamAdapter('/api/chat');
|
|
124
|
+
|
|
125
|
+
// Plain text — treat every chunk as raw text:
|
|
126
|
+
const adapter = createFetchStreamAdapter('/api/chat', {
|
|
127
|
+
parseChunk: (chunk) => chunk ? [{ type: 'text', text: chunk }] : [],
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// Custom JSON lines schema:
|
|
131
|
+
const adapter = createFetchStreamAdapter('/api/chat', {
|
|
132
|
+
parseChunk: (chunk) => {
|
|
133
|
+
try {
|
|
134
|
+
const json = JSON.parse(chunk);
|
|
135
|
+
if (json.error) return [{ type: 'error', message: json.error }];
|
|
136
|
+
if (json.done) return [{ type: 'done' }];
|
|
137
|
+
if (json.delta) return [{ type: 'text', text: json.delta }];
|
|
138
|
+
return [];
|
|
139
|
+
} catch { return []; }
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
| Option | Type | Default | Description |
|
|
145
|
+
|---|---|---|---|
|
|
146
|
+
| `method` | `string` | `'POST'` | HTTP method |
|
|
147
|
+
| `headers` | `Record<string, string>` | `{}` | Static headers |
|
|
148
|
+
| `getHeaders` | `(input) => Record<string, string>` | — | Dynamic headers, called per request |
|
|
149
|
+
| `getBody` | `(input) => unknown` | `{ messages, context }` | Override request body |
|
|
150
|
+
| `parseChunk` | `(chunk: string) => AiStreamEvent[]` | NDJSON parser | Parse each newline-delimited chunk into events |
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
### Writing your own adapter
|
|
155
|
+
|
|
156
|
+
If neither factory fits, implementing the interface directly takes about 10 lines:
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
import type { AiBackendAdapter } from 'juneau';
|
|
160
|
+
|
|
161
|
+
export const myAdapter: AiBackendAdapter = {
|
|
162
|
+
async *sendMessage({ messages, context }) {
|
|
163
|
+
const res = await fetch('/api/chat', {
|
|
164
|
+
method: 'POST',
|
|
165
|
+
headers: { 'Content-Type': 'application/json' },
|
|
166
|
+
body: JSON.stringify({ messages }),
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const reader = res.body!.getReader();
|
|
170
|
+
const decoder = new TextDecoder();
|
|
171
|
+
|
|
172
|
+
while (true) {
|
|
173
|
+
const { done, value } = await reader.read();
|
|
174
|
+
if (done) break;
|
|
175
|
+
yield { type: 'text', text: decoder.decode(value) };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
yield { type: 'done' };
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
### Backend utilities — `juneau/server`
|
|
186
|
+
|
|
187
|
+
If your backend uses ai-sdk, Juneau ships server-side helpers that eliminate the boilerplate of history mapping, activity streaming, and tool failure recovery. Import from the `/server` subpath — zod stays out of the browser bundle.
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
import { toSdkMessages, createSkillSet, buildSkillIndex, selectSkills, selectSkillsById, withToolRecovery, withSkillDispatch } from 'juneau/server';
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
**Full integration in ~15 lines:**
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
import { toSdkMessages, createSkillSet, withToolRecovery } from 'juneau/server';
|
|
197
|
+
import { streamText } from 'ai';
|
|
198
|
+
import { z } from 'zod';
|
|
199
|
+
|
|
200
|
+
const skillSet = createSkillSet({
|
|
201
|
+
search: {
|
|
202
|
+
title: 'Record Search',
|
|
203
|
+
description: 'Search for records.',
|
|
204
|
+
instructions: 'Present results concisely. If cards are shown, write one sentence only.',
|
|
205
|
+
input: z.object({ query: z.string() }),
|
|
206
|
+
labels: {
|
|
207
|
+
running: { en: 'Searching…', cs: 'Vyhledávám…' },
|
|
208
|
+
done: { en: 'Results found', cs: 'Nalezeno' },
|
|
209
|
+
},
|
|
210
|
+
execute: async ({ query }) => db.search(query),
|
|
211
|
+
},
|
|
212
|
+
}, { language: context.language });
|
|
213
|
+
|
|
214
|
+
for await (const chunk of withToolRecovery({
|
|
215
|
+
phase1: () => streamText({ model, system, messages: toSdkMessages(input.messages), tools: skillSet.tools, maxSteps: 1 }),
|
|
216
|
+
phase2: (ctx) => streamText({ model, system, messages: [...toSdkMessages(input.messages), { role: 'assistant', content: ctx }] }),
|
|
217
|
+
skillSet,
|
|
218
|
+
})) {
|
|
219
|
+
res.write(chunk);
|
|
220
|
+
}
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
#### `toSdkMessages(messages)`
|
|
224
|
+
|
|
225
|
+
Converts `AiMessage[]` to `CoreMessage[]` for ai-sdk. Extracts text from parts, fixes conversation alternation (no two consecutive user turns), filters empty turns.
|
|
226
|
+
|
|
227
|
+
#### `createSkillSet(skills, options?)`
|
|
228
|
+
|
|
229
|
+
Wraps skill definitions into ai-sdk `tools` with a built-in activity buffer. Each tool call automatically emits `running` / `done` / `failed` Juneau wire SSE strings — no manual activity handling needed.
|
|
230
|
+
|
|
231
|
+
`execute` receives a `SkillExecuteContext` as its second argument with an `emit(part)` callback — push custom part wire events into the stream alongside the activities, e.g. an invoice card widget the frontend renders via `renderPart`:
|
|
232
|
+
|
|
233
|
+
```ts
|
|
234
|
+
execute: async ({ query }, { emit }) => {
|
|
235
|
+
const inv = await db.findInvoice(query);
|
|
236
|
+
emit({ type: 'invoice-card', invoiceNumber: inv.number, amount: inv.total });
|
|
237
|
+
return { found: 1, invoice: inv }; // returned to the model as the tool result
|
|
238
|
+
},
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Emitted parts share the activity buffer and are drained by `streamToWire` at the same points — they appear in the stream in emit order, before the model's text response.
|
|
242
|
+
|
|
243
|
+
Skill metadata: `title` (human-readable name), `id` (numeric, required — chosen by the consumer, must be unique across the skill map; `createSkillSet` throws at startup on duplicates), `instructions?` (agent workflow text — lazily loaded via `selectSkills` / `selectSkillsById`), `readOnly?` (default `true`), `requiresConfirmation?` (default `false`), `tools?` (skill composition — validated at startup, `createSkillSet` throws on a reference to an unknown skill).
|
|
244
|
+
|
|
245
|
+
`SkillSet` members: `tools`, `skills`, `skillsById` (Map<number, SkillDefinition>), `calledSkillNames`, `calledSkillIds`, `drainActivities()`, `hadFailure`, `failureContext`.
|
|
246
|
+
|
|
247
|
+
`SkillSetOptions`: `language?` — selects label variant (`'en'` default). `debug?` — emit `console.debug` logs per skill execution (default: `false`).
|
|
248
|
+
|
|
249
|
+
#### `buildSkillIndex(skillSet)`
|
|
250
|
+
|
|
251
|
+
Generates a compact one-liner-per-skill index for the system prompt — registry-driven, so the prompt never drifts from the actual skills. Each line includes a numeric ID so the model can request skills by ID rather than by name:
|
|
252
|
+
|
|
253
|
+
```
|
|
254
|
+
[1] invoiceSearch (read): Find invoices by number, supplier, date, or status.
|
|
255
|
+
[2] invoiceApprove (write, requires confirmation): Approve an invoice.
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
#### `selectSkills(skillSet, skillNames)`
|
|
259
|
+
|
|
260
|
+
Returns concatenated `instructions` for the given skill names — typically `skillSet.calledSkillNames` after phase 1. Workflow instructions load lazily, only for skills the model actually used.
|
|
261
|
+
|
|
262
|
+
#### `selectSkillsById(skillSet, skillIds)`
|
|
263
|
+
|
|
264
|
+
Same as `selectSkills` but resolves by numeric ID — use with `skillSet.calledSkillIds` to avoid string comparisons entirely. Unknown IDs and skills without instructions are skipped silently.
|
|
265
|
+
|
|
266
|
+
#### `streamToWire(fullStream, skillSet?, options?)`
|
|
267
|
+
|
|
268
|
+
Converts ai-sdk `fullStream` to Juneau wire SSE strings. Handles all ai-sdk v7 text chunk types (`text-delta` and `text`), drains activity buffer at the right moment, emits `done` at the end. Pass `{ debug: true }` to log every chunk received from ai-sdk.
|
|
269
|
+
|
|
270
|
+
#### `withSkillDispatch(options)`
|
|
271
|
+
|
|
272
|
+
Deliberate 2-phase skill dispatch — the clean alternative to sending every skill's full instructions on every request.
|
|
273
|
+
|
|
274
|
+
**Phase 1:** stream with `buildSkillIndex` as the system prompt and thin tool definitions. The model picks a skill by calling a tool — `calledSkillIds` is populated as tools fire.
|
|
275
|
+
|
|
276
|
+
**Phase 2:** always runs when at least one skill was called. Receives the full workflow instructions for the called skills (via `selectSkillsById`) and the complete phase 1 message history including tool-call and tool-result turns. The model is called again without tools so it reads the instructions and produces a text response.
|
|
277
|
+
|
|
278
|
+
If no skill was called (model answered directly), the phase 1 text is emitted as-is and the stream ends cleanly — no phase 2 needed.
|
|
279
|
+
|
|
280
|
+
```ts
|
|
281
|
+
for await (const chunk of withSkillDispatch({
|
|
282
|
+
phase1: () => streamText({
|
|
283
|
+
model,
|
|
284
|
+
system: buildSkillIndex(skillSet), // compact index: [1] search (read): ...
|
|
285
|
+
messages: sdkMessages,
|
|
286
|
+
tools: skillSet.tools,
|
|
287
|
+
maxSteps: 1,
|
|
288
|
+
}),
|
|
289
|
+
phase2: (instructions, history) => streamText({
|
|
290
|
+
model,
|
|
291
|
+
system: instructions, // full workflow text for the chosen skill only
|
|
292
|
+
messages: history, // full phase 1 history incl. tool-call + tool-result
|
|
293
|
+
}),
|
|
294
|
+
skillSet,
|
|
295
|
+
onFinish: ({ text }) => saveAssistantReply(text),
|
|
296
|
+
})) {
|
|
297
|
+
res.write(chunk);
|
|
298
|
+
}
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
| Option | Type | Description |
|
|
302
|
+
|---|---|---|
|
|
303
|
+
| `phase1` | `() => ToolRecoveryStreamResult` | Phase 1 stream — model picks a skill via tool call |
|
|
304
|
+
| `phase2` | `(instructions, messages) => { fullStream }` | Phase 2 stream — model executes with full instructions |
|
|
305
|
+
| `skillSet` | `SkillSet` | The skill set used in phase 1 |
|
|
306
|
+
| `onFinish` | `({ text }) => void` | Called once before `done` with accumulated text. Not called on error paths. |
|
|
307
|
+
| `debug` | `boolean` | Log phase decisions to `console.debug`. Default: `false`. |
|
|
308
|
+
|
|
309
|
+
#### `withToolRecovery(options)`
|
|
310
|
+
|
|
311
|
+
Multi-phase pattern for Gemini-style tool calls. Phase 1 streams with tools. Phase 2 triggers only when a tool failed **and** no text was produced — it calls the model without tools and injects the failure context to force a text response. Phase 3 (optional) handles the silent-success case — Gemini 2.5 Flash often treats a successful tool call as its complete response and never writes text. When the tool succeeded but no text was produced, `phase3` receives the full phase 1 message history (resolved from the ai-sdk result's `messages` promise — includes tool-call and tool-result turns, which Gemini requires to accept the history) so a second model call without tools can summarise the result. Errors thrown by phase 2/3 are emitted as wire `error` events instead of a silent `done`. Pass `debug: true` to log phase decisions, the resolved phase 3 history, and the `textProduced` / `hadFailure` state at each decision point.
|
|
312
|
+
|
|
313
|
+
Pass `onFinish` to receive the assistant text accumulated across all phases — called exactly once, right before the final `done` event (never on error paths). Use it to persist the response server-side.
|
|
314
|
+
|
|
315
|
+
```ts
|
|
316
|
+
yield* withToolRecovery({
|
|
317
|
+
phase1: () => streamText({ model, system, messages, tools: skillSet.tools, maxSteps: 2 }),
|
|
318
|
+
phase2: (ctx) => streamText({ model, system, messages: [...messages, { role: 'assistant', content: ctx }] }),
|
|
319
|
+
phase3: (fullMessages) => streamText({ model, system, messages: fullMessages }), // no tools — forced text
|
|
320
|
+
skillSet,
|
|
321
|
+
onFinish: ({ text }) => saveAssistantReply(text),
|
|
322
|
+
});
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
#### Type re-exports
|
|
326
|
+
|
|
327
|
+
The wire/message types shared with the client — `AiMessage`, `AiMessageRole`, `AiMessagePart`, `AiTextPart`, `AiSerializedMessage`, `AiStreamEvent`, `JuneauWireEvent` (and its member types) — are also re-exported from `juneau/server`, so backend code never needs to import from the client entry.
|
|
328
|
+
|
|
329
|
+
Server-only types: `SkillSet`, `SkillDefinition`, `SkillExecuteContext`, `SkillSetOptions`, `SkillDispatchOptions`, `ToolRecoveryOptions`, `ToolRecoveryStreamResult`, `StreamToWireOptions`, `CoreMessage`.
|
|
330
|
+
|
|
331
|
+
---
|
|
332
|
+
|
|
333
|
+
### Juneau wire protocol — for Juneau-compatible backends
|
|
334
|
+
|
|
335
|
+
If your backend is built specifically for Juneau (e.g. Tappeer), stream newline-delimited JSON where each line is one of these shapes. Both `createSseAdapter` and `createFetchStreamAdapter` parse this automatically — no custom `parseEvent` or `parseChunk` needed.
|
|
336
|
+
|
|
337
|
+
```ts
|
|
338
|
+
// Text chunk — appended to the current assistant bubble
|
|
339
|
+
{ "type": "text", "text": "Here is what I found:" }
|
|
340
|
+
|
|
341
|
+
// Activity — shows AI progress (skill selection, tool calls, etc.)
|
|
342
|
+
// Send the same `id` with a new status to update in-place
|
|
343
|
+
{ "type": "activity", "id": "skill-select", "title": "Selecting skill", "description": "Finding the best skill for this request.", "status": "running" }
|
|
344
|
+
{ "type": "activity", "id": "skill-select", "title": "Skill selected", "description": "Using Document Search.", "status": "done" }
|
|
345
|
+
{ "type": "activity", "id": "doc-search", "title": "Searching document", "status": "running" }
|
|
346
|
+
{ "type": "activity", "id": "doc-search", "title": "Document found", "description": "INV-2024-0894", "status": "done" }
|
|
347
|
+
|
|
348
|
+
// Rich block — table, entity, entity list, or proposal
|
|
349
|
+
{ "type": "part", "part": { "type": "table", "columns": ["Name", "Amount"], "rows": [...] } }
|
|
350
|
+
{ "type": "part", "part": { "type": "entity", "entityType": "invoice", "entity": { "id": "...", "title": "...", "subtitle": "...", "fields": [{ "label": "Status", "value": "Approved", "badge": "success" }] } } }
|
|
351
|
+
{ "type": "part", "part": { "type": "entity-list", "title": "3 results", "entities": [...] } }
|
|
352
|
+
{ "type": "part", "part": { "type": "proposal", "proposal": { "id": "...", "title": "..." } } }
|
|
353
|
+
|
|
354
|
+
// Stream finished cleanly
|
|
355
|
+
{ "type": "done" }
|
|
356
|
+
|
|
357
|
+
// Stream error — ends the stream
|
|
358
|
+
{ "type": "error", "message": "Something went wrong." }
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
**Activity `status` values:** `running` | `done` | `failed`
|
|
362
|
+
|
|
363
|
+
**Activity `id` behaviour:**
|
|
364
|
+
- With `id` — a later event with the same `id` updates the existing row in-place (running → done)
|
|
365
|
+
- Without `id` — each activity appends as a new timeline row
|
|
366
|
+
|
|
367
|
+
**Activity `metadata`** is an optional opaque object — not rendered by Juneau, available for logging or custom renderers. Use it for internal data (tool name, duration, skill ID) that should not be shown to the user.
|
|
368
|
+
|
|
369
|
+
`createSseAdapter` also retains OpenAI-format fallback parsing (`choices[0].delta.content`) so it works with both Juneau-compatible backends and standard OpenAI proxies.
|
|
370
|
+
|
|
371
|
+
---
|
|
372
|
+
|
|
373
|
+
### `mockAdapter` — for development
|
|
374
|
+
|
|
375
|
+
Shipped for local development. No backend needed — responds to keywords in the message:
|
|
376
|
+
|
|
377
|
+
| Say... | Gets you... |
|
|
378
|
+
|---|---|
|
|
379
|
+
| `"show"`, `"list"`, `"data"`, `"table"` | A rendered data table |
|
|
380
|
+
| `"suggest"`, `"recommend"`, `"proposal"` | A proposal card with confirm/cancel |
|
|
381
|
+
| `"entity"`, `"card"`, `"detail"`, `"find"` | An entity list + entity detail card |
|
|
382
|
+
| `"activity"`, `"progress"`, `"document"` | An activity timeline with running/done states |
|
|
383
|
+
| `"help"`, `"what can you do"` | Capability overview |
|
|
384
|
+
| `"error"`, `"fail"` | Simulated error response |
|
|
385
|
+
| anything else | Explains the available triggers |
|
|
386
|
+
|
|
387
|
+
```ts
|
|
388
|
+
import { mockAdapter } from 'juneau';
|
|
389
|
+
// Pass to AiChatProvider — see below
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
---
|
|
393
|
+
|
|
394
|
+
## Providers
|
|
395
|
+
|
|
396
|
+
### `<JuneauProvider>`
|
|
397
|
+
|
|
398
|
+
Wrap your app once. Provides theme tokens and UI labels to all Juneau components below it.
|
|
399
|
+
|
|
400
|
+
```tsx
|
|
401
|
+
import { JuneauProvider, juneauCs } from 'juneau';
|
|
402
|
+
|
|
403
|
+
<JuneauProvider
|
|
404
|
+
theme={{ colorPrimary: '#0f766e', colorAccent: '#14b8a6' }}
|
|
405
|
+
labels={juneauCs}
|
|
406
|
+
>
|
|
407
|
+
{children}
|
|
408
|
+
</JuneauProvider>
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
| Prop | Type | Description |
|
|
412
|
+
|---|---|---|
|
|
413
|
+
| `theme` | `JuneauTheme` | Override design tokens. Only specified keys are applied. |
|
|
414
|
+
| `labels` | `Partial<JuneauLabels>` | Override UI strings. Omitted keys fall back to English. |
|
|
415
|
+
| `className` | `string` | Added to the root `<div>`. |
|
|
416
|
+
| `style` | `CSSProperties` | Inline styles on the root `<div>`. |
|
|
417
|
+
|
|
418
|
+
---
|
|
419
|
+
|
|
420
|
+
### `<AiChatProvider>`
|
|
421
|
+
|
|
422
|
+
Holds the shared conversation state. Wrap your app (or layout) once — any page can then render `<AiSidebar />` without losing conversation history on navigation.
|
|
423
|
+
|
|
424
|
+
```tsx
|
|
425
|
+
import { AiChatProvider } from 'juneau';
|
|
426
|
+
|
|
427
|
+
<AiChatProvider
|
|
428
|
+
adapter={myAdapter}
|
|
429
|
+
onProposalConfirm={(id, payload) => handleAction(id, payload)}
|
|
430
|
+
onProposalCancel={(id) => handleDismiss(id)}
|
|
431
|
+
>
|
|
432
|
+
<App />
|
|
433
|
+
</AiChatProvider>
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
| Prop | Type | Description |
|
|
437
|
+
|---|---|---|
|
|
438
|
+
| `adapter` | `AiBackendAdapter` | **Required.** Your adapter. |
|
|
439
|
+
| `context` | `Record<string, unknown>` | Initial context forwarded to every adapter call. Update per-page via `setContext()`. |
|
|
440
|
+
| `onProposalConfirm` | `(id, payload) => void` | Called when user confirms a proposal card. |
|
|
441
|
+
| `onProposalCancel` | `(id) => void` | Called when user cancels a proposal card. |
|
|
442
|
+
| `initialMessages` | `AiMessage[]` | Restored conversation to start with (see Chat history). |
|
|
443
|
+
| `historyLimit` | `number` | Max messages sent to the adapter per request. Rendering never trimmed. |
|
|
444
|
+
| `onMessagesChange` | `(messages) => void` | Called when the conversation settles. Use to persist. |
|
|
445
|
+
|
|
446
|
+
**Updating context per page:**
|
|
447
|
+
|
|
448
|
+
Use `setContext()` from `useAiChatContext()` to tell the AI where the user is on each page. The context is forwarded opaquely to every `adapter.sendMessage` call as `input.context`.
|
|
449
|
+
|
|
450
|
+
```tsx
|
|
451
|
+
import { useAiChatContext } from 'juneau';
|
|
452
|
+
|
|
453
|
+
function InvoicesPage() {
|
|
454
|
+
const { setContext } = useAiChatContext();
|
|
455
|
+
|
|
456
|
+
useEffect(() => {
|
|
457
|
+
setContext({
|
|
458
|
+
page: 'invoices',
|
|
459
|
+
availableTools: ['search', 'export'],
|
|
460
|
+
userRole: 'admin',
|
|
461
|
+
});
|
|
462
|
+
}, []);
|
|
463
|
+
|
|
464
|
+
return <main>...</main>;
|
|
465
|
+
}
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
**Replace vs merge:** passing an object to `setContext` **replaces** the whole context. To keep existing keys (e.g. a `sessionId` set elsewhere) while updating others, use the updater form:
|
|
469
|
+
|
|
470
|
+
```tsx
|
|
471
|
+
setContext(prev => ({ ...prev, page: 'invoices' }));
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
**Consuming chat state outside a guaranteed provider:**
|
|
475
|
+
|
|
476
|
+
`useAiChatContext()` throws when called outside `<AiChatProvider>` — fail fast is right for components that require it. For components that may render outside the provider (e.g. during sign-out transitions), use `useAiChatContextSafe()`, which returns `null` instead of throwing:
|
|
477
|
+
|
|
478
|
+
```tsx
|
|
479
|
+
import { useAiChatContextSafe } from 'juneau';
|
|
480
|
+
|
|
481
|
+
function OptionalChatButton() {
|
|
482
|
+
const chat = useAiChatContextSafe(); // AiChatContextValue | null
|
|
483
|
+
if (!chat) return null;
|
|
484
|
+
return <button onClick={() => chat.sendMessageWithText('Help')}>Ask AI</button>;
|
|
485
|
+
}
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
The context value is memoized — consumers re-render only when chat state actually changes, not on every provider render.
|
|
489
|
+
|
|
490
|
+
---
|
|
491
|
+
|
|
492
|
+
## Components
|
|
493
|
+
|
|
494
|
+
### `<AiSidebar>`
|
|
495
|
+
|
|
496
|
+
Fixed-position chat panel. Reads all state from the nearest `<AiChatProvider>` — renders wherever you place it, conversation persists across navigation.
|
|
497
|
+
|
|
498
|
+
The sidebar anchors to the bottom-right of the viewport and opens at 60% screen height by default. Users can minimize it to a compact header bar.
|
|
499
|
+
|
|
500
|
+
```tsx
|
|
501
|
+
<AiSidebar
|
|
502
|
+
title="AI Assistant"
|
|
503
|
+
height="70vh"
|
|
504
|
+
/>
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
| Prop | Type | Description |
|
|
508
|
+
|---|---|---|
|
|
509
|
+
| `title` | `string` | Overrides the `sidebarTitle` label for this instance. |
|
|
510
|
+
| `icon` | `ReactNode` | Override the header + avatar icon. Defaults to a wand icon. |
|
|
511
|
+
| `actions` | `AiInputAction[]` | Toolbar buttons left of send. Pass `[]` to hide entirely. |
|
|
512
|
+
| `sendIcon` | `ReactNode` | Override the send button icon. |
|
|
513
|
+
| `height` | `string` | Height when open. Any CSS value. Defaults to `'60vh'`. |
|
|
514
|
+
| `className` | `string` | Added to the `<aside>` element. |
|
|
515
|
+
| `style` | `CSSProperties` | Inline styles on the `<aside>`. |
|
|
516
|
+
| `renderPart` | `RenderPartFn` | Custom part renderer — see below. |
|
|
517
|
+
| `onEntityClick` | `(entity, entityType?) => void` | Makes entity cards clickable — e.g. navigate to the record. |
|
|
518
|
+
| `headerActions` | `ReactNode` | Extra controls in the header, left of reset/minimize. Rendered as given — Juneau never interprets them. |
|
|
519
|
+
|
|
520
|
+
**Header chrome (`headerActions`):**
|
|
521
|
+
|
|
522
|
+
The header exposes a slot for consumer-owned controls — a settings button, a link, anything. Juneau renders the node untouched and owns none of its behaviour:
|
|
523
|
+
|
|
524
|
+
```tsx
|
|
525
|
+
<AiSidebar
|
|
526
|
+
headerActions={
|
|
527
|
+
<button onClick={() => setSettingsOpen(true)} aria-label='Chat settings'>
|
|
528
|
+
⚙
|
|
529
|
+
</button>
|
|
530
|
+
}
|
|
531
|
+
/>
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
---
|
|
535
|
+
|
|
536
|
+
### `<AiChat>`
|
|
537
|
+
|
|
538
|
+
Headless chat body — message list + input bar + error banner, no surrounding chrome. Use this when you want to embed chat inside your own layout (dashboard panel, modal, full-page view).
|
|
539
|
+
|
|
540
|
+
The parent is responsible for calling `useAiChat` and passing results down as props.
|
|
541
|
+
|
|
542
|
+
```tsx
|
|
543
|
+
import { useAiChat, AiChat } from 'juneau';
|
|
544
|
+
|
|
545
|
+
function MyPage() {
|
|
546
|
+
const chat = useAiChat({ adapter });
|
|
547
|
+
|
|
548
|
+
return (
|
|
549
|
+
<div className="my-layout">
|
|
550
|
+
<MySidebar />
|
|
551
|
+
<AiChat
|
|
552
|
+
messages={chat.messages}
|
|
553
|
+
input={chat.input}
|
|
554
|
+
isLoading={chat.isLoading}
|
|
555
|
+
error={chat.error}
|
|
556
|
+
onInputChange={chat.setInput}
|
|
557
|
+
onSend={chat.sendMessage}
|
|
558
|
+
onStop={chat.stop}
|
|
559
|
+
onProposalConfirm={chat.confirmProposal}
|
|
560
|
+
onProposalCancel={chat.cancelProposal}
|
|
561
|
+
/>
|
|
562
|
+
</div>
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
| Prop | Type | Description |
|
|
568
|
+
|---|---|---|
|
|
569
|
+
| `messages` | `AiMessage[]` | Conversation history. |
|
|
570
|
+
| `input` | `string` | Current textarea value. |
|
|
571
|
+
| `isLoading` | `boolean` | Whether a stream is in progress. |
|
|
572
|
+
| `error` | `string \| null` | Last error message, or `null`. |
|
|
573
|
+
| `onInputChange` | `(value: string) => void` | Input change handler. |
|
|
574
|
+
| `onSend` | `() => void` | Send the current input. |
|
|
575
|
+
| `onStop` | `() => void` | Abort the in-flight stream. Renders a stop button while loading. |
|
|
576
|
+
| `onProposalConfirm` | `(id, payload) => void` | Proposal confirmed. |
|
|
577
|
+
| `onProposalCancel` | `(id) => void` | Proposal cancelled. |
|
|
578
|
+
| `assistantIcon` | `ReactNode` | Override the assistant avatar in all bubbles. |
|
|
579
|
+
| `actions` | `AiInputAction[]` | Toolbar buttons. |
|
|
580
|
+
| `sendIcon` | `ReactNode` | Override send button icon. |
|
|
581
|
+
| `placeholder` | `string` | Input placeholder text. |
|
|
582
|
+
| `renderPart` | `RenderPartFn` | Custom part renderer — see below. |
|
|
583
|
+
| `onEntityClick` | `(entity, entityType?) => void` | Makes entity cards clickable — e.g. navigate to the record. |
|
|
584
|
+
|
|
585
|
+
---
|
|
586
|
+
|
|
587
|
+
## Custom part rendering — `renderPart`
|
|
588
|
+
|
|
589
|
+
Both `AiSidebar` and `AiChat` accept a `renderPart` prop — an escape hatch for rendering consumer-defined part types (or overriding built-in ones):
|
|
590
|
+
|
|
591
|
+
```ts
|
|
592
|
+
type RenderPartFn = (part: AiMessagePart) => ReactNode | null | undefined;
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
It is called **before** the built-in renderers for every message part. Return a ReactNode to render it; return `null`/`undefined` to fall through to the built-ins (`text`, `table`, `entity`, `entity-list`, `proposal`, `activity`, `error`).
|
|
596
|
+
|
|
597
|
+
The backend can stream any custom part through the standard wire protocol:
|
|
598
|
+
|
|
599
|
+
```json
|
|
600
|
+
{ "type": "part", "part": { "type": "invoice-card", "invoiceNumber": "23251", "amount": "1200.00" } }
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
`AiCustomPart` (`{ type: string; [key: string]: unknown }`) is part of the `AiMessagePart` union, so TypeScript accepts custom shapes on both ends. Juneau applies **no validation or narrowing** to custom parts — the consumer owns all type assertions:
|
|
604
|
+
|
|
605
|
+
```tsx
|
|
606
|
+
// Define your part type wherever you like — Juneau doesn't need to know about it
|
|
607
|
+
type InvoiceCardPart = { type: 'invoice-card'; invoiceNumber: string; amount: string };
|
|
608
|
+
|
|
609
|
+
<AiSidebar renderPart={part => {
|
|
610
|
+
if (part.type === 'invoice-card') return <InvoiceCard part={part as InvoiceCardPart} />;
|
|
611
|
+
return null; // everything else falls through to the built-ins
|
|
612
|
+
}} />
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
Unhandled custom part types show a dashed warning outline in development (so they're never a silent mystery) and render nothing in production.
|
|
616
|
+
|
|
617
|
+
---
|
|
618
|
+
|
|
619
|
+
## `useAiChat` hook
|
|
620
|
+
|
|
621
|
+
For full control over layout and behaviour. Returns everything needed to build a custom chat UI.
|
|
622
|
+
|
|
623
|
+
```ts
|
|
624
|
+
const {
|
|
625
|
+
messages, // AiMessage[] — full conversation history
|
|
626
|
+
input, // string — current textarea value
|
|
627
|
+
setInput, // (value: string) => void
|
|
628
|
+
sendMessage, // () => Promise<void> — sends current input as user message
|
|
629
|
+
sendMessageWithText, // (text: string) => Promise<void> — send programmatically, no input state change
|
|
630
|
+
sendGreeting, // (contextHint: string) => Promise<void> — assistant speaks first, no user bubble shown
|
|
631
|
+
stop, // () => void — abort in-flight stream, keep existing messages
|
|
632
|
+
isLoading, // boolean — true while streaming
|
|
633
|
+
isConnecting, // boolean — true from send until first token arrives
|
|
634
|
+
error, // string | null — last error, cleared on next send
|
|
635
|
+
reset, // (nextMessages?: AiMessage[]) => void — clear (or replace) messages, abort any stream
|
|
636
|
+
confirmProposal, // (id: string, payload: unknown) => void — marks proposal resolved + fires callback
|
|
637
|
+
cancelProposal, // (id: string) => void — marks proposal resolved + fires callback
|
|
638
|
+
} = useAiChat({
|
|
639
|
+
adapter, // required
|
|
640
|
+
context, // optional — forwarded to every adapter.sendMessage call
|
|
641
|
+
onProposalConfirm, // optional — called by confirmProposal
|
|
642
|
+
onProposalCancel, // optional — called by cancelProposal
|
|
643
|
+
initialMessages, // optional — restored conversation to start with (see Chat history)
|
|
644
|
+
historyLimit, // optional — max messages sent to the adapter per request (token saving)
|
|
645
|
+
onMessagesChange, // optional — called when the conversation settles; use to persist
|
|
646
|
+
});
|
|
647
|
+
```
|
|
648
|
+
|
|
649
|
+
## `useAiChatContext` hook
|
|
650
|
+
|
|
651
|
+
Reads the shared state from `<AiChatProvider>`. Includes everything from `useAiChat` plus `setContext()`.
|
|
652
|
+
|
|
653
|
+
```ts
|
|
654
|
+
const {
|
|
655
|
+
// all useAiChat fields +
|
|
656
|
+
setContext, // (ctx: Record<string, unknown>) => void — update context forwarded to adapter
|
|
657
|
+
} = useAiChatContext();
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
Throws a descriptive error if called outside `<AiChatProvider>`.
|
|
661
|
+
|
|
662
|
+
---
|
|
663
|
+
|
|
664
|
+
### Useful patterns
|
|
665
|
+
|
|
666
|
+
**Proactive greeting on mount (`sendGreeting`):**
|
|
667
|
+
|
|
668
|
+
`sendGreeting(hint)` sends the hint as a `system` role message to the adapter and streams the response as an assistant-only message — no user bubble is added to the conversation. Perfect for page-load summaries where the AI speaks first.
|
|
669
|
+
|
|
670
|
+
```ts
|
|
671
|
+
const { sendGreeting } = useAiChatContext();
|
|
672
|
+
|
|
673
|
+
useEffect(() => {
|
|
674
|
+
sendGreeting(
|
|
675
|
+
'The user is viewing the Invoices page. ' +
|
|
676
|
+
'Briefly introduce what you can help with on this page.'
|
|
677
|
+
);
|
|
678
|
+
}, []);
|
|
679
|
+
```
|
|
680
|
+
|
|
681
|
+
**Trigger from outside the chat (e.g. clicking a data row):**
|
|
682
|
+
```ts
|
|
683
|
+
const { sendMessageWithText } = useAiChatContext();
|
|
684
|
+
sendMessageWithText(`Summarise order #${order.id} for me`);
|
|
685
|
+
```
|
|
686
|
+
|
|
687
|
+
**Pass page context to every request:**
|
|
688
|
+
```tsx
|
|
689
|
+
const { setContext } = useAiChatContext();
|
|
690
|
+
|
|
691
|
+
useEffect(() => {
|
|
692
|
+
setContext({ pageId: 'invoices', entityId: invoice.id, userRole: 'admin' });
|
|
693
|
+
}, [invoice.id]);
|
|
694
|
+
```
|
|
695
|
+
|
|
696
|
+
The `context` object lands in `input.context` inside every `adapter.sendMessage` call — use it to inject page-level data without polluting the message history.
|
|
697
|
+
|
|
698
|
+
**Reading message text in your adapter:**
|
|
699
|
+
|
|
700
|
+
Don't dig into `parts` manually — use the exported `getMessageText` helper:
|
|
701
|
+
|
|
702
|
+
```ts
|
|
703
|
+
import { getMessageText } from 'juneau';
|
|
704
|
+
|
|
705
|
+
async *sendMessage({ messages }) {
|
|
706
|
+
const lastUser = [...messages].reverse().find(m => m.role === 'user');
|
|
707
|
+
const text = lastUser ? getMessageText(lastUser) : '';
|
|
708
|
+
// → plain string, all text parts concatenated
|
|
709
|
+
}
|
|
710
|
+
```
|
|
711
|
+
|
|
712
|
+
**Auth — bearer token from React context:**
|
|
713
|
+
```ts
|
|
714
|
+
const adapter = createSseAdapter('/api/chat', {
|
|
715
|
+
getHeaders: async () => ({
|
|
716
|
+
Authorization: `Bearer ${await getAccessToken()}`,
|
|
717
|
+
}),
|
|
718
|
+
});
|
|
719
|
+
```
|
|
720
|
+
|
|
721
|
+
**Auth — session cookie (no extra config needed):**
|
|
722
|
+
|
|
723
|
+
Cookies are sent automatically by `fetch` to same-origin URLs. Just use `createSseAdapter('/api/chat')` with no headers — the browser attaches the session cookie for you.
|
|
724
|
+
|
|
725
|
+
For cross-origin backends, add `credentials: 'include'` by writing a custom adapter:
|
|
726
|
+
|
|
727
|
+
```ts
|
|
728
|
+
const adapter: AiBackendAdapter = {
|
|
729
|
+
async *sendMessage({ messages }) {
|
|
730
|
+
const res = await fetch('https://api.example.com/chat', {
|
|
731
|
+
method: 'POST',
|
|
732
|
+
credentials: 'include', // sends cookies cross-origin
|
|
733
|
+
headers: { 'Content-Type': 'application/json' },
|
|
734
|
+
body: JSON.stringify({ messages }),
|
|
735
|
+
});
|
|
736
|
+
// …stream response
|
|
737
|
+
},
|
|
738
|
+
};
|
|
739
|
+
```
|
|
740
|
+
|
|
741
|
+
**Stop button feedback:**
|
|
742
|
+
|
|
743
|
+
When `onStop` is provided, the send button becomes a stop button while streaming. After the user hits stop, `isLoading` goes false immediately and the existing partial response stays visible in the conversation — there's no error state, the stream just ends cleanly where it was cut.
|
|
744
|
+
|
|
745
|
+
---
|
|
746
|
+
|
|
747
|
+
## Message parts
|
|
748
|
+
|
|
749
|
+
Assistant messages are composed of typed **parts**. Text is streamed chunk by chunk; rich UI blocks are emitted as complete `part` events.
|
|
750
|
+
|
|
751
|
+
| Part type | Emitted as | Rendered by |
|
|
752
|
+
|---|---|---|
|
|
753
|
+
| `text` | `{ type: 'text', text: string }` stream events, accumulated | Markdown via `react-markdown` — safe against XSS |
|
|
754
|
+
| `table` | `{ type: 'part', part: { type: 'table', ... } }` | `AiTablePart` |
|
|
755
|
+
| `entity` | `{ type: 'part', part: { type: 'entity', ... } }` | `AiEntityCard` |
|
|
756
|
+
| `entity-list` | `{ type: 'part', part: { type: 'entity-list', ... } }` | `AiEntityListPart` |
|
|
757
|
+
| `proposal` | `{ type: 'part', part: { type: 'proposal', ... } }` | `AiProposalCard` |
|
|
758
|
+
| `activity` | `{ type: 'part', part: { type: 'activity', ... } }` | `AiActivityPart` |
|
|
759
|
+
| `error` | `{ type: 'error', message: string }` or `{ type: 'part', part: { type: 'error', ... } }` | Inline error in bubble |
|
|
760
|
+
|
|
761
|
+
**Emitting an activity from your adapter:**
|
|
762
|
+
|
|
763
|
+
Activity parts show the user what the AI is doing while it works — skill selection, document search, tool calls, etc. They have three states: `running`, `done`, `failed`.
|
|
764
|
+
|
|
765
|
+
```ts
|
|
766
|
+
// Show a running activity
|
|
767
|
+
yield {
|
|
768
|
+
type: 'part',
|
|
769
|
+
part: {
|
|
770
|
+
type: 'activity',
|
|
771
|
+
id: 'doc-search', // optional stable ID — enables in-place update
|
|
772
|
+
title: 'Searching document',
|
|
773
|
+
description: 'Looking up document by ID.',
|
|
774
|
+
status: 'running',
|
|
775
|
+
},
|
|
776
|
+
};
|
|
777
|
+
|
|
778
|
+
// Later: update the same activity in-place (same id)
|
|
779
|
+
yield {
|
|
780
|
+
type: 'part',
|
|
781
|
+
part: {
|
|
782
|
+
type: 'activity',
|
|
783
|
+
id: 'doc-search',
|
|
784
|
+
title: 'Document found',
|
|
785
|
+
description: 'Found document INV-2024-0894.',
|
|
786
|
+
status: 'done',
|
|
787
|
+
},
|
|
788
|
+
};
|
|
789
|
+
```
|
|
790
|
+
|
|
791
|
+
If `id` is provided, a later activity part with the same `id` updates the previous one in-place instead of appending a new row. Without `id`, activity parts append as a timeline.
|
|
792
|
+
|
|
793
|
+
**Emitting a proposal from your adapter:**
|
|
794
|
+
```ts
|
|
795
|
+
yield {
|
|
796
|
+
type: 'part',
|
|
797
|
+
part: {
|
|
798
|
+
type: 'proposal',
|
|
799
|
+
proposal: {
|
|
800
|
+
id: 'confirm-delete', // passed back to onProposalConfirm
|
|
801
|
+
title: 'Delete this item?',
|
|
802
|
+
description: 'This cannot be undone.',
|
|
803
|
+
confirmLabel: 'Delete', // optional, falls back to labels.proposalConfirm
|
|
804
|
+
cancelLabel: 'Keep', // optional, falls back to labels.proposalCancel
|
|
805
|
+
payload: { itemId: 42 }, // anything — you get it back in onProposalConfirm
|
|
806
|
+
},
|
|
807
|
+
},
|
|
808
|
+
};
|
|
809
|
+
```
|
|
810
|
+
|
|
811
|
+
**Emitting a table:**
|
|
812
|
+
```ts
|
|
813
|
+
yield {
|
|
814
|
+
type: 'part',
|
|
815
|
+
part: {
|
|
816
|
+
type: 'table',
|
|
817
|
+
columns: ['Name', 'Amount', 'Status'],
|
|
818
|
+
rows: [
|
|
819
|
+
{ Name: 'Item A', Amount: '$1,200', Status: 'Paid' },
|
|
820
|
+
{ Name: 'Item B', Amount: '$840', Status: 'Pending' },
|
|
821
|
+
],
|
|
822
|
+
},
|
|
823
|
+
};
|
|
824
|
+
```
|
|
825
|
+
|
|
826
|
+
**Emitting an entity or entity list:**
|
|
827
|
+
|
|
828
|
+
Entity parts render structured records as cards — a title, optional subtitle, and labelled field rows. Field values can render as badges (`success` / `warning` / `danger` / `neutral`). Pass `onEntityClick` to `AiSidebar` / `AiChat` to make the cards clickable (e.g. navigate to the record); `entityType` and `entity.payload` are forwarded so consumers can route without guessing.
|
|
829
|
+
|
|
830
|
+
```ts
|
|
831
|
+
// Single entity card
|
|
832
|
+
yield {
|
|
833
|
+
type: 'part',
|
|
834
|
+
part: {
|
|
835
|
+
type: 'entity',
|
|
836
|
+
entityType: 'invoice', // optional kind — forwarded to onEntityClick and custom renderers
|
|
837
|
+
entity: {
|
|
838
|
+
id: 'inv-0894', // forwarded to onEntityClick
|
|
839
|
+
title: 'INV-2024-0894',
|
|
840
|
+
subtitle: 'DataSys a.s.',
|
|
841
|
+
fields: [
|
|
842
|
+
{ label: 'Amount', value: 'CZK 390 000' },
|
|
843
|
+
{ label: 'Status', value: 'Approved', badge: 'success' },
|
|
844
|
+
],
|
|
845
|
+
payload: { internalId: 42 }, // anything — not rendered, available in onEntityClick
|
|
846
|
+
},
|
|
847
|
+
},
|
|
848
|
+
};
|
|
849
|
+
|
|
850
|
+
// List of entities with an optional heading
|
|
851
|
+
yield {
|
|
852
|
+
type: 'part',
|
|
853
|
+
part: {
|
|
854
|
+
type: 'entity-list',
|
|
855
|
+
title: '2 results',
|
|
856
|
+
entityType: 'invoice',
|
|
857
|
+
entities: [
|
|
858
|
+
{ id: 'inv-1', title: 'INV-2024-0894', fields: [{ label: 'Status', value: 'Approved', badge: 'success' }] },
|
|
859
|
+
{ id: 'inv-2', title: 'INV-2024-0895', fields: [{ label: 'Status', value: 'Pending', badge: 'warning' }] },
|
|
860
|
+
],
|
|
861
|
+
},
|
|
862
|
+
};
|
|
863
|
+
```
|
|
864
|
+
|
|
865
|
+
For fully custom entity layouts, override the built-in card via `renderPart` — return your own component for `part.type === 'entity'` / `'entity-list'` and it wins over the default renderer.
|
|
866
|
+
|
|
867
|
+
---
|
|
868
|
+
|
|
869
|
+
## Chat history
|
|
870
|
+
|
|
871
|
+
Juneau is storage-agnostic: it defines the exact data contract and renders restored conversations (including tables, proposals, and custom parts), but never touches storage itself. You persist chats wherever you want — localStorage, a database — and hand Juneau plain data.
|
|
872
|
+
|
|
873
|
+
**Persist a conversation:**
|
|
874
|
+
|
|
875
|
+
```tsx
|
|
876
|
+
import { useAiChat, serializeForStorage } from 'juneau';
|
|
877
|
+
|
|
878
|
+
const chat = useAiChat({
|
|
879
|
+
adapter,
|
|
880
|
+
historyLimit: 20, // send at most 20 messages to the adapter per request (token saving)
|
|
881
|
+
onMessagesChange: (messages) => {
|
|
882
|
+
// called when the conversation settles — never per streamed token
|
|
883
|
+
localStorage.setItem('chat', JSON.stringify(serializeForStorage(messages)));
|
|
884
|
+
},
|
|
885
|
+
});
|
|
886
|
+
```
|
|
887
|
+
|
|
888
|
+
`serializeForStorage` prepares messages for persistence:
|
|
889
|
+
- `createdAt` dates become ISO strings
|
|
890
|
+
- unresolved proposals are marked `resolved: 'expired'` — a restored proposal card renders disabled and can never fire callbacks against a stale payload
|
|
891
|
+
- `running` activity parts are dropped (they'd look permanently stuck)
|
|
892
|
+
- each message is stamped with the format version (`v: 1`); `deserializeMessages` treats a missing `v` as v1, so pre-versioned histories still parse
|
|
893
|
+
|
|
894
|
+
**Restore a conversation:**
|
|
895
|
+
|
|
896
|
+
```tsx
|
|
897
|
+
import { deserializeMessages } from 'juneau';
|
|
898
|
+
|
|
899
|
+
const stored = JSON.parse(localStorage.getItem('chat') ?? '[]');
|
|
900
|
+
const chat = useAiChat({ adapter, initialMessages: deserializeMessages(stored) });
|
|
901
|
+
```
|
|
902
|
+
|
|
903
|
+
All rich parts re-render exactly as they streamed in — no reconstruction needed. The restored history is also sent to the adapter, so the AI keeps full context.
|
|
904
|
+
|
|
905
|
+
**Rendering trims nothing.** `historyLimit` only caps what is *sent to the adapter*; the cut keeps the most recent messages and never splits a user/assistant exchange, so alternation stays valid.
|
|
906
|
+
|
|
907
|
+
**Multiple chats:**
|
|
908
|
+
|
|
909
|
+
Juneau supports a multi-chat UX via `AiChatSummary` and the `AiChatHistoryList` component. You own the chat list and per-chat storage; Juneau renders the list and switches conversations via `reset(nextMessages)`:
|
|
910
|
+
|
|
911
|
+
```tsx
|
|
912
|
+
import { AiChatHistoryList, trimChats } from 'juneau';
|
|
913
|
+
|
|
914
|
+
<AiChatHistoryList
|
|
915
|
+
chats={trimChats(myChats, 10)} // keep the 10 most recently updated
|
|
916
|
+
activeChatId={currentChatId}
|
|
917
|
+
onSelect={(chatId) => chat.reset(loadMessagesFor(chatId))}
|
|
918
|
+
onDelete={(chatId) => deleteChat(chatId)} // optional — omit to hide delete buttons
|
|
919
|
+
/>
|
|
920
|
+
```
|
|
921
|
+
|
|
922
|
+
```ts
|
|
923
|
+
type AiChatSummary = {
|
|
924
|
+
id: string;
|
|
925
|
+
title: string;
|
|
926
|
+
createdAt: Date;
|
|
927
|
+
updatedAt: Date;
|
|
928
|
+
};
|
|
929
|
+
```
|
|
930
|
+
|
|
931
|
+
`trimChats(chats, limit)` returns the `limit` most recently updated chats (sorted newest first) — delete the rest from your storage to enforce an overall chats limit.
|
|
932
|
+
|
|
933
|
+
---
|
|
934
|
+
|
|
935
|
+
## Theming
|
|
936
|
+
|
|
937
|
+
All visual values are CSS custom properties prefixed `--juneau-`. Override them via `JuneauProvider`:
|
|
938
|
+
|
|
939
|
+
```tsx
|
|
940
|
+
<JuneauProvider theme={{
|
|
941
|
+
colorPrimary: '#1d4ed8',
|
|
942
|
+
colorAccent: '#7c3aed',
|
|
943
|
+
radiusLg: '16px',
|
|
944
|
+
fontFamily: '"Inter", sans-serif',
|
|
945
|
+
}}>
|
|
946
|
+
```
|
|
947
|
+
|
|
948
|
+
Only keys you specify are overridden — everything else keeps its default. Overrides are scoped to the provider's subtree so multiple providers with different themes can coexist.
|
|
949
|
+
|
|
950
|
+
### Full theme reference
|
|
951
|
+
|
|
952
|
+
| Key | CSS variable | Default | Usage |
|
|
953
|
+
|---|---|---|---|
|
|
954
|
+
| `colorPrimary` | `--juneau-color-primary` | `#000000` | Send button, user bubble |
|
|
955
|
+
| `colorPrimaryDark` | `--juneau-color-primary-dark` | `#252528` | Primary hover state |
|
|
956
|
+
| `colorPrimaryLight` | `--juneau-color-primary-light` | `#ECECEC` | Primary tinted backgrounds |
|
|
957
|
+
| `colorAccent` | `--juneau-color-accent` | `#FF49A4` | Header bg, proposal confirm, avatar |
|
|
958
|
+
| `colorAccentDark` | `--juneau-color-accent-dark` | `#FF6BB3` | Accent hover |
|
|
959
|
+
| `colorAccentLight` | `--juneau-color-accent-light` | `#FFF0F7` | Proposal card background |
|
|
960
|
+
| `colorSurface` | `--juneau-color-surface` | `#FFFFFF` | Cards, sidebar, input background |
|
|
961
|
+
| `colorSurfaceRaised` | `--juneau-color-surface-raised` | `#F4F4F6` | Page background, table headers |
|
|
962
|
+
| `colorSurfaceHover` | `--juneau-color-surface-hover` | `#ECECEC` | Row hover |
|
|
963
|
+
| `colorBorder` | `--juneau-color-border` | `#E8E8EC` | Default borders |
|
|
964
|
+
| `colorTextPrimary` | `--juneau-color-text-primary` | `#000000` | Main body text |
|
|
965
|
+
| `colorTextSecondary` | `--juneau-color-text-secondary` | `#474747` | Supporting text |
|
|
966
|
+
| `colorTextMuted` | `--juneau-color-text-muted` | `#474747` | Labels, hints |
|
|
967
|
+
| `colorTextFaint` | `--juneau-color-text-faint` | `#9090A0` | Empty states |
|
|
968
|
+
| `colorTextInverse` | `--juneau-color-text-inverse` | `#FFFFFF` | Text on dark backgrounds |
|
|
969
|
+
| `colorAssistantAvatar` | `--juneau-color-assistant-avatar` | `#FF49A4` | Assistant avatar circle |
|
|
970
|
+
| `radiusSm` | `--juneau-radius-sm` | `6px` | Buttons, small elements |
|
|
971
|
+
| `radiusMd` | `--juneau-radius-md` | `8px` | Inputs, cards |
|
|
972
|
+
| `radiusLg` | `--juneau-radius-lg` | `12px` | Panels, large cards |
|
|
973
|
+
| `fontFamily` | `--juneau-font-family` | system-ui | Font used across all components |
|
|
974
|
+
|
|
975
|
+
---
|
|
976
|
+
|
|
977
|
+
## i18n / Labels
|
|
978
|
+
|
|
979
|
+
All UI strings are overridable. Built-in locales: `juneauEn` (default) and `juneauCs`.
|
|
980
|
+
|
|
981
|
+
```tsx
|
|
982
|
+
import { juneauCs } from 'juneau';
|
|
983
|
+
<JuneauProvider labels={juneauCs}>...</JuneauProvider>
|
|
984
|
+
```
|
|
985
|
+
|
|
986
|
+
Pass any `Partial<JuneauLabels>` — omitted keys fall back to English:
|
|
987
|
+
|
|
988
|
+
```tsx
|
|
989
|
+
<JuneauProvider labels={{ sidebarTitle: 'Ask AI', sendMessage: 'Send' }}>
|
|
990
|
+
```
|
|
991
|
+
|
|
992
|
+
### Full label reference
|
|
993
|
+
|
|
994
|
+
| Key | Default (EN) | Used in |
|
|
995
|
+
|---|---|---|
|
|
996
|
+
| `sidebarTitle` | `AI Assistant` | `AiChatHeader` title |
|
|
997
|
+
| `minimizeSidebar` | `Minimize` | `AiChatHeader` minimize button |
|
|
998
|
+
| `expandSidebar` | `Expand` | `AiChatHeader` expand button (when minimized) |
|
|
999
|
+
| `inputPlaceholder` | `Ask a question… (Enter to send)` | `AiInput` textarea |
|
|
1000
|
+
| `sendMessage` | `Send message` | `AiInput` send button |
|
|
1001
|
+
| `stopMessage` | `Stop` | `AiInput` stop button (while streaming) |
|
|
1002
|
+
| `emptyStateText` | `How can I help you today?` | `AiMessageList` empty state |
|
|
1003
|
+
| `emptyStateHint` | `Try: "Show me the data" or "Can you suggest something?"` | `AiMessageList` empty state hint |
|
|
1004
|
+
| `proposalConfirm` | `Confirm` | `AiProposalCard` fallback confirm label |
|
|
1005
|
+
| `proposalCancel` | `Cancel` | `AiProposalCard` fallback cancel label |
|
|
1006
|
+
| `proposalConfirmed` | `Confirmed` | `AiProposalCard` badge after confirm |
|
|
1007
|
+
| `proposalCancelled` | `Cancelled` | `AiProposalCard` badge after cancel |
|
|
1008
|
+
| `proposalExpired` | `No longer available` | `AiProposalCard` badge for restored proposals |
|
|
1009
|
+
| `errorDismiss` | `Dismiss` | `AiError` dismiss button |
|
|
1010
|
+
| `historyEmpty` | `No previous chats` | `AiChatHistoryList` empty state |
|
|
1011
|
+
| `historyDeleteChat` | `Delete chat` | `AiChatHistoryList` delete button |
|
|
1012
|
+
| `actionAddFile` | `Add file` | `AiInput` toolbar |
|
|
1013
|
+
| `actionQuickActions` | `Quick actions` | `AiInput` toolbar |
|
|
1014
|
+
| `actionNew` | `New` | `AiInput` toolbar |
|
|
1015
|
+
| `actionHistory` | `History` | `AiInput` toolbar |
|
|
1016
|
+
| `actionRules` | `Rules` | `AiInput` toolbar |
|
|
1017
|
+
|
|
1018
|
+
---
|
|
1019
|
+
|
|
1020
|
+
## Custom toolbar actions
|
|
1021
|
+
|
|
1022
|
+
The toolbar buttons left of the send button are fully configurable:
|
|
1023
|
+
|
|
1024
|
+
```tsx
|
|
1025
|
+
<AiSidebar
|
|
1026
|
+
actions={[
|
|
1027
|
+
{
|
|
1028
|
+
icon: <MyAttachIcon />,
|
|
1029
|
+
label: 'Attach file',
|
|
1030
|
+
onClick: () => openFilePicker(),
|
|
1031
|
+
},
|
|
1032
|
+
{
|
|
1033
|
+
icon: <MyTemplatesIcon />,
|
|
1034
|
+
label: 'Templates',
|
|
1035
|
+
onClick: () => openTemplateMenu(),
|
|
1036
|
+
},
|
|
1037
|
+
]}
|
|
1038
|
+
/>
|
|
1039
|
+
|
|
1040
|
+
// Hide the toolbar entirely:
|
|
1041
|
+
<AiSidebar actions={[]} />
|
|
1042
|
+
```
|
|
1043
|
+
|
|
1044
|
+
Each action: `{ icon: ReactNode, label: string, onClick?: () => void }`
|
|
1045
|
+
|
|
1046
|
+
---
|
|
1047
|
+
|
|
1048
|
+
## Security
|
|
1049
|
+
|
|
1050
|
+
- **No API keys in the library** — all AI calls happen in your adapter, which calls your backend. Juneau never sees credentials.
|
|
1051
|
+
- **XSS-safe markdown** — text parts are rendered via `react-markdown`, which never uses `dangerouslySetInnerHTML`. Malicious model output cannot inject scripts.
|
|
1052
|
+
- **No data storage** — Juneau holds conversation state in React memory only. Nothing is persisted or sent anywhere by the library itself.
|
|
1053
|
+
|
|
1054
|
+
---
|
|
1055
|
+
|
|
1056
|
+
## License
|
|
1057
|
+
|
|
1058
|
+
MIT
|
|
1059
|
+
|
|
1060
|
+
---
|
|
1061
|
+
|
|
1062
|
+
## Icon attribution
|
|
1063
|
+
|
|
1064
|
+
Icons used internally by Juneau are from [Font Awesome Free](https://fontawesome.com) (v6), licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). SVG paths are inlined directly — no runtime dependency on the Font Awesome package.
|
|
1065
|
+
|
|
1066
|
+
Icons used: `crow`, `paper-plane`, `paperclip`, `bolt`, `plus`, `clock-rotate-left`, `scroll`, `arrow-rotate-left`, `window-minimize`, `window-restore`.
|