@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/dist/storage.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reference adapter — keeps everything in process memory. Fine for `next dev`
|
|
3
|
+
* on a single instance and for trying the OSS demo; do NOT use in production
|
|
4
|
+
* (state is lost on restart and not shared across instances).
|
|
5
|
+
*/
|
|
6
|
+
export class InMemoryStorage {
|
|
7
|
+
constructor() {
|
|
8
|
+
this.sessions = new Map();
|
|
9
|
+
this.threadIndex = new Map();
|
|
10
|
+
this.messages = new Map();
|
|
11
|
+
}
|
|
12
|
+
async createSession(session) {
|
|
13
|
+
this.sessions.set(session.id, { ...session });
|
|
14
|
+
if (session.threadId != null) {
|
|
15
|
+
this.threadIndex.set(session.threadId, session.id);
|
|
16
|
+
}
|
|
17
|
+
if (!this.messages.has(session.id))
|
|
18
|
+
this.messages.set(session.id, []);
|
|
19
|
+
}
|
|
20
|
+
async getSession(id) {
|
|
21
|
+
const s = this.sessions.get(id);
|
|
22
|
+
return s ? { ...s } : null;
|
|
23
|
+
}
|
|
24
|
+
async getSessionByThread(threadId) {
|
|
25
|
+
const id = this.threadIndex.get(threadId);
|
|
26
|
+
return id ? this.getSession(id) : null;
|
|
27
|
+
}
|
|
28
|
+
async getThreadByEmail(email) {
|
|
29
|
+
const target = email.trim().toLowerCase();
|
|
30
|
+
let latest = null;
|
|
31
|
+
for (const s of this.sessions.values()) {
|
|
32
|
+
if (s.threadId && s.visitor.email.trim().toLowerCase() === target) {
|
|
33
|
+
if (!latest || s.createdAt > latest.createdAt)
|
|
34
|
+
latest = s;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return latest?.threadId ?? null;
|
|
38
|
+
}
|
|
39
|
+
async updateSession(id, patch) {
|
|
40
|
+
const existing = this.sessions.get(id);
|
|
41
|
+
if (!existing)
|
|
42
|
+
return;
|
|
43
|
+
const next = { ...existing, ...patch, updatedAt: Date.now() };
|
|
44
|
+
this.sessions.set(id, next);
|
|
45
|
+
if (patch.threadId != null) {
|
|
46
|
+
this.threadIndex.set(patch.threadId, id);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async getSessionsIdleSince(beforeTs) {
|
|
50
|
+
const out = [];
|
|
51
|
+
for (const s of this.sessions.values()) {
|
|
52
|
+
if (s.status === "closed")
|
|
53
|
+
continue;
|
|
54
|
+
if ((s.lastSeenAt ?? s.createdAt) < beforeTs)
|
|
55
|
+
out.push({ ...s });
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
async appendMessage(message) {
|
|
60
|
+
const list = this.messages.get(message.sessionId) ?? [];
|
|
61
|
+
list.push({ ...message });
|
|
62
|
+
this.messages.set(message.sessionId, list);
|
|
63
|
+
}
|
|
64
|
+
async getMessages(sessionId, opts) {
|
|
65
|
+
let list = this.messages.get(sessionId) ?? [];
|
|
66
|
+
if (opts?.after != null)
|
|
67
|
+
list = list.filter((m) => m.createdAt > opts.after);
|
|
68
|
+
if (opts?.roles)
|
|
69
|
+
list = list.filter((m) => opts.roles.includes(m.role));
|
|
70
|
+
return list.map((m) => ({ ...m }));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ChatTransport } from "./transport";
|
|
2
|
+
import type { ParsedAgentReply } from "./types";
|
|
3
|
+
export interface TelegramConfig {
|
|
4
|
+
/** Bot token from @BotFather (use a DEDICATED bot for chat, not your ops bot). */
|
|
5
|
+
botToken: string;
|
|
6
|
+
/**
|
|
7
|
+
* Chat id of the support group. Must be a *forum* supergroup (Topics enabled)
|
|
8
|
+
* and the bot must be an admin with "Manage Topics" permission, otherwise
|
|
9
|
+
* `createForumTopic` fails.
|
|
10
|
+
*/
|
|
11
|
+
chatId: string | number;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Telegram transport. v1 of chatkit's "human handoff" surface — designed
|
|
15
|
+
* behind a thin interface so other transports (Slack, Discord, email) can be
|
|
16
|
+
* added later without touching the engine.
|
|
17
|
+
*
|
|
18
|
+
* Each chat session gets its own forum topic; the agent replies inside that
|
|
19
|
+
* topic and the webhook maps `message_thread_id` back to the session.
|
|
20
|
+
*/
|
|
21
|
+
export declare class TelegramTransport implements ChatTransport {
|
|
22
|
+
private readonly cfg;
|
|
23
|
+
constructor(cfg: TelegramConfig);
|
|
24
|
+
private call;
|
|
25
|
+
/** Create a forum topic and return its `message_thread_id` (as a string). */
|
|
26
|
+
createTopic(name: string): Promise<string>;
|
|
27
|
+
/** Post a message into a topic. Best-effort: never throws to the caller. */
|
|
28
|
+
sendMessage(threadId: string, text: string): Promise<void>;
|
|
29
|
+
/** Close a topic when a conversation ends (keeps the group tidy). */
|
|
30
|
+
closeTopic(threadId: string): Promise<void>;
|
|
31
|
+
/** Reopen a closed topic (so a returning visitor reuses the same thread). */
|
|
32
|
+
reopenTopic(threadId: string): Promise<void>;
|
|
33
|
+
/**
|
|
34
|
+
* Parse an inbound webhook update into an agent reply, or `null` if it isn't
|
|
35
|
+
* a relevant human message. Bots don't receive their own messages, so AI/bot
|
|
36
|
+
* echoes never come back through here. Service messages (topic created, etc.)
|
|
37
|
+
* and non-topic chatter are ignored.
|
|
38
|
+
*/
|
|
39
|
+
parseUpdate(update: unknown): ParsedAgentReply | null;
|
|
40
|
+
}
|
package/dist/telegram.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram transport. v1 of chatkit's "human handoff" surface — designed
|
|
3
|
+
* behind a thin interface so other transports (Slack, Discord, email) can be
|
|
4
|
+
* added later without touching the engine.
|
|
5
|
+
*
|
|
6
|
+
* Each chat session gets its own forum topic; the agent replies inside that
|
|
7
|
+
* topic and the webhook maps `message_thread_id` back to the session.
|
|
8
|
+
*/
|
|
9
|
+
export class TelegramTransport {
|
|
10
|
+
constructor(cfg) {
|
|
11
|
+
this.cfg = cfg;
|
|
12
|
+
}
|
|
13
|
+
async call(method, body) {
|
|
14
|
+
const res = await fetch(`https://api.telegram.org/bot${this.cfg.botToken}/${method}`, {
|
|
15
|
+
method: "POST",
|
|
16
|
+
headers: { "Content-Type": "application/json" },
|
|
17
|
+
body: JSON.stringify({ chat_id: this.cfg.chatId, ...body }),
|
|
18
|
+
});
|
|
19
|
+
const data = (await res.json().catch(() => ({})));
|
|
20
|
+
if (!data.ok) {
|
|
21
|
+
throw new Error(`Telegram ${method} failed: ${data.description ?? res.status}`);
|
|
22
|
+
}
|
|
23
|
+
return data.result;
|
|
24
|
+
}
|
|
25
|
+
/** Create a forum topic and return its `message_thread_id` (as a string). */
|
|
26
|
+
async createTopic(name) {
|
|
27
|
+
// Telegram truncates topic names at 128 chars.
|
|
28
|
+
const result = await this.call("createForumTopic", { name: name.slice(0, 128) });
|
|
29
|
+
return String(result.message_thread_id);
|
|
30
|
+
}
|
|
31
|
+
/** Post a message into a topic. Best-effort: never throws to the caller. */
|
|
32
|
+
async sendMessage(threadId, text) {
|
|
33
|
+
try {
|
|
34
|
+
await this.call("sendMessage", {
|
|
35
|
+
message_thread_id: Number(threadId),
|
|
36
|
+
text,
|
|
37
|
+
parse_mode: "HTML",
|
|
38
|
+
disable_web_page_preview: true,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
console.error("[chatkit] telegram sendMessage failed:", err);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** Close a topic when a conversation ends (keeps the group tidy). */
|
|
46
|
+
async closeTopic(threadId) {
|
|
47
|
+
try {
|
|
48
|
+
await this.call("closeForumTopic", { message_thread_id: Number(threadId) });
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
console.error("[chatkit] telegram closeForumTopic failed:", err);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Reopen a closed topic (so a returning visitor reuses the same thread). */
|
|
55
|
+
async reopenTopic(threadId) {
|
|
56
|
+
try {
|
|
57
|
+
await this.call("reopenForumTopic", { message_thread_id: Number(threadId) });
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// Already open, or topic gone — non-fatal.
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Parse an inbound webhook update into an agent reply, or `null` if it isn't
|
|
65
|
+
* a relevant human message. Bots don't receive their own messages, so AI/bot
|
|
66
|
+
* echoes never come back through here. Service messages (topic created, etc.)
|
|
67
|
+
* and non-topic chatter are ignored.
|
|
68
|
+
*/
|
|
69
|
+
parseUpdate(update) {
|
|
70
|
+
const msg = update?.message;
|
|
71
|
+
if (!msg)
|
|
72
|
+
return null;
|
|
73
|
+
if (msg.message_thread_id == null)
|
|
74
|
+
return null; // not inside a topic
|
|
75
|
+
if (msg.from?.is_bot)
|
|
76
|
+
return null;
|
|
77
|
+
if (msg.forum_topic_created || msg.forum_topic_closed)
|
|
78
|
+
return null;
|
|
79
|
+
const text = (msg.text ?? msg.caption ?? "").trim();
|
|
80
|
+
if (!text)
|
|
81
|
+
return null;
|
|
82
|
+
const fromName = [msg.from?.first_name, msg.from?.last_name].filter(Boolean).join(" ") ||
|
|
83
|
+
msg.from?.username;
|
|
84
|
+
const threadId = String(msg.message_thread_id);
|
|
85
|
+
const lower = text.toLowerCase();
|
|
86
|
+
if (lower === "/close" || lower === "/end") {
|
|
87
|
+
return { threadId, text, fromName, command: "close" };
|
|
88
|
+
}
|
|
89
|
+
return { threadId, text, fromName };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ChatMessage, ChatSession } from "./types";
|
|
2
|
+
export interface TranscriptOptions {
|
|
3
|
+
/** Brand name used for the AI's label + email copy. */
|
|
4
|
+
brand?: string;
|
|
5
|
+
}
|
|
6
|
+
export interface RenderedTranscript {
|
|
7
|
+
subject: string;
|
|
8
|
+
html: string;
|
|
9
|
+
text: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Render a chat session into an email-ready transcript (subject + HTML + plain
|
|
13
|
+
* text). Pure and host-agnostic — the host wires it to its own mailer.
|
|
14
|
+
*/
|
|
15
|
+
export declare function renderTranscript(session: ChatSession, messages: ChatMessage[], opts?: TranscriptOptions): RenderedTranscript;
|
|
16
|
+
/**
|
|
17
|
+
* Render a "you have new replies" email for a visitor who disconnected before
|
|
18
|
+
* seeing the agent/AI reply(s). `replyUrl`, if given, links back to the chat.
|
|
19
|
+
*/
|
|
20
|
+
export declare function renderReplyNotification(session: ChatSession, unseen: ChatMessage[], opts?: TranscriptOptions & {
|
|
21
|
+
replyUrl?: string;
|
|
22
|
+
}): RenderedTranscript;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render a chat session into an email-ready transcript (subject + HTML + plain
|
|
3
|
+
* text). Pure and host-agnostic — the host wires it to its own mailer.
|
|
4
|
+
*/
|
|
5
|
+
export function renderTranscript(session, messages, opts = {}) {
|
|
6
|
+
const brand = opts.brand ?? "Support";
|
|
7
|
+
const subject = `Your chat with ${brand}`;
|
|
8
|
+
const rows = messages
|
|
9
|
+
.filter((m) => m.text && m.text.trim())
|
|
10
|
+
.map((m) => ({
|
|
11
|
+
role: m.role,
|
|
12
|
+
who: m.role === "visitor"
|
|
13
|
+
? session.visitor.name
|
|
14
|
+
: m.role === "agent"
|
|
15
|
+
? m.authorName || "Support"
|
|
16
|
+
: m.role === "ai"
|
|
17
|
+
? brand
|
|
18
|
+
: "",
|
|
19
|
+
text: m.text,
|
|
20
|
+
}));
|
|
21
|
+
const htmlRows = rows
|
|
22
|
+
.map((r) => r.role === "system"
|
|
23
|
+
? `<p style="margin:8px 0;color:#888;font-size:13px;font-style:italic">${esc(r.text)}</p>`
|
|
24
|
+
: `<p style="margin:10px 0"><strong>${esc(r.who)}:</strong> ${esc(r.text)}</p>`)
|
|
25
|
+
.join("");
|
|
26
|
+
const html = `<div style="font-family:Arial,Helvetica,sans-serif;max-width:560px;margin:0 auto;color:#0f172a">` +
|
|
27
|
+
`<h2 style="margin:0 0 4px">${esc(subject)}</h2>` +
|
|
28
|
+
`<p style="color:#475569;margin:0 0 16px">Here's a copy of your conversation with ${esc(brand)}.</p>` +
|
|
29
|
+
`<hr style="border:none;border-top:1px solid #e2e8f0"/>` +
|
|
30
|
+
htmlRows +
|
|
31
|
+
`<hr style="border:none;border-top:1px solid #e2e8f0"/>` +
|
|
32
|
+
`<p style="color:#94a3b8;font-size:12px">Sent to ${esc(session.visitor.email)}.</p>` +
|
|
33
|
+
`</div>`;
|
|
34
|
+
const text = `${subject}\n\n` +
|
|
35
|
+
rows.map((r) => (r.role === "system" ? `(${r.text})` : `${r.who}: ${r.text}`)).join("\n");
|
|
36
|
+
return { subject, html, text };
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Render a "you have new replies" email for a visitor who disconnected before
|
|
40
|
+
* seeing the agent/AI reply(s). `replyUrl`, if given, links back to the chat.
|
|
41
|
+
*/
|
|
42
|
+
export function renderReplyNotification(session, unseen, opts = {}) {
|
|
43
|
+
const brand = opts.brand ?? "Support";
|
|
44
|
+
const subject = `New reply from ${brand}`;
|
|
45
|
+
const rows = unseen
|
|
46
|
+
.filter((m) => m.text && m.text.trim())
|
|
47
|
+
.map((m) => ({
|
|
48
|
+
who: m.role === "agent" ? m.authorName || "Support" : brand,
|
|
49
|
+
text: m.text,
|
|
50
|
+
}));
|
|
51
|
+
const htmlRows = rows
|
|
52
|
+
.map((r) => `<p style="margin:10px 0"><strong>${esc(r.who)}:</strong> ${esc(r.text)}</p>`)
|
|
53
|
+
.join("");
|
|
54
|
+
const link = opts.replyUrl
|
|
55
|
+
? `<p style="margin:16px 0"><a href="${esc(opts.replyUrl)}" style="color:#0f172a">Reply to continue the conversation →</a></p>`
|
|
56
|
+
: "";
|
|
57
|
+
const html = `<div style="font-family:Arial,Helvetica,sans-serif;max-width:560px;margin:0 auto;color:#0f172a">` +
|
|
58
|
+
`<h2 style="margin:0 0 4px">${esc(subject)}</h2>` +
|
|
59
|
+
`<p style="color:#475569;margin:0 0 16px">Hi ${esc(session.visitor.name)}, you have a new reply to your chat:</p>` +
|
|
60
|
+
`<hr style="border:none;border-top:1px solid #e2e8f0"/>` +
|
|
61
|
+
htmlRows +
|
|
62
|
+
link +
|
|
63
|
+
`<hr style="border:none;border-top:1px solid #e2e8f0"/>` +
|
|
64
|
+
`<p style="color:#94a3b8;font-size:12px">Sent to ${esc(session.visitor.email)} because you left the chat before this reply arrived.</p>` +
|
|
65
|
+
`</div>`;
|
|
66
|
+
const text = `${subject}\n\nHi ${session.visitor.name}, you have a new reply:\n\n` +
|
|
67
|
+
rows.map((r) => `${r.who}: ${r.text}`).join("\n") +
|
|
68
|
+
(opts.replyUrl ? `\n\nReply: ${opts.replyUrl}` : "");
|
|
69
|
+
return { subject, html, text };
|
|
70
|
+
}
|
|
71
|
+
function esc(s) {
|
|
72
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
73
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ParsedAgentReply } from "./types";
|
|
2
|
+
/**
|
|
3
|
+
* The "human handoff" seam. A transport routes a conversation to wherever your
|
|
4
|
+
* team already works and relays their replies back.
|
|
5
|
+
*
|
|
6
|
+
* v1 ships {@link TelegramTransport}. The interface is deliberately small so a
|
|
7
|
+
* SlackTransport (thread per chat in a channel), DiscordTransport, or
|
|
8
|
+
* email/SMTP transport can be added without touching the engine. Thread ids are
|
|
9
|
+
* opaque strings so any backend's native id fits (Telegram `message_thread_id`,
|
|
10
|
+
* Slack `thread_ts`, etc.).
|
|
11
|
+
*/
|
|
12
|
+
export interface ChatTransport {
|
|
13
|
+
/** Create a thread/topic for a new chat and return its opaque id. */
|
|
14
|
+
createTopic(name: string): Promise<string>;
|
|
15
|
+
/** Post a message into a thread. Should be best-effort (not throw). */
|
|
16
|
+
sendMessage(threadId: string, text: string): Promise<void>;
|
|
17
|
+
/** Close/archive a thread when the conversation ends. */
|
|
18
|
+
closeTopic(threadId: string): Promise<void>;
|
|
19
|
+
/** Reopen a previously closed thread (best-effort; no-op where not applicable). */
|
|
20
|
+
reopenTopic(threadId: string): Promise<void>;
|
|
21
|
+
/** Parse an inbound webhook payload into an agent reply, or null if irrelevant. */
|
|
22
|
+
parseUpdate(update: unknown): ParsedAgentReply | null;
|
|
23
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
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
|
+
/** Who authored a message. */
|
|
9
|
+
export type ChatRole = "visitor" | "ai" | "agent" | "system";
|
|
10
|
+
/**
|
|
11
|
+
* Lifecycle of a conversation.
|
|
12
|
+
* - `ai` — the AI first-line is handling the visitor.
|
|
13
|
+
* - `live` — a human agent has taken over (AI stays quiet).
|
|
14
|
+
* - `closed` — the conversation has ended.
|
|
15
|
+
*/
|
|
16
|
+
export type ChatStatus = "ai" | "live" | "closed";
|
|
17
|
+
/** Details captured by the pre-chat form before messaging starts. */
|
|
18
|
+
export interface ChatVisitor {
|
|
19
|
+
name: string;
|
|
20
|
+
email: string;
|
|
21
|
+
/** Optional "what do you need help with?" from the pre-chat form. */
|
|
22
|
+
topic?: string;
|
|
23
|
+
}
|
|
24
|
+
export interface ChatMessage {
|
|
25
|
+
id: string;
|
|
26
|
+
sessionId: string;
|
|
27
|
+
role: ChatRole;
|
|
28
|
+
text: string;
|
|
29
|
+
/** Display name of the author (for agent replies — the staff member's name). */
|
|
30
|
+
authorName?: string;
|
|
31
|
+
/** Epoch milliseconds. */
|
|
32
|
+
createdAt: number;
|
|
33
|
+
}
|
|
34
|
+
export interface ChatSession {
|
|
35
|
+
/** Opaque public id (uuid) — the client holds this to read/write its session. */
|
|
36
|
+
id: string;
|
|
37
|
+
visitor: ChatVisitor;
|
|
38
|
+
/**
|
|
39
|
+
* Transport thread id, once the thread/topic is created. Opaque string so any
|
|
40
|
+
* backend fits (Telegram `message_thread_id`, Slack `thread_ts`, …).
|
|
41
|
+
*/
|
|
42
|
+
threadId: string | null;
|
|
43
|
+
status: ChatStatus;
|
|
44
|
+
createdAt: number;
|
|
45
|
+
updatedAt: number;
|
|
46
|
+
/** Last time the visitor's widget polled/heartbeat — used to detect disconnect. */
|
|
47
|
+
lastSeenAt?: number;
|
|
48
|
+
/** Timestamp up to which unseen replies have been emailed (avoids re-sending). */
|
|
49
|
+
notifiedAt?: number;
|
|
50
|
+
}
|
|
51
|
+
/** A human agent's reply parsed from an inbound transport webhook update. */
|
|
52
|
+
export interface ParsedAgentReply {
|
|
53
|
+
threadId: string;
|
|
54
|
+
text: string;
|
|
55
|
+
/** Display name of the agent who replied, if available. */
|
|
56
|
+
fromName?: string;
|
|
57
|
+
/** True when the message is a control command (e.g. `/close`). */
|
|
58
|
+
command?: "close";
|
|
59
|
+
}
|
|
60
|
+
/** Result of an AI first-line turn. */
|
|
61
|
+
export interface AIReply {
|
|
62
|
+
/** The text to show the visitor. */
|
|
63
|
+
text: string;
|
|
64
|
+
/** True when the AI wants a human to take over. */
|
|
65
|
+
escalate: boolean;
|
|
66
|
+
/** Short internal note on why it escalated (shown to agents, not the visitor). */
|
|
67
|
+
reason?: string;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Optional first-line layer. Implement this to plug in a different LLM —
|
|
71
|
+
* cloud ({@link AnthropicProvider}) or local/self-hosted ({@link LocalProvider}).
|
|
72
|
+
*/
|
|
73
|
+
export interface AIProvider {
|
|
74
|
+
reply(history: ChatMessage[], visitor: ChatVisitor): Promise<AIReply>;
|
|
75
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
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
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@freewaretools/outercom",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Self-hosted live-chat widget that hands off to Telegram (Slack/Discord pluggable), with an optional Claude or local-LLM first-line. Pre-chat capture, topic-per-customer, transcript email, notification sounds.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"sideEffects": false,
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"default": "./dist/index.js"
|
|
15
|
+
},
|
|
16
|
+
"./react": {
|
|
17
|
+
"types": "./dist/react.d.ts",
|
|
18
|
+
"default": "./dist/react.js"
|
|
19
|
+
},
|
|
20
|
+
"./next": {
|
|
21
|
+
"types": "./dist/next.d.ts",
|
|
22
|
+
"default": "./dist/next.js"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"src",
|
|
28
|
+
"README.md"
|
|
29
|
+
],
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "tsc -p tsconfig.json",
|
|
32
|
+
"clean": "rm -rf dist"
|
|
33
|
+
},
|
|
34
|
+
"//install-notes": "Published to npm as @freewaretools/outercom; also git-URL installable (dist/ is committed, so no build step on install). NO prepack/prepare/postinstall scripts on purpose: pnpm blocks build scripts on git-URL installs (ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED). No peerDependencies — react/@anthropic-ai/sdk are provided by the host (the SDK is lazy-loaded); pnpm fails to snapshot a git dep that declares peers. To release: `npm run build`, commit dist/, tag, then `npm publish`.",
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@anthropic-ai/sdk": "^0.102.0",
|
|
37
|
+
"@types/react": "^19",
|
|
38
|
+
"react": "^19",
|
|
39
|
+
"typescript": "^5.7.0"
|
|
40
|
+
},
|
|
41
|
+
"repository": {
|
|
42
|
+
"type": "git",
|
|
43
|
+
"url": "git+https://github.com/freewaretools/outercom.git"
|
|
44
|
+
},
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=18"
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/ai.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type Anthropic from "@anthropic-ai/sdk"
|
|
2
|
+
import type { AIProvider, ChatMessage, ChatVisitor } from "./types"
|
|
3
|
+
import { buildSupportSystemPrompt, parseAIReply, toChatTurns } from "./prompt"
|
|
4
|
+
|
|
5
|
+
export interface AnthropicProviderConfig {
|
|
6
|
+
apiKey: string
|
|
7
|
+
/**
|
|
8
|
+
* Model id. Defaults to Claude Haiku 4.5 — cheapest tier ($1/$5 per 1M
|
|
9
|
+
* tokens), ample for grounded first-line support. Bump to
|
|
10
|
+
* `claude-sonnet-4-6` if answers need more nuance.
|
|
11
|
+
*/
|
|
12
|
+
model?: string
|
|
13
|
+
/** Product/FAQ content the AI must ground its answers in. */
|
|
14
|
+
knowledgeBase: string
|
|
15
|
+
/** Company/brand name used in the persona. */
|
|
16
|
+
brand?: string
|
|
17
|
+
/** Extra persona/policy text appended to the system prompt. */
|
|
18
|
+
systemPromptExtra?: string
|
|
19
|
+
maxTokens?: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const DEFAULT_MODEL = "claude-haiku-4-5"
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Claude-backed first-line responder. Asks for a small JSON envelope and parses
|
|
26
|
+
* it defensively (falls back to plain text). No `thinking` / `effort` — both
|
|
27
|
+
* error on Haiku 4.5 and a support first-line doesn't need them.
|
|
28
|
+
*/
|
|
29
|
+
export class AnthropicProvider implements AIProvider {
|
|
30
|
+
private clientPromise: Promise<Anthropic> | null = null
|
|
31
|
+
private readonly model: string
|
|
32
|
+
private readonly maxTokens: number
|
|
33
|
+
|
|
34
|
+
constructor(private readonly cfg: AnthropicProviderConfig) {
|
|
35
|
+
this.model = cfg.model ?? DEFAULT_MODEL
|
|
36
|
+
this.maxTokens = cfg.maxTokens ?? 1024
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Lazy-load the SDK so it's only required when Claude is actually used —
|
|
40
|
+
// local-LLM / human-only consumers don't need @anthropic-ai/sdk installed.
|
|
41
|
+
private client(): Promise<Anthropic> {
|
|
42
|
+
if (!this.clientPromise) {
|
|
43
|
+
this.clientPromise = import("@anthropic-ai/sdk").then(
|
|
44
|
+
(m) => new m.default({ apiKey: this.cfg.apiKey })
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
return this.clientPromise
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async reply(history: ChatMessage[], visitor: ChatVisitor) {
|
|
51
|
+
const turns = toChatTurns(history)
|
|
52
|
+
if (turns.length === 0 || turns[0].role !== "user") {
|
|
53
|
+
return { text: "Thanks for reaching out — how can I help?", escalate: false }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const client = await this.client()
|
|
57
|
+
const res = await client.messages.create({
|
|
58
|
+
model: this.model,
|
|
59
|
+
max_tokens: this.maxTokens,
|
|
60
|
+
system: buildSupportSystemPrompt(this.cfg, visitor),
|
|
61
|
+
messages: turns,
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const raw = res.content
|
|
65
|
+
.filter((b): b is Anthropic.TextBlock => b.type === "text")
|
|
66
|
+
.map((b) => b.text)
|
|
67
|
+
.join("")
|
|
68
|
+
.trim()
|
|
69
|
+
|
|
70
|
+
return parseAIReply(raw)
|
|
71
|
+
}
|
|
72
|
+
}
|