@exulu/backend 2.2.0 → 3.0.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/{chunk-Y7JPNBFM.js → chunk-KFL7HIID.js} +489 -1
- package/dist/{convert-exulu-tools-to-ai-sdk-tools-M7I2TZQQ.js → convert-exulu-tools-to-ai-sdk-tools-YY2WIMMJ.js} +2 -1
- package/dist/index.cjs +17268 -14995
- package/dist/index.d.cts +4 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +6941 -5210
- package/ee/queues/decorator.ts +11 -0
- package/ee/queues/prune-job-results.test.ts +41 -0
- package/ee/queues/prune-job-results.ts +5 -4
- package/ee/schemas.ts +96 -1
- package/ee/workers.flow.test.ts +236 -0
- package/ee/workers.ts +409 -168
- package/package.json +6 -1
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import "dotenv/config";
|
|
2
|
+
import {
|
|
3
|
+
findLiteLLMModel
|
|
4
|
+
} from "./chunk-7CCMW3IW.js";
|
|
2
5
|
|
|
3
6
|
// src/templates/tools/convert-exulu-tools-to-ai-sdk-tools.ts
|
|
4
7
|
import { S3Client as S3Client2, PutObjectCommand as PutObjectCommand2, S3ServiceException } from "@aws-sdk/client-s3";
|
|
@@ -1715,7 +1718,7 @@ var ExuluTool = class _ExuluTool {
|
|
|
1715
1718
|
});
|
|
1716
1719
|
providerapikey = resolved.apiKey;
|
|
1717
1720
|
}
|
|
1718
|
-
const { convertExuluToolsToAiSdkTools: convertExuluToolsToAiSdkTools2 } = await import("./convert-exulu-tools-to-ai-sdk-tools-
|
|
1721
|
+
const { convertExuluToolsToAiSdkTools: convertExuluToolsToAiSdkTools2 } = await import("./convert-exulu-tools-to-ai-sdk-tools-YY2WIMMJ.js");
|
|
1719
1722
|
const tools = await convertExuluToolsToAiSdkTools2(
|
|
1720
1723
|
[this],
|
|
1721
1724
|
[],
|
|
@@ -5381,6 +5384,15 @@ var REQUIRED_SYSTEM_DEPENDENCIES = [
|
|
|
5381
5384
|
macos: "brew install poppler"
|
|
5382
5385
|
}
|
|
5383
5386
|
},
|
|
5387
|
+
{
|
|
5388
|
+
check: { kind: "binary", binary: "pdftotext" },
|
|
5389
|
+
displayName: "Poppler (pdftotext)",
|
|
5390
|
+
purpose: "parse_document tool: extracting page-marked text from PDFs",
|
|
5391
|
+
installHints: {
|
|
5392
|
+
debian: "apt-get install -y poppler-utils",
|
|
5393
|
+
macos: "brew install poppler"
|
|
5394
|
+
}
|
|
5395
|
+
},
|
|
5384
5396
|
{
|
|
5385
5397
|
check: { kind: "npm-global", packageName: "docx" },
|
|
5386
5398
|
displayName: "docx (npm global)",
|
|
@@ -6223,6 +6235,469 @@ var createSessionFileReadTool = ({
|
|
|
6223
6235
|
});
|
|
6224
6236
|
};
|
|
6225
6237
|
|
|
6238
|
+
// src/templates/tools/parse-document-tool.ts
|
|
6239
|
+
import { z as z11 } from "zod";
|
|
6240
|
+
import { extname } from "path";
|
|
6241
|
+
import { parseOfficeAsync } from "officeparser";
|
|
6242
|
+
|
|
6243
|
+
// src/templates/tools/document-render-helpers.ts
|
|
6244
|
+
import { execFile } from "child_process";
|
|
6245
|
+
import { promisify as promisify3 } from "util";
|
|
6246
|
+
import { mkdtemp, readdir as readdir2, readFile, rm as rm2, writeFile as writeFile2 } from "fs/promises";
|
|
6247
|
+
import { tmpdir } from "os";
|
|
6248
|
+
import { join as join3 } from "path";
|
|
6249
|
+
var execFileAsync = promisify3(execFile);
|
|
6250
|
+
var MAX_STDOUT_BYTES = 64 * 1024 * 1024;
|
|
6251
|
+
async function pdfToText(pdf) {
|
|
6252
|
+
const dir = await mkdtemp(join3(tmpdir(), "exulu-parse-"));
|
|
6253
|
+
try {
|
|
6254
|
+
const inputPath = join3(dir, "input.pdf");
|
|
6255
|
+
await writeFile2(inputPath, pdf);
|
|
6256
|
+
const { stdout } = await execFileAsync("pdftotext", ["-layout", inputPath, "-"], {
|
|
6257
|
+
timeout: 6e4,
|
|
6258
|
+
maxBuffer: MAX_STDOUT_BYTES
|
|
6259
|
+
});
|
|
6260
|
+
return stdout;
|
|
6261
|
+
} finally {
|
|
6262
|
+
await rm2(dir, { recursive: true, force: true });
|
|
6263
|
+
}
|
|
6264
|
+
}
|
|
6265
|
+
async function renderPdfPageToPng(pdf, page, scaleTo) {
|
|
6266
|
+
const dir = await mkdtemp(join3(tmpdir(), "exulu-render-"));
|
|
6267
|
+
try {
|
|
6268
|
+
const inputPath = join3(dir, "input.pdf");
|
|
6269
|
+
await writeFile2(inputPath, pdf);
|
|
6270
|
+
try {
|
|
6271
|
+
await execFileAsync(
|
|
6272
|
+
"pdftoppm",
|
|
6273
|
+
["-png", "-f", String(page), "-l", String(page), "-scale-to", String(scaleTo), inputPath, join3(dir, "page")],
|
|
6274
|
+
{ timeout: 6e4, maxBuffer: MAX_STDOUT_BYTES }
|
|
6275
|
+
);
|
|
6276
|
+
} catch (err) {
|
|
6277
|
+
if (err?.code === 99) return null;
|
|
6278
|
+
throw err;
|
|
6279
|
+
}
|
|
6280
|
+
const produced = (await readdir2(dir)).find((f) => f.startsWith("page") && f.endsWith(".png"));
|
|
6281
|
+
if (!produced) return null;
|
|
6282
|
+
return await readFile(join3(dir, produced));
|
|
6283
|
+
} finally {
|
|
6284
|
+
await rm2(dir, { recursive: true, force: true });
|
|
6285
|
+
}
|
|
6286
|
+
}
|
|
6287
|
+
|
|
6288
|
+
// src/templates/tools/parse-document-tool.ts
|
|
6289
|
+
var DEFAULT_LIMIT2 = 250;
|
|
6290
|
+
var MAX_CONTENT_CHARS2 = 16e3;
|
|
6291
|
+
var MIN_CHARS_PER_PAGE = 20;
|
|
6292
|
+
var OFFICE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
6293
|
+
".docx",
|
|
6294
|
+
".doc",
|
|
6295
|
+
".xlsx",
|
|
6296
|
+
".xls",
|
|
6297
|
+
".pptx",
|
|
6298
|
+
".ppt",
|
|
6299
|
+
".odt",
|
|
6300
|
+
".ods",
|
|
6301
|
+
".odp",
|
|
6302
|
+
".rtf"
|
|
6303
|
+
]);
|
|
6304
|
+
var pagesPattern = /^(\d+)(?:-(\d+))?$/;
|
|
6305
|
+
var createParseDocumentTool = ({
|
|
6306
|
+
sessionID,
|
|
6307
|
+
user,
|
|
6308
|
+
exuluConfig
|
|
6309
|
+
}) => {
|
|
6310
|
+
if (!sessionID || !exuluConfig?.fileUploads?.s3Bucket) return void 0;
|
|
6311
|
+
const parseDocumentExecute = async ({
|
|
6312
|
+
filename,
|
|
6313
|
+
pages,
|
|
6314
|
+
offset,
|
|
6315
|
+
limit
|
|
6316
|
+
}) => {
|
|
6317
|
+
const safeName = String(filename ?? "").trim();
|
|
6318
|
+
if (!safeName || safeName.includes("..") || safeName.includes("/") || safeName.includes("\\")) {
|
|
6319
|
+
return {
|
|
6320
|
+
error: "Invalid filename \u2014 pass the bare file name exactly as listed in the session files (no paths)."
|
|
6321
|
+
};
|
|
6322
|
+
}
|
|
6323
|
+
const ext = extname(safeName).toLowerCase();
|
|
6324
|
+
if (ext !== ".pdf" && !OFFICE_EXTENSIONS.has(ext)) {
|
|
6325
|
+
return {
|
|
6326
|
+
error: `Unsupported extension "${ext}" \u2014 parse_document handles PDF and Office formats. For plain-text files use read_session_file.`
|
|
6327
|
+
};
|
|
6328
|
+
}
|
|
6329
|
+
if (pages && ext !== ".pdf") {
|
|
6330
|
+
return { error: `The pages option is only supported for PDF files \u2014 "${ext}" documents are extracted whole.` };
|
|
6331
|
+
}
|
|
6332
|
+
const uploads = exuluConfig.fileUploads;
|
|
6333
|
+
const generalPrefix = uploads.s3prefix ? `${uploads.s3prefix.replace(/\/$/, "")}/` : "";
|
|
6334
|
+
const key = `${generalPrefix}user_${user?.id ?? "api"}/sessions/${sessionID}/${safeName}`;
|
|
6335
|
+
try {
|
|
6336
|
+
const url = await getPresignedUrl(uploads.s3Bucket, key, exuluConfig);
|
|
6337
|
+
const res = await fetch(url);
|
|
6338
|
+
if (!res.ok) {
|
|
6339
|
+
return { error: `Could not read session file "${safeName}" (status ${res.status}). Check the exact file name.` };
|
|
6340
|
+
}
|
|
6341
|
+
const bytes = Buffer.from(await res.arrayBuffer());
|
|
6342
|
+
let fullText;
|
|
6343
|
+
let totalPages;
|
|
6344
|
+
if (ext === ".pdf") {
|
|
6345
|
+
const raw = await pdfToText(bytes);
|
|
6346
|
+
const pageTexts = raw.replace(/\f$/, "").split("\f");
|
|
6347
|
+
totalPages = pageTexts.length;
|
|
6348
|
+
const nonWhitespace = raw.replace(/\s/g, "").length;
|
|
6349
|
+
if (nonWhitespace < totalPages * MIN_CHARS_PER_PAGE) {
|
|
6350
|
+
return {
|
|
6351
|
+
error: `"${safeName}" has no extractable text layer (likely a scan or image-based PDF). Use view_document_page to look at pages visually, or suggest the user add the document to a knowledge base with a document processor for full OCR.`
|
|
6352
|
+
};
|
|
6353
|
+
}
|
|
6354
|
+
let range = [1, totalPages];
|
|
6355
|
+
if (pages) {
|
|
6356
|
+
const m = pagesPattern.exec(pages.trim());
|
|
6357
|
+
if (!m) return { error: `Invalid pages "${pages}" \u2014 use "3" or "2-5".` };
|
|
6358
|
+
range = [Number(m[1]), Number(m[2] ?? m[1])];
|
|
6359
|
+
if (range[0] < 1 || range[0] > range[1]) {
|
|
6360
|
+
return { error: `Invalid pages "${pages}" \u2014 start must be at least 1 and not greater than the end.` };
|
|
6361
|
+
}
|
|
6362
|
+
if (range[0] > totalPages) {
|
|
6363
|
+
return { error: `Page range starts at ${range[0]} but "${safeName}" has only ${totalPages} page${totalPages === 1 ? "" : "s"}.` };
|
|
6364
|
+
}
|
|
6365
|
+
}
|
|
6366
|
+
fullText = pageTexts.map((text, i) => ({ page: i + 1, text })).filter(({ page }) => page >= range[0] && page <= range[1]).map(({ page, text }) => `--- page ${page} ---
|
|
6367
|
+
${text.trim()}`).join("\n");
|
|
6368
|
+
} else {
|
|
6369
|
+
const extracted = await parseOfficeAsync(bytes, {
|
|
6370
|
+
outputErrorToConsole: false,
|
|
6371
|
+
newlineDelimiter: "\n"
|
|
6372
|
+
});
|
|
6373
|
+
fullText = String(extracted);
|
|
6374
|
+
}
|
|
6375
|
+
const lines = fullText.split("\n");
|
|
6376
|
+
const start = (offset ?? 1) - 1;
|
|
6377
|
+
const requested = limit ?? DEFAULT_LIMIT2;
|
|
6378
|
+
const sliced = lines.slice(start, start + requested);
|
|
6379
|
+
let content = sliced.join("\n");
|
|
6380
|
+
let linesReturned = sliced.length;
|
|
6381
|
+
if (content.length > MAX_CONTENT_CHARS2) {
|
|
6382
|
+
content = content.slice(0, MAX_CONTENT_CHARS2);
|
|
6383
|
+
linesReturned = Math.max(1, content.split("\n").length - 1);
|
|
6384
|
+
content = content + "\n[slice truncated \u2014 request fewer lines]";
|
|
6385
|
+
}
|
|
6386
|
+
return {
|
|
6387
|
+
content,
|
|
6388
|
+
...totalPages !== void 0 ? { totalPages } : {},
|
|
6389
|
+
totalLines: lines.length,
|
|
6390
|
+
offset: start + 1,
|
|
6391
|
+
linesReturned
|
|
6392
|
+
};
|
|
6393
|
+
} catch (err) {
|
|
6394
|
+
return { error: `Failed to parse "${safeName}": ${err instanceof Error ? err.message : "unknown error"}` };
|
|
6395
|
+
}
|
|
6396
|
+
};
|
|
6397
|
+
return ExuluTool.internal({
|
|
6398
|
+
id: "parse_document",
|
|
6399
|
+
name: "parse_document",
|
|
6400
|
+
needsApproval: false,
|
|
6401
|
+
description: `Extract the text of an uploaded PDF or Office document from this session's files, with "--- page N ---" markers for PDFs so you can locate content by page. Free and fast (no OCR): works only on documents with a real text layer. To SEE a page or an image inside a document, use view_document_page.`,
|
|
6402
|
+
inputSchema: z11.object({
|
|
6403
|
+
filename: z11.string().describe('Exact session file name, e.g. "report.pdf"'),
|
|
6404
|
+
pages: z11.string().optional().describe('PDF page or range to extract, e.g. "2" or "1-5" (default: all pages) (PDF only)'),
|
|
6405
|
+
offset: z11.number().int().min(1).optional().describe("1-based first output line to read (default 1)"),
|
|
6406
|
+
limit: z11.number().int().min(1).max(1e3).optional().describe(`Number of lines to read (default ${DEFAULT_LIMIT2})`)
|
|
6407
|
+
}),
|
|
6408
|
+
type: "function",
|
|
6409
|
+
category: "session",
|
|
6410
|
+
config: [],
|
|
6411
|
+
// Same shape mismatch as read_session_file / memory-tool: internal utility
|
|
6412
|
+
// tools return richer objects than ExuluTool's retrieval-flavored execute
|
|
6413
|
+
// type; the AI SDK passes the object through verbatim.
|
|
6414
|
+
execute: parseDocumentExecute
|
|
6415
|
+
});
|
|
6416
|
+
};
|
|
6417
|
+
|
|
6418
|
+
// src/templates/tools/view-document-page-tool.ts
|
|
6419
|
+
import { z as z12 } from "zod";
|
|
6420
|
+
import { extname as extname3 } from "path";
|
|
6421
|
+
|
|
6422
|
+
// src/sessions/pdf-preview-cache.ts
|
|
6423
|
+
import { exec as exec3 } from "child_process";
|
|
6424
|
+
import { existsSync as existsSync4 } from "fs";
|
|
6425
|
+
import { mkdir as mkdir2, readFile as readFile2, rename, rm as rm3, writeFile as writeFile3 } from "fs/promises";
|
|
6426
|
+
import { extname as extname2, join as join4 } from "path";
|
|
6427
|
+
import { promisify as promisify4 } from "util";
|
|
6428
|
+
var execAsync3 = promisify4(exec3);
|
|
6429
|
+
var CACHE_ROOT = "/tmp/exulu-pdf-cache";
|
|
6430
|
+
var CACHE_IN = join4(CACHE_ROOT, "_in");
|
|
6431
|
+
var CACHE_OUT = join4(CACHE_ROOT, "_out");
|
|
6432
|
+
var inFlight = /* @__PURE__ */ new Map();
|
|
6433
|
+
var PreviewRenderError = class extends Error {
|
|
6434
|
+
constructor(message) {
|
|
6435
|
+
super(message);
|
|
6436
|
+
this.name = "PreviewRenderError";
|
|
6437
|
+
}
|
|
6438
|
+
};
|
|
6439
|
+
function sanitizeEtag(raw) {
|
|
6440
|
+
return raw.replace(/^"|"$/g, "").replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
6441
|
+
}
|
|
6442
|
+
async function getPdfPreviewBytes(opts) {
|
|
6443
|
+
const { sourceKey, etag, config } = opts;
|
|
6444
|
+
const safeEtag = sanitizeEtag(etag);
|
|
6445
|
+
if (!safeEtag) {
|
|
6446
|
+
throw new PreviewRenderError(`Invalid ETag for ${sourceKey}`);
|
|
6447
|
+
}
|
|
6448
|
+
const cachedPath = join4(CACHE_ROOT, `${safeEtag}.pdf`);
|
|
6449
|
+
if (existsSync4(cachedPath)) {
|
|
6450
|
+
return readFile2(cachedPath);
|
|
6451
|
+
}
|
|
6452
|
+
const existing = inFlight.get(safeEtag);
|
|
6453
|
+
if (existing) return existing;
|
|
6454
|
+
const promise = (async () => {
|
|
6455
|
+
try {
|
|
6456
|
+
await mkdir2(CACHE_IN, { recursive: true });
|
|
6457
|
+
await mkdir2(CACHE_OUT, { recursive: true });
|
|
6458
|
+
const ext = (extname2(sourceKey) || ".docx").toLowerCase();
|
|
6459
|
+
const inputPath = join4(CACHE_IN, `${safeEtag}${ext}`);
|
|
6460
|
+
const outputPath = join4(CACHE_OUT, `${safeEtag}.pdf`);
|
|
6461
|
+
try {
|
|
6462
|
+
const bytes = await getS3ObjectBytes(sourceKey, config);
|
|
6463
|
+
await writeFile3(inputPath, bytes);
|
|
6464
|
+
try {
|
|
6465
|
+
await execAsync3(
|
|
6466
|
+
`soffice --headless --convert-to pdf "${inputPath}" --outdir "${CACHE_OUT}"`,
|
|
6467
|
+
{ timeout: 6e4, maxBuffer: 16 * 1024 * 1024 }
|
|
6468
|
+
);
|
|
6469
|
+
} catch (err) {
|
|
6470
|
+
throw new PreviewRenderError(
|
|
6471
|
+
`LibreOffice conversion failed for ${sourceKey} (etag ${etag}): ${err?.stderr ?? err?.message ?? "unknown error"}`
|
|
6472
|
+
);
|
|
6473
|
+
}
|
|
6474
|
+
if (!existsSync4(outputPath)) {
|
|
6475
|
+
throw new PreviewRenderError(
|
|
6476
|
+
`LibreOffice produced no output for ${sourceKey} (etag ${etag})`
|
|
6477
|
+
);
|
|
6478
|
+
}
|
|
6479
|
+
await rename(outputPath, cachedPath);
|
|
6480
|
+
return await readFile2(cachedPath);
|
|
6481
|
+
} finally {
|
|
6482
|
+
await rm3(inputPath, { force: true });
|
|
6483
|
+
}
|
|
6484
|
+
} finally {
|
|
6485
|
+
inFlight.delete(safeEtag);
|
|
6486
|
+
}
|
|
6487
|
+
})();
|
|
6488
|
+
inFlight.set(safeEtag, promise);
|
|
6489
|
+
return promise;
|
|
6490
|
+
}
|
|
6491
|
+
|
|
6492
|
+
// src/exulu/tool-image-attachments.ts
|
|
6493
|
+
var INJECTED_IMAGE_PREFIX = "[Image attached from tool call ";
|
|
6494
|
+
var stash = /* @__PURE__ */ new Map();
|
|
6495
|
+
var MAX_ENTRIES = 100;
|
|
6496
|
+
var TTL_MS = 30 * 60 * 1e3;
|
|
6497
|
+
var MAX_TOTAL_BYTES = 1e8;
|
|
6498
|
+
var STASH_TOOL_NAME = "view_document_page";
|
|
6499
|
+
function sweep() {
|
|
6500
|
+
const cutoff = Date.now() - TTL_MS;
|
|
6501
|
+
for (const [id, entry] of stash) {
|
|
6502
|
+
if (entry.stashedAt < cutoff) stash.delete(id);
|
|
6503
|
+
}
|
|
6504
|
+
while (stash.size > MAX_ENTRIES) {
|
|
6505
|
+
const oldest = stash.keys().next().value;
|
|
6506
|
+
if (oldest === void 0) break;
|
|
6507
|
+
stash.delete(oldest);
|
|
6508
|
+
}
|
|
6509
|
+
let totalBytes = 0;
|
|
6510
|
+
for (const entry of stash.values()) totalBytes += entry.data.length;
|
|
6511
|
+
while (totalBytes > MAX_TOTAL_BYTES) {
|
|
6512
|
+
const oldest = stash.keys().next().value;
|
|
6513
|
+
if (oldest === void 0) break;
|
|
6514
|
+
totalBytes -= stash.get(oldest).data.length;
|
|
6515
|
+
stash.delete(oldest);
|
|
6516
|
+
}
|
|
6517
|
+
}
|
|
6518
|
+
function stashToolImage(toolCallId, image) {
|
|
6519
|
+
stash.set(toolCallId, { ...image, stashedAt: Date.now() });
|
|
6520
|
+
sweep();
|
|
6521
|
+
}
|
|
6522
|
+
function stashedIdsInMessage(message) {
|
|
6523
|
+
if (message?.role !== "tool" || !Array.isArray(message.content)) return [];
|
|
6524
|
+
return message.content.filter(
|
|
6525
|
+
(p) => p?.type === "tool-result" && typeof p.toolCallId === "string" && p.toolName === STASH_TOOL_NAME && stash.has(p.toolCallId)
|
|
6526
|
+
).map((p) => p.toolCallId);
|
|
6527
|
+
}
|
|
6528
|
+
function injectedTextFor(id, label) {
|
|
6529
|
+
return `${INJECTED_IMAGE_PREFIX}${id}: ${label}]`;
|
|
6530
|
+
}
|
|
6531
|
+
function firstTextPart(message) {
|
|
6532
|
+
if (message?.role !== "user" || !Array.isArray(message.content)) return void 0;
|
|
6533
|
+
const first = message.content[0];
|
|
6534
|
+
return first?.type === "text" ? first.text : void 0;
|
|
6535
|
+
}
|
|
6536
|
+
function imageAttachmentGuard() {
|
|
6537
|
+
return ({ messages }) => {
|
|
6538
|
+
sweep();
|
|
6539
|
+
if (!Array.isArray(messages) || messages.length === 0 || stash.size === 0) return void 0;
|
|
6540
|
+
let changed = false;
|
|
6541
|
+
const next = [];
|
|
6542
|
+
for (let i = 0; i < messages.length; i++) {
|
|
6543
|
+
const message = messages[i];
|
|
6544
|
+
next.push(message);
|
|
6545
|
+
const ids = stashedIdsInMessage(message);
|
|
6546
|
+
if (ids.length === 0) continue;
|
|
6547
|
+
const alreadyInjected = [];
|
|
6548
|
+
for (let j = i + 1; j < messages.length; j++) {
|
|
6549
|
+
const text = firstTextPart(messages[j]);
|
|
6550
|
+
if (text === void 0 || !text.startsWith(INJECTED_IMAGE_PREFIX)) break;
|
|
6551
|
+
alreadyInjected.push(text);
|
|
6552
|
+
}
|
|
6553
|
+
for (const id of ids) {
|
|
6554
|
+
if (alreadyInjected.some((text) => text.startsWith(`${INJECTED_IMAGE_PREFIX}${id}:`))) continue;
|
|
6555
|
+
const image = stash.get(id);
|
|
6556
|
+
changed = true;
|
|
6557
|
+
next.push({
|
|
6558
|
+
role: "user",
|
|
6559
|
+
content: [
|
|
6560
|
+
{ type: "text", text: injectedTextFor(id, image.label) },
|
|
6561
|
+
{ type: "image", image: image.data, mediaType: image.mediaType }
|
|
6562
|
+
]
|
|
6563
|
+
});
|
|
6564
|
+
}
|
|
6565
|
+
}
|
|
6566
|
+
return changed ? { messages: next } : void 0;
|
|
6567
|
+
};
|
|
6568
|
+
}
|
|
6569
|
+
|
|
6570
|
+
// src/templates/tools/view-document-page-tool.ts
|
|
6571
|
+
var MAX_IMAGE_BYTES = 375e4;
|
|
6572
|
+
var SCALE_PRIMARY = 1568;
|
|
6573
|
+
var SCALE_FALLBACK = 1024;
|
|
6574
|
+
var IMAGE_MEDIA_TYPES = {
|
|
6575
|
+
".png": "image/png",
|
|
6576
|
+
".jpg": "image/jpeg",
|
|
6577
|
+
".jpeg": "image/jpeg",
|
|
6578
|
+
".gif": "image/gif",
|
|
6579
|
+
".webp": "image/webp"
|
|
6580
|
+
};
|
|
6581
|
+
var OFFICE_EXTENSIONS2 = /* @__PURE__ */ new Set([
|
|
6582
|
+
".docx",
|
|
6583
|
+
".doc",
|
|
6584
|
+
".xlsx",
|
|
6585
|
+
".xls",
|
|
6586
|
+
".pptx",
|
|
6587
|
+
".ppt",
|
|
6588
|
+
".odt",
|
|
6589
|
+
".ods",
|
|
6590
|
+
".odp",
|
|
6591
|
+
".rtf"
|
|
6592
|
+
]);
|
|
6593
|
+
var createViewDocumentPageTool = ({
|
|
6594
|
+
sessionID,
|
|
6595
|
+
user,
|
|
6596
|
+
exuluConfig
|
|
6597
|
+
}) => {
|
|
6598
|
+
if (!sessionID || !exuluConfig?.fileUploads?.s3Bucket) return void 0;
|
|
6599
|
+
const viewDocumentPageExecute = async ({ filename, page, model }, options) => {
|
|
6600
|
+
const safeName = String(filename ?? "").trim();
|
|
6601
|
+
if (!safeName || safeName.includes("..") || safeName.includes("/") || safeName.includes("\\")) {
|
|
6602
|
+
return {
|
|
6603
|
+
error: "Invalid filename \u2014 pass the bare file name exactly as listed in the session files (no paths)."
|
|
6604
|
+
};
|
|
6605
|
+
}
|
|
6606
|
+
const ext = extname3(safeName).toLowerCase();
|
|
6607
|
+
const isImage = ext in IMAGE_MEDIA_TYPES;
|
|
6608
|
+
const isPdf = ext === ".pdf";
|
|
6609
|
+
const isOffice = OFFICE_EXTENSIONS2.has(ext);
|
|
6610
|
+
if (!isImage && !isPdf && !isOffice) {
|
|
6611
|
+
return { error: `Unsupported extension "${ext}" \u2014 view_document_page handles PDF, Office, and image files.` };
|
|
6612
|
+
}
|
|
6613
|
+
const modelId = typeof model === "string" ? model : model?.modelId;
|
|
6614
|
+
if (!modelId) {
|
|
6615
|
+
console.warn("[EXULU] view_document_page: no model id available for vision gating \u2014 proceeding optimistically.");
|
|
6616
|
+
}
|
|
6617
|
+
if (modelId) {
|
|
6618
|
+
try {
|
|
6619
|
+
const entry = await findLiteLLMModel(modelId);
|
|
6620
|
+
if (entry && entry.supports_vision === false) {
|
|
6621
|
+
return {
|
|
6622
|
+
error: `The current model "${modelId}" does not support images, so this page cannot be shown to you. Use parse_document for the text, or tell the user a vision-capable model is required.`
|
|
6623
|
+
};
|
|
6624
|
+
}
|
|
6625
|
+
} catch {
|
|
6626
|
+
}
|
|
6627
|
+
}
|
|
6628
|
+
const uploads = exuluConfig.fileUploads;
|
|
6629
|
+
const generalPrefix = uploads.s3prefix ? `${uploads.s3prefix.replace(/\/$/, "")}/` : "";
|
|
6630
|
+
const key = `${generalPrefix}user_${user?.id ?? "api"}/sessions/${sessionID}/${safeName}`;
|
|
6631
|
+
const pageNumber = page ?? 1;
|
|
6632
|
+
try {
|
|
6633
|
+
let imageBytes;
|
|
6634
|
+
let mediaType = "image/png";
|
|
6635
|
+
if (isImage) {
|
|
6636
|
+
imageBytes = await getS3ObjectBytes(key, exuluConfig);
|
|
6637
|
+
mediaType = IMAGE_MEDIA_TYPES[ext];
|
|
6638
|
+
if (imageBytes.length > MAX_IMAGE_BYTES) {
|
|
6639
|
+
return {
|
|
6640
|
+
error: `"${safeName}" is too large to attach (${imageBytes.length} bytes, max ${MAX_IMAGE_BYTES}). Ask the user for a smaller version of the image.`
|
|
6641
|
+
};
|
|
6642
|
+
}
|
|
6643
|
+
} else {
|
|
6644
|
+
let pdfBytes;
|
|
6645
|
+
if (isPdf) {
|
|
6646
|
+
pdfBytes = await getS3ObjectBytes(key, exuluConfig);
|
|
6647
|
+
} else {
|
|
6648
|
+
const etag = await getS3ObjectEtag(key, exuluConfig);
|
|
6649
|
+
if (!etag) {
|
|
6650
|
+
return { error: `Could not read session file "${safeName}". Check the exact file name.` };
|
|
6651
|
+
}
|
|
6652
|
+
pdfBytes = await getPdfPreviewBytes({ sourceKey: key, etag, config: exuluConfig });
|
|
6653
|
+
}
|
|
6654
|
+
let rendered = await renderPdfPageToPng(pdfBytes, pageNumber, SCALE_PRIMARY);
|
|
6655
|
+
if (!rendered) {
|
|
6656
|
+
return { error: `Could not render page ${pageNumber} of "${safeName}" \u2014 the document may have fewer pages.` };
|
|
6657
|
+
}
|
|
6658
|
+
if (rendered.length > MAX_IMAGE_BYTES) {
|
|
6659
|
+
rendered = await renderPdfPageToPng(pdfBytes, pageNumber, SCALE_FALLBACK);
|
|
6660
|
+
}
|
|
6661
|
+
if (!rendered || rendered.length > MAX_IMAGE_BYTES) {
|
|
6662
|
+
return { error: `Page ${pageNumber} of "${safeName}" is too complex to attach within the image size limit.` };
|
|
6663
|
+
}
|
|
6664
|
+
imageBytes = rendered;
|
|
6665
|
+
}
|
|
6666
|
+
if (!options?.toolCallId) {
|
|
6667
|
+
return { error: "Internal error: missing toolCallId \u2014 the image cannot be attached." };
|
|
6668
|
+
}
|
|
6669
|
+
stashToolImage(options.toolCallId, {
|
|
6670
|
+
data: imageBytes.toString("base64"),
|
|
6671
|
+
mediaType,
|
|
6672
|
+
label: isImage ? safeName : `${safeName} page ${pageNumber}`
|
|
6673
|
+
});
|
|
6674
|
+
return {
|
|
6675
|
+
attached: true,
|
|
6676
|
+
filename: safeName,
|
|
6677
|
+
page: pageNumber,
|
|
6678
|
+
note: "The rendered image follows this tool result as an attached user message \u2014 analyze it there. If no image message follows, the attachment has expired; call this tool again to re-render it."
|
|
6679
|
+
};
|
|
6680
|
+
} catch (err) {
|
|
6681
|
+
return { error: `Failed to render "${safeName}": ${err instanceof Error ? err.message : "unknown error"}` };
|
|
6682
|
+
}
|
|
6683
|
+
};
|
|
6684
|
+
return ExuluTool.internal({
|
|
6685
|
+
id: "view_document_page",
|
|
6686
|
+
name: "view_document_page",
|
|
6687
|
+
needsApproval: false,
|
|
6688
|
+
description: "LOOK at a page of an uploaded PDF/Office document, or at an uploaded image, from this session's files. The rendered image is attached as a user message directly after this tool result so you can visually analyze photos, charts, scans, and layouts. Use parse_document first to find which page you need. Requires a vision-capable model.",
|
|
6689
|
+
inputSchema: z12.object({
|
|
6690
|
+
filename: z12.string().describe('Exact session file name, e.g. "report.pdf" or "screenshot.png"'),
|
|
6691
|
+
page: z12.number().int().min(1).optional().describe("Page number to render (default 1; ignored for image files)")
|
|
6692
|
+
}),
|
|
6693
|
+
type: "function",
|
|
6694
|
+
category: "session",
|
|
6695
|
+
config: [],
|
|
6696
|
+
// Same execute-shape cast as read_session_file / parse_document.
|
|
6697
|
+
execute: viewDocumentPageExecute
|
|
6698
|
+
});
|
|
6699
|
+
};
|
|
6700
|
+
|
|
6226
6701
|
// src/templates/tools/convert-exulu-tools-to-ai-sdk-tools.ts
|
|
6227
6702
|
var OUTPUT_OFFLOAD_EXEMPT_TOOL_IDS = /* @__PURE__ */ new Set(["agentic_context_search"]);
|
|
6228
6703
|
var generateS3Key = (filename) => `${randomUUID4()}-${filename}`;
|
|
@@ -6404,6 +6879,14 @@ var convertExuluToolsToAiSdkTools = async (currentTools, currentSkills, approved
|
|
|
6404
6879
|
if (sessionFileReadTool && !disabled.has(sessionFileReadTool.id)) {
|
|
6405
6880
|
currentTools.push(sessionFileReadTool);
|
|
6406
6881
|
}
|
|
6882
|
+
const parseDocumentTool = createParseDocumentTool({ sessionID, user, exuluConfig });
|
|
6883
|
+
if (parseDocumentTool && !disabled.has(parseDocumentTool.id)) {
|
|
6884
|
+
currentTools.push(parseDocumentTool);
|
|
6885
|
+
}
|
|
6886
|
+
const viewDocumentPageTool = createViewDocumentPageTool({ sessionID, user, exuluConfig });
|
|
6887
|
+
if (viewDocumentPageTool && !disabled.has(viewDocumentPageTool.id)) {
|
|
6888
|
+
currentTools.push(viewDocumentPageTool);
|
|
6889
|
+
}
|
|
6407
6890
|
console.log("[EXULU] Creating agentic search tool", contexts?.length, model);
|
|
6408
6891
|
if (contexts?.length && model && !disabled.has("agentic_context_search")) {
|
|
6409
6892
|
const index = currentTools.findIndex((tool3) => tool3.id === "agentic_context_search");
|
|
@@ -6713,6 +7196,8 @@ export {
|
|
|
6713
7196
|
resolveModel,
|
|
6714
7197
|
exuluApp,
|
|
6715
7198
|
oauthRegistry,
|
|
7199
|
+
encrypt,
|
|
7200
|
+
decrypt,
|
|
6716
7201
|
oauthTokenStore,
|
|
6717
7202
|
OAUTH_CALLBACK_PATH,
|
|
6718
7203
|
decryptOauthState,
|
|
@@ -6731,6 +7216,9 @@ export {
|
|
|
6731
7216
|
ContextCompactionRequiredError,
|
|
6732
7217
|
mapStreamErrorMessage,
|
|
6733
7218
|
guardExtractedFileText,
|
|
7219
|
+
PreviewRenderError,
|
|
7220
|
+
getPdfPreviewBytes,
|
|
7221
|
+
imageAttachmentGuard,
|
|
6734
7222
|
hydrateVariables,
|
|
6735
7223
|
convertExuluToolsToAiSdkTools,
|
|
6736
7224
|
ExuluTool,
|