@juspay/neurolink 12.30.2 → 12.31.1
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/CHANGELOG.md +3 -2
- package/dist/browser/neurolink.min.js +399 -382
- package/dist/cli/loop/optionsSchema.d.ts +1 -1
- package/dist/cli/proxy-clients/codex.js +21 -16
- package/dist/core/baseProvider.js +1 -0
- package/dist/core/modules/MessageBuilder.js +10 -0
- package/dist/neurolink.js +1 -0
- package/dist/processors/document/ExcelProcessor.d.ts +13 -1
- package/dist/processors/document/ExcelProcessor.js +118 -0
- package/dist/processors/document/WordProcessor.js +21 -0
- package/dist/proxy/proxyHistoryTruncation.js +28 -7
- package/dist/types/file.d.ts +15 -0
- package/dist/types/generate.d.ts +11 -1
- package/dist/types/processor.d.ts +11 -0
- package/dist/types/stream.d.ts +6 -1
- package/dist/types/utilities.d.ts +19 -0
- package/dist/utils/fileDetector.js +31 -36
- package/dist/utils/htmlToMarkdown.d.ts +23 -0
- package/dist/utils/htmlToMarkdown.js +442 -0
- package/dist/utils/messageBuilder.js +3 -0
- package/dist/utils/multimodalOptionsBuilder.d.ts +3 -0
- package/dist/utils/multimodalOptionsBuilder.js +6 -0
- package/docs-site/static/search-index.json +5 -1
- package/package.json +2 -1
|
@@ -341,14 +341,14 @@ export class FileDetector {
|
|
|
341
341
|
logger.warn(`[FileDetector] All fallback parsing failed for type "${detection.type}". ` +
|
|
342
342
|
`Attempted: ${options.allowedTypes.join(", ")}. Falling through to universal handler.`);
|
|
343
343
|
const csvOptions = options?.csvOptions;
|
|
344
|
-
const result = await FileDetector.processFile(content, detection, csvOptions, options?.provider, options?.videoOptions);
|
|
344
|
+
const result = await FileDetector.processFile(content, detection, csvOptions, options?.provider, options?.videoOptions, options?.officeOptions);
|
|
345
345
|
FileDetector.setFileResultSpanAttributes(span, result, inputFilename, detection.type);
|
|
346
346
|
return result;
|
|
347
347
|
}
|
|
348
348
|
const content = loadedUrl?.content ??
|
|
349
349
|
(await FileDetector.loadContent(input, detection, options));
|
|
350
350
|
const csvOptions = options?.csvOptions;
|
|
351
|
-
const result = await FileDetector.processFile(content, detection, csvOptions, options?.provider, options?.videoOptions);
|
|
351
|
+
const result = await FileDetector.processFile(content, detection, csvOptions, options?.provider, options?.videoOptions, options?.officeOptions);
|
|
352
352
|
FileDetector.setFileResultSpanAttributes(span, result, inputFilename, detection.type);
|
|
353
353
|
return result;
|
|
354
354
|
});
|
|
@@ -1063,7 +1063,7 @@ export class FileDetector {
|
|
|
1063
1063
|
/**
|
|
1064
1064
|
* Route to appropriate processor
|
|
1065
1065
|
*/
|
|
1066
|
-
static async processFile(content, detection, options, provider, videoOptions) {
|
|
1066
|
+
static async processFile(content, detection, options, provider, videoOptions, officeOptions) {
|
|
1067
1067
|
switch (detection.type) {
|
|
1068
1068
|
case "csv":
|
|
1069
1069
|
// Pass original extension through to CSV processor; if detection has none,
|
|
@@ -1087,7 +1087,7 @@ export class FileDetector {
|
|
|
1087
1087
|
case "archive":
|
|
1088
1088
|
return await FileDetector.processArchiveFile(content, detection);
|
|
1089
1089
|
case "xlsx":
|
|
1090
|
-
return await FileDetector.processXlsxFile(content, detection);
|
|
1090
|
+
return await FileDetector.processXlsxFile(content, detection, officeOptions);
|
|
1091
1091
|
case "docx":
|
|
1092
1092
|
return await FileDetector.processDocxFile(content, detection);
|
|
1093
1093
|
case "pptx":
|
|
@@ -1283,7 +1283,7 @@ export class FileDetector {
|
|
|
1283
1283
|
/**
|
|
1284
1284
|
* Process Excel/OpenDocument spreadsheet file via ExcelProcessor or OpenDocumentProcessor
|
|
1285
1285
|
*/
|
|
1286
|
-
static async processXlsxFile(content, detection) {
|
|
1286
|
+
static async processXlsxFile(content, detection, officeOptions) {
|
|
1287
1287
|
const xlsxFilename = detection.metadata.filename || "spreadsheet";
|
|
1288
1288
|
try {
|
|
1289
1289
|
const ext = detection.extension?.toLowerCase();
|
|
@@ -1308,7 +1308,7 @@ export class FileDetector {
|
|
|
1308
1308
|
}
|
|
1309
1309
|
}
|
|
1310
1310
|
else {
|
|
1311
|
-
const { excelProcessor } = await import("../processors/document/ExcelProcessor.js");
|
|
1311
|
+
const { excelProcessor, formatWorksheetsForPrompt } = await import("../processors/document/ExcelProcessor.js");
|
|
1312
1312
|
const xlsxResult = await excelProcessor.processFile({
|
|
1313
1313
|
id: xlsxFilename,
|
|
1314
1314
|
name: xlsxFilename,
|
|
@@ -1318,30 +1318,9 @@ export class FileDetector {
|
|
|
1318
1318
|
buffer: content,
|
|
1319
1319
|
});
|
|
1320
1320
|
if (xlsxResult.success && xlsxResult.data) {
|
|
1321
|
-
// Build text content from worksheets
|
|
1322
|
-
const sheets = xlsxResult.data.worksheets || [];
|
|
1323
|
-
let textContent = `Spreadsheet: ${sheets.length} sheet(s), ${xlsxResult.data.totalRows} total rows\n`;
|
|
1324
|
-
for (const sheet of sheets) {
|
|
1325
|
-
textContent += `\n### Sheet: ${sheet.name}\n`;
|
|
1326
|
-
textContent += `Columns (${sheet.columnCount}): ${sheet.headers.join(", ")}\n`;
|
|
1327
|
-
textContent += `Rows: ${sheet.rowCount}\n`;
|
|
1328
|
-
// Include first rows as sample data
|
|
1329
|
-
const sampleRows = sheet.rows.slice(0, 20);
|
|
1330
|
-
const rowText = sampleRows
|
|
1331
|
-
.map((row) => row.map((c) => String(c ?? "")).join("\t"))
|
|
1332
|
-
.join("\n");
|
|
1333
|
-
if (!rowText) {
|
|
1334
|
-
continue;
|
|
1335
|
-
}
|
|
1336
|
-
textContent += `\nData:\n${sheet.headers.join("\t")}\n${rowText}\n`;
|
|
1337
|
-
const remaining = sheet.rowCount - 20;
|
|
1338
|
-
if (remaining > 0) {
|
|
1339
|
-
textContent += `... (${remaining} more rows)\n`;
|
|
1340
|
-
}
|
|
1341
|
-
}
|
|
1342
1321
|
return {
|
|
1343
1322
|
type: "xlsx",
|
|
1344
|
-
content:
|
|
1323
|
+
content: formatWorksheetsForPrompt(xlsxResult.data.worksheets || [], xlsxResult.data.totalRows, officeOptions),
|
|
1345
1324
|
mimeType: detection.mimeType,
|
|
1346
1325
|
metadata: detection.metadata,
|
|
1347
1326
|
};
|
|
@@ -1374,13 +1353,18 @@ export class FileDetector {
|
|
|
1374
1353
|
try {
|
|
1375
1354
|
if (ext === "odt") {
|
|
1376
1355
|
const { openDocumentProcessor } = await import("../processors/document/OpenDocumentProcessor.js");
|
|
1377
|
-
|
|
1356
|
+
// Bounded per the project's async-timeout guideline, matching the ODP
|
|
1357
|
+
// branch of processPptxFile below: this unzips and parses
|
|
1358
|
+
// attacker-supplied bytes, and a stalled parse would otherwise hold
|
|
1359
|
+
// the request open with no ceiling. On timeout the throw lands in
|
|
1360
|
+
// this block's existing catch, which degrades to the placeholder.
|
|
1361
|
+
const odtResult = await withTimeout(openDocumentProcessor.processFile({
|
|
1378
1362
|
id: docxFilename,
|
|
1379
1363
|
name: docxFilename,
|
|
1380
1364
|
mimetype: detection.mimeType || "application/vnd.oasis.opendocument.text",
|
|
1381
1365
|
size: content.length,
|
|
1382
1366
|
buffer: content,
|
|
1383
|
-
});
|
|
1367
|
+
}), FileDetector.DEFAULT_DOCUMENT_TIMEOUT);
|
|
1384
1368
|
if (odtResult.success && odtResult.data) {
|
|
1385
1369
|
return {
|
|
1386
1370
|
type: "docx",
|
|
@@ -1393,13 +1377,14 @@ export class FileDetector {
|
|
|
1393
1377
|
}
|
|
1394
1378
|
else if (ext === "rtf") {
|
|
1395
1379
|
const { rtfProcessor } = await import("../processors/document/RtfProcessor.js");
|
|
1396
|
-
|
|
1380
|
+
// Bounded for the same reason as the odt branch above.
|
|
1381
|
+
const rtfResult = await withTimeout(rtfProcessor.processFile({
|
|
1397
1382
|
id: docxFilename,
|
|
1398
1383
|
name: docxFilename,
|
|
1399
1384
|
mimetype: detection.mimeType || "application/rtf",
|
|
1400
1385
|
size: content.length,
|
|
1401
1386
|
buffer: content,
|
|
1402
|
-
});
|
|
1387
|
+
}), FileDetector.DEFAULT_DOCUMENT_TIMEOUT);
|
|
1403
1388
|
if (rtfResult.success && rtfResult.data) {
|
|
1404
1389
|
return {
|
|
1405
1390
|
type: "docx",
|
|
@@ -1412,19 +1397,29 @@ export class FileDetector {
|
|
|
1412
1397
|
}
|
|
1413
1398
|
else {
|
|
1414
1399
|
const { wordProcessor } = await import("../processors/document/WordProcessor.js");
|
|
1415
|
-
|
|
1400
|
+
// Bounded for the same reason as the odt branch above. This call now
|
|
1401
|
+
// also runs htmlToMarkdown's recursive HTML-to-markdown rendering
|
|
1402
|
+
// over mammoth's output, so it is real additional work inside what
|
|
1403
|
+
// was already an unbounded call.
|
|
1404
|
+
const docxResult = await withTimeout(wordProcessor.processFile({
|
|
1416
1405
|
id: docxFilename,
|
|
1417
1406
|
name: docxFilename,
|
|
1418
1407
|
mimetype: detection.mimeType ||
|
|
1419
1408
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
1420
1409
|
size: content.length,
|
|
1421
1410
|
buffer: content,
|
|
1422
|
-
});
|
|
1411
|
+
}), FileDetector.DEFAULT_DOCUMENT_TIMEOUT);
|
|
1423
1412
|
if (docxResult.success && docxResult.data) {
|
|
1413
|
+
// Prefer the markdown rendering: it keeps the headings, lists and
|
|
1414
|
+
// tables that the plain-text extraction flattens away. Plain text
|
|
1415
|
+
// remains the fallback for a document with no convertible structure.
|
|
1416
|
+
const { markdownContent, textContent, wordCount, paragraphCount, characterCount, } = docxResult.data;
|
|
1417
|
+
const body = markdownContent || textContent;
|
|
1424
1418
|
return {
|
|
1425
1419
|
type: "docx",
|
|
1426
|
-
content:
|
|
1427
|
-
|
|
1420
|
+
content: body
|
|
1421
|
+
? `Document: ${wordCount} words, ${paragraphCount} paragraphs, ${characterCount} characters\n\n${body}`
|
|
1422
|
+
: FileDetector.formatInformativePlaceholder("Document", docxFilename, content, detection),
|
|
1428
1423
|
mimeType: detection.mimeType,
|
|
1429
1424
|
metadata: detection.metadata,
|
|
1430
1425
|
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML to Markdown conversion for document processors.
|
|
3
|
+
*
|
|
4
|
+
* Scoped deliberately to the small, well-formed HTML subset that document
|
|
5
|
+
* converters emit (mammoth for DOCX): headings, paragraphs, emphasis, links,
|
|
6
|
+
* images, lists, block quotes, preformatted text and tables. It is not a
|
|
7
|
+
* general-purpose web-page converter, which is why the repository gains no
|
|
8
|
+
* dependency for it.
|
|
9
|
+
*
|
|
10
|
+
* @module utils/htmlToMarkdown
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Convert an HTML fragment to Markdown.
|
|
14
|
+
*
|
|
15
|
+
* @param html - HTML produced by a document converter
|
|
16
|
+
* @returns Markdown text, or an empty string when the fragment has no content
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```typescript
|
|
20
|
+
* htmlToMarkdown("<h1>Title</h1><p>Body</p>"); // "# Title\n\nBody"
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export declare function htmlToMarkdown(html: string): string;
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML to Markdown conversion for document processors.
|
|
3
|
+
*
|
|
4
|
+
* Scoped deliberately to the small, well-formed HTML subset that document
|
|
5
|
+
* converters emit (mammoth for DOCX): headings, paragraphs, emphasis, links,
|
|
6
|
+
* images, lists, block quotes, preformatted text and tables. It is not a
|
|
7
|
+
* general-purpose web-page converter, which is why the repository gains no
|
|
8
|
+
* dependency for it.
|
|
9
|
+
*
|
|
10
|
+
* @module utils/htmlToMarkdown
|
|
11
|
+
*/
|
|
12
|
+
/** Tags that never have a closing tag and so never open a scope. */
|
|
13
|
+
const VOID_TAGS = new Set([
|
|
14
|
+
"area",
|
|
15
|
+
"base",
|
|
16
|
+
"br",
|
|
17
|
+
"col",
|
|
18
|
+
"embed",
|
|
19
|
+
"hr",
|
|
20
|
+
"img",
|
|
21
|
+
"input",
|
|
22
|
+
"link",
|
|
23
|
+
"meta",
|
|
24
|
+
"source",
|
|
25
|
+
"track",
|
|
26
|
+
"wbr",
|
|
27
|
+
]);
|
|
28
|
+
const NAMED_ENTITIES = {
|
|
29
|
+
amp: "&",
|
|
30
|
+
lt: "<",
|
|
31
|
+
gt: ">",
|
|
32
|
+
quot: '"',
|
|
33
|
+
apos: "'",
|
|
34
|
+
nbsp: " ",
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Decode the numeric and named character references a document converter can
|
|
38
|
+
* emit. Unknown references are left verbatim rather than dropped, so no text
|
|
39
|
+
* is silently lost.
|
|
40
|
+
*/
|
|
41
|
+
function decodeEntities(value) {
|
|
42
|
+
return value.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (match, body) => {
|
|
43
|
+
if (body.startsWith("#")) {
|
|
44
|
+
const codePoint = body.startsWith("#x") || body.startsWith("#X")
|
|
45
|
+
? Number.parseInt(body.slice(2), 16)
|
|
46
|
+
: Number.parseInt(body.slice(1), 10);
|
|
47
|
+
if (Number.isNaN(codePoint) || codePoint < 0 || codePoint > 0x10ffff) {
|
|
48
|
+
return match;
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
return String.fromCodePoint(codePoint);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return match;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const named = NAMED_ENTITIES[body.toLowerCase()];
|
|
58
|
+
return named ?? match;
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Parse the HTML subset into a node tree.
|
|
63
|
+
*
|
|
64
|
+
* Unbalanced closing tags are ignored and unclosed elements are closed at the
|
|
65
|
+
* end of input, so malformed markup degrades to partial structure instead of
|
|
66
|
+
* throwing.
|
|
67
|
+
*/
|
|
68
|
+
function parseHtml(html) {
|
|
69
|
+
const root = {
|
|
70
|
+
kind: "element",
|
|
71
|
+
tag: "#root",
|
|
72
|
+
attrs: {},
|
|
73
|
+
children: [],
|
|
74
|
+
};
|
|
75
|
+
const stack = [root];
|
|
76
|
+
const tagPattern = /<\/?([a-zA-Z][a-zA-Z0-9-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/g;
|
|
77
|
+
let cursor = 0;
|
|
78
|
+
let match;
|
|
79
|
+
const pushText = (raw) => {
|
|
80
|
+
if (!raw) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const current = stack[stack.length - 1];
|
|
84
|
+
current.children.push({ kind: "text", value: decodeEntities(raw) });
|
|
85
|
+
};
|
|
86
|
+
while ((match = tagPattern.exec(html)) !== null) {
|
|
87
|
+
pushText(html.slice(cursor, match.index));
|
|
88
|
+
cursor = match.index + match[0].length;
|
|
89
|
+
const tag = match[1].toLowerCase();
|
|
90
|
+
const isClosing = match[0].startsWith("</");
|
|
91
|
+
const isSelfClosing = match[2].trimEnd().endsWith("/");
|
|
92
|
+
if (isClosing) {
|
|
93
|
+
// Only unwind if the tag is actually open, so a stray `</div>` is
|
|
94
|
+
// inert. Search from the top of the stack down so a closing tag
|
|
95
|
+
// matches the innermost (most recently opened) element with that
|
|
96
|
+
// name — `findIndex` would match the OUTERMOST one instead, and for
|
|
97
|
+
// nested same-tag elements (`<div><div>A</div>B</div>`) that pops both
|
|
98
|
+
// levels at once, reattaching everything after the real inner close to
|
|
99
|
+
// the wrong ancestor.
|
|
100
|
+
let openIndex = -1;
|
|
101
|
+
for (let i = stack.length - 1; i >= 0; i--) {
|
|
102
|
+
if (stack[i].tag === tag) {
|
|
103
|
+
openIndex = i;
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (openIndex > 0) {
|
|
108
|
+
stack.length = openIndex;
|
|
109
|
+
}
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const element = {
|
|
113
|
+
kind: "element",
|
|
114
|
+
tag,
|
|
115
|
+
attrs: parseAttributes(match[2]),
|
|
116
|
+
children: [],
|
|
117
|
+
};
|
|
118
|
+
stack[stack.length - 1].children.push(element);
|
|
119
|
+
if (!isSelfClosing && !VOID_TAGS.has(tag)) {
|
|
120
|
+
stack.push(element);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
pushText(html.slice(cursor));
|
|
124
|
+
return root.children;
|
|
125
|
+
}
|
|
126
|
+
function parseAttributes(raw) {
|
|
127
|
+
const attrs = {};
|
|
128
|
+
const pattern = /([a-zA-Z_:][a-zA-Z0-9_.:-]*)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))/g;
|
|
129
|
+
let match;
|
|
130
|
+
while ((match = pattern.exec(raw)) !== null) {
|
|
131
|
+
const value = match[3] ?? match[4] ?? match[5] ?? "";
|
|
132
|
+
attrs[match[1].toLowerCase()] = decodeEntities(value);
|
|
133
|
+
}
|
|
134
|
+
return attrs;
|
|
135
|
+
}
|
|
136
|
+
function isElement(node) {
|
|
137
|
+
return node.kind === "element";
|
|
138
|
+
}
|
|
139
|
+
/** Collapse runs of whitespace the way an HTML renderer would. */
|
|
140
|
+
function collapseWhitespace(value) {
|
|
141
|
+
return value
|
|
142
|
+
.replace(/\s+/g, " ")
|
|
143
|
+
.replace(/&/g, "&")
|
|
144
|
+
.replace(/</g, "<")
|
|
145
|
+
.replace(/>/g, ">");
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Format a link/image destination per CommonMark's angle-bracket form.
|
|
149
|
+
*
|
|
150
|
+
* A bare `(dest)` destination ends at the first whitespace or unbalanced
|
|
151
|
+
* `)`, so a value containing either — an `href`/`src` with a space, or one
|
|
152
|
+
* with unbalanced parentheses — would silently truncate or corrupt the
|
|
153
|
+
* surrounding `[text](dest)` / `` markup. Wrapping in `<...>`
|
|
154
|
+
* makes the destination unambiguous; CommonMark requires any literal `<`
|
|
155
|
+
* or `>` inside that form to be escaped. Ordinary destinations (no
|
|
156
|
+
* whitespace or parens) are returned byte-identical to before.
|
|
157
|
+
*/
|
|
158
|
+
function formatLinkDestination(dest) {
|
|
159
|
+
if (!/[\s()]/.test(dest)) {
|
|
160
|
+
return dest;
|
|
161
|
+
}
|
|
162
|
+
// Backslash first: escaping only `<`/`>` turns `\>` into `\\>`, which
|
|
163
|
+
// CommonMark reads as a literal backslash followed by a bare `>`.
|
|
164
|
+
const escaped = dest
|
|
165
|
+
.replace(/\\/g, "\\\\")
|
|
166
|
+
.replace(/</g, "\\<")
|
|
167
|
+
.replace(/>/g, "\\>");
|
|
168
|
+
return `<${escaped}>`;
|
|
169
|
+
}
|
|
170
|
+
/** Render inline content (emphasis, links, images, line breaks) to markdown. */
|
|
171
|
+
function renderInline(nodes) {
|
|
172
|
+
let out = "";
|
|
173
|
+
for (const node of nodes) {
|
|
174
|
+
if (node.kind === "text") {
|
|
175
|
+
out += collapseWhitespace(node.value);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
switch (node.tag) {
|
|
179
|
+
case "script":
|
|
180
|
+
case "style":
|
|
181
|
+
break;
|
|
182
|
+
case "strong":
|
|
183
|
+
case "b": {
|
|
184
|
+
const inner = renderInline(node.children).trim();
|
|
185
|
+
out += inner ? `**${inner}**` : "";
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
case "em":
|
|
189
|
+
case "i": {
|
|
190
|
+
const inner = renderInline(node.children).trim();
|
|
191
|
+
out += inner ? `*${inner}*` : "";
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
case "code": {
|
|
195
|
+
const inner = renderInline(node.children).trim();
|
|
196
|
+
out += inner ? `\`${inner}\`` : "";
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
case "a": {
|
|
200
|
+
const inner = renderInline(node.children).trim();
|
|
201
|
+
const href = node.attrs.href;
|
|
202
|
+
out += href ? `[${inner}](${formatLinkDestination(href)})` : inner;
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
case "img": {
|
|
206
|
+
const alt = node.attrs.alt ?? "";
|
|
207
|
+
const src = node.attrs.src ?? "";
|
|
208
|
+
// A DOCX image is inlined as a multi-megabyte data URI; keep the
|
|
209
|
+
// alt text and drop the payload rather than flooding the prompt.
|
|
210
|
+
out += src.startsWith("data:")
|
|
211
|
+
? ``
|
|
212
|
+
: `})`;
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
case "br":
|
|
216
|
+
out += "\n";
|
|
217
|
+
break;
|
|
218
|
+
default:
|
|
219
|
+
out += renderInline(node.children);
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Extract a `<pre>` block's text content verbatim.
|
|
227
|
+
*
|
|
228
|
+
* `renderInline()` funnels every text node through `collapseWhitespace()`,
|
|
229
|
+
* which does `.replace(/\s+/g, " ")` — right for prose, destructive for
|
|
230
|
+
* code: it collapses every newline, tab and indentation run onto one line.
|
|
231
|
+
* This walks the same tree but keeps each text node's raw value, so a
|
|
232
|
+
* multi-line, indented code sample survives into the fenced block intact.
|
|
233
|
+
* A nested element (e.g. `<pre><code>…</code></pre>`) still contributes its
|
|
234
|
+
* text, but ignores inline markdown semantics like `**`/`*`/`` ` `` —
|
|
235
|
+
* `<pre>` content is literal code, not prose, and injecting markdown markers
|
|
236
|
+
* into it would corrupt the code rather than format it.
|
|
237
|
+
*/
|
|
238
|
+
function renderPreformattedText(nodes) {
|
|
239
|
+
let out = "";
|
|
240
|
+
for (const node of nodes) {
|
|
241
|
+
if (node.kind === "text") {
|
|
242
|
+
out += node.value;
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (node.tag === "script" || node.tag === "style") {
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (node.tag === "br") {
|
|
249
|
+
out += "\n";
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
out += renderPreformattedText(node.children);
|
|
253
|
+
}
|
|
254
|
+
return out;
|
|
255
|
+
}
|
|
256
|
+
/** Collect the `<tr>` rows under a table, seeing through thead/tbody/tfoot. */
|
|
257
|
+
function collectRows(node) {
|
|
258
|
+
const rows = [];
|
|
259
|
+
for (const child of node.children.filter(isElement)) {
|
|
260
|
+
if (child.tag === "tr") {
|
|
261
|
+
rows.push(child);
|
|
262
|
+
}
|
|
263
|
+
else if (["thead", "tbody", "tfoot"].includes(child.tag)) {
|
|
264
|
+
rows.push(...collectRows(child));
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return rows;
|
|
268
|
+
}
|
|
269
|
+
/** A pipe inside a cell would otherwise split it into two columns. */
|
|
270
|
+
function escapeCell(value) {
|
|
271
|
+
return value
|
|
272
|
+
.replace(/\\/g, "\\\\")
|
|
273
|
+
.replace(/\|/g, "\\|")
|
|
274
|
+
.replace(/\n/g, " ")
|
|
275
|
+
.trim();
|
|
276
|
+
}
|
|
277
|
+
function renderTable(node) {
|
|
278
|
+
const rows = collectRows(node);
|
|
279
|
+
if (rows.length === 0) {
|
|
280
|
+
return "";
|
|
281
|
+
}
|
|
282
|
+
const cellsOf = (row) => row.children
|
|
283
|
+
.filter(isElement)
|
|
284
|
+
.filter((cell) => cell.tag === "td" || cell.tag === "th")
|
|
285
|
+
.map((cell) => escapeCell(renderBlocks(cell.children).replace(/\n+/g, " ")));
|
|
286
|
+
// Markdown has no headerless table, so when the source marked no header row
|
|
287
|
+
// the first row is promoted — that is what a reader sees anyway.
|
|
288
|
+
const header = cellsOf(rows[0]);
|
|
289
|
+
const bodyRows = rows.slice(1).map(cellsOf);
|
|
290
|
+
const columnCount = Math.max(header.length, ...bodyRows.map((r) => r.length), 1);
|
|
291
|
+
const pad = (cells) => Array.from({ length: columnCount }, (_, i) => cells[i] ?? "");
|
|
292
|
+
const lines = [
|
|
293
|
+
`| ${pad(header).join(" | ")} |`,
|
|
294
|
+
`| ${Array.from({ length: columnCount }, () => "---").join(" | ")} |`,
|
|
295
|
+
...bodyRows.map((cells) => `| ${pad(cells).join(" | ")} |`),
|
|
296
|
+
];
|
|
297
|
+
return lines.join("\n");
|
|
298
|
+
}
|
|
299
|
+
function renderList(node, depth) {
|
|
300
|
+
const ordered = node.tag === "ol";
|
|
301
|
+
const items = node.children
|
|
302
|
+
.filter(isElement)
|
|
303
|
+
.filter((child) => child.tag === "li");
|
|
304
|
+
const indent = " ".repeat(depth);
|
|
305
|
+
return items
|
|
306
|
+
.map((item, index) => {
|
|
307
|
+
const marker = ordered ? `${index + 1}.` : "-";
|
|
308
|
+
// Nested lists render themselves at the next depth; everything else on
|
|
309
|
+
// the item is inline content belonging to this bullet.
|
|
310
|
+
const nested = item.children
|
|
311
|
+
.filter(isElement)
|
|
312
|
+
.filter((child) => child.tag === "ul" || child.tag === "ol");
|
|
313
|
+
const own = item.children.filter((child) => !(isElement(child) && (child.tag === "ul" || child.tag === "ol")));
|
|
314
|
+
const label = renderBlocks(own).replace(/\n+/g, " ").trim();
|
|
315
|
+
const nestedText = nested
|
|
316
|
+
.map((child) => renderList(child, depth + 1))
|
|
317
|
+
.filter(Boolean)
|
|
318
|
+
.join("\n");
|
|
319
|
+
return [`${indent}${marker} ${label}`.trimEnd(), nestedText]
|
|
320
|
+
.filter(Boolean)
|
|
321
|
+
.join("\n");
|
|
322
|
+
})
|
|
323
|
+
.join("\n");
|
|
324
|
+
}
|
|
325
|
+
/** Render block-level content, separating blocks with a blank line. */
|
|
326
|
+
function renderBlocks(nodes) {
|
|
327
|
+
const blocks = [];
|
|
328
|
+
let inlineBuffer = "";
|
|
329
|
+
const flushInline = () => {
|
|
330
|
+
const text = inlineBuffer.trim();
|
|
331
|
+
if (text) {
|
|
332
|
+
blocks.push(text);
|
|
333
|
+
}
|
|
334
|
+
inlineBuffer = "";
|
|
335
|
+
};
|
|
336
|
+
for (const node of nodes) {
|
|
337
|
+
if (node.kind === "text") {
|
|
338
|
+
inlineBuffer += collapseWhitespace(node.value);
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
const headingMatch = /^h([1-6])$/.exec(node.tag);
|
|
342
|
+
if (headingMatch) {
|
|
343
|
+
flushInline();
|
|
344
|
+
const level = Number.parseInt(headingMatch[1], 10);
|
|
345
|
+
const text = renderInline(node.children).replace(/\n+/g, " ").trim();
|
|
346
|
+
if (text) {
|
|
347
|
+
blocks.push(`${"#".repeat(level)} ${text}`);
|
|
348
|
+
}
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
switch (node.tag) {
|
|
352
|
+
case "script":
|
|
353
|
+
case "style":
|
|
354
|
+
flushInline();
|
|
355
|
+
break;
|
|
356
|
+
case "p": {
|
|
357
|
+
flushInline();
|
|
358
|
+
const text = renderBlocks(node.children).trim();
|
|
359
|
+
if (text) {
|
|
360
|
+
blocks.push(text);
|
|
361
|
+
}
|
|
362
|
+
break;
|
|
363
|
+
}
|
|
364
|
+
case "ul":
|
|
365
|
+
case "ol": {
|
|
366
|
+
flushInline();
|
|
367
|
+
const text = renderList(node, 0);
|
|
368
|
+
if (text) {
|
|
369
|
+
blocks.push(text);
|
|
370
|
+
}
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
case "table": {
|
|
374
|
+
flushInline();
|
|
375
|
+
const text = renderTable(node);
|
|
376
|
+
if (text) {
|
|
377
|
+
blocks.push(text);
|
|
378
|
+
}
|
|
379
|
+
break;
|
|
380
|
+
}
|
|
381
|
+
case "blockquote": {
|
|
382
|
+
flushInline();
|
|
383
|
+
const inner = renderBlocks(node.children).trim();
|
|
384
|
+
if (inner) {
|
|
385
|
+
blocks.push(inner
|
|
386
|
+
.split("\n")
|
|
387
|
+
.map((line) => (line ? `> ${line}` : ">"))
|
|
388
|
+
.join("\n"));
|
|
389
|
+
}
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
case "pre": {
|
|
393
|
+
flushInline();
|
|
394
|
+
const inner = renderPreformattedText(node.children).replace(/^\n+|\n+$/g, "");
|
|
395
|
+
if (inner) {
|
|
396
|
+
blocks.push(`\`\`\`\n${inner}\n\`\`\``);
|
|
397
|
+
}
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
400
|
+
case "hr": {
|
|
401
|
+
flushInline();
|
|
402
|
+
blocks.push("---");
|
|
403
|
+
break;
|
|
404
|
+
}
|
|
405
|
+
case "div":
|
|
406
|
+
case "section":
|
|
407
|
+
case "article": {
|
|
408
|
+
flushInline();
|
|
409
|
+
const inner = renderBlocks(node.children).trim();
|
|
410
|
+
if (inner) {
|
|
411
|
+
blocks.push(inner);
|
|
412
|
+
}
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
415
|
+
default:
|
|
416
|
+
inlineBuffer += renderInline([node]);
|
|
417
|
+
break;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
flushInline();
|
|
421
|
+
return blocks.join("\n\n");
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Convert an HTML fragment to Markdown.
|
|
425
|
+
*
|
|
426
|
+
* @param html - HTML produced by a document converter
|
|
427
|
+
* @returns Markdown text, or an empty string when the fragment has no content
|
|
428
|
+
*
|
|
429
|
+
* @example
|
|
430
|
+
* ```typescript
|
|
431
|
+
* htmlToMarkdown("<h1>Title</h1><p>Body</p>"); // "# Title\n\nBody"
|
|
432
|
+
* ```
|
|
433
|
+
*/
|
|
434
|
+
export function htmlToMarkdown(html) {
|
|
435
|
+
if (!html || !html.trim()) {
|
|
436
|
+
return "";
|
|
437
|
+
}
|
|
438
|
+
return renderBlocks(parseHtml(html))
|
|
439
|
+
.replace(/[ \t]+$/gm, "")
|
|
440
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
441
|
+
.trim();
|
|
442
|
+
}
|
|
@@ -895,6 +895,9 @@ async function detectFileForUnifiedArray(file, options, provider, genericFileMax
|
|
|
895
895
|
"unknown",
|
|
896
896
|
],
|
|
897
897
|
csvOptions: options.csvOptions,
|
|
898
|
+
// Office options travel the same unified `files` path, so this is where a
|
|
899
|
+
// caller's sheet selection / format choice reaches the XLSX processor.
|
|
900
|
+
officeOptions: options.officeOptions,
|
|
898
901
|
// #478: videos arrive through this unified `files` path, so this is
|
|
899
902
|
// where the CLI's frame/quality/format request has to be handed on.
|
|
900
903
|
videoOptions: options.videoOptions
|
|
@@ -16,6 +16,7 @@ import type { StreamOptions } from "../types/index.js";
|
|
|
16
16
|
* - input.videoFiles: Video files (Buffer | string paths)
|
|
17
17
|
* - csvOptions: CSV parsing options
|
|
18
18
|
* - videoOptions: Video frame/quality/format/transcription options
|
|
19
|
+
* - officeOptions: DOCX/XLSX sheet selection and output-format options
|
|
19
20
|
* - systemPrompt: System-level instructions
|
|
20
21
|
* - conversationMessages: Chat history
|
|
21
22
|
* - temperature: Model temperature (0-1)
|
|
@@ -28,6 +29,7 @@ import type { StreamOptions } from "../types/index.js";
|
|
|
28
29
|
* @returns {object} Normalized options object with:
|
|
29
30
|
* - input: { text, images, content, files, csvFiles, pdfFiles, audioFiles, videoFiles }
|
|
30
31
|
* - csvOptions: CSV processing options
|
|
32
|
+
* - officeOptions: Office (DOCX/XLSX) processing options
|
|
31
33
|
* - systemPrompt: System prompt string
|
|
32
34
|
* - conversationHistory: Message history array
|
|
33
35
|
* - provider: Provider name
|
|
@@ -66,6 +68,7 @@ export declare function buildMultimodalOptions(options: StreamOptions, providerN
|
|
|
66
68
|
outputFormat?: import("../index.js").VisionImageOutputFormat;
|
|
67
69
|
} | undefined;
|
|
68
70
|
videoOptions: import("../index.js").VideoProcessorOptions | undefined;
|
|
71
|
+
officeOptions: import("../index.js").OfficeProcessorOptions | undefined;
|
|
69
72
|
systemPrompt: string | undefined;
|
|
70
73
|
conversationHistory: import("../index.js").ChatMessage[] | undefined;
|
|
71
74
|
provider: string;
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* - input.videoFiles: Video files (Buffer | string paths)
|
|
16
16
|
* - csvOptions: CSV parsing options
|
|
17
17
|
* - videoOptions: Video frame/quality/format/transcription options
|
|
18
|
+
* - officeOptions: DOCX/XLSX sheet selection and output-format options
|
|
18
19
|
* - systemPrompt: System-level instructions
|
|
19
20
|
* - conversationMessages: Chat history
|
|
20
21
|
* - temperature: Model temperature (0-1)
|
|
@@ -27,6 +28,7 @@
|
|
|
27
28
|
* @returns {object} Normalized options object with:
|
|
28
29
|
* - input: { text, images, content, files, csvFiles, pdfFiles, audioFiles, videoFiles }
|
|
29
30
|
* - csvOptions: CSV processing options
|
|
31
|
+
* - officeOptions: Office (DOCX/XLSX) processing options
|
|
30
32
|
* - systemPrompt: System prompt string
|
|
31
33
|
* - conversationHistory: Message history array
|
|
32
34
|
* - provider: Provider name
|
|
@@ -67,6 +69,10 @@ export function buildMultimodalOptions(options, providerName, modelName) {
|
|
|
67
69
|
// `StreamOptions` never reach VideoProcessor on the Bedrock branch, and
|
|
68
70
|
// it silently falls back to the duration-tier default.
|
|
69
71
|
videoOptions: options.videoOptions,
|
|
72
|
+
// Same #1259 whitelist-drop bug class as audioFiles/videoFiles above:
|
|
73
|
+
// omitted here, Bedrock's multimodal generate()/stream() path always got
|
|
74
|
+
// sheetName/formatStyle undefined regardless of what the caller passed.
|
|
75
|
+
officeOptions: options.officeOptions,
|
|
70
76
|
systemPrompt: options.systemPrompt,
|
|
71
77
|
conversationHistory: options.conversationMessages,
|
|
72
78
|
provider: providerName,
|