@frumu/tandem-panel 0.4.31 → 0.4.33
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/.env.example +13 -0
- package/bin/setup.js +319 -58
- package/dist/assets/{index-Bk_1J3q1.css → index-B2g55O4_.css} +1 -1
- package/dist/assets/{index-Cm325d0n.js → index-DlJxMgEA.js} +53 -53
- package/dist/index.html +2 -2
- package/lib/setup/paths.js +11 -0
- package/package.json +3 -3
- package/server/routes/knowledgebase.js +157 -0
package/.env.example
CHANGED
|
@@ -94,3 +94,16 @@ ACA_PROBE_TIMEOUT_MS=5000
|
|
|
94
94
|
# How long to cache capability probe results, in milliseconds (default: 45000 = 45 s).
|
|
95
95
|
# Set lower for faster ACA availability detection, higher to reduce probe frequency.
|
|
96
96
|
ACA_CAPABILITY_CACHE_TTL_MS=45000
|
|
97
|
+
|
|
98
|
+
# ─── Knowledgebase MCP Proxy ─────────────────────────────────────────
|
|
99
|
+
# Local KB admin endpoint exposed by the hosted/provisioned bundle.
|
|
100
|
+
TANDEM_KB_ADMIN_URL=
|
|
101
|
+
|
|
102
|
+
# Optional path to a file containing the KB admin API key.
|
|
103
|
+
TANDEM_KB_ADMIN_API_KEY_FILE=
|
|
104
|
+
|
|
105
|
+
# Optional KB admin API key value. The file value wins if both are set.
|
|
106
|
+
TANDEM_KB_ADMIN_API_KEY=
|
|
107
|
+
|
|
108
|
+
# Default KB collection id for provisioned servers.
|
|
109
|
+
TANDEM_KB_DEFAULT_COLLECTION_ID=
|
package/bin/setup.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { spawn } from "child_process";
|
|
4
4
|
import { createServer } from "http";
|
|
5
5
|
import { readFileSync, existsSync, createReadStream, createWriteStream } from "fs";
|
|
6
|
-
import { mkdir, readdir, stat, rm, readFile, writeFile } from "fs/promises";
|
|
6
|
+
import { mkdir, readdir, stat, rm, readFile, rename, writeFile } from "fs/promises";
|
|
7
7
|
import { createHash, randomBytes } from "crypto";
|
|
8
8
|
import { join, dirname, extname, normalize, resolve, basename, relative } from "path";
|
|
9
9
|
import { Transform } from "stream";
|
|
@@ -24,6 +24,7 @@ import { createSwarmApiHandler, getOrchestratorMetrics } from "../server/routes/
|
|
|
24
24
|
import { createAcaApiHandler } from "../server/routes/aca.js";
|
|
25
25
|
import { createCapabilitiesHandler, getCapabilitiesMetrics } from "../server/routes/capabilities.js";
|
|
26
26
|
import { createControlPanelConfigHandler } from "../server/routes/control-panel-config.js";
|
|
27
|
+
import { createKnowledgebaseApiHandler } from "../server/routes/knowledgebase.js";
|
|
27
28
|
import { createControlPanelPreferencesHandler } from "../server/routes/control-panel-preferences.js";
|
|
28
29
|
|
|
29
30
|
function parseDotEnv(content) {
|
|
@@ -50,6 +51,28 @@ function serializeEnv(entries) {
|
|
|
50
51
|
return `${entries.map(([key, value]) => `${key}=${value}`).join("\n")}\n`;
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
async function writeTextFileAtomic(pathname, content) {
|
|
55
|
+
const targetPath = String(pathname || "").trim();
|
|
56
|
+
if (!targetPath) throw new Error("Missing path for atomic write");
|
|
57
|
+
const targetDir = dirname(targetPath);
|
|
58
|
+
await mkdir(targetDir, { recursive: true });
|
|
59
|
+
let mode = 0o640;
|
|
60
|
+
try {
|
|
61
|
+
mode = (await stat(targetPath)).mode & 0o777;
|
|
62
|
+
} catch {}
|
|
63
|
+
const tempPath = join(
|
|
64
|
+
targetDir,
|
|
65
|
+
`${basename(targetPath)}.${process.pid}.${Date.now()}.${randomBytes(6).toString("hex")}.tmp`
|
|
66
|
+
);
|
|
67
|
+
await writeFile(tempPath, content, { encoding: "utf8", mode });
|
|
68
|
+
try {
|
|
69
|
+
await rename(tempPath, targetPath);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
await rm(tempPath, { force: true }).catch(() => {});
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
53
76
|
function loadDotEnvFile(pathname) {
|
|
54
77
|
if (!existsSync(pathname)) return false;
|
|
55
78
|
const parsed = parseDotEnv(readFileSync(pathname, "utf8"));
|
|
@@ -192,6 +215,15 @@ const ENGINE_URL = (
|
|
|
192
215
|
const ACA_BASE_URL = String(process.env.ACA_BASE_URL || "")
|
|
193
216
|
.trim()
|
|
194
217
|
.replace(/\/+$/, "");
|
|
218
|
+
const KB_ADMIN_URL = String(process.env.TANDEM_KB_ADMIN_URL || process.env.KB_ADMIN_URL || "")
|
|
219
|
+
.trim()
|
|
220
|
+
.replace(/\/+$/, "");
|
|
221
|
+
const KB_ADMIN_API_KEY_FILE = String(
|
|
222
|
+
process.env.TANDEM_KB_ADMIN_API_KEY_FILE || process.env.KB_ADMIN_API_KEY_FILE || ""
|
|
223
|
+
).trim();
|
|
224
|
+
const KB_DEFAULT_COLLECTION_ID = String(
|
|
225
|
+
process.env.TANDEM_KB_DEFAULT_COLLECTION_ID || process.env.KB_DEFAULT_COLLECTION_ID || ""
|
|
226
|
+
).trim();
|
|
195
227
|
const CONTROL_PANEL_CONFIG_FILE = String(process.env.TANDEM_CONTROL_PANEL_CONFIG_FILE || "").trim();
|
|
196
228
|
const CONTROL_PANEL_PREFERENCES_FILE = resolveControlPanelPreferencesPath({
|
|
197
229
|
env: process.env,
|
|
@@ -229,11 +261,6 @@ const SESSION_TTL_MS =
|
|
|
229
261
|
const FILES_ROOT = resolve(
|
|
230
262
|
process.env.TANDEM_CONTROL_PANEL_FILES_ROOT || resolveDefaultChannelUploadsRoot()
|
|
231
263
|
);
|
|
232
|
-
const FILES_SCOPE = String(process.env.TANDEM_CONTROL_PANEL_FILES_SCOPE || "control-panel")
|
|
233
|
-
.trim()
|
|
234
|
-
.replace(/\\/g, "/")
|
|
235
|
-
.replace(/^\/+/, "")
|
|
236
|
-
.replace(/\/+$/, "");
|
|
237
264
|
const MAX_UPLOAD_BYTES = Math.max(
|
|
238
265
|
1,
|
|
239
266
|
Number.parseInt(
|
|
@@ -277,6 +304,16 @@ const MIME_TYPES = {
|
|
|
277
304
|
".html": "text/html",
|
|
278
305
|
".js": "text/javascript",
|
|
279
306
|
".css": "text/css",
|
|
307
|
+
".md": "text/markdown",
|
|
308
|
+
".markdown": "text/markdown",
|
|
309
|
+
".csv": "text/csv",
|
|
310
|
+
".yml": "application/yaml",
|
|
311
|
+
".yaml": "application/yaml",
|
|
312
|
+
".pdf": "application/pdf",
|
|
313
|
+
".jpg": "image/jpeg",
|
|
314
|
+
".jpeg": "image/jpeg",
|
|
315
|
+
".gif": "image/gif",
|
|
316
|
+
".webp": "image/webp",
|
|
280
317
|
".png": "image/png",
|
|
281
318
|
".svg": "image/svg+xml",
|
|
282
319
|
".json": "application/json",
|
|
@@ -284,6 +321,15 @@ const MIME_TYPES = {
|
|
|
284
321
|
".txt": "text/plain",
|
|
285
322
|
};
|
|
286
323
|
|
|
324
|
+
const FILE_BUCKETS = ["uploads", "artifacts", "exports"];
|
|
325
|
+
const LEGACY_UPLOAD_BUCKET = "control-panel";
|
|
326
|
+
const FILE_BUCKET_PHYSICAL_NAMES = {
|
|
327
|
+
uploads: LEGACY_UPLOAD_BUCKET,
|
|
328
|
+
artifacts: "artifacts",
|
|
329
|
+
exports: "exports",
|
|
330
|
+
};
|
|
331
|
+
const MAX_PREVIEW_BYTES = Math.max(1, Math.min(MAX_UPLOAD_BYTES, 2 * 1024 * 1024));
|
|
332
|
+
|
|
287
333
|
const sessions = new Map();
|
|
288
334
|
let engineProcess = null;
|
|
289
335
|
let server = null;
|
|
@@ -841,7 +887,7 @@ async function installServices() {
|
|
|
841
887
|
const engineEnvBody = Object.entries(engineEnv)
|
|
842
888
|
.map(([k, v]) => `${k}=${v}`)
|
|
843
889
|
.join("\n");
|
|
844
|
-
await
|
|
890
|
+
await writeTextFileAtomic(engineEnvPath, `${engineEnvBody}\n`);
|
|
845
891
|
await runCmd("chmod", ["640", engineEnvPath]);
|
|
846
892
|
|
|
847
893
|
const panelAutoStart = serviceMode === "panel" ? "1" : "0";
|
|
@@ -858,7 +904,7 @@ async function installServices() {
|
|
|
858
904
|
const panelEnvBody = Object.entries(panelEnv)
|
|
859
905
|
.map(([k, v]) => `${k}=${v}`)
|
|
860
906
|
.join("\n");
|
|
861
|
-
await
|
|
907
|
+
await writeTextFileAtomic(panelEnvPath, `${panelEnvBody}\n`);
|
|
862
908
|
await runCmd("chmod", ["640", panelEnvPath]);
|
|
863
909
|
|
|
864
910
|
if (installEngine) {
|
|
@@ -1083,7 +1129,7 @@ function readManagedSearchSettings() {
|
|
|
1083
1129
|
writable: localEngine,
|
|
1084
1130
|
managed_env_path: envPath,
|
|
1085
1131
|
restart_required: false,
|
|
1086
|
-
restart_hint: "
|
|
1132
|
+
restart_hint: "Changes apply immediately.",
|
|
1087
1133
|
settings: {
|
|
1088
1134
|
backend: normalizeSearchBackend(env.TANDEM_SEARCH_BACKEND || "auto"),
|
|
1089
1135
|
tandem_url: normalizeSearchUrl(env.TANDEM_SEARCH_URL || ""),
|
|
@@ -1162,10 +1208,10 @@ async function writeManagedSearchSettings(payload = {}) {
|
|
|
1162
1208
|
for (const [key, value] of Object.entries(nextEnv)) {
|
|
1163
1209
|
if (!preferredKeys.includes(key)) ordered.push([key, value]);
|
|
1164
1210
|
}
|
|
1165
|
-
await
|
|
1211
|
+
await writeTextFileAtomic(envPath, serializeEnv(ordered));
|
|
1166
1212
|
return {
|
|
1167
1213
|
...readManagedSearchSettings(),
|
|
1168
|
-
restart_required:
|
|
1214
|
+
restart_required: false,
|
|
1169
1215
|
};
|
|
1170
1216
|
}
|
|
1171
1217
|
|
|
@@ -1226,7 +1272,7 @@ async function writeManagedSchedulerSettings(payload = {}) {
|
|
|
1226
1272
|
for (const [key, value] of Object.entries(nextEnv)) {
|
|
1227
1273
|
if (!preferredKeys.includes(key)) ordered.push([key, value]);
|
|
1228
1274
|
}
|
|
1229
|
-
await
|
|
1275
|
+
await writeTextFileAtomic(envPath, serializeEnv(ordered));
|
|
1230
1276
|
return {
|
|
1231
1277
|
...getManagedSchedulerSettings(),
|
|
1232
1278
|
restart_required: true,
|
|
@@ -1286,6 +1332,16 @@ async function getInstallProfile({ acaAvailable = false, acaReason = "" } = {})
|
|
|
1286
1332
|
control_panel_config_ready: summary.ready,
|
|
1287
1333
|
control_panel_config_missing: summary.missing,
|
|
1288
1334
|
control_panel_compact_nav: !!summary.control_panel?.aca_compact_nav,
|
|
1335
|
+
hosted_managed: summary.hosted?.managed === true,
|
|
1336
|
+
hosted_provider: String(summary.hosted?.provider || "").trim(),
|
|
1337
|
+
hosted_deployment_id: String(summary.hosted?.deployment_id || "").trim(),
|
|
1338
|
+
hosted_deployment_slug: String(summary.hosted?.deployment_slug || "").trim(),
|
|
1339
|
+
hosted_hostname: String(summary.hosted?.hostname || "").trim(),
|
|
1340
|
+
hosted_public_url: String(summary.hosted?.public_url || "").trim(),
|
|
1341
|
+
hosted_control_plane_url: String(summary.hosted?.control_plane_url || "").trim(),
|
|
1342
|
+
hosted_release_version: String(summary.hosted?.release_version || "").trim(),
|
|
1343
|
+
hosted_release_channel: String(summary.hosted?.release_channel || "").trim(),
|
|
1344
|
+
hosted_update_policy: String(summary.hosted?.update_policy || "").trim(),
|
|
1289
1345
|
aca_integration: !!acaAvailable,
|
|
1290
1346
|
aca_reason: acaReason || "",
|
|
1291
1347
|
};
|
|
@@ -1336,6 +1392,38 @@ async function engineHealth(token = "") {
|
|
|
1336
1392
|
}
|
|
1337
1393
|
}
|
|
1338
1394
|
|
|
1395
|
+
async function probeEngineHealth(token = "") {
|
|
1396
|
+
try {
|
|
1397
|
+
const response = await fetch(`${ENGINE_URL}/global/health`, {
|
|
1398
|
+
headers: token
|
|
1399
|
+
? {
|
|
1400
|
+
authorization: `Bearer ${token}`,
|
|
1401
|
+
"x-tandem-token": token,
|
|
1402
|
+
}
|
|
1403
|
+
: {},
|
|
1404
|
+
signal: AbortSignal.timeout(1800),
|
|
1405
|
+
});
|
|
1406
|
+
const text = await response.text().catch(() => "");
|
|
1407
|
+
let payload = null;
|
|
1408
|
+
try {
|
|
1409
|
+
payload = text ? JSON.parse(text) : null;
|
|
1410
|
+
} catch {
|
|
1411
|
+
payload = null;
|
|
1412
|
+
}
|
|
1413
|
+
return {
|
|
1414
|
+
ok: response.ok,
|
|
1415
|
+
status: response.status,
|
|
1416
|
+
payload,
|
|
1417
|
+
};
|
|
1418
|
+
} catch {
|
|
1419
|
+
return {
|
|
1420
|
+
ok: false,
|
|
1421
|
+
status: 0,
|
|
1422
|
+
payload: null,
|
|
1423
|
+
};
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1339
1427
|
async function executeEngineTool(token, tool, args = {}) {
|
|
1340
1428
|
const response = await fetch(`${ENGINE_URL}/tool/execute`, {
|
|
1341
1429
|
method: "POST",
|
|
@@ -1507,18 +1595,58 @@ function sanitizeStaticPath(rawUrl) {
|
|
|
1507
1595
|
return full;
|
|
1508
1596
|
}
|
|
1509
1597
|
|
|
1510
|
-
function
|
|
1598
|
+
function normalizeVisibleFilesPath(raw, allowEmpty = true) {
|
|
1511
1599
|
const normalized = String(raw || "")
|
|
1512
1600
|
.trim()
|
|
1513
1601
|
.replace(/\\/g, "/")
|
|
1514
|
-
.replace(/^\/+/, "")
|
|
1515
|
-
|
|
1602
|
+
.replace(/^\/+/, "")
|
|
1603
|
+
.replace(/\/+$/, "");
|
|
1604
|
+
if (!normalized) return allowEmpty ? "" : null;
|
|
1516
1605
|
if (normalized.includes("\0")) return null;
|
|
1517
|
-
const
|
|
1606
|
+
const parts = normalized.split("/").filter(Boolean);
|
|
1607
|
+
if (!parts.length) return allowEmpty ? "" : null;
|
|
1608
|
+
if (parts.some((part) => part === "." || part === "..")) return null;
|
|
1609
|
+
const [first, ...rest] = parts;
|
|
1610
|
+
if (first === LEGACY_UPLOAD_BUCKET) return ["uploads", ...rest].join("/");
|
|
1611
|
+
if (!FILE_BUCKETS.includes(first)) return null;
|
|
1612
|
+
return [first, ...rest].join("/");
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
function visibleFilesPathToPhysicalPath(raw) {
|
|
1616
|
+
const normalized = String(raw || "")
|
|
1617
|
+
.trim()
|
|
1618
|
+
.replace(/\\/g, "/")
|
|
1619
|
+
.replace(/^\/+/, "")
|
|
1620
|
+
.replace(/\/+$/, "");
|
|
1621
|
+
if (!normalized) return "";
|
|
1622
|
+
const parts = normalized.split("/").filter(Boolean);
|
|
1623
|
+
if (!parts.length) return "";
|
|
1624
|
+
const [first, ...rest] = parts;
|
|
1625
|
+
const physicalBucket = FILE_BUCKET_PHYSICAL_NAMES[first] || first;
|
|
1626
|
+
return [physicalBucket, ...rest].filter(Boolean).join("/");
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
function physicalFilesPathToVisiblePath(raw) {
|
|
1630
|
+
const normalized = String(raw || "")
|
|
1631
|
+
.trim()
|
|
1632
|
+
.replace(/\\/g, "/")
|
|
1633
|
+
.replace(/^\/+/, "")
|
|
1634
|
+
.replace(/\/+$/, "");
|
|
1635
|
+
if (!normalized) return "";
|
|
1636
|
+
const parts = normalized.split("/").filter(Boolean);
|
|
1637
|
+
if (!parts.length) return "";
|
|
1638
|
+
const [first, ...rest] = parts;
|
|
1639
|
+
if (first === LEGACY_UPLOAD_BUCKET) return ["uploads", ...rest].join("/");
|
|
1640
|
+
return [first, ...rest].join("/");
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
function toSafeRelPath(raw, allowEmpty = true) {
|
|
1644
|
+
const visible = normalizeVisibleFilesPath(raw, allowEmpty);
|
|
1645
|
+
if (visible === null) return null;
|
|
1646
|
+
if (!visible) return "";
|
|
1647
|
+
const full = resolve(FILES_ROOT, visibleFilesPathToPhysicalPath(visible));
|
|
1518
1648
|
if (full !== FILES_ROOT && !full.startsWith(`${FILES_ROOT}/`)) return null;
|
|
1519
|
-
|
|
1520
|
-
return null;
|
|
1521
|
-
return normalized;
|
|
1649
|
+
return visible;
|
|
1522
1650
|
}
|
|
1523
1651
|
|
|
1524
1652
|
function toSafeRelFileName(rawName) {
|
|
@@ -1527,19 +1655,48 @@ function toSafeRelFileName(rawName) {
|
|
|
1527
1655
|
return cleaned;
|
|
1528
1656
|
}
|
|
1529
1657
|
|
|
1530
|
-
|
|
1531
|
-
const
|
|
1532
|
-
|
|
1533
|
-
|
|
1658
|
+
function parentVisiblePath(raw) {
|
|
1659
|
+
const normalized = String(raw || "")
|
|
1660
|
+
.trim()
|
|
1661
|
+
.replace(/\\/g, "/")
|
|
1662
|
+
.replace(/^\/+/, "")
|
|
1663
|
+
.replace(/\/+$/, "");
|
|
1664
|
+
if (!normalized) return null;
|
|
1665
|
+
const idx = normalized.lastIndexOf("/");
|
|
1666
|
+
if (idx < 0) return null;
|
|
1667
|
+
return normalized.slice(0, idx);
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
function inferFileMime(pathname = "") {
|
|
1671
|
+
const ext = extname(String(pathname || "")).toLowerCase();
|
|
1672
|
+
return MIME_TYPES[ext] || "application/octet-stream";
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
function inferFilePreviewKind(pathname = "", mime = "") {
|
|
1676
|
+
const ext = extname(String(pathname || "")).toLowerCase();
|
|
1677
|
+
if (mime === "application/pdf" || ext === ".pdf") return "pdf";
|
|
1678
|
+
if (String(mime || "").startsWith("image/")) return "image";
|
|
1679
|
+
if (ext === ".md" || ext === ".markdown" || mime === "text/markdown") return "markdown";
|
|
1680
|
+
if (ext === ".json" || mime === "application/json") return "json";
|
|
1681
|
+
if (ext === ".yaml" || ext === ".yml" || mime === "application/yaml") return "yaml";
|
|
1682
|
+
if (String(mime || "").startsWith("text/") || mime === "application/xml") return "text";
|
|
1683
|
+
return "binary";
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
async function ensureUniqueVisibleRelPath(visiblePath) {
|
|
1687
|
+
const ext = extname(visiblePath);
|
|
1688
|
+
const stem = ext ? visiblePath.slice(0, -ext.length) : visiblePath;
|
|
1689
|
+
let candidate = visibleFilesPathToPhysicalPath(visiblePath);
|
|
1534
1690
|
let counter = 1;
|
|
1535
1691
|
while (true) {
|
|
1536
1692
|
const full = resolve(FILES_ROOT, candidate);
|
|
1537
1693
|
try {
|
|
1538
1694
|
await stat(full);
|
|
1539
1695
|
counter += 1;
|
|
1540
|
-
|
|
1696
|
+
const nextVisible = `${stem}-${counter}${ext}`;
|
|
1697
|
+
candidate = visibleFilesPathToPhysicalPath(nextVisible);
|
|
1541
1698
|
} catch {
|
|
1542
|
-
return candidate;
|
|
1699
|
+
return physicalFilesPathToVisiblePath(candidate);
|
|
1543
1700
|
}
|
|
1544
1701
|
}
|
|
1545
1702
|
}
|
|
@@ -1550,31 +1707,77 @@ async function handleFilesApi(req, res, _session) {
|
|
|
1550
1707
|
|
|
1551
1708
|
if (pathname === "/api/files/list" && req.method === "GET") {
|
|
1552
1709
|
const incomingDir = url.searchParams.get("dir") || "";
|
|
1553
|
-
const
|
|
1554
|
-
const dirRelRaw = toSafeRelPath(incomingDir || defaultDir);
|
|
1710
|
+
const dirRelRaw = toSafeRelPath(incomingDir, true);
|
|
1555
1711
|
if (dirRelRaw === null) {
|
|
1556
1712
|
sendJson(res, 400, { ok: false, error: "Invalid directory path." });
|
|
1557
1713
|
return true;
|
|
1558
1714
|
}
|
|
1559
1715
|
const dirRel = dirRelRaw || "";
|
|
1560
|
-
const dirFull = resolve(FILES_ROOT, dirRel);
|
|
1561
1716
|
try {
|
|
1717
|
+
if (!dirRel) {
|
|
1718
|
+
const directories = await Promise.all(
|
|
1719
|
+
FILE_BUCKETS.map(async (bucket) => {
|
|
1720
|
+
const physicalRel = visibleFilesPathToPhysicalPath(bucket);
|
|
1721
|
+
const info = await stat(resolve(FILES_ROOT, physicalRel)).catch(() => null);
|
|
1722
|
+
return {
|
|
1723
|
+
name: bucket,
|
|
1724
|
+
path: bucket,
|
|
1725
|
+
updatedAt: info?.mtimeMs || 0,
|
|
1726
|
+
previewKind: "directory",
|
|
1727
|
+
};
|
|
1728
|
+
})
|
|
1729
|
+
);
|
|
1730
|
+
sendJson(res, 200, {
|
|
1731
|
+
ok: true,
|
|
1732
|
+
root: FILES_ROOT,
|
|
1733
|
+
dir: "",
|
|
1734
|
+
parent: null,
|
|
1735
|
+
directories,
|
|
1736
|
+
files: [],
|
|
1737
|
+
});
|
|
1738
|
+
return true;
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
const physicalDirRel = visibleFilesPathToPhysicalPath(dirRel);
|
|
1742
|
+
const dirFull = resolve(FILES_ROOT, physicalDirRel);
|
|
1562
1743
|
await mkdir(dirFull, { recursive: true });
|
|
1563
1744
|
const entries = await readdir(dirFull, { withFileTypes: true });
|
|
1745
|
+
const directories = [];
|
|
1564
1746
|
const files = [];
|
|
1565
1747
|
for (const entry of entries) {
|
|
1566
|
-
const
|
|
1567
|
-
|
|
1568
|
-
const info = await stat(resolve(FILES_ROOT,
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1748
|
+
const physicalChildRel = physicalDirRel ? `${physicalDirRel}/${entry.name}` : entry.name;
|
|
1749
|
+
const visibleChildRel = physicalFilesPathToVisiblePath(physicalChildRel);
|
|
1750
|
+
const info = await stat(resolve(FILES_ROOT, physicalChildRel)).catch(() => null);
|
|
1751
|
+
if (entry.isDirectory()) {
|
|
1752
|
+
directories.push({
|
|
1753
|
+
name: entry.name,
|
|
1754
|
+
path: visibleChildRel,
|
|
1755
|
+
updatedAt: info?.mtimeMs || 0,
|
|
1756
|
+
previewKind: "directory",
|
|
1757
|
+
});
|
|
1758
|
+
} else if (entry.isFile()) {
|
|
1759
|
+
const mime = inferFileMime(visibleChildRel || entry.name);
|
|
1760
|
+
files.push({
|
|
1761
|
+
name: entry.name,
|
|
1762
|
+
path: visibleChildRel,
|
|
1763
|
+
size: info?.size || 0,
|
|
1764
|
+
updatedAt: info?.mtimeMs || 0,
|
|
1765
|
+
mime,
|
|
1766
|
+
previewKind: inferFilePreviewKind(visibleChildRel || entry.name, mime),
|
|
1767
|
+
downloadUrl: `/api/files/download?path=${encodeURIComponent(visibleChildRel)}`,
|
|
1768
|
+
});
|
|
1769
|
+
}
|
|
1575
1770
|
}
|
|
1576
|
-
|
|
1577
|
-
|
|
1771
|
+
directories.sort((a, b) => String(a.name).localeCompare(String(b.name)));
|
|
1772
|
+
files.sort((a, b) => b.updatedAt - a.updatedAt || String(a.name).localeCompare(String(b.name)));
|
|
1773
|
+
sendJson(res, 200, {
|
|
1774
|
+
ok: true,
|
|
1775
|
+
root: FILES_ROOT,
|
|
1776
|
+
dir: dirRel,
|
|
1777
|
+
parent: parentVisiblePath(dirRel),
|
|
1778
|
+
directories,
|
|
1779
|
+
files,
|
|
1780
|
+
});
|
|
1578
1781
|
} catch (e) {
|
|
1579
1782
|
sendJson(res, 500, { ok: false, error: e instanceof Error ? e.message : String(e) });
|
|
1580
1783
|
}
|
|
@@ -1593,16 +1796,16 @@ async function handleFilesApi(req, res, _session) {
|
|
|
1593
1796
|
}
|
|
1594
1797
|
|
|
1595
1798
|
const incomingDir = url.searchParams.get("dir") || "";
|
|
1596
|
-
const defaultDir =
|
|
1597
|
-
const dirRelRaw = toSafeRelPath(incomingDir || defaultDir);
|
|
1799
|
+
const defaultDir = "uploads";
|
|
1800
|
+
const dirRelRaw = toSafeRelPath(incomingDir || defaultDir, true);
|
|
1598
1801
|
if (dirRelRaw === null) {
|
|
1599
1802
|
sendJson(res, 400, { ok: false, error: "Invalid upload directory." });
|
|
1600
1803
|
return true;
|
|
1601
1804
|
}
|
|
1602
1805
|
const dirRel = dirRelRaw || "";
|
|
1603
1806
|
let relPath = dirRel ? `${dirRel}/${safeName}` : safeName;
|
|
1604
|
-
relPath = await
|
|
1605
|
-
const fullPath = resolve(FILES_ROOT, relPath);
|
|
1807
|
+
relPath = await ensureUniqueVisibleRelPath(relPath);
|
|
1808
|
+
const fullPath = resolve(FILES_ROOT, visibleFilesPathToPhysicalPath(relPath));
|
|
1606
1809
|
const folder = dirname(fullPath);
|
|
1607
1810
|
|
|
1608
1811
|
try {
|
|
@@ -1620,6 +1823,8 @@ async function handleFilesApi(req, res, _session) {
|
|
|
1620
1823
|
});
|
|
1621
1824
|
await pipeline(req, guard, createWriteStream(fullPath, { flags: "wx" }));
|
|
1622
1825
|
const meta = await stat(fullPath);
|
|
1826
|
+
const mime = inferFileMime(relPath);
|
|
1827
|
+
const previewKind = inferFilePreviewKind(relPath, mime);
|
|
1623
1828
|
sendJson(res, 200, {
|
|
1624
1829
|
ok: true,
|
|
1625
1830
|
root: FILES_ROOT,
|
|
@@ -1627,6 +1832,8 @@ async function handleFilesApi(req, res, _session) {
|
|
|
1627
1832
|
path: relPath,
|
|
1628
1833
|
absPath: fullPath,
|
|
1629
1834
|
size: meta.size,
|
|
1835
|
+
mime,
|
|
1836
|
+
previewKind,
|
|
1630
1837
|
downloadUrl: `/api/files/download?path=${encodeURIComponent(relPath)}`,
|
|
1631
1838
|
});
|
|
1632
1839
|
} catch (e) {
|
|
@@ -1640,17 +1847,33 @@ async function handleFilesApi(req, res, _session) {
|
|
|
1640
1847
|
}
|
|
1641
1848
|
|
|
1642
1849
|
if (pathname === "/api/files/read" && req.method === "GET") {
|
|
1643
|
-
const rel = toSafeRelPath(url.searchParams.get("path") || "");
|
|
1850
|
+
const rel = toSafeRelPath(url.searchParams.get("path") || "", false);
|
|
1644
1851
|
if (!rel) {
|
|
1645
1852
|
sendJson(res, 400, { ok: false, error: "Missing or invalid file path." });
|
|
1646
1853
|
return true;
|
|
1647
1854
|
}
|
|
1648
|
-
const
|
|
1855
|
+
const physicalRel = visibleFilesPathToPhysicalPath(rel);
|
|
1856
|
+
const full = resolve(FILES_ROOT, physicalRel);
|
|
1649
1857
|
try {
|
|
1650
1858
|
const info = await stat(full);
|
|
1651
1859
|
if (!info.isFile()) throw new Error("Not a file");
|
|
1652
|
-
|
|
1653
|
-
|
|
1860
|
+
const mime = inferFileMime(rel);
|
|
1861
|
+
const previewKind = inferFilePreviewKind(rel, mime);
|
|
1862
|
+
const previewable = ["text", "markdown", "json", "yaml"].includes(previewKind);
|
|
1863
|
+
if (!previewable || info.size > MAX_PREVIEW_BYTES) {
|
|
1864
|
+
sendJson(res, 200, {
|
|
1865
|
+
ok: true,
|
|
1866
|
+
root: FILES_ROOT,
|
|
1867
|
+
path: rel,
|
|
1868
|
+
absPath: full,
|
|
1869
|
+
name: basename(full),
|
|
1870
|
+
size: info.size,
|
|
1871
|
+
mime,
|
|
1872
|
+
previewKind,
|
|
1873
|
+
previewable: false,
|
|
1874
|
+
reason: !previewable ? "not_previewable" : "too_large",
|
|
1875
|
+
downloadUrl: `/api/files/download?path=${encodeURIComponent(rel)}`,
|
|
1876
|
+
});
|
|
1654
1877
|
return true;
|
|
1655
1878
|
}
|
|
1656
1879
|
const text = await readFile(full, "utf8");
|
|
@@ -1659,7 +1882,12 @@ async function handleFilesApi(req, res, _session) {
|
|
|
1659
1882
|
root: FILES_ROOT,
|
|
1660
1883
|
path: rel,
|
|
1661
1884
|
absPath: full,
|
|
1885
|
+
name: basename(full),
|
|
1662
1886
|
size: info.size,
|
|
1887
|
+
mime,
|
|
1888
|
+
previewKind,
|
|
1889
|
+
previewable: true,
|
|
1890
|
+
downloadUrl: `/api/files/download?path=${encodeURIComponent(rel)}`,
|
|
1663
1891
|
text,
|
|
1664
1892
|
});
|
|
1665
1893
|
} catch {
|
|
@@ -1671,7 +1899,7 @@ async function handleFilesApi(req, res, _session) {
|
|
|
1671
1899
|
if (pathname === "/api/files/write" && req.method === "POST") {
|
|
1672
1900
|
try {
|
|
1673
1901
|
const body = await readJsonBody(req);
|
|
1674
|
-
const rel = toSafeRelPath(body?.path || "");
|
|
1902
|
+
const rel = toSafeRelPath(body?.path || "", false);
|
|
1675
1903
|
const text = String(body?.text ?? "");
|
|
1676
1904
|
const overwrite = body?.overwrite !== false;
|
|
1677
1905
|
if (!rel) {
|
|
@@ -1682,7 +1910,7 @@ async function handleFilesApi(req, res, _session) {
|
|
|
1682
1910
|
sendJson(res, 413, { ok: false, error: "Text payload exceeds max upload bytes limit." });
|
|
1683
1911
|
return true;
|
|
1684
1912
|
}
|
|
1685
|
-
const full = resolve(FILES_ROOT, rel);
|
|
1913
|
+
const full = resolve(FILES_ROOT, visibleFilesPathToPhysicalPath(rel));
|
|
1686
1914
|
await mkdir(dirname(full), { recursive: true });
|
|
1687
1915
|
await writeFile(full, text, { encoding: "utf8", flag: overwrite ? "w" : "wx" });
|
|
1688
1916
|
const info = await stat(full);
|
|
@@ -1704,17 +1932,16 @@ async function handleFilesApi(req, res, _session) {
|
|
|
1704
1932
|
}
|
|
1705
1933
|
|
|
1706
1934
|
if (pathname === "/api/files/download" && req.method === "GET") {
|
|
1707
|
-
const rel = toSafeRelPath(url.searchParams.get("path") || "");
|
|
1935
|
+
const rel = toSafeRelPath(url.searchParams.get("path") || "", false);
|
|
1708
1936
|
if (!rel) {
|
|
1709
1937
|
sendJson(res, 400, { ok: false, error: "Missing or invalid file path." });
|
|
1710
1938
|
return true;
|
|
1711
1939
|
}
|
|
1712
|
-
const full = resolve(FILES_ROOT, rel);
|
|
1940
|
+
const full = resolve(FILES_ROOT, visibleFilesPathToPhysicalPath(rel));
|
|
1713
1941
|
try {
|
|
1714
1942
|
const info = await stat(full);
|
|
1715
1943
|
if (!info.isFile()) throw new Error("Not a file");
|
|
1716
|
-
const
|
|
1717
|
-
const mime = MIME_TYPES[ext] || "application/octet-stream";
|
|
1944
|
+
const mime = inferFileMime(rel);
|
|
1718
1945
|
res.writeHead(200, {
|
|
1719
1946
|
"content-type": mime,
|
|
1720
1947
|
"content-length": String(info.size),
|
|
@@ -1730,12 +1957,12 @@ async function handleFilesApi(req, res, _session) {
|
|
|
1730
1957
|
if (pathname === "/api/files/delete" && req.method === "POST") {
|
|
1731
1958
|
try {
|
|
1732
1959
|
const body = await readJsonBody(req);
|
|
1733
|
-
const rel = toSafeRelPath(body?.path || "");
|
|
1960
|
+
const rel = toSafeRelPath(body?.path || "", false);
|
|
1734
1961
|
if (!rel) {
|
|
1735
1962
|
sendJson(res, 400, { ok: false, error: "Missing or invalid file path." });
|
|
1736
1963
|
return true;
|
|
1737
1964
|
}
|
|
1738
|
-
await rm(resolve(FILES_ROOT, rel), { force: true });
|
|
1965
|
+
await rm(resolve(FILES_ROOT, visibleFilesPathToPhysicalPath(rel)), { force: true });
|
|
1739
1966
|
sendJson(res, 200, { ok: true, path: rel });
|
|
1740
1967
|
} catch (e) {
|
|
1741
1968
|
sendJson(res, 500, { ok: false, error: e instanceof Error ? e.message : String(e) });
|
|
@@ -4784,6 +5011,14 @@ const handleControlPanelPreferences = createControlPanelPreferencesHandler({
|
|
|
4784
5011
|
readJsonBody,
|
|
4785
5012
|
});
|
|
4786
5013
|
|
|
5014
|
+
const handleKnowledgebaseApi = createKnowledgebaseApiHandler({
|
|
5015
|
+
PORTAL_PORT,
|
|
5016
|
+
TANDEM_KB_ADMIN_URL: KB_ADMIN_URL,
|
|
5017
|
+
KB_ADMIN_API_KEY_FILE,
|
|
5018
|
+
KB_DEFAULT_COLLECTION_ID,
|
|
5019
|
+
sendJson,
|
|
5020
|
+
});
|
|
5021
|
+
|
|
4787
5022
|
async function handleApi(req, res) {
|
|
4788
5023
|
const pathname = new URL(req.url, `http://127.0.0.1:${PORTAL_PORT}`).pathname;
|
|
4789
5024
|
|
|
@@ -4909,11 +5144,13 @@ async function handleApi(req, res) {
|
|
|
4909
5144
|
}
|
|
4910
5145
|
|
|
4911
5146
|
if (pathname === "/api/auth/login" && req.method === "POST") {
|
|
5147
|
+
res.setHeader("cache-control", "no-store, max-age=0");
|
|
4912
5148
|
await handleAuthLogin(req, res);
|
|
4913
5149
|
return true;
|
|
4914
5150
|
}
|
|
4915
5151
|
|
|
4916
5152
|
if (pathname === "/api/auth/logout" && req.method === "POST") {
|
|
5153
|
+
res.setHeader("cache-control", "no-store, max-age=0");
|
|
4917
5154
|
const current = getSession(req);
|
|
4918
5155
|
if (current?.sid) sessions.delete(current.sid);
|
|
4919
5156
|
clearSessionCookie(res);
|
|
@@ -4922,10 +5159,28 @@ async function handleApi(req, res) {
|
|
|
4922
5159
|
}
|
|
4923
5160
|
|
|
4924
5161
|
if (pathname === "/api/auth/me" && req.method === "GET") {
|
|
5162
|
+
res.setHeader("cache-control", "no-store, max-age=0");
|
|
4925
5163
|
const session = requireSession(req, res);
|
|
4926
5164
|
if (!session) return true;
|
|
4927
|
-
const
|
|
4928
|
-
if (!
|
|
5165
|
+
const probe = await probeEngineHealth(session.token);
|
|
5166
|
+
if (!probe.ok) {
|
|
5167
|
+
if (probe.status === 401 || probe.status === 403) {
|
|
5168
|
+
sessions.delete(session.sid);
|
|
5169
|
+
clearSessionCookie(res);
|
|
5170
|
+
sendJson(res, 401, {
|
|
5171
|
+
ok: false,
|
|
5172
|
+
error: "Session token is no longer valid for the configured engine.",
|
|
5173
|
+
});
|
|
5174
|
+
return true;
|
|
5175
|
+
}
|
|
5176
|
+
sendJson(res, 503, {
|
|
5177
|
+
ok: false,
|
|
5178
|
+
error: "Engine is temporarily unavailable while restoring your session.",
|
|
5179
|
+
});
|
|
5180
|
+
return true;
|
|
5181
|
+
}
|
|
5182
|
+
const health = probe.payload;
|
|
5183
|
+
if (!health || typeof health !== "object") {
|
|
4929
5184
|
sessions.delete(session.sid);
|
|
4930
5185
|
clearSessionCookie(res);
|
|
4931
5186
|
sendJson(res, 401, {
|
|
@@ -4961,6 +5216,12 @@ async function handleApi(req, res) {
|
|
|
4961
5216
|
return handleControlPanelConfig(req, res);
|
|
4962
5217
|
}
|
|
4963
5218
|
|
|
5219
|
+
if (pathname.startsWith("/api/knowledgebase")) {
|
|
5220
|
+
const session = requireSession(req, res);
|
|
5221
|
+
if (!session) return true;
|
|
5222
|
+
return handleKnowledgebaseApi(req, res);
|
|
5223
|
+
}
|
|
5224
|
+
|
|
4964
5225
|
if (pathname.startsWith("/api/swarm") || pathname.startsWith("/api/orchestrator")) {
|
|
4965
5226
|
const session = requireSession(req, res);
|
|
4966
5227
|
if (!session) return true;
|
|
@@ -5085,7 +5346,7 @@ async function main() {
|
|
|
5085
5346
|
log(`Engine URL: ${ENGINE_URL}`);
|
|
5086
5347
|
log(`Engine mode: ${isLocalEngineUrl(ENGINE_URL) ? "local" : "remote"}`);
|
|
5087
5348
|
log(`Files root: ${FILES_ROOT}`);
|
|
5088
|
-
log(`Files
|
|
5349
|
+
log(`Files buckets: uploads, artifacts, exports`);
|
|
5089
5350
|
log(`Build: ${CONTROL_PANEL_BUILD_FINGERPRINT}`);
|
|
5090
5351
|
log("=========================================");
|
|
5091
5352
|
});
|