@freewaretools/outercom 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +153 -0
- package/dist/ai.d.ts +31 -0
- package/dist/ai.js +42 -0
- package/dist/engine.d.ts +96 -0
- package/dist/engine.js +282 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +23 -0
- package/dist/local.d.ts +38 -0
- package/dist/local.js +44 -0
- package/dist/next.d.ts +46 -0
- package/dist/next.js +155 -0
- package/dist/prompt.d.ts +24 -0
- package/dist/prompt.js +105 -0
- package/dist/react.d.ts +51 -0
- package/dist/react.js +618 -0
- package/dist/storage.d.ts +54 -0
- package/dist/storage.js +72 -0
- package/dist/telegram.d.ts +40 -0
- package/dist/telegram.js +91 -0
- package/dist/transcript.d.ts +22 -0
- package/dist/transcript.js +73 -0
- package/dist/transport.d.ts +23 -0
- package/dist/transport.js +1 -0
- package/dist/types.d.ts +75 -0
- package/dist/types.js +8 -0
- package/package.json +48 -0
- package/src/ai.ts +72 -0
- package/src/engine.ts +363 -0
- package/src/index.ts +24 -0
- package/src/local.ts +77 -0
- package/src/next.ts +189 -0
- package/src/prompt.ts +131 -0
- package/src/react.tsx +994 -0
- package/src/storage.ts +111 -0
- package/src/telegram.ts +135 -0
- package/src/transcript.ts +116 -0
- package/src/transport.ts +24 -0
- package/src/types.ts +83 -0
package/src/engine.ts
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import type { ChatStorage } from "./storage"
|
|
2
|
+
import type { ChatTransport } from "./transport"
|
|
3
|
+
import type {
|
|
4
|
+
AIProvider,
|
|
5
|
+
ChatMessage,
|
|
6
|
+
ChatRole,
|
|
7
|
+
ChatSession,
|
|
8
|
+
ChatVisitor,
|
|
9
|
+
} from "./types"
|
|
10
|
+
|
|
11
|
+
export interface ChatEngineOptions {
|
|
12
|
+
storage: ChatStorage
|
|
13
|
+
transport: ChatTransport
|
|
14
|
+
/** Optional first-line AI. Omit (or pass `aiEnabled: false`) for human-only chat. */
|
|
15
|
+
ai?: AIProvider
|
|
16
|
+
aiEnabled?: boolean
|
|
17
|
+
/** Builds the forum-topic title for a new chat. Default: "Name · email". */
|
|
18
|
+
topicName?: (visitor: ChatVisitor) => string
|
|
19
|
+
/** Message shown to the visitor when a chat is escalated to a human. */
|
|
20
|
+
escalationMessage?: string
|
|
21
|
+
/**
|
|
22
|
+
* Optional hook to email/send a transcript. Called when a chat is closed and
|
|
23
|
+
* when the visitor requests a copy. Host-provided (e.g. render + Resend) so
|
|
24
|
+
* chatkit stays mailer-agnostic.
|
|
25
|
+
*/
|
|
26
|
+
sendTranscript?: (session: ChatSession, messages: ChatMessage[]) => Promise<void>
|
|
27
|
+
/**
|
|
28
|
+
* Optional hook to email a visitor replies they haven't seen (because they
|
|
29
|
+
* disconnected). Called by {@link flushUnseen}. Host-provided.
|
|
30
|
+
*/
|
|
31
|
+
sendReplyNotification?: (
|
|
32
|
+
session: ChatSession,
|
|
33
|
+
unseen: ChatMessage[]
|
|
34
|
+
) => Promise<void>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const DEFAULT_ESCALATION =
|
|
38
|
+
"I've passed this to our team — someone will jump in here shortly. Feel free to add any extra detail in the meantime."
|
|
39
|
+
|
|
40
|
+
/** Messages the client should render (everything except the visitor's own echoes). */
|
|
41
|
+
const CLIENT_ROLES: ChatRole[] = ["ai", "agent", "system"]
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Orchestrates a conversation across the storage, Telegram transport, and the
|
|
45
|
+
* optional AI first-line. Transport-agnostic at heart — `transport` is the only
|
|
46
|
+
* Telegram-specific dependency and sits behind a small surface.
|
|
47
|
+
*/
|
|
48
|
+
export class ChatEngine {
|
|
49
|
+
private readonly storage: ChatStorage
|
|
50
|
+
private readonly transport: ChatTransport
|
|
51
|
+
private readonly ai?: AIProvider
|
|
52
|
+
private readonly aiEnabled: boolean
|
|
53
|
+
private readonly topicName: (v: ChatVisitor) => string
|
|
54
|
+
private readonly escalationMessage: string
|
|
55
|
+
private readonly sendTranscript?: (
|
|
56
|
+
session: ChatSession,
|
|
57
|
+
messages: ChatMessage[]
|
|
58
|
+
) => Promise<void>
|
|
59
|
+
private readonly sendReplyNotification?: (
|
|
60
|
+
session: ChatSession,
|
|
61
|
+
unseen: ChatMessage[]
|
|
62
|
+
) => Promise<void>
|
|
63
|
+
|
|
64
|
+
constructor(opts: ChatEngineOptions) {
|
|
65
|
+
this.storage = opts.storage
|
|
66
|
+
this.transport = opts.transport
|
|
67
|
+
this.ai = opts.ai
|
|
68
|
+
this.aiEnabled = (opts.aiEnabled ?? true) && !!opts.ai
|
|
69
|
+
this.topicName =
|
|
70
|
+
opts.topicName ?? ((v) => `${v.name} · ${v.email}`)
|
|
71
|
+
this.escalationMessage = opts.escalationMessage ?? DEFAULT_ESCALATION
|
|
72
|
+
this.sendTranscript = opts.sendTranscript
|
|
73
|
+
this.sendReplyNotification = opts.sendReplyNotification
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Email the visitor a transcript on demand. Returns false if no mailer is wired. */
|
|
77
|
+
async emailTranscript(sessionId: string): Promise<boolean> {
|
|
78
|
+
if (!this.sendTranscript) return false
|
|
79
|
+
const session = await this.storage.getSession(sessionId)
|
|
80
|
+
if (!session) return false
|
|
81
|
+
try {
|
|
82
|
+
const messages = await this.storage.getMessages(sessionId)
|
|
83
|
+
await this.sendTranscript(session, messages)
|
|
84
|
+
return true
|
|
85
|
+
} catch (err) {
|
|
86
|
+
console.error("[chatkit] sendTranscript failed:", err)
|
|
87
|
+
return false
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Start a new chat: create the session, open a Telegram topic, and post the
|
|
93
|
+
* captured visitor details. The pre-chat form has already collected name +
|
|
94
|
+
* email (+ optional question) so a topic-per-chat is created up front.
|
|
95
|
+
*/
|
|
96
|
+
async startSession(visitor: ChatVisitor): Promise<{ sessionId: string }> {
|
|
97
|
+
const id = crypto.randomUUID()
|
|
98
|
+
const now = Date.now()
|
|
99
|
+
|
|
100
|
+
// Reuse one topic per customer email; reopen it if a prior chat closed it.
|
|
101
|
+
let threadId = await this.storage.getThreadByEmail(visitor.email)
|
|
102
|
+
const reused = threadId !== null
|
|
103
|
+
if (threadId !== null) {
|
|
104
|
+
await this.transport.reopenTopic(threadId)
|
|
105
|
+
} else {
|
|
106
|
+
try {
|
|
107
|
+
threadId = await this.transport.createTopic(this.topicName(visitor))
|
|
108
|
+
} catch (err) {
|
|
109
|
+
// If topic creation fails (misconfigured group/perms), the chat still
|
|
110
|
+
// works AI-only; agent relay just won't be available until fixed.
|
|
111
|
+
console.error("[chatkit] could not create transport thread:", err)
|
|
112
|
+
threadId = null
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const session: ChatSession = {
|
|
117
|
+
id,
|
|
118
|
+
visitor,
|
|
119
|
+
threadId,
|
|
120
|
+
status: this.aiEnabled ? "ai" : "live",
|
|
121
|
+
createdAt: now,
|
|
122
|
+
updatedAt: now,
|
|
123
|
+
}
|
|
124
|
+
await this.storage.createSession(session)
|
|
125
|
+
|
|
126
|
+
// Post the Telegram intro + handle the opening question in the BACKGROUND so
|
|
127
|
+
// "Start chat" returns immediately. Awaiting these blocked the widget on two
|
|
128
|
+
// Telegram round-trips plus the full AI generation. The widget shows the
|
|
129
|
+
// opening message optimistically and the AI reply arrives via poll().
|
|
130
|
+
void (async () => {
|
|
131
|
+
try {
|
|
132
|
+
if (threadId != null) {
|
|
133
|
+
const header = reused
|
|
134
|
+
? "🔁 <b>Returning visitor — new chat</b>"
|
|
135
|
+
: "🆕 <b>New chat</b>"
|
|
136
|
+
const intro =
|
|
137
|
+
`${header}\n` +
|
|
138
|
+
`From: ${esc(visitor.name)} (${esc(visitor.email)})\n` +
|
|
139
|
+
(visitor.topic ? `Topic: ${esc(visitor.topic)}\n` : "") +
|
|
140
|
+
(this.aiEnabled
|
|
141
|
+
? `\nThe AI assistant is handling this. Reply in this topic to take over.`
|
|
142
|
+
: `\nReply in this topic to respond.`)
|
|
143
|
+
await this.transport.sendMessage(threadId, intro)
|
|
144
|
+
}
|
|
145
|
+
// The pre-chat question becomes the visitor's opening message.
|
|
146
|
+
if (visitor.topic) {
|
|
147
|
+
await this.handleVisitorMessage(id, visitor.topic)
|
|
148
|
+
}
|
|
149
|
+
} catch (err) {
|
|
150
|
+
console.error("[chatkit] post-start tasks failed:", err)
|
|
151
|
+
}
|
|
152
|
+
})()
|
|
153
|
+
|
|
154
|
+
return { sessionId: id }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Handle a visitor message: persist + mirror to Telegram, then (if the AI is
|
|
159
|
+
* handling) generate a reply. Returns any new client-facing messages produced
|
|
160
|
+
* synchronously (the AI reply / escalation note) so the widget can render
|
|
161
|
+
* them immediately; async agent replies arrive via {@link poll}.
|
|
162
|
+
*/
|
|
163
|
+
async handleVisitorMessage(
|
|
164
|
+
sessionId: string,
|
|
165
|
+
text: string
|
|
166
|
+
): Promise<{ session: ChatSession; newMessages: ChatMessage[] }> {
|
|
167
|
+
const session = await this.requireSession(sessionId)
|
|
168
|
+
const clean = text.trim()
|
|
169
|
+
if (!clean) return { session, newMessages: [] }
|
|
170
|
+
|
|
171
|
+
await this.append(session, "visitor", clean)
|
|
172
|
+
await this.mirror(session, `💬 <b>${esc(session.visitor.name)}:</b> ${esc(clean)}`)
|
|
173
|
+
|
|
174
|
+
const produced: ChatMessage[] = []
|
|
175
|
+
|
|
176
|
+
if (session.status === "ai" && this.aiEnabled && this.ai) {
|
|
177
|
+
try {
|
|
178
|
+
const history = await this.storage.getMessages(sessionId)
|
|
179
|
+
const ai = await this.ai.reply(history, session.visitor)
|
|
180
|
+
|
|
181
|
+
if (ai.escalate) {
|
|
182
|
+
await this.mirror(
|
|
183
|
+
session,
|
|
184
|
+
`🙋 <b>AI requested a human</b>${ai.reason ? ` — ${esc(ai.reason)}` : ""}`
|
|
185
|
+
)
|
|
186
|
+
const note = await this.append(session, "system", this.escalationMessage)
|
|
187
|
+
produced.push(note)
|
|
188
|
+
await this.storage.updateSession(sessionId, { status: "live" })
|
|
189
|
+
} else {
|
|
190
|
+
const reply = await this.append(session, "ai", ai.text)
|
|
191
|
+
produced.push(reply)
|
|
192
|
+
await this.mirror(session, `🤖 <b>AI:</b> ${esc(ai.text)}`)
|
|
193
|
+
}
|
|
194
|
+
} catch (err) {
|
|
195
|
+
console.error("[chatkit] AI reply failed, escalating:", err)
|
|
196
|
+
const note = await this.append(session, "system", this.escalationMessage)
|
|
197
|
+
produced.push(note)
|
|
198
|
+
await this.storage.updateSession(sessionId, { status: "live" })
|
|
199
|
+
await this.mirror(session, `⚠️ <b>AI error — needs a human.</b>`)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return { session: await this.requireSession(sessionId), newMessages: produced }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Handle an inbound Telegram webhook update — a human agent replying in a
|
|
208
|
+
* topic. Flips the session to `live` so the AI stays quiet, or closes it on
|
|
209
|
+
* `/close`. Returns true if the update mapped to a known session.
|
|
210
|
+
*/
|
|
211
|
+
async handleWebhookUpdate(update: unknown): Promise<boolean> {
|
|
212
|
+
const parsed = this.transport.parseUpdate(update)
|
|
213
|
+
if (!parsed) return false
|
|
214
|
+
|
|
215
|
+
const session = await this.storage.getSessionByThread(parsed.threadId)
|
|
216
|
+
if (!session) return false
|
|
217
|
+
|
|
218
|
+
if (parsed.command === "close") {
|
|
219
|
+
await this.closeSession(session.id)
|
|
220
|
+
return true
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
await this.append(session, "agent", parsed.text, parsed.fromName)
|
|
224
|
+
if (session.status !== "live") {
|
|
225
|
+
await this.storage.updateSession(session.id, { status: "live" })
|
|
226
|
+
}
|
|
227
|
+
return true
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Poll for messages the visitor hasn't seen yet, plus the live status. Also
|
|
232
|
+
* records the visitor as "seen" now (a present visitor is polling/heartbeating)
|
|
233
|
+
* — the disconnect detector keys off this.
|
|
234
|
+
*/
|
|
235
|
+
async poll(
|
|
236
|
+
sessionId: string,
|
|
237
|
+
after = 0
|
|
238
|
+
): Promise<{ status: ChatSession["status"]; messages: ChatMessage[] }> {
|
|
239
|
+
const session = await this.requireSession(sessionId)
|
|
240
|
+
const messages = await this.storage.getMessages(sessionId, {
|
|
241
|
+
after,
|
|
242
|
+
roles: CLIENT_ROLES,
|
|
243
|
+
})
|
|
244
|
+
if (session.status !== "closed") {
|
|
245
|
+
await this.storage.updateSession(sessionId, { lastSeenAt: Date.now() })
|
|
246
|
+
}
|
|
247
|
+
return { status: session.status, messages }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Email visitors who disconnected (no poll for >= thresholdMs) the agent/AI
|
|
252
|
+
* replies they never saw. Idempotent via `notifiedAt`. Call from a cron.
|
|
253
|
+
* Returns how many sessions were emailed.
|
|
254
|
+
*/
|
|
255
|
+
async flushUnseen(thresholdMs: number): Promise<number> {
|
|
256
|
+
if (!this.sendReplyNotification || thresholdMs <= 0) return 0
|
|
257
|
+
const now = Date.now()
|
|
258
|
+
const idle = await this.storage.getSessionsIdleSince(now - thresholdMs)
|
|
259
|
+
let sent = 0
|
|
260
|
+
for (const session of idle) {
|
|
261
|
+
const since = Math.max(session.lastSeenAt ?? 0, session.notifiedAt ?? 0)
|
|
262
|
+
const unseen = await this.storage.getMessages(session.id, {
|
|
263
|
+
after: since,
|
|
264
|
+
roles: ["ai", "agent"],
|
|
265
|
+
})
|
|
266
|
+
if (unseen.length === 0) continue
|
|
267
|
+
try {
|
|
268
|
+
await this.sendReplyNotification(session, unseen)
|
|
269
|
+
await this.storage.updateSession(session.id, { notifiedAt: now })
|
|
270
|
+
sent++
|
|
271
|
+
} catch (err) {
|
|
272
|
+
console.error("[chatkit] reply notification failed:", err)
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return sent
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* The visitor ended/reset the chat from their side. Post a notice into the
|
|
280
|
+
* topic so agents know replies won't reach them, then close it. No transcript
|
|
281
|
+
* email (the visitor chose to start over, not to wrap up).
|
|
282
|
+
*/
|
|
283
|
+
async abandonSession(sessionId: string): Promise<void> {
|
|
284
|
+
const session = await this.storage.getSession(sessionId)
|
|
285
|
+
if (!session || session.status === "closed") return
|
|
286
|
+
await this.mirror(
|
|
287
|
+
session,
|
|
288
|
+
"🚪 <b>The visitor left / reset the chat.</b> Replies here won't reach them."
|
|
289
|
+
)
|
|
290
|
+
await this.storage.updateSession(sessionId, { status: "closed" })
|
|
291
|
+
if (session.threadId != null) {
|
|
292
|
+
await this.transport.closeTopic(session.threadId)
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async closeSession(sessionId: string): Promise<void> {
|
|
297
|
+
const session = await this.storage.getSession(sessionId)
|
|
298
|
+
if (!session) return
|
|
299
|
+
const alreadyClosed = session.status === "closed"
|
|
300
|
+
if (!alreadyClosed) {
|
|
301
|
+
await this.append(session, "system", "This chat has been closed. Thanks for getting in touch!")
|
|
302
|
+
}
|
|
303
|
+
await this.storage.updateSession(sessionId, { status: "closed" })
|
|
304
|
+
|
|
305
|
+
// Email the transcript once, on the transition to closed.
|
|
306
|
+
if (!alreadyClosed && this.sendTranscript) {
|
|
307
|
+
try {
|
|
308
|
+
const messages = await this.storage.getMessages(sessionId)
|
|
309
|
+
await this.sendTranscript(session, messages)
|
|
310
|
+
} catch (err) {
|
|
311
|
+
console.error("[chatkit] transcript on close failed:", err)
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (session.threadId != null) {
|
|
316
|
+
await this.transport.closeTopic(session.threadId)
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// --- internals -----------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
private async requireSession(id: string): Promise<ChatSession> {
|
|
323
|
+
const s = await this.storage.getSession(id)
|
|
324
|
+
if (!s) throw new ChatSessionNotFound(id)
|
|
325
|
+
return s
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private async append(
|
|
329
|
+
session: ChatSession,
|
|
330
|
+
role: ChatRole,
|
|
331
|
+
text: string,
|
|
332
|
+
authorName?: string
|
|
333
|
+
): Promise<ChatMessage> {
|
|
334
|
+
const message: ChatMessage = {
|
|
335
|
+
id: crypto.randomUUID(),
|
|
336
|
+
sessionId: session.id,
|
|
337
|
+
role,
|
|
338
|
+
text,
|
|
339
|
+
createdAt: Date.now(),
|
|
340
|
+
...(authorName ? { authorName } : {}),
|
|
341
|
+
}
|
|
342
|
+
await this.storage.appendMessage(message)
|
|
343
|
+
return message
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
private async mirror(session: ChatSession, html: string): Promise<void> {
|
|
347
|
+
if (session.threadId != null) {
|
|
348
|
+
await this.transport.sendMessage(session.threadId, html)
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export class ChatSessionNotFound extends Error {
|
|
354
|
+
constructor(public readonly sessionId: string) {
|
|
355
|
+
super(`chat session not found: ${sessionId}`)
|
|
356
|
+
this.name = "ChatSessionNotFound"
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Escape text for Telegram HTML parse mode. */
|
|
361
|
+
function esc(s: string): string {
|
|
362
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
363
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* chatkit — a portable, self-hosted customer-chat module.
|
|
3
|
+
*
|
|
4
|
+
* A floating chat widget that routes conversations to wherever your team works
|
|
5
|
+
* (Telegram in v1; the transport seam makes Slack/Discord/email pluggable),
|
|
6
|
+
* with an optional Claude first-line that deflects common questions and
|
|
7
|
+
* escalates to a human when needed.
|
|
8
|
+
*
|
|
9
|
+
* This is the framework-agnostic core. Entry points:
|
|
10
|
+
* - `chatkit` → core (this file)
|
|
11
|
+
* - `chatkit/next` → Next.js App Router route factories
|
|
12
|
+
* - `chatkit/react` → the widget component
|
|
13
|
+
*
|
|
14
|
+
* The folder has no host-app imports, so it can be lifted into its own package.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export * from "./types"
|
|
18
|
+
export * from "./storage"
|
|
19
|
+
export * from "./transport"
|
|
20
|
+
export * from "./telegram"
|
|
21
|
+
export * from "./ai"
|
|
22
|
+
export * from "./local"
|
|
23
|
+
export * from "./transcript"
|
|
24
|
+
export * from "./engine"
|
package/src/local.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { AIProvider, ChatMessage, ChatVisitor } from "./types"
|
|
2
|
+
import { buildSupportSystemPrompt, parseAIReply, toChatTurns } from "./prompt"
|
|
3
|
+
|
|
4
|
+
export interface LocalProviderConfig {
|
|
5
|
+
/**
|
|
6
|
+
* OpenAI-compatible base URL (ending in `/v1`). Works with any server that
|
|
7
|
+
* speaks the Chat Completions API:
|
|
8
|
+
* - Ollama → http://localhost:11434/v1
|
|
9
|
+
* - LM Studio → http://localhost:1234/v1
|
|
10
|
+
* - llama.cpp → http://localhost:8080/v1
|
|
11
|
+
* - vLLM / TGI → http://localhost:8000/v1
|
|
12
|
+
* From inside a Docker container, point at the host (e.g.
|
|
13
|
+
* http://host.docker.internal:11434/v1) or run the model in the same network.
|
|
14
|
+
*/
|
|
15
|
+
baseUrl: string
|
|
16
|
+
/** Model name as the server knows it (e.g. "llama3.1:8b", "qwen2.5:7b-instruct"). */
|
|
17
|
+
model: string
|
|
18
|
+
/** Optional bearer token (some servers require any non-empty value). */
|
|
19
|
+
apiKey?: string
|
|
20
|
+
knowledgeBase: string
|
|
21
|
+
brand?: string
|
|
22
|
+
systemPromptExtra?: string
|
|
23
|
+
maxTokens?: number
|
|
24
|
+
/** Low by default for reliable JSON. */
|
|
25
|
+
temperature?: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Local / self-hosted first-line responder over the OpenAI-compatible Chat
|
|
30
|
+
* Completions API. Same persona + JSON contract as {@link AnthropicProvider};
|
|
31
|
+
* keeps all chat data on your own infrastructure with no per-token cost.
|
|
32
|
+
*
|
|
33
|
+
* Smaller local models can be shakier at strict JSON, but `parseAIReply` falls
|
|
34
|
+
* back to treating the whole response as the reply, so a chat never breaks.
|
|
35
|
+
*/
|
|
36
|
+
export class LocalProvider implements AIProvider {
|
|
37
|
+
private readonly endpoint: string
|
|
38
|
+
|
|
39
|
+
constructor(private readonly cfg: LocalProviderConfig) {
|
|
40
|
+
this.endpoint = `${cfg.baseUrl.replace(/\/+$/, "")}/chat/completions`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async reply(history: ChatMessage[], visitor: ChatVisitor) {
|
|
44
|
+
const turns = toChatTurns(history)
|
|
45
|
+
if (turns.length === 0 || turns[0].role !== "user") {
|
|
46
|
+
return { text: "Thanks for reaching out — how can I help?", escalate: false }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const res = await fetch(this.endpoint, {
|
|
50
|
+
method: "POST",
|
|
51
|
+
headers: {
|
|
52
|
+
"Content-Type": "application/json",
|
|
53
|
+
...(this.cfg.apiKey ? { Authorization: `Bearer ${this.cfg.apiKey}` } : {}),
|
|
54
|
+
},
|
|
55
|
+
body: JSON.stringify({
|
|
56
|
+
model: this.cfg.model,
|
|
57
|
+
max_tokens: this.cfg.maxTokens ?? 1024,
|
|
58
|
+
temperature: this.cfg.temperature ?? 0.2,
|
|
59
|
+
stream: false,
|
|
60
|
+
messages: [
|
|
61
|
+
{ role: "system", content: buildSupportSystemPrompt(this.cfg, visitor) },
|
|
62
|
+
...turns,
|
|
63
|
+
],
|
|
64
|
+
}),
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
if (!res.ok) {
|
|
68
|
+
throw new Error(`local LLM request failed: ${res.status} ${res.statusText}`)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const data = (await res.json()) as {
|
|
72
|
+
choices?: Array<{ message?: { content?: string } }>
|
|
73
|
+
}
|
|
74
|
+
const raw = data.choices?.[0]?.message?.content?.trim() ?? ""
|
|
75
|
+
return parseAIReply(raw)
|
|
76
|
+
}
|
|
77
|
+
}
|
package/src/next.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Next.js App Router adapter. Wire these into `route.ts` files:
|
|
3
|
+
*
|
|
4
|
+
* // src/app/api/chat/route.ts
|
|
5
|
+
* import { routes } from "@/lib/chat/config"
|
|
6
|
+
* export const runtime = "nodejs"
|
|
7
|
+
* export const POST = routes.chat.POST
|
|
8
|
+
*
|
|
9
|
+
* Uses only the Web `Request`/`Response` APIs, so it isn't tied to Next beyond
|
|
10
|
+
* the file convention.
|
|
11
|
+
*/
|
|
12
|
+
import { ChatEngine, ChatSessionNotFound } from "./engine"
|
|
13
|
+
import type { ChatVisitor } from "./types"
|
|
14
|
+
|
|
15
|
+
type EngineResolver = () => ChatEngine | Promise<ChatEngine>
|
|
16
|
+
|
|
17
|
+
export interface ChatRouteOptions {
|
|
18
|
+
/** Shared secret to verify inbound transport webhooks. */
|
|
19
|
+
webhookSecret?: string
|
|
20
|
+
/** Header that carries the secret. Defaults to Telegram's. */
|
|
21
|
+
webhookSecretHeader?: string
|
|
22
|
+
/** Max length of a single visitor message. */
|
|
23
|
+
maxMessageLength?: number
|
|
24
|
+
/**
|
|
25
|
+
* Derive the visitor server-side (e.g. from the auth session). When set, the
|
|
26
|
+
* `start` action uses this identity and IGNORES any client-sent name/email —
|
|
27
|
+
* use it for authenticated apps so identity can't be spoofed. Return null to
|
|
28
|
+
* reject the request (401). A client-sent `topic` is still honoured.
|
|
29
|
+
*/
|
|
30
|
+
resolveVisitor?: (req: Request) => Promise<{ name: string; email: string } | null>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const DEFAULT_SECRET_HEADER = "x-telegram-bot-api-secret-token"
|
|
34
|
+
const DEFAULT_MAX_LEN = 4000
|
|
35
|
+
|
|
36
|
+
export function createChatRoutes(
|
|
37
|
+
getEngine: EngineResolver,
|
|
38
|
+
options: ChatRouteOptions = {}
|
|
39
|
+
) {
|
|
40
|
+
return {
|
|
41
|
+
/** POST /api/chat — { action: "start" | "send", ... } */
|
|
42
|
+
chat: { POST: (req: Request) => handleChat(getEngine, req, options) },
|
|
43
|
+
/** GET /api/chat/poll?sessionId=..&after=.. */
|
|
44
|
+
poll: { GET: (req: Request) => handlePoll(getEngine, req) },
|
|
45
|
+
/** POST /api/chat/telegram — inbound transport webhook */
|
|
46
|
+
webhook: { POST: (req: Request) => handleWebhook(getEngine, req, options) },
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function handleChat(
|
|
51
|
+
getEngine: EngineResolver,
|
|
52
|
+
req: Request,
|
|
53
|
+
options: ChatRouteOptions
|
|
54
|
+
): Promise<Response> {
|
|
55
|
+
let body: unknown
|
|
56
|
+
try {
|
|
57
|
+
body = await req.json()
|
|
58
|
+
} catch {
|
|
59
|
+
return json({ error: "invalid JSON" }, 400)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const action = (body as { action?: string })?.action
|
|
63
|
+
const engine = await getEngine()
|
|
64
|
+
const maxLen = options.maxMessageLength ?? DEFAULT_MAX_LEN
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
if (action === "start") {
|
|
68
|
+
let visitor = normalizeVisitor((body as { visitor?: unknown }).visitor, maxLen)
|
|
69
|
+
// Server-trusted identity wins over anything the client sent.
|
|
70
|
+
if (options.resolveVisitor) {
|
|
71
|
+
const resolved = await options.resolveVisitor(req)
|
|
72
|
+
if (!resolved) return json({ error: "unauthorized" }, 401)
|
|
73
|
+
visitor = {
|
|
74
|
+
name: resolved.name,
|
|
75
|
+
email: resolved.email,
|
|
76
|
+
topic: visitor?.topic,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (!visitor) return json({ error: "name and a valid email are required" }, 400)
|
|
80
|
+
const { sessionId } = await engine.startSession(visitor)
|
|
81
|
+
return json({ sessionId })
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (action === "send") {
|
|
85
|
+
const { sessionId, text } = body as { sessionId?: unknown; text?: unknown }
|
|
86
|
+
if (typeof sessionId !== "string" || !sessionId) {
|
|
87
|
+
return json({ error: "sessionId required" }, 400)
|
|
88
|
+
}
|
|
89
|
+
if (typeof text !== "string" || !text.trim()) {
|
|
90
|
+
return json({ error: "text required" }, 400)
|
|
91
|
+
}
|
|
92
|
+
const result = await engine.handleVisitorMessage(sessionId, text.slice(0, maxLen))
|
|
93
|
+
return json({ status: result.session.status, messages: result.newMessages })
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (action === "email") {
|
|
97
|
+
const { sessionId } = body as { sessionId?: unknown }
|
|
98
|
+
if (typeof sessionId !== "string" || !sessionId) {
|
|
99
|
+
return json({ error: "sessionId required" }, 400)
|
|
100
|
+
}
|
|
101
|
+
const ok = await engine.emailTranscript(sessionId)
|
|
102
|
+
return json({ ok })
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (action === "end") {
|
|
106
|
+
const { sessionId } = body as { sessionId?: unknown }
|
|
107
|
+
if (typeof sessionId !== "string" || !sessionId) {
|
|
108
|
+
return json({ error: "sessionId required" }, 400)
|
|
109
|
+
}
|
|
110
|
+
await engine.abandonSession(sessionId)
|
|
111
|
+
return json({ ok: true })
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return json({ error: "unknown action" }, 400)
|
|
115
|
+
} catch (err) {
|
|
116
|
+
if (err instanceof ChatSessionNotFound) return json({ error: "session not found" }, 404)
|
|
117
|
+
console.error("[chatkit] chat route error:", err)
|
|
118
|
+
return json({ error: "internal error" }, 500)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function handlePoll(getEngine: EngineResolver, req: Request): Promise<Response> {
|
|
123
|
+
const url = new URL(req.url)
|
|
124
|
+
const sessionId = url.searchParams.get("sessionId")
|
|
125
|
+
const after = Number(url.searchParams.get("after") ?? "0") || 0
|
|
126
|
+
if (!sessionId) return json({ error: "sessionId required" }, 400)
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
const engine = await getEngine()
|
|
130
|
+
const result = await engine.poll(sessionId, after)
|
|
131
|
+
return json(result)
|
|
132
|
+
} catch (err) {
|
|
133
|
+
if (err instanceof ChatSessionNotFound) return json({ error: "session not found" }, 404)
|
|
134
|
+
console.error("[chatkit] poll route error:", err)
|
|
135
|
+
return json({ error: "internal error" }, 500)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function handleWebhook(
|
|
140
|
+
getEngine: EngineResolver,
|
|
141
|
+
req: Request,
|
|
142
|
+
options: ChatRouteOptions
|
|
143
|
+
): Promise<Response> {
|
|
144
|
+
if (options.webhookSecret) {
|
|
145
|
+
const header = options.webhookSecretHeader ?? DEFAULT_SECRET_HEADER
|
|
146
|
+
if (req.headers.get(header) !== options.webhookSecret) {
|
|
147
|
+
return json({ ok: false }, 401)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let update: unknown
|
|
152
|
+
try {
|
|
153
|
+
update = await req.json()
|
|
154
|
+
} catch {
|
|
155
|
+
// Always 200 so the transport doesn't retry a malformed delivery forever.
|
|
156
|
+
return json({ ok: true })
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
const engine = await getEngine()
|
|
161
|
+
await engine.handleWebhookUpdate(update)
|
|
162
|
+
} catch (err) {
|
|
163
|
+
console.error("[chatkit] webhook route error:", err)
|
|
164
|
+
}
|
|
165
|
+
return json({ ok: true })
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function normalizeVisitor(input: unknown, maxLen: number): ChatVisitor | null {
|
|
169
|
+
if (!input || typeof input !== "object") return null
|
|
170
|
+
const { name, email, topic } = input as Record<string, unknown>
|
|
171
|
+
if (typeof name !== "string" || !name.trim()) return null
|
|
172
|
+
if (typeof email !== "string" || !isEmail(email)) return null
|
|
173
|
+
return {
|
|
174
|
+
name: name.trim().slice(0, 200),
|
|
175
|
+
email: email.trim().slice(0, 320),
|
|
176
|
+
topic: typeof topic === "string" && topic.trim() ? topic.trim().slice(0, maxLen) : undefined,
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function isEmail(s: string): boolean {
|
|
181
|
+
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(s.trim())
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function json(data: unknown, status = 200): Response {
|
|
185
|
+
return new Response(JSON.stringify(data), {
|
|
186
|
+
status,
|
|
187
|
+
headers: { "Content-Type": "application/json" },
|
|
188
|
+
})
|
|
189
|
+
}
|