@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/storage.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { ChatMessage, ChatSession, ChatRole } from "./types"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Persistence seam. The host app supplies a concrete implementation
|
|
5
|
+
* (e.g. Postgres/Prisma) so chatkit itself stays storage-agnostic.
|
|
6
|
+
*
|
|
7
|
+
* Sessions and the agent↔visitor relay are inherently async and survive
|
|
8
|
+
* across requests/cold-starts, so a real adapter must be durable — the
|
|
9
|
+
* in-memory adapter below is for local dev and the OSS demo only.
|
|
10
|
+
*/
|
|
11
|
+
export interface ChatStorage {
|
|
12
|
+
createSession(session: ChatSession): Promise<void>
|
|
13
|
+
getSession(id: string): Promise<ChatSession | null>
|
|
14
|
+
/** Map an inbound transport thread back to its session. */
|
|
15
|
+
getSessionByThread(threadId: string): Promise<ChatSession | null>
|
|
16
|
+
/**
|
|
17
|
+
* Most recent thread id for a visitor email (case-insensitive), or null.
|
|
18
|
+
* Lets the engine reuse one topic per customer instead of spawning a new one
|
|
19
|
+
* each time they start a chat.
|
|
20
|
+
*/
|
|
21
|
+
getThreadByEmail(email: string): Promise<string | null>
|
|
22
|
+
updateSession(id: string, patch: Partial<ChatSession>): Promise<void>
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Non-closed sessions whose visitor hasn't been seen since `beforeTs`
|
|
26
|
+
* (using lastSeenAt, falling back to createdAt). Used to find disconnected
|
|
27
|
+
* visitors who may have unseen replies to email.
|
|
28
|
+
*/
|
|
29
|
+
getSessionsIdleSince(beforeTs: number): Promise<ChatSession[]>
|
|
30
|
+
|
|
31
|
+
appendMessage(message: ChatMessage): Promise<void>
|
|
32
|
+
getMessages(
|
|
33
|
+
sessionId: string,
|
|
34
|
+
opts?: { after?: number; roles?: ChatRole[] }
|
|
35
|
+
): Promise<ChatMessage[]>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Reference adapter — keeps everything in process memory. Fine for `next dev`
|
|
40
|
+
* on a single instance and for trying the OSS demo; do NOT use in production
|
|
41
|
+
* (state is lost on restart and not shared across instances).
|
|
42
|
+
*/
|
|
43
|
+
export class InMemoryStorage implements ChatStorage {
|
|
44
|
+
private sessions = new Map<string, ChatSession>()
|
|
45
|
+
private threadIndex = new Map<string, string>()
|
|
46
|
+
private messages = new Map<string, ChatMessage[]>()
|
|
47
|
+
|
|
48
|
+
async createSession(session: ChatSession): Promise<void> {
|
|
49
|
+
this.sessions.set(session.id, { ...session })
|
|
50
|
+
if (session.threadId != null) {
|
|
51
|
+
this.threadIndex.set(session.threadId, session.id)
|
|
52
|
+
}
|
|
53
|
+
if (!this.messages.has(session.id)) this.messages.set(session.id, [])
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async getSession(id: string): Promise<ChatSession | null> {
|
|
57
|
+
const s = this.sessions.get(id)
|
|
58
|
+
return s ? { ...s } : null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async getSessionByThread(threadId: string): Promise<ChatSession | null> {
|
|
62
|
+
const id = this.threadIndex.get(threadId)
|
|
63
|
+
return id ? this.getSession(id) : null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async getThreadByEmail(email: string): Promise<string | null> {
|
|
67
|
+
const target = email.trim().toLowerCase()
|
|
68
|
+
let latest: ChatSession | null = null
|
|
69
|
+
for (const s of this.sessions.values()) {
|
|
70
|
+
if (s.threadId && s.visitor.email.trim().toLowerCase() === target) {
|
|
71
|
+
if (!latest || s.createdAt > latest.createdAt) latest = s
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return latest?.threadId ?? null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async updateSession(id: string, patch: Partial<ChatSession>): Promise<void> {
|
|
78
|
+
const existing = this.sessions.get(id)
|
|
79
|
+
if (!existing) return
|
|
80
|
+
const next = { ...existing, ...patch, updatedAt: Date.now() }
|
|
81
|
+
this.sessions.set(id, next)
|
|
82
|
+
if (patch.threadId != null) {
|
|
83
|
+
this.threadIndex.set(patch.threadId, id)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async getSessionsIdleSince(beforeTs: number): Promise<ChatSession[]> {
|
|
88
|
+
const out: ChatSession[] = []
|
|
89
|
+
for (const s of this.sessions.values()) {
|
|
90
|
+
if (s.status === "closed") continue
|
|
91
|
+
if ((s.lastSeenAt ?? s.createdAt) < beforeTs) out.push({ ...s })
|
|
92
|
+
}
|
|
93
|
+
return out
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async appendMessage(message: ChatMessage): Promise<void> {
|
|
97
|
+
const list = this.messages.get(message.sessionId) ?? []
|
|
98
|
+
list.push({ ...message })
|
|
99
|
+
this.messages.set(message.sessionId, list)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async getMessages(
|
|
103
|
+
sessionId: string,
|
|
104
|
+
opts?: { after?: number; roles?: ChatRole[] }
|
|
105
|
+
): Promise<ChatMessage[]> {
|
|
106
|
+
let list = this.messages.get(sessionId) ?? []
|
|
107
|
+
if (opts?.after != null) list = list.filter((m) => m.createdAt > opts.after!)
|
|
108
|
+
if (opts?.roles) list = list.filter((m) => opts.roles!.includes(m.role))
|
|
109
|
+
return list.map((m) => ({ ...m }))
|
|
110
|
+
}
|
|
111
|
+
}
|
package/src/telegram.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { ChatTransport } from "./transport"
|
|
2
|
+
import type { ParsedAgentReply } from "./types"
|
|
3
|
+
|
|
4
|
+
export interface TelegramConfig {
|
|
5
|
+
/** Bot token from @BotFather (use a DEDICATED bot for chat, not your ops bot). */
|
|
6
|
+
botToken: string
|
|
7
|
+
/**
|
|
8
|
+
* Chat id of the support group. Must be a *forum* supergroup (Topics enabled)
|
|
9
|
+
* and the bot must be an admin with "Manage Topics" permission, otherwise
|
|
10
|
+
* `createForumTopic` fails.
|
|
11
|
+
*/
|
|
12
|
+
chatId: string | number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Telegram transport. v1 of chatkit's "human handoff" surface — designed
|
|
17
|
+
* behind a thin interface so other transports (Slack, Discord, email) can be
|
|
18
|
+
* added later without touching the engine.
|
|
19
|
+
*
|
|
20
|
+
* Each chat session gets its own forum topic; the agent replies inside that
|
|
21
|
+
* topic and the webhook maps `message_thread_id` back to the session.
|
|
22
|
+
*/
|
|
23
|
+
export class TelegramTransport implements ChatTransport {
|
|
24
|
+
constructor(private readonly cfg: TelegramConfig) {}
|
|
25
|
+
|
|
26
|
+
private async call<T = unknown>(
|
|
27
|
+
method: string,
|
|
28
|
+
body: Record<string, unknown>
|
|
29
|
+
): Promise<T> {
|
|
30
|
+
const res = await fetch(
|
|
31
|
+
`https://api.telegram.org/bot${this.cfg.botToken}/${method}`,
|
|
32
|
+
{
|
|
33
|
+
method: "POST",
|
|
34
|
+
headers: { "Content-Type": "application/json" },
|
|
35
|
+
body: JSON.stringify({ chat_id: this.cfg.chatId, ...body }),
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
const data = (await res.json().catch(() => ({}))) as {
|
|
39
|
+
ok?: boolean
|
|
40
|
+
result?: T
|
|
41
|
+
description?: string
|
|
42
|
+
}
|
|
43
|
+
if (!data.ok) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
`Telegram ${method} failed: ${data.description ?? res.status}`
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
return data.result as T
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Create a forum topic and return its `message_thread_id` (as a string). */
|
|
52
|
+
async createTopic(name: string): Promise<string> {
|
|
53
|
+
// Telegram truncates topic names at 128 chars.
|
|
54
|
+
const result = await this.call<{ message_thread_id: number }>(
|
|
55
|
+
"createForumTopic",
|
|
56
|
+
{ name: name.slice(0, 128) }
|
|
57
|
+
)
|
|
58
|
+
return String(result.message_thread_id)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Post a message into a topic. Best-effort: never throws to the caller. */
|
|
62
|
+
async sendMessage(threadId: string, text: string): Promise<void> {
|
|
63
|
+
try {
|
|
64
|
+
await this.call("sendMessage", {
|
|
65
|
+
message_thread_id: Number(threadId),
|
|
66
|
+
text,
|
|
67
|
+
parse_mode: "HTML",
|
|
68
|
+
disable_web_page_preview: true,
|
|
69
|
+
})
|
|
70
|
+
} catch (err) {
|
|
71
|
+
console.error("[chatkit] telegram sendMessage failed:", err)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Close a topic when a conversation ends (keeps the group tidy). */
|
|
76
|
+
async closeTopic(threadId: string): Promise<void> {
|
|
77
|
+
try {
|
|
78
|
+
await this.call("closeForumTopic", { message_thread_id: Number(threadId) })
|
|
79
|
+
} catch (err) {
|
|
80
|
+
console.error("[chatkit] telegram closeForumTopic failed:", err)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Reopen a closed topic (so a returning visitor reuses the same thread). */
|
|
85
|
+
async reopenTopic(threadId: string): Promise<void> {
|
|
86
|
+
try {
|
|
87
|
+
await this.call("reopenForumTopic", { message_thread_id: Number(threadId) })
|
|
88
|
+
} catch {
|
|
89
|
+
// Already open, or topic gone — non-fatal.
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Parse an inbound webhook update into an agent reply, or `null` if it isn't
|
|
95
|
+
* a relevant human message. Bots don't receive their own messages, so AI/bot
|
|
96
|
+
* echoes never come back through here. Service messages (topic created, etc.)
|
|
97
|
+
* and non-topic chatter are ignored.
|
|
98
|
+
*/
|
|
99
|
+
parseUpdate(update: unknown): ParsedAgentReply | null {
|
|
100
|
+
const msg = (update as { message?: TelegramMessage })?.message
|
|
101
|
+
if (!msg) return null
|
|
102
|
+
if (msg.message_thread_id == null) return null // not inside a topic
|
|
103
|
+
if (msg.from?.is_bot) return null
|
|
104
|
+
if (msg.forum_topic_created || msg.forum_topic_closed) return null
|
|
105
|
+
|
|
106
|
+
const text = (msg.text ?? msg.caption ?? "").trim()
|
|
107
|
+
if (!text) return null
|
|
108
|
+
|
|
109
|
+
const fromName =
|
|
110
|
+
[msg.from?.first_name, msg.from?.last_name].filter(Boolean).join(" ") ||
|
|
111
|
+
msg.from?.username
|
|
112
|
+
|
|
113
|
+
const threadId = String(msg.message_thread_id)
|
|
114
|
+
const lower = text.toLowerCase()
|
|
115
|
+
if (lower === "/close" || lower === "/end") {
|
|
116
|
+
return { threadId, text, fromName, command: "close" }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { threadId, text, fromName }
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
interface TelegramMessage {
|
|
124
|
+
message_thread_id?: number
|
|
125
|
+
text?: string
|
|
126
|
+
caption?: string
|
|
127
|
+
forum_topic_created?: unknown
|
|
128
|
+
forum_topic_closed?: unknown
|
|
129
|
+
from?: {
|
|
130
|
+
is_bot?: boolean
|
|
131
|
+
first_name?: string
|
|
132
|
+
last_name?: string
|
|
133
|
+
username?: string
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { ChatMessage, ChatSession } from "./types"
|
|
2
|
+
|
|
3
|
+
export interface TranscriptOptions {
|
|
4
|
+
/** Brand name used for the AI's label + email copy. */
|
|
5
|
+
brand?: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface RenderedTranscript {
|
|
9
|
+
subject: string
|
|
10
|
+
html: string
|
|
11
|
+
text: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Render a chat session into an email-ready transcript (subject + HTML + plain
|
|
16
|
+
* text). Pure and host-agnostic — the host wires it to its own mailer.
|
|
17
|
+
*/
|
|
18
|
+
export function renderTranscript(
|
|
19
|
+
session: ChatSession,
|
|
20
|
+
messages: ChatMessage[],
|
|
21
|
+
opts: TranscriptOptions = {}
|
|
22
|
+
): RenderedTranscript {
|
|
23
|
+
const brand = opts.brand ?? "Support"
|
|
24
|
+
const subject = `Your chat with ${brand}`
|
|
25
|
+
|
|
26
|
+
const rows = messages
|
|
27
|
+
.filter((m) => m.text && m.text.trim())
|
|
28
|
+
.map((m) => ({
|
|
29
|
+
role: m.role,
|
|
30
|
+
who:
|
|
31
|
+
m.role === "visitor"
|
|
32
|
+
? session.visitor.name
|
|
33
|
+
: m.role === "agent"
|
|
34
|
+
? m.authorName || "Support"
|
|
35
|
+
: m.role === "ai"
|
|
36
|
+
? brand
|
|
37
|
+
: "",
|
|
38
|
+
text: m.text,
|
|
39
|
+
}))
|
|
40
|
+
|
|
41
|
+
const htmlRows = rows
|
|
42
|
+
.map((r) =>
|
|
43
|
+
r.role === "system"
|
|
44
|
+
? `<p style="margin:8px 0;color:#888;font-size:13px;font-style:italic">${esc(r.text)}</p>`
|
|
45
|
+
: `<p style="margin:10px 0"><strong>${esc(r.who)}:</strong> ${esc(r.text)}</p>`
|
|
46
|
+
)
|
|
47
|
+
.join("")
|
|
48
|
+
|
|
49
|
+
const html =
|
|
50
|
+
`<div style="font-family:Arial,Helvetica,sans-serif;max-width:560px;margin:0 auto;color:#0f172a">` +
|
|
51
|
+
`<h2 style="margin:0 0 4px">${esc(subject)}</h2>` +
|
|
52
|
+
`<p style="color:#475569;margin:0 0 16px">Here's a copy of your conversation with ${esc(brand)}.</p>` +
|
|
53
|
+
`<hr style="border:none;border-top:1px solid #e2e8f0"/>` +
|
|
54
|
+
htmlRows +
|
|
55
|
+
`<hr style="border:none;border-top:1px solid #e2e8f0"/>` +
|
|
56
|
+
`<p style="color:#94a3b8;font-size:12px">Sent to ${esc(session.visitor.email)}.</p>` +
|
|
57
|
+
`</div>`
|
|
58
|
+
|
|
59
|
+
const text =
|
|
60
|
+
`${subject}\n\n` +
|
|
61
|
+
rows.map((r) => (r.role === "system" ? `(${r.text})` : `${r.who}: ${r.text}`)).join("\n")
|
|
62
|
+
|
|
63
|
+
return { subject, html, text }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Render a "you have new replies" email for a visitor who disconnected before
|
|
68
|
+
* seeing the agent/AI reply(s). `replyUrl`, if given, links back to the chat.
|
|
69
|
+
*/
|
|
70
|
+
export function renderReplyNotification(
|
|
71
|
+
session: ChatSession,
|
|
72
|
+
unseen: ChatMessage[],
|
|
73
|
+
opts: TranscriptOptions & { replyUrl?: string } = {}
|
|
74
|
+
): RenderedTranscript {
|
|
75
|
+
const brand = opts.brand ?? "Support"
|
|
76
|
+
const subject = `New reply from ${brand}`
|
|
77
|
+
|
|
78
|
+
const rows = unseen
|
|
79
|
+
.filter((m) => m.text && m.text.trim())
|
|
80
|
+
.map((m) => ({
|
|
81
|
+
who: m.role === "agent" ? m.authorName || "Support" : brand,
|
|
82
|
+
text: m.text,
|
|
83
|
+
}))
|
|
84
|
+
|
|
85
|
+
const htmlRows = rows
|
|
86
|
+
.map(
|
|
87
|
+
(r) => `<p style="margin:10px 0"><strong>${esc(r.who)}:</strong> ${esc(r.text)}</p>`
|
|
88
|
+
)
|
|
89
|
+
.join("")
|
|
90
|
+
|
|
91
|
+
const link = opts.replyUrl
|
|
92
|
+
? `<p style="margin:16px 0"><a href="${esc(opts.replyUrl)}" style="color:#0f172a">Reply to continue the conversation →</a></p>`
|
|
93
|
+
: ""
|
|
94
|
+
|
|
95
|
+
const html =
|
|
96
|
+
`<div style="font-family:Arial,Helvetica,sans-serif;max-width:560px;margin:0 auto;color:#0f172a">` +
|
|
97
|
+
`<h2 style="margin:0 0 4px">${esc(subject)}</h2>` +
|
|
98
|
+
`<p style="color:#475569;margin:0 0 16px">Hi ${esc(session.visitor.name)}, you have a new reply to your chat:</p>` +
|
|
99
|
+
`<hr style="border:none;border-top:1px solid #e2e8f0"/>` +
|
|
100
|
+
htmlRows +
|
|
101
|
+
link +
|
|
102
|
+
`<hr style="border:none;border-top:1px solid #e2e8f0"/>` +
|
|
103
|
+
`<p style="color:#94a3b8;font-size:12px">Sent to ${esc(session.visitor.email)} because you left the chat before this reply arrived.</p>` +
|
|
104
|
+
`</div>`
|
|
105
|
+
|
|
106
|
+
const text =
|
|
107
|
+
`${subject}\n\nHi ${session.visitor.name}, you have a new reply:\n\n` +
|
|
108
|
+
rows.map((r) => `${r.who}: ${r.text}`).join("\n") +
|
|
109
|
+
(opts.replyUrl ? `\n\nReply: ${opts.replyUrl}` : "")
|
|
110
|
+
|
|
111
|
+
return { subject, html, text }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function esc(s: string): string {
|
|
115
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
116
|
+
}
|
package/src/transport.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ParsedAgentReply } from "./types"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The "human handoff" seam. A transport routes a conversation to wherever your
|
|
5
|
+
* team already works and relays their replies back.
|
|
6
|
+
*
|
|
7
|
+
* v1 ships {@link TelegramTransport}. The interface is deliberately small so a
|
|
8
|
+
* SlackTransport (thread per chat in a channel), DiscordTransport, or
|
|
9
|
+
* email/SMTP transport can be added without touching the engine. Thread ids are
|
|
10
|
+
* opaque strings so any backend's native id fits (Telegram `message_thread_id`,
|
|
11
|
+
* Slack `thread_ts`, etc.).
|
|
12
|
+
*/
|
|
13
|
+
export interface ChatTransport {
|
|
14
|
+
/** Create a thread/topic for a new chat and return its opaque id. */
|
|
15
|
+
createTopic(name: string): Promise<string>
|
|
16
|
+
/** Post a message into a thread. Should be best-effort (not throw). */
|
|
17
|
+
sendMessage(threadId: string, text: string): Promise<void>
|
|
18
|
+
/** Close/archive a thread when the conversation ends. */
|
|
19
|
+
closeTopic(threadId: string): Promise<void>
|
|
20
|
+
/** Reopen a previously closed thread (best-effort; no-op where not applicable). */
|
|
21
|
+
reopenTopic(threadId: string): Promise<void>
|
|
22
|
+
/** Parse an inbound webhook payload into an agent reply, or null if irrelevant. */
|
|
23
|
+
parseUpdate(update: unknown): ParsedAgentReply | null
|
|
24
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* chatkit — portable customer-chat module.
|
|
3
|
+
*
|
|
4
|
+
* Core domain types. Nothing in this folder imports from the host app
|
|
5
|
+
* (`@/...`); it depends only on npm packages + web/node built-ins, so the
|
|
6
|
+
* whole `src/chatkit` directory can be lifted out into its own package.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Who authored a message. */
|
|
10
|
+
export type ChatRole = "visitor" | "ai" | "agent" | "system"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Lifecycle of a conversation.
|
|
14
|
+
* - `ai` — the AI first-line is handling the visitor.
|
|
15
|
+
* - `live` — a human agent has taken over (AI stays quiet).
|
|
16
|
+
* - `closed` — the conversation has ended.
|
|
17
|
+
*/
|
|
18
|
+
export type ChatStatus = "ai" | "live" | "closed"
|
|
19
|
+
|
|
20
|
+
/** Details captured by the pre-chat form before messaging starts. */
|
|
21
|
+
export interface ChatVisitor {
|
|
22
|
+
name: string
|
|
23
|
+
email: string
|
|
24
|
+
/** Optional "what do you need help with?" from the pre-chat form. */
|
|
25
|
+
topic?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ChatMessage {
|
|
29
|
+
id: string
|
|
30
|
+
sessionId: string
|
|
31
|
+
role: ChatRole
|
|
32
|
+
text: string
|
|
33
|
+
/** Display name of the author (for agent replies — the staff member's name). */
|
|
34
|
+
authorName?: string
|
|
35
|
+
/** Epoch milliseconds. */
|
|
36
|
+
createdAt: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ChatSession {
|
|
40
|
+
/** Opaque public id (uuid) — the client holds this to read/write its session. */
|
|
41
|
+
id: string
|
|
42
|
+
visitor: ChatVisitor
|
|
43
|
+
/**
|
|
44
|
+
* Transport thread id, once the thread/topic is created. Opaque string so any
|
|
45
|
+
* backend fits (Telegram `message_thread_id`, Slack `thread_ts`, …).
|
|
46
|
+
*/
|
|
47
|
+
threadId: string | null
|
|
48
|
+
status: ChatStatus
|
|
49
|
+
createdAt: number
|
|
50
|
+
updatedAt: number
|
|
51
|
+
/** Last time the visitor's widget polled/heartbeat — used to detect disconnect. */
|
|
52
|
+
lastSeenAt?: number
|
|
53
|
+
/** Timestamp up to which unseen replies have been emailed (avoids re-sending). */
|
|
54
|
+
notifiedAt?: number
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A human agent's reply parsed from an inbound transport webhook update. */
|
|
58
|
+
export interface ParsedAgentReply {
|
|
59
|
+
threadId: string
|
|
60
|
+
text: string
|
|
61
|
+
/** Display name of the agent who replied, if available. */
|
|
62
|
+
fromName?: string
|
|
63
|
+
/** True when the message is a control command (e.g. `/close`). */
|
|
64
|
+
command?: "close"
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Result of an AI first-line turn. */
|
|
68
|
+
export interface AIReply {
|
|
69
|
+
/** The text to show the visitor. */
|
|
70
|
+
text: string
|
|
71
|
+
/** True when the AI wants a human to take over. */
|
|
72
|
+
escalate: boolean
|
|
73
|
+
/** Short internal note on why it escalated (shown to agents, not the visitor). */
|
|
74
|
+
reason?: string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Optional first-line layer. Implement this to plug in a different LLM —
|
|
79
|
+
* cloud ({@link AnthropicProvider}) or local/self-hosted ({@link LocalProvider}).
|
|
80
|
+
*/
|
|
81
|
+
export interface AIProvider {
|
|
82
|
+
reply(history: ChatMessage[], visitor: ChatVisitor): Promise<AIReply>
|
|
83
|
+
}
|