@diegoaltoworks/chatter 0.44.0 → 0.45.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/dist/client/index.js +615 -0
- package/dist/client/index.mjs +592 -0
- package/dist/core/session.d.ts +1 -0
- package/dist/core/session.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.mjs +2 -1
- package/dist/server.js +1526 -0
- package/dist/server.mjs +1497 -0
- package/package.json +9 -5
- package/dist/auth/apikeys.test.d.ts +0 -2
- package/dist/auth/apikeys.test.d.ts.map +0 -1
- package/dist/channels/gates.test.d.ts +0 -2
- package/dist/channels/gates.test.d.ts.map +0 -1
- package/dist/channels/senders.test.d.ts +0 -2
- package/dist/channels/senders.test.d.ts.map +0 -1
- package/dist/channels/whatsapp/authState.test.d.ts +0 -2
- package/dist/channels/whatsapp/authState.test.d.ts.map +0 -1
- package/dist/channels/whatsapp/baileys.test.d.ts +0 -2
- package/dist/channels/whatsapp/baileys.test.d.ts.map +0 -1
- package/dist/channels/whatsapp/channel.test.d.ts +0 -2
- package/dist/channels/whatsapp/channel.test.d.ts.map +0 -1
- package/dist/channels/whatsapp/crypto.test.d.ts +0 -2
- package/dist/channels/whatsapp/crypto.test.d.ts.map +0 -1
- package/dist/channels/whatsapp/images.test.d.ts +0 -2
- package/dist/channels/whatsapp/images.test.d.ts.map +0 -1
- package/dist/channels/whatsapp/inbound.test.d.ts +0 -2
- package/dist/channels/whatsapp/inbound.test.d.ts.map +0 -1
- package/dist/channels/whatsapp/lease.test.d.ts +0 -2
- package/dist/channels/whatsapp/lease.test.d.ts.map +0 -1
- package/dist/channels/whatsapp/pairing.test.d.ts +0 -2
- package/dist/channels/whatsapp/pairing.test.d.ts.map +0 -1
- package/dist/channels/whatsapp/qr.test.d.ts +0 -2
- package/dist/channels/whatsapp/qr.test.d.ts.map +0 -1
- package/dist/client/__tests__/markdown.test.d.ts +0 -5
- package/dist/client/__tests__/markdown.test.d.ts.map +0 -1
- package/dist/client/__tests__/mobile-chat.test.d.ts +0 -6
- package/dist/client/__tests__/mobile-chat.test.d.ts.map +0 -1
- package/dist/client/examples/node.d.ts +0 -5
- package/dist/client/examples/node.d.ts.map +0 -1
- package/dist/core/answer.test.d.ts +0 -8
- package/dist/core/answer.test.d.ts.map +0 -1
- package/dist/core/buckets.test.d.ts +0 -10
- package/dist/core/buckets.test.d.ts.map +0 -1
- package/dist/core/guardrails.test.d.ts +0 -2
- package/dist/core/guardrails.test.d.ts.map +0 -1
- package/dist/core/loaders.test.d.ts +0 -2
- package/dist/core/loaders.test.d.ts.map +0 -1
- package/dist/core/messages.test.d.ts +0 -8
- package/dist/core/messages.test.d.ts.map +0 -1
- package/dist/core/pipeline.test.d.ts +0 -8
- package/dist/core/pipeline.test.d.ts.map +0 -1
- package/dist/core/prompts.test.d.ts +0 -2
- package/dist/core/prompts.test.d.ts.map +0 -1
- package/dist/core/retrieval.test.d.ts +0 -8
- package/dist/core/retrieval.test.d.ts.map +0 -1
- package/dist/core/session.test.d.ts +0 -2
- package/dist/core/session.test.d.ts.map +0 -1
- package/dist/core/widgets.test.d.ts +0 -2
- package/dist/core/widgets.test.d.ts.map +0 -1
- package/dist/flows/__fixtures__/broken/handler.d.ts +0 -3
- package/dist/flows/__fixtures__/broken/handler.d.ts.map +0 -1
- package/dist/flows/__fixtures__/greeting/handler.d.ts +0 -3
- package/dist/flows/__fixtures__/greeting/handler.d.ts.map +0 -1
- package/dist/flows/__fixtures__/greeting/prefill.d.ts +0 -3
- package/dist/flows/__fixtures__/greeting/prefill.d.ts.map +0 -1
- package/dist/flows/engine.test.d.ts +0 -9
- package/dist/flows/engine.test.d.ts.map +0 -1
- package/dist/flows/intent.test.d.ts +0 -6
- package/dist/flows/intent.test.d.ts.map +0 -1
- package/dist/flows/loader.test.d.ts +0 -8
- package/dist/flows/loader.test.d.ts.map +0 -1
- package/dist/flows/params.test.d.ts +0 -2
- package/dist/flows/params.test.d.ts.map +0 -1
- package/dist/flows/registry.test.d.ts +0 -2
- package/dist/flows/registry.test.d.ts.map +0 -1
- package/dist/flows/session.test.d.ts +0 -7
- package/dist/flows/session.test.d.ts.map +0 -1
- package/dist/flows/utils.test.d.ts +0 -2
- package/dist/flows/utils.test.d.ts.map +0 -1
- package/dist/images/cache.test.d.ts +0 -2
- package/dist/images/cache.test.d.ts.map +0 -1
- package/dist/images/cacheStore.test.d.ts +0 -8
- package/dist/images/cacheStore.test.d.ts.map +0 -1
- package/dist/images/captionComposer.test.d.ts +0 -2
- package/dist/images/captionComposer.test.d.ts.map +0 -1
- package/dist/images/cloudinaryClient.test.d.ts +0 -2
- package/dist/images/cloudinaryClient.test.d.ts.map +0 -1
- package/dist/images/cloudinaryConfig.test.d.ts +0 -2
- package/dist/images/cloudinaryConfig.test.d.ts.map +0 -1
- package/dist/images/cloudinarySignature.test.d.ts +0 -2
- package/dist/images/cloudinarySignature.test.d.ts.map +0 -1
- package/dist/images/config.test.d.ts +0 -2
- package/dist/images/config.test.d.ts.map +0 -1
- package/dist/images/generator.test.d.ts +0 -2
- package/dist/images/generator.test.d.ts.map +0 -1
- package/dist/images/openaiClient.test.d.ts +0 -2
- package/dist/images/openaiClient.test.d.ts.map +0 -1
- package/dist/images/uploader.test.d.ts +0 -2
- package/dist/images/uploader.test.d.ts.map +0 -1
- package/dist/mcp-server/conversation-id.test.d.ts +0 -2
- package/dist/mcp-server/conversation-id.test.d.ts.map +0 -1
- package/dist/mcp-server/cost-tracker.test.d.ts +0 -2
- package/dist/mcp-server/cost-tracker.test.d.ts.map +0 -1
- package/dist/mcp-server/logger.test.d.ts +0 -2
- package/dist/mcp-server/logger.test.d.ts.map +0 -1
- package/dist/mcp-server/rate-limiter.test.d.ts +0 -2
- package/dist/mcp-server/rate-limiter.test.d.ts.map +0 -1
- package/dist/middleware/auth.test.d.ts +0 -2
- package/dist/middleware/auth.test.d.ts.map +0 -1
- package/dist/middleware/cors.test.d.ts +0 -2
- package/dist/middleware/cors.test.d.ts.map +0 -1
- package/dist/middleware/jwt.test.d.ts +0 -2
- package/dist/middleware/jwt.test.d.ts.map +0 -1
- package/dist/middleware/ratelimit.test.d.ts +0 -2
- package/dist/middleware/ratelimit.test.d.ts.map +0 -1
- package/dist/middleware/referrer.test.d.ts +0 -2
- package/dist/middleware/referrer.test.d.ts.map +0 -1
- package/dist/middleware/session.test.d.ts +0 -2
- package/dist/middleware/session.test.d.ts.map +0 -1
- package/dist/personas/greeter.test.d.ts +0 -2
- package/dist/personas/greeter.test.d.ts.map +0 -1
- package/dist/personas/resolver.test.d.ts +0 -2
- package/dist/personas/resolver.test.d.ts.map +0 -1
- package/dist/personas/timeContext.test.d.ts +0 -2
- package/dist/personas/timeContext.test.d.ts.map +0 -1
- package/dist/routes/chat.test.d.ts +0 -9
- package/dist/routes/chat.test.d.ts.map +0 -1
- package/dist/routes/openai.test.d.ts +0 -8
- package/dist/routes/openai.test.d.ts.map +0 -1
- package/dist/scheduler/claimStore.test.d.ts +0 -2
- package/dist/scheduler/claimStore.test.d.ts.map +0 -1
- package/dist/scheduler/compose.test.d.ts +0 -2
- package/dist/scheduler/compose.test.d.ts.map +0 -1
- package/dist/scheduler/deliver.test.d.ts +0 -2
- package/dist/scheduler/deliver.test.d.ts.map +0 -1
- package/dist/scheduler/dueDrops.test.d.ts +0 -2
- package/dist/scheduler/dueDrops.test.d.ts.map +0 -1
- package/dist/scheduler/scheduler.test.d.ts +0 -2
- package/dist/scheduler/scheduler.test.d.ts.map +0 -1
- package/dist/scheduler/tick.test.d.ts +0 -2
- package/dist/scheduler/tick.test.d.ts.map +0 -1
- package/dist/server.test.d.ts +0 -9
- package/dist/server.test.d.ts.map +0 -1
- package/dist/shortener.test.d.ts +0 -2
- package/dist/shortener.test.d.ts.map +0 -1
- package/dist/usage/limits.test.d.ts +0 -10
- package/dist/usage/limits.test.d.ts.map +0 -1
- package/dist/usage/tursoStore.test.d.ts +0 -9
- package/dist/usage/tursoStore.test.d.ts.map +0 -1
package/dist/server.js
ADDED
|
@@ -0,0 +1,1526 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/server.ts
|
|
31
|
+
var server_exports = {};
|
|
32
|
+
__export(server_exports, {
|
|
33
|
+
createServer: () => createServer
|
|
34
|
+
});
|
|
35
|
+
module.exports = __toCommonJS(server_exports);
|
|
36
|
+
var import_client2 = require("@libsql/client");
|
|
37
|
+
var import_hono5 = require("hono");
|
|
38
|
+
var import_bun = require("hono/bun");
|
|
39
|
+
var import_openai = __toESM(require("openai"));
|
|
40
|
+
|
|
41
|
+
// src/auth/apikeys.ts
|
|
42
|
+
var import_node_crypto = require("node:crypto");
|
|
43
|
+
var import_jose = require("jose");
|
|
44
|
+
var ApiKeyManager = class {
|
|
45
|
+
secretBytes;
|
|
46
|
+
constructor(secret) {
|
|
47
|
+
if (!secret) {
|
|
48
|
+
throw new Error("API key secret is required");
|
|
49
|
+
}
|
|
50
|
+
this.secretBytes = new TextEncoder().encode(secret);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Create a new API key
|
|
54
|
+
*
|
|
55
|
+
* @param options - API key options
|
|
56
|
+
* @returns JWT token string
|
|
57
|
+
*/
|
|
58
|
+
async create(options = {}) {
|
|
59
|
+
const keyId = (0, import_node_crypto.randomUUID)();
|
|
60
|
+
const keyName = options.name || "api-key";
|
|
61
|
+
const expiresIn = options.expiresIn || "365d";
|
|
62
|
+
const expirationMs = this.parseExpiration(expiresIn);
|
|
63
|
+
const expiresAt = new Date(Date.now() + expirationMs);
|
|
64
|
+
const jwt = await new import_jose.SignJWT({
|
|
65
|
+
name: keyName,
|
|
66
|
+
type: "api_key",
|
|
67
|
+
...options.claims
|
|
68
|
+
}).setProtectedHeader({ alg: "HS256" }).setSubject(keyId).setIssuedAt().setExpirationTime(expiresAt).sign(this.secretBytes);
|
|
69
|
+
return jwt;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Verify an API key
|
|
73
|
+
*
|
|
74
|
+
* @param token - JWT token to verify
|
|
75
|
+
* @returns Verification result with payload if valid
|
|
76
|
+
*/
|
|
77
|
+
async verify(token) {
|
|
78
|
+
try {
|
|
79
|
+
const { payload } = await (0, import_jose.jwtVerify)(token, this.secretBytes);
|
|
80
|
+
if (payload.type !== "api_key") {
|
|
81
|
+
return {
|
|
82
|
+
valid: false,
|
|
83
|
+
error: "Token is not an API key"
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
valid: true,
|
|
88
|
+
payload
|
|
89
|
+
};
|
|
90
|
+
} catch (error) {
|
|
91
|
+
return {
|
|
92
|
+
valid: false,
|
|
93
|
+
error: error instanceof Error ? error.message : "Verification failed"
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Parse expiration string to milliseconds
|
|
99
|
+
*
|
|
100
|
+
* @param expiresIn - Time string (e.g., "1y", "30d", "12h")
|
|
101
|
+
* @returns Milliseconds
|
|
102
|
+
*/
|
|
103
|
+
parseExpiration(expiresIn) {
|
|
104
|
+
const match = expiresIn.match(/^(\d+)([smhdMy])$/);
|
|
105
|
+
if (!match) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
`Invalid expiration format: ${expiresIn}. Supported: s (seconds), m (minutes), h (hours), d (days), M (months), y (years)`
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
const value = Number.parseInt(match[1], 10);
|
|
111
|
+
const unit = match[2];
|
|
112
|
+
switch (unit) {
|
|
113
|
+
case "s":
|
|
114
|
+
return value * 1e3;
|
|
115
|
+
case "m":
|
|
116
|
+
return value * 60 * 1e3;
|
|
117
|
+
case "h":
|
|
118
|
+
return value * 60 * 60 * 1e3;
|
|
119
|
+
case "d":
|
|
120
|
+
return value * 24 * 60 * 60 * 1e3;
|
|
121
|
+
case "M":
|
|
122
|
+
return value * 30 * 24 * 60 * 60 * 1e3;
|
|
123
|
+
// Approximate 30 days
|
|
124
|
+
case "y":
|
|
125
|
+
return value * 365 * 24 * 60 * 60 * 1e3;
|
|
126
|
+
// Approximate 365 days
|
|
127
|
+
default:
|
|
128
|
+
throw new Error(`Unknown time unit: ${unit}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Get information about a token without verifying signature
|
|
133
|
+
* Useful for debugging
|
|
134
|
+
*
|
|
135
|
+
* @param token - JWT token
|
|
136
|
+
* @returns Decoded payload (unverified)
|
|
137
|
+
*/
|
|
138
|
+
decode(token) {
|
|
139
|
+
try {
|
|
140
|
+
const parts = token.split(".");
|
|
141
|
+
if (parts.length !== 3) {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf-8"));
|
|
145
|
+
return payload;
|
|
146
|
+
} catch {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// src/channels/senders.ts
|
|
153
|
+
function createSenderRegistry() {
|
|
154
|
+
const senders = /* @__PURE__ */ new Map();
|
|
155
|
+
return {
|
|
156
|
+
register(name, sender) {
|
|
157
|
+
if (senders.has(name)) {
|
|
158
|
+
console.warn(`Channel sender "${name}" is already registered; overwriting.`);
|
|
159
|
+
}
|
|
160
|
+
senders.set(name, sender);
|
|
161
|
+
},
|
|
162
|
+
unregister(name) {
|
|
163
|
+
senders.delete(name);
|
|
164
|
+
},
|
|
165
|
+
available(name) {
|
|
166
|
+
return senders.has(name);
|
|
167
|
+
},
|
|
168
|
+
async sendText(name, chatId, text) {
|
|
169
|
+
const sender = senders.get(name);
|
|
170
|
+
if (!sender) return false;
|
|
171
|
+
try {
|
|
172
|
+
await sender.sendText(chatId, text);
|
|
173
|
+
return true;
|
|
174
|
+
} catch (error) {
|
|
175
|
+
console.warn(`Channel sender "${name}" sendText failed:`, error);
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
async sendVoice(name, chatId, payload) {
|
|
180
|
+
const sender = senders.get(name);
|
|
181
|
+
if (!sender?.sendVoice) return false;
|
|
182
|
+
try {
|
|
183
|
+
await sender.sendVoice(chatId, payload);
|
|
184
|
+
return true;
|
|
185
|
+
} catch (error) {
|
|
186
|
+
console.warn(`Channel sender "${name}" sendVoice failed:`, error);
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
async sendMedia(name, chatId, payload) {
|
|
191
|
+
const sender = senders.get(name);
|
|
192
|
+
if (!sender?.sendMedia) return false;
|
|
193
|
+
try {
|
|
194
|
+
await sender.sendMedia(chatId, payload);
|
|
195
|
+
return true;
|
|
196
|
+
} catch (error) {
|
|
197
|
+
console.warn(`Channel sender "${name}" sendMedia failed:`, error);
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// src/core/prompts.ts
|
|
205
|
+
var import_node_fs = require("node:fs");
|
|
206
|
+
var import_node_path = require("node:path");
|
|
207
|
+
var PromptLoader = class {
|
|
208
|
+
constructor(promptsDir, bot) {
|
|
209
|
+
this.promptsDir = promptsDir;
|
|
210
|
+
this.bot = bot;
|
|
211
|
+
}
|
|
212
|
+
promptsDir;
|
|
213
|
+
bot;
|
|
214
|
+
/**
|
|
215
|
+
* Load a prompt template and interpolate variables
|
|
216
|
+
*/
|
|
217
|
+
load(filename) {
|
|
218
|
+
const path = (0, import_node_path.join)(this.promptsDir, filename);
|
|
219
|
+
const template = (0, import_node_fs.readFileSync)(path, "utf-8");
|
|
220
|
+
const personFirstName = this.bot.personName.split(" ")[0];
|
|
221
|
+
return template.replace(/\{\{botName\}\}/g, this.bot.name).replace(/\{\{personName\}\}/g, this.bot.personName).replace(/\{\{personFirstName\}\}/g, personFirstName);
|
|
222
|
+
}
|
|
223
|
+
/** Base system rules prompt */
|
|
224
|
+
get baseSystemRules() {
|
|
225
|
+
return this.load("base.txt");
|
|
226
|
+
}
|
|
227
|
+
/** Public persona prompt */
|
|
228
|
+
get publicPersona() {
|
|
229
|
+
return this.load("public.txt");
|
|
230
|
+
}
|
|
231
|
+
/** Private persona prompt */
|
|
232
|
+
get privatePersona() {
|
|
233
|
+
return this.load("private.txt");
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
// src/core/retrieval.ts
|
|
238
|
+
var import_client = require("@libsql/client");
|
|
239
|
+
|
|
240
|
+
// src/core/loaders.ts
|
|
241
|
+
var import_node_fs2 = require("node:fs");
|
|
242
|
+
var import_node_path2 = require("node:path");
|
|
243
|
+
function walk(dir) {
|
|
244
|
+
const items = [];
|
|
245
|
+
try {
|
|
246
|
+
for (const e of (0, import_node_fs2.readdirSync)(dir)) {
|
|
247
|
+
const p = (0, import_node_path2.join)(dir, e);
|
|
248
|
+
const st = (0, import_node_fs2.statSync)(p);
|
|
249
|
+
if (st.isDirectory()) items.push(...walk(p));
|
|
250
|
+
else if (p.endsWith(".md")) items.push(p);
|
|
251
|
+
}
|
|
252
|
+
} catch {
|
|
253
|
+
}
|
|
254
|
+
return items;
|
|
255
|
+
}
|
|
256
|
+
function loadKnowledge(root = "config/knowledge") {
|
|
257
|
+
const docs = [];
|
|
258
|
+
for (const bucket of ["base", "public", "private"]) {
|
|
259
|
+
const dir = (0, import_node_path2.join)(root, bucket);
|
|
260
|
+
for (const f of walk(dir)) {
|
|
261
|
+
docs.push({ id: f, text: (0, import_node_fs2.readFileSync)(f, "utf8"), source: f, bucket });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return docs;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// src/core/retrieval.ts
|
|
268
|
+
var EMB_MODEL = "text-embedding-3-large";
|
|
269
|
+
function chunk(text, max = 900) {
|
|
270
|
+
const out = [];
|
|
271
|
+
let buf = "";
|
|
272
|
+
for (const line of text.replace(/\r/g, "").split("\n")) {
|
|
273
|
+
if (`${buf}
|
|
274
|
+
${line}`.length > max) {
|
|
275
|
+
out.push(buf.trim());
|
|
276
|
+
buf = line;
|
|
277
|
+
} else buf += `
|
|
278
|
+
${line}`;
|
|
279
|
+
}
|
|
280
|
+
if (buf.trim()) out.push(buf.trim());
|
|
281
|
+
return out;
|
|
282
|
+
}
|
|
283
|
+
async function sha256(input) {
|
|
284
|
+
const data = new TextEncoder().encode(input);
|
|
285
|
+
const hash = await crypto.subtle.digest("SHA-256", data);
|
|
286
|
+
return Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
287
|
+
}
|
|
288
|
+
var VectorStore = class _VectorStore {
|
|
289
|
+
constructor(client, options) {
|
|
290
|
+
this.client = client;
|
|
291
|
+
this.db = options.databaseClient ? options.databaseClient : (0, import_client.createClient)({
|
|
292
|
+
url: options.databaseUrl,
|
|
293
|
+
authToken: options.databaseAuthToken
|
|
294
|
+
});
|
|
295
|
+
this.knowledgeDir = options.knowledgeDir || "./config/knowledge";
|
|
296
|
+
}
|
|
297
|
+
client;
|
|
298
|
+
/**
|
|
299
|
+
* The libsql client backing this store. When a client was injected this is
|
|
300
|
+
* that same instance, so callers holding it (e.g. `ServerDependencies.db`)
|
|
301
|
+
* and the store share one connection.
|
|
302
|
+
*/
|
|
303
|
+
db;
|
|
304
|
+
knowledgeDir;
|
|
305
|
+
// On boot: ingest new chunks and embed only missing ones.
|
|
306
|
+
async build() {
|
|
307
|
+
console.log("\u{1F504} Building knowledge base...");
|
|
308
|
+
await this.db.execute(`
|
|
309
|
+
CREATE TABLE IF NOT EXISTS chunks (id TEXT PRIMARY KEY, bucket TEXT NOT NULL, source TEXT NOT NULL, text TEXT NOT NULL);
|
|
310
|
+
`);
|
|
311
|
+
await this.db.execute(`
|
|
312
|
+
CREATE TABLE IF NOT EXISTS embeddings (id TEXT PRIMARY KEY, model TEXT NOT NULL, embedding BLOB NOT NULL);
|
|
313
|
+
`);
|
|
314
|
+
await this.db.execute("CREATE INDEX IF NOT EXISTS idx_chunks_bucket ON chunks(bucket);");
|
|
315
|
+
const docs = loadKnowledge(this.knowledgeDir);
|
|
316
|
+
console.log(`\u{1F4DA} Loaded ${docs.length} knowledge documents`);
|
|
317
|
+
const rows = [];
|
|
318
|
+
for (const d of docs) {
|
|
319
|
+
for (const part of chunk(d.text)) {
|
|
320
|
+
const id = await sha256(`${d.bucket}|${d.source}|${part}`);
|
|
321
|
+
rows.push({ id, bucket: d.bucket, source: d.source, text: part });
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
console.log(`\u{1F4E6} Created ${rows.length} chunks from knowledge documents`);
|
|
325
|
+
await this.cleanupStaleChunks(rows.map((r) => r.id));
|
|
326
|
+
const tx = await this.db.transaction("write");
|
|
327
|
+
try {
|
|
328
|
+
for (const r of rows) {
|
|
329
|
+
await tx.execute({
|
|
330
|
+
sql: `INSERT INTO chunks(id,bucket,source,text) VALUES(?,?,?,?)
|
|
331
|
+
ON CONFLICT(id) DO NOTHING`,
|
|
332
|
+
args: [r.id, r.bucket, r.source, r.text]
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
await tx.commit();
|
|
336
|
+
} catch (e) {
|
|
337
|
+
await tx.rollback();
|
|
338
|
+
throw e;
|
|
339
|
+
}
|
|
340
|
+
const ids = rows.map((r) => r.id);
|
|
341
|
+
const missing = [];
|
|
342
|
+
for (let i = 0; i < ids.length; i += 500) {
|
|
343
|
+
const batch = ids.slice(i, i + 500);
|
|
344
|
+
const placeholders = batch.map(() => "?").join(",");
|
|
345
|
+
const res = await this.db.execute({
|
|
346
|
+
sql: `SELECT id FROM chunks WHERE id IN (${placeholders})
|
|
347
|
+
EXCEPT SELECT id FROM embeddings`,
|
|
348
|
+
args: batch
|
|
349
|
+
});
|
|
350
|
+
for (const row of res.rows) missing.push(String(row.id));
|
|
351
|
+
}
|
|
352
|
+
if (missing.length === 0) {
|
|
353
|
+
console.log("\u2705 No new chunks to embed - knowledge base is up to date");
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
console.log(`\u{1F52E} Embedding ${missing.length} new/updated chunks with ${EMB_MODEL}...`);
|
|
357
|
+
const textById = new Map(rows.map((r) => [r.id, r.text]));
|
|
358
|
+
const BATCH = 96;
|
|
359
|
+
for (let i = 0; i < missing.length; i += BATCH) {
|
|
360
|
+
const batchIds = missing.slice(i, i + BATCH);
|
|
361
|
+
const inputs = batchIds.map((id) => textById.get(id) || "");
|
|
362
|
+
const emb = await this.client.embeddings.create({ model: EMB_MODEL, input: inputs });
|
|
363
|
+
const tx2 = await this.db.transaction("write");
|
|
364
|
+
try {
|
|
365
|
+
emb.data.forEach((d, idx) => {
|
|
366
|
+
const id = batchIds[idx];
|
|
367
|
+
tx2.execute({
|
|
368
|
+
sql: "INSERT INTO embeddings(id,model,embedding) VALUES(?,?,?)",
|
|
369
|
+
args: [id, EMB_MODEL, JSON.stringify(d.embedding)]
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
await tx2.commit();
|
|
373
|
+
} catch (e) {
|
|
374
|
+
await tx2.rollback();
|
|
375
|
+
throw e;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
console.log(`\u2705 Successfully embedded ${missing.length} new chunks`);
|
|
379
|
+
}
|
|
380
|
+
// Remove chunks from database that no longer exist in markdown files
|
|
381
|
+
async cleanupStaleChunks(currentIds) {
|
|
382
|
+
const result = await this.db.execute("SELECT id FROM chunks");
|
|
383
|
+
const dbIds = result.rows.map((row) => String(row.id));
|
|
384
|
+
const currentIdSet = new Set(currentIds);
|
|
385
|
+
const staleIds = dbIds.filter((id) => !currentIdSet.has(id));
|
|
386
|
+
if (staleIds.length === 0) {
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
console.log(`\u{1F9F9} Cleaning up ${staleIds.length} stale chunks...`);
|
|
390
|
+
const BATCH_SIZE = 500;
|
|
391
|
+
for (let i = 0; i < staleIds.length; i += BATCH_SIZE) {
|
|
392
|
+
const batch = staleIds.slice(i, i + BATCH_SIZE);
|
|
393
|
+
const placeholders = batch.map(() => "?").join(",");
|
|
394
|
+
await this.db.execute({
|
|
395
|
+
sql: `DELETE FROM chunks WHERE id IN (${placeholders})`,
|
|
396
|
+
args: batch
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
console.log(`\u2713 Cleaned up ${staleIds.length} stale chunks and their embeddings`);
|
|
400
|
+
}
|
|
401
|
+
static cosine(a, b) {
|
|
402
|
+
let dot = 0;
|
|
403
|
+
let na = 0;
|
|
404
|
+
let nb = 0;
|
|
405
|
+
for (let i = 0; i < a.length; i++) {
|
|
406
|
+
dot += a[i] * b[i];
|
|
407
|
+
na += a[i] * a[i];
|
|
408
|
+
nb += b[i] * b[i];
|
|
409
|
+
}
|
|
410
|
+
return dot / (Math.sqrt(na) * Math.sqrt(nb));
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Retrieve top-k across allowed buckets; compute similarity in app (simple
|
|
414
|
+
* & portable).
|
|
415
|
+
*
|
|
416
|
+
* `allowed` is a bucket-name list rather than the {@link Bucket} union: the
|
|
417
|
+
* `chunks` table constrains nothing, so a deployment that ingests its own
|
|
418
|
+
* buckets can query them (`build` only writes and prunes the three the
|
|
419
|
+
* knowledge loader walks). Names are bound as query parameters, never
|
|
420
|
+
* interpolated. An empty list retrieves nothing, and short-circuits before
|
|
421
|
+
* the embedding call.
|
|
422
|
+
*/
|
|
423
|
+
async query(q, k = 6, allowed = ["base"]) {
|
|
424
|
+
if (allowed.length === 0) return [];
|
|
425
|
+
const qv = (await this.client.embeddings.create({ model: EMB_MODEL, input: [q] })).data[0].embedding;
|
|
426
|
+
const placeholders = allowed.map(() => "?").join(",");
|
|
427
|
+
const res = await this.db.execute({
|
|
428
|
+
sql: `SELECT c.id, c.text, e.embedding
|
|
429
|
+
FROM chunks c
|
|
430
|
+
JOIN embeddings e ON e.id = c.id
|
|
431
|
+
WHERE c.bucket IN (${placeholders})`,
|
|
432
|
+
args: allowed
|
|
433
|
+
});
|
|
434
|
+
const scored = [];
|
|
435
|
+
for (const row of res.rows) {
|
|
436
|
+
const emb = JSON.parse(String(row.embedding));
|
|
437
|
+
const s = _VectorStore.cosine(qv, emb);
|
|
438
|
+
scored.push({ s, text: String(row.text) });
|
|
439
|
+
}
|
|
440
|
+
scored.sort((a, b) => b.s - a.s);
|
|
441
|
+
return scored.slice(0, k).map((r) => r.text);
|
|
442
|
+
}
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
// src/core/widgets.ts
|
|
446
|
+
var import_node_fs3 = require("node:fs");
|
|
447
|
+
var import_node_path3 = require("node:path");
|
|
448
|
+
var import_node_url = require("node:url");
|
|
449
|
+
var import_meta = {};
|
|
450
|
+
function resolveStatic(configStaticDir) {
|
|
451
|
+
if (configStaticDir && (0, import_node_fs3.existsSync)(configStaticDir)) {
|
|
452
|
+
return { staticDir: configStaticDir };
|
|
453
|
+
}
|
|
454
|
+
try {
|
|
455
|
+
if (import_meta.url) {
|
|
456
|
+
const moduleDir = (0, import_node_path3.dirname)((0, import_node_url.fileURLToPath)(import_meta.url));
|
|
457
|
+
const staticDir = (0, import_node_path3.join)(moduleDir, "static");
|
|
458
|
+
if ((0, import_node_fs3.existsSync)(staticDir)) return { staticDir };
|
|
459
|
+
}
|
|
460
|
+
} catch {
|
|
461
|
+
}
|
|
462
|
+
try {
|
|
463
|
+
if (typeof __dirname !== "undefined") {
|
|
464
|
+
const staticDir = (0, import_node_path3.join)(__dirname, "static");
|
|
465
|
+
if ((0, import_node_fs3.existsSync)(staticDir)) return { staticDir };
|
|
466
|
+
}
|
|
467
|
+
} catch {
|
|
468
|
+
}
|
|
469
|
+
return { staticDir: null };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// src/middleware/cors.ts
|
|
473
|
+
var ALLOWED_HEADERS = "content-type, x-api-key, authorization";
|
|
474
|
+
var ALLOWED_METHODS = "POST, GET, OPTIONS";
|
|
475
|
+
var cors = (allowedOrigins) => async (c, next) => {
|
|
476
|
+
const requestOrigin = c.req.header("origin");
|
|
477
|
+
const allowAll = !allowedOrigins || allowedOrigins.length === 0 || allowedOrigins.includes("*");
|
|
478
|
+
const originHeader = resolveOrigin(requestOrigin, allowedOrigins, allowAll);
|
|
479
|
+
if (c.req.method === "OPTIONS") {
|
|
480
|
+
return new Response(null, {
|
|
481
|
+
status: 204,
|
|
482
|
+
headers: {
|
|
483
|
+
"access-control-allow-origin": originHeader,
|
|
484
|
+
"access-control-allow-headers": ALLOWED_HEADERS,
|
|
485
|
+
"access-control-allow-methods": ALLOWED_METHODS,
|
|
486
|
+
...allowAll ? {} : { vary: "Origin" }
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
c.header("access-control-allow-origin", originHeader);
|
|
491
|
+
c.header("access-control-allow-headers", ALLOWED_HEADERS);
|
|
492
|
+
c.header("access-control-allow-methods", ALLOWED_METHODS);
|
|
493
|
+
if (!allowAll) {
|
|
494
|
+
c.header("vary", "Origin");
|
|
495
|
+
}
|
|
496
|
+
await next();
|
|
497
|
+
};
|
|
498
|
+
function resolveOrigin(requestOrigin, allowedOrigins, allowAll) {
|
|
499
|
+
if (allowAll) return "*";
|
|
500
|
+
if (requestOrigin && allowedOrigins.includes(requestOrigin)) {
|
|
501
|
+
return requestOrigin;
|
|
502
|
+
}
|
|
503
|
+
return allowedOrigins[0];
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// src/routes/demo.ts
|
|
507
|
+
var import_hono = require("hono");
|
|
508
|
+
var import_streaming = require("hono/streaming");
|
|
509
|
+
|
|
510
|
+
// src/core/guardrails.ts
|
|
511
|
+
function detectLeakage(text) {
|
|
512
|
+
const flags = [
|
|
513
|
+
/system prompt/i,
|
|
514
|
+
/hidden instruction/i,
|
|
515
|
+
/here (are|is) (my|the) rules/i,
|
|
516
|
+
/BEGIN SYSTEM PROMPT/i,
|
|
517
|
+
/tool instructions/i
|
|
518
|
+
];
|
|
519
|
+
return flags.some((rx) => rx.test(text));
|
|
520
|
+
}
|
|
521
|
+
function scrubOutput(text) {
|
|
522
|
+
return text.replace(/sk-[A-Za-z0-9]{15,}/g, "[REDACTED]").replace(/AIza[0-9A-Za-z_-]{30,}/g, "[REDACTED]");
|
|
523
|
+
}
|
|
524
|
+
function guardOutput(text) {
|
|
525
|
+
if (detectLeakage(text)) {
|
|
526
|
+
return "Sorry, I can't share internal instructions. How else can I help?";
|
|
527
|
+
}
|
|
528
|
+
return scrubOutput(text);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// src/core/llm.ts
|
|
532
|
+
var DEFAULT_MODEL = "gpt-4o";
|
|
533
|
+
async function completeOnce({
|
|
534
|
+
client,
|
|
535
|
+
system,
|
|
536
|
+
messages,
|
|
537
|
+
temperature = 0.2,
|
|
538
|
+
model = DEFAULT_MODEL
|
|
539
|
+
}) {
|
|
540
|
+
const res = await client.chat.completions.create({
|
|
541
|
+
model,
|
|
542
|
+
temperature,
|
|
543
|
+
messages: [{ role: "system", content: system }, ...messages]
|
|
544
|
+
});
|
|
545
|
+
const text = res.choices[0]?.message?.content ?? "";
|
|
546
|
+
return {
|
|
547
|
+
content: guardOutput(text),
|
|
548
|
+
usage: res.usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
async function* completeStream({
|
|
552
|
+
client,
|
|
553
|
+
system,
|
|
554
|
+
messages,
|
|
555
|
+
temperature = 0.2,
|
|
556
|
+
model = DEFAULT_MODEL
|
|
557
|
+
}) {
|
|
558
|
+
const stream5 = await client.chat.completions.create({
|
|
559
|
+
model,
|
|
560
|
+
temperature,
|
|
561
|
+
stream: true,
|
|
562
|
+
messages: [{ role: "system", content: system }, ...messages]
|
|
563
|
+
});
|
|
564
|
+
for await (const chunk2 of stream5) {
|
|
565
|
+
const delta = chunk2.choices[0]?.delta?.content ?? "";
|
|
566
|
+
if (delta) yield scrubOutput(delta);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// src/core/answer.ts
|
|
571
|
+
var noUsage = () => ({ prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 });
|
|
572
|
+
async function runAnswerFn(answerFn, { system, messages, mode, sender }) {
|
|
573
|
+
const result = await answerFn({ system, messages, mode, sender });
|
|
574
|
+
const raw = typeof result === "string" ? result : result?.content;
|
|
575
|
+
const usage = typeof result === "string" ? void 0 : result?.usage;
|
|
576
|
+
return {
|
|
577
|
+
content: guardOutput(typeof raw === "string" ? raw : ""),
|
|
578
|
+
usage: usage ?? noUsage()
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
async function answerOnce({
|
|
582
|
+
answerFn,
|
|
583
|
+
client,
|
|
584
|
+
system,
|
|
585
|
+
messages,
|
|
586
|
+
mode,
|
|
587
|
+
sender,
|
|
588
|
+
temperature,
|
|
589
|
+
model
|
|
590
|
+
}) {
|
|
591
|
+
if (answerFn) {
|
|
592
|
+
return runAnswerFn(answerFn, { system, messages, mode, sender });
|
|
593
|
+
}
|
|
594
|
+
return completeOnce({ client, system, messages, temperature, model });
|
|
595
|
+
}
|
|
596
|
+
async function* answerStream({
|
|
597
|
+
answerFn,
|
|
598
|
+
client,
|
|
599
|
+
system,
|
|
600
|
+
messages,
|
|
601
|
+
mode,
|
|
602
|
+
sender,
|
|
603
|
+
temperature,
|
|
604
|
+
model
|
|
605
|
+
}) {
|
|
606
|
+
if (answerFn) {
|
|
607
|
+
const { content } = await runAnswerFn(answerFn, { system, messages, mode, sender });
|
|
608
|
+
if (content) yield content;
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
yield* completeStream({ client, system, messages, temperature, model });
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// src/core/buckets.ts
|
|
615
|
+
function defaultBuckets(mode) {
|
|
616
|
+
return ["base", mode];
|
|
617
|
+
}
|
|
618
|
+
function isIdentified(sender) {
|
|
619
|
+
return typeof sender === "string" && sender.trim().length > 0;
|
|
620
|
+
}
|
|
621
|
+
function normalize(buckets) {
|
|
622
|
+
const out = [];
|
|
623
|
+
for (const raw of buckets) {
|
|
624
|
+
if (typeof raw !== "string") continue;
|
|
625
|
+
const bucket = raw.trim();
|
|
626
|
+
if (bucket && !out.includes(bucket)) out.push(bucket);
|
|
627
|
+
}
|
|
628
|
+
return out;
|
|
629
|
+
}
|
|
630
|
+
async function resolveBuckets({
|
|
631
|
+
mode,
|
|
632
|
+
sender,
|
|
633
|
+
bucketsFor
|
|
634
|
+
}) {
|
|
635
|
+
if (!bucketsFor) return void 0;
|
|
636
|
+
const identified = isIdentified(sender);
|
|
637
|
+
const requested = await bucketsFor(identified ? { mode, sender } : { mode });
|
|
638
|
+
if (!Array.isArray(requested)) return void 0;
|
|
639
|
+
const cleaned = normalize(requested);
|
|
640
|
+
if (identified) return cleaned;
|
|
641
|
+
const ceiling = new Set(defaultBuckets(mode));
|
|
642
|
+
return cleaned.filter((bucket) => ceiling.has(bucket));
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// src/core/messages.ts
|
|
646
|
+
function isClientRole(role) {
|
|
647
|
+
return role === "user" || role === "assistant";
|
|
648
|
+
}
|
|
649
|
+
function normalizeMessages(raw) {
|
|
650
|
+
if (!Array.isArray(raw) || raw.length === 0) return null;
|
|
651
|
+
const messages = [];
|
|
652
|
+
for (const entry of raw) {
|
|
653
|
+
if (!entry || typeof entry !== "object") return null;
|
|
654
|
+
const { role, content } = entry;
|
|
655
|
+
if (!isClientRole(role)) continue;
|
|
656
|
+
let text = null;
|
|
657
|
+
if (typeof content === "string") {
|
|
658
|
+
text = content;
|
|
659
|
+
} else if (Array.isArray(content)) {
|
|
660
|
+
text = content.filter(
|
|
661
|
+
(part) => !!part && typeof part === "object" && part.type === "text" && typeof part.text === "string"
|
|
662
|
+
).map((part) => part.text).join("");
|
|
663
|
+
}
|
|
664
|
+
if (text === null) continue;
|
|
665
|
+
messages.push({ role, content: text });
|
|
666
|
+
}
|
|
667
|
+
return messages.length > 0 ? messages : null;
|
|
668
|
+
}
|
|
669
|
+
function normalizeChatBody(body) {
|
|
670
|
+
const source = body && typeof body === "object" ? body : {};
|
|
671
|
+
if (Array.isArray(source.messages)) {
|
|
672
|
+
if (source.messages.length === 0) {
|
|
673
|
+
return { ok: false, error: "messages array cannot be empty" };
|
|
674
|
+
}
|
|
675
|
+
const messages = normalizeMessages(source.messages);
|
|
676
|
+
if (!messages) {
|
|
677
|
+
return {
|
|
678
|
+
ok: false,
|
|
679
|
+
error: "messages must contain user/assistant messages with text content"
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
return { ok: true, messages };
|
|
683
|
+
}
|
|
684
|
+
const { message } = source;
|
|
685
|
+
if (message && typeof message !== "object") {
|
|
686
|
+
return { ok: true, messages: [{ role: "user", content: String(message) }] };
|
|
687
|
+
}
|
|
688
|
+
return { ok: false, error: "either 'message' or 'messages' required" };
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// src/core/session.ts
|
|
692
|
+
var import_node_crypto2 = require("node:crypto");
|
|
693
|
+
var sessions = /* @__PURE__ */ new Map();
|
|
694
|
+
var sessionSweeper = setInterval(
|
|
695
|
+
() => {
|
|
696
|
+
const now = Date.now();
|
|
697
|
+
for (const [key, data] of Array.from(sessions.entries())) {
|
|
698
|
+
if (now > data.expiresAt) {
|
|
699
|
+
sessions.delete(key);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
},
|
|
703
|
+
5 * 60 * 1e3
|
|
704
|
+
);
|
|
705
|
+
sessionSweeper.unref?.();
|
|
706
|
+
function createSession(options = {}) {
|
|
707
|
+
const { maxRequests = 20, ttl = 3600, metadata } = options;
|
|
708
|
+
const key = `session_${(0, import_node_crypto2.randomUUID)()}`;
|
|
709
|
+
const now = Date.now();
|
|
710
|
+
const session = {
|
|
711
|
+
key,
|
|
712
|
+
created: now,
|
|
713
|
+
requests: 0,
|
|
714
|
+
maxRequests,
|
|
715
|
+
expiresAt: now + ttl * 1e3,
|
|
716
|
+
...metadata && { metadata }
|
|
717
|
+
};
|
|
718
|
+
sessions.set(key, session);
|
|
719
|
+
return session;
|
|
720
|
+
}
|
|
721
|
+
function validateSession(key) {
|
|
722
|
+
const session = sessions.get(key);
|
|
723
|
+
if (!session) return false;
|
|
724
|
+
const now = Date.now();
|
|
725
|
+
if (now > session.expiresAt) {
|
|
726
|
+
sessions.delete(key);
|
|
727
|
+
return false;
|
|
728
|
+
}
|
|
729
|
+
if (session.requests >= session.maxRequests) {
|
|
730
|
+
return false;
|
|
731
|
+
}
|
|
732
|
+
session.requests++;
|
|
733
|
+
return true;
|
|
734
|
+
}
|
|
735
|
+
function getSessionInfo(key) {
|
|
736
|
+
return sessions.get(key) || null;
|
|
737
|
+
}
|
|
738
|
+
function getActiveSessions() {
|
|
739
|
+
return sessions.size;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// src/routes/demo.ts
|
|
743
|
+
function checkDemoOrigin(allowedOrigins) {
|
|
744
|
+
const allowAll = allowedOrigins.length === 0 || allowedOrigins.includes("*");
|
|
745
|
+
return async (c, next) => {
|
|
746
|
+
const origin = c.req.header("origin");
|
|
747
|
+
const referer = c.req.header("referer");
|
|
748
|
+
const host = c.req.header("host") || "";
|
|
749
|
+
if (host.startsWith("localhost:") || host.startsWith("127.0.0.1:")) {
|
|
750
|
+
return next();
|
|
751
|
+
}
|
|
752
|
+
if (allowAll) {
|
|
753
|
+
return next();
|
|
754
|
+
}
|
|
755
|
+
if (origin) {
|
|
756
|
+
const allowed = allowedOrigins.some((allowed2) => origin.startsWith(allowed2));
|
|
757
|
+
if (allowed) return next();
|
|
758
|
+
}
|
|
759
|
+
if (referer) {
|
|
760
|
+
const allowed = allowedOrigins.some((allowed2) => referer.startsWith(allowed2));
|
|
761
|
+
if (allowed) return next();
|
|
762
|
+
}
|
|
763
|
+
return c.json(
|
|
764
|
+
{
|
|
765
|
+
error: "Demo access restricted to authorized domains"
|
|
766
|
+
},
|
|
767
|
+
403
|
|
768
|
+
);
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
function limitSessionCreation() {
|
|
772
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
773
|
+
const WINDOW_MS = 36e5;
|
|
774
|
+
const LIMIT = 10;
|
|
775
|
+
return async (c, next) => {
|
|
776
|
+
const ip = c.req.header("x-forwarded-for")?.split(",")[0].trim() || "unknown-ip";
|
|
777
|
+
const key = `session:${ip}`;
|
|
778
|
+
const now = Date.now();
|
|
779
|
+
const b = buckets.get(key) ?? { count: 0, windowStart: now };
|
|
780
|
+
if (now - b.windowStart >= WINDOW_MS) {
|
|
781
|
+
b.count = 0;
|
|
782
|
+
b.windowStart = now;
|
|
783
|
+
}
|
|
784
|
+
b.count += 1;
|
|
785
|
+
buckets.set(key, b);
|
|
786
|
+
if (b.count > LIMIT) {
|
|
787
|
+
return c.json(
|
|
788
|
+
{
|
|
789
|
+
error: "Session key limit exceeded (10 per hour). Please wait or use your own API key."
|
|
790
|
+
},
|
|
791
|
+
429
|
|
792
|
+
);
|
|
793
|
+
}
|
|
794
|
+
await next();
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
function limitDemo() {
|
|
798
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
799
|
+
const WINDOW_MS = 6e4;
|
|
800
|
+
const LIMIT = 10;
|
|
801
|
+
return async (c, next) => {
|
|
802
|
+
const ip = c.req.header("x-forwarded-for")?.split(",")[0].trim() || "unknown-ip";
|
|
803
|
+
const key = `demo:${ip}`;
|
|
804
|
+
const now = Date.now();
|
|
805
|
+
const b = buckets.get(key) ?? { count: 0, windowStart: now };
|
|
806
|
+
if (now - b.windowStart >= WINDOW_MS) {
|
|
807
|
+
b.count = 0;
|
|
808
|
+
b.windowStart = now;
|
|
809
|
+
}
|
|
810
|
+
b.count += 1;
|
|
811
|
+
buckets.set(key, b);
|
|
812
|
+
if (b.count > LIMIT) {
|
|
813
|
+
return c.json(
|
|
814
|
+
{
|
|
815
|
+
error: "Demo rate limit exceeded (10 requests/minute). Please wait or use your own API key."
|
|
816
|
+
},
|
|
817
|
+
429
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
await next();
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
function demoRoutes(deps) {
|
|
824
|
+
const { client, store, config, prompts } = deps;
|
|
825
|
+
const app = new import_hono.Hono();
|
|
826
|
+
const demoOrigins = config.server?.allowedOrigins ?? [];
|
|
827
|
+
app.get("/api/demo/session", checkDemoOrigin(demoOrigins), limitSessionCreation(), (c) => {
|
|
828
|
+
const session = createSession({
|
|
829
|
+
maxRequests: 20,
|
|
830
|
+
ttl: 3600
|
|
831
|
+
// 1 hour
|
|
832
|
+
});
|
|
833
|
+
console.log(
|
|
834
|
+
`[Demo] Created session: ${session.key.slice(0, 20)}... (${session.maxRequests} requests, ${3600}s TTL)`
|
|
835
|
+
);
|
|
836
|
+
return c.json({
|
|
837
|
+
key: session.key,
|
|
838
|
+
expiresIn: 3600,
|
|
839
|
+
maxRequests: session.maxRequests,
|
|
840
|
+
message: "Use this temporary key in the x-api-key header"
|
|
841
|
+
});
|
|
842
|
+
});
|
|
843
|
+
app.get("/api/demo/stats", (c) => {
|
|
844
|
+
return c.json({
|
|
845
|
+
activeSessions: getActiveSessions()
|
|
846
|
+
});
|
|
847
|
+
});
|
|
848
|
+
app.post("/api/demo/chat", limitDemo(), async (c) => {
|
|
849
|
+
try {
|
|
850
|
+
const body = await c.req.json().catch(() => ({}));
|
|
851
|
+
const normalized = normalizeChatBody(body);
|
|
852
|
+
if (!normalized.ok) {
|
|
853
|
+
return c.json({ error: normalized.error }, 400);
|
|
854
|
+
}
|
|
855
|
+
const { messages } = normalized;
|
|
856
|
+
const lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
|
|
857
|
+
if (!lastUserMsg) {
|
|
858
|
+
return c.json({ error: "no user message found in conversation" }, 400);
|
|
859
|
+
}
|
|
860
|
+
const buckets = await resolveBuckets({ mode: "public", bucketsFor: config.bucketsFor }) ?? defaultBuckets("public");
|
|
861
|
+
const ctx = await store.query(lastUserMsg.content, 6, buckets);
|
|
862
|
+
const system = [
|
|
863
|
+
prompts.baseSystemRules,
|
|
864
|
+
prompts.publicPersona,
|
|
865
|
+
`Context:
|
|
866
|
+
${ctx.join("\n\n")}`
|
|
867
|
+
].join("\n\n");
|
|
868
|
+
const url = new URL(c.req.url);
|
|
869
|
+
const wantsStream3 = url.searchParams.get("stream") === "1";
|
|
870
|
+
if (wantsStream3) {
|
|
871
|
+
return (0, import_streaming.stream)(c, async (s) => {
|
|
872
|
+
for await (const delta of answerStream({
|
|
873
|
+
answerFn: config.answerFn,
|
|
874
|
+
client,
|
|
875
|
+
system,
|
|
876
|
+
messages,
|
|
877
|
+
mode: "public"
|
|
878
|
+
})) {
|
|
879
|
+
await s.write(`data: ${JSON.stringify({ delta })}
|
|
880
|
+
|
|
881
|
+
`);
|
|
882
|
+
}
|
|
883
|
+
await s.write("event: end\ndata: {}\n\n");
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
const out = await answerOnce({
|
|
887
|
+
answerFn: config.answerFn,
|
|
888
|
+
client,
|
|
889
|
+
system,
|
|
890
|
+
messages,
|
|
891
|
+
mode: "public"
|
|
892
|
+
});
|
|
893
|
+
return c.json({ reply: out.content });
|
|
894
|
+
} catch (error) {
|
|
895
|
+
const err = error;
|
|
896
|
+
console.error("Demo chat error:", err);
|
|
897
|
+
return c.json({ error: err.message || "Internal error" }, 500);
|
|
898
|
+
}
|
|
899
|
+
});
|
|
900
|
+
return app;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// src/routes/openai.ts
|
|
904
|
+
var import_hono2 = require("hono");
|
|
905
|
+
var import_streaming2 = require("hono/streaming");
|
|
906
|
+
|
|
907
|
+
// src/core/pipeline.ts
|
|
908
|
+
var MODE_SETTINGS = {
|
|
909
|
+
public: { topK: 6, label: "Context" },
|
|
910
|
+
private: { topK: 8, label: "Internal Context" }
|
|
911
|
+
};
|
|
912
|
+
async function prepareChat({
|
|
913
|
+
store,
|
|
914
|
+
prompts,
|
|
915
|
+
mode,
|
|
916
|
+
messages,
|
|
917
|
+
personaLayer,
|
|
918
|
+
channelHint,
|
|
919
|
+
buckets
|
|
920
|
+
}) {
|
|
921
|
+
const lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
|
|
922
|
+
if (!lastUserMsg) {
|
|
923
|
+
throw new Error("no user message found in conversation");
|
|
924
|
+
}
|
|
925
|
+
const { topK, label } = MODE_SETTINGS[mode];
|
|
926
|
+
const ctx = await store.query(lastUserMsg.content, topK, buckets ?? defaultBuckets(mode));
|
|
927
|
+
const persona = personaLayer?.trim() || (mode === "private" ? prompts.privatePersona : prompts.publicPersona);
|
|
928
|
+
const hint = channelHint?.trim();
|
|
929
|
+
const system = [
|
|
930
|
+
prompts.baseSystemRules,
|
|
931
|
+
persona,
|
|
932
|
+
...hint ? [hint] : [],
|
|
933
|
+
`${label}:
|
|
934
|
+
${ctx.join("\n\n")}`
|
|
935
|
+
].join("\n\n");
|
|
936
|
+
return { system, messages };
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// src/middleware/jwt.ts
|
|
940
|
+
var import_jose2 = require("jose");
|
|
941
|
+
function jwtSubject(c) {
|
|
942
|
+
const sub = c.jwtSub;
|
|
943
|
+
return sub && sub.trim().length > 0 ? sub : void 0;
|
|
944
|
+
}
|
|
945
|
+
function createJWTMiddleware(config) {
|
|
946
|
+
let jwks = null;
|
|
947
|
+
let spkiKey = null;
|
|
948
|
+
const jwtConfig = config.auth?.jwt;
|
|
949
|
+
async function verify(token) {
|
|
950
|
+
const opts = {};
|
|
951
|
+
if (jwtConfig?.issuer) opts.issuer = jwtConfig.issuer;
|
|
952
|
+
if (jwtConfig?.audience) opts.audience = jwtConfig.audience;
|
|
953
|
+
if (jwtConfig?.jwksUrl) {
|
|
954
|
+
jwks = jwks ?? (0, import_jose2.createRemoteJWKSet)(new URL(jwtConfig.jwksUrl));
|
|
955
|
+
const { payload } = await (0, import_jose2.jwtVerify)(token, jwks, opts);
|
|
956
|
+
return payload;
|
|
957
|
+
}
|
|
958
|
+
if (jwtConfig?.publicKeyPem) {
|
|
959
|
+
spkiKey = spkiKey ?? await (0, import_jose2.importSPKI)(jwtConfig.publicKeyPem, "RS256");
|
|
960
|
+
const { payload } = await (0, import_jose2.jwtVerify)(token, spkiKey, opts);
|
|
961
|
+
return payload;
|
|
962
|
+
}
|
|
963
|
+
throw new Error("JWT configuration missing (JWKS URL or PUBLIC KEY PEM).");
|
|
964
|
+
}
|
|
965
|
+
return async function requirePrivateJWT2(c, next) {
|
|
966
|
+
const hdr = c.req.header("authorization") ?? "";
|
|
967
|
+
if (!hdr.startsWith("Bearer ")) return c.json({ error: "Unauthorized" }, 401);
|
|
968
|
+
const token = hdr.slice("Bearer ".length);
|
|
969
|
+
try {
|
|
970
|
+
const payload = await verify(token);
|
|
971
|
+
c.jwtSub = String(payload.sub ?? "");
|
|
972
|
+
await next();
|
|
973
|
+
} catch {
|
|
974
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
975
|
+
}
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
var requirePrivateJWT = createJWTMiddleware({});
|
|
979
|
+
|
|
980
|
+
// src/middleware/ratelimit.ts
|
|
981
|
+
var DEMO_KEYS = ["chatter-api-key-here"];
|
|
982
|
+
function getKeyFromIP(c) {
|
|
983
|
+
const h = c.req.header("x-forwarded-for");
|
|
984
|
+
if (h) return h.split(",")[0].trim();
|
|
985
|
+
return "unknown-ip";
|
|
986
|
+
}
|
|
987
|
+
function createRateLimiter(config) {
|
|
988
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
989
|
+
const WINDOW_MS = 6e4;
|
|
990
|
+
const publicLimit = config.rateLimit?.public || 60;
|
|
991
|
+
const privateLimit = config.rateLimit?.private || 120;
|
|
992
|
+
function allow(key, limit) {
|
|
993
|
+
const now = Date.now();
|
|
994
|
+
const b = buckets.get(key) ?? { count: 0, windowStart: now };
|
|
995
|
+
if (now - b.windowStart >= WINDOW_MS) {
|
|
996
|
+
b.count = 0;
|
|
997
|
+
b.windowStart = now;
|
|
998
|
+
}
|
|
999
|
+
b.count += 1;
|
|
1000
|
+
buckets.set(key, b);
|
|
1001
|
+
return b.count <= limit;
|
|
1002
|
+
}
|
|
1003
|
+
return {
|
|
1004
|
+
// Public: key is IP only
|
|
1005
|
+
// Demo keys get 10x stricter rate limits
|
|
1006
|
+
limitPublic() {
|
|
1007
|
+
return async (c, next) => {
|
|
1008
|
+
const apiKey = c.req.header("x-api-key");
|
|
1009
|
+
const isDemoKey = apiKey ? DEMO_KEYS.includes(apiKey) : false;
|
|
1010
|
+
const key = `pub:${getKeyFromIP(c)}`;
|
|
1011
|
+
const limit = isDemoKey ? Math.floor(publicLimit / 10) : publicLimit;
|
|
1012
|
+
if (!allow(key, limit)) {
|
|
1013
|
+
return c.json(
|
|
1014
|
+
{
|
|
1015
|
+
error: isDemoKey ? "Demo rate limit exceeded. Please use your own API key for higher limits." : "Rate limit exceeded"
|
|
1016
|
+
},
|
|
1017
|
+
429
|
|
1018
|
+
);
|
|
1019
|
+
}
|
|
1020
|
+
await next();
|
|
1021
|
+
};
|
|
1022
|
+
},
|
|
1023
|
+
// Private: key is JWT subject only
|
|
1024
|
+
limitPrivate() {
|
|
1025
|
+
return async (c, next) => {
|
|
1026
|
+
const sub = c.jwtSub;
|
|
1027
|
+
const key = `private:${sub ?? "no-sub"}`;
|
|
1028
|
+
if (!allow(key, privateLimit)) return c.json({ error: "Rate limit" }, 429);
|
|
1029
|
+
await next();
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
var defaultLimiter = createRateLimiter({});
|
|
1035
|
+
var limitPublic = defaultLimiter.limitPublic;
|
|
1036
|
+
var limitPrivate = defaultLimiter.limitPrivate;
|
|
1037
|
+
|
|
1038
|
+
// src/routes/openai.ts
|
|
1039
|
+
function errorJson(c, status, message) {
|
|
1040
|
+
return c.json(
|
|
1041
|
+
{ error: { message, type: status === 401 ? "authentication_error" : "invalid_request_error" } },
|
|
1042
|
+
status
|
|
1043
|
+
);
|
|
1044
|
+
}
|
|
1045
|
+
function openaiRoutes(deps) {
|
|
1046
|
+
const { client, store, config, prompts, apiKeyManager } = deps;
|
|
1047
|
+
const app = new import_hono2.Hono();
|
|
1048
|
+
const { limitPublic: limitPublic2, limitPrivate: limitPrivate2 } = createRateLimiter(config);
|
|
1049
|
+
const requirePrivateJWT2 = createJWTMiddleware(config);
|
|
1050
|
+
const enablePublic = config.features?.enablePublicChat !== false;
|
|
1051
|
+
const enablePrivate = config.features?.enablePrivateChat !== false;
|
|
1052
|
+
const serverModel = config.openai.model || DEFAULT_MODEL;
|
|
1053
|
+
async function requireApiKey(c, next) {
|
|
1054
|
+
if (!apiKeyManager) {
|
|
1055
|
+
return errorJson(c, 401, "API key authentication is not configured on this server");
|
|
1056
|
+
}
|
|
1057
|
+
const bearer = c.req.header("authorization") ?? "";
|
|
1058
|
+
const key = bearer.startsWith("Bearer ") ? bearer.slice("Bearer ".length) : c.req.header("x-api-key");
|
|
1059
|
+
if (!key) {
|
|
1060
|
+
return errorJson(c, 401, "Missing API key (Authorization: Bearer or x-api-key header)");
|
|
1061
|
+
}
|
|
1062
|
+
try {
|
|
1063
|
+
const result = await apiKeyManager.verify(key);
|
|
1064
|
+
if (result.valid) return await next();
|
|
1065
|
+
} catch {
|
|
1066
|
+
}
|
|
1067
|
+
return errorJson(c, 401, "Invalid API key");
|
|
1068
|
+
}
|
|
1069
|
+
const handleChatCompletions = (mode) => async (c) => {
|
|
1070
|
+
const body = await c.req.json().catch(() => null);
|
|
1071
|
+
if (!body || typeof body !== "object") {
|
|
1072
|
+
return errorJson(c, 400, "Request body must be a JSON object");
|
|
1073
|
+
}
|
|
1074
|
+
const messages = normalizeMessages(body.messages);
|
|
1075
|
+
if (!messages) {
|
|
1076
|
+
return errorJson(c, 400, "'messages' must be a non-empty array of user/assistant messages");
|
|
1077
|
+
}
|
|
1078
|
+
const { stream: wantsStream3, temperature: rawTemperature } = body;
|
|
1079
|
+
const temperature = typeof rawTemperature === "number" && Number.isFinite(rawTemperature) ? Math.min(Math.max(rawTemperature, 0), 2) : void 0;
|
|
1080
|
+
const buckets = await resolveBuckets({
|
|
1081
|
+
mode,
|
|
1082
|
+
sender: jwtSubject(c),
|
|
1083
|
+
bucketsFor: config.bucketsFor
|
|
1084
|
+
});
|
|
1085
|
+
let system;
|
|
1086
|
+
try {
|
|
1087
|
+
({ system } = await prepareChat({ store, prompts, mode, messages, buckets }));
|
|
1088
|
+
} catch {
|
|
1089
|
+
return errorJson(c, 400, "Conversation must contain at least one user message");
|
|
1090
|
+
}
|
|
1091
|
+
const id = `chatcmpl-${crypto.randomUUID()}`;
|
|
1092
|
+
const created = Math.floor(Date.now() / 1e3);
|
|
1093
|
+
if (wantsStream3 === true) {
|
|
1094
|
+
c.header("Content-Type", "text/event-stream");
|
|
1095
|
+
c.header("Cache-Control", "no-cache");
|
|
1096
|
+
return (0, import_streaming2.stream)(c, async (s) => {
|
|
1097
|
+
const chunk2 = (delta, finishReason) => `data: ${JSON.stringify({
|
|
1098
|
+
id,
|
|
1099
|
+
object: "chat.completion.chunk",
|
|
1100
|
+
created,
|
|
1101
|
+
model: serverModel,
|
|
1102
|
+
choices: [{ index: 0, delta, finish_reason: finishReason }]
|
|
1103
|
+
})}
|
|
1104
|
+
|
|
1105
|
+
`;
|
|
1106
|
+
await s.write(chunk2({ role: "assistant", content: "" }, null));
|
|
1107
|
+
for await (const delta of answerStream({
|
|
1108
|
+
answerFn: config.answerFn,
|
|
1109
|
+
client,
|
|
1110
|
+
system,
|
|
1111
|
+
messages,
|
|
1112
|
+
mode,
|
|
1113
|
+
temperature,
|
|
1114
|
+
model: serverModel
|
|
1115
|
+
})) {
|
|
1116
|
+
await s.write(chunk2({ content: delta }, null));
|
|
1117
|
+
}
|
|
1118
|
+
await s.write(chunk2({}, "stop"));
|
|
1119
|
+
await s.write("data: [DONE]\n\n");
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
const out = await answerOnce({
|
|
1123
|
+
answerFn: config.answerFn,
|
|
1124
|
+
client,
|
|
1125
|
+
system,
|
|
1126
|
+
messages,
|
|
1127
|
+
mode,
|
|
1128
|
+
temperature,
|
|
1129
|
+
model: serverModel
|
|
1130
|
+
});
|
|
1131
|
+
return c.json({
|
|
1132
|
+
id,
|
|
1133
|
+
object: "chat.completion",
|
|
1134
|
+
created,
|
|
1135
|
+
model: serverModel,
|
|
1136
|
+
choices: [
|
|
1137
|
+
{ index: 0, message: { role: "assistant", content: out.content }, finish_reason: "stop" }
|
|
1138
|
+
],
|
|
1139
|
+
usage: out.usage
|
|
1140
|
+
});
|
|
1141
|
+
};
|
|
1142
|
+
if (enablePublic) {
|
|
1143
|
+
app.use("/v1/*", requireApiKey);
|
|
1144
|
+
app.use("/v1/*", limitPublic2());
|
|
1145
|
+
app.post("/v1/chat/completions", handleChatCompletions("public"));
|
|
1146
|
+
}
|
|
1147
|
+
if (enablePrivate) {
|
|
1148
|
+
app.use("/api/private/v1/*", requirePrivateJWT2);
|
|
1149
|
+
app.use("/api/private/v1/*", limitPrivate2());
|
|
1150
|
+
app.post("/api/private/v1/chat/completions", handleChatCompletions("private"));
|
|
1151
|
+
}
|
|
1152
|
+
return app;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// src/routes/private.ts
|
|
1156
|
+
var import_hono3 = require("hono");
|
|
1157
|
+
var import_streaming3 = require("hono/streaming");
|
|
1158
|
+
function wantsStream(c) {
|
|
1159
|
+
const url = new URL(c.req.url);
|
|
1160
|
+
if (url.searchParams.get("stream") === "1") return true;
|
|
1161
|
+
const acc = c.req.header("accept") ?? "";
|
|
1162
|
+
return acc.includes("text/event-stream");
|
|
1163
|
+
}
|
|
1164
|
+
function privateRoutes(deps) {
|
|
1165
|
+
const { client, store, config, prompts } = deps;
|
|
1166
|
+
const app = new import_hono3.Hono();
|
|
1167
|
+
const requirePrivateJWT2 = createJWTMiddleware(config);
|
|
1168
|
+
const { limitPrivate: limitPrivate2 } = createRateLimiter(config);
|
|
1169
|
+
app.use("/api/private/*", requirePrivateJWT2);
|
|
1170
|
+
app.use("/api/private/*", limitPrivate2());
|
|
1171
|
+
app.post("/api/private/chat", async (c) => {
|
|
1172
|
+
const body = await c.req.json().catch(() => ({}));
|
|
1173
|
+
const normalized = normalizeChatBody(body);
|
|
1174
|
+
if (!normalized.ok) {
|
|
1175
|
+
return c.json({ error: normalized.error }, 400);
|
|
1176
|
+
}
|
|
1177
|
+
const { messages } = normalized;
|
|
1178
|
+
const buckets = await resolveBuckets({
|
|
1179
|
+
mode: "private",
|
|
1180
|
+
sender: jwtSubject(c),
|
|
1181
|
+
bucketsFor: config.bucketsFor
|
|
1182
|
+
});
|
|
1183
|
+
let system;
|
|
1184
|
+
try {
|
|
1185
|
+
({ system } = await prepareChat({ store, prompts, mode: "private", messages, buckets }));
|
|
1186
|
+
} catch {
|
|
1187
|
+
return c.json({ error: "no user message found in conversation" }, 400);
|
|
1188
|
+
}
|
|
1189
|
+
const model = config.openai.model;
|
|
1190
|
+
const answerFn = config.answerFn;
|
|
1191
|
+
if (wantsStream(c)) {
|
|
1192
|
+
return (0, import_streaming3.stream)(c, async (s) => {
|
|
1193
|
+
for await (const delta of answerStream({
|
|
1194
|
+
answerFn,
|
|
1195
|
+
client,
|
|
1196
|
+
system,
|
|
1197
|
+
messages,
|
|
1198
|
+
mode: "private",
|
|
1199
|
+
model
|
|
1200
|
+
})) {
|
|
1201
|
+
await s.write(`data: ${JSON.stringify({ delta })}
|
|
1202
|
+
|
|
1203
|
+
`);
|
|
1204
|
+
}
|
|
1205
|
+
await s.write("event: end\ndata: {}\n\n");
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1208
|
+
const out = await answerOnce({ answerFn, client, system, messages, mode: "private", model });
|
|
1209
|
+
return c.json({ reply: out.content });
|
|
1210
|
+
});
|
|
1211
|
+
return app;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
// src/routes/public.ts
|
|
1215
|
+
var import_hono4 = require("hono");
|
|
1216
|
+
var import_streaming4 = require("hono/streaming");
|
|
1217
|
+
|
|
1218
|
+
// src/middleware/auth.ts
|
|
1219
|
+
function createAuthMiddleware(deps) {
|
|
1220
|
+
const { apiKeyManager } = deps;
|
|
1221
|
+
return async function requirePublicKey(c, next) {
|
|
1222
|
+
const hdr = c.req.header("x-api-key");
|
|
1223
|
+
if (hdr?.startsWith("session_")) {
|
|
1224
|
+
console.log(`[Auth] Session key allowed: ${hdr.slice(0, 20)}...`);
|
|
1225
|
+
await next();
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
if (!apiKeyManager) {
|
|
1229
|
+
console.log("[Auth] No API key manager configured");
|
|
1230
|
+
return c.json({ error: "Unauthorized - API key authentication not configured" }, 401);
|
|
1231
|
+
}
|
|
1232
|
+
if (!hdr) {
|
|
1233
|
+
console.log("[Auth] No API key provided");
|
|
1234
|
+
return c.json({ error: "Unauthorized - Missing x-api-key header" }, 401);
|
|
1235
|
+
}
|
|
1236
|
+
try {
|
|
1237
|
+
const result = await apiKeyManager.verify(hdr);
|
|
1238
|
+
if (result.valid) {
|
|
1239
|
+
const payload = result.payload;
|
|
1240
|
+
console.log(`[Auth] JWT API key authorized: ${payload.name || payload.sub || "unknown"}`);
|
|
1241
|
+
await next();
|
|
1242
|
+
return;
|
|
1243
|
+
}
|
|
1244
|
+
console.log(`[Auth] JWT verification failed: ${result.error}`);
|
|
1245
|
+
} catch (error) {
|
|
1246
|
+
console.log(`[Auth] JWT verification error: ${error}`);
|
|
1247
|
+
}
|
|
1248
|
+
console.log(`[Auth] Invalid API key: ${hdr?.slice(0, 20) || "none"}`);
|
|
1249
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
// src/middleware/referrer.ts
|
|
1254
|
+
var DEMO_KEYS2 = ["chatter-api-key-here"];
|
|
1255
|
+
function requireReferrer(allowedOrigins) {
|
|
1256
|
+
return async (c, next) => {
|
|
1257
|
+
const apiKey = c.req.header("x-api-key");
|
|
1258
|
+
const isDemoKey = apiKey && DEMO_KEYS2.includes(apiKey);
|
|
1259
|
+
const isSessionKey = apiKey?.startsWith("session_");
|
|
1260
|
+
if (isDemoKey || isSessionKey) {
|
|
1261
|
+
const referer = c.req.header("referer");
|
|
1262
|
+
const origin = c.req.header("origin");
|
|
1263
|
+
console.log(
|
|
1264
|
+
`[Referrer] Checking ${isSessionKey ? "session" : "demo"} key: ${apiKey?.slice(0, 20)}...`
|
|
1265
|
+
);
|
|
1266
|
+
console.log(`[Referrer] Origin: ${origin || "none"}, Referer: ${referer || "none"}`);
|
|
1267
|
+
const isAllowed = allowedOrigins.some((allowed) => {
|
|
1268
|
+
if (origin === allowed) return true;
|
|
1269
|
+
if (referer) {
|
|
1270
|
+
if (referer === allowed) return true;
|
|
1271
|
+
if (referer.startsWith(`${allowed}/`)) return true;
|
|
1272
|
+
if (referer.startsWith(`${allowed}?`)) return true;
|
|
1273
|
+
}
|
|
1274
|
+
return false;
|
|
1275
|
+
});
|
|
1276
|
+
if (!isAllowed) {
|
|
1277
|
+
console.log(`[Referrer] BLOCKED - not from allowed origins: ${allowedOrigins.join(", ")}`);
|
|
1278
|
+
return c.json(
|
|
1279
|
+
{
|
|
1280
|
+
error: "Demo API keys only work on official demo pages. Please visit our website or use your own API key."
|
|
1281
|
+
},
|
|
1282
|
+
403
|
|
1283
|
+
);
|
|
1284
|
+
}
|
|
1285
|
+
console.log("[Referrer] Allowed - origin matches");
|
|
1286
|
+
}
|
|
1287
|
+
await next();
|
|
1288
|
+
};
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
// src/middleware/session.ts
|
|
1292
|
+
function validateSessionKey() {
|
|
1293
|
+
return async (c, next) => {
|
|
1294
|
+
const apiKey = c.req.header("x-api-key");
|
|
1295
|
+
if (apiKey?.startsWith("session_")) {
|
|
1296
|
+
console.log(`[Session] Validating key: ${apiKey.slice(0, 20)}...`);
|
|
1297
|
+
const info = getSessionInfo(apiKey);
|
|
1298
|
+
if (info) {
|
|
1299
|
+
console.log(
|
|
1300
|
+
`[Session] Key found - requests: ${info.requests}/${info.maxRequests}, expires: ${new Date(info.expiresAt).toISOString()}`
|
|
1301
|
+
);
|
|
1302
|
+
} else {
|
|
1303
|
+
console.log("[Session] Key not found in session store");
|
|
1304
|
+
}
|
|
1305
|
+
const isValid = validateSession(apiKey);
|
|
1306
|
+
if (!isValid) {
|
|
1307
|
+
console.log("[Session] REJECTED - expired or quota exceeded");
|
|
1308
|
+
return c.json(
|
|
1309
|
+
{
|
|
1310
|
+
error: "Session expired or quota exceeded. Please refresh the page to get a new session."
|
|
1311
|
+
},
|
|
1312
|
+
401
|
|
1313
|
+
);
|
|
1314
|
+
}
|
|
1315
|
+
console.log("[Session] Valid - request count incremented");
|
|
1316
|
+
}
|
|
1317
|
+
await next();
|
|
1318
|
+
};
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
// src/routes/public.ts
|
|
1322
|
+
function wantsStream2(c) {
|
|
1323
|
+
const url = new URL(c.req.url);
|
|
1324
|
+
if (url.searchParams.get("stream") === "1") return true;
|
|
1325
|
+
const acc = c.req.header("accept") ?? "";
|
|
1326
|
+
return acc.includes("text/event-stream");
|
|
1327
|
+
}
|
|
1328
|
+
function publicRoutes(deps) {
|
|
1329
|
+
const { client, store, config, prompts } = deps;
|
|
1330
|
+
const app = new import_hono4.Hono();
|
|
1331
|
+
const requirePublicKey = createAuthMiddleware(deps);
|
|
1332
|
+
const { limitPublic: limitPublic2 } = createRateLimiter(config);
|
|
1333
|
+
const referrerOrigins = config.server?.allowedOrigins?.length ? config.server.allowedOrigins.filter((o) => o !== "*") : [config.bot.publicUrl, "http://localhost:8181", "http://127.0.0.1:8181"];
|
|
1334
|
+
app.use("/api/public/*", validateSessionKey());
|
|
1335
|
+
app.use("/api/public/*", requirePublicKey);
|
|
1336
|
+
app.use("/api/public/*", requireReferrer(referrerOrigins));
|
|
1337
|
+
app.use("/api/public/*", limitPublic2());
|
|
1338
|
+
app.post("/api/public/chat", async (c) => {
|
|
1339
|
+
const body = await c.req.json().catch(() => ({}));
|
|
1340
|
+
const normalized = normalizeChatBody(body);
|
|
1341
|
+
if (!normalized.ok) {
|
|
1342
|
+
return c.json({ error: normalized.error }, 400);
|
|
1343
|
+
}
|
|
1344
|
+
const { messages } = normalized;
|
|
1345
|
+
const buckets = await resolveBuckets({ mode: "public", bucketsFor: config.bucketsFor });
|
|
1346
|
+
let system;
|
|
1347
|
+
try {
|
|
1348
|
+
({ system } = await prepareChat({ store, prompts, mode: "public", messages, buckets }));
|
|
1349
|
+
} catch {
|
|
1350
|
+
return c.json({ error: "no user message found in conversation" }, 400);
|
|
1351
|
+
}
|
|
1352
|
+
const model = config.openai.model;
|
|
1353
|
+
const answerFn = config.answerFn;
|
|
1354
|
+
if (wantsStream2(c)) {
|
|
1355
|
+
return (0, import_streaming4.stream)(c, async (s) => {
|
|
1356
|
+
for await (const delta of answerStream({
|
|
1357
|
+
answerFn,
|
|
1358
|
+
client,
|
|
1359
|
+
system,
|
|
1360
|
+
messages,
|
|
1361
|
+
mode: "public",
|
|
1362
|
+
model
|
|
1363
|
+
})) {
|
|
1364
|
+
await s.write(`data: ${JSON.stringify({ delta })}
|
|
1365
|
+
|
|
1366
|
+
`);
|
|
1367
|
+
}
|
|
1368
|
+
await s.write("event: end\ndata: {}\n\n");
|
|
1369
|
+
});
|
|
1370
|
+
}
|
|
1371
|
+
const out = await answerOnce({ answerFn, client, system, messages, mode: "public", model });
|
|
1372
|
+
return c.json({ reply: out.content });
|
|
1373
|
+
});
|
|
1374
|
+
return app;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
// src/server.ts
|
|
1378
|
+
async function createServer(config) {
|
|
1379
|
+
console.log(`\u{1F680} Starting ${config.bot.name}...`);
|
|
1380
|
+
const knowledgeDir = config.knowledgeDir || "./config/knowledge";
|
|
1381
|
+
const promptsDir = config.promptsDir || "./config/prompts";
|
|
1382
|
+
const publicDir = config.publicDir || "./public";
|
|
1383
|
+
const enablePublic = config.features?.enablePublicChat !== false;
|
|
1384
|
+
const enablePrivate = config.features?.enablePrivateChat !== false;
|
|
1385
|
+
const enableDemo = config.features?.enableDemoRoutes || false;
|
|
1386
|
+
const enableOpenAICompat = config.features?.enableOpenAICompat !== false;
|
|
1387
|
+
const headless = config.features?.headless === true;
|
|
1388
|
+
const { staticDir } = resolveStatic(config.staticDir);
|
|
1389
|
+
const client = new import_openai.default({
|
|
1390
|
+
apiKey: config.openai.apiKey
|
|
1391
|
+
});
|
|
1392
|
+
const db = (0, import_client2.createClient)({
|
|
1393
|
+
url: config.database.url,
|
|
1394
|
+
authToken: config.database.authToken
|
|
1395
|
+
});
|
|
1396
|
+
const store = new VectorStore(client, {
|
|
1397
|
+
databaseClient: db,
|
|
1398
|
+
knowledgeDir
|
|
1399
|
+
});
|
|
1400
|
+
await store.build();
|
|
1401
|
+
console.log("\u2705 Knowledge base ready");
|
|
1402
|
+
const prompts = new PromptLoader(promptsDir, config.bot);
|
|
1403
|
+
let apiKeyManager;
|
|
1404
|
+
const secret = process.env.CHATTER_SECRET || config.auth?.secret;
|
|
1405
|
+
if (secret) {
|
|
1406
|
+
apiKeyManager = new ApiKeyManager(secret);
|
|
1407
|
+
console.log("\u2705 API key manager initialized");
|
|
1408
|
+
} else if (config.auth?.apiKeyManager) {
|
|
1409
|
+
apiKeyManager = config.auth.apiKeyManager;
|
|
1410
|
+
console.log("\u2705 Custom API key manager configured");
|
|
1411
|
+
}
|
|
1412
|
+
const app = new import_hono5.Hono();
|
|
1413
|
+
if (config.server?.cors !== false) {
|
|
1414
|
+
app.use("*", cors(config.server?.allowedOrigins));
|
|
1415
|
+
}
|
|
1416
|
+
app.get("/healthz", (c) => c.text("ok"));
|
|
1417
|
+
app.get(
|
|
1418
|
+
"/config",
|
|
1419
|
+
(c) => c.json({
|
|
1420
|
+
botName: config.bot.name,
|
|
1421
|
+
publicUrl: config.bot.publicUrl,
|
|
1422
|
+
clerkPublishableKey: config.auth?.clerk?.publishableKey || null,
|
|
1423
|
+
clerkFrontendUrl: config.auth?.clerk?.frontendUrl || null,
|
|
1424
|
+
branding: config.branding || {},
|
|
1425
|
+
chat: config.chat || {}
|
|
1426
|
+
})
|
|
1427
|
+
);
|
|
1428
|
+
if (!headless && staticDir) {
|
|
1429
|
+
const relativePath = require("node:path").relative(process.cwd(), staticDir);
|
|
1430
|
+
app.get("/chatter.js", (0, import_bun.serveStatic)({ path: `${relativePath}/chatter.js` }));
|
|
1431
|
+
app.get("/chatter.css", (0, import_bun.serveStatic)({ path: `${relativePath}/chatter.css` }));
|
|
1432
|
+
}
|
|
1433
|
+
if (!headless && publicDir) {
|
|
1434
|
+
app.get("/", (0, import_bun.serveStatic)({ path: `${publicDir}/index.html` }));
|
|
1435
|
+
app.get("/chat", (0, import_bun.serveStatic)({ path: `${publicDir}/chat.html` }));
|
|
1436
|
+
app.get("/private", (0, import_bun.serveStatic)({ path: `${publicDir}/private.html` }));
|
|
1437
|
+
app.get("/vanilla/chat", (0, import_bun.serveStatic)({ path: `${publicDir}/vanilla/chat.html` }));
|
|
1438
|
+
app.get(
|
|
1439
|
+
"/demo/widget-button-public",
|
|
1440
|
+
(0, import_bun.serveStatic)({ path: `${publicDir}/demo/widget-button-public.html` })
|
|
1441
|
+
);
|
|
1442
|
+
app.get(
|
|
1443
|
+
"/demo/widget-button-private",
|
|
1444
|
+
(0, import_bun.serveStatic)({ path: `${publicDir}/demo/widget-button-private.html` })
|
|
1445
|
+
);
|
|
1446
|
+
app.get(
|
|
1447
|
+
"/demo/widget-inline-public",
|
|
1448
|
+
(0, import_bun.serveStatic)({ path: `${publicDir}/demo/widget-inline-public.html` })
|
|
1449
|
+
);
|
|
1450
|
+
app.get(
|
|
1451
|
+
"/demo/widget-inline-private",
|
|
1452
|
+
(0, import_bun.serveStatic)({ path: `${publicDir}/demo/widget-inline-private.html` })
|
|
1453
|
+
);
|
|
1454
|
+
app.get(
|
|
1455
|
+
"/demo/react-button-public",
|
|
1456
|
+
(0, import_bun.serveStatic)({ path: `${publicDir}/demo/react-button-public.html` })
|
|
1457
|
+
);
|
|
1458
|
+
app.get(
|
|
1459
|
+
"/demo/react-button-private",
|
|
1460
|
+
(0, import_bun.serveStatic)({ path: `${publicDir}/demo/react-button-private.html` })
|
|
1461
|
+
);
|
|
1462
|
+
app.get(
|
|
1463
|
+
"/demo/react-inline-public",
|
|
1464
|
+
(0, import_bun.serveStatic)({ path: `${publicDir}/demo/react-inline-public.html` })
|
|
1465
|
+
);
|
|
1466
|
+
app.get(
|
|
1467
|
+
"/demo/react-inline-private",
|
|
1468
|
+
(0, import_bun.serveStatic)({ path: `${publicDir}/demo/react-inline-private.html` })
|
|
1469
|
+
);
|
|
1470
|
+
app.get("/demo/public", (0, import_bun.serveStatic)({ path: `${publicDir}/demo-public.html` }));
|
|
1471
|
+
app.get("/demo/private", (0, import_bun.serveStatic)({ path: `${publicDir}/demo-private.html` }));
|
|
1472
|
+
app.get("/demo/chatbot", (0, import_bun.serveStatic)({ path: `${publicDir}/demo-chatbot.html` }));
|
|
1473
|
+
app.get("/demo/react", (0, import_bun.serveStatic)({ path: `${publicDir}/react-demo.html` }));
|
|
1474
|
+
}
|
|
1475
|
+
const senders = createSenderRegistry();
|
|
1476
|
+
const deps = { client, store, db, config, prompts, apiKeyManager, senders };
|
|
1477
|
+
if (enablePublic) {
|
|
1478
|
+
app.route("/", publicRoutes(deps));
|
|
1479
|
+
}
|
|
1480
|
+
if (enablePrivate) {
|
|
1481
|
+
app.route("/", privateRoutes(deps));
|
|
1482
|
+
}
|
|
1483
|
+
if (enableDemo) {
|
|
1484
|
+
app.route("/", demoRoutes(deps));
|
|
1485
|
+
}
|
|
1486
|
+
if (enableOpenAICompat) {
|
|
1487
|
+
app.route("/", openaiRoutes(deps));
|
|
1488
|
+
}
|
|
1489
|
+
if (config.customRoutes) {
|
|
1490
|
+
await config.customRoutes(app, deps);
|
|
1491
|
+
}
|
|
1492
|
+
const started = [];
|
|
1493
|
+
if (config.channels && config.channels.length > 0) {
|
|
1494
|
+
for (const channel of config.channels) {
|
|
1495
|
+
try {
|
|
1496
|
+
await channel.start(deps);
|
|
1497
|
+
started.push(channel);
|
|
1498
|
+
console.log(`\u2705 Channel "${channel.name}" started`);
|
|
1499
|
+
} catch (error) {
|
|
1500
|
+
console.error(`\u274C Channel "${channel.name}" failed to start:`, error);
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
console.log(`\u2705 ${config.bot.name} server ready`);
|
|
1505
|
+
return Object.assign(app, {
|
|
1506
|
+
// Scoped to this call's started channels, not global — two servers in
|
|
1507
|
+
// one process each stop only their own. A channel's `stop()` throwing
|
|
1508
|
+
// (sync or async) is caught per-channel so one misbehaving channel can't
|
|
1509
|
+
// stop the rest from cleaning up.
|
|
1510
|
+
stopChannels: async () => {
|
|
1511
|
+
await Promise.allSettled(
|
|
1512
|
+
started.map(async (channel) => {
|
|
1513
|
+
try {
|
|
1514
|
+
await channel.stop?.();
|
|
1515
|
+
} catch (error) {
|
|
1516
|
+
console.error(`Channel "${channel.name}" failed to stop:`, error);
|
|
1517
|
+
}
|
|
1518
|
+
})
|
|
1519
|
+
);
|
|
1520
|
+
}
|
|
1521
|
+
});
|
|
1522
|
+
}
|
|
1523
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
1524
|
+
0 && (module.exports = {
|
|
1525
|
+
createServer
|
|
1526
|
+
});
|