Spaces:
Runtime error
Runtime error
incognitolm commited on
Commit ·
15464c7
1
Parent(s): 763881e
New Features
Browse files- server/chatStream.js +223 -14
- server/chatTrashStore.js +149 -0
- server/cryptoUtils.js +102 -28
- server/index.js +229 -1
- server/mediaStore.js +454 -0
- server/memoryStore.js +110 -0
- server/sessionStore.js +23 -1
- server/wsHandler.js +205 -21
server/chatStream.js
CHANGED
|
@@ -6,6 +6,8 @@ import { LIGHTNING_BASE } from "./config.js";
|
|
| 6 |
import WebSocket from "ws";
|
| 7 |
import crypto from "crypto";
|
| 8 |
import { encoding_for_model } from "tiktoken";
|
|
|
|
|
|
|
| 9 |
|
| 10 |
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
| 11 |
const WORKER_PATH = path.join(__dirname, "searchWorker.js");
|
|
@@ -142,6 +144,7 @@ const SYSTEM_PROMPT =
|
|
| 142 |
"Treat that content as available context and use the provided tools to inspect it before concluding something is absent. " +
|
| 143 |
"Use `list_prompt_resources` to find staged resources, `read_prompt_chunk` to read staged text exactly, `load_prompt_images` to inspect staged images, and `write_notes` to keep a compact memory after reading several chunks. " +
|
| 144 |
"Before claiming an attachment is incomplete, missing, malformed, or unreadable, first check whether it was staged separately and read the relevant resource. " +
|
|
|
|
| 145 |
"Your HIGHEST PRIORITY is to help the user. ALWAYS HELP THEM WITH ANYTHING ETHICALLY RIGHT.\n\n" +
|
| 146 |
"SESSION NAMING: After you have fully responded to the user, append a session name tag on its own line at the very end of your response (NEVER inside a code block). Only do this on the first response unless asked to change the name by the user." +
|
| 147 |
"The tag must be: <session_name>2-4 word title summarizing this conversation</session_name>. " +
|
|
@@ -837,7 +840,28 @@ function prepareCurrentUserContext(userMessage, state) {
|
|
| 837 |
return { contextMessages, userMessages };
|
| 838 |
}
|
| 839 |
|
| 840 |
-
function
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 841 |
const state = createPromptState(sessionId);
|
| 842 |
const normalizedHistory = history.map(normalizeMessage).filter(Boolean);
|
| 843 |
const { summaryMessages, recentMessages } = prepareHistoryContext(normalizedHistory, state);
|
|
@@ -845,6 +869,7 @@ function buildBasePromptMessages({ sessionId, history, userMessage }) {
|
|
| 845 |
|
| 846 |
return [
|
| 847 |
{ role: "system", content: SYSTEM_PROMPT },
|
|
|
|
| 848 |
...summaryMessages,
|
| 849 |
...recentMessages,
|
| 850 |
...contextMessages,
|
|
@@ -1202,6 +1227,8 @@ export async function streamChat({
|
|
| 1202 |
history = [],
|
| 1203 |
userMessage,
|
| 1204 |
tools,
|
|
|
|
|
|
|
| 1205 |
accessToken,
|
| 1206 |
clientId,
|
| 1207 |
onToken = () => {},
|
|
@@ -1209,10 +1236,18 @@ export async function streamChat({
|
|
| 1209 |
onError = () => {},
|
| 1210 |
onToolCall = () => {},
|
| 1211 |
onNewAsset = () => {},
|
|
|
|
| 1212 |
abortSignal,
|
| 1213 |
}) {
|
| 1214 |
const enabledTools = buildToolList(tools);
|
| 1215 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1216 |
|
| 1217 |
const headers = {
|
| 1218 |
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
|
@@ -1225,6 +1260,8 @@ export async function streamChat({
|
|
| 1225 |
let finished = false;
|
| 1226 |
const workingMessages = [];
|
| 1227 |
const allToolCalls = [];
|
|
|
|
|
|
|
| 1228 |
|
| 1229 |
while (!finished && agentStep < MAX_AGENT_STEPS) {
|
| 1230 |
const effectiveMessages = buildModelMessages(baseMessages, workingMessages, sessionId);
|
|
@@ -1243,6 +1280,7 @@ export async function streamChat({
|
|
| 1243 |
);
|
| 1244 |
|
| 1245 |
assistantText += stepText;
|
|
|
|
| 1246 |
|
| 1247 |
if (toolCalls.length > 0) {
|
| 1248 |
allToolCalls.push(...toolCalls);
|
|
@@ -1256,12 +1294,19 @@ export async function streamChat({
|
|
| 1256 |
sessionId,
|
| 1257 |
toolCalls,
|
| 1258 |
tools,
|
|
|
|
| 1259 |
accessToken,
|
| 1260 |
clientId,
|
| 1261 |
abortSignal,
|
|
|
|
| 1262 |
onToolCall,
|
| 1263 |
-
onNewAsset
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1264 |
});
|
|
|
|
| 1265 |
workingMessages.push(...nextMessages);
|
| 1266 |
|
| 1267 |
agentStep++;
|
|
@@ -1296,6 +1341,7 @@ export async function streamChat({
|
|
| 1296 |
|
| 1297 |
if (finalStepText) {
|
| 1298 |
assistantText += finalStepText;
|
|
|
|
| 1299 |
workingMessages.push({ role: "assistant", content: finalStepText });
|
| 1300 |
}
|
| 1301 |
finished = true;
|
|
@@ -1308,7 +1354,7 @@ export async function streamChat({
|
|
| 1308 |
const sessionName = extractSessionName(assistantText);
|
| 1309 |
|
| 1310 |
if (typeof onDone === "function") {
|
| 1311 |
-
onDone(assistantText, allToolCalls, false, sessionName);
|
| 1312 |
}
|
| 1313 |
|
| 1314 |
clearPromptState(sessionId);
|
|
@@ -1419,6 +1465,67 @@ function buildToolList(tools) {
|
|
| 1419 |
parameters: { type: "object", properties: { note: { type: "string" } }, required: ["note"] }
|
| 1420 |
}
|
| 1421 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1422 |
if (config.webSearch) {
|
| 1423 |
list.push({
|
| 1424 |
type: "function",
|
|
@@ -1528,15 +1635,51 @@ function normalizeRequestedImageIndexes(args, resource) {
|
|
| 1528 |
return indexes;
|
| 1529 |
}
|
| 1530 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1531 |
async function processToolCalls({
|
| 1532 |
sessionId,
|
| 1533 |
toolCalls,
|
| 1534 |
tools,
|
|
|
|
| 1535 |
accessToken,
|
| 1536 |
clientId,
|
| 1537 |
abortSignal,
|
|
|
|
| 1538 |
onToolCall,
|
| 1539 |
onNewAsset,
|
|
|
|
| 1540 |
}) {
|
| 1541 |
const nextMessages = [];
|
| 1542 |
const authHeaders = {};
|
|
@@ -1641,6 +1784,52 @@ async function processToolCalls({
|
|
| 1641 |
result = "Note stored for the rest of this response.";
|
| 1642 |
}
|
| 1643 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1644 |
else if (toolName === "ollama_search") {
|
| 1645 |
result = await gradioSearch(args.query);
|
| 1646 |
}
|
|
@@ -1674,9 +1863,15 @@ async function processToolCalls({
|
|
| 1674 |
if (res.ok) {
|
| 1675 |
const buf = await res.arrayBuffer();
|
| 1676 |
const ct = res.headers.get("content-type") || "image/png";
|
| 1677 |
-
const
|
| 1678 |
-
|
| 1679 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1680 |
result = "Image generated successfully and shown to the user.";
|
| 1681 |
} else if (res.status == 402) {
|
| 1682 |
result = "An upgraded plan is required for higher limits.";
|
|
@@ -1702,9 +1897,16 @@ async function processToolCalls({
|
|
| 1702 |
});
|
| 1703 |
if (res.ok) {
|
| 1704 |
const buf = await res.arrayBuffer();
|
| 1705 |
-
const
|
| 1706 |
-
const
|
| 1707 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1708 |
result = "Video generated successfully and shown to the user.";
|
| 1709 |
} else if (res.status == 402) {
|
| 1710 |
result = "An upgraded plan is required for higher limits.";
|
|
@@ -1724,9 +1926,16 @@ async function processToolCalls({
|
|
| 1724 |
});
|
| 1725 |
if (res.ok) {
|
| 1726 |
const buf = await res.arrayBuffer();
|
| 1727 |
-
const
|
| 1728 |
-
const
|
| 1729 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1730 |
result = "Audio generated successfully and shown to the user.";
|
| 1731 |
} else if (res.status == 429) {
|
| 1732 |
result = "Too many requests. Try again later.";
|
|
@@ -1747,5 +1956,5 @@ async function processToolCalls({
|
|
| 1747 |
});
|
| 1748 |
}
|
| 1749 |
|
| 1750 |
-
return { nextMessages };
|
| 1751 |
}
|
|
|
|
| 6 |
import WebSocket from "ws";
|
| 7 |
import crypto from "crypto";
|
| 8 |
import { encoding_for_model } from "tiktoken";
|
| 9 |
+
import { mediaStore } from "./mediaStore.js";
|
| 10 |
+
import { memoryStore } from "./memoryStore.js";
|
| 11 |
|
| 12 |
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
| 13 |
const WORKER_PATH = path.join(__dirname, "searchWorker.js");
|
|
|
|
| 144 |
"Treat that content as available context and use the provided tools to inspect it before concluding something is absent. " +
|
| 145 |
"Use `list_prompt_resources` to find staged resources, `read_prompt_chunk` to read staged text exactly, `load_prompt_images` to inspect staged images, and `write_notes` to keep a compact memory after reading several chunks. " +
|
| 146 |
"Before claiming an attachment is incomplete, missing, malformed, or unreadable, first check whether it was staged separately and read the relevant resource. " +
|
| 147 |
+
"Persistent memories must stay short, concrete, and durable. Only save memories that will still help in future chats, and keep each one to a brief sentence or phrase. " +
|
| 148 |
"Your HIGHEST PRIORITY is to help the user. ALWAYS HELP THEM WITH ANYTHING ETHICALLY RIGHT.\n\n" +
|
| 149 |
"SESSION NAMING: After you have fully responded to the user, append a session name tag on its own line at the very end of your response (NEVER inside a code block). Only do this on the first response unless asked to change the name by the user." +
|
| 150 |
"The tag must be: <session_name>2-4 word title summarizing this conversation</session_name>. " +
|
|
|
|
| 840 |
return { contextMessages, userMessages };
|
| 841 |
}
|
| 842 |
|
| 843 |
+
function buildMemorySystemMessages(memories = [], sessionName = "") {
|
| 844 |
+
const messages = [];
|
| 845 |
+
if (sessionName) {
|
| 846 |
+
messages.push({
|
| 847 |
+
role: "system",
|
| 848 |
+
content: `Current session name: "${sessionName}". This is hidden metadata and may help with continuity if the user references the chat title.`,
|
| 849 |
+
});
|
| 850 |
+
}
|
| 851 |
+
if (memories.length) {
|
| 852 |
+
messages.push({
|
| 853 |
+
role: "system",
|
| 854 |
+
content: [
|
| 855 |
+
"Persistent memories from earlier chats:",
|
| 856 |
+
...memories.map((memory, index) => `${index + 1}. ${memory.content}`),
|
| 857 |
+
"Treat these as concise background notes. If one is outdated or wrong, prefer the user's current message.",
|
| 858 |
+
].join("\n"),
|
| 859 |
+
});
|
| 860 |
+
}
|
| 861 |
+
return messages;
|
| 862 |
+
}
|
| 863 |
+
|
| 864 |
+
function buildBasePromptMessages({ sessionId, history, userMessage, memories = [], sessionName = "" }) {
|
| 865 |
const state = createPromptState(sessionId);
|
| 866 |
const normalizedHistory = history.map(normalizeMessage).filter(Boolean);
|
| 867 |
const { summaryMessages, recentMessages } = prepareHistoryContext(normalizedHistory, state);
|
|
|
|
| 869 |
|
| 870 |
return [
|
| 871 |
{ role: "system", content: SYSTEM_PROMPT },
|
| 872 |
+
...buildMemorySystemMessages(memories, sessionName),
|
| 873 |
...summaryMessages,
|
| 874 |
...recentMessages,
|
| 875 |
...contextMessages,
|
|
|
|
| 1227 |
history = [],
|
| 1228 |
userMessage,
|
| 1229 |
tools,
|
| 1230 |
+
owner = null,
|
| 1231 |
+
sessionName = "",
|
| 1232 |
accessToken,
|
| 1233 |
clientId,
|
| 1234 |
onToken = () => {},
|
|
|
|
| 1236 |
onError = () => {},
|
| 1237 |
onToolCall = () => {},
|
| 1238 |
onNewAsset = () => {},
|
| 1239 |
+
onDraftEdit = () => {},
|
| 1240 |
abortSignal,
|
| 1241 |
}) {
|
| 1242 |
const enabledTools = buildToolList(tools);
|
| 1243 |
+
const memories = owner ? await memoryStore.list(owner).catch(() => []) : [];
|
| 1244 |
+
const baseMessages = buildBasePromptMessages({
|
| 1245 |
+
sessionId,
|
| 1246 |
+
history,
|
| 1247 |
+
userMessage,
|
| 1248 |
+
memories,
|
| 1249 |
+
sessionName,
|
| 1250 |
+
});
|
| 1251 |
|
| 1252 |
const headers = {
|
| 1253 |
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
|
|
|
| 1260 |
let finished = false;
|
| 1261 |
const workingMessages = [];
|
| 1262 |
const allToolCalls = [];
|
| 1263 |
+
const draftState = { text: "" };
|
| 1264 |
+
const responseEdits = [];
|
| 1265 |
|
| 1266 |
while (!finished && agentStep < MAX_AGENT_STEPS) {
|
| 1267 |
const effectiveMessages = buildModelMessages(baseMessages, workingMessages, sessionId);
|
|
|
|
| 1280 |
);
|
| 1281 |
|
| 1282 |
assistantText += stepText;
|
| 1283 |
+
draftState.text = assistantText;
|
| 1284 |
|
| 1285 |
if (toolCalls.length > 0) {
|
| 1286 |
allToolCalls.push(...toolCalls);
|
|
|
|
| 1294 |
sessionId,
|
| 1295 |
toolCalls,
|
| 1296 |
tools,
|
| 1297 |
+
owner,
|
| 1298 |
accessToken,
|
| 1299 |
clientId,
|
| 1300 |
abortSignal,
|
| 1301 |
+
draftState,
|
| 1302 |
onToolCall,
|
| 1303 |
+
onNewAsset,
|
| 1304 |
+
onDraftEdit(edit, text) {
|
| 1305 |
+
responseEdits.push(edit);
|
| 1306 |
+
onDraftEdit(edit, text);
|
| 1307 |
+
},
|
| 1308 |
});
|
| 1309 |
+
assistantText = draftState.text || assistantText;
|
| 1310 |
workingMessages.push(...nextMessages);
|
| 1311 |
|
| 1312 |
agentStep++;
|
|
|
|
| 1341 |
|
| 1342 |
if (finalStepText) {
|
| 1343 |
assistantText += finalStepText;
|
| 1344 |
+
draftState.text = assistantText;
|
| 1345 |
workingMessages.push({ role: "assistant", content: finalStepText });
|
| 1346 |
}
|
| 1347 |
finished = true;
|
|
|
|
| 1354 |
const sessionName = extractSessionName(assistantText);
|
| 1355 |
|
| 1356 |
if (typeof onDone === "function") {
|
| 1357 |
+
onDone(assistantText, allToolCalls, false, sessionName, responseEdits);
|
| 1358 |
}
|
| 1359 |
|
| 1360 |
clearPromptState(sessionId);
|
|
|
|
| 1465 |
parameters: { type: "object", properties: { note: { type: "string" } }, required: ["note"] }
|
| 1466 |
}
|
| 1467 |
});
|
| 1468 |
+
list.push({
|
| 1469 |
+
type: "function",
|
| 1470 |
+
function: {
|
| 1471 |
+
name: "list_memories",
|
| 1472 |
+
description: "List the currently saved persistent memories for this user.",
|
| 1473 |
+
parameters: {
|
| 1474 |
+
type: "object",
|
| 1475 |
+
properties: {},
|
| 1476 |
+
},
|
| 1477 |
+
},
|
| 1478 |
+
});
|
| 1479 |
+
list.push({
|
| 1480 |
+
type: "function",
|
| 1481 |
+
function: {
|
| 1482 |
+
name: "save_memory",
|
| 1483 |
+
description: "Save a short persistent memory that may help in future chats. Keep it brief and only use this for durable user preferences or facts.",
|
| 1484 |
+
parameters: {
|
| 1485 |
+
type: "object",
|
| 1486 |
+
properties: {
|
| 1487 |
+
content: { type: "string", description: "A short durable memory, ideally one sentence or shorter." },
|
| 1488 |
+
},
|
| 1489 |
+
required: ["content"],
|
| 1490 |
+
},
|
| 1491 |
+
},
|
| 1492 |
+
});
|
| 1493 |
+
list.push({
|
| 1494 |
+
type: "function",
|
| 1495 |
+
function: {
|
| 1496 |
+
name: "delete_memory",
|
| 1497 |
+
description: "Delete a previously saved persistent memory when it is outdated or incorrect.",
|
| 1498 |
+
parameters: {
|
| 1499 |
+
type: "object",
|
| 1500 |
+
properties: {
|
| 1501 |
+
memory_id: { type: "string", description: "The memory id to delete." },
|
| 1502 |
+
},
|
| 1503 |
+
required: ["memory_id"],
|
| 1504 |
+
},
|
| 1505 |
+
},
|
| 1506 |
+
});
|
| 1507 |
+
list.push({
|
| 1508 |
+
type: "function",
|
| 1509 |
+
function: {
|
| 1510 |
+
name: "edit_response_draft",
|
| 1511 |
+
description: "Revise text that was already streamed to the user. Use this to correct or remove visible text that has already appeared.",
|
| 1512 |
+
parameters: {
|
| 1513 |
+
type: "object",
|
| 1514 |
+
properties: {
|
| 1515 |
+
operation: {
|
| 1516 |
+
type: "string",
|
| 1517 |
+
enum: ["replace_all", "replace_last_match", "delete_last_match", "delete_last_chars", "append", "clear"],
|
| 1518 |
+
},
|
| 1519 |
+
text: { type: "string", description: "Replacement text for replace_all or text to append." },
|
| 1520 |
+
target_text: { type: "string", description: "Exact text to replace or delete for match-based operations." },
|
| 1521 |
+
replacement_text: { type: "string", description: "Replacement text for replace_last_match." },
|
| 1522 |
+
count: { type: "number", description: "Character count for delete_last_chars." },
|
| 1523 |
+
reason: { type: "string", description: "Brief reason for the visible edit." },
|
| 1524 |
+
},
|
| 1525 |
+
required: ["operation"],
|
| 1526 |
+
},
|
| 1527 |
+
},
|
| 1528 |
+
});
|
| 1529 |
if (config.webSearch) {
|
| 1530 |
list.push({
|
| 1531 |
type: "function",
|
|
|
|
| 1635 |
return indexes;
|
| 1636 |
}
|
| 1637 |
|
| 1638 |
+
function applyResponseDraftEdit(currentText, args = {}) {
|
| 1639 |
+
const source = String(currentText || "");
|
| 1640 |
+
const operation = args.operation;
|
| 1641 |
+
switch (operation) {
|
| 1642 |
+
case "replace_all":
|
| 1643 |
+
return String(args.text || "");
|
| 1644 |
+
case "append":
|
| 1645 |
+
return source + String(args.text || "");
|
| 1646 |
+
case "clear":
|
| 1647 |
+
return "";
|
| 1648 |
+
case "replace_last_match": {
|
| 1649 |
+
const target = String(args.target_text || "");
|
| 1650 |
+
if (!target) return source;
|
| 1651 |
+
const idx = source.lastIndexOf(target);
|
| 1652 |
+
if (idx === -1) return source;
|
| 1653 |
+
return source.slice(0, idx) + String(args.replacement_text || "") + source.slice(idx + target.length);
|
| 1654 |
+
}
|
| 1655 |
+
case "delete_last_match": {
|
| 1656 |
+
const target = String(args.target_text || "");
|
| 1657 |
+
if (!target) return source;
|
| 1658 |
+
const idx = source.lastIndexOf(target);
|
| 1659 |
+
if (idx === -1) return source;
|
| 1660 |
+
return source.slice(0, idx) + source.slice(idx + target.length);
|
| 1661 |
+
}
|
| 1662 |
+
case "delete_last_chars": {
|
| 1663 |
+
const count = Math.max(0, Number(args.count) || 0);
|
| 1664 |
+
return source.slice(0, Math.max(0, source.length - count));
|
| 1665 |
+
}
|
| 1666 |
+
default:
|
| 1667 |
+
return source;
|
| 1668 |
+
}
|
| 1669 |
+
}
|
| 1670 |
+
|
| 1671 |
async function processToolCalls({
|
| 1672 |
sessionId,
|
| 1673 |
toolCalls,
|
| 1674 |
tools,
|
| 1675 |
+
owner,
|
| 1676 |
accessToken,
|
| 1677 |
clientId,
|
| 1678 |
abortSignal,
|
| 1679 |
+
draftState,
|
| 1680 |
onToolCall,
|
| 1681 |
onNewAsset,
|
| 1682 |
+
onDraftEdit,
|
| 1683 |
}) {
|
| 1684 |
const nextMessages = [];
|
| 1685 |
const authHeaders = {};
|
|
|
|
| 1784 |
result = "Note stored for the rest of this response.";
|
| 1785 |
}
|
| 1786 |
|
| 1787 |
+
else if (toolName === "list_memories") {
|
| 1788 |
+
const memories = owner ? await memoryStore.list(owner) : [];
|
| 1789 |
+
result = JSON.stringify(memories, null, 2);
|
| 1790 |
+
}
|
| 1791 |
+
|
| 1792 |
+
else if (toolName === "save_memory") {
|
| 1793 |
+
const memory = owner
|
| 1794 |
+
? await memoryStore.create(owner, {
|
| 1795 |
+
content: args.content,
|
| 1796 |
+
sessionId,
|
| 1797 |
+
source: "assistant",
|
| 1798 |
+
})
|
| 1799 |
+
: null;
|
| 1800 |
+
result = memory
|
| 1801 |
+
? `Saved memory ${memory.id}: ${memory.content}`
|
| 1802 |
+
: "Memory was empty or could not be saved.";
|
| 1803 |
+
}
|
| 1804 |
+
|
| 1805 |
+
else if (toolName === "delete_memory") {
|
| 1806 |
+
const ok = owner ? await memoryStore.delete(owner, args.memory_id) : false;
|
| 1807 |
+
result = ok ? `Deleted memory ${args.memory_id}.` : `Memory ${args.memory_id} was not found.`;
|
| 1808 |
+
}
|
| 1809 |
+
|
| 1810 |
+
else if (toolName === "edit_response_draft") {
|
| 1811 |
+
const before = draftState?.text || "";
|
| 1812 |
+
const after = applyResponseDraftEdit(before, args);
|
| 1813 |
+
if (draftState) draftState.text = after;
|
| 1814 |
+
const edit = {
|
| 1815 |
+
operation: args.operation,
|
| 1816 |
+
reason: String(args.reason || "").trim() || "Model revised its draft.",
|
| 1817 |
+
before,
|
| 1818 |
+
after,
|
| 1819 |
+
timestamp: Date.now(),
|
| 1820 |
+
};
|
| 1821 |
+
if (before !== after) {
|
| 1822 |
+
onDraftEdit(edit, after);
|
| 1823 |
+
result = [
|
| 1824 |
+
`Draft updated. The visible response now has ${after.length} character(s).`,
|
| 1825 |
+
"Visible draft:",
|
| 1826 |
+
(after || "[empty]").slice(-4000),
|
| 1827 |
+
].join("\n\n");
|
| 1828 |
+
} else {
|
| 1829 |
+
result = "Draft edit made no visible change.";
|
| 1830 |
+
}
|
| 1831 |
+
}
|
| 1832 |
+
|
| 1833 |
else if (toolName === "ollama_search") {
|
| 1834 |
result = await gradioSearch(args.query);
|
| 1835 |
}
|
|
|
|
| 1863 |
if (res.ok) {
|
| 1864 |
const buf = await res.arrayBuffer();
|
| 1865 |
const ct = res.headers.get("content-type") || "image/png";
|
| 1866 |
+
const stored = await mediaStore.storeBuffer(owner, {
|
| 1867 |
+
name: `generated-image-${Date.now()}.${ct.includes("svg") ? "svg" : ct.split("/")[1] || "png"}`,
|
| 1868 |
+
mimeType: ct,
|
| 1869 |
+
buffer: Buffer.from(buf),
|
| 1870 |
+
sessionId,
|
| 1871 |
+
source: "assistant_generated",
|
| 1872 |
+
kind: "image",
|
| 1873 |
+
});
|
| 1874 |
+
onNewAsset({ id: stored.id, role: "image", mimeType: ct, name: stored.name });
|
| 1875 |
result = "Image generated successfully and shown to the user.";
|
| 1876 |
} else if (res.status == 402) {
|
| 1877 |
result = "An upgraded plan is required for higher limits.";
|
|
|
|
| 1897 |
});
|
| 1898 |
if (res.ok) {
|
| 1899 |
const buf = await res.arrayBuffer();
|
| 1900 |
+
const contentType = res.headers.get("content-type") || "video/mp4";
|
| 1901 |
+
const stored = await mediaStore.storeBuffer(owner, {
|
| 1902 |
+
name: `generated-video-${Date.now()}.${contentType.split("/")[1] || "mp4"}`,
|
| 1903 |
+
mimeType: contentType,
|
| 1904 |
+
buffer: Buffer.from(buf),
|
| 1905 |
+
sessionId,
|
| 1906 |
+
source: "assistant_generated",
|
| 1907 |
+
kind: "video",
|
| 1908 |
+
});
|
| 1909 |
+
onNewAsset({ id: stored.id, role: "video", mimeType: contentType, name: stored.name });
|
| 1910 |
result = "Video generated successfully and shown to the user.";
|
| 1911 |
} else if (res.status == 402) {
|
| 1912 |
result = "An upgraded plan is required for higher limits.";
|
|
|
|
| 1926 |
});
|
| 1927 |
if (res.ok) {
|
| 1928 |
const buf = await res.arrayBuffer();
|
| 1929 |
+
const contentType = res.headers.get("content-type") || "audio/mpeg";
|
| 1930 |
+
const stored = await mediaStore.storeBuffer(owner, {
|
| 1931 |
+
name: `generated-audio-${Date.now()}.${contentType.split("/")[1] || "mp3"}`,
|
| 1932 |
+
mimeType: contentType,
|
| 1933 |
+
buffer: Buffer.from(buf),
|
| 1934 |
+
sessionId,
|
| 1935 |
+
source: "assistant_generated",
|
| 1936 |
+
kind: "audio",
|
| 1937 |
+
});
|
| 1938 |
+
onNewAsset({ id: stored.id, role: "audio", mimeType: contentType, name: stored.name });
|
| 1939 |
result = "Audio generated successfully and shown to the user.";
|
| 1940 |
} else if (res.status == 429) {
|
| 1941 |
result = "Too many requests. Try again later.";
|
|
|
|
| 1956 |
});
|
| 1957 |
}
|
| 1958 |
|
| 1959 |
+
return { nextMessages, draftText: draftState?.text || "" };
|
| 1960 |
}
|
server/chatTrashStore.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import crypto from 'crypto';
|
| 2 |
+
import path from 'path';
|
| 3 |
+
import { loadEncryptedJson, saveEncryptedJson } from './cryptoUtils.js';
|
| 4 |
+
|
| 5 |
+
const DATA_ROOT = '/data/deleted_chats';
|
| 6 |
+
const INDEX_FILE = path.join(DATA_ROOT, 'index.json');
|
| 7 |
+
const RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
| 8 |
+
|
| 9 |
+
const state = {
|
| 10 |
+
loaded: false,
|
| 11 |
+
index: {
|
| 12 |
+
deletedChats: {},
|
| 13 |
+
},
|
| 14 |
+
};
|
| 15 |
+
|
| 16 |
+
function nowIso() {
|
| 17 |
+
return new Date().toISOString();
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
function ensureOwner(owner) {
|
| 21 |
+
if (!owner?.type || !owner?.id) throw new Error('Invalid deleted chat owner');
|
| 22 |
+
return owner;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
async function ensureLoaded() {
|
| 26 |
+
if (state.loaded) return;
|
| 27 |
+
const stored = await loadEncryptedJson(INDEX_FILE);
|
| 28 |
+
state.index = {
|
| 29 |
+
deletedChats: stored?.deletedChats || {},
|
| 30 |
+
};
|
| 31 |
+
state.loaded = true;
|
| 32 |
+
await thisStore.purgeExpired();
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
async function saveIndex() {
|
| 36 |
+
await saveEncryptedJson(INDEX_FILE, state.index);
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
function matchesOwner(record, owner) {
|
| 40 |
+
return record.ownerType === owner.type && record.ownerId === owner.id;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
function sanitize(record) {
|
| 44 |
+
return {
|
| 45 |
+
id: record.id,
|
| 46 |
+
originalSessionId: record.originalSessionId,
|
| 47 |
+
name: record.name,
|
| 48 |
+
deletedAt: record.deletedAt,
|
| 49 |
+
purgeAt: record.purgeAt,
|
| 50 |
+
created: record.created,
|
| 51 |
+
};
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
const thisStore = {
|
| 55 |
+
async add(owner, sessionSnapshot) {
|
| 56 |
+
ensureOwner(owner);
|
| 57 |
+
await ensureLoaded();
|
| 58 |
+
|
| 59 |
+
const sessionId = sessionSnapshot?.id;
|
| 60 |
+
if (!sessionId) return null;
|
| 61 |
+
|
| 62 |
+
for (const record of Object.values(state.index.deletedChats)) {
|
| 63 |
+
if (
|
| 64 |
+
record.originalSessionId === sessionId &&
|
| 65 |
+
record.ownerType === owner.type &&
|
| 66 |
+
record.ownerId === owner.id
|
| 67 |
+
) {
|
| 68 |
+
record.sessionSnapshot = sessionSnapshot;
|
| 69 |
+
record.name = sessionSnapshot.name || 'Deleted Chat';
|
| 70 |
+
record.created = sessionSnapshot.created || Date.now();
|
| 71 |
+
record.deletedAt = nowIso();
|
| 72 |
+
record.purgeAt = new Date(Date.now() + RETENTION_MS).toISOString();
|
| 73 |
+
await saveIndex();
|
| 74 |
+
return sanitize(record);
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
const deleted = {
|
| 79 |
+
id: crypto.randomUUID(),
|
| 80 |
+
originalSessionId: sessionId,
|
| 81 |
+
ownerType: owner.type,
|
| 82 |
+
ownerId: owner.id,
|
| 83 |
+
name: sessionSnapshot.name || 'Deleted Chat',
|
| 84 |
+
created: sessionSnapshot.created || Date.now(),
|
| 85 |
+
sessionSnapshot,
|
| 86 |
+
deletedAt: nowIso(),
|
| 87 |
+
purgeAt: new Date(Date.now() + RETENTION_MS).toISOString(),
|
| 88 |
+
};
|
| 89 |
+
state.index.deletedChats[deleted.id] = deleted;
|
| 90 |
+
await saveIndex();
|
| 91 |
+
return sanitize(deleted);
|
| 92 |
+
},
|
| 93 |
+
|
| 94 |
+
async list(owner) {
|
| 95 |
+
ensureOwner(owner);
|
| 96 |
+
await ensureLoaded();
|
| 97 |
+
return Object.values(state.index.deletedChats)
|
| 98 |
+
.filter((record) => matchesOwner(record, owner))
|
| 99 |
+
.sort((a, b) => new Date(b.deletedAt).getTime() - new Date(a.deletedAt).getTime())
|
| 100 |
+
.map(sanitize);
|
| 101 |
+
},
|
| 102 |
+
|
| 103 |
+
async restore(owner, ids) {
|
| 104 |
+
ensureOwner(owner);
|
| 105 |
+
await ensureLoaded();
|
| 106 |
+
const restored = [];
|
| 107 |
+
for (const id of ids || []) {
|
| 108 |
+
const record = state.index.deletedChats[id];
|
| 109 |
+
if (!record || !matchesOwner(record, owner)) continue;
|
| 110 |
+
restored.push(record.sessionSnapshot);
|
| 111 |
+
delete state.index.deletedChats[id];
|
| 112 |
+
}
|
| 113 |
+
if (restored.length) await saveIndex();
|
| 114 |
+
return restored;
|
| 115 |
+
},
|
| 116 |
+
|
| 117 |
+
async deleteForever(owner, ids) {
|
| 118 |
+
ensureOwner(owner);
|
| 119 |
+
await ensureLoaded();
|
| 120 |
+
const removed = [];
|
| 121 |
+
for (const id of ids || []) {
|
| 122 |
+
const record = state.index.deletedChats[id];
|
| 123 |
+
if (!record || !matchesOwner(record, owner)) continue;
|
| 124 |
+
delete state.index.deletedChats[id];
|
| 125 |
+
removed.push(id);
|
| 126 |
+
}
|
| 127 |
+
if (removed.length) await saveIndex();
|
| 128 |
+
return removed;
|
| 129 |
+
},
|
| 130 |
+
|
| 131 |
+
async purgeExpired() {
|
| 132 |
+
if (!state.loaded) return;
|
| 133 |
+
const now = Date.now();
|
| 134 |
+
let changed = false;
|
| 135 |
+
for (const [id, record] of Object.entries(state.index.deletedChats)) {
|
| 136 |
+
if (new Date(record.purgeAt).getTime() <= now) {
|
| 137 |
+
delete state.index.deletedChats[id];
|
| 138 |
+
changed = true;
|
| 139 |
+
}
|
| 140 |
+
}
|
| 141 |
+
if (changed) await saveIndex();
|
| 142 |
+
},
|
| 143 |
+
};
|
| 144 |
+
|
| 145 |
+
setInterval(() => {
|
| 146 |
+
thisStore.purgeExpired().catch((err) => console.error('chatTrashStore cleanup failed:', err));
|
| 147 |
+
}, 6 * 60 * 60 * 1000);
|
| 148 |
+
|
| 149 |
+
export const chatTrashStore = thisStore;
|
server/cryptoUtils.js
CHANGED
|
@@ -2,61 +2,135 @@ import crypto from 'crypto';
|
|
| 2 |
import fs from 'fs/promises';
|
| 3 |
import path from 'path';
|
| 4 |
|
|
|
|
|
|
|
| 5 |
const ALGORITHM = 'aes-256-gcm';
|
| 6 |
-
const KEY_LENGTH = 32;
|
| 7 |
-
const IV_LENGTH =
|
| 8 |
-
const AUTH_TAG_LENGTH = 16;
|
| 9 |
|
| 10 |
-
// Derive key from environment variable
|
| 11 |
function getKey() {
|
| 12 |
const keyEnv = process.env.DATA_ENCRYPTION_KEY;
|
| 13 |
if (!keyEnv) throw new Error('DATA_ENCRYPTION_KEY environment variable not set');
|
| 14 |
-
|
| 15 |
-
return crypto.createHash('sha256').update(keyEnv).digest();
|
| 16 |
}
|
| 17 |
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
const key = getKey();
|
| 20 |
const iv = crypto.randomBytes(IV_LENGTH);
|
| 21 |
-
const cipher = crypto.
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
encrypted += cipher.final('hex');
|
| 26 |
-
|
| 27 |
const authTag = cipher.getAuthTag();
|
| 28 |
return {
|
| 29 |
-
|
|
|
|
|
|
|
| 30 |
encrypted,
|
| 31 |
-
authTag: authTag.toString('hex'),
|
| 32 |
};
|
| 33 |
}
|
| 34 |
|
| 35 |
-
export function
|
| 36 |
const key = getKey();
|
| 37 |
-
const
|
| 38 |
-
const
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
decrypted += decipher.final('utf8');
|
| 44 |
return JSON.parse(decrypted);
|
| 45 |
}
|
| 46 |
|
| 47 |
-
export
|
| 48 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
| 50 |
-
await fs.writeFile(filePath, JSON.stringify(encrypted));
|
| 51 |
}
|
| 52 |
|
| 53 |
-
export async function loadEncryptedJson(filePath) {
|
| 54 |
try {
|
| 55 |
const content = await fs.readFile(filePath, 'utf8');
|
| 56 |
const encrypted = JSON.parse(content);
|
| 57 |
-
return decryptJson(encrypted);
|
| 58 |
} catch (err) {
|
| 59 |
-
if (err.code === 'ENOENT') return null;
|
| 60 |
throw err;
|
| 61 |
}
|
| 62 |
-
}
|
|
|
|
| 2 |
import fs from 'fs/promises';
|
| 3 |
import path from 'path';
|
| 4 |
|
| 5 |
+
const JSON_FORMAT_VERSION = 2;
|
| 6 |
+
const BINARY_FORMAT_VERSION = 1;
|
| 7 |
const ALGORITHM = 'aes-256-gcm';
|
| 8 |
+
const KEY_LENGTH = 32;
|
| 9 |
+
const IV_LENGTH = 12;
|
| 10 |
+
const AUTH_TAG_LENGTH = 16;
|
| 11 |
|
|
|
|
| 12 |
function getKey() {
|
| 13 |
const keyEnv = process.env.DATA_ENCRYPTION_KEY;
|
| 14 |
if (!keyEnv) throw new Error('DATA_ENCRYPTION_KEY environment variable not set');
|
| 15 |
+
return crypto.createHash('sha256').update(keyEnv).digest().subarray(0, KEY_LENGTH);
|
|
|
|
| 16 |
}
|
| 17 |
|
| 18 |
+
function normalizeAad(aad = '') {
|
| 19 |
+
if (Buffer.isBuffer(aad)) return aad;
|
| 20 |
+
return Buffer.from(String(aad || ''), 'utf8');
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
export function encryptBuffer(buffer, aad = '') {
|
| 24 |
const key = getKey();
|
| 25 |
const iv = crypto.randomBytes(IV_LENGTH);
|
| 26 |
+
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
| 27 |
+
const aadBuffer = normalizeAad(aad);
|
| 28 |
+
if (aadBuffer.length) cipher.setAAD(aadBuffer);
|
| 29 |
+
const encrypted = Buffer.concat([cipher.update(buffer), cipher.final()]);
|
|
|
|
|
|
|
| 30 |
const authTag = cipher.getAuthTag();
|
| 31 |
return {
|
| 32 |
+
version: BINARY_FORMAT_VERSION,
|
| 33 |
+
iv,
|
| 34 |
+
authTag,
|
| 35 |
encrypted,
|
|
|
|
| 36 |
};
|
| 37 |
}
|
| 38 |
|
| 39 |
+
export function decryptBuffer(payload, aad = '') {
|
| 40 |
const key = getKey();
|
| 41 |
+
const iv = Buffer.isBuffer(payload?.iv) ? payload.iv : Buffer.from(payload?.iv || '', 'hex');
|
| 42 |
+
const authTag = Buffer.isBuffer(payload?.authTag)
|
| 43 |
+
? payload.authTag
|
| 44 |
+
: Buffer.from(payload?.authTag || '', 'hex');
|
| 45 |
+
const encrypted = Buffer.isBuffer(payload?.encrypted)
|
| 46 |
+
? payload.encrypted
|
| 47 |
+
: Buffer.from(payload?.encrypted || '', 'hex');
|
| 48 |
+
|
| 49 |
+
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
| 50 |
+
const aadBuffer = normalizeAad(aad);
|
| 51 |
+
if (aadBuffer.length) decipher.setAAD(aadBuffer);
|
| 52 |
+
decipher.setAuthTag(authTag);
|
| 53 |
+
return Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
| 54 |
+
}
|
| 55 |
|
| 56 |
+
export function packEncryptedBuffer(payload) {
|
| 57 |
+
const header = Buffer.allocUnsafe(1 + 1 + payload.iv.length + payload.authTag.length);
|
| 58 |
+
header.writeUInt8(payload.version || BINARY_FORMAT_VERSION, 0);
|
| 59 |
+
header.writeUInt8(payload.iv.length, 1);
|
| 60 |
+
payload.iv.copy(header, 2);
|
| 61 |
+
payload.authTag.copy(header, 2 + payload.iv.length);
|
| 62 |
+
return Buffer.concat([header, payload.encrypted]);
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
export function unpackEncryptedBuffer(buffer) {
|
| 66 |
+
const version = buffer.readUInt8(0);
|
| 67 |
+
if (version !== BINARY_FORMAT_VERSION) {
|
| 68 |
+
throw new Error(`Unsupported encrypted buffer version: ${version}`);
|
| 69 |
+
}
|
| 70 |
+
const ivLength = buffer.readUInt8(1);
|
| 71 |
+
const ivStart = 2;
|
| 72 |
+
const ivEnd = ivStart + ivLength;
|
| 73 |
+
const tagEnd = ivEnd + AUTH_TAG_LENGTH;
|
| 74 |
+
return {
|
| 75 |
+
version,
|
| 76 |
+
iv: buffer.subarray(ivStart, ivEnd),
|
| 77 |
+
authTag: buffer.subarray(ivEnd, tagEnd),
|
| 78 |
+
encrypted: buffer.subarray(tagEnd),
|
| 79 |
+
};
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
export async function writeEncryptedFile(filePath, buffer, aad = '') {
|
| 83 |
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
| 84 |
+
const payload = encryptBuffer(buffer, aad);
|
| 85 |
+
await fs.writeFile(filePath, packEncryptedBuffer(payload));
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
export async function readEncryptedFile(filePath, aad = '') {
|
| 89 |
+
const packed = await fs.readFile(filePath);
|
| 90 |
+
return decryptBuffer(unpackEncryptedBuffer(packed), aad);
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
function legacyDecryptJson(encryptedData) {
|
| 94 |
+
const key = getKey();
|
| 95 |
+
const decipher = crypto.createDecipher(ALGORITHM, key);
|
| 96 |
+
decipher.setAuthTag(Buffer.from(encryptedData.authTag, 'hex'));
|
| 97 |
+
let decrypted = decipher.update(encryptedData.encrypted, 'hex', 'utf8');
|
| 98 |
decrypted += decipher.final('utf8');
|
| 99 |
return JSON.parse(decrypted);
|
| 100 |
}
|
| 101 |
|
| 102 |
+
export function encryptJson(data, aad = '') {
|
| 103 |
+
const payload = encryptBuffer(Buffer.from(JSON.stringify(data), 'utf8'), aad);
|
| 104 |
+
return {
|
| 105 |
+
version: JSON_FORMAT_VERSION,
|
| 106 |
+
iv: payload.iv.toString('hex'),
|
| 107 |
+
authTag: payload.authTag.toString('hex'),
|
| 108 |
+
encrypted: payload.encrypted.toString('hex'),
|
| 109 |
+
};
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
export function decryptJson(encryptedData, aad = '') {
|
| 113 |
+
if (!encryptedData) return null;
|
| 114 |
+
if ((encryptedData.version || 0) >= JSON_FORMAT_VERSION) {
|
| 115 |
+
const decrypted = decryptBuffer(encryptedData, aad);
|
| 116 |
+
return JSON.parse(decrypted.toString('utf8'));
|
| 117 |
+
}
|
| 118 |
+
return legacyDecryptJson(encryptedData);
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
export async function saveEncryptedJson(filePath, data, aad = '') {
|
| 122 |
+
const encrypted = encryptJson(data, aad);
|
| 123 |
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
| 124 |
+
await fs.writeFile(filePath, JSON.stringify(encrypted, null, 2), 'utf8');
|
| 125 |
}
|
| 126 |
|
| 127 |
+
export async function loadEncryptedJson(filePath, aad = '') {
|
| 128 |
try {
|
| 129 |
const content = await fs.readFile(filePath, 'utf8');
|
| 130 |
const encrypted = JSON.parse(content);
|
| 131 |
+
return decryptJson(encrypted, aad);
|
| 132 |
} catch (err) {
|
| 133 |
+
if (err.code === 'ENOENT') return null;
|
| 134 |
throw err;
|
| 135 |
}
|
| 136 |
+
}
|
server/index.js
CHANGED
|
@@ -13,6 +13,8 @@ import { handleWsMessage } from './wsHandler.js';
|
|
| 13 |
import { sessionStore, initStoreConfig } from './sessionStore.js';
|
| 14 |
import { SUPABASE_URL, SUPABASE_ANON_KEY } from './config.js';
|
| 15 |
import { safeSend } from './helpers.js';
|
|
|
|
|
|
|
| 16 |
|
| 17 |
export { SUPABASE_URL, SUPABASE_ANON_KEY };
|
| 18 |
export { LIGHTNING_BASE, PUBLIC_URL } from './config.js';
|
|
@@ -29,6 +31,17 @@ const GITHUB_REPO = 'incognitolm/InferencePort-Pages';
|
|
| 29 |
const CDN_BASE = `https://cdn.jsdelivr.net/gh/${GITHUB_REPO}`;
|
| 30 |
let latestSHA = null;
|
| 31 |
const ADMIN_TOKEN = process.env.ADMIN_TOKEN || 'supersecret';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
// Rate limiter for admin endpoints (5 attempts per IP per minute)
|
| 34 |
const verifyLimiter = rateLimit({ windowMs: 60*1000, max: 5, standardHeaders: true, legacyHeaders: false });
|
|
@@ -107,6 +120,31 @@ app.use('/api', (req, res, next) => {
|
|
| 107 |
return res.status(403).json({ error: 'turnstile:required' });
|
| 108 |
});
|
| 109 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
app.get('/health', (_req,res) => res.json({ok:true}));
|
| 111 |
|
| 112 |
app.get('/api/share/:token', async (req,res) => {
|
|
@@ -124,6 +162,176 @@ app.get('/api/share/:token', async (req,res) => {
|
|
| 124 |
} catch { res.status(500).json({error:'Server error'}); }
|
| 125 |
});
|
| 126 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
app.post('/api/turnstile', async (req,res)=>{
|
| 128 |
try{
|
| 129 |
const token = req.body?.token;
|
|
@@ -166,6 +374,7 @@ if (!latestSHA) {
|
|
| 166 |
|
| 167 |
// --- Admin endpoints ---
|
| 168 |
app.get('/admin.html', async (req, res) => {
|
|
|
|
| 169 |
if (!latestSHA) return res.status(500).send('Server not ready');
|
| 170 |
|
| 171 |
const url = `${CDN_BASE}@${latestSHA}/admin.html`;
|
|
@@ -223,12 +432,31 @@ function getMimeType(filePath){
|
|
| 223 |
}
|
| 224 |
}
|
| 225 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
// Hybrid serving: everything via latest SHA (auto-refresh / manual refresh)
|
| 227 |
app.get('*', async (req,res)=>{
|
| 228 |
if(req.path.startsWith('/api/')) return res.status(404).send('Not found');
|
| 229 |
|
| 230 |
// All client files are fetched from latest SHA
|
| 231 |
const filePath = req.path === '/' ? '/index.html' : req.path;
|
|
|
|
| 232 |
const url = `${CDN_BASE}@${latestSHA}${filePath}`;
|
| 233 |
|
| 234 |
try{
|
|
@@ -277,4 +505,4 @@ wss.on('connection',(ws,req)=>{
|
|
| 277 |
safeSend(ws,{type:'connected', tempId:wsClients.get(ws)?.tempId});
|
| 278 |
});
|
| 279 |
|
| 280 |
-
httpServer.listen(PORT,'0.0.0.0',()=>console.log(`Running on port ${PORT}`));
|
|
|
|
| 13 |
import { sessionStore, initStoreConfig } from './sessionStore.js';
|
| 14 |
import { SUPABASE_URL, SUPABASE_ANON_KEY } from './config.js';
|
| 15 |
import { safeSend } from './helpers.js';
|
| 16 |
+
import { verifySupabaseToken } from './auth.js';
|
| 17 |
+
import { mediaStore } from './mediaStore.js';
|
| 18 |
|
| 19 |
export { SUPABASE_URL, SUPABASE_ANON_KEY };
|
| 20 |
export { LIGHTNING_BASE, PUBLIC_URL } from './config.js';
|
|
|
|
| 31 |
const CDN_BASE = `https://cdn.jsdelivr.net/gh/${GITHUB_REPO}`;
|
| 32 |
let latestSHA = null;
|
| 33 |
const ADMIN_TOKEN = process.env.ADMIN_TOKEN || 'supersecret';
|
| 34 |
+
const LOCAL_UI_DIR = [
|
| 35 |
+
process.env.UI_LOCAL_PATH,
|
| 36 |
+
path.resolve(__dirname, '..', '..', 'InferencePort-Pages'),
|
| 37 |
+
path.resolve(process.cwd(), '..', 'InferencePort-Pages'),
|
| 38 |
+
].filter(Boolean).find((dir) => {
|
| 39 |
+
try {
|
| 40 |
+
return fs.existsSync(path.join(dir, 'index.html'));
|
| 41 |
+
} catch {
|
| 42 |
+
return false;
|
| 43 |
+
}
|
| 44 |
+
}) || null;
|
| 45 |
|
| 46 |
// Rate limiter for admin endpoints (5 attempts per IP per minute)
|
| 47 |
const verifyLimiter = rateLimit({ windowMs: 60*1000, max: 5, standardHeaders: true, legacyHeaders: false });
|
|
|
|
| 120 |
return res.status(403).json({ error: 'turnstile:required' });
|
| 121 |
});
|
| 122 |
|
| 123 |
+
async function getRequestOwner(req) {
|
| 124 |
+
const authHeader = req.headers.authorization || '';
|
| 125 |
+
const accessToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : '';
|
| 126 |
+
if (accessToken) {
|
| 127 |
+
const user = await verifySupabaseToken(accessToken);
|
| 128 |
+
if (user) return { owner: { type: 'user', id: user.id }, accessToken };
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
const tempId = String(req.headers['x-temp-id'] || req.query.tempId || '').trim();
|
| 132 |
+
if (tempId) {
|
| 133 |
+
sessionStore.initTemp(tempId);
|
| 134 |
+
return { owner: { type: 'guest', id: tempId }, accessToken: null };
|
| 135 |
+
}
|
| 136 |
+
return null;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
async function requireRequestOwner(req, res) {
|
| 140 |
+
const resolved = await getRequestOwner(req);
|
| 141 |
+
if (!resolved?.owner) {
|
| 142 |
+
res.status(401).json({ error: 'auth:required' });
|
| 143 |
+
return null;
|
| 144 |
+
}
|
| 145 |
+
return resolved;
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
app.get('/health', (_req,res) => res.json({ok:true}));
|
| 149 |
|
| 150 |
app.get('/api/share/:token', async (req,res) => {
|
|
|
|
| 162 |
} catch { res.status(500).json({error:'Server error'}); }
|
| 163 |
});
|
| 164 |
|
| 165 |
+
app.get('/api/media', async (req, res) => {
|
| 166 |
+
const resolved = await requireRequestOwner(req, res);
|
| 167 |
+
if (!resolved) return;
|
| 168 |
+
try {
|
| 169 |
+
const result = await mediaStore.list(resolved.owner, {
|
| 170 |
+
view: req.query.view === 'trash' ? 'trash' : 'active',
|
| 171 |
+
parentId: req.query.parentId ? String(req.query.parentId) : null,
|
| 172 |
+
});
|
| 173 |
+
res.json(result);
|
| 174 |
+
} catch (err) {
|
| 175 |
+
console.error('media list error', err);
|
| 176 |
+
res.status(500).json({ error: 'media:list_failed' });
|
| 177 |
+
}
|
| 178 |
+
});
|
| 179 |
+
|
| 180 |
+
app.post('/api/media/upload', express.raw({ type: () => true, limit: '100mb' }), async (req, res) => {
|
| 181 |
+
const resolved = await requireRequestOwner(req, res);
|
| 182 |
+
if (!resolved) return;
|
| 183 |
+
try {
|
| 184 |
+
const name = decodeURIComponent(String(req.headers['x-file-name'] || 'upload.bin'));
|
| 185 |
+
const mimeType = String(req.headers['x-mime-type'] || 'application/octet-stream');
|
| 186 |
+
const parentId = req.headers['x-parent-id'] ? String(req.headers['x-parent-id']) : null;
|
| 187 |
+
const sessionId = req.headers['x-session-id'] ? String(req.headers['x-session-id']) : null;
|
| 188 |
+
const kindHeader = req.headers['x-file-kind'] ? String(req.headers['x-file-kind']) : null;
|
| 189 |
+
const item = await mediaStore.storeBuffer(resolved.owner, {
|
| 190 |
+
name,
|
| 191 |
+
mimeType,
|
| 192 |
+
buffer: Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || []),
|
| 193 |
+
parentId,
|
| 194 |
+
sessionId,
|
| 195 |
+
source: 'user_upload',
|
| 196 |
+
kind: kindHeader || null,
|
| 197 |
+
});
|
| 198 |
+
res.json({ item });
|
| 199 |
+
} catch (err) {
|
| 200 |
+
console.error('media upload error', err);
|
| 201 |
+
res.status(500).json({ error: 'media:upload_failed' });
|
| 202 |
+
}
|
| 203 |
+
});
|
| 204 |
+
|
| 205 |
+
app.post('/api/media/folders', async (req, res) => {
|
| 206 |
+
const resolved = await requireRequestOwner(req, res);
|
| 207 |
+
if (!resolved) return;
|
| 208 |
+
try {
|
| 209 |
+
const item = await mediaStore.createFolder(resolved.owner, {
|
| 210 |
+
name: req.body?.name || 'New Folder',
|
| 211 |
+
parentId: req.body?.parentId || null,
|
| 212 |
+
});
|
| 213 |
+
res.json({ item });
|
| 214 |
+
} catch (err) {
|
| 215 |
+
console.error('media create folder error', err);
|
| 216 |
+
res.status(500).json({ error: 'media:create_folder_failed' });
|
| 217 |
+
}
|
| 218 |
+
});
|
| 219 |
+
|
| 220 |
+
app.post('/api/media/documents', async (req, res) => {
|
| 221 |
+
const resolved = await requireRequestOwner(req, res);
|
| 222 |
+
if (!resolved) return;
|
| 223 |
+
try {
|
| 224 |
+
const item = await mediaStore.createDocument(resolved.owner, {
|
| 225 |
+
name: req.body?.name,
|
| 226 |
+
parentId: req.body?.parentId || null,
|
| 227 |
+
richText: !!req.body?.richText,
|
| 228 |
+
content: req.body?.content || '',
|
| 229 |
+
source: 'user_upload',
|
| 230 |
+
});
|
| 231 |
+
res.json({ item });
|
| 232 |
+
} catch (err) {
|
| 233 |
+
console.error('media create document error', err);
|
| 234 |
+
res.status(500).json({ error: 'media:create_document_failed' });
|
| 235 |
+
}
|
| 236 |
+
});
|
| 237 |
+
|
| 238 |
+
app.post('/api/media/move', async (req, res) => {
|
| 239 |
+
const resolved = await requireRequestOwner(req, res);
|
| 240 |
+
if (!resolved) return;
|
| 241 |
+
try {
|
| 242 |
+
const items = await mediaStore.move(resolved.owner, req.body?.ids || [], req.body?.parentId || null);
|
| 243 |
+
res.json({ items });
|
| 244 |
+
} catch (err) {
|
| 245 |
+
console.error('media move error', err);
|
| 246 |
+
res.status(500).json({ error: 'media:move_failed' });
|
| 247 |
+
}
|
| 248 |
+
});
|
| 249 |
+
|
| 250 |
+
app.post('/api/media/trash', async (req, res) => {
|
| 251 |
+
const resolved = await requireRequestOwner(req, res);
|
| 252 |
+
if (!resolved) return;
|
| 253 |
+
try {
|
| 254 |
+
const items = await mediaStore.moveToTrash(resolved.owner, req.body?.ids || []);
|
| 255 |
+
res.json({ items });
|
| 256 |
+
} catch (err) {
|
| 257 |
+
console.error('media trash error', err);
|
| 258 |
+
res.status(500).json({ error: 'media:trash_failed' });
|
| 259 |
+
}
|
| 260 |
+
});
|
| 261 |
+
|
| 262 |
+
app.post('/api/media/restore', async (req, res) => {
|
| 263 |
+
const resolved = await requireRequestOwner(req, res);
|
| 264 |
+
if (!resolved) return;
|
| 265 |
+
try {
|
| 266 |
+
const items = await mediaStore.restore(resolved.owner, req.body?.ids || []);
|
| 267 |
+
res.json({ items });
|
| 268 |
+
} catch (err) {
|
| 269 |
+
console.error('media restore error', err);
|
| 270 |
+
res.status(500).json({ error: 'media:restore_failed' });
|
| 271 |
+
}
|
| 272 |
+
});
|
| 273 |
+
|
| 274 |
+
app.post('/api/media/deleteForever', async (req, res) => {
|
| 275 |
+
const resolved = await requireRequestOwner(req, res);
|
| 276 |
+
if (!resolved) return;
|
| 277 |
+
try {
|
| 278 |
+
const ids = await mediaStore.deleteForever(resolved.owner, req.body?.ids || []);
|
| 279 |
+
res.json({ ids });
|
| 280 |
+
} catch (err) {
|
| 281 |
+
console.error('media delete forever error', err);
|
| 282 |
+
res.status(500).json({ error: 'media:delete_failed' });
|
| 283 |
+
}
|
| 284 |
+
});
|
| 285 |
+
|
| 286 |
+
app.get('/api/media/:id/text', async (req, res) => {
|
| 287 |
+
const resolved = await requireRequestOwner(req, res);
|
| 288 |
+
if (!resolved) return;
|
| 289 |
+
try {
|
| 290 |
+
const loaded = await mediaStore.readText(resolved.owner, req.params.id);
|
| 291 |
+
if (!loaded) return res.status(404).json({ error: 'media:not_found' });
|
| 292 |
+
res.json({ item: loaded.entry, content: loaded.text });
|
| 293 |
+
} catch (err) {
|
| 294 |
+
console.error('media read text error', err);
|
| 295 |
+
res.status(500).json({ error: 'media:read_failed' });
|
| 296 |
+
}
|
| 297 |
+
});
|
| 298 |
+
|
| 299 |
+
app.put('/api/media/:id/text', async (req, res) => {
|
| 300 |
+
const resolved = await requireRequestOwner(req, res);
|
| 301 |
+
if (!resolved) return;
|
| 302 |
+
try {
|
| 303 |
+
const item = await mediaStore.updateContent(resolved.owner, req.params.id, {
|
| 304 |
+
buffer: Buffer.from(String(req.body?.content || ''), 'utf8'),
|
| 305 |
+
mimeType: req.body?.mimeType || null,
|
| 306 |
+
kind: req.body?.richText ? 'rich_text' : null,
|
| 307 |
+
name: req.body?.name || null,
|
| 308 |
+
});
|
| 309 |
+
if (!item) return res.status(404).json({ error: 'media:not_found' });
|
| 310 |
+
res.json({ item });
|
| 311 |
+
} catch (err) {
|
| 312 |
+
console.error('media update text error', err);
|
| 313 |
+
res.status(500).json({ error: 'media:update_failed' });
|
| 314 |
+
}
|
| 315 |
+
});
|
| 316 |
+
|
| 317 |
+
app.get('/api/media/:id/content', async (req, res) => {
|
| 318 |
+
const resolved = await requireRequestOwner(req, res);
|
| 319 |
+
if (!resolved) return;
|
| 320 |
+
try {
|
| 321 |
+
const loaded = await mediaStore.readBuffer(resolved.owner, req.params.id);
|
| 322 |
+
if (!loaded) return res.status(404).json({ error: 'media:not_found' });
|
| 323 |
+
res.setHeader('Content-Type', loaded.entry.mimeType || 'application/octet-stream');
|
| 324 |
+
res.setHeader('Cache-Control', 'private, max-age=60');
|
| 325 |
+
if (req.query.download === '1') {
|
| 326 |
+
res.setHeader('Content-Disposition', `attachment; filename="${loaded.entry.name}"`);
|
| 327 |
+
}
|
| 328 |
+
res.send(loaded.buffer);
|
| 329 |
+
} catch (err) {
|
| 330 |
+
console.error('media read binary error', err);
|
| 331 |
+
res.status(500).json({ error: 'media:read_failed' });
|
| 332 |
+
}
|
| 333 |
+
});
|
| 334 |
+
|
| 335 |
app.post('/api/turnstile', async (req,res)=>{
|
| 336 |
try{
|
| 337 |
const token = req.body?.token;
|
|
|
|
| 374 |
|
| 375 |
// --- Admin endpoints ---
|
| 376 |
app.get('/admin.html', async (req, res) => {
|
| 377 |
+
if (serveLocalUiFile('/admin.html', res)) return;
|
| 378 |
if (!latestSHA) return res.status(500).send('Server not ready');
|
| 379 |
|
| 380 |
const url = `${CDN_BASE}@${latestSHA}/admin.html`;
|
|
|
|
| 432 |
}
|
| 433 |
}
|
| 434 |
|
| 435 |
+
function resolveLocalUiFile(filePath) {
|
| 436 |
+
if (!LOCAL_UI_DIR) return null;
|
| 437 |
+
const resolvedRoot = path.resolve(LOCAL_UI_DIR);
|
| 438 |
+
const relativePath = String(filePath || '/index.html').replace(/^[/\\]+/, '');
|
| 439 |
+
const resolvedFile = path.resolve(resolvedRoot, relativePath);
|
| 440 |
+
if (resolvedFile !== resolvedRoot && !resolvedFile.startsWith(`${resolvedRoot}${path.sep}`)) return null;
|
| 441 |
+
if (!fs.existsSync(resolvedFile) || fs.statSync(resolvedFile).isDirectory()) return null;
|
| 442 |
+
return resolvedFile;
|
| 443 |
+
}
|
| 444 |
+
|
| 445 |
+
function serveLocalUiFile(filePath, res) {
|
| 446 |
+
const resolvedFile = resolveLocalUiFile(filePath);
|
| 447 |
+
if (!resolvedFile) return false;
|
| 448 |
+
res.setHeader('Content-Type', getMimeType(resolvedFile));
|
| 449 |
+
res.send(fs.readFileSync(resolvedFile));
|
| 450 |
+
return true;
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
// Hybrid serving: everything via latest SHA (auto-refresh / manual refresh)
|
| 454 |
app.get('*', async (req,res)=>{
|
| 455 |
if(req.path.startsWith('/api/')) return res.status(404).send('Not found');
|
| 456 |
|
| 457 |
// All client files are fetched from latest SHA
|
| 458 |
const filePath = req.path === '/' ? '/index.html' : req.path;
|
| 459 |
+
if (serveLocalUiFile(filePath, res)) return;
|
| 460 |
const url = `${CDN_BASE}@${latestSHA}${filePath}`;
|
| 461 |
|
| 462 |
try{
|
|
|
|
| 505 |
safeSend(ws,{type:'connected', tempId:wsClients.get(ws)?.tempId});
|
| 506 |
});
|
| 507 |
|
| 508 |
+
httpServer.listen(PORT,'0.0.0.0',()=>console.log(`Running on port ${PORT}`));
|
server/mediaStore.js
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import crypto from 'crypto';
|
| 2 |
+
import fs from 'fs/promises';
|
| 3 |
+
import path from 'path';
|
| 4 |
+
import { loadEncryptedJson, saveEncryptedJson, readEncryptedFile, writeEncryptedFile } from './cryptoUtils.js';
|
| 5 |
+
|
| 6 |
+
const DATA_ROOT = '/data/media';
|
| 7 |
+
const INDEX_FILE = path.join(DATA_ROOT, 'index.json');
|
| 8 |
+
const BLOBS_DIR = path.join(DATA_ROOT, 'blobs');
|
| 9 |
+
const TRASH_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
| 10 |
+
const GUEST_RETENTION_MS = 24 * 60 * 60 * 1000;
|
| 11 |
+
|
| 12 |
+
const state = {
|
| 13 |
+
loaded: false,
|
| 14 |
+
index: {
|
| 15 |
+
entries: {},
|
| 16 |
+
},
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
function nowIso() {
|
| 20 |
+
return new Date().toISOString();
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
function ownerKey(owner) {
|
| 24 |
+
return `${owner.type}:${owner.id}`;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
function ensureOwner(owner) {
|
| 28 |
+
if (!owner?.type || !owner?.id) throw new Error('Invalid media owner');
|
| 29 |
+
return owner;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
function normalizeName(name, fallback = 'Untitled') {
|
| 33 |
+
const clean = String(name || '').trim().replace(/[\\/:*?"<>|]+/g, '_');
|
| 34 |
+
return clean || fallback;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
function extensionFromName(name = '') {
|
| 38 |
+
const ext = path.extname(String(name || '')).toLowerCase();
|
| 39 |
+
return ext.startsWith('.') ? ext.slice(1) : ext;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
function inferKind(name, mimeType = '') {
|
| 43 |
+
const mime = String(mimeType || '').toLowerCase();
|
| 44 |
+
const ext = extensionFromName(name);
|
| 45 |
+
if (mime.startsWith('image/') || ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext)) return 'image';
|
| 46 |
+
if (mime.startsWith('video/') || ['mp4', 'webm', 'mov'].includes(ext)) return 'video';
|
| 47 |
+
if (mime.startsWith('audio/') || ['mp3', 'wav', 'ogg', 'm4a'].includes(ext)) return 'audio';
|
| 48 |
+
if (mime === 'text/html' || ['html', 'htm'].includes(ext)) return 'rich_text';
|
| 49 |
+
if (mime.startsWith('text/') || ['txt', 'md', 'json', 'js', 'ts', 'css', 'py', 'html', 'xml', 'csv', 'rtf'].includes(ext)) return 'text';
|
| 50 |
+
return 'file';
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
function guessMimeType(name, fallbackKind = 'file') {
|
| 54 |
+
const ext = extensionFromName(name);
|
| 55 |
+
switch (ext) {
|
| 56 |
+
case 'txt': return 'text/plain';
|
| 57 |
+
case 'md': return 'text/markdown';
|
| 58 |
+
case 'json': return 'application/json';
|
| 59 |
+
case 'html':
|
| 60 |
+
case 'htm': return 'text/html';
|
| 61 |
+
case 'css': return 'text/css';
|
| 62 |
+
case 'js': return 'application/javascript';
|
| 63 |
+
case 'ts': return 'text/plain';
|
| 64 |
+
case 'svg': return 'image/svg+xml';
|
| 65 |
+
case 'png': return 'image/png';
|
| 66 |
+
case 'jpg':
|
| 67 |
+
case 'jpeg': return 'image/jpeg';
|
| 68 |
+
case 'gif': return 'image/gif';
|
| 69 |
+
case 'webp': return 'image/webp';
|
| 70 |
+
case 'mp4': return 'video/mp4';
|
| 71 |
+
case 'webm': return 'video/webm';
|
| 72 |
+
case 'mp3': return 'audio/mpeg';
|
| 73 |
+
case 'wav': return 'audio/wav';
|
| 74 |
+
case 'ogg': return 'audio/ogg';
|
| 75 |
+
case 'csv': return 'text/csv';
|
| 76 |
+
default:
|
| 77 |
+
if (fallbackKind === 'rich_text') return 'text/html';
|
| 78 |
+
if (fallbackKind === 'text') return 'text/plain';
|
| 79 |
+
return 'application/octet-stream';
|
| 80 |
+
}
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
async function ensureLoaded() {
|
| 84 |
+
if (state.loaded) return;
|
| 85 |
+
const stored = await loadEncryptedJson(INDEX_FILE);
|
| 86 |
+
state.index = {
|
| 87 |
+
entries: stored?.entries || {},
|
| 88 |
+
};
|
| 89 |
+
state.loaded = true;
|
| 90 |
+
await purgeExpiredInternal();
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
async function saveIndex() {
|
| 94 |
+
await saveEncryptedJson(INDEX_FILE, state.index);
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
function getEntry(id) {
|
| 98 |
+
return state.index.entries[id] || null;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
function canAccess(entry, owner) {
|
| 102 |
+
return !!entry && entry.ownerType === owner.type && entry.ownerId === owner.id;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
function sanitizeEntry(entry) {
|
| 106 |
+
return {
|
| 107 |
+
id: entry.id,
|
| 108 |
+
type: entry.type,
|
| 109 |
+
name: entry.name,
|
| 110 |
+
parentId: entry.parentId || null,
|
| 111 |
+
ownerType: entry.ownerType,
|
| 112 |
+
ownerId: entry.ownerId,
|
| 113 |
+
mimeType: entry.mimeType || null,
|
| 114 |
+
kind: entry.kind || null,
|
| 115 |
+
size: entry.size || 0,
|
| 116 |
+
source: entry.source || null,
|
| 117 |
+
createdAt: entry.createdAt,
|
| 118 |
+
updatedAt: entry.updatedAt,
|
| 119 |
+
trashedAt: entry.trashedAt || null,
|
| 120 |
+
purgeAt: entry.purgeAt || null,
|
| 121 |
+
sessionIds: entry.sessionIds || [],
|
| 122 |
+
deletedByAssistant: !!entry.deletedByAssistant,
|
| 123 |
+
};
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
function blobPathFor(id) {
|
| 127 |
+
return path.join(BLOBS_DIR, `${id}.bin`);
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
function buildAad(entry) {
|
| 131 |
+
return `media:${entry.id}:${entry.ownerType}:${entry.ownerId}`;
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
function getChildren(owner, parentId, includeTrash = true) {
|
| 135 |
+
return Object.values(state.index.entries).filter((entry) =>
|
| 136 |
+
entry.ownerType === owner.type &&
|
| 137 |
+
entry.ownerId === owner.id &&
|
| 138 |
+
(entry.parentId || null) === (parentId || null) &&
|
| 139 |
+
(includeTrash || !entry.trashedAt)
|
| 140 |
+
);
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
function collectDescendantIds(owner, rootId, acc = new Set()) {
|
| 144 |
+
acc.add(rootId);
|
| 145 |
+
const children = getChildren(owner, rootId, true);
|
| 146 |
+
for (const child of children) {
|
| 147 |
+
collectDescendantIds(owner, child.id, acc);
|
| 148 |
+
}
|
| 149 |
+
return [...acc];
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
function resolveParentFolder(owner, parentId) {
|
| 153 |
+
if (!parentId) return null;
|
| 154 |
+
const parent = getEntry(parentId);
|
| 155 |
+
if (!parent || !canAccess(parent, owner) || parent.type !== 'folder') return null;
|
| 156 |
+
return parent.id;
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
function wouldCreateCycle(owner, entryId, candidateParentId) {
|
| 160 |
+
let currentId = candidateParentId || null;
|
| 161 |
+
while (currentId) {
|
| 162 |
+
if (currentId === entryId) return true;
|
| 163 |
+
const current = getEntry(currentId);
|
| 164 |
+
if (!current || !canAccess(current, owner)) return false;
|
| 165 |
+
currentId = current.parentId || null;
|
| 166 |
+
}
|
| 167 |
+
return false;
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
async function purgeEntry(id) {
|
| 171 |
+
const entry = getEntry(id);
|
| 172 |
+
if (!entry) return;
|
| 173 |
+
if (entry.type === 'file') {
|
| 174 |
+
await fs.rm(blobPathFor(id), { force: true }).catch(() => {});
|
| 175 |
+
}
|
| 176 |
+
delete state.index.entries[id];
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
async function purgeExpiredInternal() {
|
| 180 |
+
const now = Date.now();
|
| 181 |
+
let changed = false;
|
| 182 |
+
for (const entry of Object.values(state.index.entries)) {
|
| 183 |
+
const shouldPurge =
|
| 184 |
+
(entry.purgeAt && new Date(entry.purgeAt).getTime() <= now) ||
|
| 185 |
+
(entry.expiresAt && new Date(entry.expiresAt).getTime() <= now);
|
| 186 |
+
if (!shouldPurge) continue;
|
| 187 |
+
for (const id of collectDescendantIds({ type: entry.ownerType, id: entry.ownerId }, entry.id)) {
|
| 188 |
+
await purgeEntry(id);
|
| 189 |
+
}
|
| 190 |
+
changed = true;
|
| 191 |
+
}
|
| 192 |
+
if (changed) await saveIndex();
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
setInterval(() => {
|
| 196 |
+
purgeExpiredInternal().catch((err) => console.error('mediaStore cleanup failed:', err));
|
| 197 |
+
}, 6 * 60 * 60 * 1000);
|
| 198 |
+
|
| 199 |
+
export const mediaStore = {
|
| 200 |
+
async list(owner, { view = 'active', parentId = null } = {}) {
|
| 201 |
+
ensureOwner(owner);
|
| 202 |
+
await ensureLoaded();
|
| 203 |
+
|
| 204 |
+
const includeTrash = view === 'trash';
|
| 205 |
+
const items = getChildren(owner, parentId, true)
|
| 206 |
+
.filter((entry) => includeTrash ? !!entry.trashedAt : !entry.trashedAt)
|
| 207 |
+
.sort((a, b) => {
|
| 208 |
+
if (a.type !== b.type) return a.type === 'folder' ? -1 : 1;
|
| 209 |
+
return new Date(b.updatedAt || b.createdAt).getTime() - new Date(a.updatedAt || a.createdAt).getTime();
|
| 210 |
+
})
|
| 211 |
+
.map(sanitizeEntry);
|
| 212 |
+
|
| 213 |
+
const breadcrumbs = [];
|
| 214 |
+
let currentId = parentId;
|
| 215 |
+
while (currentId) {
|
| 216 |
+
const entry = getEntry(currentId);
|
| 217 |
+
if (!entry || !canAccess(entry, owner)) break;
|
| 218 |
+
breadcrumbs.unshift({ id: entry.id, name: entry.name });
|
| 219 |
+
currentId = entry.parentId || null;
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
return { items, breadcrumbs };
|
| 223 |
+
},
|
| 224 |
+
|
| 225 |
+
async get(owner, id) {
|
| 226 |
+
ensureOwner(owner);
|
| 227 |
+
await ensureLoaded();
|
| 228 |
+
const entry = getEntry(id);
|
| 229 |
+
if (!canAccess(entry, owner)) return null;
|
| 230 |
+
return sanitizeEntry(entry);
|
| 231 |
+
},
|
| 232 |
+
|
| 233 |
+
async storeBuffer(owner, {
|
| 234 |
+
name,
|
| 235 |
+
mimeType,
|
| 236 |
+
buffer,
|
| 237 |
+
parentId = null,
|
| 238 |
+
sessionId = null,
|
| 239 |
+
source = 'upload',
|
| 240 |
+
kind = null,
|
| 241 |
+
deletedByAssistant = false,
|
| 242 |
+
}) {
|
| 243 |
+
ensureOwner(owner);
|
| 244 |
+
await ensureLoaded();
|
| 245 |
+
|
| 246 |
+
const entryId = crypto.randomUUID();
|
| 247 |
+
const resolvedParentId = resolveParentFolder(owner, parentId);
|
| 248 |
+
const fileName = normalizeName(name, 'Untitled');
|
| 249 |
+
const inferredKind = kind || inferKind(fileName, mimeType);
|
| 250 |
+
const entry = {
|
| 251 |
+
id: entryId,
|
| 252 |
+
type: 'file',
|
| 253 |
+
name: fileName,
|
| 254 |
+
ownerType: owner.type,
|
| 255 |
+
ownerId: owner.id,
|
| 256 |
+
mimeType: mimeType || guessMimeType(fileName, inferredKind),
|
| 257 |
+
kind: inferredKind,
|
| 258 |
+
size: buffer.byteLength,
|
| 259 |
+
parentId: resolvedParentId,
|
| 260 |
+
source,
|
| 261 |
+
createdAt: nowIso(),
|
| 262 |
+
updatedAt: nowIso(),
|
| 263 |
+
trashedAt: null,
|
| 264 |
+
purgeAt: null,
|
| 265 |
+
sessionIds: sessionId ? [sessionId] : [],
|
| 266 |
+
expiresAt: owner.type === 'guest' ? new Date(Date.now() + GUEST_RETENTION_MS).toISOString() : null,
|
| 267 |
+
deletedByAssistant,
|
| 268 |
+
};
|
| 269 |
+
|
| 270 |
+
await writeEncryptedFile(blobPathFor(entry.id), Buffer.from(buffer), buildAad(entry));
|
| 271 |
+
state.index.entries[entry.id] = entry;
|
| 272 |
+
await saveIndex();
|
| 273 |
+
return sanitizeEntry(entry);
|
| 274 |
+
},
|
| 275 |
+
|
| 276 |
+
async createFolder(owner, { name, parentId = null }) {
|
| 277 |
+
ensureOwner(owner);
|
| 278 |
+
await ensureLoaded();
|
| 279 |
+
const resolvedParentId = resolveParentFolder(owner, parentId);
|
| 280 |
+
|
| 281 |
+
const folder = {
|
| 282 |
+
id: crypto.randomUUID(),
|
| 283 |
+
type: 'folder',
|
| 284 |
+
name: normalizeName(name, 'New Folder'),
|
| 285 |
+
ownerType: owner.type,
|
| 286 |
+
ownerId: owner.id,
|
| 287 |
+
parentId: resolvedParentId,
|
| 288 |
+
createdAt: nowIso(),
|
| 289 |
+
updatedAt: nowIso(),
|
| 290 |
+
trashedAt: null,
|
| 291 |
+
purgeAt: null,
|
| 292 |
+
sessionIds: [],
|
| 293 |
+
expiresAt: owner.type === 'guest' ? new Date(Date.now() + GUEST_RETENTION_MS).toISOString() : null,
|
| 294 |
+
};
|
| 295 |
+
|
| 296 |
+
state.index.entries[folder.id] = folder;
|
| 297 |
+
await saveIndex();
|
| 298 |
+
return sanitizeEntry(folder);
|
| 299 |
+
},
|
| 300 |
+
|
| 301 |
+
async createDocument(owner, {
|
| 302 |
+
name,
|
| 303 |
+
parentId = null,
|
| 304 |
+
richText = false,
|
| 305 |
+
content = '',
|
| 306 |
+
source = 'upload',
|
| 307 |
+
}) {
|
| 308 |
+
const normalizedName = normalizeName(
|
| 309 |
+
name,
|
| 310 |
+
richText ? 'Untitled Document.html' : 'Untitled Document.txt'
|
| 311 |
+
);
|
| 312 |
+
return this.storeBuffer(owner, {
|
| 313 |
+
name: normalizedName,
|
| 314 |
+
mimeType: richText ? 'text/html' : 'text/plain',
|
| 315 |
+
buffer: Buffer.from(content || (richText ? '<p></p>' : ''), 'utf8'),
|
| 316 |
+
parentId,
|
| 317 |
+
source,
|
| 318 |
+
kind: richText ? 'rich_text' : 'text',
|
| 319 |
+
});
|
| 320 |
+
},
|
| 321 |
+
|
| 322 |
+
async readBuffer(owner, id) {
|
| 323 |
+
ensureOwner(owner);
|
| 324 |
+
await ensureLoaded();
|
| 325 |
+
const entry = getEntry(id);
|
| 326 |
+
if (!entry || entry.type !== 'file' || !canAccess(entry, owner)) return null;
|
| 327 |
+
return {
|
| 328 |
+
entry: sanitizeEntry(entry),
|
| 329 |
+
buffer: await readEncryptedFile(blobPathFor(id), buildAad(entry)),
|
| 330 |
+
};
|
| 331 |
+
},
|
| 332 |
+
|
| 333 |
+
async readText(owner, id) {
|
| 334 |
+
const loaded = await this.readBuffer(owner, id);
|
| 335 |
+
if (!loaded) return null;
|
| 336 |
+
return {
|
| 337 |
+
entry: loaded.entry,
|
| 338 |
+
text: loaded.buffer.toString('utf8'),
|
| 339 |
+
};
|
| 340 |
+
},
|
| 341 |
+
|
| 342 |
+
async updateContent(owner, id, { buffer, name = null, mimeType = null, kind = null }) {
|
| 343 |
+
ensureOwner(owner);
|
| 344 |
+
await ensureLoaded();
|
| 345 |
+
const entry = getEntry(id);
|
| 346 |
+
if (!entry || entry.type !== 'file' || !canAccess(entry, owner)) return null;
|
| 347 |
+
|
| 348 |
+
if (name) entry.name = normalizeName(name, entry.name);
|
| 349 |
+
if (mimeType) entry.mimeType = mimeType;
|
| 350 |
+
if (kind) entry.kind = kind;
|
| 351 |
+
entry.size = buffer.byteLength;
|
| 352 |
+
entry.updatedAt = nowIso();
|
| 353 |
+
|
| 354 |
+
await writeEncryptedFile(blobPathFor(entry.id), Buffer.from(buffer), buildAad(entry));
|
| 355 |
+
await saveIndex();
|
| 356 |
+
return sanitizeEntry(entry);
|
| 357 |
+
},
|
| 358 |
+
|
| 359 |
+
async move(owner, ids, parentId = null) {
|
| 360 |
+
ensureOwner(owner);
|
| 361 |
+
await ensureLoaded();
|
| 362 |
+
const destinationId = parentId ? resolveParentFolder(owner, parentId) : null;
|
| 363 |
+
if (parentId && !destinationId) throw new Error('Invalid destination folder');
|
| 364 |
+
const updated = [];
|
| 365 |
+
for (const id of ids || []) {
|
| 366 |
+
const entry = getEntry(id);
|
| 367 |
+
if (!entry || !canAccess(entry, owner)) continue;
|
| 368 |
+
if (destinationId === entry.id) continue;
|
| 369 |
+
if (wouldCreateCycle(owner, entry.id, destinationId)) continue;
|
| 370 |
+
entry.parentId = destinationId;
|
| 371 |
+
entry.updatedAt = nowIso();
|
| 372 |
+
updated.push(sanitizeEntry(entry));
|
| 373 |
+
}
|
| 374 |
+
if (updated.length) await saveIndex();
|
| 375 |
+
return updated;
|
| 376 |
+
},
|
| 377 |
+
|
| 378 |
+
async moveToTrash(owner, ids) {
|
| 379 |
+
ensureOwner(owner);
|
| 380 |
+
await ensureLoaded();
|
| 381 |
+
const trashed = [];
|
| 382 |
+
const now = nowIso();
|
| 383 |
+
const purgeAt = new Date(Date.now() + TRASH_RETENTION_MS).toISOString();
|
| 384 |
+
|
| 385 |
+
for (const id of ids || []) {
|
| 386 |
+
const entry = getEntry(id);
|
| 387 |
+
if (!entry || !canAccess(entry, owner)) continue;
|
| 388 |
+
for (const targetId of collectDescendantIds(owner, id)) {
|
| 389 |
+
const target = getEntry(targetId);
|
| 390 |
+
if (!target) continue;
|
| 391 |
+
target.trashedAt = now;
|
| 392 |
+
target.purgeAt = purgeAt;
|
| 393 |
+
target.updatedAt = now;
|
| 394 |
+
trashed.push(sanitizeEntry(target));
|
| 395 |
+
}
|
| 396 |
+
}
|
| 397 |
+
if (trashed.length) await saveIndex();
|
| 398 |
+
return trashed;
|
| 399 |
+
},
|
| 400 |
+
|
| 401 |
+
async restore(owner, ids) {
|
| 402 |
+
ensureOwner(owner);
|
| 403 |
+
await ensureLoaded();
|
| 404 |
+
const restored = [];
|
| 405 |
+
for (const id of ids || []) {
|
| 406 |
+
const entry = getEntry(id);
|
| 407 |
+
if (!entry || !canAccess(entry, owner)) continue;
|
| 408 |
+
for (const targetId of collectDescendantIds(owner, id)) {
|
| 409 |
+
const target = getEntry(targetId);
|
| 410 |
+
if (!target) continue;
|
| 411 |
+
target.trashedAt = null;
|
| 412 |
+
target.purgeAt = null;
|
| 413 |
+
target.updatedAt = nowIso();
|
| 414 |
+
restored.push(sanitizeEntry(target));
|
| 415 |
+
}
|
| 416 |
+
}
|
| 417 |
+
if (restored.length) await saveIndex();
|
| 418 |
+
return restored;
|
| 419 |
+
},
|
| 420 |
+
|
| 421 |
+
async deleteForever(owner, ids) {
|
| 422 |
+
ensureOwner(owner);
|
| 423 |
+
await ensureLoaded();
|
| 424 |
+
const removedIds = new Set();
|
| 425 |
+
for (const id of ids || []) {
|
| 426 |
+
const entry = getEntry(id);
|
| 427 |
+
if (!entry || !canAccess(entry, owner)) continue;
|
| 428 |
+
for (const targetId of collectDescendantIds(owner, id)) {
|
| 429 |
+
removedIds.add(targetId);
|
| 430 |
+
}
|
| 431 |
+
}
|
| 432 |
+
for (const targetId of removedIds) {
|
| 433 |
+
await purgeEntry(targetId);
|
| 434 |
+
}
|
| 435 |
+
if (removedIds.size) await saveIndex();
|
| 436 |
+
return [...removedIds];
|
| 437 |
+
},
|
| 438 |
+
|
| 439 |
+
async attachToSession(owner, ids, sessionId) {
|
| 440 |
+
ensureOwner(owner);
|
| 441 |
+
if (!sessionId) return [];
|
| 442 |
+
await ensureLoaded();
|
| 443 |
+
const updated = [];
|
| 444 |
+
for (const id of ids || []) {
|
| 445 |
+
const entry = getEntry(id);
|
| 446 |
+
if (!entry || !canAccess(entry, owner)) continue;
|
| 447 |
+
entry.sessionIds = [...new Set([...(entry.sessionIds || []), sessionId])];
|
| 448 |
+
entry.updatedAt = nowIso();
|
| 449 |
+
updated.push(sanitizeEntry(entry));
|
| 450 |
+
}
|
| 451 |
+
if (updated.length) await saveIndex();
|
| 452 |
+
return updated;
|
| 453 |
+
},
|
| 454 |
+
};
|
server/memoryStore.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import crypto from 'crypto';
|
| 2 |
+
import path from 'path';
|
| 3 |
+
import { loadEncryptedJson, saveEncryptedJson } from './cryptoUtils.js';
|
| 4 |
+
|
| 5 |
+
const DATA_ROOT = '/data/memories';
|
| 6 |
+
const INDEX_FILE = path.join(DATA_ROOT, 'index.json');
|
| 7 |
+
const MAX_MEMORY_LENGTH = 220;
|
| 8 |
+
|
| 9 |
+
const state = {
|
| 10 |
+
loaded: false,
|
| 11 |
+
index: {
|
| 12 |
+
memories: {},
|
| 13 |
+
},
|
| 14 |
+
};
|
| 15 |
+
|
| 16 |
+
function ensureOwner(owner) {
|
| 17 |
+
if (!owner?.type || !owner?.id) throw new Error('Invalid memory owner');
|
| 18 |
+
return owner;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
function nowIso() {
|
| 22 |
+
return new Date().toISOString();
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
function sanitizeText(text) {
|
| 26 |
+
return String(text || '').replace(/\s+/g, ' ').trim().slice(0, MAX_MEMORY_LENGTH);
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
async function ensureLoaded() {
|
| 30 |
+
if (state.loaded) return;
|
| 31 |
+
const stored = await loadEncryptedJson(INDEX_FILE);
|
| 32 |
+
state.index = {
|
| 33 |
+
memories: stored?.memories || {},
|
| 34 |
+
};
|
| 35 |
+
state.loaded = true;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
async function saveIndex() {
|
| 39 |
+
await saveEncryptedJson(INDEX_FILE, state.index);
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
function matchesOwner(memory, owner) {
|
| 43 |
+
return memory.ownerType === owner.type && memory.ownerId === owner.id;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
function sanitize(memory) {
|
| 47 |
+
return {
|
| 48 |
+
id: memory.id,
|
| 49 |
+
content: memory.content,
|
| 50 |
+
source: memory.source || 'assistant',
|
| 51 |
+
sessionId: memory.sessionId || null,
|
| 52 |
+
createdAt: memory.createdAt,
|
| 53 |
+
updatedAt: memory.updatedAt,
|
| 54 |
+
};
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
export const memoryStore = {
|
| 58 |
+
async list(owner) {
|
| 59 |
+
ensureOwner(owner);
|
| 60 |
+
await ensureLoaded();
|
| 61 |
+
return Object.values(state.index.memories)
|
| 62 |
+
.filter((memory) => matchesOwner(memory, owner))
|
| 63 |
+
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
|
| 64 |
+
.map(sanitize);
|
| 65 |
+
},
|
| 66 |
+
|
| 67 |
+
async create(owner, { content, sessionId = null, source = 'assistant' }) {
|
| 68 |
+
ensureOwner(owner);
|
| 69 |
+
await ensureLoaded();
|
| 70 |
+
const normalized = sanitizeText(content);
|
| 71 |
+
if (!normalized) return null;
|
| 72 |
+
|
| 73 |
+
const memory = {
|
| 74 |
+
id: crypto.randomUUID(),
|
| 75 |
+
ownerType: owner.type,
|
| 76 |
+
ownerId: owner.id,
|
| 77 |
+
content: normalized,
|
| 78 |
+
sessionId,
|
| 79 |
+
source,
|
| 80 |
+
createdAt: nowIso(),
|
| 81 |
+
updatedAt: nowIso(),
|
| 82 |
+
};
|
| 83 |
+
state.index.memories[memory.id] = memory;
|
| 84 |
+
await saveIndex();
|
| 85 |
+
return sanitize(memory);
|
| 86 |
+
},
|
| 87 |
+
|
| 88 |
+
async update(owner, id, content) {
|
| 89 |
+
ensureOwner(owner);
|
| 90 |
+
await ensureLoaded();
|
| 91 |
+
const memory = state.index.memories[id];
|
| 92 |
+
if (!memory || !matchesOwner(memory, owner)) return null;
|
| 93 |
+
const normalized = sanitizeText(content);
|
| 94 |
+
if (!normalized) return null;
|
| 95 |
+
memory.content = normalized;
|
| 96 |
+
memory.updatedAt = nowIso();
|
| 97 |
+
await saveIndex();
|
| 98 |
+
return sanitize(memory);
|
| 99 |
+
},
|
| 100 |
+
|
| 101 |
+
async delete(owner, id) {
|
| 102 |
+
ensureOwner(owner);
|
| 103 |
+
await ensureLoaded();
|
| 104 |
+
const memory = state.index.memories[id];
|
| 105 |
+
if (!memory || !matchesOwner(memory, owner)) return false;
|
| 106 |
+
delete state.index.memories[id];
|
| 107 |
+
await saveIndex();
|
| 108 |
+
return true;
|
| 109 |
+
},
|
| 110 |
+
};
|
server/sessionStore.js
CHANGED
|
@@ -89,8 +89,24 @@ export const sessionStore = {
|
|
| 89 |
saveTempStore().catch(err => console.error('Failed to save temp store:', err));
|
| 90 |
return s;
|
| 91 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
deleteTempSession(t, id) { tempStore.get(t)?.sessions.delete(id); saveTempStore().catch(err => console.error('Failed to save temp store:', err)); },
|
| 93 |
deleteTempAll(t) { tempStore.get(t)?.sessions.clear(); saveTempStore().catch(err => console.error('Failed to save temp store:', err)); },
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
|
| 95 |
/**
|
| 96 |
* Copy temp sessions into the user's account on login.
|
|
@@ -146,6 +162,12 @@ export const sessionStore = {
|
|
| 146 |
await this._persist(userClient(accessToken), userId, s).catch(() => {});
|
| 147 |
return s;
|
| 148 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
async updateUserSession(userId, accessToken, sessionId, patch) {
|
| 150 |
const user = userCache.get(userId); if (!user) { console.error("No user for " + userId); return null; }
|
| 151 |
const s = user.sessions.get(sessionId); if (!s) { console.error ("No session found for " + sessionId); return null; }
|
|
@@ -237,4 +259,4 @@ export const deviceSessionStore = {
|
|
| 237 |
const s = devSessions.get(token); if (!s || !s.active) return null;
|
| 238 |
s.lastSeen = new Date().toISOString(); return s;
|
| 239 |
},
|
| 240 |
-
};
|
|
|
|
| 89 |
saveTempStore().catch(err => console.error('Failed to save temp store:', err));
|
| 90 |
return s;
|
| 91 |
},
|
| 92 |
+
restoreTempSession(t, session) {
|
| 93 |
+
const d = this.initTemp(t);
|
| 94 |
+
const restored = JSON.parse(JSON.stringify(session));
|
| 95 |
+
d.sessions.set(restored.id, restored);
|
| 96 |
+
d.lastActive = Date.now();
|
| 97 |
+
saveTempStore().catch(err => console.error('Failed to save temp store:', err));
|
| 98 |
+
return restored;
|
| 99 |
+
},
|
| 100 |
deleteTempSession(t, id) { tempStore.get(t)?.sessions.delete(id); saveTempStore().catch(err => console.error('Failed to save temp store:', err)); },
|
| 101 |
deleteTempAll(t) { tempStore.get(t)?.sessions.clear(); saveTempStore().catch(err => console.error('Failed to save temp store:', err)); },
|
| 102 |
+
deleteTempSessionEverywhere(id) {
|
| 103 |
+
let changed = false;
|
| 104 |
+
for (const temp of tempStore.values()) {
|
| 105 |
+
if (temp.sessions.delete(id)) changed = true;
|
| 106 |
+
}
|
| 107 |
+
if (changed) saveTempStore().catch(err => console.error('Failed to save temp store:', err));
|
| 108 |
+
return changed;
|
| 109 |
+
},
|
| 110 |
|
| 111 |
/**
|
| 112 |
* Copy temp sessions into the user's account on login.
|
|
|
|
| 162 |
await this._persist(userClient(accessToken), userId, s).catch(() => {});
|
| 163 |
return s;
|
| 164 |
},
|
| 165 |
+
async restoreUserSession(userId, accessToken, session) {
|
| 166 |
+
const restored = JSON.parse(JSON.stringify(session));
|
| 167 |
+
this._ensureUser(userId).sessions.set(restored.id, restored);
|
| 168 |
+
await this._persist(userClient(accessToken), userId, restored).catch(() => {});
|
| 169 |
+
return restored;
|
| 170 |
+
},
|
| 171 |
async updateUserSession(userId, accessToken, sessionId, patch) {
|
| 172 |
const user = userCache.get(userId); if (!user) { console.error("No user for " + userId); return null; }
|
| 173 |
const s = user.sessions.get(sessionId); if (!s) { console.error ("No session found for " + sessionId); return null; }
|
|
|
|
| 259 |
const s = devSessions.get(token); if (!s || !s.active) return null;
|
| 260 |
s.lastSeen = new Date().toISOString(); return s;
|
| 261 |
},
|
| 262 |
+
};
|
server/wsHandler.js
CHANGED
|
@@ -9,8 +9,12 @@ import {
|
|
| 9 |
getUserProfile, setUsername, getSubscriptionInfo,
|
| 10 |
getTierConfig, getUsageInfo,
|
| 11 |
} from './auth.js';
|
| 12 |
-
import { streamChat
|
|
|
|
|
|
|
|
|
|
| 13 |
import crypto from 'crypto';
|
|
|
|
| 14 |
|
| 15 |
/**
|
| 16 |
* Message Structure: Tree-based with versioned tails
|
|
@@ -134,15 +138,36 @@ const handlers = {
|
|
| 134 |
},
|
| 135 |
|
| 136 |
'sessions:delete': async (ws, msg, client) => {
|
| 137 |
-
|
| 138 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
safeSend(ws, { type: 'sessions:deleted', sessionId: msg.sessionId });
|
|
|
|
| 140 |
},
|
| 141 |
|
| 142 |
'sessions:deleteAll': async (ws, msg, client) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
if (client.userId) await sessionStore.deleteAllUserSessions(client.userId, client.accessToken);
|
| 144 |
else sessionStore.deleteTempAll(client.tempId);
|
| 145 |
safeSend(ws, { type: 'sessions:deletedAll' });
|
|
|
|
| 146 |
},
|
| 147 |
|
| 148 |
'sessions:rename': async (ws, msg, client) => {
|
|
@@ -175,8 +200,34 @@ const handlers = {
|
|
| 175 |
safeSend(ws, { type: 'sessions:imported', session: ser(s) });
|
| 176 |
},
|
| 177 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
'chat:send': async (ws, msg, client) => {
|
| 179 |
const { sessionId, content, tools } = msg;
|
|
|
|
| 180 |
if (!client.userId) {
|
| 181 |
const allowed = await consumeGuestRequest(client.ip || 'unknown');
|
| 182 |
if (!allowed) return safeSend(ws, { type: 'guest:rateLimit', message: 'Guest request limit exceeded' });
|
|
@@ -194,33 +245,49 @@ const handlers = {
|
|
| 194 |
safeSend(ws, { type: 'chat:start', sessionId });
|
| 195 |
|
| 196 |
let fullText = '';
|
| 197 |
-
const assetsCollected = [], toolCallsCollected = [];
|
| 198 |
|
| 199 |
// Extract flat history from tree structure
|
| 200 |
const rootMessage = session.history?.[0];
|
| 201 |
const flatHistory = rootMessage ? extractFlatHistory(rootMessage) : [];
|
| 202 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
await streamChat({
|
| 204 |
sessionId,
|
| 205 |
model: session.model,
|
| 206 |
-
history: flatHistory,
|
| 207 |
-
userMessage: content,
|
| 208 |
tools: tools || {},
|
| 209 |
-
accessToken: client.accessToken,
|
| 210 |
-
clientId: msg.clientId,
|
|
|
|
|
|
|
| 211 |
abortSignal: abort.signal,
|
| 212 |
onToken(t) { fullText += t; safeSend(ws, { type: 'chat:token', token: t, sessionId }); },
|
| 213 |
onToolCall(call) {
|
| 214 |
safeSend(ws, { type: 'chat:toolCall', call, sessionId });
|
| 215 |
if (call.state === 'resolved' || call.state === 'canceled') toolCallsCollected.push(call);
|
| 216 |
},
|
| 217 |
-
onNewAsset(asset) {
|
| 218 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
activeStreams.delete(ws);
|
| 220 |
const finalText = text || fullText;
|
| 221 |
-
|
| 222 |
// Only create user entry if content was actually provided
|
| 223 |
-
const hasContent = content !== undefined && content !== null && content !== '' &&
|
| 224 |
!(Array.isArray(content) && content.length === 0);
|
| 225 |
const userEntry = hasContent
|
| 226 |
? buildEntry('user', content)
|
|
@@ -231,17 +298,39 @@ const handlers = {
|
|
| 231 |
const resolved = resolvedMap.get(c.id) || {};
|
| 232 |
return { ...c, state: resolved.state || 'resolved', result: resolved.result };
|
| 233 |
});
|
| 234 |
-
const asstEntry = buildEntry('assistant', finalText, mergedCalls
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
|
| 236 |
// Rebuild tree structure with new messages appended
|
| 237 |
let newRootMessage = rootMessage ? validateAndRepairTree(JSON.parse(JSON.stringify(rootMessage))) : null;
|
| 238 |
-
|
| 239 |
if (!newRootMessage) {
|
| 240 |
// First message in session - must have user entry
|
| 241 |
if (!userEntry) return safeSend(ws, { type: 'error', message: 'No content for first message' });
|
| 242 |
newRootMessage = userEntry;
|
| 243 |
-
|
| 244 |
-
newRootMessage.versions[0].tail = [asstWrap];
|
| 245 |
} else {
|
| 246 |
// Append to current tail
|
| 247 |
const currentVerIdx = newRootMessage.currentVersionIdx ?? 0;
|
|
@@ -251,6 +340,7 @@ const handlers = {
|
|
| 251 |
currentTail.push(userEntry);
|
| 252 |
}
|
| 253 |
currentTail.push(asstEntry);
|
|
|
|
| 254 |
newRootMessage.versions[currentVerIdx].tail = currentTail;
|
| 255 |
}
|
| 256 |
|
|
@@ -266,13 +356,13 @@ const handlers = {
|
|
| 266 |
if (client.userId)
|
| 267 |
await sessionStore.updateUserSession(client.userId, client.accessToken, sessionId, { history: newHistory, name: newName });
|
| 268 |
else sessionStore.updateTempSession(client.tempId, sessionId, { history: newHistory, name: newName });
|
| 269 |
-
|
| 270 |
safeSend(ws, { type: aborted ? 'chat:aborted' : 'chat:done', sessionId, name: newName, history: extractFlatHistory(newRootMessage) });
|
| 271 |
},
|
| 272 |
-
onError(err) {
|
| 273 |
activeStreams.delete(ws);
|
| 274 |
console.error('streamChat error:', err);
|
| 275 |
-
safeSend(ws, { type: 'chat:error', error: String(err), sessionId });
|
| 276 |
},
|
| 277 |
});
|
| 278 |
},
|
|
@@ -378,6 +468,37 @@ const handlers = {
|
|
| 378 |
bcast(wsClients, client.userId, { type: 'settings:updated', settings: msg.settings }, ws);
|
| 379 |
},
|
| 380 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 381 |
'account:getProfile': async (ws, msg, c) => { if (!c.userId) return; safeSend(ws, { type: 'account:profile', profile: await getUserProfile(c.userId, c.accessToken) }); },
|
| 382 |
'account:setUsername': async (ws, msg, c) => { if (!c.userId) return; safeSend(ws, { type: 'account:usernameResult', ...await setUsername(c.userId, c.accessToken, msg.username) }); },
|
| 383 |
'account:getSubscription': async (ws, msg, c) => {
|
|
@@ -400,11 +521,31 @@ const handlers = {
|
|
| 400 |
|
| 401 |
function ser(s) { return { id: s.id, name: s.name, created: s.created, history: s.history || [], model: s.model }; }
|
| 402 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 403 |
function generateMessageId() {
|
| 404 |
return `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
| 405 |
}
|
| 406 |
|
| 407 |
-
function buildEntry(role, content, toolCalls = []) {
|
| 408 |
const normalizedCalls = toolCalls.map(c => ({
|
| 409 |
id: c.id,
|
| 410 |
name: c.name || c.function?.name,
|
|
@@ -420,10 +561,53 @@ function buildEntry(role, content, toolCalls = []) {
|
|
| 420 |
timestamp: Date.now(),
|
| 421 |
versions: [{ content: validContent, tail: [], timestamp: Date.now() }],
|
| 422 |
currentVersionIdx: 0,
|
| 423 |
-
...(normalizedCalls.length ? { toolCalls: normalizedCalls } : {})
|
|
|
|
| 424 |
};
|
| 425 |
}
|
| 426 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 427 |
/**
|
| 428 |
* Validate and repair tree structure after cloning/modification
|
| 429 |
* Ensures all messages and versions have valid content property
|
|
|
|
| 9 |
getUserProfile, setUsername, getSubscriptionInfo,
|
| 10 |
getTierConfig, getUsageInfo,
|
| 11 |
} from './auth.js';
|
| 12 |
+
import { streamChat } from './chatStream.js';
|
| 13 |
+
import { mediaStore } from './mediaStore.js';
|
| 14 |
+
import { memoryStore } from './memoryStore.js';
|
| 15 |
+
import { chatTrashStore } from './chatTrashStore.js';
|
| 16 |
import crypto from 'crypto';
|
| 17 |
+
import path from 'path';
|
| 18 |
|
| 19 |
/**
|
| 20 |
* Message Structure: Tree-based with versioned tails
|
|
|
|
| 138 |
},
|
| 139 |
|
| 140 |
'sessions:delete': async (ws, msg, client) => {
|
| 141 |
+
const owner = getClientOwner(client);
|
| 142 |
+
const session = client.userId
|
| 143 |
+
? sessionStore.getUserSession(client.userId, msg.sessionId)
|
| 144 |
+
: sessionStore.getTempSession(client.tempId, msg.sessionId);
|
| 145 |
+
if (!session) return safeSend(ws, { type: 'error', message: 'Session not found' });
|
| 146 |
+
|
| 147 |
+
await chatTrashStore.add(owner, JSON.parse(JSON.stringify(session)));
|
| 148 |
+
if (client.userId) {
|
| 149 |
+
await sessionStore.deleteUserSession(client.userId, client.accessToken, msg.sessionId);
|
| 150 |
+
sessionStore.deleteTempSessionEverywhere(msg.sessionId);
|
| 151 |
+
} else {
|
| 152 |
+
sessionStore.deleteTempSession(client.tempId, msg.sessionId);
|
| 153 |
+
}
|
| 154 |
safeSend(ws, { type: 'sessions:deleted', sessionId: msg.sessionId });
|
| 155 |
+
safeSend(ws, { type: 'trash:chats:changed' });
|
| 156 |
},
|
| 157 |
|
| 158 |
'sessions:deleteAll': async (ws, msg, client) => {
|
| 159 |
+
const owner = getClientOwner(client);
|
| 160 |
+
const sessions = client.userId
|
| 161 |
+
? sessionStore.getUserSessions(client.userId)
|
| 162 |
+
: sessionStore.getTempSessions(client.tempId);
|
| 163 |
+
for (const session of sessions) {
|
| 164 |
+
await chatTrashStore.add(owner, JSON.parse(JSON.stringify(session)));
|
| 165 |
+
if (client.userId) sessionStore.deleteTempSessionEverywhere(session.id);
|
| 166 |
+
}
|
| 167 |
if (client.userId) await sessionStore.deleteAllUserSessions(client.userId, client.accessToken);
|
| 168 |
else sessionStore.deleteTempAll(client.tempId);
|
| 169 |
safeSend(ws, { type: 'sessions:deletedAll' });
|
| 170 |
+
safeSend(ws, { type: 'trash:chats:changed' });
|
| 171 |
},
|
| 172 |
|
| 173 |
'sessions:rename': async (ws, msg, client) => {
|
|
|
|
| 200 |
safeSend(ws, { type: 'sessions:imported', session: ser(s) });
|
| 201 |
},
|
| 202 |
|
| 203 |
+
'trash:chats:list': async (ws, msg, client) => {
|
| 204 |
+
const owner = getClientOwner(client);
|
| 205 |
+
const items = await chatTrashStore.list(owner);
|
| 206 |
+
safeSend(ws, { type: 'trash:chats:list', items });
|
| 207 |
+
},
|
| 208 |
+
|
| 209 |
+
'trash:chats:restore': async (ws, msg, client) => {
|
| 210 |
+
const owner = getClientOwner(client);
|
| 211 |
+
const restored = await chatTrashStore.restore(owner, msg.ids || []);
|
| 212 |
+
const sessions = [];
|
| 213 |
+
for (const snapshot of restored) {
|
| 214 |
+
const restoredSession = await restoreDeletedSession(client, snapshot);
|
| 215 |
+
if (restoredSession) sessions.push(ser(restoredSession));
|
| 216 |
+
}
|
| 217 |
+
safeSend(ws, { type: 'trash:chats:restored', sessions });
|
| 218 |
+
safeSend(ws, { type: 'trash:chats:changed' });
|
| 219 |
+
},
|
| 220 |
+
|
| 221 |
+
'trash:chats:deleteForever': async (ws, msg, client) => {
|
| 222 |
+
const owner = getClientOwner(client);
|
| 223 |
+
const removedIds = await chatTrashStore.deleteForever(owner, msg.ids || []);
|
| 224 |
+
safeSend(ws, { type: 'trash:chats:deletedForever', ids: removedIds });
|
| 225 |
+
safeSend(ws, { type: 'trash:chats:changed' });
|
| 226 |
+
},
|
| 227 |
+
|
| 228 |
'chat:send': async (ws, msg, client) => {
|
| 229 |
const { sessionId, content, tools } = msg;
|
| 230 |
+
const owner = getClientOwner(client);
|
| 231 |
if (!client.userId) {
|
| 232 |
const allowed = await consumeGuestRequest(client.ip || 'unknown');
|
| 233 |
if (!allowed) return safeSend(ws, { type: 'guest:rateLimit', message: 'Guest request limit exceeded' });
|
|
|
|
| 245 |
safeSend(ws, { type: 'chat:start', sessionId });
|
| 246 |
|
| 247 |
let fullText = '';
|
| 248 |
+
const assetsCollected = [], toolCallsCollected = [], responseEditsCollected = [];
|
| 249 |
|
| 250 |
// Extract flat history from tree structure
|
| 251 |
const rootMessage = session.history?.[0];
|
| 252 |
const flatHistory = rootMessage ? extractFlatHistory(rootMessage) : [];
|
| 253 |
|
| 254 |
+
if (Array.isArray(msg.linkedMediaIds) && msg.linkedMediaIds.length) {
|
| 255 |
+
await mediaStore.attachToSession(owner, msg.linkedMediaIds, sessionId).catch((err) => {
|
| 256 |
+
console.error('Failed to link uploaded media to session:', err);
|
| 257 |
+
});
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
await streamChat({
|
| 261 |
sessionId,
|
| 262 |
model: session.model,
|
| 263 |
+
history: flatHistory,
|
| 264 |
+
userMessage: content,
|
| 265 |
tools: tools || {},
|
| 266 |
+
accessToken: client.accessToken,
|
| 267 |
+
clientId: msg.clientId,
|
| 268 |
+
owner,
|
| 269 |
+
sessionName: session.name,
|
| 270 |
abortSignal: abort.signal,
|
| 271 |
onToken(t) { fullText += t; safeSend(ws, { type: 'chat:token', token: t, sessionId }); },
|
| 272 |
onToolCall(call) {
|
| 273 |
safeSend(ws, { type: 'chat:toolCall', call, sessionId });
|
| 274 |
if (call.state === 'resolved' || call.state === 'canceled') toolCallsCollected.push(call);
|
| 275 |
},
|
| 276 |
+
onNewAsset(asset) {
|
| 277 |
+
safeSend(ws, { type: 'chat:asset', asset, sessionId });
|
| 278 |
+
assetsCollected.push(asset);
|
| 279 |
+
safeSend(ws, { type: 'media:changed' });
|
| 280 |
+
},
|
| 281 |
+
onDraftEdit(edit, draftText) {
|
| 282 |
+
responseEditsCollected.push(edit);
|
| 283 |
+
safeSend(ws, { type: 'chat:draftEdited', edit, text: draftText, sessionId });
|
| 284 |
+
},
|
| 285 |
+
async onDone(text, toolCalls, aborted, sessionNameFromTag, responseEdits = []) {
|
| 286 |
activeStreams.delete(ws);
|
| 287 |
const finalText = text || fullText;
|
| 288 |
+
|
| 289 |
// Only create user entry if content was actually provided
|
| 290 |
+
const hasContent = content !== undefined && content !== null && content !== '' &&
|
| 291 |
!(Array.isArray(content) && content.length === 0);
|
| 292 |
const userEntry = hasContent
|
| 293 |
? buildEntry('user', content)
|
|
|
|
| 298 |
const resolved = resolvedMap.get(c.id) || {};
|
| 299 |
return { ...c, state: resolved.state || 'resolved', result: resolved.result };
|
| 300 |
});
|
| 301 |
+
const asstEntry = buildEntry('assistant', finalText, mergedCalls, {
|
| 302 |
+
responseEdits: [...responseEditsCollected, ...responseEdits],
|
| 303 |
+
});
|
| 304 |
+
|
| 305 |
+
const mediaEntries = assetsCollected.map((asset) =>
|
| 306 |
+
buildMediaEntry(asset.role, {
|
| 307 |
+
assetId: asset.id,
|
| 308 |
+
mimeType: asset.mimeType,
|
| 309 |
+
name: asset.name,
|
| 310 |
+
})
|
| 311 |
+
);
|
| 312 |
+
|
| 313 |
+
const generatedFiles = extractAssistantGeneratedFiles(finalText);
|
| 314 |
+
for (const file of generatedFiles) {
|
| 315 |
+
await mediaStore.storeBuffer(owner, {
|
| 316 |
+
name: file.name,
|
| 317 |
+
mimeType: file.mimeType,
|
| 318 |
+
buffer: file.buffer,
|
| 319 |
+
sessionId,
|
| 320 |
+
source: 'assistant_generated',
|
| 321 |
+
kind: file.kind,
|
| 322 |
+
}).catch((err) => console.error('Failed to store generated text asset:', err));
|
| 323 |
+
}
|
| 324 |
+
if (generatedFiles.length) safeSend(ws, { type: 'media:changed' });
|
| 325 |
|
| 326 |
// Rebuild tree structure with new messages appended
|
| 327 |
let newRootMessage = rootMessage ? validateAndRepairTree(JSON.parse(JSON.stringify(rootMessage))) : null;
|
| 328 |
+
|
| 329 |
if (!newRootMessage) {
|
| 330 |
// First message in session - must have user entry
|
| 331 |
if (!userEntry) return safeSend(ws, { type: 'error', message: 'No content for first message' });
|
| 332 |
newRootMessage = userEntry;
|
| 333 |
+
newRootMessage.versions[0].tail = [{ ...asstEntry }, ...mediaEntries];
|
|
|
|
| 334 |
} else {
|
| 335 |
// Append to current tail
|
| 336 |
const currentVerIdx = newRootMessage.currentVersionIdx ?? 0;
|
|
|
|
| 340 |
currentTail.push(userEntry);
|
| 341 |
}
|
| 342 |
currentTail.push(asstEntry);
|
| 343 |
+
currentTail.push(...mediaEntries);
|
| 344 |
newRootMessage.versions[currentVerIdx].tail = currentTail;
|
| 345 |
}
|
| 346 |
|
|
|
|
| 356 |
if (client.userId)
|
| 357 |
await sessionStore.updateUserSession(client.userId, client.accessToken, sessionId, { history: newHistory, name: newName });
|
| 358 |
else sessionStore.updateTempSession(client.tempId, sessionId, { history: newHistory, name: newName });
|
| 359 |
+
|
| 360 |
safeSend(ws, { type: aborted ? 'chat:aborted' : 'chat:done', sessionId, name: newName, history: extractFlatHistory(newRootMessage) });
|
| 361 |
},
|
| 362 |
+
onError(err) {
|
| 363 |
activeStreams.delete(ws);
|
| 364 |
console.error('streamChat error:', err);
|
| 365 |
+
safeSend(ws, { type: 'chat:error', error: String(err), sessionId });
|
| 366 |
},
|
| 367 |
});
|
| 368 |
},
|
|
|
|
| 468 |
bcast(wsClients, client.userId, { type: 'settings:updated', settings: msg.settings }, ws);
|
| 469 |
},
|
| 470 |
|
| 471 |
+
'memories:list': async (ws, msg, client) => {
|
| 472 |
+
const owner = getClientOwner(client);
|
| 473 |
+
const items = await memoryStore.list(owner);
|
| 474 |
+
safeSend(ws, { type: 'memories:list', items });
|
| 475 |
+
},
|
| 476 |
+
|
| 477 |
+
'memories:create': async (ws, msg, client, wsClients) => {
|
| 478 |
+
const owner = getClientOwner(client);
|
| 479 |
+
const memory = await memoryStore.create(owner, {
|
| 480 |
+
content: msg.content,
|
| 481 |
+
sessionId: msg.sessionId || null,
|
| 482 |
+
source: msg.source || 'manual',
|
| 483 |
+
});
|
| 484 |
+
safeSend(ws, { type: 'memories:created', memory });
|
| 485 |
+
if (client.userId) bcast(wsClients, client.userId, { type: 'memories:changed' }, ws);
|
| 486 |
+
},
|
| 487 |
+
|
| 488 |
+
'memories:update': async (ws, msg, client, wsClients) => {
|
| 489 |
+
const owner = getClientOwner(client);
|
| 490 |
+
const memory = await memoryStore.update(owner, msg.id, msg.content);
|
| 491 |
+
safeSend(ws, { type: 'memories:updated', memory });
|
| 492 |
+
if (client.userId) bcast(wsClients, client.userId, { type: 'memories:changed' }, ws);
|
| 493 |
+
},
|
| 494 |
+
|
| 495 |
+
'memories:delete': async (ws, msg, client, wsClients) => {
|
| 496 |
+
const owner = getClientOwner(client);
|
| 497 |
+
const ok = await memoryStore.delete(owner, msg.id);
|
| 498 |
+
safeSend(ws, { type: 'memories:deleted', id: ok ? msg.id : null });
|
| 499 |
+
if (client.userId) bcast(wsClients, client.userId, { type: 'memories:changed' }, ws);
|
| 500 |
+
},
|
| 501 |
+
|
| 502 |
'account:getProfile': async (ws, msg, c) => { if (!c.userId) return; safeSend(ws, { type: 'account:profile', profile: await getUserProfile(c.userId, c.accessToken) }); },
|
| 503 |
'account:setUsername': async (ws, msg, c) => { if (!c.userId) return; safeSend(ws, { type: 'account:usernameResult', ...await setUsername(c.userId, c.accessToken, msg.username) }); },
|
| 504 |
'account:getSubscription': async (ws, msg, c) => {
|
|
|
|
| 521 |
|
| 522 |
function ser(s) { return { id: s.id, name: s.name, created: s.created, history: s.history || [], model: s.model }; }
|
| 523 |
|
| 524 |
+
function getClientOwner(client) {
|
| 525 |
+
return client.userId
|
| 526 |
+
? { type: 'user', id: client.userId }
|
| 527 |
+
: { type: 'guest', id: client.tempId };
|
| 528 |
+
}
|
| 529 |
+
|
| 530 |
+
async function restoreDeletedSession(client, snapshot) {
|
| 531 |
+
if (!snapshot) return null;
|
| 532 |
+
const restored = JSON.parse(JSON.stringify(snapshot));
|
| 533 |
+
const existing = client.userId
|
| 534 |
+
? sessionStore.getUserSession(client.userId, restored.id)
|
| 535 |
+
: sessionStore.getTempSession(client.tempId, restored.id);
|
| 536 |
+
if (existing) restored.id = crypto.randomUUID();
|
| 537 |
+
restored.created = restored.created || Date.now();
|
| 538 |
+
if (client.userId) {
|
| 539 |
+
return sessionStore.restoreUserSession(client.userId, client.accessToken, restored);
|
| 540 |
+
}
|
| 541 |
+
return sessionStore.restoreTempSession(client.tempId, restored);
|
| 542 |
+
}
|
| 543 |
+
|
| 544 |
function generateMessageId() {
|
| 545 |
return `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
| 546 |
}
|
| 547 |
|
| 548 |
+
function buildEntry(role, content, toolCalls = [], extraFields = {}) {
|
| 549 |
const normalizedCalls = toolCalls.map(c => ({
|
| 550 |
id: c.id,
|
| 551 |
name: c.name || c.function?.name,
|
|
|
|
| 561 |
timestamp: Date.now(),
|
| 562 |
versions: [{ content: validContent, tail: [], timestamp: Date.now() }],
|
| 563 |
currentVersionIdx: 0,
|
| 564 |
+
...(normalizedCalls.length ? { toolCalls: normalizedCalls } : {}),
|
| 565 |
+
...extraFields,
|
| 566 |
};
|
| 567 |
}
|
| 568 |
|
| 569 |
+
function buildMediaEntry(role, content) {
|
| 570 |
+
return {
|
| 571 |
+
id: generateMessageId(),
|
| 572 |
+
role,
|
| 573 |
+
content,
|
| 574 |
+
timestamp: Date.now(),
|
| 575 |
+
versions: [{ content, tail: [], timestamp: Date.now() }],
|
| 576 |
+
currentVersionIdx: 0,
|
| 577 |
+
};
|
| 578 |
+
}
|
| 579 |
+
|
| 580 |
+
function extractAssistantGeneratedFiles(text) {
|
| 581 |
+
if (!text || typeof text !== 'string') return [];
|
| 582 |
+
const files = [];
|
| 583 |
+
const detailsRe = /<details><summary>([^<]+?)<\/summary>\s*```(?:\w*)\n([\s\S]*?)\n```\s*<\/details>/g;
|
| 584 |
+
const svgRe = /```svg\n([\s\S]*?)\n```/g;
|
| 585 |
+
let match;
|
| 586 |
+
|
| 587 |
+
while ((match = detailsRe.exec(text)) !== null) {
|
| 588 |
+
const name = String(match[1] || '').trim();
|
| 589 |
+
const ext = path.extname(name).toLowerCase();
|
| 590 |
+
files.push({
|
| 591 |
+
name: name || 'generated-file.txt',
|
| 592 |
+
mimeType: ext === '.html' || ext === '.htm' ? 'text/html' : 'text/plain',
|
| 593 |
+
kind: ext === '.html' || ext === '.htm' ? 'rich_text' : 'text',
|
| 594 |
+
buffer: Buffer.from(match[2], 'utf8'),
|
| 595 |
+
});
|
| 596 |
+
}
|
| 597 |
+
|
| 598 |
+
let svgIndex = 1;
|
| 599 |
+
while ((match = svgRe.exec(text)) !== null) {
|
| 600 |
+
files.push({
|
| 601 |
+
name: `generated-image-${svgIndex++}.svg`,
|
| 602 |
+
mimeType: 'image/svg+xml',
|
| 603 |
+
kind: 'image',
|
| 604 |
+
buffer: Buffer.from(match[1], 'utf8'),
|
| 605 |
+
});
|
| 606 |
+
}
|
| 607 |
+
|
| 608 |
+
return files;
|
| 609 |
+
}
|
| 610 |
+
|
| 611 |
/**
|
| 612 |
* Validate and repair tree structure after cloning/modification
|
| 613 |
* Ensures all messages and versions have valid content property
|