@formio/uag 2.0.0-rc.6 → 2.0.0-rc.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -6
- package/bin/uag.js +48 -5
- package/lib/UAGFormInterface.d.ts +12 -0
- package/lib/UAGFormInterface.js +30 -3
- package/lib/UAGProjectInterface.d.ts +9 -1
- package/lib/UAGProjectInterface.js +14 -3
- package/lib/agent/anonymous.js +1 -1
- package/lib/agent/engines.d.ts +10 -0
- package/lib/agent/engines.js +15 -1
- package/lib/agent/errors.d.ts +9 -0
- package/lib/agent/errors.js +20 -0
- package/lib/agent/index.d.ts +1 -1
- package/lib/agent/index.js +2 -1
- package/lib/agent/remote.d.ts +5 -1
- package/lib/agent/remote.js +25 -2
- package/lib/agent/router.d.ts +32 -2
- package/lib/agent/router.js +17 -11
- package/lib/agent/stubs.d.ts +13 -2
- package/lib/agent/stubs.js +18 -5
- package/lib/agent/tools.d.ts +24 -3
- package/lib/agent/tools.js +47 -4
- package/lib/config.d.ts +31 -2
- package/lib/documents/convert.d.ts +38 -0
- package/lib/documents/convert.js +151 -0
- package/lib/documents/converters.d.ts +28 -0
- package/lib/documents/converters.js +149 -0
- package/lib/documents/detect.d.ts +31 -0
- package/lib/documents/detect.js +169 -0
- package/lib/documents/index.d.ts +9 -0
- package/lib/documents/index.js +43 -0
- package/lib/documents/mime.d.ts +39 -0
- package/lib/documents/mime.js +100 -0
- package/lib/documents/plugins.d.ts +18 -0
- package/lib/documents/plugins.js +65 -0
- package/lib/documents/readers.d.ts +19 -0
- package/lib/documents/readers.js +64 -0
- package/lib/documents/routes.d.ts +52 -0
- package/lib/documents/routes.js +99 -0
- package/lib/documents/types.d.ts +126 -0
- package/lib/documents/types.js +2 -0
- package/lib/documents/uagJs.d.ts +17 -0
- package/lib/documents/uagJs.js +157 -0
- package/lib/formflow.d.ts +4 -1
- package/lib/formflow.js +2 -2
- package/lib/index.d.ts +50 -6
- package/lib/index.js +151 -15
- package/lib/markdown.d.ts +1 -0
- package/lib/markdown.js +166 -0
- package/lib/modules/api.d.ts +22 -0
- package/lib/modules/api.js +100 -0
- package/lib/modules/index.d.ts +4 -0
- package/lib/modules/index.js +24 -0
- package/lib/modules/routes.d.ts +14 -0
- package/lib/modules/routes.js +21 -0
- package/lib/modules/types.d.ts +68 -0
- package/lib/modules/types.js +5 -0
- package/lib/pluginFiles.d.ts +28 -0
- package/lib/pluginFiles.js +101 -0
- package/lib/plugins.d.ts +17 -0
- package/lib/plugins.js +93 -0
- package/lib/richText.d.ts +20 -0
- package/lib/richText.js +99 -0
- package/lib/router.d.ts +9 -0
- package/lib/router.js +19 -0
- package/lib/testing.d.ts +74 -0
- package/lib/testing.js +175 -0
- package/lib/tools/index.d.ts +2 -1
- package/lib/tools/index.js +5 -2
- package/lib/tools/readDocument.d.ts +17 -0
- package/lib/tools/readDocument.js +99 -0
- package/lib/tools/submissionUpdate.js +3 -2
- package/lib/tools/utils.d.ts +40 -0
- package/lib/tools/utils.js +16 -1
- package/package.json +10 -4
- package/testing.d.ts +1 -0
- package/testing.js +2 -0
package/lib/agent/stubs.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { GenerateRequest } from '@formio/uag-sdk';
|
|
1
2
|
/**
|
|
2
3
|
* Stand-in engines for tests and local development. They answer instantly with
|
|
3
4
|
* predictable values, so the Agent API can be exercised end to end without
|
|
@@ -6,7 +7,9 @@
|
|
|
6
7
|
* any instance name.
|
|
7
8
|
*/
|
|
8
9
|
export declare const StubDecisionEngine: Readonly<{
|
|
9
|
-
type:
|
|
10
|
+
type: "engine";
|
|
11
|
+
name: string;
|
|
12
|
+
title: string;
|
|
10
13
|
kinds: "decision"[];
|
|
11
14
|
apiVersion: 1;
|
|
12
15
|
description: string;
|
|
@@ -14,8 +17,16 @@ export declare const StubDecisionEngine: Readonly<{
|
|
|
14
17
|
calls: number;
|
|
15
18
|
};
|
|
16
19
|
}>;
|
|
20
|
+
/** How many generate requests the stub agent engines keep: a UAG running them for a demo never grows without bound. */
|
|
21
|
+
export declare const STUB_GENERATE_REQUESTS_KEPT = 20;
|
|
22
|
+
/** The last generate requests the stub agent engines received (STUB_GENERATE_REQUESTS_KEPT), oldest first, for tests. */
|
|
23
|
+
export declare const stubGenerateRequests: Array<Omit<GenerateRequest, 'signal' | 'onProgress'> & {
|
|
24
|
+
engine: string;
|
|
25
|
+
}>;
|
|
17
26
|
export declare const StubAgentEngine: Readonly<{
|
|
18
|
-
type:
|
|
27
|
+
type: "engine";
|
|
28
|
+
name: string;
|
|
29
|
+
title: string;
|
|
19
30
|
kinds: "agent"[];
|
|
20
31
|
apiVersion: 1;
|
|
21
32
|
description: string;
|
package/lib/agent/stubs.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.StubAgentEngine = exports.StubDecisionEngine = void 0;
|
|
3
|
+
exports.StubAgentEngine = exports.stubGenerateRequests = exports.STUB_GENERATE_REQUESTS_KEPT = exports.StubDecisionEngine = void 0;
|
|
4
4
|
const uag_sdk_1 = require("@formio/uag-sdk");
|
|
5
5
|
const formflow_1 = require("../formflow");
|
|
6
6
|
/**
|
|
@@ -11,16 +11,29 @@ const formflow_1 = require("../formflow");
|
|
|
11
11
|
* any instance name.
|
|
12
12
|
*/
|
|
13
13
|
exports.StubDecisionEngine = (0, uag_sdk_1.defineEngine)({
|
|
14
|
-
type: '
|
|
14
|
+
type: 'engine',
|
|
15
|
+
name: 'stub-decision',
|
|
16
|
+
title: 'Stub decision engine',
|
|
15
17
|
kinds: ['decision'],
|
|
16
18
|
apiVersion: uag_sdk_1.ENGINE_API_VERSION,
|
|
17
19
|
description: 'Answers every decision with a fixed, predictable value. For tests and local development.',
|
|
18
20
|
create: ({ name }) => (0, formflow_1.stubDecisionEngine)({ name }),
|
|
19
21
|
});
|
|
22
|
+
/** How many generate requests the stub agent engines keep: a UAG running them for a demo never grows without bound. */
|
|
23
|
+
exports.STUB_GENERATE_REQUESTS_KEPT = 20;
|
|
24
|
+
/** The last generate requests the stub agent engines received (STUB_GENERATE_REQUESTS_KEPT), oldest first, for tests. */
|
|
25
|
+
exports.stubGenerateRequests = [];
|
|
20
26
|
exports.StubAgentEngine = (0, uag_sdk_1.defineEngine)({
|
|
21
|
-
type: '
|
|
27
|
+
type: 'engine',
|
|
28
|
+
name: 'stub-agent',
|
|
29
|
+
title: 'Stub agent engine',
|
|
22
30
|
kinds: ['agent'],
|
|
23
31
|
apiVersion: uag_sdk_1.ENGINE_API_VERSION,
|
|
24
|
-
description: 'Extracts nothing. For tests and local development.',
|
|
25
|
-
|
|
32
|
+
description: 'Extracts nothing; generates a predictable answer from the text it is given. For tests and local development.',
|
|
33
|
+
// Text only; its generate answer shows what reached it. Observing its requests keeps it a built-in stub.
|
|
34
|
+
create: ({ name }) => (0, formflow_1.stubAgentEngine)({ name, onGenerate: (request) => {
|
|
35
|
+
exports.stubGenerateRequests.push({ ...request, engine: name });
|
|
36
|
+
if (exports.stubGenerateRequests.length > exports.STUB_GENERATE_REQUESTS_KEPT)
|
|
37
|
+
exports.stubGenerateRequests.splice(0, exports.stubGenerateRequests.length - exports.STUB_GENERATE_REQUESTS_KEPT);
|
|
38
|
+
} }),
|
|
26
39
|
});
|
package/lib/agent/tools.d.ts
CHANGED
|
@@ -1,10 +1,31 @@
|
|
|
1
1
|
import { FormflowTool } from "../formflow";
|
|
2
|
+
import type { AuthRequest } from "@formio/appserver";
|
|
2
3
|
import type { UAGProjectInterface } from "../UAGProjectInterface";
|
|
3
|
-
import { ToolInfo } from "../tools/utils";
|
|
4
|
-
/**
|
|
4
|
+
import { ToolEntry, ToolInfo } from "../tools/utils";
|
|
5
|
+
/** Record the module a tool came from: its `execute` gets the module's API as `extra.module`. */
|
|
6
|
+
export declare function setToolModule(entry: object, api: any): void;
|
|
7
|
+
/**
|
|
8
|
+
* A `config.tools` entry as a tool for the project: a bound tool's static
|
|
9
|
+
* fields, overlaid with what its `bind(project)` returns. A tool without
|
|
10
|
+
* `bind` (the 1.x shape) is used as it is.
|
|
11
|
+
*/
|
|
12
|
+
export declare function resolveTool(project: UAGProjectInterface, entry: ToolEntry): Promise<ToolInfo | undefined>;
|
|
13
|
+
/**
|
|
14
|
+
* Every tool the UAG serves, including custom tools from configuration, each
|
|
15
|
+
* entry of which is a tool, optionally bound to the project.
|
|
16
|
+
*/
|
|
5
17
|
export declare function allTools(project: UAGProjectInterface): Promise<ToolInfo[]>;
|
|
18
|
+
/**
|
|
19
|
+
* Run a tool with its arguments and the `ToolExtra` for the project: the same
|
|
20
|
+
* call on `/mcp` and for Agent API research. `context` is whatever else the
|
|
21
|
+
* caller has (on `/mcp`, the MCP SDK's request context, with `authInfo`).
|
|
22
|
+
*/
|
|
23
|
+
export declare function runTool(tool: ToolInfo, project: UAGProjectInterface, args: any, context: {
|
|
24
|
+
authInfo: AuthRequest;
|
|
25
|
+
[key: string]: any;
|
|
26
|
+
}): any;
|
|
6
27
|
/**
|
|
7
28
|
* An MCP tool as an Agentic Formflow tool: its schema as JSON, its arguments
|
|
8
29
|
* validated with its own schema, and read-only only when annotated so.
|
|
9
30
|
*/
|
|
10
|
-
export declare function formflowTool(tool: ToolInfo): FormflowTool;
|
|
31
|
+
export declare function formflowTool(tool: ToolInfo, project: UAGProjectInterface): FormflowTool;
|
package/lib/agent/tools.js
CHANGED
|
@@ -3,19 +3,62 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.setToolModule = setToolModule;
|
|
7
|
+
exports.resolveTool = resolveTool;
|
|
6
8
|
exports.allTools = allTools;
|
|
9
|
+
exports.runTool = runTool;
|
|
7
10
|
exports.formflowTool = formflowTool;
|
|
8
11
|
const zod_1 = __importDefault(require("zod"));
|
|
9
12
|
const tools_1 = require("../tools");
|
|
10
|
-
|
|
13
|
+
const utils_1 = require("../tools/utils");
|
|
14
|
+
/** The module API of the module each `config.tools` entry came from, for its `extra.module`. */
|
|
15
|
+
const toolModules = new WeakMap();
|
|
16
|
+
/** Record the module a tool came from: its `execute` gets the module's API as `extra.module`. */
|
|
17
|
+
function setToolModule(entry, api) {
|
|
18
|
+
if (entry && typeof entry === 'object')
|
|
19
|
+
toolModules.set(entry, api);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* A `config.tools` entry as a tool for the project: a bound tool's static
|
|
23
|
+
* fields, overlaid with what its `bind(project)` returns. A tool without
|
|
24
|
+
* `bind` (the 1.x shape) is used as it is.
|
|
25
|
+
*/
|
|
26
|
+
async function resolveTool(project, entry) {
|
|
27
|
+
if (!entry || typeof entry.bind !== 'function')
|
|
28
|
+
return entry;
|
|
29
|
+
const { bind, ...fixed } = entry;
|
|
30
|
+
const tool = { ...fixed, ...((await bind(project)) || {}) };
|
|
31
|
+
if (toolModules.has(entry))
|
|
32
|
+
toolModules.set(tool, toolModules.get(entry));
|
|
33
|
+
return tool;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Every tool the UAG serves, including custom tools from configuration, each
|
|
37
|
+
* entry of which is a tool, optionally bound to the project.
|
|
38
|
+
*/
|
|
11
39
|
async function allTools(project) {
|
|
12
|
-
|
|
40
|
+
(0, utils_1.assertToolEntries)(project.config?.tools);
|
|
41
|
+
const custom = await Promise.all((project.config?.tools || []).map((entry) => resolveTool(project, entry)));
|
|
42
|
+
return [...await (0, tools_1.getTools)(project), ...custom].filter((tool) => !!tool?.name);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Run a tool with its arguments and the `ToolExtra` for the project: the same
|
|
46
|
+
* call on `/mcp` and for Agent API research. `context` is whatever else the
|
|
47
|
+
* caller has (on `/mcp`, the MCP SDK's request context, with `authInfo`).
|
|
48
|
+
*/
|
|
49
|
+
function runTool(tool, project, args, context) {
|
|
50
|
+
if (!tool.execute) {
|
|
51
|
+
return { content: [{ type: 'text', text: `The tool "${tool.name}" has no implementation.` }], isError: true };
|
|
52
|
+
}
|
|
53
|
+
const module = toolModules.get(tool);
|
|
54
|
+
const extra = { ...context, authInfo: context.authInfo, project, ...(module && { module }) };
|
|
55
|
+
return tool.execute(args ?? {}, extra);
|
|
13
56
|
}
|
|
14
57
|
/**
|
|
15
58
|
* An MCP tool as an Agentic Formflow tool: its schema as JSON, its arguments
|
|
16
59
|
* validated with its own schema, and read-only only when annotated so.
|
|
17
60
|
*/
|
|
18
|
-
function formflowTool(tool) {
|
|
61
|
+
function formflowTool(tool, project) {
|
|
19
62
|
const schema = zod_1.default.object(tool.inputSchema || {});
|
|
20
63
|
const inputSchema = zod_1.default.toJSONSchema(schema, { io: 'input', unrepresentable: 'any' });
|
|
21
64
|
delete inputSchema.$schema;
|
|
@@ -28,6 +71,6 @@ function formflowTool(tool) {
|
|
|
28
71
|
const parsed = schema.safeParse(args || {});
|
|
29
72
|
return parsed.success ? { ok: true, value: parsed.data } : { ok: false, error: parsed.error.message };
|
|
30
73
|
},
|
|
31
|
-
execute: async (args, auth) => tool
|
|
74
|
+
execute: async (args, auth) => runTool(tool, project, args, { authInfo: auth })
|
|
32
75
|
};
|
|
33
76
|
}
|
package/lib/config.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ServerConfig } from '@formio/appserver';
|
|
2
2
|
import type { FormflowAgentApiConfig, FormflowRateLimit, FormflowRoutingConfig } from './formflow';
|
|
3
3
|
import type { EngineInstanceSpec, EngineTypeDefinition } from '@formio/uag-sdk';
|
|
4
|
-
import { ToolInfo } from './tools/utils';
|
|
4
|
+
import type { ToolEntry, ToolInfo } from './tools/utils';
|
|
5
5
|
import type { UAGDatabaseOptions } from './database';
|
|
6
6
|
export type UAGForm = {
|
|
7
7
|
name: string;
|
|
@@ -49,6 +49,30 @@ export type UAGToolOverride = {
|
|
|
49
49
|
agent_provide?: ToolInfo;
|
|
50
50
|
agent_decide?: ToolInfo;
|
|
51
51
|
fetch_external_data?: ToolInfo;
|
|
52
|
+
read_document?: ToolInfo;
|
|
53
|
+
};
|
|
54
|
+
/** `config.documents`: limits and choices of the AI Document module (@formio/uag-extras/modules/ai-document). */
|
|
55
|
+
export type UAGDocumentsConfig = {
|
|
56
|
+
/** Largest uploaded file read, in bytes (25 MB). DOCUMENTS_MAX_FILE_BYTES. */
|
|
57
|
+
maxFileBytes?: number;
|
|
58
|
+
/** Most files one component sends (10). */
|
|
59
|
+
maxFiles?: number;
|
|
60
|
+
/** Most bytes of one request's files (32 MB, the provider request cap). */
|
|
61
|
+
maxTotalBytes?: number;
|
|
62
|
+
/** Most pages once converted (500). DOCUMENTS_MAX_PAGES. */
|
|
63
|
+
maxPages?: number;
|
|
64
|
+
/** Most characters of text once converted (2,000,000). */
|
|
65
|
+
maxTextChars?: number;
|
|
66
|
+
/** One processing request, in ms (600000). DOCUMENTS_TIMEOUT_MS. */
|
|
67
|
+
timeoutMs?: number;
|
|
68
|
+
/** Comment line interval on a streamed response, in ms (15000). */
|
|
69
|
+
heartbeatMs?: number;
|
|
70
|
+
/** Concurrent processing requests per identity (3). */
|
|
71
|
+
perUser?: number;
|
|
72
|
+
/** The engine instance documents use. Default: the first capable agent engine. DOCUMENTS_ENGINE. */
|
|
73
|
+
engine?: string;
|
|
74
|
+
/** Converter name by MIME type, when several can do a step. */
|
|
75
|
+
prefer?: Record<string, string>;
|
|
52
76
|
};
|
|
53
77
|
/**
|
|
54
78
|
* One engine instance: `"<type>"` or `"<name>:<type>"`, or `{ name, type, options }`, where the
|
|
@@ -85,6 +109,8 @@ export interface UAGChatConfig {
|
|
|
85
109
|
export type UAGAgentApiConfig = FormflowAgentApiConfig & {
|
|
86
110
|
anonymous?: UAGAnonymousConfig;
|
|
87
111
|
chat?: UAGChatConfig;
|
|
112
|
+
/** The largest request body an agent operation takes, in bytes (AGENT_BODY_LIMIT). Default 5 MB. */
|
|
113
|
+
bodyLimit?: number;
|
|
88
114
|
};
|
|
89
115
|
/**
|
|
90
116
|
* Express's `trust proxy` from TRUST_PROXY: `true` or `false`, a number of
|
|
@@ -98,8 +124,11 @@ export interface UAGConfig extends ServerConfig {
|
|
|
98
124
|
baseUrl?: string;
|
|
99
125
|
loginForm?: string;
|
|
100
126
|
responseTemplates?: UAGTemplateConfig;
|
|
127
|
+
/** Limits and choices of the AI Document module. */
|
|
128
|
+
documents?: UAGDocumentsConfig;
|
|
101
129
|
toolOverrides?: UAGToolOverride;
|
|
102
|
-
tools
|
|
130
|
+
/** Custom MCP tools: each a tool, optionally bound to the project with `bind(project)` (see ToolInfo.bind). */
|
|
131
|
+
tools?: ToolEntry[];
|
|
103
132
|
/**
|
|
104
133
|
* The engine instances to enable, as in `UAG_ENGINES` (`"jev"`, `"fast:claude"`) or as
|
|
105
134
|
* `{ name, type, options }`. When unset, `UAG_ENGINES` is used; when neither names any, no
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { PluginContext } from '@formio/appserver';
|
|
2
|
+
import type { Converter, ConverterOutput, Representation } from './types';
|
|
3
|
+
export declare const CONVERTER_DEFAULTS: {
|
|
4
|
+
maxBytes: number;
|
|
5
|
+
timeoutMs: number;
|
|
6
|
+
concurrency: number;
|
|
7
|
+
};
|
|
8
|
+
/** A conversion that did not complete. The message names the converter and the file, never its content. */
|
|
9
|
+
export declare class ConversionError extends Error {
|
|
10
|
+
constructor(message: string);
|
|
11
|
+
}
|
|
12
|
+
export type ConvertOptions = {
|
|
13
|
+
to: Representation;
|
|
14
|
+
settings?: Record<string, any>;
|
|
15
|
+
signal?: AbortSignal;
|
|
16
|
+
log: PluginContext['log'];
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Run one conversion: under the converter's size limit, in a temporary
|
|
20
|
+
* directory made for the call and removed after it, aborted at its timeout,
|
|
21
|
+
* and queued behind its other conversions beyond its concurrency.
|
|
22
|
+
*/
|
|
23
|
+
export declare function runConverter(converter: Converter, input: {
|
|
24
|
+
buffer: Buffer;
|
|
25
|
+
type: string;
|
|
26
|
+
name: string;
|
|
27
|
+
}, options: ConvertOptions): Promise<ConverterOutput>;
|
|
28
|
+
/**
|
|
29
|
+
* Run a route: each step's output feeds the next (an intermediate step always
|
|
30
|
+
* makes a PDF), with each converter's operator settings.
|
|
31
|
+
*/
|
|
32
|
+
export declare function runRoute(steps: Converter[], input: {
|
|
33
|
+
buffer: Buffer;
|
|
34
|
+
type: string;
|
|
35
|
+
name: string;
|
|
36
|
+
}, to: Representation, options: Omit<ConvertOptions, 'to' | 'settings'> & {
|
|
37
|
+
settingsOf?: (converter: Converter) => Record<string, any> | undefined;
|
|
38
|
+
}): Promise<ConverterOutput>;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.ConversionError = exports.CONVERTER_DEFAULTS = void 0;
|
|
7
|
+
exports.runConverter = runConverter;
|
|
8
|
+
exports.runRoute = runRoute;
|
|
9
|
+
const fs_1 = __importDefault(require("fs"));
|
|
10
|
+
const os_1 = __importDefault(require("os"));
|
|
11
|
+
const path_1 = __importDefault(require("path"));
|
|
12
|
+
exports.CONVERTER_DEFAULTS = { maxBytes: 25 * 1024 * 1024, timeoutMs: 120000, concurrency: 2 };
|
|
13
|
+
/** A conversion that did not complete. The message names the converter and the file, never its content. */
|
|
14
|
+
class ConversionError extends Error {
|
|
15
|
+
constructor(message) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = 'ConversionError';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
exports.ConversionError = ConversionError;
|
|
21
|
+
/**
|
|
22
|
+
* A queue per converter, so at most `concurrency` of its conversions run at
|
|
23
|
+
* once. A slot is held until its conversion has actually stopped: a converter
|
|
24
|
+
* that ignores its signal still counts after its caller has given up on it.
|
|
25
|
+
*/
|
|
26
|
+
class Queue {
|
|
27
|
+
constructor(limit) {
|
|
28
|
+
this.limit = limit;
|
|
29
|
+
this.running = 0;
|
|
30
|
+
this.waiting = [];
|
|
31
|
+
}
|
|
32
|
+
/** Wait for a slot; the returned function frees it (once). */
|
|
33
|
+
async acquire(signal) {
|
|
34
|
+
signal?.throwIfAborted();
|
|
35
|
+
if (this.running < this.limit) {
|
|
36
|
+
this.running++;
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
// A freed slot passes straight to the next waiter, so no new caller can take it in between.
|
|
40
|
+
await new Promise((resolve, reject) => {
|
|
41
|
+
const go = () => { signal?.removeEventListener('abort', cancel); resolve(); };
|
|
42
|
+
const cancel = () => { this.waiting = this.waiting.filter((entry) => entry !== go); reject(signal.reason ?? new Error('aborted')); };
|
|
43
|
+
this.waiting.push(go);
|
|
44
|
+
signal?.addEventListener('abort', cancel, { once: true });
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
let released = false;
|
|
48
|
+
return () => {
|
|
49
|
+
if (released)
|
|
50
|
+
return;
|
|
51
|
+
released = true;
|
|
52
|
+
const next = this.waiting.shift();
|
|
53
|
+
if (next)
|
|
54
|
+
next();
|
|
55
|
+
else
|
|
56
|
+
this.running--;
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const queues = new WeakMap();
|
|
61
|
+
/**
|
|
62
|
+
* Run one conversion: under the converter's size limit, in a temporary
|
|
63
|
+
* directory made for the call and removed after it, aborted at its timeout,
|
|
64
|
+
* and queued behind its other conversions beyond its concurrency.
|
|
65
|
+
*/
|
|
66
|
+
async function runConverter(converter, input, options) {
|
|
67
|
+
const limits = { ...exports.CONVERTER_DEFAULTS, ...(converter.limits || {}) };
|
|
68
|
+
if (input.buffer.length > limits.maxBytes) {
|
|
69
|
+
throw new ConversionError(`"${input.name}" is larger than the ${converter.name} converter takes (${limits.maxBytes} bytes).`);
|
|
70
|
+
}
|
|
71
|
+
let queue = queues.get(converter);
|
|
72
|
+
if (!queue) {
|
|
73
|
+
queue = new Queue(Math.max(1, limits.concurrency));
|
|
74
|
+
queues.set(converter, queue);
|
|
75
|
+
}
|
|
76
|
+
const release = await queue.acquire(options.signal);
|
|
77
|
+
let tmpdir;
|
|
78
|
+
try {
|
|
79
|
+
options.signal?.throwIfAborted();
|
|
80
|
+
tmpdir = await fs_1.default.promises.mkdtemp(path_1.default.join(os_1.default.tmpdir(), 'uag-convert-'));
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
release();
|
|
84
|
+
throw err;
|
|
85
|
+
}
|
|
86
|
+
const controller = new AbortController();
|
|
87
|
+
const abort = () => controller.abort(options.signal?.reason);
|
|
88
|
+
options.signal?.addEventListener('abort', abort, { once: true });
|
|
89
|
+
if (options.signal?.aborted)
|
|
90
|
+
abort();
|
|
91
|
+
const work = Promise.resolve().then(() => converter.convert(input, { to: options.to, settings: options.settings || {}, signal: controller.signal, tmpdir, log: options.log }));
|
|
92
|
+
// The slot and the temporary directory are kept until convert() settles, even after a timeout or an abort.
|
|
93
|
+
const stopped = work.then(() => undefined, () => undefined).then(async () => {
|
|
94
|
+
await fs_1.default.promises.rm(tmpdir, { recursive: true, force: true }).catch(() => undefined);
|
|
95
|
+
release();
|
|
96
|
+
});
|
|
97
|
+
let timer;
|
|
98
|
+
let finished = false;
|
|
99
|
+
try {
|
|
100
|
+
const timeout = new Promise((_, reject) => {
|
|
101
|
+
timer = setTimeout(() => {
|
|
102
|
+
const error = new ConversionError(`Converting "${input.name}" with ${converter.name} took longer than ${limits.timeoutMs} ms.`);
|
|
103
|
+
// Abort first: a converter running a process stops it on its signal.
|
|
104
|
+
controller.abort(error);
|
|
105
|
+
reject(error);
|
|
106
|
+
}, limits.timeoutMs);
|
|
107
|
+
});
|
|
108
|
+
const aborted = new Promise((_, reject) => {
|
|
109
|
+
if (controller.signal.aborted)
|
|
110
|
+
reject(controller.signal.reason);
|
|
111
|
+
controller.signal.addEventListener('abort', () => reject(controller.signal.reason), { once: true });
|
|
112
|
+
});
|
|
113
|
+
const output = await Promise.race([work.finally(() => { finished = true; }), timeout, aborted]);
|
|
114
|
+
return checkOutput(converter, input.name, options.to, output);
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
clearTimeout(timer);
|
|
118
|
+
options.signal?.removeEventListener('abort', abort);
|
|
119
|
+
// A conversion that completed has its directory removed before its result is used.
|
|
120
|
+
if (finished)
|
|
121
|
+
await stopped;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
function checkOutput(converter, name, to, output) {
|
|
125
|
+
if (to === 'pdf') {
|
|
126
|
+
if (!output || !Buffer.isBuffer(output.pdf))
|
|
127
|
+
throw new ConversionError(`The ${converter.name} converter did not produce a PDF for "${name}".`);
|
|
128
|
+
return output;
|
|
129
|
+
}
|
|
130
|
+
if (!output || typeof output.text !== 'string' || !['plain', 'markdown', 'csv'].includes(output.textFormat)) {
|
|
131
|
+
throw new ConversionError(`The ${converter.name} converter did not produce text for "${name}".`);
|
|
132
|
+
}
|
|
133
|
+
return output;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Run a route: each step's output feeds the next (an intermediate step always
|
|
137
|
+
* makes a PDF), with each converter's operator settings.
|
|
138
|
+
*/
|
|
139
|
+
async function runRoute(steps, input, to, options) {
|
|
140
|
+
let current = input;
|
|
141
|
+
let output;
|
|
142
|
+
for (let i = 0; i < steps.length; i++) {
|
|
143
|
+
const target = i === steps.length - 1 ? to : 'pdf';
|
|
144
|
+
output = await runConverter(steps[i], current, { ...options, to: target, settings: options.settingsOf?.(steps[i]) });
|
|
145
|
+
if (target === 'pdf')
|
|
146
|
+
current = { buffer: output.pdf, type: 'application/pdf', name: input.name };
|
|
147
|
+
}
|
|
148
|
+
if (!output)
|
|
149
|
+
throw new ConversionError(`No converter step for "${input.name}".`);
|
|
150
|
+
return output;
|
|
151
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Converter } from './types';
|
|
2
|
+
/** unpdf, loaded on first use: it is an ES module, and carries a PDF.js build. */
|
|
3
|
+
export declare function loadUnpdf(): Promise<typeof import('unpdf')>;
|
|
4
|
+
/** pdf-text: a PDF's text layer, page by page, with unpdf. */
|
|
5
|
+
export declare const pdfTextConverter: Converter;
|
|
6
|
+
/** Decode text: UTF-16 with a byte order mark, else UTF-8 (without its BOM), else Windows-1252 (a superset of Latin-1). */
|
|
7
|
+
export declare function decodeText(buffer: Buffer): string;
|
|
8
|
+
/** text: plain text, Markdown, CSV, JSON, HTML (as Markdown) and XML. */
|
|
9
|
+
export declare const textConverter: Converter;
|
|
10
|
+
/**
|
|
11
|
+
* The most pixels of a PNG image-pdf takes (50 megapixels, about 200 MB once
|
|
12
|
+
* decoded). A PNG is decoded whole, in memory and synchronously, so its pixel
|
|
13
|
+
* count is checked from its header first: a small, highly compressed file can
|
|
14
|
+
* describe an image far larger than its size suggests. JPEGs are embedded
|
|
15
|
+
* without being decoded. The converter's `maxPixels` setting changes it
|
|
16
|
+
* (config.plugins.settings.converter['image-pdf'].maxPixels, or
|
|
17
|
+
* DOCUMENTS_MAX_IMAGE_PIXELS).
|
|
18
|
+
*/
|
|
19
|
+
export declare const MAX_IMAGE_PIXELS = 50000000;
|
|
20
|
+
/** A PNG's width and height, from its IHDR header; undefined when it has none. */
|
|
21
|
+
export declare function pngSize(buffer: Buffer): {
|
|
22
|
+
width: number;
|
|
23
|
+
height: number;
|
|
24
|
+
} | undefined;
|
|
25
|
+
/** image-pdf: PNG and JPEG images into a PDF, one page per image, scaled to fit and never resampled. */
|
|
26
|
+
export declare const imagePdfConverter: Converter;
|
|
27
|
+
/** The converters every UAG has. None needs a system binary. */
|
|
28
|
+
export declare const BUILT_IN_CONVERTERS: Converter[];
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.BUILT_IN_CONVERTERS = exports.imagePdfConverter = exports.MAX_IMAGE_PIXELS = exports.textConverter = exports.pdfTextConverter = void 0;
|
|
4
|
+
exports.loadUnpdf = loadUnpdf;
|
|
5
|
+
exports.decodeText = decodeText;
|
|
6
|
+
exports.pngSize = pngSize;
|
|
7
|
+
const node_html_markdown_1 = require("node-html-markdown");
|
|
8
|
+
const mime_1 = require("./mime");
|
|
9
|
+
const convert_1 = require("./convert");
|
|
10
|
+
const version_1 = require("../version");
|
|
11
|
+
/**
|
|
12
|
+
* A real dynamic import: TypeScript compiles `import()` in a CommonJS build to
|
|
13
|
+
* `require()`, which cannot load an ES module before Node 22.12.
|
|
14
|
+
*/
|
|
15
|
+
const esmImport = new Function('specifier', 'return import(specifier)');
|
|
16
|
+
/** unpdf, loaded on first use: it is an ES module, and carries a PDF.js build. */
|
|
17
|
+
function loadUnpdf() {
|
|
18
|
+
return esmImport('unpdf');
|
|
19
|
+
}
|
|
20
|
+
/** pdf-text: a PDF's text layer, page by page, with unpdf. */
|
|
21
|
+
exports.pdfTextConverter = {
|
|
22
|
+
type: 'converter',
|
|
23
|
+
name: 'pdf-text',
|
|
24
|
+
title: 'PDF text',
|
|
25
|
+
description: 'The text layer of a PDF, page by page. A PDF without one (a scan) yields empty text and a warning.',
|
|
26
|
+
version: version_1.UAG_VERSION,
|
|
27
|
+
apiVersion: 1,
|
|
28
|
+
accepts: [{ type: mime_1.MIME.pdf, ext: ['.pdf'] }],
|
|
29
|
+
produces: ['text'],
|
|
30
|
+
async convert(input, options) {
|
|
31
|
+
options.signal.throwIfAborted();
|
|
32
|
+
const { extractText, getDocumentProxy } = await loadUnpdf();
|
|
33
|
+
const document = await getDocumentProxy(new Uint8Array(input.buffer));
|
|
34
|
+
const { totalPages, text } = await extractText(document, { mergePages: false });
|
|
35
|
+
const pages = text.map((pageText, i) => ({ page: i + 1, text: String(pageText || '').trim() }));
|
|
36
|
+
const all = pages.map((page) => page.text).join('\n\n').trim();
|
|
37
|
+
return {
|
|
38
|
+
text: all,
|
|
39
|
+
textFormat: 'plain',
|
|
40
|
+
pages,
|
|
41
|
+
...(!all && { warnings: [`"${input.name}" has no text layer (${totalPages} page${totalPages === 1 ? '' : 's'}): it may be a scan.`] })
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
/** Decode text: UTF-16 with a byte order mark, else UTF-8 (without its BOM), else Windows-1252 (a superset of Latin-1). */
|
|
46
|
+
function decodeText(buffer) {
|
|
47
|
+
if (buffer[0] === 0xff && buffer[1] === 0xfe)
|
|
48
|
+
return new TextDecoder('utf-16le').decode(buffer.subarray(2));
|
|
49
|
+
if (buffer[0] === 0xfe && buffer[1] === 0xff)
|
|
50
|
+
return new TextDecoder('utf-16be').decode(buffer.subarray(2));
|
|
51
|
+
try {
|
|
52
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(buffer).replace(/^\uFEFF/, '');
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return new TextDecoder('windows-1252').decode(buffer);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const TEXT_FORMATS = {
|
|
59
|
+
[mime_1.MIME.markdown]: 'markdown',
|
|
60
|
+
[mime_1.MIME.html]: 'markdown',
|
|
61
|
+
[mime_1.MIME.csv]: 'csv'
|
|
62
|
+
};
|
|
63
|
+
/** text: plain text, Markdown, CSV, JSON, HTML (as Markdown) and XML. */
|
|
64
|
+
exports.textConverter = {
|
|
65
|
+
type: 'converter',
|
|
66
|
+
name: 'text',
|
|
67
|
+
title: 'Text',
|
|
68
|
+
description: 'Text files as they are; HTML as Markdown.',
|
|
69
|
+
version: version_1.UAG_VERSION,
|
|
70
|
+
apiVersion: 1,
|
|
71
|
+
accepts: [
|
|
72
|
+
{ type: mime_1.MIME.text, ext: ['.txt'] },
|
|
73
|
+
{ type: mime_1.MIME.markdown, ext: ['.md', '.markdown'] },
|
|
74
|
+
{ type: mime_1.MIME.csv, ext: ['.csv'] },
|
|
75
|
+
{ type: mime_1.MIME.json, ext: ['.json'] },
|
|
76
|
+
{ type: mime_1.MIME.html, ext: ['.html', '.htm'] },
|
|
77
|
+
{ type: mime_1.MIME.xml, ext: ['.xml'] }
|
|
78
|
+
],
|
|
79
|
+
produces: ['text'],
|
|
80
|
+
async convert(input, options) {
|
|
81
|
+
options.signal.throwIfAborted();
|
|
82
|
+
const decoded = decodeText(input.buffer);
|
|
83
|
+
const text = input.type === mime_1.MIME.html ? node_html_markdown_1.NodeHtmlMarkdown.translate(decoded) : decoded;
|
|
84
|
+
return { text, textFormat: TEXT_FORMATS[input.type] || 'plain' };
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
/** A4 portrait, in points. */
|
|
88
|
+
const PAGE = { width: 595.28, height: 841.89, margin: 36 };
|
|
89
|
+
/**
|
|
90
|
+
* The most pixels of a PNG image-pdf takes (50 megapixels, about 200 MB once
|
|
91
|
+
* decoded). A PNG is decoded whole, in memory and synchronously, so its pixel
|
|
92
|
+
* count is checked from its header first: a small, highly compressed file can
|
|
93
|
+
* describe an image far larger than its size suggests. JPEGs are embedded
|
|
94
|
+
* without being decoded. The converter's `maxPixels` setting changes it
|
|
95
|
+
* (config.plugins.settings.converter['image-pdf'].maxPixels, or
|
|
96
|
+
* DOCUMENTS_MAX_IMAGE_PIXELS).
|
|
97
|
+
*/
|
|
98
|
+
exports.MAX_IMAGE_PIXELS = 50000000;
|
|
99
|
+
/** A PNG's width and height, from its IHDR header; undefined when it has none. */
|
|
100
|
+
function pngSize(buffer) {
|
|
101
|
+
if (buffer.length < 24 || buffer.toString('latin1', 12, 16) !== 'IHDR')
|
|
102
|
+
return undefined;
|
|
103
|
+
return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
|
|
104
|
+
}
|
|
105
|
+
/** image-pdf: PNG and JPEG images into a PDF, one page per image, scaled to fit and never resampled. */
|
|
106
|
+
exports.imagePdfConverter = {
|
|
107
|
+
type: 'converter',
|
|
108
|
+
name: 'image-pdf',
|
|
109
|
+
title: 'Image to PDF',
|
|
110
|
+
description: 'A PNG or JPEG image as a one-page PDF, scaled to fit the page.',
|
|
111
|
+
version: version_1.UAG_VERSION,
|
|
112
|
+
apiVersion: 1,
|
|
113
|
+
accepts: [{ type: mime_1.MIME.png, ext: ['.png'] }, { type: mime_1.MIME.jpeg, ext: ['.jpg', '.jpeg'] }],
|
|
114
|
+
produces: ['pdf'],
|
|
115
|
+
settings: {
|
|
116
|
+
maxPixels: {
|
|
117
|
+
type: 'number',
|
|
118
|
+
env: 'DOCUMENTS_MAX_IMAGE_PIXELS',
|
|
119
|
+
default: exports.MAX_IMAGE_PIXELS,
|
|
120
|
+
description: 'The largest PNG, in pixels (width × height), converted; a larger one is refused before it is decoded.'
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
async convert(input, options) {
|
|
124
|
+
options.signal.throwIfAborted();
|
|
125
|
+
if (input.type === mime_1.MIME.png) {
|
|
126
|
+
const size = pngSize(input.buffer);
|
|
127
|
+
if (!size)
|
|
128
|
+
throw new convert_1.ConversionError(`"${input.name}" is not a PNG image the image-pdf converter can read.`);
|
|
129
|
+
const maxPixels = Number(options.settings?.maxPixels) > 0 ? Number(options.settings.maxPixels) : exports.MAX_IMAGE_PIXELS;
|
|
130
|
+
if (size.width * size.height > maxPixels) {
|
|
131
|
+
throw new convert_1.ConversionError(`"${input.name}" is ${size.width}×${size.height} pixels, more than the image-pdf converter takes (${maxPixels} pixels).`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const { PDFDocument } = require('pdf-lib');
|
|
135
|
+
const pdf = await PDFDocument.create();
|
|
136
|
+
const image = input.type === mime_1.MIME.png ? await pdf.embedPng(input.buffer) : await pdf.embedJpg(input.buffer);
|
|
137
|
+
const landscape = image.width > image.height;
|
|
138
|
+
const width = landscape ? PAGE.height : PAGE.width;
|
|
139
|
+
const height = landscape ? PAGE.width : PAGE.height;
|
|
140
|
+
const scale = Math.min(1, (width - 2 * PAGE.margin) / image.width, (height - 2 * PAGE.margin) / image.height);
|
|
141
|
+
const page = pdf.addPage([width, height]);
|
|
142
|
+
const drawn = { width: image.width * scale, height: image.height * scale };
|
|
143
|
+
// The image is embedded as it is and only drawn smaller: its pixels are never resampled.
|
|
144
|
+
page.drawImage(image, { x: (width - drawn.width) / 2, y: (height - drawn.height) / 2, ...drawn });
|
|
145
|
+
return { pdf: Buffer.from(await pdf.save()), pages: 1 };
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
/** The converters every UAG has. None needs a system binary. */
|
|
149
|
+
exports.BUILT_IN_CONVERTERS = [exports.pdfTextConverter, exports.textConverter, exports.imagePdfConverter];
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Converter } from './types';
|
|
2
|
+
/** A file refused for what its bytes are. The message names the file, never its content. */
|
|
3
|
+
export declare class DetectionError extends Error {
|
|
4
|
+
constructor(message: string);
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* The names in a ZIP archive's central directory, read from the end of the
|
|
8
|
+
* buffer only: no entry is ever inflated. Undefined when it is not a readable
|
|
9
|
+
* ZIP archive.
|
|
10
|
+
*/
|
|
11
|
+
export declare function zipEntryNames(buffer: Buffer): string[] | undefined;
|
|
12
|
+
/** Whether a buffer is valid UTF-8 (or UTF-16 with a byte order mark) without NUL bytes. */
|
|
13
|
+
export declare function isText(buffer: Buffer): boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Whether a buffer is text in a legacy 8-bit encoding (Latin-1, Windows-1252):
|
|
16
|
+
* no NUL bytes and no control characters other than tab, line breaks, form feed
|
|
17
|
+
* and the DOS end-of-file mark. Spreadsheet and editor exports still write these.
|
|
18
|
+
*/
|
|
19
|
+
export declare function isLegacyText(buffer: Buffer): boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Identify a file's type from its bytes. The name and the client's MIME type
|
|
22
|
+
* are hints only: known signatures first, then each converter's `detect`, then
|
|
23
|
+
* text (typed by extension). A detected type that contradicts the family the
|
|
24
|
+
* extension promises is refused.
|
|
25
|
+
*/
|
|
26
|
+
export declare function identify(buffer: Buffer, hint: {
|
|
27
|
+
name: string;
|
|
28
|
+
type?: string;
|
|
29
|
+
}, converters?: Converter[]): string;
|
|
30
|
+
/** The extensions known for a type, from the converters that accept it and the usual ones. */
|
|
31
|
+
export declare function extensionsOf(type: string, converters?: Converter[]): string[];
|