chatbotlite 0.3.0 → 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/README.md +207 -223
- package/dist/client/index.cjs +292 -25
- package/dist/client/index.cjs.map +1 -1
- package/dist/client/index.d.cts +54 -4
- package/dist/client/index.d.ts +54 -4
- package/dist/client/index.js +292 -26
- package/dist/client/index.js.map +1 -1
- package/dist/core/index.cjs +89 -18
- package/dist/core/index.cjs.map +1 -1
- package/dist/core/index.d.cts +50 -5
- package/dist/core/index.d.ts +50 -5
- package/dist/core/index.js +86 -19
- package/dist/core/index.js.map +1 -1
- package/dist/index.cjs +347 -25
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +4 -4
- package/dist/index.d.ts +4 -4
- package/dist/index.js +343 -26
- package/dist/index.js.map +1 -1
- package/dist/judges-B0AAZLS9.d.ts +49 -0
- package/dist/judges-CSRIUVlF.d.cts +49 -0
- package/dist/react/index.cjs +1232 -110
- package/dist/react/index.cjs.map +1 -1
- package/dist/react/index.d.cts +56 -2
- package/dist/react/index.d.ts +56 -2
- package/dist/react/index.js +1232 -110
- package/dist/react/index.js.map +1 -1
- package/dist/{types-J7BXpiRU.d.cts → types-BFlAWQF4.d.cts} +16 -1
- package/dist/{types-J7BXpiRU.d.ts → types-BFlAWQF4.d.ts} +16 -1
- package/package.json +7 -3
- package/dist/types-4alyzg8O.d.cts +0 -16
- package/dist/types-4alyzg8O.d.ts +0 -16
package/dist/client/index.d.cts
CHANGED
|
@@ -1,17 +1,24 @@
|
|
|
1
|
-
import { P as Provider, c as ProviderConfig, b as ClientOptions, A as AttemptInfo } from '../types-
|
|
2
|
-
export { C as ChainEntry, a as ChainStep, i as isKnownProvider } from '../types-
|
|
3
|
-
import {
|
|
1
|
+
import { P as Provider, K as Knowledge, c as ProviderConfig, b as ClientOptions, M as Message, A as AttemptInfo } from '../types-BFlAWQF4.cjs';
|
|
2
|
+
export { C as ChainEntry, a as ChainStep, i as isKnownProvider } from '../types-BFlAWQF4.cjs';
|
|
3
|
+
import { G as GuardsConfig, a as JudgeVerdict } from '../judges-CSRIUVlF.cjs';
|
|
4
4
|
|
|
5
5
|
interface ProviderEndpoint {
|
|
6
6
|
baseUrl: string;
|
|
7
7
|
defaultModel: string;
|
|
8
|
+
/** Vision-capable model. If absent, provider doesn't support image inputs. */
|
|
9
|
+
visionModel?: string;
|
|
8
10
|
}
|
|
9
11
|
/**
|
|
10
12
|
* Built-in OpenAI-compatible providers. All use /v1/chat/completions
|
|
11
13
|
* with response in OpenAI format. Caller supplies API key per provider.
|
|
14
|
+
*
|
|
15
|
+
* Providers with `visionModel` set support image inputs via OpenAI-style
|
|
16
|
+
* `image_url` content blocks (data: URLs accepted).
|
|
12
17
|
*/
|
|
13
18
|
declare const PROVIDER_ENDPOINTS: Record<Provider, ProviderEndpoint>;
|
|
14
19
|
declare function isRetryableError(err: unknown): boolean;
|
|
20
|
+
/** Convert a File/Blob to a `data:` URL for inline vision input. */
|
|
21
|
+
declare function fileToDataUrl(file: File | Blob): Promise<string>;
|
|
15
22
|
|
|
16
23
|
interface ChatBotInit {
|
|
17
24
|
/** Markdown describing the business — services, hours, policies, anything. */
|
|
@@ -20,6 +27,8 @@ interface ChatBotInit {
|
|
|
20
27
|
providers: ProviderConfig;
|
|
21
28
|
/** Optional runtime overrides. */
|
|
22
29
|
options?: ClientOptions;
|
|
30
|
+
/** Defense-in-depth: phrase redlines (default) + optional LLM input/output judges. */
|
|
31
|
+
guards?: GuardsConfig;
|
|
23
32
|
}
|
|
24
33
|
interface ReplyOptions {
|
|
25
34
|
/** Conversation history (excluding the new user message). */
|
|
@@ -27,6 +36,10 @@ interface ReplyOptions {
|
|
|
27
36
|
/** Override system prompt — advanced use only. */
|
|
28
37
|
systemPrompt?: string;
|
|
29
38
|
}
|
|
39
|
+
interface ReplyWithMediaOptions extends ReplyOptions {
|
|
40
|
+
/** Image files (PNG/JPEG/WebP) to include alongside the message. Skipped on providers without vision. */
|
|
41
|
+
images?: (File | Blob)[];
|
|
42
|
+
}
|
|
30
43
|
interface ReplyResult {
|
|
31
44
|
reply: string;
|
|
32
45
|
/** Provider/model that produced the final reply (after fallback). */
|
|
@@ -39,8 +52,15 @@ interface ReplyResult {
|
|
|
39
52
|
};
|
|
40
53
|
/** Guard violations the bot caught and stripped, if any. */
|
|
41
54
|
guardWarnings: string[];
|
|
55
|
+
/** LLM-judge verdicts if judges configured. */
|
|
56
|
+
judges?: {
|
|
57
|
+
input?: JudgeVerdict;
|
|
58
|
+
output?: JudgeVerdict;
|
|
59
|
+
};
|
|
42
60
|
/** Debug trace of every attempt in the chain. */
|
|
43
61
|
attempts: AttemptInfo[];
|
|
62
|
+
/** True when blocked by input judge — `reply` will be the refusal message. */
|
|
63
|
+
blockedByInputJudge?: boolean;
|
|
44
64
|
}
|
|
45
65
|
/**
|
|
46
66
|
* The main ChatBot entry. Holds knowledge + provider chain.
|
|
@@ -64,9 +84,39 @@ declare class ChatBot {
|
|
|
64
84
|
private readonly fetcher;
|
|
65
85
|
private readonly timeoutMs;
|
|
66
86
|
private readonly cachedSystemPrompt;
|
|
87
|
+
private readonly guards;
|
|
67
88
|
constructor(init: ChatBotInit);
|
|
89
|
+
/** Run an LLM judge against content. Fail-open on errors. */
|
|
90
|
+
private judge;
|
|
91
|
+
/**
|
|
92
|
+
* Stream a reply as SSE events. Returns a ReadableStream that yields tokens
|
|
93
|
+
* progressively. Designed to plug into Next.js/Hono/Express route handlers:
|
|
94
|
+
*
|
|
95
|
+
* ```ts
|
|
96
|
+
* export async function POST(req: Request) {
|
|
97
|
+
* const { message, transcript } = await req.json();
|
|
98
|
+
* const stream = await bot.replyStream(message, { history: transcript });
|
|
99
|
+
* return new Response(stream, {
|
|
100
|
+
* headers: { "Content-Type": "text/event-stream" }
|
|
101
|
+
* });
|
|
102
|
+
* }
|
|
103
|
+
* ```
|
|
104
|
+
*
|
|
105
|
+
* Events emitted (one per `data:` line, SSE format):
|
|
106
|
+
* event: token data: "<text fragment>"
|
|
107
|
+
* event: done data: {"reply":"...","usedProvider":"...","usedModel":"...","attempts":[...]}
|
|
108
|
+
* event: error data: {"message":"...","attempts":[...]}
|
|
109
|
+
*/
|
|
110
|
+
replyStream(message: string, opts?: ReplyOptions): Promise<ReadableStream<Uint8Array>>;
|
|
111
|
+
/**
|
|
112
|
+
* Reply to a message with optional image attachments. Routes through vision-capable
|
|
113
|
+
* providers only — chain steps without `visionModel` are skipped (logged in attempts).
|
|
114
|
+
*
|
|
115
|
+
* Images are inlined as data: URLs. Keep total payload modest (<10MB).
|
|
116
|
+
*/
|
|
117
|
+
replyWithMedia(message: string, opts?: ReplyWithMediaOptions): Promise<ReplyResult>;
|
|
68
118
|
reply(message: string, opts?: ReplyOptions): Promise<ReplyResult>;
|
|
69
119
|
private callProvider;
|
|
70
120
|
}
|
|
71
121
|
|
|
72
|
-
export { AttemptInfo, ChatBot, type ChatBotInit, ClientOptions, PROVIDER_ENDPOINTS, Provider, ProviderConfig, type ProviderEndpoint, type ReplyOptions, type ReplyResult, isRetryableError };
|
|
122
|
+
export { AttemptInfo, ChatBot, type ChatBotInit, ClientOptions, PROVIDER_ENDPOINTS, Provider, ProviderConfig, type ProviderEndpoint, type ReplyOptions, type ReplyResult, type ReplyWithMediaOptions, fileToDataUrl, isRetryableError };
|
package/dist/client/index.d.ts
CHANGED
|
@@ -1,17 +1,24 @@
|
|
|
1
|
-
import { P as Provider, c as ProviderConfig, b as ClientOptions, A as AttemptInfo } from '../types-
|
|
2
|
-
export { C as ChainEntry, a as ChainStep, i as isKnownProvider } from '../types-
|
|
3
|
-
import {
|
|
1
|
+
import { P as Provider, K as Knowledge, c as ProviderConfig, b as ClientOptions, M as Message, A as AttemptInfo } from '../types-BFlAWQF4.js';
|
|
2
|
+
export { C as ChainEntry, a as ChainStep, i as isKnownProvider } from '../types-BFlAWQF4.js';
|
|
3
|
+
import { G as GuardsConfig, a as JudgeVerdict } from '../judges-B0AAZLS9.js';
|
|
4
4
|
|
|
5
5
|
interface ProviderEndpoint {
|
|
6
6
|
baseUrl: string;
|
|
7
7
|
defaultModel: string;
|
|
8
|
+
/** Vision-capable model. If absent, provider doesn't support image inputs. */
|
|
9
|
+
visionModel?: string;
|
|
8
10
|
}
|
|
9
11
|
/**
|
|
10
12
|
* Built-in OpenAI-compatible providers. All use /v1/chat/completions
|
|
11
13
|
* with response in OpenAI format. Caller supplies API key per provider.
|
|
14
|
+
*
|
|
15
|
+
* Providers with `visionModel` set support image inputs via OpenAI-style
|
|
16
|
+
* `image_url` content blocks (data: URLs accepted).
|
|
12
17
|
*/
|
|
13
18
|
declare const PROVIDER_ENDPOINTS: Record<Provider, ProviderEndpoint>;
|
|
14
19
|
declare function isRetryableError(err: unknown): boolean;
|
|
20
|
+
/** Convert a File/Blob to a `data:` URL for inline vision input. */
|
|
21
|
+
declare function fileToDataUrl(file: File | Blob): Promise<string>;
|
|
15
22
|
|
|
16
23
|
interface ChatBotInit {
|
|
17
24
|
/** Markdown describing the business — services, hours, policies, anything. */
|
|
@@ -20,6 +27,8 @@ interface ChatBotInit {
|
|
|
20
27
|
providers: ProviderConfig;
|
|
21
28
|
/** Optional runtime overrides. */
|
|
22
29
|
options?: ClientOptions;
|
|
30
|
+
/** Defense-in-depth: phrase redlines (default) + optional LLM input/output judges. */
|
|
31
|
+
guards?: GuardsConfig;
|
|
23
32
|
}
|
|
24
33
|
interface ReplyOptions {
|
|
25
34
|
/** Conversation history (excluding the new user message). */
|
|
@@ -27,6 +36,10 @@ interface ReplyOptions {
|
|
|
27
36
|
/** Override system prompt — advanced use only. */
|
|
28
37
|
systemPrompt?: string;
|
|
29
38
|
}
|
|
39
|
+
interface ReplyWithMediaOptions extends ReplyOptions {
|
|
40
|
+
/** Image files (PNG/JPEG/WebP) to include alongside the message. Skipped on providers without vision. */
|
|
41
|
+
images?: (File | Blob)[];
|
|
42
|
+
}
|
|
30
43
|
interface ReplyResult {
|
|
31
44
|
reply: string;
|
|
32
45
|
/** Provider/model that produced the final reply (after fallback). */
|
|
@@ -39,8 +52,15 @@ interface ReplyResult {
|
|
|
39
52
|
};
|
|
40
53
|
/** Guard violations the bot caught and stripped, if any. */
|
|
41
54
|
guardWarnings: string[];
|
|
55
|
+
/** LLM-judge verdicts if judges configured. */
|
|
56
|
+
judges?: {
|
|
57
|
+
input?: JudgeVerdict;
|
|
58
|
+
output?: JudgeVerdict;
|
|
59
|
+
};
|
|
42
60
|
/** Debug trace of every attempt in the chain. */
|
|
43
61
|
attempts: AttemptInfo[];
|
|
62
|
+
/** True when blocked by input judge — `reply` will be the refusal message. */
|
|
63
|
+
blockedByInputJudge?: boolean;
|
|
44
64
|
}
|
|
45
65
|
/**
|
|
46
66
|
* The main ChatBot entry. Holds knowledge + provider chain.
|
|
@@ -64,9 +84,39 @@ declare class ChatBot {
|
|
|
64
84
|
private readonly fetcher;
|
|
65
85
|
private readonly timeoutMs;
|
|
66
86
|
private readonly cachedSystemPrompt;
|
|
87
|
+
private readonly guards;
|
|
67
88
|
constructor(init: ChatBotInit);
|
|
89
|
+
/** Run an LLM judge against content. Fail-open on errors. */
|
|
90
|
+
private judge;
|
|
91
|
+
/**
|
|
92
|
+
* Stream a reply as SSE events. Returns a ReadableStream that yields tokens
|
|
93
|
+
* progressively. Designed to plug into Next.js/Hono/Express route handlers:
|
|
94
|
+
*
|
|
95
|
+
* ```ts
|
|
96
|
+
* export async function POST(req: Request) {
|
|
97
|
+
* const { message, transcript } = await req.json();
|
|
98
|
+
* const stream = await bot.replyStream(message, { history: transcript });
|
|
99
|
+
* return new Response(stream, {
|
|
100
|
+
* headers: { "Content-Type": "text/event-stream" }
|
|
101
|
+
* });
|
|
102
|
+
* }
|
|
103
|
+
* ```
|
|
104
|
+
*
|
|
105
|
+
* Events emitted (one per `data:` line, SSE format):
|
|
106
|
+
* event: token data: "<text fragment>"
|
|
107
|
+
* event: done data: {"reply":"...","usedProvider":"...","usedModel":"...","attempts":[...]}
|
|
108
|
+
* event: error data: {"message":"...","attempts":[...]}
|
|
109
|
+
*/
|
|
110
|
+
replyStream(message: string, opts?: ReplyOptions): Promise<ReadableStream<Uint8Array>>;
|
|
111
|
+
/**
|
|
112
|
+
* Reply to a message with optional image attachments. Routes through vision-capable
|
|
113
|
+
* providers only — chain steps without `visionModel` are skipped (logged in attempts).
|
|
114
|
+
*
|
|
115
|
+
* Images are inlined as data: URLs. Keep total payload modest (<10MB).
|
|
116
|
+
*/
|
|
117
|
+
replyWithMedia(message: string, opts?: ReplyWithMediaOptions): Promise<ReplyResult>;
|
|
68
118
|
reply(message: string, opts?: ReplyOptions): Promise<ReplyResult>;
|
|
69
119
|
private callProvider;
|
|
70
120
|
}
|
|
71
121
|
|
|
72
|
-
export { AttemptInfo, ChatBot, type ChatBotInit, ClientOptions, PROVIDER_ENDPOINTS, Provider, ProviderConfig, type ProviderEndpoint, type ReplyOptions, type ReplyResult, isRetryableError };
|
|
122
|
+
export { AttemptInfo, ChatBot, type ChatBotInit, ClientOptions, PROVIDER_ENDPOINTS, Provider, ProviderConfig, type ProviderEndpoint, type ReplyOptions, type ReplyResult, type ReplyWithMediaOptions, fileToDataUrl, isRetryableError };
|
package/dist/client/index.js
CHANGED
|
@@ -18,22 +18,34 @@ function isKnownProvider(name) {
|
|
|
18
18
|
|
|
19
19
|
// src/client/providers.ts
|
|
20
20
|
var PROVIDER_ENDPOINTS = {
|
|
21
|
-
openai: { baseUrl: "https://api.openai.com/v1", defaultModel: "gpt-4o-mini" },
|
|
21
|
+
openai: { baseUrl: "https://api.openai.com/v1", defaultModel: "gpt-4o-mini", visionModel: "gpt-4o" },
|
|
22
22
|
deepseek: { baseUrl: "https://api.deepseek.com/v1", defaultModel: "deepseek-chat" },
|
|
23
|
-
groq: { baseUrl: "https://api.groq.com/openai/v1", defaultModel: "llama-3.3-70b-versatile" },
|
|
24
|
-
gemini: { baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai", defaultModel: "gemini-2.5-flash" },
|
|
25
|
-
anthropic: { baseUrl: "https://api.anthropic.com/v1", defaultModel: "claude-haiku-4-5" },
|
|
23
|
+
groq: { baseUrl: "https://api.groq.com/openai/v1", defaultModel: "llama-3.3-70b-versatile", visionModel: "llama-3.2-90b-vision-preview" },
|
|
24
|
+
gemini: { baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai", defaultModel: "gemini-2.5-flash", visionModel: "gemini-2.5-flash" },
|
|
25
|
+
anthropic: { baseUrl: "https://api.anthropic.com/v1", defaultModel: "claude-haiku-4-5", visionModel: "claude-haiku-4-5" },
|
|
26
26
|
cerebras: { baseUrl: "https://api.cerebras.ai/v1", defaultModel: "qwen-3-235b-a22b-instruct-2507" },
|
|
27
27
|
sambanova: { baseUrl: "https://api.sambanova.ai/v1", defaultModel: "Meta-Llama-3.3-70B-Instruct" },
|
|
28
28
|
fireworks: { baseUrl: "https://api.fireworks.ai/inference/v1", defaultModel: "accounts/fireworks/models/llama-v3p3-70b-instruct" },
|
|
29
29
|
mistral: { baseUrl: "https://api.mistral.ai/v1", defaultModel: "mistral-small-latest" },
|
|
30
|
-
openrouter: { baseUrl: "https://openrouter.ai/api/v1", defaultModel: "deepseek/deepseek-chat" },
|
|
31
|
-
moonshot: { baseUrl: "https://api.moonshot.ai/v1", defaultModel: "moonshot-v1-32k" }
|
|
30
|
+
openrouter: { baseUrl: "https://openrouter.ai/api/v1", defaultModel: "deepseek/deepseek-chat", visionModel: "openai/gpt-4o" },
|
|
31
|
+
moonshot: { baseUrl: "https://api.moonshot.ai/v1", defaultModel: "moonshot-v1-32k", visionModel: "moonshot-v1-32k-vision-preview" }
|
|
32
32
|
};
|
|
33
33
|
function isRetryableError(err) {
|
|
34
34
|
const msg = err instanceof Error ? err.message : String(err);
|
|
35
35
|
return /\b(429|rate.?limit|quota|exceed|5\d\d|timeout|ECONNRESET|fetch failed)\b/i.test(msg);
|
|
36
36
|
}
|
|
37
|
+
async function fileToDataUrl(file) {
|
|
38
|
+
const buf = new Uint8Array(await file.arrayBuffer());
|
|
39
|
+
const base64 = bufferToBase64(buf);
|
|
40
|
+
const mime = file.type || "application/octet-stream";
|
|
41
|
+
return `data:${mime};base64,${base64}`;
|
|
42
|
+
}
|
|
43
|
+
function bufferToBase64(buf) {
|
|
44
|
+
let bin = "";
|
|
45
|
+
for (let i = 0; i < buf.length; i++) bin += String.fromCharCode(buf[i]);
|
|
46
|
+
if (typeof btoa === "function") return btoa(bin);
|
|
47
|
+
return globalThis.Buffer.from(bin, "binary").toString("base64");
|
|
48
|
+
}
|
|
37
49
|
|
|
38
50
|
// src/core/prompts.ts
|
|
39
51
|
function buildSystemPrompt(knowledge) {
|
|
@@ -55,27 +67,16 @@ function buildSystemPrompt(knowledge) {
|
|
|
55
67
|
|
|
56
68
|
// src/core/guards.ts
|
|
57
69
|
var FORBIDDEN_PHRASES = [
|
|
58
|
-
"help is coming",
|
|
59
|
-
"someone is on the way",
|
|
60
|
-
"technician is on the way",
|
|
61
|
-
"provider is on the way",
|
|
62
|
-
"dispatching someone",
|
|
63
70
|
"i've booked",
|
|
71
|
+
// fake booking
|
|
64
72
|
"i have booked",
|
|
65
|
-
"reservation confirmed",
|
|
66
73
|
"your appointment is confirmed",
|
|
67
|
-
|
|
68
|
-
"
|
|
69
|
-
"
|
|
70
|
-
|
|
71
|
-
"i
|
|
72
|
-
|
|
73
|
-
"guaranteed delivery",
|
|
74
|
-
"guaranteed arrival",
|
|
75
|
-
"will arrive at",
|
|
76
|
-
"arriving at",
|
|
77
|
-
"i'll send",
|
|
78
|
-
"i will send"
|
|
74
|
+
// fake confirmation
|
|
75
|
+
"reservation confirmed",
|
|
76
|
+
"someone is on the way",
|
|
77
|
+
// false dispatch
|
|
78
|
+
"i guarantee"
|
|
79
|
+
// legal liability
|
|
79
80
|
];
|
|
80
81
|
function checkForbiddenPhrases(reply) {
|
|
81
82
|
const lower = reply.toLowerCase();
|
|
@@ -100,6 +101,33 @@ function stripForbidden(reply) {
|
|
|
100
101
|
return trimmed;
|
|
101
102
|
}
|
|
102
103
|
|
|
104
|
+
// src/core/judges.ts
|
|
105
|
+
async function runJudge(config, apiKey, endpointUrl, content, fetcher) {
|
|
106
|
+
const res = await fetcher(`${endpointUrl}/chat/completions`, {
|
|
107
|
+
method: "POST",
|
|
108
|
+
headers: {
|
|
109
|
+
Authorization: `Bearer ${apiKey}`,
|
|
110
|
+
"Content-Type": "application/json"
|
|
111
|
+
},
|
|
112
|
+
body: JSON.stringify({
|
|
113
|
+
model: config.model,
|
|
114
|
+
messages: [
|
|
115
|
+
{ role: "system", content: config.prompt },
|
|
116
|
+
{ role: "user", content }
|
|
117
|
+
],
|
|
118
|
+
temperature: 0,
|
|
119
|
+
max_tokens: 10
|
|
120
|
+
})
|
|
121
|
+
});
|
|
122
|
+
if (!res.ok) {
|
|
123
|
+
return { decision: "PASS", raw: `judge HTTP ${res.status} \u2014 fail-open` };
|
|
124
|
+
}
|
|
125
|
+
const data = await res.json();
|
|
126
|
+
const raw = (data.choices?.[0]?.message?.content ?? "").trim().toUpperCase();
|
|
127
|
+
const decision = raw.startsWith("BLOCK") ? "BLOCK" : "PASS";
|
|
128
|
+
return { decision, raw };
|
|
129
|
+
}
|
|
130
|
+
|
|
103
131
|
// src/client/chatbot.ts
|
|
104
132
|
var ChatBot = class {
|
|
105
133
|
steps;
|
|
@@ -107,6 +135,7 @@ var ChatBot = class {
|
|
|
107
135
|
fetcher;
|
|
108
136
|
timeoutMs;
|
|
109
137
|
cachedSystemPrompt;
|
|
138
|
+
guards;
|
|
110
139
|
constructor(init) {
|
|
111
140
|
if (!init.knowledge || typeof init.knowledge !== "string" || init.knowledge.trim().length === 0) {
|
|
112
141
|
throw new Error("chatbotlite: knowledge is required (a non-empty markdown string).");
|
|
@@ -116,8 +145,237 @@ var ChatBot = class {
|
|
|
116
145
|
this.fetcher = init.options?.fetch ?? globalThis.fetch.bind(globalThis);
|
|
117
146
|
this.timeoutMs = init.options?.timeoutMs ?? 3e4;
|
|
118
147
|
this.cachedSystemPrompt = buildSystemPrompt(init.knowledge);
|
|
148
|
+
this.guards = init.guards ?? {};
|
|
149
|
+
}
|
|
150
|
+
/** Run an LLM judge against content. Fail-open on errors. */
|
|
151
|
+
async judge(config, content) {
|
|
152
|
+
const endpoint = PROVIDER_ENDPOINTS[config.provider];
|
|
153
|
+
const key = this.keys[config.provider];
|
|
154
|
+
if (!key) {
|
|
155
|
+
return { decision: "PASS", raw: `judge provider ${config.provider} has no key \u2014 fail-open` };
|
|
156
|
+
}
|
|
157
|
+
const model = config.model ?? endpoint.defaultModel;
|
|
158
|
+
return runJudge(
|
|
159
|
+
{ provider: config.provider, model, prompt: config.prompt },
|
|
160
|
+
key,
|
|
161
|
+
endpoint.baseUrl,
|
|
162
|
+
content,
|
|
163
|
+
this.fetcher
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Stream a reply as SSE events. Returns a ReadableStream that yields tokens
|
|
168
|
+
* progressively. Designed to plug into Next.js/Hono/Express route handlers:
|
|
169
|
+
*
|
|
170
|
+
* ```ts
|
|
171
|
+
* export async function POST(req: Request) {
|
|
172
|
+
* const { message, transcript } = await req.json();
|
|
173
|
+
* const stream = await bot.replyStream(message, { history: transcript });
|
|
174
|
+
* return new Response(stream, {
|
|
175
|
+
* headers: { "Content-Type": "text/event-stream" }
|
|
176
|
+
* });
|
|
177
|
+
* }
|
|
178
|
+
* ```
|
|
179
|
+
*
|
|
180
|
+
* Events emitted (one per `data:` line, SSE format):
|
|
181
|
+
* event: token data: "<text fragment>"
|
|
182
|
+
* event: done data: {"reply":"...","usedProvider":"...","usedModel":"...","attempts":[...]}
|
|
183
|
+
* event: error data: {"message":"...","attempts":[...]}
|
|
184
|
+
*/
|
|
185
|
+
async replyStream(message, opts = {}) {
|
|
186
|
+
const systemPrompt = opts.systemPrompt ?? this.cachedSystemPrompt;
|
|
187
|
+
const messages = [
|
|
188
|
+
{ role: "system", content: systemPrompt },
|
|
189
|
+
...opts.history ?? [],
|
|
190
|
+
{ role: "user", content: message }
|
|
191
|
+
];
|
|
192
|
+
const steps = this.steps;
|
|
193
|
+
const fetcher = this.fetcher;
|
|
194
|
+
const keys = this.keys;
|
|
195
|
+
const timeoutMs = this.timeoutMs;
|
|
196
|
+
const encoder = new TextEncoder();
|
|
197
|
+
const sse = (event, data) => encoder.encode(`event: ${event}
|
|
198
|
+
data: ${data}
|
|
199
|
+
|
|
200
|
+
`);
|
|
201
|
+
return new ReadableStream({
|
|
202
|
+
async start(controller) {
|
|
203
|
+
const attempts = [];
|
|
204
|
+
let lastError;
|
|
205
|
+
let assembled = "";
|
|
206
|
+
for (const step of steps) {
|
|
207
|
+
const t0 = Date.now();
|
|
208
|
+
const endpoint = PROVIDER_ENDPOINTS[step.provider];
|
|
209
|
+
const key = keys[step.provider];
|
|
210
|
+
if (!key) {
|
|
211
|
+
attempts.push({ provider: step.provider, model: step.model, status: "error", error: "missing key", latencyMs: 0 });
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
const abortCtrl = new AbortController();
|
|
215
|
+
const timer = setTimeout(() => abortCtrl.abort(), timeoutMs);
|
|
216
|
+
try {
|
|
217
|
+
const res = await fetcher(`${endpoint.baseUrl}/chat/completions`, {
|
|
218
|
+
method: "POST",
|
|
219
|
+
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
|
|
220
|
+
body: JSON.stringify({ model: step.model, messages, temperature: 0.3, max_tokens: 300, stream: true }),
|
|
221
|
+
signal: abortCtrl.signal
|
|
222
|
+
});
|
|
223
|
+
if (!res.ok) {
|
|
224
|
+
const body = await res.text();
|
|
225
|
+
throw new Error(`${res.status}: ${body.slice(0, 200)}`);
|
|
226
|
+
}
|
|
227
|
+
const reader = res.body.getReader();
|
|
228
|
+
const decoder = new TextDecoder();
|
|
229
|
+
let sseBuffer = "";
|
|
230
|
+
while (true) {
|
|
231
|
+
const { done, value } = await reader.read();
|
|
232
|
+
if (done) break;
|
|
233
|
+
sseBuffer += decoder.decode(value, { stream: true });
|
|
234
|
+
const lines = sseBuffer.split("\n");
|
|
235
|
+
sseBuffer = lines.pop() ?? "";
|
|
236
|
+
for (const line of lines) {
|
|
237
|
+
const trimmed = line.trim();
|
|
238
|
+
if (!trimmed.startsWith("data:")) continue;
|
|
239
|
+
const payload = trimmed.slice(5).trim();
|
|
240
|
+
if (payload === "[DONE]") continue;
|
|
241
|
+
try {
|
|
242
|
+
const obj = JSON.parse(payload);
|
|
243
|
+
const delta = obj.choices?.[0]?.delta?.content ?? obj.choices?.[0]?.delta?.reasoning_content ?? "";
|
|
244
|
+
if (delta) {
|
|
245
|
+
assembled += delta;
|
|
246
|
+
controller.enqueue(sse("token", JSON.stringify(delta)));
|
|
247
|
+
}
|
|
248
|
+
} catch {
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
attempts.push({ provider: step.provider, model: step.model, status: "ok", latencyMs: Date.now() - t0 });
|
|
253
|
+
const guard = checkForbiddenPhrases(assembled);
|
|
254
|
+
const finalReply = guard.ok ? assembled : stripForbidden(assembled);
|
|
255
|
+
controller.enqueue(sse("done", JSON.stringify({
|
|
256
|
+
reply: finalReply,
|
|
257
|
+
usedProvider: step.provider,
|
|
258
|
+
usedModel: step.model,
|
|
259
|
+
guardWarnings: guard.violations,
|
|
260
|
+
attempts
|
|
261
|
+
})));
|
|
262
|
+
controller.close();
|
|
263
|
+
return;
|
|
264
|
+
} catch (err) {
|
|
265
|
+
lastError = err;
|
|
266
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
267
|
+
attempts.push({ provider: step.provider, model: step.model, status: "error", error: errMsg, latencyMs: Date.now() - t0 });
|
|
268
|
+
assembled = "";
|
|
269
|
+
if (!isRetryableError(err)) {
|
|
270
|
+
controller.enqueue(sse("error", JSON.stringify({ message: `${step.label} failed (non-retryable): ${errMsg}`, attempts })));
|
|
271
|
+
controller.close();
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
} finally {
|
|
275
|
+
clearTimeout(timer);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
const summary = attempts.map((a) => `${a.provider}/${a.model}:${a.error ?? "ok"}`).join(" \u2192 ");
|
|
279
|
+
controller.enqueue(sse("error", JSON.stringify({
|
|
280
|
+
message: `all chain steps failed. Trace: ${summary}. Last error: ${lastError instanceof Error ? lastError.message : String(lastError)}`,
|
|
281
|
+
attempts
|
|
282
|
+
})));
|
|
283
|
+
controller.close();
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Reply to a message with optional image attachments. Routes through vision-capable
|
|
289
|
+
* providers only — chain steps without `visionModel` are skipped (logged in attempts).
|
|
290
|
+
*
|
|
291
|
+
* Images are inlined as data: URLs. Keep total payload modest (<10MB).
|
|
292
|
+
*/
|
|
293
|
+
async replyWithMedia(message, opts = {}) {
|
|
294
|
+
const images = opts.images ?? [];
|
|
295
|
+
if (images.length === 0) {
|
|
296
|
+
return this.reply(message, opts);
|
|
297
|
+
}
|
|
298
|
+
const dataUrls = await Promise.all(images.map(fileToDataUrl));
|
|
299
|
+
const systemPrompt = opts.systemPrompt ?? this.cachedSystemPrompt;
|
|
300
|
+
const userContent = [];
|
|
301
|
+
if (message) userContent.push({ type: "text", text: message });
|
|
302
|
+
for (const url of dataUrls) userContent.push({ type: "image_url", image_url: { url } });
|
|
303
|
+
const messages = [
|
|
304
|
+
{ role: "system", content: systemPrompt },
|
|
305
|
+
...opts.history ?? [],
|
|
306
|
+
{ role: "user", content: userContent }
|
|
307
|
+
];
|
|
308
|
+
const attempts = [];
|
|
309
|
+
let lastError;
|
|
310
|
+
for (const step of this.steps) {
|
|
311
|
+
const endpoint = PROVIDER_ENDPOINTS[step.provider];
|
|
312
|
+
if (!endpoint.visionModel) {
|
|
313
|
+
attempts.push({ provider: step.provider, model: step.model, status: "error", error: "no vision support", latencyMs: 0 });
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
const t0 = Date.now();
|
|
317
|
+
const visionStep = { provider: step.provider, model: endpoint.visionModel, label: `${step.provider}/${endpoint.visionModel}` };
|
|
318
|
+
try {
|
|
319
|
+
const key = this.keys[step.provider];
|
|
320
|
+
if (!key) throw new Error(`Missing API key for provider: ${step.provider}`);
|
|
321
|
+
const controller = new AbortController();
|
|
322
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
323
|
+
try {
|
|
324
|
+
const res = await this.fetcher(`${endpoint.baseUrl}/chat/completions`, {
|
|
325
|
+
method: "POST",
|
|
326
|
+
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
|
|
327
|
+
body: JSON.stringify({ model: visionStep.model, messages, temperature: 0.3, max_tokens: 400 }),
|
|
328
|
+
signal: controller.signal
|
|
329
|
+
});
|
|
330
|
+
if (!res.ok) {
|
|
331
|
+
const body = await res.text();
|
|
332
|
+
throw new Error(`${res.status}: ${body.slice(0, 200)}`);
|
|
333
|
+
}
|
|
334
|
+
const data = await res.json();
|
|
335
|
+
const reply = (data.choices?.[0]?.message?.content ?? "").trim();
|
|
336
|
+
if (!reply) throw new Error("empty vision reply");
|
|
337
|
+
attempts.push({ provider: visionStep.provider, model: visionStep.model, status: "ok", latencyMs: Date.now() - t0 });
|
|
338
|
+
const guard = checkForbiddenPhrases(reply);
|
|
339
|
+
const finalReply = guard.ok ? reply : stripForbidden(reply);
|
|
340
|
+
return {
|
|
341
|
+
reply: finalReply,
|
|
342
|
+
usedProvider: visionStep.provider,
|
|
343
|
+
usedModel: visionStep.model,
|
|
344
|
+
...data.usage ? { usage: data.usage } : {},
|
|
345
|
+
guardWarnings: guard.violations,
|
|
346
|
+
attempts
|
|
347
|
+
};
|
|
348
|
+
} finally {
|
|
349
|
+
clearTimeout(timer);
|
|
350
|
+
}
|
|
351
|
+
} catch (err) {
|
|
352
|
+
lastError = err;
|
|
353
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
354
|
+
attempts.push({ provider: visionStep.provider, model: visionStep.model, status: "error", error: errMsg, latencyMs: Date.now() - t0 });
|
|
355
|
+
if (!isRetryableError(err)) {
|
|
356
|
+
throw new Error(`chatbotlite: ${visionStep.label} vision failed (non-retryable). ${errMsg}`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
const summary = attempts.map((a) => `${a.provider}/${a.model}:${a.error ?? "ok"}`).join(" \u2192 ");
|
|
361
|
+
throw new Error(`chatbotlite: no vision-capable provider succeeded. Trace: ${summary}. Last error: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
|
|
119
362
|
}
|
|
120
363
|
async reply(message, opts = {}) {
|
|
364
|
+
let inputVerdict;
|
|
365
|
+
if (this.guards.inputJudge) {
|
|
366
|
+
inputVerdict = await this.judge(this.guards.inputJudge, message);
|
|
367
|
+
if (inputVerdict.decision === "BLOCK") {
|
|
368
|
+
return {
|
|
369
|
+
reply: "I can't process that request. Please ask in a different way.",
|
|
370
|
+
usedProvider: this.steps[0].provider,
|
|
371
|
+
usedModel: this.steps[0].model,
|
|
372
|
+
guardWarnings: [],
|
|
373
|
+
judges: { input: inputVerdict },
|
|
374
|
+
attempts: [],
|
|
375
|
+
blockedByInputJudge: true
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
}
|
|
121
379
|
const systemPrompt = opts.systemPrompt ?? this.cachedSystemPrompt;
|
|
122
380
|
const messages = [
|
|
123
381
|
{ role: "system", content: systemPrompt },
|
|
@@ -132,13 +390,21 @@ var ChatBot = class {
|
|
|
132
390
|
const result = await this.callProvider(step, messages);
|
|
133
391
|
attempts.push({ provider: step.provider, model: step.model, status: "ok", latencyMs: Date.now() - t0 });
|
|
134
392
|
const guard = checkForbiddenPhrases(result.reply);
|
|
135
|
-
|
|
393
|
+
let finalReply = guard.ok ? result.reply : stripForbidden(result.reply);
|
|
394
|
+
let outputVerdict;
|
|
395
|
+
if (this.guards.outputJudge) {
|
|
396
|
+
outputVerdict = await this.judge(this.guards.outputJudge, finalReply);
|
|
397
|
+
if (outputVerdict.decision === "BLOCK") {
|
|
398
|
+
finalReply = "Let me check with the owner and get back to you on that.";
|
|
399
|
+
}
|
|
400
|
+
}
|
|
136
401
|
return {
|
|
137
402
|
reply: finalReply,
|
|
138
403
|
usedProvider: step.provider,
|
|
139
404
|
usedModel: step.model,
|
|
140
405
|
...result.usage ? { usage: result.usage } : {},
|
|
141
406
|
guardWarnings: guard.violations,
|
|
407
|
+
...inputVerdict || outputVerdict ? { judges: { ...inputVerdict ? { input: inputVerdict } : {}, ...outputVerdict ? { output: outputVerdict } : {} } } : {},
|
|
142
408
|
attempts
|
|
143
409
|
};
|
|
144
410
|
} catch (err) {
|
|
@@ -224,6 +490,6 @@ function normalizeChainEntry(entry, keys) {
|
|
|
224
490
|
return { provider, model, label: `${provider}/${model}` };
|
|
225
491
|
}
|
|
226
492
|
|
|
227
|
-
export { ChatBot, PROVIDER_ENDPOINTS, isKnownProvider, isRetryableError };
|
|
493
|
+
export { ChatBot, PROVIDER_ENDPOINTS, fileToDataUrl, isKnownProvider, isRetryableError };
|
|
228
494
|
//# sourceMappingURL=index.js.map
|
|
229
495
|
//# sourceMappingURL=index.js.map
|