@27works/chat-core 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 +776 -0
- package/dist/components/index.d.ts +18 -0
- package/dist/components/index.js +609 -0
- package/dist/contexts/index.d.ts +11 -0
- package/dist/contexts/index.js +175 -0
- package/dist/hooks/index.d.ts +76 -0
- package/dist/hooks/index.js +406 -0
- package/dist/server/index.d.ts +227 -0
- package/dist/server/index.js +15561 -0
- package/dist/types/index.d.ts +9 -0
- package/dist/types/index.js +0 -0
- package/dist/utils/index.d.ts +19 -0
- package/dist/utils/index.js +56 -0
- package/package.json +72 -0
package/README.md
ADDED
|
@@ -0,0 +1,776 @@
|
|
|
1
|
+
# @27works/chat-core
|
|
2
|
+
|
|
3
|
+
Shared server utilities, headless React components, hooks, and contexts for building AI chat applications on top of [Caruuto](https://caruuto.com) and the [Vercel AI SDK](https://sdk.vercel.ai).
|
|
4
|
+
|
|
5
|
+
This package provides the plumbing — state management, streaming, RAG, rate limiting, caching, tool confirmation, and acquisition tracking — so each app only needs to supply its own UI, system prompt, and tool definitions.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Contents
|
|
10
|
+
|
|
11
|
+
- [@27works/chat-core](#27workschat-core)
|
|
12
|
+
- [Contents](#contents)
|
|
13
|
+
- [Installation](#installation)
|
|
14
|
+
- [Setup](#setup)
|
|
15
|
+
- [Server: the chat API route](#server-the-chat-api-route)
|
|
16
|
+
- [Approach A — Caruuto context (recommended)](#approach-a--caruuto-context-recommended)
|
|
17
|
+
- [Approach B — createRagHandler](#approach-b--createraghandler)
|
|
18
|
+
- [Other required routes](#other-required-routes)
|
|
19
|
+
- [Link click tracking](#link-click-tracking)
|
|
20
|
+
- [Client: rendering a chat UI](#client-rendering-a-chat-ui)
|
|
21
|
+
- [ChatSessionProvider](#chatsessionprovider)
|
|
22
|
+
- [MessageList](#messagelist)
|
|
23
|
+
- [UserInput](#userinput)
|
|
24
|
+
- [useChatSession](#usechatsession)
|
|
25
|
+
- [Hooks](#hooks)
|
|
26
|
+
- [`useChatVisibility(chatId, pathname)`](#usechatvisibilitychatid-pathname)
|
|
27
|
+
- [`useEmailForm(messages, options?)`](#useemailformmessages-options)
|
|
28
|
+
- [`useForkConversation()`](#useforkconversation)
|
|
29
|
+
- [`useNavigateWithQuestion(question)`](#usenavigatewithquestionquestion)
|
|
30
|
+
- [`useParentRouteSync(pathname)`](#useparentroutesyncpathname)
|
|
31
|
+
- [Contexts](#contexts)
|
|
32
|
+
- [`ChatProvider` / `useChatContext`](#chatprovider--usechatcontext)
|
|
33
|
+
- [`ToastProvider` / `useToast`](#toastprovider--usetoast)
|
|
34
|
+
- [Utils](#utils)
|
|
35
|
+
- [`cn(...inputs)`](#cninputs)
|
|
36
|
+
- [`APPROVAL`](#approval)
|
|
37
|
+
- [`getToolsRequiringConfirmation(tools)`](#gettoolsrequiringconfirmationtools)
|
|
38
|
+
- [`trackLinkClick({ conversationId, url, linkLabel?, messageIndex? })`](#tracklinkclick-conversationid-url-linklabel-messageindex-)
|
|
39
|
+
- [Styling](#styling)
|
|
40
|
+
- [Environment variables](#environment-variables)
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Installation
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
npm install @27works/chat-core
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
or
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
yarn add @27works/chat-core
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Install peer dependencies alongside it:
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
npm install @ai-sdk/openai @ai-sdk/react @caruuto/caruuto-js @supabase/supabase-js @upstash/ratelimit @upstash/redis ai clsx next react tailwind-merge server-only
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
or
|
|
63
|
+
|
|
64
|
+
```sh
|
|
65
|
+
yarn add @ai-sdk/openai @ai-sdk/react @caruuto/caruuto-js @supabase/supabase-js @upstash/ratelimit @upstash/redis ai clsx next react tailwind-merge server-only
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## Setup
|
|
71
|
+
|
|
72
|
+
Before any server functions run, call `configure()` once — typically in your app's `lib/server/services.js`:
|
|
73
|
+
|
|
74
|
+
```js
|
|
75
|
+
// lib/server/services.js
|
|
76
|
+
import 'server-only'
|
|
77
|
+
|
|
78
|
+
import { configure } from '@27works/chat-core/server'
|
|
79
|
+
import { createClient as createCaruutoClient } from '@caruuto/caruuto-js'
|
|
80
|
+
import { createClient as createSupabaseClient } from '@supabase/supabase-js'
|
|
81
|
+
|
|
82
|
+
configure({
|
|
83
|
+
supabase: createSupabaseClient(
|
|
84
|
+
process.env.SUPABASE_URL,
|
|
85
|
+
process.env.SUPABASE_SERVICE_ROLE_KEY
|
|
86
|
+
),
|
|
87
|
+
caruuto: createCaruutoClient(
|
|
88
|
+
process.env.CARUUTO_URL,
|
|
89
|
+
process.env.CARUUTO_API_KEY
|
|
90
|
+
)
|
|
91
|
+
})
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
If `configure()` is never called the package falls back to the `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, `CARUUTO_URL`, and `CARUUTO_API_KEY` environment variables automatically — so existing apps work without any changes.
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Server: the chat API route
|
|
99
|
+
|
|
100
|
+
Every app needs a `POST /api/chat` route that receives messages from the browser, calls the AI, and streams the response back. There are two patterns — pick the one that matches your setup.
|
|
101
|
+
|
|
102
|
+
### Approach A — Caruuto context (recommended)
|
|
103
|
+
|
|
104
|
+
Use this when Caruuto manages your knowledge base, vocabulary, tone guide, and entity detection. The `caruutoAdmin.ai.context()` call handles RAG, cache lookup, and system prompt assembly on Caruuto's side; your route drives OpenAI and streams the result.
|
|
105
|
+
|
|
106
|
+
```js
|
|
107
|
+
// app/api/chat/route.js
|
|
108
|
+
import 'server-only'
|
|
109
|
+
|
|
110
|
+
import { openai } from '@ai-sdk/openai'
|
|
111
|
+
import {
|
|
112
|
+
convertToModelMessages,
|
|
113
|
+
createUIMessageStream,
|
|
114
|
+
createUIMessageStreamResponse,
|
|
115
|
+
generateId,
|
|
116
|
+
stepCountIs,
|
|
117
|
+
streamText
|
|
118
|
+
} from 'ai'
|
|
119
|
+
|
|
120
|
+
import {
|
|
121
|
+
apiErrors,
|
|
122
|
+
caruutoAdmin,
|
|
123
|
+
chatRateLimit,
|
|
124
|
+
checkRateLimit,
|
|
125
|
+
getProjectId,
|
|
126
|
+
handleAPIError
|
|
127
|
+
} from '@27works/chat-core/server'
|
|
128
|
+
|
|
129
|
+
import { tools } from '@/lib/tools'
|
|
130
|
+
import { AI_CHAT_MODEL } from '@/lib/constants'
|
|
131
|
+
|
|
132
|
+
export const maxDuration = 30
|
|
133
|
+
|
|
134
|
+
export async function POST(req) {
|
|
135
|
+
try {
|
|
136
|
+
const rateLimitResponse = await checkRateLimit(req, chatRateLimit)
|
|
137
|
+
if (rateLimitResponse) return rateLimitResponse
|
|
138
|
+
|
|
139
|
+
const { messages, acquisition, clientSessionId } = await req.json()
|
|
140
|
+
const lastMessage = messages[messages.length - 1]
|
|
141
|
+
const messageText = lastMessage?.parts?.[0]?.text
|
|
142
|
+
|
|
143
|
+
if (!messageText) {
|
|
144
|
+
throw apiErrors.validation('Message text is required')
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// The conversationId travels in assistant message metadata after the
|
|
148
|
+
// first turn — null on the first call, which makes Caruuto create one.
|
|
149
|
+
const priorAssistant = [...messages]
|
|
150
|
+
.reverse()
|
|
151
|
+
.find(m => m.role === 'assistant')
|
|
152
|
+
const caruutoConversationId =
|
|
153
|
+
priorAssistant?.metadata?.caruutoConversationId ?? null
|
|
154
|
+
|
|
155
|
+
const projectId = await getProjectId()
|
|
156
|
+
|
|
157
|
+
const ctx = await caruutoAdmin.ai.context({
|
|
158
|
+
projectId,
|
|
159
|
+
message: messageText,
|
|
160
|
+
conversationId: caruutoConversationId,
|
|
161
|
+
source: 'widget',
|
|
162
|
+
clientSessionId,
|
|
163
|
+
referrerUrl: acquisition?.referrer_url,
|
|
164
|
+
utmSource: acquisition?.utm_source,
|
|
165
|
+
utmMedium: acquisition?.utm_medium,
|
|
166
|
+
utmCampaign: acquisition?.utm_campaign,
|
|
167
|
+
utmTerm: acquisition?.utm_term,
|
|
168
|
+
utmContent: acquisition?.utm_content
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
const stream = createUIMessageStream({
|
|
172
|
+
originalMessages: messages,
|
|
173
|
+
execute: async ({ writer }) => {
|
|
174
|
+
// Cache hit — stream the cached answer directly, no LLM call needed
|
|
175
|
+
if (ctx.from_cache) {
|
|
176
|
+
const messageId = generateId()
|
|
177
|
+
const textId = generateId()
|
|
178
|
+
writer.write({ type: 'start', messageId })
|
|
179
|
+
writer.write({ type: 'text-start', id: textId })
|
|
180
|
+
writer.write({
|
|
181
|
+
type: 'text-delta',
|
|
182
|
+
id: textId,
|
|
183
|
+
delta: ctx.cached_answer
|
|
184
|
+
})
|
|
185
|
+
writer.write({ type: 'text-end', id: textId })
|
|
186
|
+
writer.write({
|
|
187
|
+
type: 'finish',
|
|
188
|
+
messageMetadata: { caruutoConversationId: ctx.conversation_id }
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
caruutoAdmin.ai
|
|
192
|
+
.saveTurn({
|
|
193
|
+
conversationId: ctx.conversation_id,
|
|
194
|
+
projectId,
|
|
195
|
+
userContent: messageText,
|
|
196
|
+
assistantContent: ctx.cached_answer,
|
|
197
|
+
inputTokens: 0,
|
|
198
|
+
outputTokens: 0
|
|
199
|
+
})
|
|
200
|
+
.catch(err =>
|
|
201
|
+
console.error(
|
|
202
|
+
'[chat] Failed to persist cached turn:',
|
|
203
|
+
err?.message
|
|
204
|
+
)
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const result = streamText({
|
|
211
|
+
model: openai(AI_CHAT_MODEL),
|
|
212
|
+
system: ctx.system_prompt,
|
|
213
|
+
messages: [
|
|
214
|
+
...ctx.history,
|
|
215
|
+
...(await convertToModelMessages([
|
|
216
|
+
{ id: lastMessage.id, role: 'user', parts: lastMessage.parts }
|
|
217
|
+
]))
|
|
218
|
+
],
|
|
219
|
+
tools,
|
|
220
|
+
stopWhen: stepCountIs(2),
|
|
221
|
+
async onStepFinish({ text, toolCalls, usage }) {
|
|
222
|
+
if (!text && toolCalls?.length) return
|
|
223
|
+
|
|
224
|
+
if (text) {
|
|
225
|
+
caruutoAdmin.ai
|
|
226
|
+
.saveTurn({
|
|
227
|
+
conversationId: ctx.conversation_id,
|
|
228
|
+
projectId,
|
|
229
|
+
userContent: messageText,
|
|
230
|
+
assistantContent: text,
|
|
231
|
+
modelId: AI_CHAT_MODEL,
|
|
232
|
+
inputTokens: usage?.promptTokens,
|
|
233
|
+
outputTokens: usage?.completionTokens
|
|
234
|
+
})
|
|
235
|
+
.catch(err =>
|
|
236
|
+
console.error('[chat] Failed to persist turn:', err?.message)
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
await writer.merge(
|
|
243
|
+
result.toUIMessageStream({
|
|
244
|
+
originalMessages: messages,
|
|
245
|
+
messageMetadata: () => ({
|
|
246
|
+
caruutoConversationId: ctx.conversation_id
|
|
247
|
+
})
|
|
248
|
+
})
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
return createUIMessageStreamResponse({ stream })
|
|
254
|
+
} catch (error) {
|
|
255
|
+
return handleAPIError(error)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
### Approach B — createRagHandler
|
|
261
|
+
|
|
262
|
+
Use this when your knowledge base lives directly in Supabase (via the `match_content_chunks` vector search RPC) and you want the package to own the full RAG pipeline — cache lookup, embedding, vector search, context augmentation, streaming, and cache write.
|
|
263
|
+
|
|
264
|
+
```js
|
|
265
|
+
// app/api/chat/route.js
|
|
266
|
+
import { createRagHandler } from '@27works/chat-core/server'
|
|
267
|
+
import { tools } from '@/lib/tools'
|
|
268
|
+
import { SYSTEM_PROMPT } from '@/lib/prompts'
|
|
269
|
+
import { AI_CHAT_MODEL, AI_EMBEDDING_MODEL } from '@/lib/constants'
|
|
270
|
+
import { submitLeadCapture } from '@/lib/server/utils'
|
|
271
|
+
|
|
272
|
+
export const maxDuration = 30
|
|
273
|
+
|
|
274
|
+
export const { POST } = createRagHandler({
|
|
275
|
+
model: AI_CHAT_MODEL,
|
|
276
|
+
embeddingModel: AI_EMBEDDING_MODEL,
|
|
277
|
+
systemPrompt: SYSTEM_PROMPT,
|
|
278
|
+
tools,
|
|
279
|
+
toolHandlers: {
|
|
280
|
+
// Called server-side when the user confirms the emailCapture tool
|
|
281
|
+
async emailCapture({ email, name, phone, message }) {
|
|
282
|
+
await submitLeadCapture(email, name, message)
|
|
283
|
+
return { success: true }
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
useCache: true, // default: true
|
|
287
|
+
ragOptions: {
|
|
288
|
+
matchThreshold: 0.05, // default
|
|
289
|
+
matchCount: 10, // default
|
|
290
|
+
minContentLength: 50 // default
|
|
291
|
+
}
|
|
292
|
+
})
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
`createRagHandler` returns `{ POST }`, which is a Next.js App Router route handler.
|
|
296
|
+
|
|
297
|
+
**`ragOptions`**
|
|
298
|
+
|
|
299
|
+
| Option | Type | Default | Description |
|
|
300
|
+
| ------------------ | ---------------- | ------- | --------------------------------------------------------- |
|
|
301
|
+
| `matchThreshold` | `number` | `0.05` | Minimum cosine similarity for a chunk to be included |
|
|
302
|
+
| `matchCount` | `number` | `10` | Maximum chunks to retrieve |
|
|
303
|
+
| `minContentLength` | `number` | `50` | Minimum characters for a chunk to be considered |
|
|
304
|
+
| `similarityFilter` | `number \| null` | `null` | Post-retrieval filter — drops chunks below this threshold |
|
|
305
|
+
|
|
306
|
+
### Other required routes
|
|
307
|
+
|
|
308
|
+
These routes are app-implemented (they're too app-specific for a factory), but the helpers that power them all come from `@27works/chat-core/server`.
|
|
309
|
+
|
|
310
|
+
**`GET /api/chat/load/[id]`** — load an existing conversation
|
|
311
|
+
|
|
312
|
+
```js
|
|
313
|
+
import {
|
|
314
|
+
caruutoAdmin,
|
|
315
|
+
getProjectId,
|
|
316
|
+
handleAPIError
|
|
317
|
+
} from '@27works/chat-core/server'
|
|
318
|
+
|
|
319
|
+
export async function GET(req, { params }) {
|
|
320
|
+
const { id } = await params
|
|
321
|
+
try {
|
|
322
|
+
const projectId = await getProjectId()
|
|
323
|
+
const conversation = await caruutoAdmin.ai.loadConversation({
|
|
324
|
+
conversationId: id,
|
|
325
|
+
projectId
|
|
326
|
+
})
|
|
327
|
+
return Response.json({
|
|
328
|
+
id: conversation.id,
|
|
329
|
+
messages: conversation.messages || [],
|
|
330
|
+
created_at: conversation.started_at
|
|
331
|
+
})
|
|
332
|
+
} catch (error) {
|
|
333
|
+
return handleAPIError(error)
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
**`POST /api/chat/fork`** — fork a conversation thread from a question
|
|
339
|
+
|
|
340
|
+
```js
|
|
341
|
+
import {
|
|
342
|
+
caruutoAdmin,
|
|
343
|
+
getProjectId,
|
|
344
|
+
handleAPIError
|
|
345
|
+
} from '@27works/chat-core/server'
|
|
346
|
+
|
|
347
|
+
export async function POST(req) {
|
|
348
|
+
try {
|
|
349
|
+
const { questionId, sourceChatId } = await req.json()
|
|
350
|
+
const projectId = await getProjectId()
|
|
351
|
+
const { id: chatId } = await caruutoAdmin.ai.forkConversation({
|
|
352
|
+
questionId,
|
|
353
|
+
sourceChatId,
|
|
354
|
+
projectId
|
|
355
|
+
})
|
|
356
|
+
return Response.json({ chatId })
|
|
357
|
+
} catch (error) {
|
|
358
|
+
return handleAPIError(error)
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
**`GET|POST /api/chat/share`** — get or toggle public/private visibility
|
|
364
|
+
|
|
365
|
+
**`POST /api/chat/create`** — create a conversation with a pending question (used by `useNavigateWithQuestion`)
|
|
366
|
+
|
|
367
|
+
### Link click tracking
|
|
368
|
+
|
|
369
|
+
Add a route that proxies browser link-click beacons to Caruuto. Uses `sendBeacon` on the client, so it always returns `204` regardless of outcome — tracking failures must never surface to the user.
|
|
370
|
+
|
|
371
|
+
```js
|
|
372
|
+
// app/api/chat/link-click/route.js
|
|
373
|
+
import { createLinkClickHandler } from '@27works/chat-core/server'
|
|
374
|
+
|
|
375
|
+
export const { POST } = createLinkClickHandler()
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
On the client, call `trackLinkClick` when a link in an assistant message is clicked:
|
|
379
|
+
|
|
380
|
+
```js
|
|
381
|
+
import { trackLinkClick } from '@27works/chat-core/utils'
|
|
382
|
+
|
|
383
|
+
trackLinkClick({
|
|
384
|
+
conversationId,
|
|
385
|
+
url,
|
|
386
|
+
linkLabel: 'Book a tour',
|
|
387
|
+
messageIndex: 2
|
|
388
|
+
})
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
---
|
|
392
|
+
|
|
393
|
+
## Client: rendering a chat UI
|
|
394
|
+
|
|
395
|
+
The component layer is headless — the package manages state, and your app renders whatever JSX it likes via render props. There is no pre-built Chat.js component in this package.
|
|
396
|
+
|
|
397
|
+
### ChatSessionProvider
|
|
398
|
+
|
|
399
|
+
Wrap your chat UI with `ChatSessionProvider`. It initialises the message stream, handles acquisition/anonymous-ID capture, rate limit state, and conversation loading.
|
|
400
|
+
|
|
401
|
+
```jsx
|
|
402
|
+
import { ChatSessionProvider } from '@27works/chat-core/components'
|
|
403
|
+
|
|
404
|
+
export default function ChatPage({ chatId }) {
|
|
405
|
+
return (
|
|
406
|
+
<ChatSessionProvider
|
|
407
|
+
chatId={chatId}
|
|
408
|
+
apiPath='/api/chat' // default
|
|
409
|
+
shouldLoadConversation={true} // default — fetches existing messages on mount
|
|
410
|
+
onFinish={() => console.log('first stream complete')}
|
|
411
|
+
onError={err => console.error(err)}
|
|
412
|
+
>
|
|
413
|
+
<YourChatUI />
|
|
414
|
+
</ChatSessionProvider>
|
|
415
|
+
)
|
|
416
|
+
}
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
**Props**
|
|
420
|
+
|
|
421
|
+
| Prop | Type | Default | Description |
|
|
422
|
+
| ------------------------ | ------------------------------ | -------------- | --------------------------------------------------------------------- |
|
|
423
|
+
| `chatId` | `string` | required | Conversation ID — drives `useChat` deduplication and the load request |
|
|
424
|
+
| `apiPath` | `string` | `'/api/chat'` | URL of the chat POST endpoint |
|
|
425
|
+
| `shouldLoadConversation` | `boolean` | `true` | Fetch existing messages from `/api/chat/load/[chatId]` on mount |
|
|
426
|
+
| `thinkingOptions` | `string[]` | built-in array | Rotated randomly while awaiting the first streamed token |
|
|
427
|
+
| `onFinish` | `(message: UIMessage) => void` | — | Called once when the first stream completes |
|
|
428
|
+
| `onError` | `(err: Error) => void` | — | Called on stream errors (after toast notification) |
|
|
429
|
+
|
|
430
|
+
**Reading `caruutoConversationId` for navigation**
|
|
431
|
+
|
|
432
|
+
When using Approach A, Caruuto returns a conversation ID in the assistant message's `metadata`. The AI SDK sets this on the message object _after_ `onFinish` fires, so reading it from the `onFinish` argument will always return `undefined`. Use a `useEffect` on `messages` instead:
|
|
433
|
+
|
|
434
|
+
```js
|
|
435
|
+
import { useChatSession } from '@27works/chat-core/components'
|
|
436
|
+
|
|
437
|
+
const { messages } = useChatSession()
|
|
438
|
+
|
|
439
|
+
useEffect(() => {
|
|
440
|
+
const lastAssistant = [...messages]
|
|
441
|
+
.reverse()
|
|
442
|
+
.find(m => m.role === 'assistant')
|
|
443
|
+
const caruutoConversationId = lastAssistant?.metadata?.caruutoConversationId
|
|
444
|
+
|
|
445
|
+
if (caruutoConversationId && window.location.pathname === '/') {
|
|
446
|
+
window.location.href = `/conversation/${caruutoConversationId}`
|
|
447
|
+
}
|
|
448
|
+
}, [messages])
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
### MessageList
|
|
452
|
+
|
|
453
|
+
Iterates the message array and delegates rendering to your render props. The component itself renders nothing — it only calls your functions.
|
|
454
|
+
|
|
455
|
+
```jsx
|
|
456
|
+
import { MessageList } from '@27works/chat-core/components'
|
|
457
|
+
|
|
458
|
+
function ChatMessages() {
|
|
459
|
+
return (
|
|
460
|
+
<MessageList
|
|
461
|
+
renderMessage={({
|
|
462
|
+
key,
|
|
463
|
+
message,
|
|
464
|
+
parts,
|
|
465
|
+
isUser,
|
|
466
|
+
isStreaming,
|
|
467
|
+
addToolResult
|
|
468
|
+
}) => (
|
|
469
|
+
<div key={key} className={isUser ? 'user-bubble' : 'assistant-bubble'}>
|
|
470
|
+
{parts.map((part, i) => {
|
|
471
|
+
if (part.type === 'text') return <p key={i}>{part.text}</p>
|
|
472
|
+
// render tool confirmation UI, images, etc.
|
|
473
|
+
})}
|
|
474
|
+
</div>
|
|
475
|
+
)}
|
|
476
|
+
renderThinking={({ message }) => (
|
|
477
|
+
<div className='thinking-indicator'>{message}</div>
|
|
478
|
+
)}
|
|
479
|
+
renderStreamingIndicator={() => <div className='streaming-dots'>...</div>}
|
|
480
|
+
/>
|
|
481
|
+
)
|
|
482
|
+
}
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
**Render prop arguments for `renderMessage`**
|
|
486
|
+
|
|
487
|
+
| Arg | Type | Description |
|
|
488
|
+
| --------------- | ------------------ | ---------------------------------------------------------------------- |
|
|
489
|
+
| `key` | `string \| number` | Stable message key for React |
|
|
490
|
+
| `message` | `UIMessage` | Full AI SDK message object |
|
|
491
|
+
| `parts` | `UIMessagePart[]` | `message.parts` — text, tool-invocation, and tool-result parts |
|
|
492
|
+
| `isUser` | `boolean` | Whether this is a user message |
|
|
493
|
+
| `isStreaming` | `boolean` | True for the last assistant message while streaming |
|
|
494
|
+
| `addToolResult` | `fn` | Call with `{ toolCallId, result }` to resolve a human-in-the-loop tool |
|
|
495
|
+
|
|
496
|
+
`renderThinking` receives `{ message: string }` — the randomly selected thinking string.
|
|
497
|
+
`renderStreamingIndicator` receives nothing — shown when streaming has started but no text token has arrived yet.
|
|
498
|
+
|
|
499
|
+
### UserInput
|
|
500
|
+
|
|
501
|
+
Manages input state, submission, keyboard shortcuts, and disabled conditions (rate limiting, pending tool confirmation, loading). Renders nothing itself.
|
|
502
|
+
|
|
503
|
+
```jsx
|
|
504
|
+
import { UserInput } from '@27works/chat-core/components'
|
|
505
|
+
|
|
506
|
+
function ChatInput() {
|
|
507
|
+
return (
|
|
508
|
+
<UserInput
|
|
509
|
+
renderInput={({
|
|
510
|
+
value,
|
|
511
|
+
onChange,
|
|
512
|
+
onSubmit,
|
|
513
|
+
onKeyDown,
|
|
514
|
+
disabled,
|
|
515
|
+
isLoading,
|
|
516
|
+
countdownSeconds
|
|
517
|
+
}) => (
|
|
518
|
+
<div className='input-row'>
|
|
519
|
+
<textarea
|
|
520
|
+
value={value}
|
|
521
|
+
onChange={onChange}
|
|
522
|
+
onKeyDown={onKeyDown}
|
|
523
|
+
placeholder='Ask anything...'
|
|
524
|
+
disabled={isLoading}
|
|
525
|
+
/>
|
|
526
|
+
<button onClick={onSubmit} disabled={disabled}>
|
|
527
|
+
{countdownSeconds > 0 ? `Wait ${countdownSeconds}s` : 'Send'}
|
|
528
|
+
</button>
|
|
529
|
+
</div>
|
|
530
|
+
)}
|
|
531
|
+
/>
|
|
532
|
+
)
|
|
533
|
+
}
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
**Render prop arguments**
|
|
537
|
+
|
|
538
|
+
| Arg | Type | Description |
|
|
539
|
+
| ------------------ | ----------------------- | -------------------------------------------------------------------- |
|
|
540
|
+
| `value` | `string` | Controlled input value |
|
|
541
|
+
| `onChange` | `(e \| string) => void` | Accepts a change event or a raw string |
|
|
542
|
+
| `onSubmit` | `() => void` | Submits the current value; no-ops if disabled |
|
|
543
|
+
| `onKeyDown` | `(e) => void` | Enter submits (Shift+Enter inserts newline) |
|
|
544
|
+
| `disabled` | `boolean` | True when rate-limited, loading, pending tool confirmation, or empty |
|
|
545
|
+
| `isLoading` | `boolean` | True while `status` is `submitted` or `streaming` |
|
|
546
|
+
| `countdownSeconds` | `number` | Seconds remaining on the rate limit (0 when not limited) |
|
|
547
|
+
|
|
548
|
+
### useChatSession
|
|
549
|
+
|
|
550
|
+
Access any part of the session context directly — useful when building components that don't fit neatly into `MessageList` or `UserInput`:
|
|
551
|
+
|
|
552
|
+
```js
|
|
553
|
+
import { useChatSession } from '@27works/chat-core/components'
|
|
554
|
+
|
|
555
|
+
const {
|
|
556
|
+
messages,
|
|
557
|
+
sendMessage,
|
|
558
|
+
status, // 'ready' | 'submitted' | 'streaming' | 'error'
|
|
559
|
+
addToolResult,
|
|
560
|
+
setMessages,
|
|
561
|
+
clearError,
|
|
562
|
+
streamingWithNoText,
|
|
563
|
+
thinkingMessage,
|
|
564
|
+
pendingToolCallConfirmation,
|
|
565
|
+
rateLimitSeconds,
|
|
566
|
+
conversationLoading,
|
|
567
|
+
loadFailed,
|
|
568
|
+
lastFailedInput,
|
|
569
|
+
acquisition,
|
|
570
|
+
anonymousUserId
|
|
571
|
+
} = useChatSession()
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
---
|
|
575
|
+
|
|
576
|
+
## Hooks
|
|
577
|
+
|
|
578
|
+
All hooks are client components — import from `@27works/chat-core/hooks`.
|
|
579
|
+
|
|
580
|
+
### `useChatVisibility(chatId, pathname)`
|
|
581
|
+
|
|
582
|
+
Loads and toggles the public/private visibility of a conversation. Calls `/api/chat/share` internally.
|
|
583
|
+
|
|
584
|
+
```js
|
|
585
|
+
const { visibility, toggle } = useChatVisibility(chatId, pathname)
|
|
586
|
+
// visibility: 'public' | 'private'
|
|
587
|
+
// toggle(): flips visibility and copies the share URL to the clipboard when making public
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
### `useEmailForm(messages, options?)`
|
|
591
|
+
|
|
592
|
+
Manages email capture form state — fields, validation, auto-population from the `emailCapture` tool's `summary` input, and reset.
|
|
593
|
+
|
|
594
|
+
```js
|
|
595
|
+
const {
|
|
596
|
+
email,
|
|
597
|
+
name,
|
|
598
|
+
phone,
|
|
599
|
+
message,
|
|
600
|
+
setEmail,
|
|
601
|
+
setName,
|
|
602
|
+
setPhone,
|
|
603
|
+
setMessage,
|
|
604
|
+
error,
|
|
605
|
+
setError,
|
|
606
|
+
isValid, // () => boolean — requires email + name
|
|
607
|
+
validateEmail, // (value) => boolean — sets error state
|
|
608
|
+
reset
|
|
609
|
+
} = useEmailForm(messages, { toolName: 'emailCapture' })
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
### `useForkConversation()`
|
|
613
|
+
|
|
614
|
+
Creates a copy of a conversation thread and navigates to it. Waits for DB confirmation before navigating.
|
|
615
|
+
|
|
616
|
+
```js
|
|
617
|
+
const { forkConversation, isForking } = useForkConversation()
|
|
618
|
+
|
|
619
|
+
// forkConversation({ questionId, sourceChatId })
|
|
620
|
+
```
|
|
621
|
+
|
|
622
|
+
### `useNavigateWithQuestion(question)`
|
|
623
|
+
|
|
624
|
+
Creates a new conversation pre-seeded with a question and navigates to it. Calls `POST /api/chat/create`.
|
|
625
|
+
|
|
626
|
+
```js
|
|
627
|
+
const { navigate, isNavigating } = useNavigateWithQuestion()
|
|
628
|
+
|
|
629
|
+
// navigate('What are your opening hours?')
|
|
630
|
+
```
|
|
631
|
+
|
|
632
|
+
### `useParentRouteSync(pathname)`
|
|
633
|
+
|
|
634
|
+
When the app runs inside an iframe, posts the current pathname to the parent window whenever it changes. The parent can listen for `{ type: 'route', path }` messages to keep its URL bar in sync.
|
|
635
|
+
|
|
636
|
+
```js
|
|
637
|
+
useParentRouteSync(pathname)
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
---
|
|
641
|
+
|
|
642
|
+
## Contexts
|
|
643
|
+
|
|
644
|
+
Import from `@27works/chat-core/contexts`.
|
|
645
|
+
|
|
646
|
+
### `ChatProvider` / `useChatContext`
|
|
647
|
+
|
|
648
|
+
Cross-component communication channel for the chat UI — message triggering, share handlers, transition state, and share modal state. `ChatSessionProvider` mounts this automatically; you only need it directly if building outside the standard provider stack.
|
|
649
|
+
|
|
650
|
+
```js
|
|
651
|
+
const {
|
|
652
|
+
triggerMessage, // (text: string) => void — programmatically send a message
|
|
653
|
+
shareConversation, // () => void
|
|
654
|
+
shareAnswer, // () => void
|
|
655
|
+
canShare, // boolean
|
|
656
|
+
registerShareHandlers, // attach share callbacks from Chat.js
|
|
657
|
+
chatControls, // { firstQuestionId, onMakePublic } | null
|
|
658
|
+
registerChatControls,
|
|
659
|
+
hasTransitioned, // whether the intro → chat transition has fired
|
|
660
|
+
setHasTransitioned,
|
|
661
|
+
shareModalOpen,
|
|
662
|
+
openShareModal,
|
|
663
|
+
closeShareModal
|
|
664
|
+
} = useChatContext()
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
### `ToastProvider` / `useToast`
|
|
668
|
+
|
|
669
|
+
Toast notification context. `ChatSessionProvider` mounts this automatically.
|
|
670
|
+
|
|
671
|
+
```js
|
|
672
|
+
const { showToast } = useToast()
|
|
673
|
+
|
|
674
|
+
showToast('Copied to clipboard')
|
|
675
|
+
showToast('Something went wrong', 'error')
|
|
676
|
+
```
|
|
677
|
+
|
|
678
|
+
---
|
|
679
|
+
|
|
680
|
+
## Utils
|
|
681
|
+
|
|
682
|
+
Import from `@27works/chat-core/utils`.
|
|
683
|
+
|
|
684
|
+
### `cn(...inputs)`
|
|
685
|
+
|
|
686
|
+
Merges Tailwind class names, resolving conflicts via `tailwind-merge`.
|
|
687
|
+
|
|
688
|
+
```js
|
|
689
|
+
import { cn } from '@27works/chat-core/utils'
|
|
690
|
+
|
|
691
|
+
cn('px-4 py-2', isActive && 'bg-black text-white', className)
|
|
692
|
+
```
|
|
693
|
+
|
|
694
|
+
### `APPROVAL`
|
|
695
|
+
|
|
696
|
+
Constants for resolving human-in-the-loop tool confirmations.
|
|
697
|
+
|
|
698
|
+
```js
|
|
699
|
+
import { APPROVAL } from '@27works/chat-core/utils'
|
|
700
|
+
|
|
701
|
+
// APPROVAL.YES → 'Yes, confirmed.'
|
|
702
|
+
// APPROVAL.NO → 'No, denied.'
|
|
703
|
+
|
|
704
|
+
addToolResult({ toolCallId, result: APPROVAL.YES })
|
|
705
|
+
```
|
|
706
|
+
|
|
707
|
+
### `getToolsRequiringConfirmation(tools)`
|
|
708
|
+
|
|
709
|
+
Returns the names of tools that have no `execute` function — i.e., tools that pause for human confirmation before running server-side.
|
|
710
|
+
|
|
711
|
+
```js
|
|
712
|
+
import { getToolsRequiringConfirmation } from '@27works/chat-core/utils'
|
|
713
|
+
|
|
714
|
+
// In lib/tools.ts
|
|
715
|
+
export const confirmationTools = getToolsRequiringConfirmation(tools)
|
|
716
|
+
```
|
|
717
|
+
|
|
718
|
+
### `trackLinkClick({ conversationId, url, linkLabel?, messageIndex? })`
|
|
719
|
+
|
|
720
|
+
Sends a fire-and-forget `sendBeacon` to `/api/chat/link-click`. Safe to call in click handlers — never throws, never blocks navigation.
|
|
721
|
+
|
|
722
|
+
```js
|
|
723
|
+
import { trackLinkClick } from '@27works/chat-core/utils'
|
|
724
|
+
;<a
|
|
725
|
+
href={url}
|
|
726
|
+
onClick={() =>
|
|
727
|
+
trackLinkClick({ conversationId, url, linkLabel: link.label, messageIndex })
|
|
728
|
+
}
|
|
729
|
+
>
|
|
730
|
+
{link.label}
|
|
731
|
+
</a>
|
|
732
|
+
```
|
|
733
|
+
|
|
734
|
+
---
|
|
735
|
+
|
|
736
|
+
## Styling
|
|
737
|
+
|
|
738
|
+
This package ships no CSS. Components use Tailwind utility classes internally; your app is responsible for providing the Tailwind build and setting the theme variables.
|
|
739
|
+
|
|
740
|
+
Add these CSS custom properties to your `globals.css`:
|
|
741
|
+
|
|
742
|
+
```css
|
|
743
|
+
@import 'tailwindcss';
|
|
744
|
+
|
|
745
|
+
@theme inline {
|
|
746
|
+
--color-primary: #your-brand-colour;
|
|
747
|
+
--color-primary-hover: #your-brand-colour-darker;
|
|
748
|
+
--color-foreground: #231f20;
|
|
749
|
+
--color-foreground-secondary: #414042;
|
|
750
|
+
--color-foreground-muted: #6b7280;
|
|
751
|
+
--color-surface: #f9fafb;
|
|
752
|
+
--color-card: #ffffff;
|
|
753
|
+
--color-border: #e5e7eb;
|
|
754
|
+
--color-border-light: #f3f4f6;
|
|
755
|
+
--color-error: #dc2626;
|
|
756
|
+
--color-success: #16a34a;
|
|
757
|
+
}
|
|
758
|
+
```
|
|
759
|
+
|
|
760
|
+
---
|
|
761
|
+
|
|
762
|
+
## Environment variables
|
|
763
|
+
|
|
764
|
+
| Variable | Used by | Description |
|
|
765
|
+
| --------------------------- | ---------------------------- | --------------------------------------------------------- |
|
|
766
|
+
| `CARUUTO_URL` | `services.js` fallback | Base URL of your Caruuto instance |
|
|
767
|
+
| `CARUUTO_API_KEY` | `services.js` fallback | Project API key |
|
|
768
|
+
| `SUPABASE_URL` | `services.js` fallback | Supabase project URL |
|
|
769
|
+
| `SUPABASE_SERVICE_ROLE_KEY` | `services.js` fallback | Service-role key (server only) |
|
|
770
|
+
| `OPENAI_API_KEY` | `rag-handler.js`, app routes | OpenAI API key |
|
|
771
|
+
| `CARUUTO_PROJECT_ID` | `getProjectId()` | Project ID scoping all Caruuto/Supabase queries |
|
|
772
|
+
| `UPSTASH_REDIS_REST_URL` | `rate-limit.js` | Upstash Redis URL — rate limiting is disabled if absent |
|
|
773
|
+
| `UPSTASH_REDIS_REST_TOKEN` | `rate-limit.js` | Upstash Redis token |
|
|
774
|
+
| `RATE_LIMIT_TEST` | `rate-limit.js` | Set to `"true"` to apply a tight test limit (2 req / 15s) |
|
|
775
|
+
|
|
776
|
+
`SUPABASE_URL` / `SUPABASE_SERVICE_ROLE_KEY` / `CARUUTO_URL` / `CARUUTO_API_KEY` are only needed as env vars if you skip `configure()`. If you call `configure()` at startup you can name your env vars whatever you like.
|