@frumu/tandem-panel 0.4.31 → 0.4.32
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 +309 -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,
|
|
@@ -1336,6 +1382,38 @@ async function engineHealth(token = "") {
|
|
|
1336
1382
|
}
|
|
1337
1383
|
}
|
|
1338
1384
|
|
|
1385
|
+
async function probeEngineHealth(token = "") {
|
|
1386
|
+
try {
|
|
1387
|
+
const response = await fetch(`${ENGINE_URL}/global/health`, {
|
|
1388
|
+
headers: token
|
|
1389
|
+
? {
|
|
1390
|
+
authorization: `Bearer ${token}`,
|
|
1391
|
+
"x-tandem-token": token,
|
|
1392
|
+
}
|
|
1393
|
+
: {},
|
|
1394
|
+
signal: AbortSignal.timeout(1800),
|
|
1395
|
+
});
|
|
1396
|
+
const text = await response.text().catch(() => "");
|
|
1397
|
+
let payload = null;
|
|
1398
|
+
try {
|
|
1399
|
+
payload = text ? JSON.parse(text) : null;
|
|
1400
|
+
} catch {
|
|
1401
|
+
payload = null;
|
|
1402
|
+
}
|
|
1403
|
+
return {
|
|
1404
|
+
ok: response.ok,
|
|
1405
|
+
status: response.status,
|
|
1406
|
+
payload,
|
|
1407
|
+
};
|
|
1408
|
+
} catch {
|
|
1409
|
+
return {
|
|
1410
|
+
ok: false,
|
|
1411
|
+
status: 0,
|
|
1412
|
+
payload: null,
|
|
1413
|
+
};
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1339
1417
|
async function executeEngineTool(token, tool, args = {}) {
|
|
1340
1418
|
const response = await fetch(`${ENGINE_URL}/tool/execute`, {
|
|
1341
1419
|
method: "POST",
|
|
@@ -1507,18 +1585,58 @@ function sanitizeStaticPath(rawUrl) {
|
|
|
1507
1585
|
return full;
|
|
1508
1586
|
}
|
|
1509
1587
|
|
|
1510
|
-
function
|
|
1588
|
+
function normalizeVisibleFilesPath(raw, allowEmpty = true) {
|
|
1511
1589
|
const normalized = String(raw || "")
|
|
1512
1590
|
.trim()
|
|
1513
1591
|
.replace(/\\/g, "/")
|
|
1514
|
-
.replace(/^\/+/, "")
|
|
1515
|
-
|
|
1592
|
+
.replace(/^\/+/, "")
|
|
1593
|
+
.replace(/\/+$/, "");
|
|
1594
|
+
if (!normalized) return allowEmpty ? "" : null;
|
|
1516
1595
|
if (normalized.includes("\0")) return null;
|
|
1517
|
-
const
|
|
1596
|
+
const parts = normalized.split("/").filter(Boolean);
|
|
1597
|
+
if (!parts.length) return allowEmpty ? "" : null;
|
|
1598
|
+
if (parts.some((part) => part === "." || part === "..")) return null;
|
|
1599
|
+
const [first, ...rest] = parts;
|
|
1600
|
+
if (first === LEGACY_UPLOAD_BUCKET) return ["uploads", ...rest].join("/");
|
|
1601
|
+
if (!FILE_BUCKETS.includes(first)) return null;
|
|
1602
|
+
return [first, ...rest].join("/");
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
function visibleFilesPathToPhysicalPath(raw) {
|
|
1606
|
+
const normalized = String(raw || "")
|
|
1607
|
+
.trim()
|
|
1608
|
+
.replace(/\\/g, "/")
|
|
1609
|
+
.replace(/^\/+/, "")
|
|
1610
|
+
.replace(/\/+$/, "");
|
|
1611
|
+
if (!normalized) return "";
|
|
1612
|
+
const parts = normalized.split("/").filter(Boolean);
|
|
1613
|
+
if (!parts.length) return "";
|
|
1614
|
+
const [first, ...rest] = parts;
|
|
1615
|
+
const physicalBucket = FILE_BUCKET_PHYSICAL_NAMES[first] || first;
|
|
1616
|
+
return [physicalBucket, ...rest].filter(Boolean).join("/");
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
function physicalFilesPathToVisiblePath(raw) {
|
|
1620
|
+
const normalized = String(raw || "")
|
|
1621
|
+
.trim()
|
|
1622
|
+
.replace(/\\/g, "/")
|
|
1623
|
+
.replace(/^\/+/, "")
|
|
1624
|
+
.replace(/\/+$/, "");
|
|
1625
|
+
if (!normalized) return "";
|
|
1626
|
+
const parts = normalized.split("/").filter(Boolean);
|
|
1627
|
+
if (!parts.length) return "";
|
|
1628
|
+
const [first, ...rest] = parts;
|
|
1629
|
+
if (first === LEGACY_UPLOAD_BUCKET) return ["uploads", ...rest].join("/");
|
|
1630
|
+
return [first, ...rest].join("/");
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
function toSafeRelPath(raw, allowEmpty = true) {
|
|
1634
|
+
const visible = normalizeVisibleFilesPath(raw, allowEmpty);
|
|
1635
|
+
if (visible === null) return null;
|
|
1636
|
+
if (!visible) return "";
|
|
1637
|
+
const full = resolve(FILES_ROOT, visibleFilesPathToPhysicalPath(visible));
|
|
1518
1638
|
if (full !== FILES_ROOT && !full.startsWith(`${FILES_ROOT}/`)) return null;
|
|
1519
|
-
|
|
1520
|
-
return null;
|
|
1521
|
-
return normalized;
|
|
1639
|
+
return visible;
|
|
1522
1640
|
}
|
|
1523
1641
|
|
|
1524
1642
|
function toSafeRelFileName(rawName) {
|
|
@@ -1527,19 +1645,48 @@ function toSafeRelFileName(rawName) {
|
|
|
1527
1645
|
return cleaned;
|
|
1528
1646
|
}
|
|
1529
1647
|
|
|
1530
|
-
|
|
1531
|
-
const
|
|
1532
|
-
|
|
1533
|
-
|
|
1648
|
+
function parentVisiblePath(raw) {
|
|
1649
|
+
const normalized = String(raw || "")
|
|
1650
|
+
.trim()
|
|
1651
|
+
.replace(/\\/g, "/")
|
|
1652
|
+
.replace(/^\/+/, "")
|
|
1653
|
+
.replace(/\/+$/, "");
|
|
1654
|
+
if (!normalized) return null;
|
|
1655
|
+
const idx = normalized.lastIndexOf("/");
|
|
1656
|
+
if (idx < 0) return null;
|
|
1657
|
+
return normalized.slice(0, idx);
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
function inferFileMime(pathname = "") {
|
|
1661
|
+
const ext = extname(String(pathname || "")).toLowerCase();
|
|
1662
|
+
return MIME_TYPES[ext] || "application/octet-stream";
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
function inferFilePreviewKind(pathname = "", mime = "") {
|
|
1666
|
+
const ext = extname(String(pathname || "")).toLowerCase();
|
|
1667
|
+
if (mime === "application/pdf" || ext === ".pdf") return "pdf";
|
|
1668
|
+
if (String(mime || "").startsWith("image/")) return "image";
|
|
1669
|
+
if (ext === ".md" || ext === ".markdown" || mime === "text/markdown") return "markdown";
|
|
1670
|
+
if (ext === ".json" || mime === "application/json") return "json";
|
|
1671
|
+
if (ext === ".yaml" || ext === ".yml" || mime === "application/yaml") return "yaml";
|
|
1672
|
+
if (String(mime || "").startsWith("text/") || mime === "application/xml") return "text";
|
|
1673
|
+
return "binary";
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
async function ensureUniqueVisibleRelPath(visiblePath) {
|
|
1677
|
+
const ext = extname(visiblePath);
|
|
1678
|
+
const stem = ext ? visiblePath.slice(0, -ext.length) : visiblePath;
|
|
1679
|
+
let candidate = visibleFilesPathToPhysicalPath(visiblePath);
|
|
1534
1680
|
let counter = 1;
|
|
1535
1681
|
while (true) {
|
|
1536
1682
|
const full = resolve(FILES_ROOT, candidate);
|
|
1537
1683
|
try {
|
|
1538
1684
|
await stat(full);
|
|
1539
1685
|
counter += 1;
|
|
1540
|
-
|
|
1686
|
+
const nextVisible = `${stem}-${counter}${ext}`;
|
|
1687
|
+
candidate = visibleFilesPathToPhysicalPath(nextVisible);
|
|
1541
1688
|
} catch {
|
|
1542
|
-
return candidate;
|
|
1689
|
+
return physicalFilesPathToVisiblePath(candidate);
|
|
1543
1690
|
}
|
|
1544
1691
|
}
|
|
1545
1692
|
}
|
|
@@ -1550,31 +1697,77 @@ async function handleFilesApi(req, res, _session) {
|
|
|
1550
1697
|
|
|
1551
1698
|
if (pathname === "/api/files/list" && req.method === "GET") {
|
|
1552
1699
|
const incomingDir = url.searchParams.get("dir") || "";
|
|
1553
|
-
const
|
|
1554
|
-
const dirRelRaw = toSafeRelPath(incomingDir || defaultDir);
|
|
1700
|
+
const dirRelRaw = toSafeRelPath(incomingDir, true);
|
|
1555
1701
|
if (dirRelRaw === null) {
|
|
1556
1702
|
sendJson(res, 400, { ok: false, error: "Invalid directory path." });
|
|
1557
1703
|
return true;
|
|
1558
1704
|
}
|
|
1559
1705
|
const dirRel = dirRelRaw || "";
|
|
1560
|
-
const dirFull = resolve(FILES_ROOT, dirRel);
|
|
1561
1706
|
try {
|
|
1707
|
+
if (!dirRel) {
|
|
1708
|
+
const directories = await Promise.all(
|
|
1709
|
+
FILE_BUCKETS.map(async (bucket) => {
|
|
1710
|
+
const physicalRel = visibleFilesPathToPhysicalPath(bucket);
|
|
1711
|
+
const info = await stat(resolve(FILES_ROOT, physicalRel)).catch(() => null);
|
|
1712
|
+
return {
|
|
1713
|
+
name: bucket,
|
|
1714
|
+
path: bucket,
|
|
1715
|
+
updatedAt: info?.mtimeMs || 0,
|
|
1716
|
+
previewKind: "directory",
|
|
1717
|
+
};
|
|
1718
|
+
})
|
|
1719
|
+
);
|
|
1720
|
+
sendJson(res, 200, {
|
|
1721
|
+
ok: true,
|
|
1722
|
+
root: FILES_ROOT,
|
|
1723
|
+
dir: "",
|
|
1724
|
+
parent: null,
|
|
1725
|
+
directories,
|
|
1726
|
+
files: [],
|
|
1727
|
+
});
|
|
1728
|
+
return true;
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
const physicalDirRel = visibleFilesPathToPhysicalPath(dirRel);
|
|
1732
|
+
const dirFull = resolve(FILES_ROOT, physicalDirRel);
|
|
1562
1733
|
await mkdir(dirFull, { recursive: true });
|
|
1563
1734
|
const entries = await readdir(dirFull, { withFileTypes: true });
|
|
1735
|
+
const directories = [];
|
|
1564
1736
|
const files = [];
|
|
1565
1737
|
for (const entry of entries) {
|
|
1566
|
-
const
|
|
1567
|
-
|
|
1568
|
-
const info = await stat(resolve(FILES_ROOT,
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1738
|
+
const physicalChildRel = physicalDirRel ? `${physicalDirRel}/${entry.name}` : entry.name;
|
|
1739
|
+
const visibleChildRel = physicalFilesPathToVisiblePath(physicalChildRel);
|
|
1740
|
+
const info = await stat(resolve(FILES_ROOT, physicalChildRel)).catch(() => null);
|
|
1741
|
+
if (entry.isDirectory()) {
|
|
1742
|
+
directories.push({
|
|
1743
|
+
name: entry.name,
|
|
1744
|
+
path: visibleChildRel,
|
|
1745
|
+
updatedAt: info?.mtimeMs || 0,
|
|
1746
|
+
previewKind: "directory",
|
|
1747
|
+
});
|
|
1748
|
+
} else if (entry.isFile()) {
|
|
1749
|
+
const mime = inferFileMime(visibleChildRel || entry.name);
|
|
1750
|
+
files.push({
|
|
1751
|
+
name: entry.name,
|
|
1752
|
+
path: visibleChildRel,
|
|
1753
|
+
size: info?.size || 0,
|
|
1754
|
+
updatedAt: info?.mtimeMs || 0,
|
|
1755
|
+
mime,
|
|
1756
|
+
previewKind: inferFilePreviewKind(visibleChildRel || entry.name, mime),
|
|
1757
|
+
downloadUrl: `/api/files/download?path=${encodeURIComponent(visibleChildRel)}`,
|
|
1758
|
+
});
|
|
1759
|
+
}
|
|
1575
1760
|
}
|
|
1576
|
-
|
|
1577
|
-
|
|
1761
|
+
directories.sort((a, b) => String(a.name).localeCompare(String(b.name)));
|
|
1762
|
+
files.sort((a, b) => b.updatedAt - a.updatedAt || String(a.name).localeCompare(String(b.name)));
|
|
1763
|
+
sendJson(res, 200, {
|
|
1764
|
+
ok: true,
|
|
1765
|
+
root: FILES_ROOT,
|
|
1766
|
+
dir: dirRel,
|
|
1767
|
+
parent: parentVisiblePath(dirRel),
|
|
1768
|
+
directories,
|
|
1769
|
+
files,
|
|
1770
|
+
});
|
|
1578
1771
|
} catch (e) {
|
|
1579
1772
|
sendJson(res, 500, { ok: false, error: e instanceof Error ? e.message : String(e) });
|
|
1580
1773
|
}
|
|
@@ -1593,16 +1786,16 @@ async function handleFilesApi(req, res, _session) {
|
|
|
1593
1786
|
}
|
|
1594
1787
|
|
|
1595
1788
|
const incomingDir = url.searchParams.get("dir") || "";
|
|
1596
|
-
const defaultDir =
|
|
1597
|
-
const dirRelRaw = toSafeRelPath(incomingDir || defaultDir);
|
|
1789
|
+
const defaultDir = "uploads";
|
|
1790
|
+
const dirRelRaw = toSafeRelPath(incomingDir || defaultDir, true);
|
|
1598
1791
|
if (dirRelRaw === null) {
|
|
1599
1792
|
sendJson(res, 400, { ok: false, error: "Invalid upload directory." });
|
|
1600
1793
|
return true;
|
|
1601
1794
|
}
|
|
1602
1795
|
const dirRel = dirRelRaw || "";
|
|
1603
1796
|
let relPath = dirRel ? `${dirRel}/${safeName}` : safeName;
|
|
1604
|
-
relPath = await
|
|
1605
|
-
const fullPath = resolve(FILES_ROOT, relPath);
|
|
1797
|
+
relPath = await ensureUniqueVisibleRelPath(relPath);
|
|
1798
|
+
const fullPath = resolve(FILES_ROOT, visibleFilesPathToPhysicalPath(relPath));
|
|
1606
1799
|
const folder = dirname(fullPath);
|
|
1607
1800
|
|
|
1608
1801
|
try {
|
|
@@ -1620,6 +1813,8 @@ async function handleFilesApi(req, res, _session) {
|
|
|
1620
1813
|
});
|
|
1621
1814
|
await pipeline(req, guard, createWriteStream(fullPath, { flags: "wx" }));
|
|
1622
1815
|
const meta = await stat(fullPath);
|
|
1816
|
+
const mime = inferFileMime(relPath);
|
|
1817
|
+
const previewKind = inferFilePreviewKind(relPath, mime);
|
|
1623
1818
|
sendJson(res, 200, {
|
|
1624
1819
|
ok: true,
|
|
1625
1820
|
root: FILES_ROOT,
|
|
@@ -1627,6 +1822,8 @@ async function handleFilesApi(req, res, _session) {
|
|
|
1627
1822
|
path: relPath,
|
|
1628
1823
|
absPath: fullPath,
|
|
1629
1824
|
size: meta.size,
|
|
1825
|
+
mime,
|
|
1826
|
+
previewKind,
|
|
1630
1827
|
downloadUrl: `/api/files/download?path=${encodeURIComponent(relPath)}`,
|
|
1631
1828
|
});
|
|
1632
1829
|
} catch (e) {
|
|
@@ -1640,17 +1837,33 @@ async function handleFilesApi(req, res, _session) {
|
|
|
1640
1837
|
}
|
|
1641
1838
|
|
|
1642
1839
|
if (pathname === "/api/files/read" && req.method === "GET") {
|
|
1643
|
-
const rel = toSafeRelPath(url.searchParams.get("path") || "");
|
|
1840
|
+
const rel = toSafeRelPath(url.searchParams.get("path") || "", false);
|
|
1644
1841
|
if (!rel) {
|
|
1645
1842
|
sendJson(res, 400, { ok: false, error: "Missing or invalid file path." });
|
|
1646
1843
|
return true;
|
|
1647
1844
|
}
|
|
1648
|
-
const
|
|
1845
|
+
const physicalRel = visibleFilesPathToPhysicalPath(rel);
|
|
1846
|
+
const full = resolve(FILES_ROOT, physicalRel);
|
|
1649
1847
|
try {
|
|
1650
1848
|
const info = await stat(full);
|
|
1651
1849
|
if (!info.isFile()) throw new Error("Not a file");
|
|
1652
|
-
|
|
1653
|
-
|
|
1850
|
+
const mime = inferFileMime(rel);
|
|
1851
|
+
const previewKind = inferFilePreviewKind(rel, mime);
|
|
1852
|
+
const previewable = ["text", "markdown", "json", "yaml"].includes(previewKind);
|
|
1853
|
+
if (!previewable || info.size > MAX_PREVIEW_BYTES) {
|
|
1854
|
+
sendJson(res, 200, {
|
|
1855
|
+
ok: true,
|
|
1856
|
+
root: FILES_ROOT,
|
|
1857
|
+
path: rel,
|
|
1858
|
+
absPath: full,
|
|
1859
|
+
name: basename(full),
|
|
1860
|
+
size: info.size,
|
|
1861
|
+
mime,
|
|
1862
|
+
previewKind,
|
|
1863
|
+
previewable: false,
|
|
1864
|
+
reason: !previewable ? "not_previewable" : "too_large",
|
|
1865
|
+
downloadUrl: `/api/files/download?path=${encodeURIComponent(rel)}`,
|
|
1866
|
+
});
|
|
1654
1867
|
return true;
|
|
1655
1868
|
}
|
|
1656
1869
|
const text = await readFile(full, "utf8");
|
|
@@ -1659,7 +1872,12 @@ async function handleFilesApi(req, res, _session) {
|
|
|
1659
1872
|
root: FILES_ROOT,
|
|
1660
1873
|
path: rel,
|
|
1661
1874
|
absPath: full,
|
|
1875
|
+
name: basename(full),
|
|
1662
1876
|
size: info.size,
|
|
1877
|
+
mime,
|
|
1878
|
+
previewKind,
|
|
1879
|
+
previewable: true,
|
|
1880
|
+
downloadUrl: `/api/files/download?path=${encodeURIComponent(rel)}`,
|
|
1663
1881
|
text,
|
|
1664
1882
|
});
|
|
1665
1883
|
} catch {
|
|
@@ -1671,7 +1889,7 @@ async function handleFilesApi(req, res, _session) {
|
|
|
1671
1889
|
if (pathname === "/api/files/write" && req.method === "POST") {
|
|
1672
1890
|
try {
|
|
1673
1891
|
const body = await readJsonBody(req);
|
|
1674
|
-
const rel = toSafeRelPath(body?.path || "");
|
|
1892
|
+
const rel = toSafeRelPath(body?.path || "", false);
|
|
1675
1893
|
const text = String(body?.text ?? "");
|
|
1676
1894
|
const overwrite = body?.overwrite !== false;
|
|
1677
1895
|
if (!rel) {
|
|
@@ -1682,7 +1900,7 @@ async function handleFilesApi(req, res, _session) {
|
|
|
1682
1900
|
sendJson(res, 413, { ok: false, error: "Text payload exceeds max upload bytes limit." });
|
|
1683
1901
|
return true;
|
|
1684
1902
|
}
|
|
1685
|
-
const full = resolve(FILES_ROOT, rel);
|
|
1903
|
+
const full = resolve(FILES_ROOT, visibleFilesPathToPhysicalPath(rel));
|
|
1686
1904
|
await mkdir(dirname(full), { recursive: true });
|
|
1687
1905
|
await writeFile(full, text, { encoding: "utf8", flag: overwrite ? "w" : "wx" });
|
|
1688
1906
|
const info = await stat(full);
|
|
@@ -1704,17 +1922,16 @@ async function handleFilesApi(req, res, _session) {
|
|
|
1704
1922
|
}
|
|
1705
1923
|
|
|
1706
1924
|
if (pathname === "/api/files/download" && req.method === "GET") {
|
|
1707
|
-
const rel = toSafeRelPath(url.searchParams.get("path") || "");
|
|
1925
|
+
const rel = toSafeRelPath(url.searchParams.get("path") || "", false);
|
|
1708
1926
|
if (!rel) {
|
|
1709
1927
|
sendJson(res, 400, { ok: false, error: "Missing or invalid file path." });
|
|
1710
1928
|
return true;
|
|
1711
1929
|
}
|
|
1712
|
-
const full = resolve(FILES_ROOT, rel);
|
|
1930
|
+
const full = resolve(FILES_ROOT, visibleFilesPathToPhysicalPath(rel));
|
|
1713
1931
|
try {
|
|
1714
1932
|
const info = await stat(full);
|
|
1715
1933
|
if (!info.isFile()) throw new Error("Not a file");
|
|
1716
|
-
const
|
|
1717
|
-
const mime = MIME_TYPES[ext] || "application/octet-stream";
|
|
1934
|
+
const mime = inferFileMime(rel);
|
|
1718
1935
|
res.writeHead(200, {
|
|
1719
1936
|
"content-type": mime,
|
|
1720
1937
|
"content-length": String(info.size),
|
|
@@ -1730,12 +1947,12 @@ async function handleFilesApi(req, res, _session) {
|
|
|
1730
1947
|
if (pathname === "/api/files/delete" && req.method === "POST") {
|
|
1731
1948
|
try {
|
|
1732
1949
|
const body = await readJsonBody(req);
|
|
1733
|
-
const rel = toSafeRelPath(body?.path || "");
|
|
1950
|
+
const rel = toSafeRelPath(body?.path || "", false);
|
|
1734
1951
|
if (!rel) {
|
|
1735
1952
|
sendJson(res, 400, { ok: false, error: "Missing or invalid file path." });
|
|
1736
1953
|
return true;
|
|
1737
1954
|
}
|
|
1738
|
-
await rm(resolve(FILES_ROOT, rel), { force: true });
|
|
1955
|
+
await rm(resolve(FILES_ROOT, visibleFilesPathToPhysicalPath(rel)), { force: true });
|
|
1739
1956
|
sendJson(res, 200, { ok: true, path: rel });
|
|
1740
1957
|
} catch (e) {
|
|
1741
1958
|
sendJson(res, 500, { ok: false, error: e instanceof Error ? e.message : String(e) });
|
|
@@ -4784,6 +5001,14 @@ const handleControlPanelPreferences = createControlPanelPreferencesHandler({
|
|
|
4784
5001
|
readJsonBody,
|
|
4785
5002
|
});
|
|
4786
5003
|
|
|
5004
|
+
const handleKnowledgebaseApi = createKnowledgebaseApiHandler({
|
|
5005
|
+
PORTAL_PORT,
|
|
5006
|
+
TANDEM_KB_ADMIN_URL: KB_ADMIN_URL,
|
|
5007
|
+
KB_ADMIN_API_KEY_FILE,
|
|
5008
|
+
KB_DEFAULT_COLLECTION_ID,
|
|
5009
|
+
sendJson,
|
|
5010
|
+
});
|
|
5011
|
+
|
|
4787
5012
|
async function handleApi(req, res) {
|
|
4788
5013
|
const pathname = new URL(req.url, `http://127.0.0.1:${PORTAL_PORT}`).pathname;
|
|
4789
5014
|
|
|
@@ -4909,11 +5134,13 @@ async function handleApi(req, res) {
|
|
|
4909
5134
|
}
|
|
4910
5135
|
|
|
4911
5136
|
if (pathname === "/api/auth/login" && req.method === "POST") {
|
|
5137
|
+
res.setHeader("cache-control", "no-store, max-age=0");
|
|
4912
5138
|
await handleAuthLogin(req, res);
|
|
4913
5139
|
return true;
|
|
4914
5140
|
}
|
|
4915
5141
|
|
|
4916
5142
|
if (pathname === "/api/auth/logout" && req.method === "POST") {
|
|
5143
|
+
res.setHeader("cache-control", "no-store, max-age=0");
|
|
4917
5144
|
const current = getSession(req);
|
|
4918
5145
|
if (current?.sid) sessions.delete(current.sid);
|
|
4919
5146
|
clearSessionCookie(res);
|
|
@@ -4922,10 +5149,28 @@ async function handleApi(req, res) {
|
|
|
4922
5149
|
}
|
|
4923
5150
|
|
|
4924
5151
|
if (pathname === "/api/auth/me" && req.method === "GET") {
|
|
5152
|
+
res.setHeader("cache-control", "no-store, max-age=0");
|
|
4925
5153
|
const session = requireSession(req, res);
|
|
4926
5154
|
if (!session) return true;
|
|
4927
|
-
const
|
|
4928
|
-
if (!
|
|
5155
|
+
const probe = await probeEngineHealth(session.token);
|
|
5156
|
+
if (!probe.ok) {
|
|
5157
|
+
if (probe.status === 401 || probe.status === 403) {
|
|
5158
|
+
sessions.delete(session.sid);
|
|
5159
|
+
clearSessionCookie(res);
|
|
5160
|
+
sendJson(res, 401, {
|
|
5161
|
+
ok: false,
|
|
5162
|
+
error: "Session token is no longer valid for the configured engine.",
|
|
5163
|
+
});
|
|
5164
|
+
return true;
|
|
5165
|
+
}
|
|
5166
|
+
sendJson(res, 503, {
|
|
5167
|
+
ok: false,
|
|
5168
|
+
error: "Engine is temporarily unavailable while restoring your session.",
|
|
5169
|
+
});
|
|
5170
|
+
return true;
|
|
5171
|
+
}
|
|
5172
|
+
const health = probe.payload;
|
|
5173
|
+
if (!health || typeof health !== "object") {
|
|
4929
5174
|
sessions.delete(session.sid);
|
|
4930
5175
|
clearSessionCookie(res);
|
|
4931
5176
|
sendJson(res, 401, {
|
|
@@ -4961,6 +5206,12 @@ async function handleApi(req, res) {
|
|
|
4961
5206
|
return handleControlPanelConfig(req, res);
|
|
4962
5207
|
}
|
|
4963
5208
|
|
|
5209
|
+
if (pathname.startsWith("/api/knowledgebase")) {
|
|
5210
|
+
const session = requireSession(req, res);
|
|
5211
|
+
if (!session) return true;
|
|
5212
|
+
return handleKnowledgebaseApi(req, res);
|
|
5213
|
+
}
|
|
5214
|
+
|
|
4964
5215
|
if (pathname.startsWith("/api/swarm") || pathname.startsWith("/api/orchestrator")) {
|
|
4965
5216
|
const session = requireSession(req, res);
|
|
4966
5217
|
if (!session) return true;
|
|
@@ -5085,7 +5336,7 @@ async function main() {
|
|
|
5085
5336
|
log(`Engine URL: ${ENGINE_URL}`);
|
|
5086
5337
|
log(`Engine mode: ${isLocalEngineUrl(ENGINE_URL) ? "local" : "remote"}`);
|
|
5087
5338
|
log(`Files root: ${FILES_ROOT}`);
|
|
5088
|
-
log(`Files
|
|
5339
|
+
log(`Files buckets: uploads, artifacts, exports`);
|
|
5089
5340
|
log(`Build: ${CONTROL_PANEL_BUILD_FINGERPRINT}`);
|
|
5090
5341
|
log("=========================================");
|
|
5091
5342
|
});
|