@chatcops/server 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -7
- package/dist/adapters/ai-sdk.d.ts +12 -0
- package/dist/adapters/ai-sdk.d.ts.map +1 -0
- package/dist/adapters/ai-sdk.js +27 -0
- package/dist/adapters/ai-sdk.js.map +1 -0
- package/dist/adapters/cloudflare.d.ts +3 -1
- package/dist/adapters/cloudflare.d.ts.map +1 -1
- package/dist/adapters/cloudflare.js +11 -58
- package/dist/adapters/cloudflare.js.map +1 -1
- package/dist/adapters/express.d.ts +6 -2
- package/dist/adapters/express.d.ts.map +1 -1
- package/dist/adapters/express.js +109 -26
- package/dist/adapters/express.js.map +1 -1
- package/dist/adapters/index.d.ts +1 -0
- package/dist/adapters/index.d.ts.map +1 -1
- package/dist/adapters/index.js +1 -0
- package/dist/adapters/index.js.map +1 -1
- package/dist/adapters/vercel.d.ts.map +1 -1
- package/dist/adapters/vercel.js +7 -60
- package/dist/adapters/vercel.js.map +1 -1
- package/dist/client-ip.d.ts +36 -0
- package/dist/client-ip.d.ts.map +1 -0
- package/dist/client-ip.js +62 -0
- package/dist/client-ip.js.map +1 -0
- package/dist/config.d.ts +110 -8
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +5 -1
- package/dist/config.js.map +1 -1
- package/dist/handler.d.ts +53 -2
- package/dist/handler.d.ts.map +1 -1
- package/dist/handler.js +410 -127
- package/dist/handler.js.map +1 -1
- package/dist/http.d.ts +66 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +262 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/rate-limit.d.ts +5 -0
- package/dist/rate-limit.d.ts.map +1 -1
- package/dist/rate-limit.js +5 -0
- package/dist/rate-limit.js.map +1 -1
- package/dist/security/origin-policy.d.ts +19 -0
- package/dist/security/origin-policy.d.ts.map +1 -0
- package/dist/security/origin-policy.js +58 -0
- package/dist/security/origin-policy.js.map +1 -0
- package/dist/security/origin.d.ts +5 -0
- package/dist/security/origin.d.ts.map +1 -0
- package/dist/security/origin.js +47 -0
- package/dist/security/origin.js.map +1 -0
- package/dist/security/session-token.d.ts +65 -0
- package/dist/security/session-token.d.ts.map +1 -0
- package/dist/security/session-token.js +338 -0
- package/dist/security/session-token.js.map +1 -0
- package/dist/security/sessions.d.ts +49 -0
- package/dist/security/sessions.d.ts.map +1 -0
- package/dist/security/sessions.js +159 -0
- package/dist/security/sessions.js.map +1 -0
- package/dist/security/startup-checks.d.ts +44 -0
- package/dist/security/startup-checks.d.ts.map +1 -0
- package/dist/security/startup-checks.js +134 -0
- package/dist/security/startup-checks.js.map +1 -0
- package/dist/security/turnstile.d.ts +24 -0
- package/dist/security/turnstile.d.ts.map +1 -0
- package/dist/security/turnstile.js +93 -0
- package/dist/security/turnstile.js.map +1 -0
- package/dist/visitor-context.d.ts +20 -0
- package/dist/visitor-context.d.ts.map +1 -0
- package/dist/visitor-context.js +60 -0
- package/dist/visitor-context.js.map +1 -0
- package/dist/webhook.d.ts +48 -2
- package/dist/webhook.d.ts.map +1 -1
- package/dist/webhook.js +159 -31
- package/dist/webhook.js.map +1 -1
- package/package.json +22 -5
package/dist/handler.js
CHANGED
|
@@ -1,47 +1,211 @@
|
|
|
1
|
-
import { createProvider, ConversationManager, AnalyticsCollector, toolToDefinition, } from '@chatcops/core';
|
|
2
|
-
import { chatRequestSchema
|
|
3
|
-
import {
|
|
1
|
+
import { createProvider, isAIProvider, ConversationManager, AnalyticsCollector, BufferedEventSink, statsFromSink, createBudgetGuard, withDefaultBudgets, createRateLimiter, memoryStorage, describeInvalidRequest, errorBody, partsOf, PROTOCOL_VERSION, runTurn, statusForError, toLegacyChunk, toolRegistry, toolToDefinition, validateTools, } from '@chatcops/core';
|
|
2
|
+
import { chatRequestSchema } from './config.js';
|
|
3
|
+
import { allowedOriginsOf } from './security/origin-policy.js';
|
|
4
|
+
import { DEFAULT_SESSION_TTL_SEC, createSessions } from './security/sessions.js';
|
|
5
|
+
import { logSecurityAtStartup } from './security/startup-checks.js';
|
|
6
|
+
import { VISITOR_CONTEXT_INSTRUCTION, buildVisitorContext, isLocaleTag, withVisitorContext } from './visitor-context.js';
|
|
4
7
|
import { WebhookDispatcher } from './webhook.js';
|
|
5
8
|
export function createChatHandler(config) {
|
|
6
9
|
if (config.i18n) {
|
|
7
10
|
console.warn('[chatcops] The `i18n` option is deprecated and ignored. Set the widget text with its `strings` option.');
|
|
8
11
|
}
|
|
9
|
-
const providerPromise =
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
const providerPromise = isAIProvider(config.provider)
|
|
13
|
+
? Promise.resolve(config.provider)
|
|
14
|
+
: createProvider(config.provider);
|
|
15
|
+
// A failed setup (unknown provider type, SDK that throws on a missing key)
|
|
16
|
+
// is logged once here and reported to visitors per request below. Without
|
|
17
|
+
// this handler it would be an unhandled rejection and stop the process.
|
|
18
|
+
// Only the message is logged: SDK errors can carry the config or headers.
|
|
19
|
+
providerPromise.catch((err) => {
|
|
20
|
+
console.error('[chatcops] Provider setup failed:', err instanceof Error ? err.message : String(err));
|
|
21
|
+
});
|
|
22
|
+
if (config.storage && config.conversationStore) {
|
|
23
|
+
throw new Error('Set `storage` or `conversationStore`, not both. `storage.conversations` replaces `conversationStore`.');
|
|
24
|
+
}
|
|
25
|
+
if (!config.storage && !config.conversationStore && onServerless() && !warnedMemoryStorage) {
|
|
26
|
+
warnedMemoryStorage = true;
|
|
27
|
+
console.warn('[chatcops] No `storage` is set, so conversations are kept in memory. On serverless each instance has its own memory, so a conversation can lose its history between messages. Use a store package such as @chatcops/store-postgres or @chatcops/store-redis.');
|
|
28
|
+
}
|
|
29
|
+
const storage = config.storage ?? (config.conversationStore ? { conversations: config.conversationStore } : undefined);
|
|
30
|
+
const conversations = new ConversationManager(storage ? { storage } : {});
|
|
31
|
+
// Counted in the storage's counters when it has them, so limits and
|
|
32
|
+
// budgets hold across every server instance; otherwise in this process.
|
|
33
|
+
// Nothing runs on a timer, which Workers don't allow at global scope.
|
|
34
|
+
const counters = (storage?.counters ?? memoryStorage().counters);
|
|
35
|
+
const budgets = withDefaultBudgets(config.budgets);
|
|
36
|
+
const budgetGuard = budgets ? createBudgetGuard(counters, budgets.budgets) : undefined;
|
|
37
|
+
if (budgets && !loggedBudgets) {
|
|
38
|
+
loggedBudgets = true;
|
|
39
|
+
console.info(describeBudgets(budgets.budgets, budgets.defaulted));
|
|
40
|
+
}
|
|
41
|
+
const rateLimit = { maxRequests: 30, windowMs: 60_000, ...config.rateLimit };
|
|
42
|
+
if (rateLimit.maxRequests <= 0)
|
|
43
|
+
throw new Error('maxRequests must be greater than 0');
|
|
44
|
+
const rateLimiter = createRateLimiter(counters, {
|
|
45
|
+
limit: rateLimit.maxRequests,
|
|
46
|
+
windowMs: rateLimit.windowMs,
|
|
47
|
+
});
|
|
48
|
+
// Events always count in this process, and go to the storage's event sink
|
|
49
|
+
// too when it has one, so stats can cover every instance.
|
|
50
|
+
const collector = config.analytics ? new AnalyticsCollector() : null;
|
|
51
|
+
const eventSink = config.analytics ? storage?.events : undefined;
|
|
52
|
+
const bufferedEvents = eventSink ? new BufferedEventSink(eventSink) : undefined;
|
|
53
|
+
const analytics = collector
|
|
54
|
+
? {
|
|
55
|
+
track(type, data) {
|
|
56
|
+
collector.track(type, data);
|
|
57
|
+
bufferedEvents?.track(type, data);
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
: null;
|
|
13
61
|
const webhooks = config.webhooks?.length ? new WebhookDispatcher(config.webhooks) : null;
|
|
14
62
|
const toolDefs = config.tools?.map(toolToDefinition) ?? [];
|
|
15
|
-
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
63
|
+
// A broken tool definition is a code mistake, so fail here, at startup,
|
|
64
|
+
// rather than as a provider error on the first message.
|
|
65
|
+
validateTools(toolDefs);
|
|
66
|
+
const tools = toolRegistry(config.tools);
|
|
67
|
+
const sessions = createSessions(config, async (id) => (await conversations.getMessages(id)).length === 0);
|
|
68
|
+
if (!loggedSecurity) {
|
|
69
|
+
loggedSecurity = true;
|
|
70
|
+
logSecurityAtStartup({
|
|
71
|
+
allowedOrigins: allowedOriginsOf(config),
|
|
72
|
+
sessionSecrets: config.sessionSecrets,
|
|
73
|
+
sessionTtlSec: sessions ? (config.sessionTtlSec ?? DEFAULT_SESSION_TTL_SEC) : undefined,
|
|
74
|
+
requireSession: sessions?.required,
|
|
75
|
+
turnstile: Boolean(config.turnstile),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
const requests = { v1: 0, legacy: 0, 'ai-sdk': 0 };
|
|
79
|
+
/**
|
|
80
|
+
* Starts one reply. Resolves a refusal for a request that can't be
|
|
81
|
+
* answered (protocol v1 sends it as an HTTP status before any stream), or
|
|
82
|
+
* the reply as protocol v1 parts. The conversation stays locked until the
|
|
83
|
+
* parts are read to the end or `return()` is called on them.
|
|
84
|
+
*/
|
|
85
|
+
async function startChat(body, clientIp, options = {}) {
|
|
86
|
+
const format = options.format ?? 'v1';
|
|
87
|
+
requests[format] += 1;
|
|
88
|
+
if (format === 'legacy' && !loggedLegacyFormat) {
|
|
89
|
+
loggedLegacyFormat = true;
|
|
90
|
+
console.info('[chatcops] Answered a request in the 0.5 stream format, from a widget older than 0.6 or a client that doesn\'t send `protocol: 1`. That format keeps working until 1.0.');
|
|
91
|
+
}
|
|
21
92
|
const parsed = chatRequestSchema.safeParse(body);
|
|
22
93
|
if (!parsed.success) {
|
|
23
|
-
|
|
24
|
-
return;
|
|
94
|
+
return refuse('invalid_request', { message: describeInvalidRequest(parsed.error) }, { error: 'Invalid request', details: parsed.error.flatten() });
|
|
25
95
|
}
|
|
26
96
|
const req = parsed.data;
|
|
27
|
-
|
|
97
|
+
if (sessions) {
|
|
98
|
+
const session = await sessions.check(options.authorization, options.origin ?? null, req.conversationId);
|
|
99
|
+
if (!session.ok)
|
|
100
|
+
return refuse(session.code, { message: session.message }, { error: 'provider_error' });
|
|
101
|
+
}
|
|
28
102
|
if (clientIp) {
|
|
29
|
-
const limit = rateLimiter.check(clientIp);
|
|
103
|
+
const limit = await rateLimiter.check(clientIp);
|
|
30
104
|
if (!limit.allowed) {
|
|
31
|
-
|
|
32
|
-
|
|
105
|
+
const retryAfterMs = limit.retryAfterMs ?? 1000;
|
|
106
|
+
// 0.5 widgets read seconds.
|
|
107
|
+
return refuse('rate_limited', { retryAfterMs }, { error: 'rate_limit', retryAfter: Math.ceil(retryAfterMs / 1000) });
|
|
33
108
|
}
|
|
34
109
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
110
|
+
let provider;
|
|
111
|
+
try {
|
|
112
|
+
provider = await providerPromise;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// Already logged where the provider was created.
|
|
116
|
+
return refuse('provider_unavailable', {}, { error: 'provider_error' });
|
|
117
|
+
}
|
|
118
|
+
// One reply per conversation at a time: a second request while a reply
|
|
119
|
+
// is being written would otherwise answer the same history twice.
|
|
120
|
+
const release = await conversations.acquireTurn(req.conversationId);
|
|
121
|
+
if (!release) {
|
|
122
|
+
return refuse('conversation_busy', {}, { error: 'provider_error' });
|
|
123
|
+
}
|
|
124
|
+
return { ok: true, parts: replyParts(provider, req, options.signal, clientIp, release) };
|
|
125
|
+
}
|
|
126
|
+
async function* replyParts(provider, req, signal, clientIp, release) {
|
|
127
|
+
const pendingWebhooks = new Set();
|
|
128
|
+
try {
|
|
129
|
+
for await (const part of reply(provider, req, signal, pendingWebhooks, clientIp)) {
|
|
130
|
+
// Tells the widget to get a session before its next request.
|
|
131
|
+
yield part.type === 'start' && sessions ? { ...part, capabilities: { ...part.capabilities, session: true } } : part;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
await release();
|
|
136
|
+
}
|
|
137
|
+
// Keeping the stream open until lead webhooks and analytics writes
|
|
138
|
+
// finish keeps serverless runtimes from cutting them off once the
|
|
139
|
+
// response ends. A failed analytics write is logged, never thrown.
|
|
140
|
+
await awaitPendingWebhooks(pendingWebhooks);
|
|
141
|
+
await bufferedEvents?.flush();
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Analytics stats for `range` (default: the last 30 days), counted in the
|
|
145
|
+
* storage's event sink when it can be queried, so they cover every
|
|
146
|
+
* instance. Otherwise they come from this process only, as
|
|
147
|
+
* `getAnalytics()` does. `null` when `analytics` is off.
|
|
148
|
+
*/
|
|
149
|
+
async function analyticsStats(range) {
|
|
150
|
+
if (!collector)
|
|
151
|
+
return null;
|
|
152
|
+
if (!bufferedEvents || !eventSink?.query)
|
|
153
|
+
return collector.getStats();
|
|
154
|
+
await bufferedEvents.flush();
|
|
155
|
+
const to = range?.to ?? Date.now() + 1;
|
|
156
|
+
return statsFromSink(eventSink, { from: range?.from ?? to - 30 * DAY_MS, to });
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Streams one reply in the 0.5 format, as JSON strings. Pass
|
|
160
|
+
* `options.signal` to stop when the visitor goes away: the provider request
|
|
161
|
+
* is cancelled and nothing more is yielded.
|
|
162
|
+
*/
|
|
163
|
+
async function* handleChat(body, clientIp, options = {}) {
|
|
164
|
+
const start = await startChat(body, clientIp, { ...options, format: 'legacy' });
|
|
165
|
+
if (!start.ok) {
|
|
166
|
+
yield JSON.stringify(start.legacy);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
yield* toLegacyStream(start.parts);
|
|
170
|
+
}
|
|
171
|
+
async function* reply(provider, req, signal, pendingWebhooks, clientIp) {
|
|
172
|
+
const conversation = await conversations.getOrCreate(req.conversationId);
|
|
173
|
+
const conversationMessages = conversation.messages;
|
|
174
|
+
// Pre-chat answers are kept from the first message on, so the widget
|
|
175
|
+
// doesn't have to send them again and they can't change mid-conversation.
|
|
176
|
+
const storedVisitor = conversation.metadata?.visitor;
|
|
177
|
+
const newVisitor = storedVisitor ? undefined : allowedUserData(req.userData, config.preChatFields);
|
|
178
|
+
const userData = storedVisitor ?? newVisitor;
|
|
179
|
+
let assistantToReplace;
|
|
180
|
+
if (typeof req.regenerate === 'object') {
|
|
181
|
+
const id = req.regenerate.assistantMessageId;
|
|
182
|
+
assistantToReplace = conversationMessages.find((message) => message.id === id && message.role === 'assistant');
|
|
183
|
+
}
|
|
184
|
+
if (req.regenerate && !assistantToReplace) {
|
|
185
|
+
const userIndex = req.messageId
|
|
186
|
+
? conversationMessages.findIndex((message) => message.id === req.messageId)
|
|
187
|
+
: findLastIndex(conversationMessages, (message) => message.role === 'user');
|
|
188
|
+
if (userIndex !== -1) {
|
|
189
|
+
assistantToReplace = conversationMessages.slice(userIndex + 1).find((message) => message.role === 'assistant');
|
|
190
|
+
}
|
|
191
|
+
else if (!req.messageId) {
|
|
192
|
+
assistantToReplace = [...conversationMessages].reverse().find((message) => message.role === 'assistant');
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// A retry of a message that already has a finished reply (the network
|
|
196
|
+
// dropped after the server saved it) gets that reply again instead of a
|
|
197
|
+
// second one. A partial reply is replaced once the new one succeeds.
|
|
198
|
+
if (!req.regenerate && req.messageId) {
|
|
199
|
+
const index = conversationMessages.findIndex((message) => message.id === req.messageId && message.role === 'user');
|
|
200
|
+
const answer = index === -1 ? undefined : conversationMessages[index + 1];
|
|
201
|
+
if (answer?.role === 'assistant') {
|
|
202
|
+
if (answer.status === undefined || answer.status === 'complete') {
|
|
203
|
+
yield* replay(answer, req.conversationId);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
assistantToReplace = answer;
|
|
42
207
|
}
|
|
43
208
|
}
|
|
44
|
-
const conversationMessages = await conversations.getMessages(req.conversationId);
|
|
45
209
|
// Track analytics
|
|
46
210
|
if (analytics && conversationMessages.length === 0) {
|
|
47
211
|
analytics.track('conversation:started', { conversationId: req.conversationId });
|
|
@@ -64,128 +228,247 @@ export function createChatHandler(config) {
|
|
|
64
228
|
content: req.message,
|
|
65
229
|
timestamp: Date.now(),
|
|
66
230
|
};
|
|
67
|
-
await conversations.
|
|
231
|
+
await conversations.appendMessages(req.conversationId, [userMessage], newVisitor ? { metadata: { visitor: newVisitor } } : undefined);
|
|
68
232
|
}
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
//
|
|
233
|
+
// The system prompt is the operator's text and nothing else, so it's the
|
|
234
|
+
// same on every request and providers can cache it. Everything that
|
|
235
|
+
// changes per request goes in the context after it.
|
|
236
|
+
const context = [];
|
|
72
237
|
if (config.knowledge?.length) {
|
|
73
238
|
const contexts = await Promise.all(config.knowledge.map((k) => k.getContext(req.message)));
|
|
74
239
|
const combined = contexts.filter(Boolean).join('\n\n');
|
|
75
|
-
if (combined)
|
|
76
|
-
|
|
77
|
-
}
|
|
240
|
+
if (combined)
|
|
241
|
+
context.push(combined);
|
|
78
242
|
}
|
|
79
|
-
//
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
243
|
+
// Visitor-supplied page context and pre-chat answers never go into the
|
|
244
|
+
// system prompt; see visitor-context.ts.
|
|
245
|
+
const visitorContext = buildVisitorContext({ pageContext: req.pageContext, userData });
|
|
246
|
+
if (visitorContext)
|
|
247
|
+
context.push(VISITOR_CONTEXT_INSTRUCTION);
|
|
248
|
+
if (req.locale && isLocaleTag(req.locale)) {
|
|
249
|
+
context.push(`Reply in the visitor's language. Their browser locale is ${req.locale}.`);
|
|
87
250
|
}
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
return result;
|
|
144
|
-
},
|
|
145
|
-
})) {
|
|
146
|
-
while (pendingEvents.length > 0) {
|
|
147
|
-
yield pendingEvents.shift();
|
|
251
|
+
// The history the model sees, trimmed to the token budget
|
|
252
|
+
const storedMessages = await conversations.getHistory(req.conversationId);
|
|
253
|
+
const messages = withVisitorContext(assistantToReplace
|
|
254
|
+
? storedMessages.filter((message) => message.id !== assistantToReplace.id)
|
|
255
|
+
: storedMessages, visitorContext);
|
|
256
|
+
// The reply is saved as it grows: appended after the first tool round,
|
|
257
|
+
// updated after each one. A reply that fails before anything was saved
|
|
258
|
+
// isn't saved at all, so a failed regenerate keeps the old answer.
|
|
259
|
+
let saved = false;
|
|
260
|
+
const turn = runTurn({
|
|
261
|
+
provider,
|
|
262
|
+
messages,
|
|
263
|
+
systemPrompt: config.systemPrompt,
|
|
264
|
+
...(context.length ? { systemContext: context.join('\n\n') } : {}),
|
|
265
|
+
tools,
|
|
266
|
+
conversationId: req.conversationId,
|
|
267
|
+
toolTimeoutMs: config.toolTimeoutMs,
|
|
268
|
+
maxTokens: config.maxTokens,
|
|
269
|
+
temperature: config.temperature,
|
|
270
|
+
exposeUsage: config.exposeUsage,
|
|
271
|
+
signal,
|
|
272
|
+
...(budgetGuard
|
|
273
|
+
? { budget: { guard: budgetGuard, context: { conversationId: req.conversationId, ...(clientIp ? { ip: clientIp } : {}) } } }
|
|
274
|
+
: {}),
|
|
275
|
+
onPart: (part) => {
|
|
276
|
+
if (part.type !== 'lead-captured')
|
|
277
|
+
return;
|
|
278
|
+
analytics?.track('lead:captured', { conversationId: req.conversationId });
|
|
279
|
+
if (!webhooks)
|
|
280
|
+
return;
|
|
281
|
+
const delivery = webhooks
|
|
282
|
+
.dispatch('lead:captured', { lead: part.lead, conversationId: req.conversationId })
|
|
283
|
+
.catch((err) => {
|
|
284
|
+
console.error('[chatcops] Webhook delivery failed:', err);
|
|
285
|
+
})
|
|
286
|
+
.finally(() => {
|
|
287
|
+
pendingWebhooks.delete(delivery);
|
|
288
|
+
});
|
|
289
|
+
pendingWebhooks.add(delivery);
|
|
290
|
+
},
|
|
291
|
+
onPersist: async (message, { final }) => {
|
|
292
|
+
const complete = message.status === 'complete';
|
|
293
|
+
if (!saved) {
|
|
294
|
+
if (final && !message.content && !message.parts?.length)
|
|
295
|
+
return;
|
|
296
|
+
// A reply the visitor walked away from is kept, marked `aborted`,
|
|
297
|
+
// so the conversation shows what they saw (the model doesn't see
|
|
298
|
+
// it). When regenerating, the old answer stands instead. A reply
|
|
299
|
+
// that failed isn't kept.
|
|
300
|
+
if (final && !complete && (message.status !== 'aborted' || assistantToReplace))
|
|
301
|
+
return;
|
|
302
|
+
await conversations.appendMessages(req.conversationId, [message]);
|
|
303
|
+
saved = true;
|
|
148
304
|
}
|
|
149
|
-
|
|
150
|
-
|
|
305
|
+
else {
|
|
306
|
+
await conversations.updateMessage(req.conversationId, message);
|
|
307
|
+
}
|
|
308
|
+
if (final && complete) {
|
|
309
|
+
if (assistantToReplace)
|
|
310
|
+
await conversations.removeMessage(req.conversationId, assistantToReplace.id);
|
|
311
|
+
if (message.content)
|
|
312
|
+
analytics?.track('message:received', { conversationId: req.conversationId });
|
|
313
|
+
}
|
|
314
|
+
},
|
|
315
|
+
});
|
|
316
|
+
let failure;
|
|
317
|
+
let next = await turn.next();
|
|
318
|
+
while (!next.done) {
|
|
319
|
+
const part = next.value;
|
|
320
|
+
if (part.type === 'error')
|
|
321
|
+
failure = part;
|
|
322
|
+
yield part;
|
|
323
|
+
next = await turn.next();
|
|
324
|
+
}
|
|
325
|
+
const result = next.value;
|
|
326
|
+
if (failure && result.status !== 'aborted') {
|
|
327
|
+
if (failure.code === 'budget_exceeded') {
|
|
328
|
+
console.warn(`[chatcops] The ${failure.scope ?? ''} budget is used up, so a reply was refused.`);
|
|
151
329
|
}
|
|
152
|
-
|
|
153
|
-
|
|
330
|
+
else {
|
|
331
|
+
console.error('[chatcops] Provider error:', result.error);
|
|
154
332
|
}
|
|
155
333
|
}
|
|
156
|
-
|
|
157
|
-
|
|
334
|
+
// The visitor left, or the reply failed: let lead webhooks finish before
|
|
335
|
+
// the stream ends.
|
|
336
|
+
if (failure || result.status === 'aborted')
|
|
158
337
|
await awaitPendingWebhooks(pendingWebhooks);
|
|
159
|
-
yield JSON.stringify({ error: 'provider_error' });
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
|
-
// Save assistant message
|
|
163
|
-
if (fullResponse) {
|
|
164
|
-
const assistantMessage = {
|
|
165
|
-
id: crypto.randomUUID(),
|
|
166
|
-
role: 'assistant',
|
|
167
|
-
content: fullResponse,
|
|
168
|
-
timestamp: Date.now(),
|
|
169
|
-
};
|
|
170
|
-
await conversations.addMessage(req.conversationId, assistantMessage);
|
|
171
|
-
analytics?.track('message:received', { conversationId: req.conversationId });
|
|
172
|
-
}
|
|
173
|
-
await awaitPendingWebhooks(pendingWebhooks);
|
|
174
|
-
yield JSON.stringify({ done: true });
|
|
175
338
|
}
|
|
176
|
-
|
|
177
|
-
|
|
339
|
+
/**
|
|
340
|
+
* Deletes conversations last updated more than `retention.conversationsDays`
|
|
341
|
+
* (default 30) days ago. Resolves how many, or `null` when the store can't
|
|
342
|
+
* purge.
|
|
343
|
+
*/
|
|
344
|
+
async function purge(now = Date.now()) {
|
|
345
|
+
const store = storage?.conversations;
|
|
346
|
+
if (!store?.purge)
|
|
347
|
+
return null;
|
|
348
|
+
const days = config.retention?.conversationsDays ?? 30;
|
|
349
|
+
return store.purge(now - days * DAY_MS);
|
|
178
350
|
}
|
|
351
|
+
/** Kept for compatibility: the handler no longer holds timers, so there's nothing to clean up. */
|
|
352
|
+
function cleanup() { }
|
|
179
353
|
return {
|
|
180
354
|
handleChat,
|
|
181
|
-
|
|
355
|
+
startChat,
|
|
356
|
+
purge,
|
|
357
|
+
/** How many requests asked for each stream format, to tell when 0.5 widgets are gone. */
|
|
358
|
+
/** Answers `POST /session`, or `undefined` when session tokens are off. */
|
|
359
|
+
startSession: sessions
|
|
360
|
+
? (body, origin, clientIp) => sessions.mint(body, origin, clientIp)
|
|
361
|
+
: undefined,
|
|
362
|
+
protocolStats: () => ({ ...requests }),
|
|
363
|
+
/** Stats from this process only. `analyticsStats()` reads the storage's event sink. */
|
|
364
|
+
getAnalytics: () => collector?.getStats() ?? null,
|
|
365
|
+
analyticsStats,
|
|
182
366
|
cleanup,
|
|
183
367
|
};
|
|
184
368
|
}
|
|
369
|
+
function refuse(code, options, legacy) {
|
|
370
|
+
return { ok: false, status: statusForError(code), error: errorBody(code, options).error, legacy };
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Protocol v1 parts as 0.5 chunks (JSON strings). An error goes out last,
|
|
374
|
+
* after the turn has ended and lead webhooks have finished, as in 0.5.
|
|
375
|
+
*/
|
|
376
|
+
export async function* toLegacyStream(parts) {
|
|
377
|
+
let failure = null;
|
|
378
|
+
let aborted = false;
|
|
379
|
+
for await (const part of parts) {
|
|
380
|
+
if (part.type === 'error') {
|
|
381
|
+
failure = toLegacyChunk(part);
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
if (part.type === 'finish' && part.reason === 'aborted')
|
|
385
|
+
aborted = true;
|
|
386
|
+
const chunk = toLegacyChunk(part);
|
|
387
|
+
if (chunk)
|
|
388
|
+
yield JSON.stringify(chunk);
|
|
389
|
+
}
|
|
390
|
+
// Nobody is listening after an abort.
|
|
391
|
+
if (failure && !aborted)
|
|
392
|
+
yield JSON.stringify(failure);
|
|
393
|
+
}
|
|
394
|
+
/** A saved reply, sent again: the request was a retry of an answered message. */
|
|
395
|
+
function* replay(message, conversationId) {
|
|
396
|
+
yield { type: 'start', protocol: PROTOCOL_VERSION, conversationId, messageId: message.id };
|
|
397
|
+
let texts = 0;
|
|
398
|
+
for (const part of partsOf(message)) {
|
|
399
|
+
if (part.type === 'text' && part.text) {
|
|
400
|
+
const id = `t${++texts}`;
|
|
401
|
+
yield { type: 'text-start', id };
|
|
402
|
+
yield { type: 'text-delta', id, delta: part.text };
|
|
403
|
+
yield { type: 'text-end', id };
|
|
404
|
+
}
|
|
405
|
+
else if (part.type === 'source') {
|
|
406
|
+
const { sourceId, url, title, snippet } = part;
|
|
407
|
+
yield { type: 'source', sourceId, url, ...(title ? { title } : {}), ...(snippet ? { snippet } : {}) };
|
|
408
|
+
}
|
|
409
|
+
else if (part.type === 'lead-captured') {
|
|
410
|
+
yield { type: 'lead-captured', lead: part.lead };
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
yield { type: 'finish', reason: 'stop' };
|
|
414
|
+
}
|
|
415
|
+
/** The pre-chat answers to keep: all of them, or only the configured fields. */
|
|
416
|
+
function allowedUserData(userData, fields) {
|
|
417
|
+
if (!userData)
|
|
418
|
+
return undefined;
|
|
419
|
+
const kept = fields ? Object.fromEntries(Object.entries(userData).filter(([key]) => fields.includes(key))) : userData;
|
|
420
|
+
return Object.keys(kept).length ? kept : undefined;
|
|
421
|
+
}
|
|
422
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
423
|
+
/** Once per process (per isolate on Workers), not once per handler. */
|
|
424
|
+
let warnedMemoryStorage = false;
|
|
425
|
+
let loggedBudgets = false;
|
|
426
|
+
let loggedSecurity = false;
|
|
427
|
+
let loggedLegacyFormat = false;
|
|
428
|
+
const SCOPE_LABELS = { conversation: 'per conversation', ip: 'per IP per day', global: 'per day in total' };
|
|
429
|
+
function amount(value, unit) {
|
|
430
|
+
const short = value >= 1_000_000 ? `${value / 1_000_000}M` : value >= 1_000 ? `${value / 1_000}k` : String(value);
|
|
431
|
+
return `${short} ${unit}`;
|
|
432
|
+
}
|
|
433
|
+
/** The startup line, so the limits in force are never a surprise. */
|
|
434
|
+
export function describeBudgets(budgets, defaulted = []) {
|
|
435
|
+
const scopes = [
|
|
436
|
+
['conversation', budgets.perConversation],
|
|
437
|
+
['ip', budgets.perIpPerDay],
|
|
438
|
+
['global', budgets.globalPerDay],
|
|
439
|
+
];
|
|
440
|
+
const parts = [];
|
|
441
|
+
for (const [scope, limit] of scopes) {
|
|
442
|
+
if (!limit)
|
|
443
|
+
continue;
|
|
444
|
+
const caps = [
|
|
445
|
+
...(limit.tokens !== undefined ? [amount(limit.tokens, 'tokens')] : []),
|
|
446
|
+
...(limit.messages !== undefined ? [amount(limit.messages, 'replies')] : []),
|
|
447
|
+
...(limit.usd !== undefined ? [`$${limit.usd}`] : []),
|
|
448
|
+
];
|
|
449
|
+
parts.push(`${caps.join(' and ')} ${SCOPE_LABELS[scope]}${defaulted.includes(scope) ? ' (default)' : ''}`);
|
|
450
|
+
}
|
|
451
|
+
const noTotal = budgets.globalPerDay ? '' : ' No daily total is set; `budgets.globalPerDay` caps your whole bill.';
|
|
452
|
+
return `[chatcops] Budgets: ${parts.join(', ')}.${noTotal} Set \`budgets\` to change them, or \`budgets: false\` to turn them off.`;
|
|
453
|
+
}
|
|
454
|
+
/** Vercel, AWS Lambda, Netlify and Cloudflare Workers, where memory doesn't last. */
|
|
455
|
+
function onServerless() {
|
|
456
|
+
const env = globalThis.process?.env ?? {};
|
|
457
|
+
if (env.VERCEL || env.AWS_LAMBDA_FUNCTION_NAME || env.NETLIFY || env.K_SERVICE)
|
|
458
|
+
return true;
|
|
459
|
+
return globalThis.navigator?.userAgent === 'Cloudflare-Workers';
|
|
460
|
+
}
|
|
185
461
|
async function awaitPendingWebhooks(pendingWebhooks) {
|
|
186
462
|
if (pendingWebhooks.size > 0) {
|
|
187
463
|
await Promise.allSettled(Array.from(pendingWebhooks));
|
|
188
464
|
pendingWebhooks.clear();
|
|
189
465
|
}
|
|
190
466
|
}
|
|
467
|
+
function findLastIndex(array, predicate) {
|
|
468
|
+
for (let i = array.length - 1; i >= 0; i--) {
|
|
469
|
+
if (predicate(array[i]))
|
|
470
|
+
return i;
|
|
471
|
+
}
|
|
472
|
+
return -1;
|
|
473
|
+
}
|
|
191
474
|
//# sourceMappingURL=handler.js.map
|