@openenthrium/oe-runtime-sdk 1.7.5
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 +139 -0
- package/index.js +58 -0
- package/package.json +64 -0
- package/src/data/connectionTypes.json +18587 -0
- package/src/engine/index.js +183 -0
- package/src/engine/llm.js +71 -0
- package/src/engine/promptBuilder.js +38 -0
- package/src/index.js +156 -0
- package/src/middleware/auth.js +63 -0
- package/src/providers/embedding/index.js +69 -0
- package/src/providers/llm/index.js +136 -0
- package/src/routes/admin.js +686 -0
- package/src/routes/agents.js +193 -0
- package/src/routes/apiKeys.js +55 -0
- package/src/routes/audio.js +111 -0
- package/src/routes/auth.js +83 -0
- package/src/routes/chat.js +381 -0
- package/src/routes/connectors.js +435 -0
- package/src/routes/dashboard.js +244 -0
- package/src/routes/documents.js +443 -0
- package/src/routes/embed.js +103 -0
- package/src/routes/marketplace.js +49 -0
- package/src/routes/models.js +81 -0
- package/src/routes/oauth.js +423 -0
- package/src/routes/projects.js +238 -0
- package/src/routes/settings.js +45 -0
- package/src/routes/setup.js +42 -0
- package/src/routes/sso.js +218 -0
- package/src/routes/superadmin.js +51 -0
- package/src/routes/templates.js +75 -0
- package/src/routes/threads.js +53 -0
- package/src/routes/workspaces.js +464 -0
- package/src/telemetry/bootstrap.js +39 -0
- package/src/telemetry/registration.js +16 -0
- package/src/utils/activityLog.js +16 -0
- package/src/utils/agentChain.js +141 -0
- package/src/utils/buildVisualization.js +102 -0
- package/src/utils/dlpScanner.js +57 -0
- package/src/utils/ingestionQueue.js +240 -0
- package/src/utils/prepareConnectors.js +66 -0
- package/src/utils/rag/_settings.js +6 -0
- package/src/utils/rag/chroma.js +82 -0
- package/src/utils/rag/lancedb.js +103 -0
- package/src/utils/rag/milvus.js +116 -0
- package/src/utils/rag/pgvector.js +104 -0
- package/src/utils/rag/pinecone.js +71 -0
- package/src/utils/rag/qdrant.js +106 -0
- package/src/utils/rag/weaviate.js +137 -0
- package/src/utils/rag/zilliz.js +115 -0
- package/src/utils/scheduler.js +168 -0
- package/src/utils/tier.js +106 -0
- package/src/utils/tools/adapters/_template.js +74 -0
- package/src/utils/tools/adapters/box.js +71 -0
- package/src/utils/tools/adapters/confluence.js +80 -0
- package/src/utils/tools/adapters/database.js +215 -0
- package/src/utils/tools/adapters/dropbox.js +74 -0
- package/src/utils/tools/adapters/elasticsearch.js +90 -0
- package/src/utils/tools/adapters/filesystem.js +197 -0
- package/src/utils/tools/adapters/freshdesk.js +87 -0
- package/src/utils/tools/adapters/gdrive.js +326 -0
- package/src/utils/tools/adapters/github.js +169 -0
- package/src/utils/tools/adapters/gmail.js +157 -0
- package/src/utils/tools/adapters/graphql.js +73 -0
- package/src/utils/tools/adapters/hubspot.js +101 -0
- package/src/utils/tools/adapters/image-gen.js +120 -0
- package/src/utils/tools/adapters/jira.js +86 -0
- package/src/utils/tools/adapters/kafka.js +126 -0
- package/src/utils/tools/adapters/ldap.js +118 -0
- package/src/utils/tools/adapters/mcp-client.js +138 -0
- package/src/utils/tools/adapters/mongodb.js +119 -0
- package/src/utils/tools/adapters/mqtt.js +106 -0
- package/src/utils/tools/adapters/music-gen.js +118 -0
- package/src/utils/tools/adapters/notion.js +93 -0
- package/src/utils/tools/adapters/ocr.js +107 -0
- package/src/utils/tools/adapters/onedrive.js +72 -0
- package/src/utils/tools/adapters/redis.js +104 -0
- package/src/utils/tools/adapters/rest-api.js +119 -0
- package/src/utils/tools/adapters/s3.js +142 -0
- package/src/utils/tools/adapters/search.js +80 -0
- package/src/utils/tools/adapters/sftp.js +121 -0
- package/src/utils/tools/adapters/shell.js +97 -0
- package/src/utils/tools/adapters/slack.js +83 -0
- package/src/utils/tools/adapters/speech.js +160 -0
- package/src/utils/tools/adapters/ssh.js +113 -0
- package/src/utils/tools/adapters/video-gen.js +152 -0
- package/src/utils/tools/adapters/web3.js +111 -0
- package/src/utils/tools/adapters/zendesk.js +82 -0
- package/src/utils/tools/adapters/zoho-mail.js +246 -0
- package/src/utils/tools/registry.js +229 -0
- package/src/utils/vectorStore.js +68 -0
- package/src/utils/workflowEngine.js +4 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
const { getLLMConfig } = require("../providers/llm");
|
|
2
|
+
const { executeTool } = require("./tools/registry");
|
|
3
|
+
const engine = require("../engine");
|
|
4
|
+
const { conditionMet } = engine;
|
|
5
|
+
|
|
6
|
+
async function runChainedAgent(agent, db, inputContext, depth, chatContext) {
|
|
7
|
+
const run = await db.agentRun.create({
|
|
8
|
+
data: { agentId: agent.id, status: "running", triggerType: "chained", input: inputContext?.slice(0, 2000) || null, triggeredFromWorkspaceId: chatContext?.workspaceId || null },
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
const connectorIds = JSON.parse(agent.connectorIds || "[]");
|
|
13
|
+
const connectors = connectorIds.length
|
|
14
|
+
? await db.connector.findMany({ where: { id: { in: connectorIds }, status: "active" } })
|
|
15
|
+
: [];
|
|
16
|
+
|
|
17
|
+
const workspace = await db.workspace.findUnique({ where: { id: agent.workspaceId } });
|
|
18
|
+
|
|
19
|
+
let appendToPrompt = "\n\nIMPORTANT: This is an automated chained run. Execute immediately using available tools. Do not ask for clarification.";
|
|
20
|
+
if (workspace?.agentMemoryEnabled) {
|
|
21
|
+
const pastRuns = await db.agentRun.findMany({
|
|
22
|
+
where: { agentId: agent.id, status: "success" },
|
|
23
|
+
orderBy: { completedAt: "desc" },
|
|
24
|
+
take: workspace.agentMemoryRuns || 5,
|
|
25
|
+
});
|
|
26
|
+
if (pastRuns.length) {
|
|
27
|
+
const memoryBlock = pastRuns.reverse().map((r, i) =>
|
|
28
|
+
`Run ${i + 1} (${r.completedAt?.toISOString().slice(0, 10)}): ${(r.output || "").slice(0, 500)}`
|
|
29
|
+
).join("\n\n");
|
|
30
|
+
appendToPrompt += `\n\n--- MEMORY FROM PREVIOUS RUNS ---\n${memoryBlock}\n--- END MEMORY ---`;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const llmConfig = await getLLMConfig();
|
|
35
|
+
const agentSpec = {
|
|
36
|
+
systemPrompt: agent.systemPrompt,
|
|
37
|
+
workflow: JSON.parse(agent.workflow || "[]"),
|
|
38
|
+
params: JSON.parse(agent.params || "[]"),
|
|
39
|
+
maxRounds: workspace?.defaultAgentMaxRounds || 25,
|
|
40
|
+
input: inputContext ? `Context from previous agent:\n\n${inputContext}\n\nNow execute your task.` : "Execute the agent task now.",
|
|
41
|
+
appendToPrompt,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const allToolCallNames = [];
|
|
45
|
+
const { output: fullOutput } = await engine.run(agentSpec, llmConfig, connectors, {
|
|
46
|
+
toolExecutor: (name, args, conns) => executeTool(name, args, conns, db),
|
|
47
|
+
onToolCall: (name) => allToolCallNames.push(name),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
await db.agentRun.update({
|
|
51
|
+
where: { id: run.id },
|
|
52
|
+
data: { status: "success", output: fullOutput, completedAt: new Date() },
|
|
53
|
+
});
|
|
54
|
+
console.log(`[chain] Agent "${agent.name}" (depth=${depth}) completed.`);
|
|
55
|
+
|
|
56
|
+
if (chatContext?.workspaceId) {
|
|
57
|
+
await db.chat.create({
|
|
58
|
+
data: {
|
|
59
|
+
workspaceId: chatContext.workspaceId,
|
|
60
|
+
threadId: chatContext.threadId || null,
|
|
61
|
+
role: "assistant",
|
|
62
|
+
content: `**@${agent.slug}** *(chained)* — ${fullOutput}`,
|
|
63
|
+
toolCalls: allToolCallNames.length ? JSON.stringify(allToolCallNames) : null,
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Continue chain
|
|
69
|
+
await maybeChain(agent, fullOutput, db, depth, chatContext);
|
|
70
|
+
|
|
71
|
+
} catch (err) {
|
|
72
|
+
await db.agentRun.update({
|
|
73
|
+
where: { id: run.id },
|
|
74
|
+
data: { status: "error", error: err.message, completedAt: new Date() },
|
|
75
|
+
});
|
|
76
|
+
if (chatContext?.workspaceId) {
|
|
77
|
+
await db.chat.create({
|
|
78
|
+
data: {
|
|
79
|
+
workspaceId: chatContext.workspaceId,
|
|
80
|
+
threadId: chatContext.threadId || null,
|
|
81
|
+
role: "assistant",
|
|
82
|
+
content: `**@${agent.slug}** *(chained)* — ❌ Error: ${err.message}`,
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
console.error(`[chain] Agent "${agent.name}" failed:`, err.message);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function maybeChain(agent, output, db, depth = 0, chatContext) {
|
|
91
|
+
const workspace = await db.workspace.findUnique({ where: { id: agent.workspaceId } });
|
|
92
|
+
const maxDepth = workspace?.maxChainDepth || 5;
|
|
93
|
+
if (depth >= maxDepth) { console.warn(`[chain] Max depth (${maxDepth}) reached at agent "${agent.name}"`); return { pendingApprovals: 0 }; }
|
|
94
|
+
|
|
95
|
+
const chains = agent.chains
|
|
96
|
+
? JSON.parse(agent.chains)
|
|
97
|
+
: agent.nextAgent
|
|
98
|
+
? [{ condition: agent.nextAgentCondition || "always", nextAgent: agent.nextAgent }]
|
|
99
|
+
: [];
|
|
100
|
+
|
|
101
|
+
if (!chains.length) return { pendingApprovals: 0 };
|
|
102
|
+
|
|
103
|
+
let pendingApprovals = 0;
|
|
104
|
+
|
|
105
|
+
for (const chain of chains) {
|
|
106
|
+
if (!chain.nextAgent) continue;
|
|
107
|
+
if (!conditionMet(chain.condition || "always", output)) {
|
|
108
|
+
console.log(`[chain] Condition "${chain.condition}" not met → skipping "${chain.nextAgent}"`);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const nextAgent = await db.agent.findFirst({ where: { slug: chain.nextAgent } });
|
|
112
|
+
if (!nextAgent) { console.warn(`[chain] Next agent "${chain.nextAgent}" not found`); continue; }
|
|
113
|
+
|
|
114
|
+
if (chain.triggerType === "manual") {
|
|
115
|
+
const timeoutAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24h
|
|
116
|
+
await db.chainApproval.create({
|
|
117
|
+
data: {
|
|
118
|
+
workspaceId: chatContext?.workspaceId || agent.workspaceId,
|
|
119
|
+
sourceAgentId: agent.id,
|
|
120
|
+
sourceRunId: chatContext?.runId || 0,
|
|
121
|
+
threadId: chatContext?.threadId || null,
|
|
122
|
+
nextAgentSlug: chain.nextAgent,
|
|
123
|
+
condition: chain.condition || "always",
|
|
124
|
+
runOutput: (output || "").slice(0, 2000),
|
|
125
|
+
status: "pending",
|
|
126
|
+
timeoutAt,
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
pendingApprovals++;
|
|
130
|
+
console.log(`[chain] "${agent.name}" → "${chain.nextAgent}" queued for manual approval`);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
console.log(`[chain] "${agent.name}" → "${nextAgent.name}" (condition: ${chain.condition}, depth: ${depth + 1})`);
|
|
135
|
+
await runChainedAgent(nextAgent, db, output, depth + 1, chatContext);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { pendingApprovals };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
module.exports = { maybeChain, runChainedAgent };
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
function parseVizConfig(instructions) {
|
|
2
|
+
const s = instructions || "";
|
|
3
|
+
const type =
|
|
4
|
+
/pie\s+chart/i.test(s) ? "pie" :
|
|
5
|
+
/bar\s+chart/i.test(s) ? "bar" :
|
|
6
|
+
/line\s+chart/i.test(s) ? "line" : "auto";
|
|
7
|
+
|
|
8
|
+
const limitMatch = s.match(/top\s+(\d+)/i);
|
|
9
|
+
const limit = limitMatch ? parseInt(limitMatch[1]) : null;
|
|
10
|
+
|
|
11
|
+
const style = /donut/i.test(s) ? "donut" : "solid";
|
|
12
|
+
|
|
13
|
+
const legend =
|
|
14
|
+
/legend.*right|right.*legend/i.test(s) ? "right" :
|
|
15
|
+
/legend.*bottom|bottom.*legend/i.test(s) ? "bottom" :
|
|
16
|
+
/no\s+legend|legend.*none/i.test(s) ? "none" : "right";
|
|
17
|
+
|
|
18
|
+
const groupOthers = /group.*others?|others?.*group/i.test(s);
|
|
19
|
+
|
|
20
|
+
return { type, limit, style, legend, groupOthers };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function buildVisualization(rawResults, instructions) {
|
|
24
|
+
if (!rawResults?.length) return null;
|
|
25
|
+
|
|
26
|
+
const cfg = parseVizConfig(instructions);
|
|
27
|
+
|
|
28
|
+
for (let i = rawResults.length - 1; i >= 0; i--) {
|
|
29
|
+
const viz = _tryBuild(rawResults[i], cfg);
|
|
30
|
+
if (viz) return viz;
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function _tryBuild(raw, cfg) {
|
|
36
|
+
if (!raw || typeof raw !== "string") return null;
|
|
37
|
+
|
|
38
|
+
let parsed;
|
|
39
|
+
try { parsed = JSON.parse(raw); } catch { return null; }
|
|
40
|
+
|
|
41
|
+
// Plain number
|
|
42
|
+
if (typeof parsed === "number") {
|
|
43
|
+
return { type: "stat", data: [{ label: "Result", value: parsed }], style: cfg.style, legend: cfg.legend };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Single object: { count: 47 }
|
|
47
|
+
if (parsed && !Array.isArray(parsed) && typeof parsed === "object") {
|
|
48
|
+
const keys = Object.keys(parsed);
|
|
49
|
+
const numKeys = keys.filter(k => typeof parsed[k] === "number" || (!isNaN(Number(parsed[k])) && parsed[k] !== ""));
|
|
50
|
+
if (numKeys.length === 1) return { type: "stat", data: [{ label: numKeys[0], value: Number(parsed[numKeys[0]]) }], style: cfg.style, legend: cfg.legend };
|
|
51
|
+
if (numKeys.length > 1) return _applyConfig({ type: "bar", data: numKeys.map(k => ({ label: k, value: Number(parsed[k]) })) }, cfg);
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!Array.isArray(parsed) || !parsed.length) return null;
|
|
56
|
+
const first = parsed[0];
|
|
57
|
+
if (typeof first !== "object" || first === null) return null;
|
|
58
|
+
|
|
59
|
+
const keys = Object.keys(first);
|
|
60
|
+
const numKey = keys.find(k => parsed.every(r => r[k] !== undefined && !isNaN(Number(r[k])) && r[k] !== ""));
|
|
61
|
+
const labelKey = keys.find(k => k !== numKey && typeof first[k] === "string");
|
|
62
|
+
|
|
63
|
+
// Single row
|
|
64
|
+
if (parsed.length === 1) {
|
|
65
|
+
const numKeys = keys.filter(k => !isNaN(Number(first[k])) && first[k] !== "");
|
|
66
|
+
if (numKeys.length === 1) return { type: "stat", data: [{ label: numKeys[0], value: Number(first[numKeys[0]]) }], style: cfg.style, legend: cfg.legend };
|
|
67
|
+
if (numKeys.length > 1) return _applyConfig({ type: "bar", data: numKeys.map(k => ({ label: k, value: Number(first[k]) })) }, cfg);
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Multiple rows
|
|
72
|
+
if (numKey && labelKey) {
|
|
73
|
+
const detectedType = cfg.type !== "auto" ? cfg.type : (/date|time|month|year|week|day|hour/i.test(labelKey) ? "line" : "bar");
|
|
74
|
+
const data = parsed.map(r => ({ label: String(r[labelKey]), value: Number(r[numKey]) }));
|
|
75
|
+
return _applyConfig({ type: detectedType, data }, cfg);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (numKey) {
|
|
79
|
+
const data = parsed.map((r, i) => ({ label: String(i + 1), value: Number(r[numKey]) }));
|
|
80
|
+
return _applyConfig({ type: cfg.type !== "auto" ? cfg.type : "bar", data }, cfg);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function _applyConfig(viz, cfg) {
|
|
87
|
+
let data = viz.data;
|
|
88
|
+
|
|
89
|
+
if (cfg.limit && data.length > cfg.limit) {
|
|
90
|
+
const sorted = [...data].sort((a, b) => b.value - a.value);
|
|
91
|
+
const top = sorted.slice(0, cfg.limit);
|
|
92
|
+
if (cfg.groupOthers) {
|
|
93
|
+
const othersValue = sorted.slice(cfg.limit).reduce((s, d) => s + d.value, 0);
|
|
94
|
+
top.push({ label: "Others", value: othersValue });
|
|
95
|
+
}
|
|
96
|
+
data = top;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { type: viz.type, data, style: cfg.style, legend: cfg.legend };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = { buildVisualization };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Built-in pattern library — each entry has a regex factory (fresh instance per call)
|
|
2
|
+
const BUILTIN_PATTERNS = {
|
|
3
|
+
passwords: () => /(?:password|passwd|pwd|secret|token|pass)\s*[:=]\s*\S+/gi,
|
|
4
|
+
ip_addresses: () => /\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g,
|
|
5
|
+
api_keys: () => /\b(?:sk-[A-Za-z0-9]{20,}|ghp_[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{16}|xox[bpoa]-[0-9A-Za-z\-]+|Bearer\s+[A-Za-z0-9\-._~+/]{20,})/g,
|
|
6
|
+
credit_cards: () => /\b\d{4}[\s\-]?\d{4}[\s\-]?\d{4}[\s\-]?\d{4}\b/g,
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Scan a message against active DLP policies.
|
|
11
|
+
* Returns: { blocked, violations, redactedText }
|
|
12
|
+
* - blocked: true if any policy with action="block" matched
|
|
13
|
+
* - violations: [{ policyId, policyName, action, snippet }]
|
|
14
|
+
* - redactedText: message with redacted content (or original if nothing to redact)
|
|
15
|
+
*/
|
|
16
|
+
function scanMessage(text, policies = []) {
|
|
17
|
+
const violations = [];
|
|
18
|
+
let redactedText = text;
|
|
19
|
+
let blocked = false;
|
|
20
|
+
|
|
21
|
+
for (const policy of policies) {
|
|
22
|
+
if (!policy.enabled) continue;
|
|
23
|
+
|
|
24
|
+
let regex;
|
|
25
|
+
try {
|
|
26
|
+
// Use built-in pattern if available, otherwise compile custom pattern
|
|
27
|
+
const builtin = BUILTIN_PATTERNS[policy.category];
|
|
28
|
+
regex = builtin ? builtin() : new RegExp(policy.pattern, "gi");
|
|
29
|
+
} catch (_) {
|
|
30
|
+
continue; // skip malformed patterns
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const matches = text.match(regex);
|
|
34
|
+
if (!matches || matches.length === 0) continue;
|
|
35
|
+
|
|
36
|
+
const snippet = text;
|
|
37
|
+
|
|
38
|
+
violations.push({
|
|
39
|
+
policyId: policy.id,
|
|
40
|
+
policyName: policy.name,
|
|
41
|
+
action: policy.action,
|
|
42
|
+
snippet,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
if (policy.action === "block") {
|
|
46
|
+
blocked = true;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (policy.action === "redact") {
|
|
50
|
+
redactedText = redactedText.replace(regex, "[REDACTED]");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return { blocked, violations, redactedText };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = { scanMessage };
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
const axios = require("axios");
|
|
2
|
+
const FormData = require("form-data");
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const { v4: uuidv4 } = require("uuid");
|
|
6
|
+
const { upsertChunksBatched } = require("./vectorStore");
|
|
7
|
+
|
|
8
|
+
const OCR_MIME_MAP = {
|
|
9
|
+
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
|
|
10
|
+
".gif": "image/gif", ".webp": "image/webp", ".tiff": "image/tiff", ".bmp": "image/bmp"
|
|
11
|
+
};
|
|
12
|
+
const OCR_PROMPT =
|
|
13
|
+
"Extract ALL text from this image exactly as it appears. " +
|
|
14
|
+
"Preserve headings, bullet points, numbered lists, tables, and paragraph structure. " +
|
|
15
|
+
"Return only the extracted text — no commentary, no explanation, nothing else.";
|
|
16
|
+
|
|
17
|
+
const PROCESSOR_URL = `http://localhost:${process.env.PROCESSOR_PORT || 5002}`;
|
|
18
|
+
const EMBED_BATCH_SIZE = 10;
|
|
19
|
+
|
|
20
|
+
async function getChunkSettings(db) {
|
|
21
|
+
const rows = await db.setting.findMany({ where: { key: { in: ["chunk_size", "chunk_overlap"] } } });
|
|
22
|
+
const map = Object.fromEntries(rows.map(r => [r.key, parseInt(r.value)]));
|
|
23
|
+
return { chunkSize: map.chunk_size || 1000, chunkOverlap: map.chunk_overlap || 150 };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
class IngestionQueue {
|
|
27
|
+
constructor() {
|
|
28
|
+
this._queue = [];
|
|
29
|
+
this._running = false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Add a document job to the queue.
|
|
34
|
+
* @param {object} db Prisma client
|
|
35
|
+
* @param {object} workspace { id, slug, name }
|
|
36
|
+
* @param {object} doc { uid, name, ... }
|
|
37
|
+
* @param {string} source file path or URL string
|
|
38
|
+
* @param {string} sourceType "file" | "url"
|
|
39
|
+
* @param {boolean} keepFile don't delete temp file after processing
|
|
40
|
+
* @param {number} uploadedByUserId user who triggered the ingestion
|
|
41
|
+
*/
|
|
42
|
+
enqueue(db, workspace, doc, source, sourceType = "file", keepFile = false, uploadedByUserId = null) {
|
|
43
|
+
this._queue.push({ db, workspace, doc, source, sourceType, keepFile, uploadedByUserId });
|
|
44
|
+
if (!this._running) this._run();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async _run() {
|
|
48
|
+
this._running = true;
|
|
49
|
+
while (this._queue.length > 0) {
|
|
50
|
+
const job = this._queue.shift();
|
|
51
|
+
await this._processJob(job).catch(e => console.error("[Queue] job error:", e.message));
|
|
52
|
+
}
|
|
53
|
+
this._running = false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async _processJob({ db, workspace, doc, source, sourceType, keepFile, uploadedByUserId }) {
|
|
57
|
+
// Mark ingesting
|
|
58
|
+
try {
|
|
59
|
+
await db.document.update({
|
|
60
|
+
where: { uid: doc.uid },
|
|
61
|
+
data: { status: "ingesting", chunksProcessed: 0, totalChunks: 0, cancelRequested: false, errorMessage: null }
|
|
62
|
+
});
|
|
63
|
+
} catch {
|
|
64
|
+
return; // doc may have been deleted while queued
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
// ── 1. Extract text + chunk via processor ─────────────────────────
|
|
69
|
+
const { chunkSize, chunkOverlap } = await getChunkSettings(db);
|
|
70
|
+
let chunks;
|
|
71
|
+
|
|
72
|
+
if (sourceType === "website-crawl") {
|
|
73
|
+
// ── BFS crawl: discover pages, create + enqueue each as a URL doc ──
|
|
74
|
+
const { startUrl, maxPages, maxDepth } = JSON.parse(source);
|
|
75
|
+
const origin = new URL(startUrl).origin;
|
|
76
|
+
const visited = new Set();
|
|
77
|
+
const bfsQueue = [{ url: startUrl, depth: 0 }];
|
|
78
|
+
const toIngest = [];
|
|
79
|
+
|
|
80
|
+
while (bfsQueue.length > 0 && toIngest.length < maxPages) {
|
|
81
|
+
const { url: raw, depth } = bfsQueue.shift();
|
|
82
|
+
let normalized;
|
|
83
|
+
try { const u = new URL(raw); u.hash = ""; normalized = u.toString(); } catch { continue; }
|
|
84
|
+
if (visited.has(normalized)) continue;
|
|
85
|
+
visited.add(normalized);
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const resp = await fetch(normalized, {
|
|
89
|
+
headers: { "User-Agent": "OpenEnthrium-Crawler/1.0" },
|
|
90
|
+
redirect: "follow",
|
|
91
|
+
signal: AbortSignal.timeout(8000)
|
|
92
|
+
});
|
|
93
|
+
if (!resp.ok) continue;
|
|
94
|
+
const ct = resp.headers.get("content-type") || "";
|
|
95
|
+
if (!ct.includes("text/html")) continue;
|
|
96
|
+
const html = await resp.text();
|
|
97
|
+
toIngest.push(normalized);
|
|
98
|
+
|
|
99
|
+
if (depth < maxDepth) {
|
|
100
|
+
const linkRe = /href=["']([^"'#]+)["']/gi;
|
|
101
|
+
let m;
|
|
102
|
+
while ((m = linkRe.exec(html)) !== null) {
|
|
103
|
+
try {
|
|
104
|
+
const linked = new URL(m[1], normalized);
|
|
105
|
+
linked.hash = "";
|
|
106
|
+
if (linked.origin === origin && !visited.has(linked.toString()))
|
|
107
|
+
bfsQueue.push({ url: linked.toString(), depth: depth + 1 });
|
|
108
|
+
} catch { /* skip */ }
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
} catch { /* skip unreachable */ }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!toIngest.length) throw new Error("No pages could be fetched from that URL");
|
|
115
|
+
|
|
116
|
+
for (const url of toIngest) {
|
|
117
|
+
const urlDoc = await db.document.create({
|
|
118
|
+
data: { uid: uuidv4(), name: url, type: "url", workspaceId: workspace.id, status: "queued", uploadedByUserId }
|
|
119
|
+
});
|
|
120
|
+
ingestionQueue.enqueue(db, workspace, urlDoc, url, "url", false, uploadedByUserId);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Mark the crawler tracker doc as ready (it has no chunks of its own)
|
|
124
|
+
await db.document.update({
|
|
125
|
+
where: { uid: doc.uid },
|
|
126
|
+
data: { status: "ready", chunkCount: toIngest.length, chunksProcessed: toIngest.length, totalChunks: toIngest.length, uploadedByUserId }
|
|
127
|
+
});
|
|
128
|
+
return;
|
|
129
|
+
|
|
130
|
+
} else if (sourceType === "ocr") {
|
|
131
|
+
// ── OCR: LLM Vision → text → processor ───────────────────────
|
|
132
|
+
if (!fs.existsSync(source)) throw new Error("Image file not found: " + source);
|
|
133
|
+
|
|
134
|
+
const mimeType = OCR_MIME_MAP[path.extname(source).toLowerCase()] || "image/jpeg";
|
|
135
|
+
const base64 = fs.readFileSync(source).toString("base64");
|
|
136
|
+
|
|
137
|
+
const { getLLMClient, getSetting } = require("../providers/llm");
|
|
138
|
+
const { provider, client } = await getLLMClient();
|
|
139
|
+
const model = (await getSetting("llm_model")) || (provider === "anthropic" ? "claude-3-5-sonnet-20241022" : "gpt-4o");
|
|
140
|
+
|
|
141
|
+
let extractedText = "";
|
|
142
|
+
if (provider === "anthropic") {
|
|
143
|
+
const response = await client.messages.create({
|
|
144
|
+
model, max_tokens: 4096,
|
|
145
|
+
messages: [{ role: "user", content: [
|
|
146
|
+
{ type: "image", source: { type: "base64", media_type: mimeType, data: base64 } },
|
|
147
|
+
{ type: "text", text: OCR_PROMPT }
|
|
148
|
+
]}]
|
|
149
|
+
});
|
|
150
|
+
extractedText = response.content?.[0]?.text || "";
|
|
151
|
+
} else {
|
|
152
|
+
const response = await client.chat.completions.create({
|
|
153
|
+
model, max_tokens: 4096,
|
|
154
|
+
messages: [{ role: "user", content: [
|
|
155
|
+
{ type: "image_url", image_url: { url: `data:${mimeType};base64,${base64}`, detail: "high" } },
|
|
156
|
+
{ type: "text", text: OCR_PROMPT }
|
|
157
|
+
]}]
|
|
158
|
+
});
|
|
159
|
+
extractedText = response.choices?.[0]?.message?.content || "";
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (!extractedText.trim()) throw new Error("No text could be extracted from this image");
|
|
163
|
+
|
|
164
|
+
// Write extracted text to a temp .txt file, process normally
|
|
165
|
+
const UPLOAD_DIR = path.join(__dirname, "../../storage/uploads/");
|
|
166
|
+
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
|
167
|
+
const txtPath = path.join(UPLOAD_DIR, uuidv4() + ".txt");
|
|
168
|
+
fs.writeFileSync(txtPath, extractedText, "utf-8");
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
const form = new FormData();
|
|
172
|
+
form.append("file", fs.createReadStream(txtPath), doc.name.replace(/ \(OCR\)$/, "") + ".txt");
|
|
173
|
+
form.append("chunkSize", String(chunkSize));
|
|
174
|
+
form.append("chunkOverlap", String(chunkOverlap));
|
|
175
|
+
const { data } = await axios.post(`${PROCESSOR_URL}/process/file`, form, {
|
|
176
|
+
headers: form.getHeaders(), maxContentLength: Infinity, maxBodyLength: Infinity
|
|
177
|
+
});
|
|
178
|
+
chunks = data.chunks;
|
|
179
|
+
} finally {
|
|
180
|
+
fs.unlink(txtPath, () => {});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
} else if (sourceType === "url") {
|
|
184
|
+
const { data } = await axios.post(`${PROCESSOR_URL}/process/url`, { url: source, chunkSize, chunkOverlap });
|
|
185
|
+
chunks = data.chunks;
|
|
186
|
+
} else {
|
|
187
|
+
if (!fs.existsSync(source)) throw new Error("Source file not found: " + source);
|
|
188
|
+
const form = new FormData();
|
|
189
|
+
form.append("file", fs.createReadStream(source), doc.name);
|
|
190
|
+
form.append("chunkSize", String(chunkSize));
|
|
191
|
+
form.append("chunkOverlap", String(chunkOverlap));
|
|
192
|
+
const { data } = await axios.post(`${PROCESSOR_URL}/process/file`, form, {
|
|
193
|
+
headers: form.getHeaders(),
|
|
194
|
+
maxContentLength: Infinity,
|
|
195
|
+
maxBodyLength: Infinity
|
|
196
|
+
});
|
|
197
|
+
chunks = data.chunks;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Store total so the UI can show X / Y
|
|
201
|
+
await db.document.update({ where: { uid: doc.uid }, data: { totalChunks: chunks.length } });
|
|
202
|
+
|
|
203
|
+
// Calculate content size (for URL/OCR docs that had no file size at upload)
|
|
204
|
+
const contentSize = chunks.reduce((sum, c) => sum + Buffer.byteLength(c.text || "", "utf8"), 0);
|
|
205
|
+
|
|
206
|
+
// ── 2. Embed + upsert in batches, checking cancel between each ────
|
|
207
|
+
const { chunksProcessed: processed, embeddingTokens, embeddingModel } = await upsertChunksBatched(workspace.slug, doc.uid, chunks, {
|
|
208
|
+
batchSize: EMBED_BATCH_SIZE,
|
|
209
|
+
onProgress: async (count) => {
|
|
210
|
+
await db.document.update({ where: { uid: doc.uid }, data: { chunksProcessed: count } });
|
|
211
|
+
},
|
|
212
|
+
shouldCancel: async () => {
|
|
213
|
+
const row = await db.document.findUnique({ where: { uid: doc.uid }, select: { cancelRequested: true } });
|
|
214
|
+
return row?.cancelRequested === true;
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// ── 3. Finalise status ─────────────────────────────────────────────
|
|
219
|
+
const wasCancelled = processed < chunks.length;
|
|
220
|
+
const sizeUpdate = (sourceType === "url" || sourceType === "ocr") ? { size: contentSize } : {};
|
|
221
|
+
await db.document.update({
|
|
222
|
+
where: { uid: doc.uid },
|
|
223
|
+
data: wasCancelled
|
|
224
|
+
? { status: processed > 0 ? "partial" : "failed", chunkCount: processed, cancelRequested: false, embeddingTokens, embeddingModel, uploadedByUserId, ...sizeUpdate }
|
|
225
|
+
: { status: "ready", chunkCount: processed, chunksProcessed: processed, cancelRequested: false, embeddingTokens, embeddingModel, uploadedByUserId, ...sizeUpdate }
|
|
226
|
+
});
|
|
227
|
+
} catch (err) {
|
|
228
|
+
console.error(`[Queue] Ingestion failed for ${doc.uid}:`, err.message);
|
|
229
|
+
await db.document.update({
|
|
230
|
+
where: { uid: doc.uid },
|
|
231
|
+
data: { status: "failed", errorMessage: err.message }
|
|
232
|
+
}).catch(() => {});
|
|
233
|
+
} finally {
|
|
234
|
+
if (!keepFile && sourceType !== "url" && sourceType !== "website-crawl") fs.unlink(source, () => {});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const ingestionQueue = new IngestionQueue();
|
|
240
|
+
module.exports = ingestionQueue;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const fs = require("fs");
|
|
3
|
+
const os = require("os");
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Merge YAML connector declarations (name+type only, no secrets)
|
|
7
|
+
* with config-file credentials, producing the connector objects
|
|
8
|
+
* the engine expects: { id, name, type, status, authConfig, config }.
|
|
9
|
+
*
|
|
10
|
+
* configConnectors supports both array format (canonical) and legacy
|
|
11
|
+
* object format { "Name": { type, ...creds } }.
|
|
12
|
+
*/
|
|
13
|
+
function prepareConnectors(yamlConnectors, configConnectors) {
|
|
14
|
+
// Normalise config to array
|
|
15
|
+
let cfgArray;
|
|
16
|
+
if (Array.isArray(configConnectors)) {
|
|
17
|
+
cfgArray = configConnectors;
|
|
18
|
+
} else if (configConnectors && typeof configConnectors === "object") {
|
|
19
|
+
cfgArray = Object.entries(configConnectors).map(([name, cfg]) => ({
|
|
20
|
+
connection_name: name,
|
|
21
|
+
connection_type: cfg.type,
|
|
22
|
+
...cfg,
|
|
23
|
+
}));
|
|
24
|
+
} else {
|
|
25
|
+
cfgArray = [];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return (yamlConnectors || []).map((yc, i) => {
|
|
29
|
+
// Accept both new (connection_*) and legacy (name/type) field names
|
|
30
|
+
const ycName = yc.connection_name || yc.name;
|
|
31
|
+
const ycType = yc.connection_type || yc.type;
|
|
32
|
+
|
|
33
|
+
// Match by name first, then fall back to type
|
|
34
|
+
const cc = cfgArray.find(c => (c.connection_name || c.name) === ycName)
|
|
35
|
+
|| cfgArray.find(c => (c.connection_type || c.type) === ycType);
|
|
36
|
+
|
|
37
|
+
if (!cc) {
|
|
38
|
+
return { id: i + 1, name: ycName, type: ycType, status: "active", authConfig: "{}", config: "{}" };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const { connection_name, connection_type, name, type, ...creds } = cc;
|
|
42
|
+
const resolvedName = connection_name || name || ycName;
|
|
43
|
+
const resolvedType = connection_type || type || ycType;
|
|
44
|
+
|
|
45
|
+
// Expand privateKeyPath → inline PEM (normalize CRLF for ssh2)
|
|
46
|
+
if (creds.privateKeyPath) {
|
|
47
|
+
const keyPath = creds.privateKeyPath.replace(/^~/, os.homedir());
|
|
48
|
+
creds.privateKey = fs.readFileSync(keyPath, "utf8").replace(/\r\n/g, "\n");
|
|
49
|
+
delete creds.privateKeyPath;
|
|
50
|
+
}
|
|
51
|
+
if (creds.privateKey) {
|
|
52
|
+
creds.privateKey = creds.privateKey.replace(/\\n/g, "\n").replace(/\r\n/g, "\n");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
id: i + 1,
|
|
57
|
+
name: resolvedName,
|
|
58
|
+
type: resolvedType,
|
|
59
|
+
status: "active",
|
|
60
|
+
authConfig: JSON.stringify(creds),
|
|
61
|
+
config: JSON.stringify(creds),
|
|
62
|
+
};
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = { prepareConnectors };
|