@geravant/sinain 1.22.5 → 1.22.7
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/package.json +1 -1
- package/sinain-core/src/index.ts +33 -57
- package/sinain-core/src/server.ts +87 -3
package/package.json
CHANGED
package/sinain-core/src/index.ts
CHANGED
|
@@ -60,14 +60,7 @@ async function queryKnowledgeFactsMulti(entities: string[], maxFacts: number): P
|
|
|
60
60
|
`${workspaceDir}/knowledge-graph.db`,
|
|
61
61
|
];
|
|
62
62
|
|
|
63
|
-
|
|
64
|
-
const __dir = dirname(fileURLToPath(import.meta.url));
|
|
65
|
-
const scriptCandidates = [
|
|
66
|
-
resolve(__dir, "..", "..", "sinain-hud-plugin", "sinain-memory", "graph_query.py"),
|
|
67
|
-
resolve(__dir, "..", "sinain-memory", "graph_query.py"),
|
|
68
|
-
`${resolveWorkspace()}/sinain-memory/graph_query.py`,
|
|
69
|
-
];
|
|
70
|
-
const scriptPath = scriptCandidates.find(p => existsSync(p)) || scriptCandidates[0];
|
|
63
|
+
const scriptPath = resolveSinainMemoryScript("graph_query.py");
|
|
71
64
|
|
|
72
65
|
// Step 1: Get candidates from Python (RRF-ranked, no embedding — avoids deadlock)
|
|
73
66
|
// Request 2x candidates in JSON for re-ranking in Node.js
|
|
@@ -142,13 +135,7 @@ async function listKnowledgeEntitiesMulti(max: number): Promise<string> {
|
|
|
142
135
|
`${workspaceDir}/knowledge-graph.db`,
|
|
143
136
|
];
|
|
144
137
|
|
|
145
|
-
const
|
|
146
|
-
const scriptCandidates = [
|
|
147
|
-
resolve(__dir, "..", "..", "sinain-hud-plugin", "sinain-memory", "graph_query.py"),
|
|
148
|
-
resolve(__dir, "..", "sinain-memory", "graph_query.py"),
|
|
149
|
-
`${resolveWorkspace()}/sinain-memory/graph_query.py`,
|
|
150
|
-
];
|
|
151
|
-
const scriptPath = scriptCandidates.find(p => existsSync(p)) || scriptCandidates[0];
|
|
138
|
+
const scriptPath = resolveSinainMemoryScript("graph_query.py");
|
|
152
139
|
|
|
153
140
|
const allFacts: any[] = [];
|
|
154
141
|
for (const dbPath of dbPaths) {
|
|
@@ -174,17 +161,33 @@ async function listKnowledgeEntitiesMulti(max: number): Promise<string> {
|
|
|
174
161
|
return JSON.stringify(unique.slice(0, max));
|
|
175
162
|
}
|
|
176
163
|
|
|
177
|
-
/**
|
|
178
|
-
|
|
164
|
+
/**
|
|
165
|
+
* Resolve a Python script under sinain-memory/. Two layouts are supported:
|
|
166
|
+
*
|
|
167
|
+
* - **dev repo**: code is at `<repo>/sinain-core/src/index.ts`, scripts at
|
|
168
|
+
* `<repo>/sinain-hud-plugin/sinain-memory/<name>` (so `__dir/../../sinain-hud-plugin/sinain-memory`).
|
|
169
|
+
* - **npm-installed**: code is at `node_modules/@geravant/sinain/sinain-core/src/index.ts`,
|
|
170
|
+
* scripts at `node_modules/@geravant/sinain/sinain-memory/<name>` (so `__dir/../../sinain-memory`).
|
|
171
|
+
*
|
|
172
|
+
* Plus a workspace fallback for OpenClaw layouts. ENOENT on the resolved
|
|
173
|
+
* path was Irina's bug — the npm path was missing from candidates.
|
|
174
|
+
*/
|
|
175
|
+
function resolveSinainMemoryScript(scriptName: string): string {
|
|
179
176
|
const __dir = new URL(import.meta.url).pathname.replace(/\/[^/]+$/, "");
|
|
180
177
|
const candidates = [
|
|
181
|
-
`${__dir}/../../sinain-hud-plugin/sinain-memory
|
|
182
|
-
`${__dir}
|
|
183
|
-
`${
|
|
178
|
+
`${__dir}/../../sinain-hud-plugin/sinain-memory/${scriptName}`, // dev repo
|
|
179
|
+
`${__dir}/../../sinain-memory/${scriptName}`, // npm install
|
|
180
|
+
`${__dir}/../sinain-memory/${scriptName}`, // legacy
|
|
181
|
+
`${resolveWorkspace()}/sinain-memory/${scriptName}`, // openclaw workspace
|
|
184
182
|
];
|
|
185
183
|
return candidates.find(p => existsSync(p)) || candidates[0];
|
|
186
184
|
}
|
|
187
185
|
|
|
186
|
+
/** Backward-compat alias used by legacy call sites. */
|
|
187
|
+
function resolveGraphQueryScript(): string {
|
|
188
|
+
return resolveSinainMemoryScript("graph_query.py");
|
|
189
|
+
}
|
|
190
|
+
|
|
188
191
|
/** List of candidate knowledge DB paths (local + workspace). */
|
|
189
192
|
function resolveKnowledgeDbPaths(): string[] {
|
|
190
193
|
return [
|
|
@@ -237,13 +240,7 @@ async function exportConceptBundle(
|
|
|
237
240
|
const { execFile } = await import("node:child_process");
|
|
238
241
|
const { promisify } = await import("node:util");
|
|
239
242
|
const pExecFile = promisify(execFile);
|
|
240
|
-
const
|
|
241
|
-
const scriptCandidates = [
|
|
242
|
-
`${__dir}/../../sinain-hud-plugin/sinain-memory/concept_export.py`,
|
|
243
|
-
`${__dir}/../sinain-memory/concept_export.py`,
|
|
244
|
-
`${resolveWorkspace()}/sinain-memory/concept_export.py`,
|
|
245
|
-
];
|
|
246
|
-
const scriptPath = scriptCandidates.find(p => existsSync(p)) || scriptCandidates[0];
|
|
243
|
+
const scriptPath = resolveSinainMemoryScript("concept_export.py");
|
|
247
244
|
const webDbPath = `${resolveLocalMemoryDir()}/web.db`;
|
|
248
245
|
|
|
249
246
|
for (const dbPath of resolveKnowledgeDbPaths()) {
|
|
@@ -286,13 +283,7 @@ async function importConceptBundle(
|
|
|
286
283
|
const { execFile } = await import("node:child_process");
|
|
287
284
|
const { promisify } = await import("node:util");
|
|
288
285
|
const pExecFile = promisify(execFile);
|
|
289
|
-
const
|
|
290
|
-
const scriptCandidates = [
|
|
291
|
-
`${__dir}/../../sinain-hud-plugin/sinain-memory/concept_import.py`,
|
|
292
|
-
`${__dir}/../sinain-memory/concept_import.py`,
|
|
293
|
-
`${resolveWorkspace()}/sinain-memory/concept_import.py`,
|
|
294
|
-
];
|
|
295
|
-
const scriptPath = scriptCandidates.find(p => existsSync(p)) || scriptCandidates[0];
|
|
286
|
+
const scriptPath = resolveSinainMemoryScript("concept_import.py");
|
|
296
287
|
const localDir = resolveLocalMemoryDir();
|
|
297
288
|
const dbPath = `${localDir}/knowledge-graph.db`;
|
|
298
289
|
const webDbPath = `${localDir}/web.db`;
|
|
@@ -336,13 +327,7 @@ async function retractOrRestoreFact(
|
|
|
336
327
|
const { execFile } = await import("node:child_process");
|
|
337
328
|
const { promisify } = await import("node:util");
|
|
338
329
|
const pExecFile = promisify(execFile);
|
|
339
|
-
const
|
|
340
|
-
const scriptCandidates = [
|
|
341
|
-
`${__dir}/../../sinain-hud-plugin/sinain-memory/retract.py`,
|
|
342
|
-
`${__dir}/../sinain-memory/retract.py`,
|
|
343
|
-
`${resolveWorkspace()}/sinain-memory/retract.py`,
|
|
344
|
-
];
|
|
345
|
-
const scriptPath = scriptCandidates.find(p => existsSync(p)) || scriptCandidates[0];
|
|
330
|
+
const scriptPath = resolveSinainMemoryScript("retract.py");
|
|
346
331
|
const webDbPath = `${resolveLocalMemoryDir()}/web.db`;
|
|
347
332
|
|
|
348
333
|
// Try DBs in order — the fact lives in one of them.
|
|
@@ -383,13 +368,7 @@ async function renderEntityPageMulti(
|
|
|
383
368
|
const { execFile } = await import("node:child_process");
|
|
384
369
|
const { promisify } = await import("node:util");
|
|
385
370
|
const pExecFile = promisify(execFile);
|
|
386
|
-
const
|
|
387
|
-
const scriptCandidates = [
|
|
388
|
-
`${__dir}/../../sinain-hud-plugin/sinain-memory/page_renderer.py`,
|
|
389
|
-
`${__dir}/../sinain-memory/page_renderer.py`,
|
|
390
|
-
`${resolveWorkspace()}/sinain-memory/page_renderer.py`,
|
|
391
|
-
];
|
|
392
|
-
const scriptPath = scriptCandidates.find(p => existsSync(p)) || scriptCandidates[0];
|
|
371
|
+
const scriptPath = resolveSinainMemoryScript("page_renderer.py");
|
|
393
372
|
const webDbPath = `${resolveLocalMemoryDir()}/web.db`;
|
|
394
373
|
|
|
395
374
|
// Try DBs in order; first one with the entity wins.
|
|
@@ -456,10 +435,12 @@ async function queryKnowledgeAsOfMulti(entity: string, date: string): Promise<st
|
|
|
456
435
|
`${workspaceDir}/knowledge-graph.db`,
|
|
457
436
|
];
|
|
458
437
|
|
|
438
|
+
const __dir = dirname(new URL(import.meta.url).pathname);
|
|
459
439
|
const scriptCandidates = [
|
|
460
|
-
`${
|
|
461
|
-
`${
|
|
462
|
-
`${
|
|
440
|
+
`${__dir}/../../sinain-hud-plugin/sinain-memory`, // dev repo
|
|
441
|
+
`${__dir}/../../sinain-memory`, // npm install
|
|
442
|
+
`${__dir}/../sinain-memory`, // legacy
|
|
443
|
+
`${resolveWorkspace()}/sinain-memory`, // workspace
|
|
463
444
|
];
|
|
464
445
|
const scriptsDir = scriptCandidates.find(p => existsSync(`${p}/triplestore.py`)) || scriptCandidates[0];
|
|
465
446
|
|
|
@@ -499,12 +480,7 @@ async function exportKnowledgeMulti(domain: string | null, max: number): Promise
|
|
|
499
480
|
`${workspaceDir}/knowledge-graph.db`,
|
|
500
481
|
];
|
|
501
482
|
|
|
502
|
-
const
|
|
503
|
-
const scriptCandidates = [
|
|
504
|
-
resolve(__dir, "..", "..", "sinain-hud-plugin", "sinain-memory", "graph_query.py"),
|
|
505
|
-
`${resolveWorkspace()}/sinain-memory/graph_query.py`,
|
|
506
|
-
];
|
|
507
|
-
const scriptPath = scriptCandidates.find(p => existsSync(p)) || scriptCandidates[0];
|
|
483
|
+
const scriptPath = resolveSinainMemoryScript("graph_query.py");
|
|
508
484
|
|
|
509
485
|
const allFacts: any[] = [];
|
|
510
486
|
for (const dbPath of dbPaths) {
|
|
@@ -493,10 +493,57 @@ function ensurePeerJsLoaded() {
|
|
|
493
493
|
|
|
494
494
|
function newPeer(idOrUndef) {
|
|
495
495
|
// Honor SHARE_PEERJS_HOST env-injected override. Empty string = peerjs.com default.
|
|
496
|
-
|
|
496
|
+
// debug: 3 enables verbose peerjs logging in console — critical for diagnosing
|
|
497
|
+
// WebRTC handshake failures (NAT, ICE state transitions, peer-unavailable, etc.).
|
|
498
|
+
const opts = SHARE_PEERJS_HOST ? { host: SHARE_PEERJS_HOST, debug: 3 } : { debug: 3 };
|
|
497
499
|
return idOrUndef ? new window.Peer(idOrUndef, opts) : new window.Peer(opts);
|
|
498
500
|
}
|
|
499
501
|
|
|
502
|
+
// Attach detailed instrumentation to a peer + its underlying RTCPeerConnections.
|
|
503
|
+
// Logs to console (sinain-share namespace) so diagnostics are visible without
|
|
504
|
+
// any UI changes. Tracks the four state machines that matter for WebRTC: ICE
|
|
505
|
+
// gathering, ICE connection, peerconnection, and signaling.
|
|
506
|
+
function instrumentPeer(peer, label) {
|
|
507
|
+
const tag = "[sinain-share:" + label + "]";
|
|
508
|
+
console.log(tag, "instrumented peer", peer.id || "(no-id-yet)");
|
|
509
|
+
peer.on("open", id => console.log(tag, "peer.open id=" + id));
|
|
510
|
+
peer.on("error", e => console.warn(tag, "peer.error type=" + (e && e.type) + " msg=" + (e && e.message)));
|
|
511
|
+
peer.on("disconnected", () => console.warn(tag, "peer.disconnected (lost broker connection)"));
|
|
512
|
+
peer.on("close", () => console.log(tag, "peer.close (destroyed)"));
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function instrumentConnection(conn, label) {
|
|
516
|
+
const tag = "[sinain-share:" + label + ":" + (conn.peer || "?") + "]";
|
|
517
|
+
console.log(tag, "new connection, reliable=" + conn.reliable);
|
|
518
|
+
conn.on("open", () => console.log(tag, "conn.open (DataChannel ready)"));
|
|
519
|
+
conn.on("error", e => console.warn(tag, "conn.error", e));
|
|
520
|
+
conn.on("close", () => console.log(tag, "conn.close"));
|
|
521
|
+
conn.on("iceStateChanged", s => console.log(tag, "conn.iceStateChanged →", s));
|
|
522
|
+
// Tap into the underlying RTCPeerConnection. peerjs exposes it as
|
|
523
|
+
// conn.peerConnection (it might not exist immediately — wait a tick).
|
|
524
|
+
setTimeout(() => {
|
|
525
|
+
const pc = conn.peerConnection;
|
|
526
|
+
if (!pc) { console.warn(tag, "no peerConnection exposed"); return; }
|
|
527
|
+
pc.addEventListener("iceconnectionstatechange",
|
|
528
|
+
() => console.log(tag, "iceConnectionState →", pc.iceConnectionState));
|
|
529
|
+
pc.addEventListener("connectionstatechange",
|
|
530
|
+
() => console.log(tag, "connectionState →", pc.connectionState));
|
|
531
|
+
pc.addEventListener("icegatheringstatechange",
|
|
532
|
+
() => console.log(tag, "iceGatheringState →", pc.iceGatheringState));
|
|
533
|
+
pc.addEventListener("signalingstatechange",
|
|
534
|
+
() => console.log(tag, "signalingState →", pc.signalingState));
|
|
535
|
+
pc.addEventListener("icecandidate", (e) => {
|
|
536
|
+
if (!e.candidate) { console.log(tag, "ICE gathering complete"); return; }
|
|
537
|
+
const c = e.candidate;
|
|
538
|
+
// Log candidate type (host = local LAN, srflx = STUN, relay = TURN, prflx = peer-reflexive).
|
|
539
|
+
// If we never see "relay" but only "host"/"srflx" and connection fails, NAT traversal
|
|
540
|
+
// requires TURN — which peerjs cloud doesn't provide.
|
|
541
|
+
const type = (c.candidate.match(/typ (\\S+)/) || [])[1] || "?";
|
|
542
|
+
console.log(tag, "iceCandidate type=" + type + " proto=" + c.protocol + " addr=" + (c.address || "?") + ":" + (c.port || "?"));
|
|
543
|
+
});
|
|
544
|
+
}, 100);
|
|
545
|
+
}
|
|
546
|
+
|
|
500
547
|
const ShareManager = (() => {
|
|
501
548
|
// share_token → live Peer instance (sender side only). Bundles are re-fetched
|
|
502
549
|
// on demand rather than kept in JS memory across resume.
|
|
@@ -543,6 +590,7 @@ const ShareManager = (() => {
|
|
|
543
590
|
// Peer mode
|
|
544
591
|
await ensurePeerJsLoaded();
|
|
545
592
|
const peer = newPeer(token);
|
|
593
|
+
instrumentPeer(peer, "sender:" + token.slice(0, 8));
|
|
546
594
|
await new Promise((res, rej) => {
|
|
547
595
|
peer.on("open", () => res());
|
|
548
596
|
peer.on("error", e => rej(e));
|
|
@@ -565,13 +613,17 @@ const ShareManager = (() => {
|
|
|
565
613
|
|
|
566
614
|
function attachSenderHandlers(peer, token, entity) {
|
|
567
615
|
peer.on("connection", (conn) => {
|
|
616
|
+
console.log("[sinain-share:sender:" + token.slice(0, 8) + "] inbound connection from", conn.peer);
|
|
617
|
+
instrumentConnection(conn, "sender");
|
|
568
618
|
patchStatus(token, "connecting");
|
|
569
619
|
conn.on("open", async () => {
|
|
570
620
|
try {
|
|
571
621
|
// Re-fetch bundle each time — keeps memory low and reflects latest state.
|
|
572
622
|
const bundle = await buildBundle(entity);
|
|
623
|
+
console.log("[sinain-share:sender:" + token.slice(0, 8) + "] sending bundle, " + bundle.length + " bytes");
|
|
573
624
|
conn.send({ type: "bundle", payload: bundle });
|
|
574
625
|
} catch (e) {
|
|
626
|
+
console.warn("[sinain-share:sender] buildBundle/send failed:", e);
|
|
575
627
|
conn.send({ type: "error", message: String(e).slice(0, 200) });
|
|
576
628
|
conn.close();
|
|
577
629
|
}
|
|
@@ -609,6 +661,7 @@ const ShareManager = (() => {
|
|
|
609
661
|
try {
|
|
610
662
|
await ensurePeerJsLoaded();
|
|
611
663
|
const peer = newPeer(share.share_token);
|
|
664
|
+
instrumentPeer(peer, "sender-resume:" + share.share_token.slice(0, 8));
|
|
612
665
|
await new Promise((res, rej) => {
|
|
613
666
|
peer.on("open", () => res());
|
|
614
667
|
peer.on("error", e => rej(e));
|
|
@@ -652,40 +705,64 @@ const ShareManager = (() => {
|
|
|
652
705
|
|
|
653
706
|
async function connectAsRecipient(token) {
|
|
654
707
|
showToast('<span class="spinner"></span> Connecting peer-to-peer…', 30_000);
|
|
708
|
+
console.log("[sinain-share:recipient:" + token.slice(0, 8) + "] connectAsRecipient start");
|
|
655
709
|
await ensurePeerJsLoaded();
|
|
656
710
|
const me = newPeer();
|
|
711
|
+
instrumentPeer(me, "recipient:" + token.slice(0, 8));
|
|
657
712
|
await new Promise((res, rej) => {
|
|
658
713
|
me.on("open", () => res());
|
|
659
714
|
me.on("error", e => rej(e));
|
|
660
715
|
setTimeout(() => rej(new Error("peerjs broker timeout")), 8000);
|
|
661
716
|
});
|
|
717
|
+
console.log("[sinain-share:recipient:" + token.slice(0, 8) + "] my peer registered, dialing", token);
|
|
662
718
|
return new Promise((resolve, reject) => {
|
|
663
719
|
const conn = me.connect(token, { reliable: true });
|
|
720
|
+
instrumentConnection(conn, "recipient");
|
|
664
721
|
const cleanup = () => { try { conn.close(); } catch {} try { me.destroy(); } catch {} };
|
|
665
722
|
const openTimeout = setTimeout(() => {
|
|
723
|
+
console.warn("[sinain-share:recipient:" + token.slice(0, 8) + "] 15s timeout — conn.open never fired. Almost certainly NAT traversal failed (no TURN). Look for ICE candidate types above — only host/srflx without 'relay' = TURN missing.");
|
|
666
724
|
cleanup();
|
|
667
725
|
reject(new Error("source offline or unreachable"));
|
|
668
726
|
}, 15_000);
|
|
669
|
-
conn.on("open", () =>
|
|
670
|
-
|
|
727
|
+
conn.on("open", () => {
|
|
728
|
+
clearTimeout(openTimeout);
|
|
729
|
+
console.log("[sinain-share:recipient:" + token.slice(0, 8) + "] DataChannel open — waiting for bundle");
|
|
730
|
+
});
|
|
731
|
+
conn.on("error", (e) => {
|
|
732
|
+
console.warn("[sinain-share:recipient:" + token.slice(0, 8) + "] conn.error", e);
|
|
733
|
+
cleanup(); reject(e);
|
|
734
|
+
});
|
|
671
735
|
conn.on("data", async (msg) => {
|
|
672
736
|
if (!msg) return;
|
|
673
737
|
if (msg.type === "error") {
|
|
738
|
+
console.warn("[sinain-share:recipient] source reported error:", msg.message);
|
|
674
739
|
cleanup();
|
|
675
740
|
reject(new Error("source error: " + msg.message));
|
|
676
741
|
return;
|
|
677
742
|
}
|
|
678
743
|
if (msg.type === "bundle") {
|
|
744
|
+
console.log("[sinain-share:recipient:" + token.slice(0, 8) + "] bundle received, " + (msg.payload && msg.payload.length) + " bytes — POSTing to /knowledge/concepts/import");
|
|
679
745
|
try {
|
|
680
746
|
const importR = await api("/knowledge/concepts/import?conflict=merge", {
|
|
681
747
|
method: "POST",
|
|
682
748
|
headers: {"Content-Type": "application/json"},
|
|
683
749
|
body: msg.payload,
|
|
684
750
|
});
|
|
751
|
+
console.log("[sinain-share:recipient] import response:", importR);
|
|
752
|
+
// CRITICAL: api() doesn't throw on {ok:false} responses — it
|
|
753
|
+
// returns the parsed body. We MUST check ok explicitly here, or
|
|
754
|
+
// a backend failure (script ENOENT, schema error, etc.) appears
|
|
755
|
+
// to the SPA as success and falls through to MissingConcept.
|
|
756
|
+
if (importR && importR.ok === false) {
|
|
757
|
+
cleanup();
|
|
758
|
+
reject(new Error(importR.error || "import failed"));
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
685
761
|
conn.send({ type: "ack" });
|
|
686
762
|
setTimeout(cleanup, 500);
|
|
687
763
|
resolve(importR);
|
|
688
764
|
} catch (e) {
|
|
765
|
+
console.warn("[sinain-share:recipient] import threw:", e);
|
|
689
766
|
cleanup();
|
|
690
767
|
reject(e);
|
|
691
768
|
}
|
|
@@ -2149,8 +2226,14 @@ export function createAppServer(deps: ServerDeps) {
|
|
|
2149
2226
|
|
|
2150
2227
|
// New "living Confluence" SPA — search-driven, LLM-rendered pages,
|
|
2151
2228
|
// bookmarks, retraction, concept transfer.
|
|
2229
|
+
// Cache-Control: no-cache forces browsers to revalidate the SPA HTML
|
|
2230
|
+
// on every navigation. Otherwise, after a sinain-core upgrade the
|
|
2231
|
+
// browser serves stale SPA from cache (bugfixes don't take effect
|
|
2232
|
+
// until the user hard-reloads). With revalidation on, ETag mismatches
|
|
2233
|
+
// are detected immediately and the new SPA is loaded.
|
|
2152
2234
|
if (req.method === "GET" && url.pathname === "/knowledge/ui") {
|
|
2153
2235
|
res.setHeader("Content-Type", "text/html");
|
|
2236
|
+
res.setHeader("Cache-Control", "no-cache, must-revalidate");
|
|
2154
2237
|
res.end(renderKnowledgeUiV2());
|
|
2155
2238
|
return;
|
|
2156
2239
|
}
|
|
@@ -2159,6 +2242,7 @@ export function createAppServer(deps: ServerDeps) {
|
|
|
2159
2242
|
// we just serve the same HTML; client-side router parses location.pathname.
|
|
2160
2243
|
if (req.method === "GET" && url.pathname.startsWith("/knowledge/ui/")) {
|
|
2161
2244
|
res.setHeader("Content-Type", "text/html");
|
|
2245
|
+
res.setHeader("Cache-Control", "no-cache, must-revalidate");
|
|
2162
2246
|
res.end(renderKnowledgeUiV2());
|
|
2163
2247
|
return;
|
|
2164
2248
|
}
|