chatbotlite 0.3.1 → 0.5.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.
@@ -1,17 +1,24 @@
1
- import { P as Provider, c as ProviderConfig, b as ClientOptions, A as AttemptInfo } from '../types-J7BXpiRU.cjs';
2
- export { C as ChainEntry, a as ChainStep, i as isKnownProvider } from '../types-J7BXpiRU.cjs';
3
- import { K as Knowledge, M as Message } from '../types-4alyzg8O.cjs';
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,12 +27,20 @@ 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). */
26
35
  history?: Message[];
27
36
  /** Override system prompt — advanced use only. */
28
37
  systemPrompt?: string;
38
+ /** Tool names that the widget will render (auto-injects examples into system prompt). */
39
+ enabledTools?: string[];
40
+ }
41
+ interface ReplyWithMediaOptions extends ReplyOptions {
42
+ /** Image files (PNG/JPEG/WebP) to include alongside the message. Skipped on providers without vision. */
43
+ images?: (File | Blob)[];
29
44
  }
30
45
  interface ReplyResult {
31
46
  reply: string;
@@ -39,8 +54,15 @@ interface ReplyResult {
39
54
  };
40
55
  /** Guard violations the bot caught and stripped, if any. */
41
56
  guardWarnings: string[];
57
+ /** LLM-judge verdicts if judges configured. */
58
+ judges?: {
59
+ input?: JudgeVerdict;
60
+ output?: JudgeVerdict;
61
+ };
42
62
  /** Debug trace of every attempt in the chain. */
43
63
  attempts: AttemptInfo[];
64
+ /** True when blocked by input judge — `reply` will be the refusal message. */
65
+ blockedByInputJudge?: boolean;
44
66
  }
45
67
  /**
46
68
  * The main ChatBot entry. Holds knowledge + provider chain.
@@ -64,9 +86,42 @@ declare class ChatBot {
64
86
  private readonly fetcher;
65
87
  private readonly timeoutMs;
66
88
  private readonly cachedSystemPrompt;
89
+ private readonly guards;
90
+ private readonly knowledge;
67
91
  constructor(init: ChatBotInit);
92
+ /** Build system prompt for given opts — uses cached if no enabledTools, else rebuilds. */
93
+ private resolveSystemPrompt;
94
+ /** Run an LLM judge against content. Fail-open on errors. */
95
+ private judge;
96
+ /**
97
+ * Stream a reply as SSE events. Returns a ReadableStream that yields tokens
98
+ * progressively. Designed to plug into Next.js/Hono/Express route handlers:
99
+ *
100
+ * ```ts
101
+ * export async function POST(req: Request) {
102
+ * const { message, transcript } = await req.json();
103
+ * const stream = await bot.replyStream(message, { history: transcript });
104
+ * return new Response(stream, {
105
+ * headers: { "Content-Type": "text/event-stream" }
106
+ * });
107
+ * }
108
+ * ```
109
+ *
110
+ * Events emitted (one per `data:` line, SSE format):
111
+ * event: token data: "<text fragment>"
112
+ * event: done data: {"reply":"...","usedProvider":"...","usedModel":"...","attempts":[...]}
113
+ * event: error data: {"message":"...","attempts":[...]}
114
+ */
115
+ replyStream(message: string, opts?: ReplyOptions): Promise<ReadableStream<Uint8Array>>;
116
+ /**
117
+ * Reply to a message with optional image attachments. Routes through vision-capable
118
+ * providers only — chain steps without `visionModel` are skipped (logged in attempts).
119
+ *
120
+ * Images are inlined as data: URLs. Keep total payload modest (<10MB).
121
+ */
122
+ replyWithMedia(message: string, opts?: ReplyWithMediaOptions): Promise<ReplyResult>;
68
123
  reply(message: string, opts?: ReplyOptions): Promise<ReplyResult>;
69
124
  private callProvider;
70
125
  }
71
126
 
72
- export { AttemptInfo, ChatBot, type ChatBotInit, ClientOptions, PROVIDER_ENDPOINTS, Provider, ProviderConfig, type ProviderEndpoint, type ReplyOptions, type ReplyResult, isRetryableError };
127
+ export { AttemptInfo, ChatBot, type ChatBotInit, ClientOptions, PROVIDER_ENDPOINTS, Provider, ProviderConfig, type ProviderEndpoint, type ReplyOptions, type ReplyResult, type ReplyWithMediaOptions, fileToDataUrl, isRetryableError };
@@ -1,17 +1,24 @@
1
- import { P as Provider, c as ProviderConfig, b as ClientOptions, A as AttemptInfo } from '../types-J7BXpiRU.js';
2
- export { C as ChainEntry, a as ChainStep, i as isKnownProvider } from '../types-J7BXpiRU.js';
3
- import { K as Knowledge, M as Message } from '../types-4alyzg8O.js';
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,12 +27,20 @@ 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). */
26
35
  history?: Message[];
27
36
  /** Override system prompt — advanced use only. */
28
37
  systemPrompt?: string;
38
+ /** Tool names that the widget will render (auto-injects examples into system prompt). */
39
+ enabledTools?: string[];
40
+ }
41
+ interface ReplyWithMediaOptions extends ReplyOptions {
42
+ /** Image files (PNG/JPEG/WebP) to include alongside the message. Skipped on providers without vision. */
43
+ images?: (File | Blob)[];
29
44
  }
30
45
  interface ReplyResult {
31
46
  reply: string;
@@ -39,8 +54,15 @@ interface ReplyResult {
39
54
  };
40
55
  /** Guard violations the bot caught and stripped, if any. */
41
56
  guardWarnings: string[];
57
+ /** LLM-judge verdicts if judges configured. */
58
+ judges?: {
59
+ input?: JudgeVerdict;
60
+ output?: JudgeVerdict;
61
+ };
42
62
  /** Debug trace of every attempt in the chain. */
43
63
  attempts: AttemptInfo[];
64
+ /** True when blocked by input judge — `reply` will be the refusal message. */
65
+ blockedByInputJudge?: boolean;
44
66
  }
45
67
  /**
46
68
  * The main ChatBot entry. Holds knowledge + provider chain.
@@ -64,9 +86,42 @@ declare class ChatBot {
64
86
  private readonly fetcher;
65
87
  private readonly timeoutMs;
66
88
  private readonly cachedSystemPrompt;
89
+ private readonly guards;
90
+ private readonly knowledge;
67
91
  constructor(init: ChatBotInit);
92
+ /** Build system prompt for given opts — uses cached if no enabledTools, else rebuilds. */
93
+ private resolveSystemPrompt;
94
+ /** Run an LLM judge against content. Fail-open on errors. */
95
+ private judge;
96
+ /**
97
+ * Stream a reply as SSE events. Returns a ReadableStream that yields tokens
98
+ * progressively. Designed to plug into Next.js/Hono/Express route handlers:
99
+ *
100
+ * ```ts
101
+ * export async function POST(req: Request) {
102
+ * const { message, transcript } = await req.json();
103
+ * const stream = await bot.replyStream(message, { history: transcript });
104
+ * return new Response(stream, {
105
+ * headers: { "Content-Type": "text/event-stream" }
106
+ * });
107
+ * }
108
+ * ```
109
+ *
110
+ * Events emitted (one per `data:` line, SSE format):
111
+ * event: token data: "<text fragment>"
112
+ * event: done data: {"reply":"...","usedProvider":"...","usedModel":"...","attempts":[...]}
113
+ * event: error data: {"message":"...","attempts":[...]}
114
+ */
115
+ replyStream(message: string, opts?: ReplyOptions): Promise<ReadableStream<Uint8Array>>;
116
+ /**
117
+ * Reply to a message with optional image attachments. Routes through vision-capable
118
+ * providers only — chain steps without `visionModel` are skipped (logged in attempts).
119
+ *
120
+ * Images are inlined as data: URLs. Keep total payload modest (<10MB).
121
+ */
122
+ replyWithMedia(message: string, opts?: ReplyWithMediaOptions): Promise<ReplyResult>;
68
123
  reply(message: string, opts?: ReplyOptions): Promise<ReplyResult>;
69
124
  private callProvider;
70
125
  }
71
126
 
72
- export { AttemptInfo, ChatBot, type ChatBotInit, ClientOptions, PROVIDER_ENDPOINTS, Provider, ProviderConfig, type ProviderEndpoint, type ReplyOptions, type ReplyResult, isRetryableError };
127
+ export { AttemptInfo, ChatBot, type ChatBotInit, ClientOptions, PROVIDER_ENDPOINTS, Provider, ProviderConfig, type ProviderEndpoint, type ReplyOptions, type ReplyResult, type ReplyWithMediaOptions, fileToDataUrl, isRetryableError };
@@ -18,25 +18,59 @@ 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
+ }
49
+
50
+ // src/core/tools.ts
51
+ function buildToolsPromptAddendum(enabledTools) {
52
+ if (enabledTools.length === 0) return "";
53
+ const examples = {
54
+ uploadForReview: '[SKILL:uploadForReview purpose="T4 slip" accept="image/*,application/pdf" maxMb=10] \u2014 collect a document for human review (bytes go to webhook, you never see content)',
55
+ scheduleCallback: '[SKILL:scheduleCallback durationMin=15 timezone="America/Vancouver"] \u2014 let the user pick a callback time slot',
56
+ requestPayment: '[SKILL:requestPayment amount=4250 currency="cad" reason="initial deposit"] \u2014 collect payment via inline card'
57
+ };
58
+ const lines = enabledTools.filter((t) => examples[t]).map((t) => `- ${examples[t]}`);
59
+ if (lines.length === 0) return "";
60
+ return [
61
+ "",
62
+ "## Available tools",
63
+ "When you need one of these workflows, emit the marker INLINE in your reply.",
64
+ "Write a short message first, THEN the marker. The marker will be replaced by an interactive card.",
65
+ "Pause the conversation after emitting \u2014 wait for the tool result before continuing.",
66
+ "",
67
+ ...lines
68
+ ].join("\n");
69
+ }
37
70
 
38
71
  // src/core/prompts.ts
39
- function buildSystemPrompt(knowledge) {
72
+ function buildSystemPrompt(knowledge, enabledTools = []) {
73
+ const toolsAddendum = buildToolsPromptAddendum(enabledTools);
40
74
  return [
41
75
  "You are an AI assistant on a business website. Use ONLY the knowledge below to answer.",
42
76
  "",
@@ -49,33 +83,23 @@ function buildSystemPrompt(knowledge) {
49
83
  "- For anything not covered in the knowledge above, say the owner will follow up \u2014 do NOT guess.",
50
84
  '- If the caller is clearly a vendor/sales pitch, say: "This does not look like a customer service request, so we will not continue this thread."',
51
85
  `- If wrong number or asked to stop, say: "Sorry about that. We won't text again."`,
52
- "- Match the caller's language automatically."
53
- ].join("\n");
86
+ "- Match the caller's language automatically.",
87
+ toolsAddendum
88
+ ].filter(Boolean).join("\n");
54
89
  }
55
90
 
56
91
  // src/core/guards.ts
57
92
  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
93
  "i've booked",
94
+ // fake booking
64
95
  "i have booked",
65
- "reservation confirmed",
66
96
  "your appointment is confirmed",
67
- "i've scheduled",
68
- "i have scheduled",
69
- "we've dispatched",
70
- "we have dispatched",
71
- "i can confirm",
72
- "i guarantee",
73
- "guaranteed delivery",
74
- "guaranteed arrival",
75
- "will arrive at",
76
- "arriving at",
77
- "i'll send",
78
- "i will send"
97
+ // fake confirmation
98
+ "reservation confirmed",
99
+ "someone is on the way",
100
+ // false dispatch
101
+ "i guarantee"
102
+ // legal liability
79
103
  ];
80
104
  function checkForbiddenPhrases(reply) {
81
105
  const lower = reply.toLowerCase();
@@ -100,6 +124,33 @@ function stripForbidden(reply) {
100
124
  return trimmed;
101
125
  }
102
126
 
127
+ // src/core/judges.ts
128
+ async function runJudge(config, apiKey, endpointUrl, content, fetcher) {
129
+ const res = await fetcher(`${endpointUrl}/chat/completions`, {
130
+ method: "POST",
131
+ headers: {
132
+ Authorization: `Bearer ${apiKey}`,
133
+ "Content-Type": "application/json"
134
+ },
135
+ body: JSON.stringify({
136
+ model: config.model,
137
+ messages: [
138
+ { role: "system", content: config.prompt },
139
+ { role: "user", content }
140
+ ],
141
+ temperature: 0,
142
+ max_tokens: 10
143
+ })
144
+ });
145
+ if (!res.ok) {
146
+ return { decision: "PASS", raw: `judge HTTP ${res.status} \u2014 fail-open` };
147
+ }
148
+ const data = await res.json();
149
+ const raw = (data.choices?.[0]?.message?.content ?? "").trim().toUpperCase();
150
+ const decision = raw.startsWith("BLOCK") ? "BLOCK" : "PASS";
151
+ return { decision, raw };
152
+ }
153
+
103
154
  // src/client/chatbot.ts
104
155
  var ChatBot = class {
105
156
  steps;
@@ -107,18 +158,258 @@ var ChatBot = class {
107
158
  fetcher;
108
159
  timeoutMs;
109
160
  cachedSystemPrompt;
161
+ guards;
162
+ knowledge;
110
163
  constructor(init) {
111
164
  if (!init.knowledge || typeof init.knowledge !== "string" || init.knowledge.trim().length === 0) {
112
165
  throw new Error("chatbotlite: knowledge is required (a non-empty markdown string).");
113
166
  }
167
+ this.knowledge = init.knowledge;
114
168
  this.keys = init.providers.keys ?? {};
115
169
  this.steps = resolveChain(init.providers);
116
170
  this.fetcher = init.options?.fetch ?? globalThis.fetch.bind(globalThis);
117
171
  this.timeoutMs = init.options?.timeoutMs ?? 3e4;
118
172
  this.cachedSystemPrompt = buildSystemPrompt(init.knowledge);
173
+ this.guards = init.guards ?? {};
174
+ }
175
+ /** Build system prompt for given opts — uses cached if no enabledTools, else rebuilds. */
176
+ resolveSystemPrompt(opts) {
177
+ if (opts.systemPrompt) return opts.systemPrompt;
178
+ if (opts.enabledTools && opts.enabledTools.length > 0) {
179
+ return buildSystemPrompt(this.knowledge, opts.enabledTools);
180
+ }
181
+ return this.cachedSystemPrompt;
182
+ }
183
+ /** Run an LLM judge against content. Fail-open on errors. */
184
+ async judge(config, content) {
185
+ const endpoint = PROVIDER_ENDPOINTS[config.provider];
186
+ const key = this.keys[config.provider];
187
+ if (!key) {
188
+ return { decision: "PASS", raw: `judge provider ${config.provider} has no key \u2014 fail-open` };
189
+ }
190
+ const model = config.model ?? endpoint.defaultModel;
191
+ return runJudge(
192
+ { provider: config.provider, model, prompt: config.prompt },
193
+ key,
194
+ endpoint.baseUrl,
195
+ content,
196
+ this.fetcher
197
+ );
198
+ }
199
+ /**
200
+ * Stream a reply as SSE events. Returns a ReadableStream that yields tokens
201
+ * progressively. Designed to plug into Next.js/Hono/Express route handlers:
202
+ *
203
+ * ```ts
204
+ * export async function POST(req: Request) {
205
+ * const { message, transcript } = await req.json();
206
+ * const stream = await bot.replyStream(message, { history: transcript });
207
+ * return new Response(stream, {
208
+ * headers: { "Content-Type": "text/event-stream" }
209
+ * });
210
+ * }
211
+ * ```
212
+ *
213
+ * Events emitted (one per `data:` line, SSE format):
214
+ * event: token data: "<text fragment>"
215
+ * event: done data: {"reply":"...","usedProvider":"...","usedModel":"...","attempts":[...]}
216
+ * event: error data: {"message":"...","attempts":[...]}
217
+ */
218
+ async replyStream(message, opts = {}) {
219
+ const systemPrompt = this.resolveSystemPrompt(opts);
220
+ const messages = [
221
+ { role: "system", content: systemPrompt },
222
+ ...opts.history ?? [],
223
+ { role: "user", content: message }
224
+ ];
225
+ const steps = this.steps;
226
+ const fetcher = this.fetcher;
227
+ const keys = this.keys;
228
+ const timeoutMs = this.timeoutMs;
229
+ const encoder = new TextEncoder();
230
+ const sse = (event, data) => encoder.encode(`event: ${event}
231
+ data: ${data}
232
+
233
+ `);
234
+ return new ReadableStream({
235
+ async start(controller) {
236
+ const attempts = [];
237
+ let lastError;
238
+ let assembled = "";
239
+ for (const step of steps) {
240
+ const t0 = Date.now();
241
+ const endpoint = PROVIDER_ENDPOINTS[step.provider];
242
+ const key = keys[step.provider];
243
+ if (!key) {
244
+ attempts.push({ provider: step.provider, model: step.model, status: "error", error: "missing key", latencyMs: 0 });
245
+ continue;
246
+ }
247
+ const abortCtrl = new AbortController();
248
+ const timer = setTimeout(() => abortCtrl.abort(), timeoutMs);
249
+ try {
250
+ const res = await fetcher(`${endpoint.baseUrl}/chat/completions`, {
251
+ method: "POST",
252
+ headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
253
+ body: JSON.stringify({ model: step.model, messages, temperature: 0.3, max_tokens: 300, stream: true }),
254
+ signal: abortCtrl.signal
255
+ });
256
+ if (!res.ok) {
257
+ const body = await res.text();
258
+ throw new Error(`${res.status}: ${body.slice(0, 200)}`);
259
+ }
260
+ const reader = res.body.getReader();
261
+ const decoder = new TextDecoder();
262
+ let sseBuffer = "";
263
+ while (true) {
264
+ const { done, value } = await reader.read();
265
+ if (done) break;
266
+ sseBuffer += decoder.decode(value, { stream: true });
267
+ const lines = sseBuffer.split("\n");
268
+ sseBuffer = lines.pop() ?? "";
269
+ for (const line of lines) {
270
+ const trimmed = line.trim();
271
+ if (!trimmed.startsWith("data:")) continue;
272
+ const payload = trimmed.slice(5).trim();
273
+ if (payload === "[DONE]") continue;
274
+ try {
275
+ const obj = JSON.parse(payload);
276
+ const delta = obj.choices?.[0]?.delta?.content ?? obj.choices?.[0]?.delta?.reasoning_content ?? "";
277
+ if (delta) {
278
+ assembled += delta;
279
+ controller.enqueue(sse("token", JSON.stringify(delta)));
280
+ }
281
+ } catch {
282
+ }
283
+ }
284
+ }
285
+ attempts.push({ provider: step.provider, model: step.model, status: "ok", latencyMs: Date.now() - t0 });
286
+ const guard = checkForbiddenPhrases(assembled);
287
+ const finalReply = guard.ok ? assembled : stripForbidden(assembled);
288
+ controller.enqueue(sse("done", JSON.stringify({
289
+ reply: finalReply,
290
+ usedProvider: step.provider,
291
+ usedModel: step.model,
292
+ guardWarnings: guard.violations,
293
+ attempts
294
+ })));
295
+ controller.close();
296
+ return;
297
+ } catch (err) {
298
+ lastError = err;
299
+ const errMsg = err instanceof Error ? err.message : String(err);
300
+ attempts.push({ provider: step.provider, model: step.model, status: "error", error: errMsg, latencyMs: Date.now() - t0 });
301
+ assembled = "";
302
+ if (!isRetryableError(err)) {
303
+ controller.enqueue(sse("error", JSON.stringify({ message: `${step.label} failed (non-retryable): ${errMsg}`, attempts })));
304
+ controller.close();
305
+ return;
306
+ }
307
+ } finally {
308
+ clearTimeout(timer);
309
+ }
310
+ }
311
+ const summary = attempts.map((a) => `${a.provider}/${a.model}:${a.error ?? "ok"}`).join(" \u2192 ");
312
+ controller.enqueue(sse("error", JSON.stringify({
313
+ message: `all chain steps failed. Trace: ${summary}. Last error: ${lastError instanceof Error ? lastError.message : String(lastError)}`,
314
+ attempts
315
+ })));
316
+ controller.close();
317
+ }
318
+ });
319
+ }
320
+ /**
321
+ * Reply to a message with optional image attachments. Routes through vision-capable
322
+ * providers only — chain steps without `visionModel` are skipped (logged in attempts).
323
+ *
324
+ * Images are inlined as data: URLs. Keep total payload modest (<10MB).
325
+ */
326
+ async replyWithMedia(message, opts = {}) {
327
+ const images = opts.images ?? [];
328
+ if (images.length === 0) {
329
+ return this.reply(message, opts);
330
+ }
331
+ const dataUrls = await Promise.all(images.map(fileToDataUrl));
332
+ const systemPrompt = this.resolveSystemPrompt(opts);
333
+ const userContent = [];
334
+ if (message) userContent.push({ type: "text", text: message });
335
+ for (const url of dataUrls) userContent.push({ type: "image_url", image_url: { url } });
336
+ const messages = [
337
+ { role: "system", content: systemPrompt },
338
+ ...opts.history ?? [],
339
+ { role: "user", content: userContent }
340
+ ];
341
+ const attempts = [];
342
+ let lastError;
343
+ for (const step of this.steps) {
344
+ const endpoint = PROVIDER_ENDPOINTS[step.provider];
345
+ if (!endpoint.visionModel) {
346
+ attempts.push({ provider: step.provider, model: step.model, status: "error", error: "no vision support", latencyMs: 0 });
347
+ continue;
348
+ }
349
+ const t0 = Date.now();
350
+ const visionStep = { provider: step.provider, model: endpoint.visionModel, label: `${step.provider}/${endpoint.visionModel}` };
351
+ try {
352
+ const key = this.keys[step.provider];
353
+ if (!key) throw new Error(`Missing API key for provider: ${step.provider}`);
354
+ const controller = new AbortController();
355
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
356
+ try {
357
+ const res = await this.fetcher(`${endpoint.baseUrl}/chat/completions`, {
358
+ method: "POST",
359
+ headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
360
+ body: JSON.stringify({ model: visionStep.model, messages, temperature: 0.3, max_tokens: 400 }),
361
+ signal: controller.signal
362
+ });
363
+ if (!res.ok) {
364
+ const body = await res.text();
365
+ throw new Error(`${res.status}: ${body.slice(0, 200)}`);
366
+ }
367
+ const data = await res.json();
368
+ const reply = (data.choices?.[0]?.message?.content ?? "").trim();
369
+ if (!reply) throw new Error("empty vision reply");
370
+ attempts.push({ provider: visionStep.provider, model: visionStep.model, status: "ok", latencyMs: Date.now() - t0 });
371
+ const guard = checkForbiddenPhrases(reply);
372
+ const finalReply = guard.ok ? reply : stripForbidden(reply);
373
+ return {
374
+ reply: finalReply,
375
+ usedProvider: visionStep.provider,
376
+ usedModel: visionStep.model,
377
+ ...data.usage ? { usage: data.usage } : {},
378
+ guardWarnings: guard.violations,
379
+ attempts
380
+ };
381
+ } finally {
382
+ clearTimeout(timer);
383
+ }
384
+ } catch (err) {
385
+ lastError = err;
386
+ const errMsg = err instanceof Error ? err.message : String(err);
387
+ attempts.push({ provider: visionStep.provider, model: visionStep.model, status: "error", error: errMsg, latencyMs: Date.now() - t0 });
388
+ if (!isRetryableError(err)) {
389
+ throw new Error(`chatbotlite: ${visionStep.label} vision failed (non-retryable). ${errMsg}`);
390
+ }
391
+ }
392
+ }
393
+ const summary = attempts.map((a) => `${a.provider}/${a.model}:${a.error ?? "ok"}`).join(" \u2192 ");
394
+ throw new Error(`chatbotlite: no vision-capable provider succeeded. Trace: ${summary}. Last error: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
119
395
  }
120
396
  async reply(message, opts = {}) {
121
- const systemPrompt = opts.systemPrompt ?? this.cachedSystemPrompt;
397
+ let inputVerdict;
398
+ if (this.guards.inputJudge) {
399
+ inputVerdict = await this.judge(this.guards.inputJudge, message);
400
+ if (inputVerdict.decision === "BLOCK") {
401
+ return {
402
+ reply: "I can't process that request. Please ask in a different way.",
403
+ usedProvider: this.steps[0].provider,
404
+ usedModel: this.steps[0].model,
405
+ guardWarnings: [],
406
+ judges: { input: inputVerdict },
407
+ attempts: [],
408
+ blockedByInputJudge: true
409
+ };
410
+ }
411
+ }
412
+ const systemPrompt = this.resolveSystemPrompt(opts);
122
413
  const messages = [
123
414
  { role: "system", content: systemPrompt },
124
415
  ...opts.history ?? [],
@@ -132,13 +423,21 @@ var ChatBot = class {
132
423
  const result = await this.callProvider(step, messages);
133
424
  attempts.push({ provider: step.provider, model: step.model, status: "ok", latencyMs: Date.now() - t0 });
134
425
  const guard = checkForbiddenPhrases(result.reply);
135
- const finalReply = guard.ok ? result.reply : stripForbidden(result.reply);
426
+ let finalReply = guard.ok ? result.reply : stripForbidden(result.reply);
427
+ let outputVerdict;
428
+ if (this.guards.outputJudge) {
429
+ outputVerdict = await this.judge(this.guards.outputJudge, finalReply);
430
+ if (outputVerdict.decision === "BLOCK") {
431
+ finalReply = "Let me check with the owner and get back to you on that.";
432
+ }
433
+ }
136
434
  return {
137
435
  reply: finalReply,
138
436
  usedProvider: step.provider,
139
437
  usedModel: step.model,
140
438
  ...result.usage ? { usage: result.usage } : {},
141
439
  guardWarnings: guard.violations,
440
+ ...inputVerdict || outputVerdict ? { judges: { ...inputVerdict ? { input: inputVerdict } : {}, ...outputVerdict ? { output: outputVerdict } : {} } } : {},
142
441
  attempts
143
442
  };
144
443
  } catch (err) {
@@ -224,6 +523,6 @@ function normalizeChainEntry(entry, keys) {
224
523
  return { provider, model, label: `${provider}/${model}` };
225
524
  }
226
525
 
227
- export { ChatBot, PROVIDER_ENDPOINTS, isKnownProvider, isRetryableError };
526
+ export { ChatBot, PROVIDER_ENDPOINTS, fileToDataUrl, isKnownProvider, isRetryableError };
228
527
  //# sourceMappingURL=index.js.map
229
528
  //# sourceMappingURL=index.js.map