@firstlovecenter/ai-chat 0.1.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.
@@ -0,0 +1,1465 @@
1
+ 'use strict';
2
+
3
+ var vertexSdk = require('@anthropic-ai/vertex-sdk');
4
+ var crypto = require('crypto');
5
+ var googleAuthLibrary = require('google-auth-library');
6
+
7
+ // src/server/tools/types.ts
8
+ var TERMINAL_TOOL_NAME = "present";
9
+ function ok(data) {
10
+ return { ok: true, data };
11
+ }
12
+ function err(code, message) {
13
+ return { ok: false, error: { code, message } };
14
+ }
15
+
16
+ // src/server/agent.ts
17
+ var DEFAULT_MAX_TOOL_TURNS = 12;
18
+ var DEFAULT_MAX_OUTPUT_TOKENS = 4096;
19
+ async function runAgent(input) {
20
+ const provider = input.provider;
21
+ const maxToolTurns = input.maxToolTurns ?? DEFAULT_MAX_TOOL_TURNS;
22
+ const maxOutputTokens = input.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS;
23
+ const transcript = [];
24
+ transcript.push({ kind: "user", text: input.question });
25
+ const messages = [{ role: "user", text: input.question }];
26
+ const system = input.systemBlocks;
27
+ const toolSchemas = Object.values(input.tools).map((t) => t.schema);
28
+ let toolCallCount = 0;
29
+ let presentPayload = null;
30
+ for (let turn = 0; turn < maxToolTurns; turn++) {
31
+ const response = await provider.runTurn({
32
+ system,
33
+ tools: toolSchemas,
34
+ messages,
35
+ maxOutputTokens
36
+ });
37
+ if (response.text) {
38
+ transcript.push({ kind: "assistant_text", text: response.text });
39
+ }
40
+ if (response.toolCalls.length === 0) {
41
+ if (presentPayload) break;
42
+ return {
43
+ ok: false,
44
+ error: {
45
+ code: "AGENT_NO_PRESENT",
46
+ message: "The agent ended without calling present(). Try rephrasing."
47
+ },
48
+ transcript
49
+ };
50
+ }
51
+ messages.push({
52
+ role: "assistant",
53
+ text: response.text,
54
+ toolCalls: response.toolCalls,
55
+ providerData: response.providerData
56
+ });
57
+ const toolResults = [];
58
+ for (const tc of response.toolCalls) {
59
+ transcript.push({ kind: "tool_use", name: tc.name, input: tc.input });
60
+ const tool = input.tools[tc.name];
61
+ if (!tool) {
62
+ const errResult = {
63
+ ok: false,
64
+ error: { code: "UNKNOWN_TOOL", message: `Unknown tool: ${tc.name}` }
65
+ };
66
+ transcript.push({ kind: "tool_result", name: tc.name, result: errResult });
67
+ toolResults.push({
68
+ toolCallId: tc.id,
69
+ toolName: tc.name,
70
+ isError: true,
71
+ content: JSON.stringify(errResult.error)
72
+ });
73
+ continue;
74
+ }
75
+ const result = await tool.execute(tc.input, {
76
+ ...input.ctx,
77
+ toolCallCount
78
+ });
79
+ transcript.push({ kind: "tool_result", name: tc.name, result });
80
+ toolCallCount += 1;
81
+ if (tc.name === TERMINAL_TOOL_NAME && result.ok) {
82
+ if (toolCallCount < 3) {
83
+ const violation = {
84
+ code: "SELF_VERIFY_REQUIRED",
85
+ message: "Per FR-8.3 you must run at least one CROSS-CHECK tool call (a different metric, a different period, or a run_sql sanity-check) before present. Make that extra call now, then call present again."
86
+ };
87
+ toolResults.push({
88
+ toolCallId: tc.id,
89
+ toolName: tc.name,
90
+ isError: true,
91
+ content: JSON.stringify(violation)
92
+ });
93
+ continue;
94
+ }
95
+ presentPayload = result.data;
96
+ break;
97
+ }
98
+ toolResults.push({
99
+ toolCallId: tc.id,
100
+ toolName: tc.name,
101
+ isError: !result.ok,
102
+ content: JSON.stringify(result.ok ? result.data : result.error)
103
+ });
104
+ }
105
+ if (presentPayload) break;
106
+ messages.push({ role: "tool", results: toolResults });
107
+ }
108
+ if (!presentPayload) {
109
+ return {
110
+ ok: false,
111
+ error: {
112
+ code: "AGENT_TURN_LIMIT",
113
+ message: `Agent exceeded the ${maxToolTurns}-turn budget without calling present().`
114
+ },
115
+ transcript
116
+ };
117
+ }
118
+ return { ok: true, structured: presentPayload, toolCallCount, transcript };
119
+ }
120
+ var ClaudeToolProvider = class {
121
+ id = "claude";
122
+ client;
123
+ modelId;
124
+ constructor(opts) {
125
+ this.modelId = opts.modelId;
126
+ this.client = new vertexSdk.AnthropicVertex({
127
+ projectId: opts.projectId,
128
+ region: opts.location,
129
+ googleAuth: opts.auth
130
+ });
131
+ patchVertexBuildRequestSync(this.client);
132
+ }
133
+ async runTurn(input) {
134
+ const system = input.system.map((b) => ({
135
+ type: "text",
136
+ text: b.text,
137
+ ...b.cached ? { cache_control: { type: "ephemeral" } } : {}
138
+ }));
139
+ const messages = toAnthropicMessages(input.messages);
140
+ const response = await this.client.messages.create({
141
+ model: this.modelId,
142
+ max_tokens: input.maxOutputTokens,
143
+ system,
144
+ tools: input.tools,
145
+ messages
146
+ });
147
+ return fromAnthropicResponse(response);
148
+ }
149
+ };
150
+ function createClaudeProvider(opts) {
151
+ return new ClaudeToolProvider(opts);
152
+ }
153
+ function toAnthropicMessages(messages) {
154
+ const out = [];
155
+ for (const msg of messages) {
156
+ if (msg.role === "user") {
157
+ out.push({ role: "user", content: msg.text });
158
+ } else if (msg.role === "assistant") {
159
+ const blocks = [];
160
+ if (msg.text) {
161
+ blocks.push({ type: "text", text: msg.text });
162
+ }
163
+ for (const tc of msg.toolCalls) {
164
+ blocks.push({
165
+ type: "tool_use",
166
+ id: tc.id,
167
+ name: tc.name,
168
+ input: tc.input
169
+ });
170
+ }
171
+ out.push({ role: "assistant", content: blocks });
172
+ } else {
173
+ const content = msg.results.map((r) => ({
174
+ type: "tool_result",
175
+ tool_use_id: r.toolCallId,
176
+ is_error: r.isError,
177
+ content: r.content
178
+ }));
179
+ out.push({ role: "user", content });
180
+ }
181
+ }
182
+ return out;
183
+ }
184
+ function fromAnthropicResponse(response) {
185
+ const textParts = [];
186
+ const toolCalls = [];
187
+ for (const block of response.content) {
188
+ if (block.type === "text") {
189
+ textParts.push(block.text);
190
+ } else if (block.type === "tool_use") {
191
+ const tu = block;
192
+ toolCalls.push({
193
+ id: tu.id,
194
+ name: tu.name,
195
+ input: tu.input ?? {}
196
+ });
197
+ }
198
+ }
199
+ return {
200
+ text: textParts.join(""),
201
+ toolCalls,
202
+ stopReason: normalizeStopReason(response.stop_reason)
203
+ };
204
+ }
205
+ function normalizeStopReason(reason) {
206
+ switch (reason) {
207
+ case "tool_use":
208
+ return "tool_use";
209
+ case "end_turn":
210
+ return "end_turn";
211
+ case "max_tokens":
212
+ return "max_tokens";
213
+ default:
214
+ return "other";
215
+ }
216
+ }
217
+ var MODEL_ENDPOINTS = /* @__PURE__ */ new Set(["/v1/messages", "/v1/messages?beta=true"]);
218
+ var VERTEX_DEFAULT_VERSION = "vertex-2023-10-16";
219
+ function isObj(value) {
220
+ return value != null && typeof value === "object" && !Array.isArray(value);
221
+ }
222
+ function patchVertexBuildRequestSync(client) {
223
+ const proto = Object.getPrototypeOf(client);
224
+ const grandparent = Object.getPrototypeOf(proto);
225
+ proto.buildRequest = function patchedBuildRequest(options, extra) {
226
+ if (isObj(options.body)) {
227
+ options.body = { ...options.body };
228
+ }
229
+ if (isObj(options.body) && !options.body.anthropic_version) {
230
+ options.body.anthropic_version = VERTEX_DEFAULT_VERSION;
231
+ }
232
+ if (options.path && MODEL_ENDPOINTS.has(options.path) && options.method === "post" && isObj(options.body)) {
233
+ if (!this.projectId) throw new Error("AnthropicVertex: projectId is required");
234
+ const model = options.body.model;
235
+ options.body.model = void 0;
236
+ const stream = options.body.stream ?? false;
237
+ const specifier = stream ? "streamRawPredict" : "rawPredict";
238
+ options.path = `/projects/${this.projectId}/locations/${this.region}/publishers/anthropic/models/${model}:${specifier}`;
239
+ }
240
+ if (options.path === "/v1/messages/count_tokens" || options.path === "/v1/messages/count_tokens?beta=true" && options.method === "post") {
241
+ if (!this.projectId) throw new Error("AnthropicVertex: projectId is required");
242
+ options.path = `/projects/${this.projectId}/locations/${this.region}/publishers/anthropic/models/count-tokens:rawPredict`;
243
+ }
244
+ return grandparent.buildRequest.call(this, options, extra);
245
+ };
246
+ }
247
+
248
+ // src/server/providers/schema.ts
249
+ function toGeminiSchema(schema) {
250
+ const out = walk(schema);
251
+ return out ?? { type: "object" };
252
+ }
253
+ function walk(node) {
254
+ if (Array.isArray(node)) return node.map(walk);
255
+ if (!isObject(node)) return node;
256
+ if ("const" in node) {
257
+ const c = node.const;
258
+ const inferred = inferConstType(c);
259
+ const next = { ...node };
260
+ delete next.const;
261
+ next.type = next.type ?? inferred;
262
+ next.enum = [c];
263
+ return walk(next);
264
+ }
265
+ const out = {};
266
+ for (const [k, v] of Object.entries(node)) {
267
+ if (k === "oneOf") {
268
+ out.anyOf = v.map(walk);
269
+ continue;
270
+ }
271
+ if (k === "additionalProperties") {
272
+ continue;
273
+ }
274
+ if (k === "properties" && isObject(v)) {
275
+ const props = {};
276
+ for (const [pk, pv] of Object.entries(v)) {
277
+ props[pk] = walk(pv);
278
+ }
279
+ out.properties = props;
280
+ continue;
281
+ }
282
+ if (k === "items") {
283
+ out.items = walk(v);
284
+ continue;
285
+ }
286
+ if (k === "anyOf" || k === "allOf") {
287
+ out[k] = v.map(walk);
288
+ continue;
289
+ }
290
+ if (k === "enum" && Array.isArray(v)) {
291
+ const allStrings = v.every((x) => typeof x === "string");
292
+ if (allStrings) {
293
+ out.enum = v;
294
+ continue;
295
+ }
296
+ const allNumbers = v.length > 0 && v.every((x) => typeof x === "number");
297
+ if (allNumbers) {
298
+ const nums = v;
299
+ out.minimum = Math.min(...nums);
300
+ out.maximum = Math.max(...nums);
301
+ }
302
+ continue;
303
+ }
304
+ out[k] = v;
305
+ }
306
+ if (out.type === "array" && out.items === void 0) {
307
+ out.items = { type: "string" };
308
+ }
309
+ return out;
310
+ }
311
+ function inferConstType(c) {
312
+ if (typeof c === "string") return "string";
313
+ if (typeof c === "number") return Number.isInteger(c) ? "integer" : "number";
314
+ if (typeof c === "boolean") return "boolean";
315
+ return "string";
316
+ }
317
+ function isObject(v) {
318
+ return v != null && typeof v === "object" && !Array.isArray(v);
319
+ }
320
+
321
+ // src/server/providers/gemini.ts
322
+ var GeminiToolProvider = class {
323
+ id = "gemini";
324
+ auth;
325
+ projectId;
326
+ location;
327
+ modelId;
328
+ fetchImpl;
329
+ constructor(opts) {
330
+ this.auth = opts.auth;
331
+ this.projectId = opts.projectId;
332
+ this.location = opts.location;
333
+ this.modelId = opts.modelId;
334
+ this.fetchImpl = opts.fetchImpl ?? fetch;
335
+ }
336
+ async runTurn(input) {
337
+ const accessToken = await this.auth.getAccessToken();
338
+ if (!accessToken) throw new Error("Failed to obtain GCP access token");
339
+ const url = `https://${vertexHost(this.location)}/v1/projects/${this.projectId}/locations/${this.location}/publishers/google/models/${this.modelId}:generateContent`;
340
+ const body = {
341
+ systemInstruction: {
342
+ parts: [{ text: input.system.map((b) => b.text).join("\n\n") }]
343
+ },
344
+ contents: toGeminiContents(input.messages),
345
+ tools: [
346
+ {
347
+ functionDeclarations: input.tools.map((t) => ({
348
+ name: t.name,
349
+ description: t.description,
350
+ parameters: toGeminiSchema(t.input_schema)
351
+ }))
352
+ }
353
+ ],
354
+ toolConfig: {
355
+ functionCallingConfig: { mode: "AUTO" }
356
+ },
357
+ generationConfig: {
358
+ maxOutputTokens: input.maxOutputTokens,
359
+ temperature: 0
360
+ }
361
+ };
362
+ const res = await this.fetchImpl(url, {
363
+ method: "POST",
364
+ headers: {
365
+ Authorization: `Bearer ${accessToken}`,
366
+ "Content-Type": "application/json"
367
+ },
368
+ body: JSON.stringify(body)
369
+ });
370
+ if (!res.ok) {
371
+ const detail = await safeReadText(res);
372
+ throw new Error(`Vertex Gemini request failed (${res.status}): ${detail}`);
373
+ }
374
+ const json = await res.json();
375
+ if (json.error) {
376
+ throw new Error(
377
+ `Vertex Gemini returned error: ${json.error.status ?? json.error.code ?? "??"} ${json.error.message ?? ""}`
378
+ );
379
+ }
380
+ return fromGeminiResponse(json);
381
+ }
382
+ };
383
+ function createGeminiProvider(opts) {
384
+ return new GeminiToolProvider(opts);
385
+ }
386
+ function vertexHost(location) {
387
+ return location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com`;
388
+ }
389
+ function toGeminiContents(messages) {
390
+ const out = [];
391
+ for (const msg of messages) {
392
+ if (msg.role === "user") {
393
+ out.push({ role: "user", parts: [{ text: msg.text }] });
394
+ } else if (msg.role === "assistant") {
395
+ if (Array.isArray(msg.providerData?.parts)) {
396
+ const parts2 = msg.providerData.parts;
397
+ out.push({ role: "model", parts: parts2 });
398
+ continue;
399
+ }
400
+ const parts = [];
401
+ if (msg.text) parts.push({ text: msg.text });
402
+ for (const tc of msg.toolCalls) {
403
+ parts.push({ functionCall: { name: tc.name, args: tc.input } });
404
+ }
405
+ if (parts.length === 0) parts.push({ text: "" });
406
+ out.push({ role: "model", parts });
407
+ } else {
408
+ const parts = msg.results.map((r) => ({
409
+ functionResponse: {
410
+ name: r.toolName,
411
+ response: parseToolResultContent(r.content, r.isError)
412
+ }
413
+ }));
414
+ out.push({ role: "user", parts });
415
+ }
416
+ }
417
+ return out;
418
+ }
419
+ function parseToolResultContent(content, isError) {
420
+ let parsed = null;
421
+ try {
422
+ parsed = JSON.parse(content);
423
+ } catch {
424
+ parsed = content;
425
+ }
426
+ if (isError) {
427
+ return { error: parsed };
428
+ }
429
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
430
+ return parsed;
431
+ }
432
+ return { result: parsed };
433
+ }
434
+ function fromGeminiResponse(json) {
435
+ const candidate = json.candidates?.[0];
436
+ const parts = candidate?.content?.parts ?? [];
437
+ const textParts = [];
438
+ const toolCalls = [];
439
+ let idx = 0;
440
+ for (const part of parts) {
441
+ if (typeof part.text === "string" && part.thought !== true) {
442
+ textParts.push(part.text);
443
+ } else if (part.functionCall) {
444
+ toolCalls.push({
445
+ id: `gem_${idx}_${part.functionCall.name}`,
446
+ name: part.functionCall.name,
447
+ input: part.functionCall.args ?? {}
448
+ });
449
+ idx += 1;
450
+ }
451
+ }
452
+ return {
453
+ text: textParts.join(""),
454
+ toolCalls,
455
+ stopReason: normalizeFinishReason(candidate?.finishReason, toolCalls.length > 0),
456
+ // Preserve the raw parts so a subsequent runTurn() echoes the
457
+ // assistant turn back faithfully, including any `thoughtSignature`
458
+ // entries thinking-mode requires.
459
+ providerData: { parts }
460
+ };
461
+ }
462
+ function normalizeFinishReason(reason, hasToolCalls) {
463
+ if (hasToolCalls) return "tool_use";
464
+ switch (reason) {
465
+ case "STOP":
466
+ return "end_turn";
467
+ case "MAX_TOKENS":
468
+ return "max_tokens";
469
+ default:
470
+ return reason ? "other" : "end_turn";
471
+ }
472
+ }
473
+ async function safeReadText(res) {
474
+ try {
475
+ return (await res.text()).slice(0, 500);
476
+ } catch {
477
+ return "";
478
+ }
479
+ }
480
+
481
+ // src/server/providers/index.ts
482
+ var toolProviders = [
483
+ {
484
+ id: "claude",
485
+ label: "Claude (Vertex)",
486
+ description: "Anthropic Messages API hosted on GCP Vertex AI.",
487
+ createProvider(opts) {
488
+ return createClaudeProvider({
489
+ auth: opts.auth,
490
+ projectId: opts.projectId,
491
+ location: opts.location ?? opts.defaultLocation,
492
+ modelId: opts.modelIds.claude
493
+ });
494
+ }
495
+ },
496
+ {
497
+ id: "gemini",
498
+ label: "Gemini (Vertex)",
499
+ description: "Google Gemini hosted on GCP Vertex AI (raw generateContent).",
500
+ createProvider(opts) {
501
+ return createGeminiProvider({
502
+ auth: opts.auth,
503
+ projectId: opts.projectId,
504
+ location: opts.location ?? opts.defaultLocation,
505
+ modelId: opts.modelIds.gemini
506
+ });
507
+ }
508
+ }
509
+ ];
510
+ function getToolProvider(id) {
511
+ return toolProviders.find((p) => p.id === id);
512
+ }
513
+ function vertexHost2(location) {
514
+ return location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com`;
515
+ }
516
+ async function getAccessToken(auth) {
517
+ const token = await auth.getAccessToken();
518
+ if (!token) throw new Error("Failed to obtain GCP access token");
519
+ return token;
520
+ }
521
+ function createAnthropicVertexClient(args) {
522
+ const client = new vertexSdk.AnthropicVertex({
523
+ projectId: args.projectId,
524
+ region: args.location,
525
+ googleAuth: args.auth
526
+ });
527
+ patchVertexBuildRequestSync2(client);
528
+ return client;
529
+ }
530
+ var MODEL_ENDPOINTS2 = /* @__PURE__ */ new Set(["/v1/messages", "/v1/messages?beta=true"]);
531
+ var VERTEX_DEFAULT_VERSION2 = "vertex-2023-10-16";
532
+ function isObj2(value) {
533
+ return value != null && typeof value === "object" && !Array.isArray(value);
534
+ }
535
+ function patchVertexBuildRequestSync2(client) {
536
+ const proto = Object.getPrototypeOf(client);
537
+ const grandparent = Object.getPrototypeOf(proto);
538
+ proto.buildRequest = function patchedBuildRequest(options, extra) {
539
+ if (isObj2(options.body)) {
540
+ options.body = { ...options.body };
541
+ }
542
+ if (isObj2(options.body) && !options.body.anthropic_version) {
543
+ options.body.anthropic_version = VERTEX_DEFAULT_VERSION2;
544
+ }
545
+ if (options.path && MODEL_ENDPOINTS2.has(options.path) && options.method === "post" && isObj2(options.body)) {
546
+ if (!this.projectId) throw new Error("AnthropicVertex: projectId is required");
547
+ const model = options.body.model;
548
+ options.body.model = void 0;
549
+ const stream = options.body.stream ?? false;
550
+ const specifier = stream ? "streamRawPredict" : "rawPredict";
551
+ options.path = `/projects/${this.projectId}/locations/${this.region}/publishers/anthropic/models/${model}:${specifier}`;
552
+ }
553
+ if (options.path === "/v1/messages/count_tokens" || options.path === "/v1/messages/count_tokens?beta=true" && options.method === "post") {
554
+ if (!this.projectId) throw new Error("AnthropicVertex: projectId is required");
555
+ options.path = `/projects/${this.projectId}/locations/${this.region}/publishers/anthropic/models/count-tokens:rawPredict`;
556
+ }
557
+ return grandparent.buildRequest.call(this, options, extra);
558
+ };
559
+ }
560
+
561
+ // src/server/narrators/claude.ts
562
+ var NARRATIVE_SYSTEM = `You are the prose narrator for the FLC Data Intelligence agent.
563
+
564
+ You receive:
565
+ - The user's original question.
566
+ - The full structured answer the agent already computed (blocks +
567
+ raw_numbers).
568
+ - One specific paragraph_brief block's topic and key_facts.
569
+
570
+ Your job: write 2\u20134 short sentences (\u2264 80 words total) of natural,
571
+ useful prose for THIS paragraph block.
572
+
573
+ Rules:
574
+ - Use only the facts in key_facts and raw_numbers. Do not invent
575
+ numbers, dates, country names, or trends.
576
+ - Be neutral and direct. No hedging ("it appears", "perhaps").
577
+ - No headings, no markdown, no bullet lists. Plain prose.
578
+ - Reference the same numbers verbatim \u2014 if raw_numbers says
579
+ 1234.56, say "1,234.56" or "$1,235", never "about 1.2k" if that
580
+ drifts.
581
+ - Do not begin with "Here is", "Below", "Based on the data".
582
+ - Stop. Do not repeat yourself.`;
583
+ async function* streamClaudeNarration(opts) {
584
+ const client = createAnthropicVertexClient({
585
+ projectId: opts.projectId,
586
+ location: opts.location,
587
+ auth: opts.auth
588
+ });
589
+ const stream = await client.messages.stream({
590
+ model: opts.modelId,
591
+ max_tokens: 400,
592
+ system: NARRATIVE_SYSTEM,
593
+ messages: [{ role: "user", content: buildNarrativeUserMessage(opts.input) }]
594
+ });
595
+ for await (const event of stream) {
596
+ if (event.type === "content_block_delta" && "delta" in event && event.delta && "type" in event.delta && event.delta.type === "text_delta") {
597
+ yield event.delta.text;
598
+ }
599
+ }
600
+ }
601
+ function buildNarrativeUserMessage(input) {
602
+ return [
603
+ `User question: ${input.question}`,
604
+ "",
605
+ `Block topic: ${input.topic}`,
606
+ `Block key_facts:
607
+ ${input.keyFacts.map((f) => `- ${f}`).join("\n")}`,
608
+ "",
609
+ "Full structured answer (for reference; do not restate the whole thing):",
610
+ JSON.stringify(input.structured, null, 2)
611
+ ].join("\n");
612
+ }
613
+
614
+ // src/server/narrators/gemini.ts
615
+ var NARRATIVE_SYSTEM2 = `You are the prose narrator for the FLC Data Intelligence agent.
616
+
617
+ You receive:
618
+ - The user's original question.
619
+ - The full structured answer the agent already computed (blocks +
620
+ raw_numbers).
621
+ - One specific paragraph_brief block's topic and key_facts.
622
+
623
+ Your job: write 2\u20134 short sentences (\u2264 80 words total) of natural,
624
+ useful prose for THIS paragraph block.
625
+
626
+ Rules:
627
+ - Use only the facts in key_facts and raw_numbers. Do not invent
628
+ numbers, dates, country names, or trends.
629
+ - Be neutral and direct. No hedging.
630
+ - No headings, no markdown, no bullet lists. Plain prose.
631
+ - Reference the same numbers verbatim.
632
+ - Do not begin with "Here is" or "Based on the data".
633
+ - Stop. Do not repeat yourself.`;
634
+ async function* streamGeminiNarration(opts) {
635
+ const accessToken = await getAccessToken(opts.auth);
636
+ const url = `https://${vertexHost2(opts.location)}/v1/projects/${opts.projectId}/locations/${opts.location}/publishers/google/models/${opts.modelId}:streamGenerateContent?alt=sse`;
637
+ const res = await fetch(url, {
638
+ method: "POST",
639
+ headers: {
640
+ Authorization: `Bearer ${accessToken}`,
641
+ "Content-Type": "application/json"
642
+ },
643
+ body: JSON.stringify({
644
+ systemInstruction: { parts: [{ text: NARRATIVE_SYSTEM2 }] },
645
+ contents: [
646
+ {
647
+ role: "user",
648
+ parts: [{ text: buildNarrativeUserMessage(opts.input) }]
649
+ }
650
+ ],
651
+ generationConfig: { maxOutputTokens: 400, temperature: 0 }
652
+ })
653
+ });
654
+ if (!res.ok || !res.body) {
655
+ const detail = await safeReadText2(res);
656
+ throw new Error(`Vertex Gemini request failed (${res.status}): ${detail}`);
657
+ }
658
+ yield* parseGeminiSseTextDeltas(res.body);
659
+ }
660
+ async function safeReadText2(res) {
661
+ try {
662
+ return (await res.text()).slice(0, 500);
663
+ } catch {
664
+ return "";
665
+ }
666
+ }
667
+ async function* parseGeminiSseTextDeltas(body) {
668
+ const reader = body.getReader();
669
+ const decoder = new TextDecoder();
670
+ let buffered = "";
671
+ while (true) {
672
+ const { value, done } = await reader.read();
673
+ if (done) break;
674
+ buffered += decoder.decode(value, { stream: true });
675
+ let newlineIndex;
676
+ while ((newlineIndex = buffered.indexOf("\n")) !== -1) {
677
+ const line = buffered.slice(0, newlineIndex).trim();
678
+ buffered = buffered.slice(newlineIndex + 1);
679
+ if (!line.startsWith("data:")) continue;
680
+ const payload = line.slice(5).trim();
681
+ if (!payload) continue;
682
+ try {
683
+ const chunk = JSON.parse(payload);
684
+ const parts = chunk.candidates?.[0]?.content?.parts ?? [];
685
+ for (const part of parts) {
686
+ if (typeof part.text === "string" && part.text.length > 0) {
687
+ yield part.text;
688
+ }
689
+ }
690
+ } catch {
691
+ }
692
+ }
693
+ }
694
+ }
695
+
696
+ // src/server/narrators/grok.ts
697
+ var NARRATIVE_SYSTEM3 = `You are the prose narrator for the FLC Data Intelligence agent.
698
+
699
+ You receive:
700
+ - The user's original question.
701
+ - The full structured answer the agent already computed (blocks +
702
+ raw_numbers).
703
+ - One specific paragraph_brief block's topic and key_facts.
704
+
705
+ Your job: write 2\u20134 short sentences (\u2264 80 words total) of natural,
706
+ useful prose for THIS paragraph block.
707
+
708
+ Rules:
709
+ - Use only the facts in key_facts and raw_numbers. Do not invent
710
+ numbers, dates, country names, or trends.
711
+ - Be neutral and direct. No hedging.
712
+ - No headings, no markdown, no bullet lists. Plain prose.
713
+ - Reference the same numbers verbatim.
714
+ - Do not begin with "Here is" or "Based on the data".
715
+ - Stop. Do not repeat yourself.`;
716
+ async function* streamGrokNarration(opts) {
717
+ const accessToken = await getAccessToken(opts.auth);
718
+ const url = `https://${vertexHost2(opts.location)}/v1beta1/projects/${opts.projectId}/locations/${opts.location}/endpoints/openapi/chat/completions`;
719
+ const res = await fetch(url, {
720
+ method: "POST",
721
+ headers: {
722
+ Authorization: `Bearer ${accessToken}`,
723
+ "Content-Type": "application/json"
724
+ },
725
+ body: JSON.stringify({
726
+ model: opts.modelId,
727
+ max_tokens: 400,
728
+ stream: true,
729
+ messages: [
730
+ { role: "system", content: NARRATIVE_SYSTEM3 },
731
+ { role: "user", content: buildNarrativeUserMessage(opts.input) }
732
+ ]
733
+ })
734
+ });
735
+ if (!res.ok || !res.body) {
736
+ const detail = await safeReadText3(res);
737
+ throw new Error(`Vertex Grok request failed (${res.status}): ${detail}`);
738
+ }
739
+ yield* parseSseDeltas(res.body);
740
+ }
741
+ async function safeReadText3(res) {
742
+ try {
743
+ return (await res.text()).slice(0, 500);
744
+ } catch {
745
+ return "";
746
+ }
747
+ }
748
+ async function* parseSseDeltas(body) {
749
+ const reader = body.getReader();
750
+ const decoder = new TextDecoder();
751
+ let buffered = "";
752
+ while (true) {
753
+ const { value, done } = await reader.read();
754
+ if (done) break;
755
+ buffered += decoder.decode(value, { stream: true });
756
+ let newlineIndex;
757
+ while ((newlineIndex = buffered.indexOf("\n")) !== -1) {
758
+ const line = buffered.slice(0, newlineIndex).trim();
759
+ buffered = buffered.slice(newlineIndex + 1);
760
+ if (!line.startsWith("data:")) continue;
761
+ const payload = line.slice(5).trim();
762
+ if (!payload || payload === "[DONE]") {
763
+ if (payload === "[DONE]") return;
764
+ continue;
765
+ }
766
+ try {
767
+ const chunk = JSON.parse(payload);
768
+ const delta = chunk.choices?.[0]?.delta?.content;
769
+ if (typeof delta === "string" && delta.length > 0) yield delta;
770
+ } catch {
771
+ }
772
+ }
773
+ }
774
+ }
775
+
776
+ // src/server/narrators/index.ts
777
+ function getNarrator(id) {
778
+ switch (id) {
779
+ case "claude":
780
+ return streamClaudeNarration;
781
+ case "gemini":
782
+ return streamGeminiNarration;
783
+ case "grok":
784
+ return streamGrokNarration;
785
+ default: {
786
+ const _exhaustive = id;
787
+ throw new Error(`Unknown narrator id: ${String(_exhaustive)}`);
788
+ }
789
+ }
790
+ }
791
+
792
+ // src/server/routes/agent-custom.ts
793
+ var NARRATOR_IDS = /* @__PURE__ */ new Set([
794
+ "claude",
795
+ "gemini",
796
+ "grok"
797
+ ]);
798
+ function isNarratorId(value) {
799
+ return NARRATOR_IDS.has(value);
800
+ }
801
+ function jsonError(status, code, message) {
802
+ return new Response(JSON.stringify({ error: { code, message } }), {
803
+ status,
804
+ headers: { "Content-Type": "application/json" }
805
+ });
806
+ }
807
+ function defaultGenerateSessionId() {
808
+ return crypto.randomUUID().replace(/-/g, "").slice(0, 16);
809
+ }
810
+ function pickNarratorModelId(vertex, narratorId) {
811
+ if (narratorId === "claude") return vertex.modelIds.claude;
812
+ if (narratorId === "gemini") return vertex.modelIds.gemini;
813
+ const extra = vertex.modelIds;
814
+ if (typeof extra.grok === "string" && extra.grok.length > 0) {
815
+ return extra.grok;
816
+ }
817
+ throw new Error(
818
+ "Narrator 'grok' selected but VertexPort.modelIds.grok is not pinned."
819
+ );
820
+ }
821
+ function createAgentCustomRoutes(ctx) {
822
+ const { persistence, auth, scope, tools, vertex, logger, hooks } = ctx;
823
+ return {
824
+ /** Next.js-compatible POST handler. */
825
+ POST: async (req) => {
826
+ if (hooks?.onRequest) {
827
+ const short = await hooks.onRequest(req);
828
+ if (short) return short;
829
+ }
830
+ const authResult = await auth.requireAuth(req);
831
+ if (!authResult.ok) return authResult.response;
832
+ const { scope: callerScope, userId } = authResult;
833
+ if (hooks?.onAuthenticated) {
834
+ const short = await hooks.onAuthenticated({
835
+ req,
836
+ scope: callerScope,
837
+ userId
838
+ });
839
+ if (short) return short;
840
+ }
841
+ const body = await req.json().catch(() => null);
842
+ const question = typeof body?.question === "string" ? body.question.trim() : "";
843
+ if (!question) {
844
+ return jsonError(
845
+ 400,
846
+ "VALIDATION_FAILED",
847
+ "question must be a non-empty string."
848
+ );
849
+ }
850
+ const rawChatSessionId = body?.chatSessionId;
851
+ const incomingChatSessionId = typeof rawChatSessionId === "number" && Number.isInteger(rawChatSessionId) ? rawChatSessionId : null;
852
+ const aiSettings = await persistence.getAiSettings();
853
+ let chatSessionId;
854
+ if (incomingChatSessionId !== null) {
855
+ const owned = await persistence.getSession(incomingChatSessionId, userId);
856
+ if (!owned) {
857
+ return jsonError(404, "NOT_FOUND", "Chat session not found.");
858
+ }
859
+ chatSessionId = owned.id;
860
+ } else {
861
+ const created = await persistence.createSession({
862
+ userId,
863
+ title: question.slice(0, 200)
864
+ });
865
+ chatSessionId = created.id;
866
+ }
867
+ await persistence.appendMessage({
868
+ sessionId: chatSessionId,
869
+ role: "user",
870
+ question
871
+ });
872
+ const sessionId = hooks?.generateSessionId ? await hooks.generateSessionId({
873
+ scope: callerScope,
874
+ userId,
875
+ chatSessionId: incomingChatSessionId
876
+ }) : defaultGenerateSessionId();
877
+ const scopeSummary = await scope.buildScopeSummary(callerScope);
878
+ const scopeLabel = await scope.resolveScopeLabel(callerScope);
879
+ const toolContext = {
880
+ scope: callerScope,
881
+ sessionId,
882
+ scopeSummary,
883
+ toolCallCount: 0
884
+ };
885
+ const systemBlocks = await tools.buildSystemBlocks(toolContext);
886
+ const def = getToolProvider(aiSettings.toolProvider);
887
+ if (!def) {
888
+ return jsonError(
889
+ 400,
890
+ "INVALID_PROVIDER",
891
+ `Unknown tool provider in ai_settings: ${aiSettings.toolProvider}`
892
+ );
893
+ }
894
+ const provider = def.createProvider({
895
+ auth: vertex.auth,
896
+ projectId: vertex.projectId,
897
+ defaultLocation: vertex.defaultLocation,
898
+ modelIds: vertex.modelIds,
899
+ location: aiSettings.gcpLocation
900
+ });
901
+ let narratorId;
902
+ if (ctx.resolveNarratorId) {
903
+ narratorId = await ctx.resolveNarratorId(callerScope);
904
+ } else if (isNarratorId(aiSettings.toolProvider)) {
905
+ narratorId = aiSettings.toolProvider;
906
+ } else {
907
+ return jsonError(
908
+ 400,
909
+ "INVALID_NARRATOR",
910
+ `Cannot derive narrator from tool provider: ${aiSettings.toolProvider}`
911
+ );
912
+ }
913
+ const stream = new ReadableStream({
914
+ async start(controller) {
915
+ const encoder = new TextEncoder();
916
+ let closed = false;
917
+ const send = (event, data) => {
918
+ if (closed) return;
919
+ try {
920
+ controller.enqueue(
921
+ encoder.encode(
922
+ `event: ${event}
923
+ data: ${JSON.stringify(data)}
924
+
925
+ `
926
+ )
927
+ );
928
+ } catch {
929
+ }
930
+ };
931
+ const persistedBlocks = [];
932
+ const persistedProse = {};
933
+ let persistedError = null;
934
+ let sessionStarted = false;
935
+ try {
936
+ if (hooks?.onSessionStart) {
937
+ await hooks.onSessionStart({
938
+ scope: callerScope,
939
+ sessionId,
940
+ userId
941
+ });
942
+ }
943
+ sessionStarted = true;
944
+ send("meta", { chatSessionId, scopeLabel });
945
+ const agentResult = await runAgent({
946
+ question,
947
+ ctx: toolContext,
948
+ tools: tools.tools,
949
+ systemBlocks,
950
+ provider
951
+ });
952
+ if (!agentResult.ok) {
953
+ persistedError = agentResult.error;
954
+ send("error", agentResult.error);
955
+ send("done", {});
956
+ return;
957
+ }
958
+ const { structured } = agentResult;
959
+ let narratorModelId = null;
960
+ const narratorFn = getNarrator(narratorId);
961
+ for (let i = 0; i < structured.blocks.length; i++) {
962
+ const block = structured.blocks[i];
963
+ persistedBlocks[i] = block;
964
+ send("block", { index: i, ...block });
965
+ if (block.kind === "paragraph_brief") {
966
+ persistedProse[i] = "";
967
+ try {
968
+ if (narratorModelId === null) {
969
+ narratorModelId = pickNarratorModelId(vertex, narratorId);
970
+ }
971
+ for await (const token of narratorFn({
972
+ auth: vertex.auth,
973
+ projectId: vertex.projectId,
974
+ location: aiSettings.gcpLocation,
975
+ modelId: narratorModelId,
976
+ input: {
977
+ question,
978
+ structured,
979
+ topic: block.topic,
980
+ keyFacts: block.key_facts,
981
+ blockIndex: i
982
+ }
983
+ })) {
984
+ persistedProse[i] += token;
985
+ send("prose", { block_index: i, delta: token });
986
+ }
987
+ } catch (e) {
988
+ const fallback = block.key_facts.join(". ") + ".";
989
+ persistedProse[i] = fallback;
990
+ send("prose", {
991
+ block_index: i,
992
+ delta: block.key_facts.join(". ")
993
+ });
994
+ send("prose", { block_index: i, delta: "." });
995
+ send("error", {
996
+ code: "NARRATOR_FAILED",
997
+ message: `Prose stream failed (${e.message}); fell back to key facts.`
998
+ });
999
+ }
1000
+ }
1001
+ }
1002
+ send("done", {});
1003
+ } catch (e) {
1004
+ const message = e.message ?? "Internal error";
1005
+ persistedError = { code: "INTERNAL", message };
1006
+ logger?.error?.(
1007
+ { chatSessionId, sessionId, err: message },
1008
+ "[agent-custom] stream errored"
1009
+ );
1010
+ try {
1011
+ controller.enqueue(
1012
+ encoder.encode(
1013
+ `event: error
1014
+ data: ${JSON.stringify({ code: "INTERNAL", message })}
1015
+
1016
+ `
1017
+ )
1018
+ );
1019
+ controller.enqueue(encoder.encode(`event: done
1020
+ data: {}
1021
+
1022
+ `));
1023
+ } catch {
1024
+ }
1025
+ } finally {
1026
+ if (hooks?.onSessionEnd) {
1027
+ const cause = req.signal.aborted ? "abort" : persistedError ? "error" : "complete";
1028
+ try {
1029
+ await hooks.onSessionEnd({
1030
+ scope: callerScope,
1031
+ sessionId,
1032
+ userId,
1033
+ cause
1034
+ });
1035
+ } catch (err2) {
1036
+ logger?.warn?.(
1037
+ {
1038
+ chatSessionId,
1039
+ sessionId,
1040
+ sessionStarted,
1041
+ err: err2.message
1042
+ },
1043
+ "[agent-custom] onSessionEnd hook failed"
1044
+ );
1045
+ }
1046
+ }
1047
+ try {
1048
+ await persistence.appendMessage({
1049
+ sessionId: chatSessionId,
1050
+ role: "assistant",
1051
+ blocks: persistedBlocks.length ? persistedBlocks : null,
1052
+ prose: Object.keys(persistedProse).length ? persistedProse : null,
1053
+ errorJson: persistedError
1054
+ });
1055
+ } catch (err2) {
1056
+ logger?.warn?.(
1057
+ { chatSessionId, sessionId, err: err2.message },
1058
+ "[agent-custom] failed to persist assistant turn"
1059
+ );
1060
+ }
1061
+ try {
1062
+ closed = true;
1063
+ controller.close();
1064
+ } catch {
1065
+ }
1066
+ }
1067
+ }
1068
+ });
1069
+ return new Response(stream, {
1070
+ headers: {
1071
+ "Content-Type": "text/event-stream; charset=utf-8",
1072
+ "Cache-Control": "no-cache, no-transform",
1073
+ Connection: "keep-alive"
1074
+ }
1075
+ });
1076
+ }
1077
+ };
1078
+ }
1079
+
1080
+ // src/server/routes/chat-sessions.ts
1081
+ var DEFAULT_TITLE = "New chat";
1082
+ var TITLE_MAX = 200;
1083
+ var STATUS_BY_CODE = {
1084
+ UNAUTHORIZED: 401,
1085
+ NOT_FOUND: 404,
1086
+ VALIDATION_FAILED: 400,
1087
+ INTERNAL: 500
1088
+ };
1089
+ function apiError(code, message) {
1090
+ return Response.json(
1091
+ { error: { code, message, details: {} } },
1092
+ { status: STATUS_BY_CODE[code] }
1093
+ );
1094
+ }
1095
+ function okJson(data) {
1096
+ return Response.json(data);
1097
+ }
1098
+ function serializeSession(s) {
1099
+ return {
1100
+ id: s.id,
1101
+ title: s.title,
1102
+ createdAt: s.createdAt ? s.createdAt.toISOString() : null,
1103
+ updatedAt: s.updatedAt ? s.updatedAt.toISOString() : null
1104
+ };
1105
+ }
1106
+ function parseSessionId(raw) {
1107
+ const id = Number(raw);
1108
+ if (!Number.isInteger(id) || id <= 0) return null;
1109
+ return id;
1110
+ }
1111
+ function createChatSessionsRoutes(ctx) {
1112
+ const { persistence, auth, logger, hooks } = ctx;
1113
+ async function gate(req) {
1114
+ if (hooks?.onRequest) {
1115
+ const r = await hooks.onRequest(req);
1116
+ if (r) return { short: r };
1117
+ }
1118
+ const authed = await auth.requireAuth(req);
1119
+ if (!authed.ok) return { short: authed.response };
1120
+ if (hooks?.onAuthenticated) {
1121
+ const r = await hooks.onAuthenticated({
1122
+ req,
1123
+ scope: authed.scope,
1124
+ userId: authed.userId
1125
+ });
1126
+ if (r) return { short: r };
1127
+ }
1128
+ return { ok: true, scope: authed.scope, userId: authed.userId };
1129
+ }
1130
+ const list = {
1131
+ /**
1132
+ * `GET /api/chat/sessions` — caller's recent sessions, newest first,
1133
+ * capped at 100. Response: `{ sessions: [{ id, title, createdAt, updatedAt }] }`.
1134
+ */
1135
+ GET: async (req) => {
1136
+ try {
1137
+ const g = await gate(req);
1138
+ if ("short" in g) return g.short;
1139
+ const rows = await persistence.listSessionsForUser(g.userId, { limit: 100 });
1140
+ return okJson({ sessions: rows.map(serializeSession) });
1141
+ } catch (err2) {
1142
+ logger?.error("[chat-sessions] list.GET failed", err2);
1143
+ const msg = err2 instanceof Error ? err2.message : "Internal error";
1144
+ return apiError("INTERNAL", msg);
1145
+ }
1146
+ },
1147
+ /**
1148
+ * `POST /api/chat/sessions` — body `{ title?: string }`. Trims and caps
1149
+ * title at 200 chars; defaults to "New chat" when blank.
1150
+ * Response: `{ session: { id, title, createdAt, updatedAt } }`.
1151
+ */
1152
+ POST: async (req) => {
1153
+ try {
1154
+ const g = await gate(req);
1155
+ if ("short" in g) return g.short;
1156
+ const body = await req.json().catch(() => ({}));
1157
+ const rawTitle = typeof body.title === "string" ? body.title.trim() : "";
1158
+ const title = rawTitle ? rawTitle.slice(0, TITLE_MAX) : DEFAULT_TITLE;
1159
+ const created = await persistence.createSession({ userId: g.userId, title });
1160
+ return okJson({ session: serializeSession(created) });
1161
+ } catch (err2) {
1162
+ logger?.error("[chat-sessions] list.POST failed", err2);
1163
+ const msg = err2 instanceof Error ? err2.message : "Internal error";
1164
+ return apiError("INTERNAL", msg);
1165
+ }
1166
+ }
1167
+ };
1168
+ const detail = {
1169
+ /**
1170
+ * `GET /api/chat/sessions/[id]` — session metadata + ordered messages.
1171
+ * 404 when the id doesn't exist or doesn't belong to the caller (we never
1172
+ * differentiate the two, to avoid leaking the id space).
1173
+ * Response: `{ session: { id, title, createdAt, updatedAt },
1174
+ * messages: [{ id, role, question, blocks, prose, errorJson, createdAt }] }`.
1175
+ */
1176
+ GET: async (req, params) => {
1177
+ try {
1178
+ const g = await gate(req);
1179
+ if ("short" in g) return g.short;
1180
+ const id = parseSessionId(params.id);
1181
+ if (id === null) return apiError("NOT_FOUND", "Chat session not found.");
1182
+ const meta = await persistence.getSession(id, g.userId);
1183
+ if (!meta) return apiError("NOT_FOUND", "Chat session not found.");
1184
+ const messages = await persistence.listMessagesForSession(id, g.userId);
1185
+ return okJson({
1186
+ session: serializeSession(meta),
1187
+ messages: messages.map((m) => ({
1188
+ id: m.id,
1189
+ role: m.role,
1190
+ question: m.question,
1191
+ blocks: m.blocks,
1192
+ prose: m.prose,
1193
+ errorJson: m.errorJson,
1194
+ createdAt: m.createdAt ? m.createdAt.toISOString() : null
1195
+ }))
1196
+ });
1197
+ } catch (err2) {
1198
+ logger?.error("[chat-sessions] detail.GET failed", err2);
1199
+ const msg = err2 instanceof Error ? err2.message : "Internal error";
1200
+ return apiError("INTERNAL", msg);
1201
+ }
1202
+ },
1203
+ /**
1204
+ * `PATCH /api/chat/sessions/[id]` — rename. Body `{ title: string }`,
1205
+ * trimmed and capped at 200 chars. Response: `{ ok: true }`.
1206
+ */
1207
+ PATCH: async (req, params) => {
1208
+ try {
1209
+ const g = await gate(req);
1210
+ if ("short" in g) return g.short;
1211
+ const id = parseSessionId(params.id);
1212
+ if (id === null) return apiError("NOT_FOUND", "Chat session not found.");
1213
+ const meta = await persistence.getSession(id, g.userId);
1214
+ if (!meta) return apiError("NOT_FOUND", "Chat session not found.");
1215
+ const body = await req.json().catch(() => ({}));
1216
+ if (typeof body.title !== "string") {
1217
+ return apiError("VALIDATION_FAILED", "title must be a string.");
1218
+ }
1219
+ const trimmed = body.title.trim();
1220
+ if (!trimmed) {
1221
+ return apiError("VALIDATION_FAILED", "title must not be empty.");
1222
+ }
1223
+ await persistence.updateSession(id, g.userId, {
1224
+ title: trimmed.slice(0, TITLE_MAX)
1225
+ });
1226
+ return okJson({ ok: true });
1227
+ } catch (err2) {
1228
+ logger?.error("[chat-sessions] detail.PATCH failed", err2);
1229
+ const msg = err2 instanceof Error ? err2.message : "Internal error";
1230
+ return apiError("INTERNAL", msg);
1231
+ }
1232
+ },
1233
+ /**
1234
+ * `DELETE /api/chat/sessions/[id]` — drop session and its messages.
1235
+ * Response: `{ ok: true }`.
1236
+ */
1237
+ DELETE: async (req, params) => {
1238
+ try {
1239
+ const g = await gate(req);
1240
+ if ("short" in g) return g.short;
1241
+ const id = parseSessionId(params.id);
1242
+ if (id === null) return apiError("NOT_FOUND", "Chat session not found.");
1243
+ const meta = await persistence.getSession(id, g.userId);
1244
+ if (!meta) return apiError("NOT_FOUND", "Chat session not found.");
1245
+ await persistence.deleteSession(id, g.userId);
1246
+ return okJson({ ok: true });
1247
+ } catch (err2) {
1248
+ logger?.error("[chat-sessions] detail.DELETE failed", err2);
1249
+ const msg = err2 instanceof Error ? err2.message : "Internal error";
1250
+ return apiError("INTERNAL", msg);
1251
+ }
1252
+ }
1253
+ };
1254
+ return { list, detail };
1255
+ }
1256
+
1257
+ // src/server/routes/admin-settings.ts
1258
+ var VALID_LOCATIONS = ["us-east5", "global"];
1259
+ function isStringRecord(v) {
1260
+ return typeof v === "object" && v !== null && !Array.isArray(v);
1261
+ }
1262
+ function jsonResponse(body, status = 200) {
1263
+ return new Response(JSON.stringify(body), {
1264
+ status,
1265
+ headers: { "content-type": "application/json" }
1266
+ });
1267
+ }
1268
+ function toWire(settings) {
1269
+ return {
1270
+ tool_provider: settings.toolProvider,
1271
+ gcp_location: settings.gcpLocation,
1272
+ chat_interface: settings.chatInterface,
1273
+ updated_at: settings.updatedAt ? settings.updatedAt.toISOString() : null,
1274
+ updated_by_user_id: settings.updatedByUserId
1275
+ };
1276
+ }
1277
+ function createAdminSettingsRoutes(ctx) {
1278
+ const { persistence, auth, toolProviders: toolProviders2, chatInterfaces, logger, hooks } = ctx;
1279
+ async function GET(req) {
1280
+ if (hooks?.onRequest) {
1281
+ const short = await hooks.onRequest(req);
1282
+ if (short) return short;
1283
+ }
1284
+ const result = await auth.requireAuth(req);
1285
+ if (!result.ok) return result.response;
1286
+ if (hooks?.onAuthenticated) {
1287
+ const short = await hooks.onAuthenticated({
1288
+ req,
1289
+ scope: result.scope,
1290
+ userId: result.userId
1291
+ });
1292
+ if (short) return short;
1293
+ }
1294
+ if (!auth.isSuperAdmin(result.scope)) {
1295
+ return jsonResponse(
1296
+ { error: "forbidden", message: "Only super admins can read AI settings." },
1297
+ 403
1298
+ );
1299
+ }
1300
+ const settings = await persistence.getAiSettings();
1301
+ return jsonResponse(toWire(settings));
1302
+ }
1303
+ async function PATCH(req) {
1304
+ if (hooks?.onRequest) {
1305
+ const short = await hooks.onRequest(req);
1306
+ if (short) return short;
1307
+ }
1308
+ const result = await auth.requireAuth(req);
1309
+ if (!result.ok) return result.response;
1310
+ if (hooks?.onAuthenticated) {
1311
+ const short = await hooks.onAuthenticated({
1312
+ req,
1313
+ scope: result.scope,
1314
+ userId: result.userId
1315
+ });
1316
+ if (short) return short;
1317
+ }
1318
+ if (!auth.isSuperAdmin(result.scope)) {
1319
+ return jsonResponse(
1320
+ { error: "forbidden", message: "Only super admins can change AI settings." },
1321
+ 403
1322
+ );
1323
+ }
1324
+ const body = await req.json().catch(() => null);
1325
+ if (!isStringRecord(body)) {
1326
+ return jsonResponse({ error: "invalid_body", message: "Body must be JSON." }, 400);
1327
+ }
1328
+ const patch = {};
1329
+ if ("tool_provider" in body) {
1330
+ const v = body.tool_provider;
1331
+ const validIds = toolProviders2.map((p) => p.id);
1332
+ if (typeof v !== "string" || !validIds.includes(v)) {
1333
+ return jsonResponse({ error: "invalid_tool_provider" }, 400);
1334
+ }
1335
+ patch.toolProvider = v;
1336
+ }
1337
+ if ("gcp_location" in body) {
1338
+ const v = body.gcp_location;
1339
+ if (typeof v !== "string" || !VALID_LOCATIONS.includes(v)) {
1340
+ return jsonResponse({ error: "invalid_gcp_location" }, 400);
1341
+ }
1342
+ patch.gcpLocation = v;
1343
+ }
1344
+ if ("chat_interface" in body) {
1345
+ const v = body.chat_interface;
1346
+ const validIds = chatInterfaces.map((i) => i.id);
1347
+ if (typeof v !== "string" || !validIds.includes(v)) {
1348
+ return jsonResponse({ error: "invalid_chat_interface" }, 400);
1349
+ }
1350
+ patch.chatInterface = v;
1351
+ }
1352
+ if (patch.toolProvider === void 0 && patch.gcpLocation === void 0 && patch.chatInterface === void 0) {
1353
+ return jsonResponse(
1354
+ {
1355
+ error: "empty_patch",
1356
+ message: "Body must set at least one of tool_provider, gcp_location, chat_interface."
1357
+ },
1358
+ 400
1359
+ );
1360
+ }
1361
+ try {
1362
+ const updated = await persistence.updateAiSettings(patch, result.userId);
1363
+ return jsonResponse(toWire(updated));
1364
+ } catch (err2) {
1365
+ logger?.error("admin-settings PATCH failed", err2);
1366
+ throw err2;
1367
+ }
1368
+ }
1369
+ return { GET, PATCH };
1370
+ }
1371
+
1372
+ // src/server/configure.ts
1373
+ var BUILTIN_CHAT_INTERFACE_IDS = ["custom", "vercel"];
1374
+ function configureAiChat(opts) {
1375
+ const toolProviders2 = [
1376
+ ...toolProviders,
1377
+ ...opts.extraToolProviders ?? []
1378
+ ];
1379
+ const getProvider = (id) => toolProviders2.find((p) => p.id === id) ?? getToolProvider(id);
1380
+ const chatInterfaces = opts.chatInterfaces ?? BUILTIN_CHAT_INTERFACE_IDS.map((id) => ({ id }));
1381
+ const runAgentBound = async ({
1382
+ question,
1383
+ ctx,
1384
+ providerId,
1385
+ location,
1386
+ maxToolTurns,
1387
+ maxOutputTokens
1388
+ }) => {
1389
+ const settings = await opts.persistence.getAiSettings();
1390
+ const def = getProvider(providerId ?? settings.toolProvider);
1391
+ if (!def) {
1392
+ throw new Error(
1393
+ `Unknown tool provider '${providerId ?? settings.toolProvider}'. Registered: ${toolProviders2.map((p) => p.id).join(", ")}.`
1394
+ );
1395
+ }
1396
+ const provider = def.createProvider({
1397
+ auth: opts.vertex.auth,
1398
+ projectId: opts.vertex.projectId,
1399
+ defaultLocation: opts.vertex.defaultLocation,
1400
+ modelIds: opts.vertex.modelIds,
1401
+ location: location ?? settings.gcpLocation
1402
+ });
1403
+ const systemBlocks = await opts.tools.buildSystemBlocks(ctx);
1404
+ const input = {
1405
+ question,
1406
+ ctx,
1407
+ tools: opts.tools.tools,
1408
+ systemBlocks,
1409
+ provider,
1410
+ maxToolTurns,
1411
+ maxOutputTokens
1412
+ };
1413
+ return runAgent(input);
1414
+ };
1415
+ const sharedHooks = opts.hooks ? {
1416
+ onRequest: opts.hooks.onRequest,
1417
+ onAuthenticated: opts.hooks.onAuthenticated
1418
+ } : void 0;
1419
+ const agentCustom = createAgentCustomRoutes({
1420
+ persistence: opts.persistence,
1421
+ auth: opts.auth,
1422
+ scope: opts.scope,
1423
+ tools: opts.tools,
1424
+ vertex: opts.vertex,
1425
+ logger: opts.logger,
1426
+ resolveNarratorId: opts.resolveNarratorId,
1427
+ hooks: opts.hooks
1428
+ });
1429
+ const chatSessions = createChatSessionsRoutes({
1430
+ persistence: opts.persistence,
1431
+ auth: opts.auth,
1432
+ logger: opts.logger,
1433
+ hooks: sharedHooks
1434
+ });
1435
+ const adminSettings = createAdminSettingsRoutes({
1436
+ persistence: opts.persistence,
1437
+ auth: opts.auth,
1438
+ toolProviders: toolProviders2,
1439
+ chatInterfaces,
1440
+ logger: opts.logger,
1441
+ hooks: sharedHooks
1442
+ });
1443
+ return {
1444
+ runAgent: runAgentBound,
1445
+ routes: { agentCustom, chatSessions, adminSettings },
1446
+ registries: { toolProviders: toolProviders2, chatInterfaces }
1447
+ };
1448
+ }
1449
+
1450
+ Object.defineProperty(exports, "GoogleAuth", {
1451
+ enumerable: true,
1452
+ get: function () { return googleAuthLibrary.GoogleAuth; }
1453
+ });
1454
+ exports.BUILTIN_CHAT_INTERFACE_IDS = BUILTIN_CHAT_INTERFACE_IDS;
1455
+ exports.DEFAULT_MAX_OUTPUT_TOKENS = DEFAULT_MAX_OUTPUT_TOKENS;
1456
+ exports.DEFAULT_MAX_TOOL_TURNS = DEFAULT_MAX_TOOL_TURNS;
1457
+ exports.TERMINAL_TOOL_NAME = TERMINAL_TOOL_NAME;
1458
+ exports.configureAiChat = configureAiChat;
1459
+ exports.err = err;
1460
+ exports.getToolProvider = getToolProvider;
1461
+ exports.ok = ok;
1462
+ exports.runAgent = runAgent;
1463
+ exports.toolProviders = toolProviders;
1464
+ //# sourceMappingURL=index.cjs.map
1465
+ //# sourceMappingURL=index.cjs.map