@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,443 @@
|
|
|
1
|
+
const router = require("express").Router();
|
|
2
|
+
const { authenticate, requireManagerOrAdmin } = require("../middleware/auth");
|
|
3
|
+
const { deleteDocumentChunks } = require("../utils/vectorStore");
|
|
4
|
+
const { getLLMClient, getSetting } = require("../providers/llm");
|
|
5
|
+
const { getTierFromDB } = require("../utils/tier");
|
|
6
|
+
const ingestionQueue = require("../utils/ingestionQueue");
|
|
7
|
+
const { v4: uuidv4 } = require("uuid");
|
|
8
|
+
const multer = require("multer");
|
|
9
|
+
const path = require("path");
|
|
10
|
+
const fs = require("fs");
|
|
11
|
+
|
|
12
|
+
const UPLOAD_DIR = path.join(__dirname, "../../storage/uploads/");
|
|
13
|
+
const OCR_UPLOAD_DIR = path.join(__dirname, "../../storage/ocr-uploads/");
|
|
14
|
+
const upload = multer({ dest: UPLOAD_DIR });
|
|
15
|
+
const uploadOcr = multer({
|
|
16
|
+
storage: multer.diskStorage({
|
|
17
|
+
destination: (req, file, cb) => { fs.mkdirSync(OCR_UPLOAD_DIR, { recursive: true }); cb(null, OCR_UPLOAD_DIR); },
|
|
18
|
+
filename: (req, file, cb) => { cb(null, require("uuid").v4() + path.extname(file.originalname).toLowerCase()); }
|
|
19
|
+
}),
|
|
20
|
+
limits: { fileSize: 20 * 1024 * 1024 }
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// Storage info for the documents panel
|
|
24
|
+
router.get("/:slug/storage-info", authenticate, async (req, res) => {
|
|
25
|
+
try {
|
|
26
|
+
const tier = await getTierFromDB(req.db);
|
|
27
|
+
const maxFileSizeSetting = await req.db.setting.findUnique({ where: { key: "storage.maxFileSizeMb" } });
|
|
28
|
+
const maxFileSizeMb = maxFileSizeSetting?.value ? parseInt(maxFileSizeSetting.value) : 100;
|
|
29
|
+
const result = await req.db.document.aggregate({ _sum: { size: true } });
|
|
30
|
+
const usedBytes = result._sum.size || 0;
|
|
31
|
+
res.json({
|
|
32
|
+
usedBytes,
|
|
33
|
+
usedGb: parseFloat((usedBytes / (1024 ** 3)).toFixed(3)),
|
|
34
|
+
limitGb: isFinite(tier.ingestionSpaceGb) ? tier.ingestionSpaceGb : null,
|
|
35
|
+
maxFileSizeMb,
|
|
36
|
+
});
|
|
37
|
+
} catch (err) {
|
|
38
|
+
console.error("[documents] storage-info error:", err.message);
|
|
39
|
+
res.status(500).json({ error: err.message });
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// List documents for a workspace
|
|
44
|
+
router.get("/:slug", authenticate, async (req, res) => {
|
|
45
|
+
const workspace = await req.db.workspace.findUnique({ where: { slug: req.params.slug } });
|
|
46
|
+
if (!workspace) return res.status(404).json({ error: "Workspace not found" });
|
|
47
|
+
const documents = await req.db.document.findMany({
|
|
48
|
+
where: { workspaceId: workspace.id },
|
|
49
|
+
orderBy: { createdAt: "desc" }
|
|
50
|
+
});
|
|
51
|
+
res.json({ documents });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Upload file(s)
|
|
55
|
+
router.post("/:slug/upload", authenticate, requireManagerOrAdmin, upload.single("file"), async (req, res) => {
|
|
56
|
+
const workspace = await req.db.workspace.findUnique({ where: { slug: req.params.slug } });
|
|
57
|
+
if (!workspace) return res.status(404).json({ error: "Workspace not found" });
|
|
58
|
+
if (!req.file) return res.status(400).json({ error: "No file uploaded" });
|
|
59
|
+
|
|
60
|
+
// Enforce max file size (default 100 MB, overridable by super admin)
|
|
61
|
+
const maxFileSizeSetting = await req.db.setting.findUnique({ where: { key: "storage.maxFileSizeMb" } });
|
|
62
|
+
const maxFileSizeMb = maxFileSizeSetting?.value ? parseInt(maxFileSizeSetting.value) : 100;
|
|
63
|
+
if (req.file.size > maxFileSizeMb * 1024 * 1024) {
|
|
64
|
+
fs.unlink(req.file.path, () => {});
|
|
65
|
+
return res.status(400).json({ error: `File too large. Max allowed: ${maxFileSizeMb} MB` });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Enforce storage limit
|
|
69
|
+
const tier = await getTierFromDB(req.db);
|
|
70
|
+
if (isFinite(tier.ingestionSpaceGb)) {
|
|
71
|
+
const result = await req.db.document.aggregate({ _sum: { size: true } });
|
|
72
|
+
const usedBytes = result._sum.size || 0;
|
|
73
|
+
const limitBytes = tier.ingestionSpaceGb * 1024 * 1024 * 1024;
|
|
74
|
+
if (usedBytes + req.file.size > limitBytes) {
|
|
75
|
+
fs.unlink(req.file.path, () => {});
|
|
76
|
+
const usedGb = (usedBytes / (1024 ** 3)).toFixed(2);
|
|
77
|
+
return res.status(400).json({ error: `Storage limit reached (${usedGb} / ${tier.ingestionSpaceGb} GB used)` });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const uid = uuidv4();
|
|
82
|
+
const batchId = req.headers["x-batch-id"] ? `upload:${req.headers["x-batch-id"]}` : null;
|
|
83
|
+
const doc = await req.db.document.create({
|
|
84
|
+
data: {
|
|
85
|
+
uid,
|
|
86
|
+
name: req.file.originalname,
|
|
87
|
+
type: req.file.mimetype,
|
|
88
|
+
size: req.file.size,
|
|
89
|
+
workspaceId: workspace.id,
|
|
90
|
+
status: "queued",
|
|
91
|
+
sourcePath: req.file.path,
|
|
92
|
+
batchId
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
ingestionQueue.enqueue(req.db, workspace, doc, req.file.path, "file", false, req.user.id);
|
|
97
|
+
res.json({ document: doc });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Ingest URL
|
|
101
|
+
router.post("/:slug/ingest-url", authenticate, requireManagerOrAdmin, handleUrlIngest);
|
|
102
|
+
router.post("/:slug/url", authenticate, requireManagerOrAdmin, handleUrlIngest);
|
|
103
|
+
|
|
104
|
+
async function handleUrlIngest(req, res) {
|
|
105
|
+
const { url } = req.body;
|
|
106
|
+
if (!url) return res.status(400).json({ error: "URL required" });
|
|
107
|
+
|
|
108
|
+
const workspace = await req.db.workspace.findUnique({ where: { slug: req.params.slug } });
|
|
109
|
+
if (!workspace) return res.status(404).json({ error: "Workspace not found" });
|
|
110
|
+
|
|
111
|
+
const uid = uuidv4();
|
|
112
|
+
const doc = await req.db.document.create({
|
|
113
|
+
data: { uid, name: url, type: "url", workspaceId: workspace.id, status: "queued" }
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
ingestionQueue.enqueue(req.db, workspace, doc, url, "url", false, req.user.id);
|
|
117
|
+
res.json({ document: doc });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Ingest GitHub repository
|
|
121
|
+
router.post("/:slug/ingest-github", authenticate, requireManagerOrAdmin, async (req, res) => {
|
|
122
|
+
const { repoUrl, token, branch = "main" } = req.body;
|
|
123
|
+
if (!repoUrl) return res.status(400).json({ error: "repoUrl required" });
|
|
124
|
+
|
|
125
|
+
// Parse owner/repo from URL or shorthand
|
|
126
|
+
let owner, repo;
|
|
127
|
+
try {
|
|
128
|
+
const cleaned = repoUrl.replace(/\.git$/, "").replace(/\/$/, "");
|
|
129
|
+
const match = cleaned.match(/github\.com\/([^/]+)\/([^/]+)/) || cleaned.match(/^([^/]+)\/([^/]+)$/);
|
|
130
|
+
if (!match) throw new Error();
|
|
131
|
+
[, owner, repo] = match;
|
|
132
|
+
} catch {
|
|
133
|
+
return res.status(400).json({ error: "Invalid GitHub repo URL. Use format: https://github.com/owner/repo or owner/repo" });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const workspace = await req.db.workspace.findUnique({ where: { slug: req.params.slug } });
|
|
137
|
+
if (!workspace) return res.status(404).json({ error: "Workspace not found" });
|
|
138
|
+
|
|
139
|
+
const axios = require("axios");
|
|
140
|
+
const os = require("os");
|
|
141
|
+
const headers = token ? { Authorization: `Bearer ${token}`, "User-Agent": "OpenEnthrium" } : { "User-Agent": "OpenEnthrium" };
|
|
142
|
+
|
|
143
|
+
// Supported text/code extensions
|
|
144
|
+
const TEXT_EXTS = new Set([
|
|
145
|
+
".js",".jsx",".ts",".tsx",".mjs",".cjs",
|
|
146
|
+
".py",".rb",".go",".java",".cs",".php",".swift",".kt",".rs",".cpp",".c",".h",".hpp",
|
|
147
|
+
".md",".txt",".rst",".adoc",".mdx",
|
|
148
|
+
".json",".yaml",".yml",".toml",".ini",".env",".conf",".config",
|
|
149
|
+
".sh",".bash",".zsh",".fish",
|
|
150
|
+
".sql",".html",".css",".scss",".sass",".less",".xml",".csv",
|
|
151
|
+
".tf",".hcl",".dockerfile",
|
|
152
|
+
]);
|
|
153
|
+
const SKIP_DIRS = new Set(["node_modules",".git","dist","build","vendor",".next","__pycache__",".cache","coverage"]);
|
|
154
|
+
|
|
155
|
+
let tree;
|
|
156
|
+
try {
|
|
157
|
+
const { data } = await axios.get(
|
|
158
|
+
`https://api.github.com/repos/${owner}/${repo}/git/trees/${branch}?recursive=1`,
|
|
159
|
+
{ headers }
|
|
160
|
+
);
|
|
161
|
+
tree = data.tree;
|
|
162
|
+
} catch (err) {
|
|
163
|
+
const msg = err.response?.status === 404
|
|
164
|
+
? `Repo "${owner}/${repo}" not found (or branch "${branch}" doesn't exist)`
|
|
165
|
+
: err.response?.status === 401 ? "Invalid GitHub token"
|
|
166
|
+
: `GitHub API error: ${err.response?.data?.message || err.message}`;
|
|
167
|
+
return res.status(400).json({ error: msg });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Filter to text files, skip ignored dirs and large blobs
|
|
171
|
+
const files = tree.filter(f => {
|
|
172
|
+
if (f.type !== "blob") return false;
|
|
173
|
+
const parts = f.path.split("/");
|
|
174
|
+
if (parts.some(p => SKIP_DIRS.has(p))) return false;
|
|
175
|
+
const ext = path.extname(f.path).toLowerCase();
|
|
176
|
+
if (!TEXT_EXTS.has(ext) && !["dockerfile","makefile","procfile","gemfile","rakefile","cmakelists.txt"].includes(path.basename(f.path).toLowerCase())) return false;
|
|
177
|
+
if (f.size && f.size > 500_000) return false; // skip >500KB files
|
|
178
|
+
return true;
|
|
179
|
+
}).slice(0, 200); // max 200 files per ingest
|
|
180
|
+
|
|
181
|
+
if (!files.length) return res.status(400).json({ error: "No supported text/code files found in repository" });
|
|
182
|
+
|
|
183
|
+
const tmpDir = path.join(os.tmpdir(), `oe-github-${Date.now()}`);
|
|
184
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
185
|
+
|
|
186
|
+
let queued = 0, skipped = 0;
|
|
187
|
+
for (const file of files) {
|
|
188
|
+
try {
|
|
189
|
+
const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${file.path}`;
|
|
190
|
+
const { data: content } = await axios.get(rawUrl, { headers, responseType: "text" });
|
|
191
|
+
|
|
192
|
+
const safeFilename = file.path.replace(/\//g, "__");
|
|
193
|
+
const tmpPath = path.join(tmpDir, safeFilename);
|
|
194
|
+
fs.writeFileSync(tmpPath, content, "utf8");
|
|
195
|
+
const stat = fs.statSync(tmpPath);
|
|
196
|
+
|
|
197
|
+
const uid = uuidv4();
|
|
198
|
+
const doc = await req.db.document.create({
|
|
199
|
+
data: {
|
|
200
|
+
uid,
|
|
201
|
+
name: `${owner}/${repo}/${file.path}`,
|
|
202
|
+
type: path.extname(file.path).slice(1) || "txt",
|
|
203
|
+
size: stat.size,
|
|
204
|
+
workspaceId: workspace.id,
|
|
205
|
+
status: "queued",
|
|
206
|
+
sourcePath: tmpPath,
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
ingestionQueue.enqueue(req.db, workspace, doc, tmpPath, "file", false, req.user.id);
|
|
210
|
+
queued++;
|
|
211
|
+
} catch { skipped++; }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
res.json({ queued, skipped, total: files.length, repo: `${owner}/${repo}`, branch });
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// Google Drive folder picker removed — cloud connector sync is no longer supported
|
|
218
|
+
router.get("/:slug/gdrive-folders", authenticate, requireManagerOrAdmin, (req, res) => {
|
|
219
|
+
res.json({ folders: [] });
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// Cloud connector endpoints removed — cloud sync via connectors is no longer supported
|
|
223
|
+
router.get("/:slug/cloud-connectors", authenticate, requireManagerOrAdmin, (req, res) => {
|
|
224
|
+
res.json({ connectors: [] });
|
|
225
|
+
});
|
|
226
|
+
router.get("/:slug/gdrive-connectors", authenticate, requireManagerOrAdmin, (req, res) => {
|
|
227
|
+
res.json({ connectors: [] });
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// Google Drive ingest removed — cloud connector sync is no longer supported
|
|
231
|
+
router.post("/:slug/ingest-gdrive-folder", authenticate, requireManagerOrAdmin, (req, res) => {
|
|
232
|
+
res.status(410).json({ error: "Cloud connector sync has been removed." });
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// Ingest local folder
|
|
236
|
+
router.post("/:slug/ingest-folder", authenticate, requireManagerOrAdmin, async (req, res) => {
|
|
237
|
+
const { folderPath } = req.body;
|
|
238
|
+
if (!folderPath) return res.status(400).json({ error: "folderPath required" });
|
|
239
|
+
|
|
240
|
+
const workspace = await req.db.workspace.findUnique({ where: { slug: req.params.slug } });
|
|
241
|
+
if (!workspace) return res.status(404).json({ error: "Workspace not found" });
|
|
242
|
+
|
|
243
|
+
const SUPPORTED = [".pdf", ".doc", ".docx", ".txt", ".md", ".csv", ".xlsx", ".xls", ".json"];
|
|
244
|
+
|
|
245
|
+
function scanDir(dir) {
|
|
246
|
+
let results = [];
|
|
247
|
+
let entries;
|
|
248
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
|
249
|
+
catch { return results; }
|
|
250
|
+
for (const e of entries) {
|
|
251
|
+
const full = path.join(dir, e.name);
|
|
252
|
+
if (e.isDirectory()) results.push(...scanDir(full));
|
|
253
|
+
else if (e.isFile() && SUPPORTED.includes(path.extname(e.name).toLowerCase())) results.push(full);
|
|
254
|
+
}
|
|
255
|
+
return results;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
let files;
|
|
259
|
+
try { files = scanDir(folderPath); }
|
|
260
|
+
catch { return res.status(400).json({ error: "Folder not found or not accessible: " + folderPath }); }
|
|
261
|
+
|
|
262
|
+
if (!files.length) return res.status(400).json({ error: "No supported files found in folder" });
|
|
263
|
+
|
|
264
|
+
const folderBatchId = `folder:${folderPath.trim()}`;
|
|
265
|
+
const docs = await Promise.all(files.map(async filePath => {
|
|
266
|
+
const name = path.basename(filePath);
|
|
267
|
+
const uid = uuidv4();
|
|
268
|
+
const stat = fs.statSync(filePath);
|
|
269
|
+
const doc = await req.db.document.create({
|
|
270
|
+
data: {
|
|
271
|
+
uid,
|
|
272
|
+
name,
|
|
273
|
+
type: path.extname(name).slice(1),
|
|
274
|
+
size: stat.size,
|
|
275
|
+
workspaceId: workspace.id,
|
|
276
|
+
status: "queued",
|
|
277
|
+
sourcePath: filePath,
|
|
278
|
+
batchId: folderBatchId
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
ingestionQueue.enqueue(req.db, workspace, doc, filePath, "file", true, req.user.id);
|
|
282
|
+
return doc;
|
|
283
|
+
}));
|
|
284
|
+
|
|
285
|
+
res.json({ queued: docs.length, documents: docs });
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// OCR — save image immediately, queue LLM Vision extraction as a background job
|
|
289
|
+
router.post("/:slug/ocr", authenticate, requireManagerOrAdmin, uploadOcr.single("image"), async (req, res) => {
|
|
290
|
+
if (!req.file) return res.status(400).json({ error: "Image file required" });
|
|
291
|
+
|
|
292
|
+
const ALLOWED = ["image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp", "image/tiff", "image/bmp"];
|
|
293
|
+
if (!ALLOWED.includes(req.file.mimetype)) {
|
|
294
|
+
fs.unlink(req.file.path, () => {});
|
|
295
|
+
return res.status(400).json({ error: "Unsupported image type" });
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const workspace = await req.db.workspace.findUnique({ where: { slug: req.params.slug } });
|
|
299
|
+
if (!workspace) { fs.unlink(req.file.path, () => {}); return res.status(404).json({ error: "Workspace not found" }); }
|
|
300
|
+
|
|
301
|
+
const docName = req.file.originalname.replace(/\.[^.]+$/, "") + " (OCR)";
|
|
302
|
+
const uid = uuidv4();
|
|
303
|
+
const batchId = req.headers["x-batch-id"] ? `ocr:${req.headers["x-batch-id"]}` : null;
|
|
304
|
+
const doc = await req.db.document.create({
|
|
305
|
+
data: { uid, name: docName, type: "ocr", size: req.file.size, workspaceId: workspace.id, status: "queued", sourcePath: req.file.path, uploadedByUserId: req.user.id, batchId }
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// LLM Vision call happens inside the queue worker — client gets response immediately
|
|
309
|
+
ingestionQueue.enqueue(req.db, workspace, doc, req.file.path, "ocr", false, req.user.id);
|
|
310
|
+
res.json({ document: doc });
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// Ingest entire website (BFS crawl) — queued immediately, crawl runs in background
|
|
314
|
+
router.post("/:slug/ingest-website", authenticate, requireManagerOrAdmin, async (req, res) => {
|
|
315
|
+
const { startUrl, maxPages = 20, maxDepth = 2 } = req.body;
|
|
316
|
+
if (!startUrl) return res.status(400).json({ error: "startUrl required" });
|
|
317
|
+
|
|
318
|
+
try { new URL(startUrl); } catch { return res.status(400).json({ error: "Invalid URL" }); }
|
|
319
|
+
|
|
320
|
+
const workspace = await req.db.workspace.findUnique({ where: { slug: req.params.slug } });
|
|
321
|
+
if (!workspace) return res.status(404).json({ error: "Workspace not found" });
|
|
322
|
+
|
|
323
|
+
const uid = uuidv4();
|
|
324
|
+
const crawlParams = JSON.stringify({ startUrl, maxPages: parseInt(maxPages), maxDepth: parseInt(maxDepth) });
|
|
325
|
+
const doc = await req.db.document.create({
|
|
326
|
+
data: { uid, name: `Website: ${startUrl}`, type: "website-crawl", workspaceId: workspace.id, status: "queued", sourcePath: crawlParams, uploadedByUserId: req.user.id }
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// BFS crawl + per-page ingestion happens entirely in the queue worker
|
|
330
|
+
ingestionQueue.enqueue(req.db, workspace, doc, crawlParams, "website-crawl", false, req.user.id);
|
|
331
|
+
res.json({ document: doc });
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// Cancel in-progress ingestion
|
|
335
|
+
router.post("/:slug/:uid/cancel", authenticate, requireManagerOrAdmin, async (req, res) => {
|
|
336
|
+
const workspace = await req.db.workspace.findUnique({ where: { slug: req.params.slug } });
|
|
337
|
+
if (!workspace) return res.status(404).json({ error: "Workspace not found" });
|
|
338
|
+
|
|
339
|
+
const doc = await req.db.document.findFirst({
|
|
340
|
+
where: { uid: req.params.uid, workspaceId: workspace.id }
|
|
341
|
+
});
|
|
342
|
+
if (!doc) return res.status(404).json({ error: "Document not found" });
|
|
343
|
+
if (!["queued", "ingesting"].includes(doc.status)) {
|
|
344
|
+
return res.status(400).json({ error: "Document is not currently being ingested" });
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// If still queued (not yet picked up), mark partial/failed immediately
|
|
348
|
+
if (doc.status === "queued") {
|
|
349
|
+
await req.db.document.update({
|
|
350
|
+
where: { uid: doc.uid },
|
|
351
|
+
data: { status: "failed", errorMessage: "Cancelled before ingestion started" }
|
|
352
|
+
});
|
|
353
|
+
return res.json({ cancelled: true });
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// If already ingesting, set the flag and the queue worker will stop between batches
|
|
357
|
+
await req.db.document.update({
|
|
358
|
+
where: { uid: doc.uid },
|
|
359
|
+
data: { cancelRequested: true }
|
|
360
|
+
});
|
|
361
|
+
res.json({ cancelling: true });
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// Retry a failed/partial document
|
|
365
|
+
router.post("/:slug/:uid/retry", authenticate, requireManagerOrAdmin, async (req, res) => {
|
|
366
|
+
const workspace = await req.db.workspace.findUnique({ where: { slug: req.params.slug } });
|
|
367
|
+
if (!workspace) return res.status(404).json({ error: "Workspace not found" });
|
|
368
|
+
|
|
369
|
+
const doc = await req.db.document.findFirst({ where: { uid: req.params.uid, workspaceId: workspace.id } });
|
|
370
|
+
if (!doc) return res.status(404).json({ error: "Document not found" });
|
|
371
|
+
|
|
372
|
+
if (doc.type !== "url" && (!doc.sourcePath || !fs.existsSync(doc.sourcePath))) {
|
|
373
|
+
return res.status(400).json({ error: "Source file no longer available. Please re-upload." });
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Delete any partial vectors first
|
|
377
|
+
await deleteDocumentChunks(workspace.slug, doc.uid);
|
|
378
|
+
|
|
379
|
+
const updated = await req.db.document.update({
|
|
380
|
+
where: { uid: doc.uid },
|
|
381
|
+
data: { status: "queued", chunkCount: 0, chunksProcessed: 0, totalChunks: 0, cancelRequested: false, errorMessage: null }
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
const source = doc.type === "url" ? doc.name : doc.sourcePath;
|
|
385
|
+
const sourceType = doc.type === "url" ? "url" : "file";
|
|
386
|
+
const keepFile = doc.sourcePath && !doc.sourcePath.includes("uploads");
|
|
387
|
+
ingestionQueue.enqueue(req.db, workspace, updated, source, sourceType, keepFile, req.user.id);
|
|
388
|
+
res.json({ document: updated });
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// Cloud storage ingestion removed — cloud connector sync is no longer supported
|
|
392
|
+
router.post("/:slug/ingest-cloud-folder", authenticate, requireManagerOrAdmin, (req, res) => {
|
|
393
|
+
res.status(410).json({ error: "Cloud connector sync has been removed." });
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// Retry all failed/partial documents in a workspace
|
|
397
|
+
router.post("/:slug/retry-all", authenticate, requireManagerOrAdmin, async (req, res) => {
|
|
398
|
+
const workspace = await req.db.workspace.findUnique({ where: { slug: req.params.slug } });
|
|
399
|
+
if (!workspace) return res.status(404).json({ error: "Workspace not found" });
|
|
400
|
+
|
|
401
|
+
const docs = await req.db.document.findMany({
|
|
402
|
+
where: { workspaceId: workspace.id, status: { in: ["failed", "partial"] } }
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
let queued = 0, skipped = 0;
|
|
406
|
+
for (const doc of docs) {
|
|
407
|
+
const source = doc.type === "url" ? doc.name : doc.sourcePath;
|
|
408
|
+
const sourceType = doc.type === "url" ? "url" : doc.type === "ocr" ? "ocr" : "file";
|
|
409
|
+
if (sourceType !== "url" && (!source || !fs.existsSync(source))) { skipped++; continue; }
|
|
410
|
+
|
|
411
|
+
await deleteDocumentChunks(workspace.slug, doc.uid);
|
|
412
|
+
const updated = await req.db.document.update({
|
|
413
|
+
where: { uid: doc.uid },
|
|
414
|
+
data: { status: "queued", chunkCount: 0, chunksProcessed: 0, totalChunks: 0, cancelRequested: false, errorMessage: null }
|
|
415
|
+
});
|
|
416
|
+
const keepFile = source && !source.includes("uploads");
|
|
417
|
+
ingestionQueue.enqueue(req.db, workspace, updated, source, sourceType, keepFile, req.user.id);
|
|
418
|
+
queued++;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
res.json({ queued, skipped });
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
// Delete document + its vectors
|
|
425
|
+
router.delete("/:slug/:uid", authenticate, requireManagerOrAdmin, async (req, res) => {
|
|
426
|
+
const workspace = await req.db.workspace.findUnique({ where: { slug: req.params.slug } });
|
|
427
|
+
if (!workspace) return res.status(404).json({ error: "Workspace not found" });
|
|
428
|
+
|
|
429
|
+
const doc = await req.db.document.findFirst({ where: { uid: req.params.uid, workspaceId: workspace.id } });
|
|
430
|
+
|
|
431
|
+
// If the doc is currently ingesting, set cancel flag first
|
|
432
|
+
if (doc && doc.status === "ingesting") {
|
|
433
|
+
await req.db.document.update({ where: { uid: doc.uid }, data: { cancelRequested: true } });
|
|
434
|
+
// Small delay to let the current batch finish before deletion
|
|
435
|
+
await new Promise(r => setTimeout(r, 500));
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
await deleteDocumentChunks(workspace.slug, req.params.uid);
|
|
439
|
+
await req.db.document.deleteMany({ where: { uid: req.params.uid, workspaceId: workspace.id } });
|
|
440
|
+
res.json({ success: true });
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
module.exports = router;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
const router = require("express").Router();
|
|
2
|
+
const rateLimit = require("express-rate-limit");
|
|
3
|
+
const { similaritySearch } = require("../utils/vectorStore");
|
|
4
|
+
const { getLLMClient, getSetting } = require("../providers/llm");
|
|
5
|
+
|
|
6
|
+
router.use(rateLimit({ windowMs: 60 * 1000, max: 20, standardHeaders: true, legacyHeaders: false,
|
|
7
|
+
message: { error: "Too many requests. Please wait a moment before sending another message." } }));
|
|
8
|
+
|
|
9
|
+
// GET /api/embed/:slug/config — public workspace info for the embed page
|
|
10
|
+
router.get("/:slug/config", async (req, res) => {
|
|
11
|
+
const ws = await req.db.workspace.findUnique({ where: { slug: req.params.slug } });
|
|
12
|
+
if (!ws || !ws.embedEnabled) return res.status(404).json({ error: "Workspace not found" });
|
|
13
|
+
res.json({
|
|
14
|
+
name: ws.name,
|
|
15
|
+
starterPrompts: (() => { try { return JSON.parse(ws.starterPrompts || "[]"); } catch { return []; } })(),
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// POST /api/embed/:slug/chat — public SSE streaming chat (anonymous, embedEnabled only)
|
|
20
|
+
router.post("/:slug/chat", async (req, res) => {
|
|
21
|
+
const { message } = req.body;
|
|
22
|
+
if (!message) return res.status(400).json({ error: "message required" });
|
|
23
|
+
|
|
24
|
+
const workspace = await req.db.workspace.findUnique({ where: { slug: req.params.slug } });
|
|
25
|
+
if (!workspace || !workspace.embedEnabled) return res.status(404).json({ error: "Workspace not found" });
|
|
26
|
+
|
|
27
|
+
const temperature = workspace.temperature ?? 0.7;
|
|
28
|
+
const topK = parseInt((await getSetting("rag_top_k")) || "15");
|
|
29
|
+
const kbShares = await req.db.workspaceKBShare.findMany({
|
|
30
|
+
where: { targetWorkspaceId: workspace.id },
|
|
31
|
+
include: { sourceWorkspace: { select: { slug: true, name: true } } },
|
|
32
|
+
});
|
|
33
|
+
const [ownSources, ...sharedResults] = await Promise.all([
|
|
34
|
+
similaritySearch(workspace.slug, message, topK),
|
|
35
|
+
...kbShares.map(s => similaritySearch(s.sourceWorkspace.slug, message, topK).catch(() => [])),
|
|
36
|
+
]);
|
|
37
|
+
const sources = [...ownSources, ...sharedResults.flat()]
|
|
38
|
+
.sort((a, b) => (b.score || 0) - (a.score || 0))
|
|
39
|
+
.slice(0, topK);
|
|
40
|
+
const context = sources.map((s, i) => `[Source ${i + 1}]: ${s.text}`).join("\n\n");
|
|
41
|
+
|
|
42
|
+
const basePrompt = workspace.systemPrompt ||
|
|
43
|
+
`You are a knowledgeable assistant for this workspace. Follow these rules strictly:
|
|
44
|
+
- Answer questions ONLY based on the documents provided in the context below.
|
|
45
|
+
- Do NOT use any general knowledge or outside information.
|
|
46
|
+
- Provide thorough, complete, and well-structured answers.`;
|
|
47
|
+
|
|
48
|
+
const refusalMsg = workspace.queryRefusalResponse || "There is no relevant information in this workspace to answer your query.";
|
|
49
|
+
const systemPrompt = context.length > 0
|
|
50
|
+
? `${basePrompt}\n\n--- Ingested Document Context ---\nThe following content was extracted from files uploaded/ingested into this workspace.\n\n${context}\n--- End of Ingested Context ---\n\nIf the answer cannot be found in the ingested context above, respond with: "${refusalMsg}"`
|
|
51
|
+
: `${basePrompt}\n\nNo ingested documents found. Respond with: "${refusalMsg}"`;
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
55
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
56
|
+
res.setHeader("Connection", "keep-alive");
|
|
57
|
+
|
|
58
|
+
const { provider, client } = await getLLMClient();
|
|
59
|
+
const model = (await getSetting("llm_model")) || process.env.OPENAI_MODEL || "gpt-4o";
|
|
60
|
+
let fullResponse = "";
|
|
61
|
+
|
|
62
|
+
if (provider === "anthropic") {
|
|
63
|
+
const stream = await client.messages.create({
|
|
64
|
+
model: model || "claude-3-5-sonnet-20241022",
|
|
65
|
+
max_tokens: 4096,
|
|
66
|
+
system: systemPrompt,
|
|
67
|
+
messages: [{ role: "user", content: message }],
|
|
68
|
+
temperature,
|
|
69
|
+
stream: true,
|
|
70
|
+
});
|
|
71
|
+
for await (const event of stream) {
|
|
72
|
+
if (event.type === "content_block_delta" && event.delta?.type === "text_delta") {
|
|
73
|
+
fullResponse += event.delta.text;
|
|
74
|
+
res.write(`data: ${JSON.stringify({ chunk: event.delta.text })}\n\n`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
} else {
|
|
78
|
+
const stream = await client.chat.completions.create({
|
|
79
|
+
model,
|
|
80
|
+
messages: [{ role: "system", content: systemPrompt }, { role: "user", content: message }],
|
|
81
|
+
temperature,
|
|
82
|
+
max_tokens: 4096,
|
|
83
|
+
stream: true,
|
|
84
|
+
});
|
|
85
|
+
for await (const part of stream) {
|
|
86
|
+
const chunk = part.choices?.[0]?.delta?.content || "";
|
|
87
|
+
if (chunk) {
|
|
88
|
+
fullResponse += chunk;
|
|
89
|
+
res.write(`data: ${JSON.stringify({ chunk })}\n\n`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
res.write(`data: ${JSON.stringify({ done: true, sources })}\n\n`);
|
|
95
|
+
res.end();
|
|
96
|
+
} catch (err) {
|
|
97
|
+
console.error("[embed chat]", err.message);
|
|
98
|
+
res.write(`data: ${JSON.stringify({ error: "Failed to get response." })}\n\n`);
|
|
99
|
+
res.end();
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
module.exports = router;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
const router = require("express").Router();
|
|
2
|
+
const { authenticate } = require("../middleware/auth");
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const fs = require("fs");
|
|
5
|
+
|
|
6
|
+
const SAMPLES_DIR = path.resolve(__dirname, "../../cli/samples");
|
|
7
|
+
|
|
8
|
+
function readSamples() {
|
|
9
|
+
if (!fs.existsSync(SAMPLES_DIR)) return [];
|
|
10
|
+
return fs.readdirSync(SAMPLES_DIR)
|
|
11
|
+
.filter(f => fs.statSync(path.join(SAMPLES_DIR, f)).isDirectory())
|
|
12
|
+
.map(folder => {
|
|
13
|
+
const yamlPath = path.join(SAMPLES_DIR, folder, "agent.yaml");
|
|
14
|
+
const configPath = path.join(SAMPLES_DIR, folder, "oe-config.json");
|
|
15
|
+
const yaml = fs.existsSync(yamlPath) ? fs.readFileSync(yamlPath, "utf8") : null;
|
|
16
|
+
const config = fs.existsSync(configPath) ? fs.readFileSync(configPath, "utf8") : null;
|
|
17
|
+
return { folder, yaml, config };
|
|
18
|
+
})
|
|
19
|
+
.filter(s => s.yaml);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// GET /api/marketplace/samples
|
|
23
|
+
router.get("/samples", authenticate, (req, res) => {
|
|
24
|
+
try {
|
|
25
|
+
res.json({ samples: readSamples() });
|
|
26
|
+
} catch (e) {
|
|
27
|
+
res.status(500).json({ error: e.message });
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// GET /api/marketplace/samples/:folder/yaml
|
|
32
|
+
router.get("/samples/:folder/yaml", authenticate, (req, res) => {
|
|
33
|
+
const file = path.join(SAMPLES_DIR, req.params.folder, "agent.yaml");
|
|
34
|
+
if (!fs.existsSync(file)) return res.status(404).json({ error: "Not found" });
|
|
35
|
+
res.setHeader("Content-Type", "text/yaml");
|
|
36
|
+
res.setHeader("Content-Disposition", `attachment; filename="${req.params.folder}.yaml"`);
|
|
37
|
+
res.send(fs.readFileSync(file, "utf8"));
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// GET /api/marketplace/samples/:folder/config
|
|
41
|
+
router.get("/samples/:folder/config", authenticate, (req, res) => {
|
|
42
|
+
const file = path.join(SAMPLES_DIR, req.params.folder, "oe-config.json");
|
|
43
|
+
if (!fs.existsSync(file)) return res.status(404).json({ error: "Not found" });
|
|
44
|
+
res.setHeader("Content-Type", "application/json");
|
|
45
|
+
res.setHeader("Content-Disposition", `attachment; filename="${req.params.folder}.oe-config.json"`);
|
|
46
|
+
res.send(fs.readFileSync(file, "utf8"));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
module.exports = router;
|