incognitolm commited on
Commit
cac3e38
·
1 Parent(s): 92f389e

More things - 2

Browse files
app-docs.md ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # InferencePort App Documentation
2
+
3
+ This document gives the assistant a structured reference for the public web interface, the main settings surfaces, account and media flows, and the current privacy and terms policies. Use the section list tool first, then read only the section that answers the user’s question.
4
+
5
+ ## Pages
6
+
7
+ - `index.html` is the main app experience. It includes the chat workspace, bottom composer, welcome screen, sidebar, media panel, settings access, authentication entry points, and share-import banner.
8
+ - `admin.html` is a separate administrator page and is not part of the normal end-user workflow.
9
+ - `privacy.html` explains what data the app collects, how it is used, and what the user can expect from storage and third-party services.
10
+ - `terms.html` explains acceptable use, prohibited use, account expectations, and service disclaimers.
11
+
12
+ ## Chat Workspace
13
+
14
+ - The welcome view is the landing state before a session has visible history. It exposes the centered input for starting a chat.
15
+ - The chat view shows the active conversation, message variants, tool calls, generated media, and message actions like copy, edit, regenerate, and continue.
16
+ - The main composer supports plain text, markdown-style input, attachments, tool toggles, and prompt editing.
17
+ - User message editing creates a new version of the prompt. Follow-up turns continue from the active branch instead of the original branch.
18
+ - Assistant responses can have multiple versions. Users can switch between versions, regenerate a reply, or continue generating from the selected assistant message when appropriate.
19
+
20
+ ## Sidebar And Media Library
21
+
22
+ - The sidebar contains the chat session list, deleted chat access, media library access, settings access, and account shortcuts.
23
+ - The media library shows folders and saved files, including text documents, rich text documents, images, video, audio, and assistant-generated assets.
24
+ - Signed-in users can upload files, create folders, create notes, rename items, move items, edit text documents, restore trashed items, and permanently delete files.
25
+ - Signed-out or guest users are restricted to download and delete behavior in the media area. Upload and creation actions require sign-in.
26
+ - The three-dot item menu exposes contextual actions like download, rename, move, trash, restore, and delete forever depending on item state and the user’s authentication status.
27
+
28
+ ## Settings And Account
29
+
30
+ - The Chat settings tab controls theme and tool toggles for web search, image generation, video generation, and audio generation.
31
+ - The Chat settings tab also shows tool usage progress bars, including the free-plan daily web search limit and other tool quotas returned by the usage service.
32
+ - The Personalization tab lets signed-in users edit a private system prompt that is stored for their account and sent before chats.
33
+ - The Memories tab lets users save, edit, and delete persistent short memories that can influence future chats.
34
+ - The Account tab lets signed-in users edit usernames, review the active subscription plan, inspect current device sessions, revoke other sessions, open billing, and delete the account.
35
+
36
+ ## Modals
37
+
38
+ - Auth modal: sign in, sign up, or continue with supported OAuth providers.
39
+ - Confirm modal: generic confirmation for destructive or sensitive actions.
40
+ - Text prompt modal: used for naming files or folders, entering URLs, and other short-form prompts.
41
+ - Image modal: full-size image preview.
42
+ - Device session modal: details for a logged-in device session with revoke controls.
43
+ - File viewer modal: view or edit attached text files from messages.
44
+ - Tool call modal: shows details of tool arguments and results from assistant tool usage.
45
+ - Media picker modal: choose saved media items to attach into a draft.
46
+ - Folder picker modal: choose a destination folder when moving library items.
47
+
48
+ ## Tool Limits And Credits
49
+
50
+ - Usage data is fetched from `https://sharktide-lightning.hf.space/usage`.
51
+ - The app shows quota progress when that endpoint returns usage metrics such as daily chat credits, image generations, video generations, audio generations, and free-plan web searches.
52
+ - Free accounts have a local limit of 15 web searches per day inside the app. When the limit is reached, the web search tool should explain that the user has hit the daily free-plan search cap and suggest waiting for reset or upgrading.
53
+
54
+ ## Data Collection
55
+
56
+ When a user visits the site, the app may collect or process:
57
+
58
+ - Basic connection metadata such as IP address, browser user agent, and timestamps for security, session tracking, rate limiting, and device session management.
59
+ - Turnstile verification state to confirm the visitor is human before using protected routes or WebSocket actions.
60
+ - Authentication tokens and account identifiers when a user signs in.
61
+ - Chat content, uploaded media, saved memories, settings, personalization prompts, and device session records when those features are used.
62
+ - Usage and subscription information returned by the billing and quota services.
63
+ - Temporary guest identifiers and client identifiers stored in local storage so guest sessions and per-device usage can work.
64
+
65
+ The app should not claim zero collection. It stores and processes the data needed to provide chats, uploads, settings synchronization, abuse prevention, and billing-aware limits.
66
+
67
+ ## Privacy Policy
68
+
69
+ InferencePort uses data to operate the service, store chats and media, apply user settings, enforce limits, detect abuse, and support account management. If a user signs in, account-linked data can be synchronized across devices. If a user stays signed out, temporary identifiers may still be stored locally so guest sessions and limits continue to work.
70
+
71
+ Uploaded files, chat messages, saved memories, and personalization prompts may be stored on the service so the product can retrieve them later. Generated assets may also be stored so they can be re-opened or downloaded. Third-party services may process requests for authentication, verification, billing, quota tracking, web search, and model execution.
72
+
73
+ The privacy policy should explain that:
74
+
75
+ - Users should avoid submitting highly sensitive personal data unless they understand the risks.
76
+ - The service may log operational metadata for debugging, billing, moderation, and abuse prevention.
77
+ - Third-party providers may receive request data needed to fulfill product features.
78
+ - Users can delete chats, media, memories, or accounts through the interface, but operational backups or logs may persist briefly where required for reliability or security.
79
+ - No guarantee of absolute security should be stated.
80
+
81
+ ## Terms Of Service
82
+
83
+ Ethical, legal use is permitted. Users may use the service for ordinary personal, educational, creative, and business tasks so long as their use is lawful and does not harm others or the service.
84
+
85
+ The terms should make clear that users must not:
86
+
87
+ - Use the service to break the law, violate contracts, infringe intellectual property, or bypass security controls.
88
+ - Upload or distribute malware, credential theft content, destructive payloads, or instructions intended for abuse.
89
+ - Harass, exploit, impersonate, defraud, stalk, or intentionally harm other people.
90
+ - Attempt to overload, disrupt, scrape, reverse engineer, or misuse the service infrastructure beyond normal permitted use.
91
+ - Use the service to generate or automate clearly illegal activity.
92
+
93
+ The terms should also explain that:
94
+
95
+ - Users are responsible for the content they submit and the actions they take using the service.
96
+ - Service availability, quotas, model behavior, and features can change over time.
97
+ - The service may suspend access for abusive, unlawful, or dangerous use.
98
+ - Outputs can be wrong, incomplete, or unsafe and should be reviewed before relying on them.
99
+ - InferencePort AI disclaims responsibility for losses or damages resulting from misuse of the service or from users choosing to run risky content.
100
+
101
+ ## Troubleshooting
102
+
103
+ - If uploads are disabled, check whether the user is signed in.
104
+ - If web search stops working, check the daily free-plan search limit.
105
+ - If a message edit seems to branch incorrectly, make sure the current selected message version is the one the user expects before sending the next follow-up.
106
+ - If a response shows a retry button, the assistant request or tool flow failed before completion and can usually be retried directly from the conversation.
server/appDocs.js ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { fileURLToPath } from 'url';
4
+
5
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
+ const DOCS_PATH = path.resolve(__dirname, '..', 'app-docs.md');
7
+
8
+ let cachedSections = [];
9
+ let cachedMtimeMs = 0;
10
+
11
+ function slugify(text) {
12
+ return String(text || '')
13
+ .toLowerCase()
14
+ .replace(/[^a-z0-9]+/g, '-')
15
+ .replace(/^-+|-+$/g, '');
16
+ }
17
+
18
+ async function loadSections() {
19
+ const stats = await fs.stat(DOCS_PATH);
20
+ if (cachedSections.length && cachedMtimeMs === stats.mtimeMs) return cachedSections;
21
+
22
+ const raw = await fs.readFile(DOCS_PATH, 'utf8');
23
+ const parts = raw.split(/^##\s+/m);
24
+ const first = parts.shift() || '';
25
+ const sections = [];
26
+
27
+ const overview = first.replace(/^#.*$/m, '').trim();
28
+ if (overview) {
29
+ sections.push({
30
+ id: 'overview',
31
+ title: 'Overview',
32
+ content: overview,
33
+ });
34
+ }
35
+
36
+ parts.forEach((part) => {
37
+ const lines = part.split('\n');
38
+ const title = (lines.shift() || '').trim();
39
+ const content = lines.join('\n').trim();
40
+ if (!title || !content) return;
41
+ sections.push({
42
+ id: slugify(title),
43
+ title,
44
+ content,
45
+ });
46
+ });
47
+
48
+ cachedSections = sections;
49
+ cachedMtimeMs = stats.mtimeMs;
50
+ return sections;
51
+ }
52
+
53
+ export async function listAppDocSections() {
54
+ const sections = await loadSections();
55
+ return sections.map(({ id, title }) => ({ id, title }));
56
+ }
57
+
58
+ export async function readAppDocSection(sectionId) {
59
+ const target = String(sectionId || '').trim().toLowerCase();
60
+ if (!target) return null;
61
+ const sections = await loadSections();
62
+ return sections.find((section) =>
63
+ section.id === target || section.title.toLowerCase() === target
64
+ ) || null;
65
+ }
server/auth.js CHANGED
@@ -93,13 +93,25 @@ export async function getTierConfig() {
93
  } catch { return null; }
94
  }
95
 
96
- export async function getUsageInfo(accessToken) {
97
  try {
98
  const h = { Accept: 'application/json' };
99
  if (accessToken) h.Authorization = `Bearer ${accessToken}`;
 
100
  const r = await fetch('https://sharktide-lightning.hf.space/usage', { headers: h });
101
- return r.ok ? r.json() : null;
102
- } catch { return null; }
 
 
 
 
 
 
 
 
 
 
 
103
  }
104
 
105
  function defaultSettings() {
 
93
  } catch { return null; }
94
  }
95
 
96
+ export async function getUsageInfo(accessToken, clientId = '') {
97
  try {
98
  const h = { Accept: 'application/json' };
99
  if (accessToken) h.Authorization = `Bearer ${accessToken}`;
100
+ if (clientId) h['X-Client-ID'] = clientId;
101
  const r = await fetch('https://sharktide-lightning.hf.space/usage', { headers: h });
102
+ const payload = r.ok ? await r.json() : null;
103
+ console.log('[Usage API]', JSON.stringify({
104
+ ok: r.ok,
105
+ status: r.status,
106
+ clientId: clientId || null,
107
+ hasAuth: !!accessToken,
108
+ payload,
109
+ }));
110
+ return payload;
111
+ } catch (err) {
112
+ console.error('[Usage API] request failed:', err.message);
113
+ return null;
114
+ }
115
  }
116
 
117
  function defaultSettings() {
server/chatStream.js CHANGED
@@ -9,6 +9,8 @@ import { encoding_for_model } from "tiktoken";
9
  import { mediaStore } from "./mediaStore.js";
10
  import { memoryStore } from "./memoryStore.js";
11
  import { systemPromptStore } from "./systemPromptStore.js";
 
 
12
 
13
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
  const WORKER_PATH = path.join(__dirname, "searchWorker.js");
@@ -1234,6 +1236,7 @@ export async function streamChat({
1234
  sessionName = "",
1235
  accessToken,
1236
  clientId,
 
1237
  onToken = () => {},
1238
  onDone = () => {},
1239
  onError = () => {},
@@ -1271,6 +1274,7 @@ export async function streamChat({
1271
  const allToolCalls = [];
1272
  const draftState = { text: "" };
1273
  const responseEdits = [];
 
1274
 
1275
  while (!finished && agentStep < MAX_AGENT_STEPS) {
1276
  const effectiveMessages = buildModelMessages(baseMessages, workingMessages, sessionId);
@@ -1288,11 +1292,20 @@ export async function streamChat({
1288
  abortSignal
1289
  );
1290
 
 
 
 
1291
  assistantText += stepText;
1292
  draftState.text = assistantText;
1293
 
1294
  if (toolCalls.length > 0) {
1295
  allToolCalls.push(...toolCalls);
 
 
 
 
 
 
1296
  workingMessages.push({
1297
  role: "assistant",
1298
  content: stepText || "",
@@ -1306,6 +1319,7 @@ export async function streamChat({
1306
  owner,
1307
  accessToken,
1308
  clientId,
 
1309
  abortSignal,
1310
  draftState,
1311
  onToolCall,
@@ -1349,6 +1363,7 @@ export async function streamChat({
1349
  );
1350
 
1351
  if (finalStepText) {
 
1352
  assistantText += finalStepText;
1353
  draftState.text = assistantText;
1354
  workingMessages.push({ role: "assistant", content: finalStepText });
@@ -1363,7 +1378,7 @@ export async function streamChat({
1363
  const sessionName = extractSessionName(assistantText);
1364
 
1365
  if (typeof onDone === "function") {
1366
- onDone(assistantText, allToolCalls, false, sessionName, responseEdits);
1367
  }
1368
 
1369
  clearPromptState(sessionId);
@@ -1474,6 +1489,31 @@ function buildToolList(tools) {
1474
  parameters: { type: "object", properties: { note: { type: "string" } }, required: ["note"] }
1475
  }
1476
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1477
  list.push({
1478
  type: "function",
1479
  function: {
@@ -1684,6 +1724,7 @@ async function processToolCalls({
1684
  owner,
1685
  accessToken,
1686
  clientId,
 
1687
  abortSignal,
1688
  draftState,
1689
  onToolCall,
@@ -1793,6 +1834,18 @@ async function processToolCalls({
1793
  result = "Note stored for the rest of this response.";
1794
  }
1795
 
 
 
 
 
 
 
 
 
 
 
 
 
1796
  else if (toolName === "list_memories") {
1797
  const memories = owner ? await memoryStore.list(owner) : [];
1798
  result = JSON.stringify(memories, null, 2);
@@ -1840,7 +1893,16 @@ async function processToolCalls({
1840
  }
1841
 
1842
  else if (toolName === "ollama_search") {
1843
- result = await gradioSearch(args.query);
 
 
 
 
 
 
 
 
 
1844
  }
1845
 
1846
  else if (toolName === "read_web_page") {
 
9
  import { mediaStore } from "./mediaStore.js";
10
  import { memoryStore } from "./memoryStore.js";
11
  import { systemPromptStore } from "./systemPromptStore.js";
12
+ import { consumeWebSearchUsage } from "./webSearchUsageStore.js";
13
+ import { listAppDocSections, readAppDocSection } from "./appDocs.js";
14
 
15
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
  const WORKER_PATH = path.join(__dirname, "searchWorker.js");
 
1236
  sessionName = "",
1237
  accessToken,
1238
  clientId,
1239
+ webSearchLimit = null,
1240
  onToken = () => {},
1241
  onDone = () => {},
1242
  onError = () => {},
 
1274
  const allToolCalls = [];
1275
  const draftState = { text: "" };
1276
  const responseEdits = [];
1277
+ const responseSegments = [];
1278
 
1279
  while (!finished && agentStep < MAX_AGENT_STEPS) {
1280
  const effectiveMessages = buildModelMessages(baseMessages, workingMessages, sessionId);
 
1292
  abortSignal
1293
  );
1294
 
1295
+ if (stepText) {
1296
+ responseSegments.push({ type: "text", text: stepText });
1297
+ }
1298
  assistantText += stepText;
1299
  draftState.text = assistantText;
1300
 
1301
  if (toolCalls.length > 0) {
1302
  allToolCalls.push(...toolCalls);
1303
+ responseSegments.push(
1304
+ ...toolCalls.map((call) => ({
1305
+ type: "tool_call",
1306
+ callId: call.id,
1307
+ }))
1308
+ );
1309
  workingMessages.push({
1310
  role: "assistant",
1311
  content: stepText || "",
 
1319
  owner,
1320
  accessToken,
1321
  clientId,
1322
+ webSearchLimit,
1323
  abortSignal,
1324
  draftState,
1325
  onToolCall,
 
1363
  );
1364
 
1365
  if (finalStepText) {
1366
+ responseSegments.push({ type: "text", text: finalStepText });
1367
  assistantText += finalStepText;
1368
  draftState.text = assistantText;
1369
  workingMessages.push({ role: "assistant", content: finalStepText });
 
1378
  const sessionName = extractSessionName(assistantText);
1379
 
1380
  if (typeof onDone === "function") {
1381
+ onDone(assistantText, allToolCalls, false, sessionName, responseEdits, responseSegments);
1382
  }
1383
 
1384
  clearPromptState(sessionId);
 
1489
  parameters: { type: "object", properties: { note: { type: "string" } }, required: ["note"] }
1490
  }
1491
  });
1492
+ list.push({
1493
+ type: "function",
1494
+ function: {
1495
+ name: "list_app_doc_sections",
1496
+ description: "List the available sections of the app documentation so you can choose one relevant section to read.",
1497
+ parameters: {
1498
+ type: "object",
1499
+ properties: {},
1500
+ },
1501
+ },
1502
+ });
1503
+ list.push({
1504
+ type: "function",
1505
+ function: {
1506
+ name: "read_app_doc_section",
1507
+ description: "Read one specific app documentation section by section id or exact section title.",
1508
+ parameters: {
1509
+ type: "object",
1510
+ properties: {
1511
+ section_id: { type: "string", description: "The section id or exact title from list_app_doc_sections." },
1512
+ },
1513
+ required: ["section_id"],
1514
+ },
1515
+ },
1516
+ });
1517
  list.push({
1518
  type: "function",
1519
  function: {
 
1724
  owner,
1725
  accessToken,
1726
  clientId,
1727
+ webSearchLimit,
1728
  abortSignal,
1729
  draftState,
1730
  onToolCall,
 
1834
  result = "Note stored for the rest of this response.";
1835
  }
1836
 
1837
+ else if (toolName === "list_app_doc_sections") {
1838
+ const sections = await listAppDocSections();
1839
+ result = JSON.stringify(sections, null, 2);
1840
+ }
1841
+
1842
+ else if (toolName === "read_app_doc_section") {
1843
+ const section = await readAppDocSection(args.section_id);
1844
+ result = section
1845
+ ? [`Section: ${section.title}`, `Section ID: ${section.id}`, "", section.content].join("\n")
1846
+ : `Documentation section "${args.section_id}" was not found.`;
1847
+ }
1848
+
1849
  else if (toolName === "list_memories") {
1850
  const memories = owner ? await memoryStore.list(owner) : [];
1851
  result = JSON.stringify(memories, null, 2);
 
1893
  }
1894
 
1895
  else if (toolName === "ollama_search") {
1896
+ if (webSearchLimit?.key && Number.isFinite(webSearchLimit.limit)) {
1897
+ const usage = await consumeWebSearchUsage(webSearchLimit.key, webSearchLimit.limit);
1898
+ if (!usage.allowed) {
1899
+ result = `Web search limit reached for today. Free accounts can use ${usage.limit} web searches per day. Try again on ${usage.window} after the daily reset or upgrade to a paid plan.`;
1900
+ } else {
1901
+ result = await gradioSearch(args.query);
1902
+ }
1903
+ } else {
1904
+ result = await gradioSearch(args.query);
1905
+ }
1906
  }
1907
 
1908
  else if (toolName === "read_web_page") {
server/index.js CHANGED
@@ -250,6 +250,12 @@ async function requireRequestOwner(req, res) {
250
  return resolved;
251
  }
252
 
 
 
 
 
 
 
253
  app.get('/health', (_req,res) => res.json({ok:true}));
254
 
255
  app.get('/api/share/:token', async (req,res) => {
@@ -285,6 +291,7 @@ app.get('/api/media', async (req, res) => {
285
  app.post('/api/media/upload', express.raw({ type: () => true, limit: '100mb' }), async (req, res) => {
286
  const resolved = await requireRequestOwner(req, res);
287
  if (!resolved) return;
 
288
  try {
289
  const name = decodeURIComponent(String(req.headers['x-file-name'] || 'upload.bin'));
290
  const mimeType = String(req.headers['x-mime-type'] || 'application/octet-stream');
@@ -319,6 +326,7 @@ app.post('/api/media/upload', express.raw({ type: () => true, limit: '100mb' }),
319
  app.post('/api/media/folders', async (req, res) => {
320
  const resolved = await requireRequestOwner(req, res);
321
  if (!resolved) return;
 
322
  try {
323
  const item = await mediaStore.createFolder(resolved.owner, {
324
  name: req.body?.name || 'New Folder',
@@ -335,6 +343,7 @@ app.post('/api/media/folders', async (req, res) => {
335
  app.post('/api/media/documents', async (req, res) => {
336
  const resolved = await requireRequestOwner(req, res);
337
  if (!resolved) return;
 
338
  try {
339
  const richText = !!req.body?.richText;
340
  const content = String(req.body?.content || '');
@@ -364,6 +373,7 @@ app.post('/api/media/documents', async (req, res) => {
364
  app.post('/api/media/move', async (req, res) => {
365
  const resolved = await requireRequestOwner(req, res);
366
  if (!resolved) return;
 
367
  try {
368
  const items = await mediaStore.move(resolved.owner, req.body?.ids || [], req.body?.parentId || null);
369
  const usage = await mediaStore.getUsage(resolved.owner);
@@ -390,6 +400,7 @@ app.post('/api/media/trash', async (req, res) => {
390
  app.post('/api/media/restore', async (req, res) => {
391
  const resolved = await requireRequestOwner(req, res);
392
  if (!resolved) return;
 
393
  try {
394
  const items = await mediaStore.restore(resolved.owner, req.body?.ids || []);
395
  const usage = await mediaStore.getUsage(resolved.owner);
@@ -429,6 +440,7 @@ app.get('/api/media/:id/text', async (req, res) => {
429
  app.put('/api/media/:id/text', async (req, res) => {
430
  const resolved = await requireRequestOwner(req, res);
431
  if (!resolved) return;
 
432
  try {
433
  const buffer = Buffer.from(String(req.body?.content || ''), 'utf8');
434
  if (buffer.byteLength > MAX_TEXT_UPLOAD_BYTES) {
@@ -453,6 +465,23 @@ app.put('/api/media/:id/text', async (req, res) => {
453
  }
454
  });
455
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
456
  app.get('/api/media/:id/content', async (req, res) => {
457
  const resolved = await requireRequestOwner(req, res);
458
  if (!resolved) return;
 
250
  return resolved;
251
  }
252
 
253
+ function requireSignedInMediaOwner(resolved, res, message = 'Sign in to upload files.') {
254
+ if (resolved?.owner?.type === 'user') return true;
255
+ res.status(403).json({ error: 'media:auth_required', message });
256
+ return false;
257
+ }
258
+
259
  app.get('/health', (_req,res) => res.json({ok:true}));
260
 
261
  app.get('/api/share/:token', async (req,res) => {
 
291
  app.post('/api/media/upload', express.raw({ type: () => true, limit: '100mb' }), async (req, res) => {
292
  const resolved = await requireRequestOwner(req, res);
293
  if (!resolved) return;
294
+ if (!requireSignedInMediaOwner(resolved, res, 'Sign in to upload files.')) return;
295
  try {
296
  const name = decodeURIComponent(String(req.headers['x-file-name'] || 'upload.bin'));
297
  const mimeType = String(req.headers['x-mime-type'] || 'application/octet-stream');
 
326
  app.post('/api/media/folders', async (req, res) => {
327
  const resolved = await requireRequestOwner(req, res);
328
  if (!resolved) return;
329
+ if (!requireSignedInMediaOwner(resolved, res, 'Sign in to create folders.')) return;
330
  try {
331
  const item = await mediaStore.createFolder(resolved.owner, {
332
  name: req.body?.name || 'New Folder',
 
343
  app.post('/api/media/documents', async (req, res) => {
344
  const resolved = await requireRequestOwner(req, res);
345
  if (!resolved) return;
346
+ if (!requireSignedInMediaOwner(resolved, res, 'Sign in to create documents.')) return;
347
  try {
348
  const richText = !!req.body?.richText;
349
  const content = String(req.body?.content || '');
 
373
  app.post('/api/media/move', async (req, res) => {
374
  const resolved = await requireRequestOwner(req, res);
375
  if (!resolved) return;
376
+ if (!requireSignedInMediaOwner(resolved, res, 'Sign in to move files.')) return;
377
  try {
378
  const items = await mediaStore.move(resolved.owner, req.body?.ids || [], req.body?.parentId || null);
379
  const usage = await mediaStore.getUsage(resolved.owner);
 
400
  app.post('/api/media/restore', async (req, res) => {
401
  const resolved = await requireRequestOwner(req, res);
402
  if (!resolved) return;
403
+ if (!requireSignedInMediaOwner(resolved, res, 'Sign in to restore files.')) return;
404
  try {
405
  const items = await mediaStore.restore(resolved.owner, req.body?.ids || []);
406
  const usage = await mediaStore.getUsage(resolved.owner);
 
440
  app.put('/api/media/:id/text', async (req, res) => {
441
  const resolved = await requireRequestOwner(req, res);
442
  if (!resolved) return;
443
+ if (!requireSignedInMediaOwner(resolved, res, 'Sign in to edit files.')) return;
444
  try {
445
  const buffer = Buffer.from(String(req.body?.content || ''), 'utf8');
446
  if (buffer.byteLength > MAX_TEXT_UPLOAD_BYTES) {
 
465
  }
466
  });
467
 
468
+ app.post('/api/media/:id/rename', async (req, res) => {
469
+ const resolved = await requireRequestOwner(req, res);
470
+ if (!resolved) return;
471
+ if (!requireSignedInMediaOwner(resolved, res, 'Sign in to rename files.')) return;
472
+ try {
473
+ const name = String(req.body?.name || '').trim();
474
+ if (!name) return res.status(400).json({ error: 'media:name_required', message: 'A file name is required.' });
475
+ const item = await mediaStore.rename(resolved.owner, req.params.id, name);
476
+ if (!item) return res.status(404).json({ error: 'media:not_found' });
477
+ const usage = await mediaStore.getUsage(resolved.owner);
478
+ res.json({ item, usage });
479
+ } catch (err) {
480
+ console.error('media rename error', err);
481
+ res.status(500).json({ error: 'media:rename_failed', message: err.message || 'Rename failed' });
482
+ }
483
+ });
484
+
485
  app.get('/api/media/:id/content', async (req, res) => {
486
  const resolved = await requireRequestOwner(req, res);
487
  if (!resolved) return;
server/mediaStore.js CHANGED
@@ -402,6 +402,17 @@ export const mediaStore = {
402
  return sanitizeEntry(entry);
403
  },
404
 
 
 
 
 
 
 
 
 
 
 
 
405
  async move(owner, ids, parentId = null) {
406
  ensureOwner(owner);
407
  await ensureLoaded();
 
402
  return sanitizeEntry(entry);
403
  },
404
 
405
+ async rename(owner, id, name) {
406
+ ensureOwner(owner);
407
+ await ensureLoaded();
408
+ const entry = getEntry(id);
409
+ if (!entry || !canAccess(entry, owner)) return null;
410
+ entry.name = normalizeName(name, entry.name || 'Untitled');
411
+ entry.updatedAt = nowIso();
412
+ await saveIndex();
413
+ return sanitizeEntry(entry);
414
+ },
415
+
416
  async move(owner, ids, parentId = null) {
417
  ensureOwner(owner);
418
  await ensureLoaded();
server/webSearchUsageStore.js ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+
4
+ const DATA_DIR = '/data';
5
+ const STORE_FILE = path.join(DATA_DIR, 'web-search-usage.json');
6
+ const APP_TIMEZONE = process.env.APP_TIMEZONE || 'America/New_York';
7
+
8
+ let state = { days: {} };
9
+ let loaded = false;
10
+ let saveChain = Promise.resolve();
11
+
12
+ function todayKey() {
13
+ return new Intl.DateTimeFormat('en-CA', {
14
+ timeZone: APP_TIMEZONE,
15
+ year: 'numeric',
16
+ month: '2-digit',
17
+ day: '2-digit',
18
+ }).format(new Date());
19
+ }
20
+
21
+ function pruneDays() {
22
+ const keepKey = todayKey();
23
+ state.days = Object.fromEntries(
24
+ Object.entries(state.days || {}).filter(([key]) => key === keepKey)
25
+ );
26
+ }
27
+
28
+ async function ensureLoaded() {
29
+ if (loaded) return;
30
+ loaded = true;
31
+ try {
32
+ await fs.mkdir(DATA_DIR, { recursive: true });
33
+ const raw = await fs.readFile(STORE_FILE, 'utf8');
34
+ const parsed = JSON.parse(raw);
35
+ if (parsed && typeof parsed === 'object') state = parsed;
36
+ } catch {}
37
+ pruneDays();
38
+ }
39
+
40
+ function saveState() {
41
+ saveChain = saveChain.then(async () => {
42
+ pruneDays();
43
+ await fs.mkdir(DATA_DIR, { recursive: true });
44
+ await fs.writeFile(STORE_FILE, JSON.stringify(state, null, 2), 'utf8');
45
+ }).catch((err) => {
46
+ console.error('Failed to persist web search usage:', err);
47
+ });
48
+ return saveChain;
49
+ }
50
+
51
+ function getCounterRecord(key) {
52
+ const day = todayKey();
53
+ if (!state.days[day]) state.days[day] = {};
54
+ if (!state.days[day][key]) state.days[day][key] = 0;
55
+ return {
56
+ day,
57
+ used: state.days[day][key],
58
+ set(nextValue) {
59
+ state.days[day][key] = nextValue;
60
+ },
61
+ };
62
+ }
63
+
64
+ export async function getWebSearchUsage(key, limit = 15) {
65
+ await ensureLoaded();
66
+ const record = getCounterRecord(key);
67
+ return {
68
+ limit,
69
+ used: record.used,
70
+ remaining: Math.max(0, limit - record.used),
71
+ window: record.day,
72
+ period: 'daily',
73
+ };
74
+ }
75
+
76
+ export async function consumeWebSearchUsage(key, limit = 15) {
77
+ await ensureLoaded();
78
+ const record = getCounterRecord(key);
79
+ if (record.used >= limit) {
80
+ return {
81
+ allowed: false,
82
+ ...(await getWebSearchUsage(key, limit)),
83
+ };
84
+ }
85
+ record.set(record.used + 1);
86
+ await saveState();
87
+ return {
88
+ allowed: true,
89
+ ...(await getWebSearchUsage(key, limit)),
90
+ };
91
+ }
server/wsHandler.js CHANGED
@@ -14,6 +14,7 @@ import { mediaStore } from './mediaStore.js';
14
  import { memoryStore } from './memoryStore.js';
15
  import { chatTrashStore } from './chatTrashStore.js';
16
  import { systemPromptStore } from './systemPromptStore.js';
 
17
  import crypto from 'crypto';
18
  import path from 'path';
19
 
@@ -45,9 +46,36 @@ import path from 'path';
45
  */
46
 
47
  const activeStreams = new Map();
 
 
 
 
48
 
49
  initGuestRequestLimiter().catch(err => console.error('Failed to initialize guest request limiter:', err));
50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  export async function handleWsMessage(ws, msg, wsClients) {
52
  const client = wsClients.get(ws); if (!client) return;
53
  // Require turnstile verification for most message types
@@ -296,6 +324,11 @@ const handlers = {
296
  });
297
  }
298
 
 
 
 
 
 
299
  await streamChat({
300
  sessionId,
301
  model: session.model,
@@ -304,6 +337,7 @@ const handlers = {
304
  tools: tools || {},
305
  accessToken: client.accessToken,
306
  clientId: msg.clientId,
 
307
  owner,
308
  sessionName: session.name,
309
  abortSignal: abort.signal,
@@ -321,7 +355,7 @@ const handlers = {
321
  responseEditsCollected.push(edit);
322
  safeSend(ws, { type: 'chat:draftEdited', edit, text: draftText, sessionId });
323
  },
324
- async onDone(text, toolCalls, aborted, sessionNameFromTag, responseEdits = []) {
325
  activeStreams.delete(ws);
326
  const finalText = text || fullText;
327
 
@@ -339,6 +373,7 @@ const handlers = {
339
  });
340
  const asstEntry = buildEntry('assistant', finalText, mergedCalls, {
341
  responseEdits: [...responseEditsCollected, ...responseEdits],
 
342
  });
343
 
344
  const mediaEntries = assetsCollected.map((asset) =>
@@ -362,8 +397,8 @@ const handlers = {
362
  }
363
  if (generatedFiles.length) safeSend(ws, { type: 'media:changed' });
364
 
365
- // Rebuild tree structure with new messages appended
366
- let newRootMessage = rootMessage ? validateAndRepairTree(JSON.parse(JSON.stringify(rootMessage))) : null;
367
 
368
  if (!newRootMessage) {
369
  // First message in session - must have user entry
@@ -371,16 +406,12 @@ const handlers = {
371
  newRootMessage = userEntry;
372
  newRootMessage.versions[0].tail = [{ ...asstEntry }, ...mediaEntries];
373
  } else {
374
- // Append to current tail
375
- const currentVerIdx = newRootMessage.currentVersionIdx ?? 0;
376
- let currentTail = newRootMessage.versions[currentVerIdx].tail || [];
377
- currentTail = JSON.parse(JSON.stringify(currentTail));
378
- if (userEntry) {
379
- currentTail.push(userEntry);
380
- }
381
- currentTail.push(asstEntry);
382
- currentTail.push(...mediaEntries);
383
- newRootMessage.versions[currentVerIdx].tail = currentTail;
384
  }
385
 
386
  const newHistory = [newRootMessage];
@@ -426,7 +457,7 @@ const handlers = {
426
  }
427
 
428
  // Find the target message in the tree and add new version
429
- const newRoot = validateAndRepairTree(JSON.parse(JSON.stringify(rootMessage)));
430
  const found = findAndUpdateMessage(newRoot, targetMsg.id, (msgInTree) => {
431
  // Add new version with EMPTY tail (no responses yet for this edited version)
432
  msgInTree.versions.push({
@@ -474,7 +505,7 @@ const handlers = {
474
  if (!targetMsg || !targetMsg.versions || versionIdx >= targetMsg.versions.length) return;
475
 
476
  // Find and update the message in tree, switching to specified version
477
- const newRoot = validateAndRepairTree(JSON.parse(JSON.stringify(rootMessage)));
478
  const found = findAndUpdateMessage(newRoot, targetMsg.id, (msgInTree) => {
479
  msgInTree.currentVersionIdx = versionIdx;
480
  msgInTree.content = msgInTree.versions[versionIdx].content;
@@ -494,6 +525,164 @@ const handlers = {
494
  safeSend(ws, { type: 'chat:versionSelected', sessionId, messageId: targetMsg.id, messageIndex, history: extractFlatHistory(newRoot) });
495
  },
496
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
497
  'settings:get': async (ws, msg, client) => {
498
  const s = client.userId
499
  ? await getUserSettings(client.userId, client.accessToken)
@@ -580,7 +769,7 @@ const handlers = {
580
  const subInfo = await getSubscriptionInfo(c.accessToken);
581
  safeSend(ws, { type: 'account:subscription', info: subInfo });
582
  },
583
- 'account:getUsage': async (ws, msg, c) => { safeSend(ws, { type: 'account:usage', usage: await getUsageInfo(c.accessToken) }); },
584
  'account:getTierConfig': async (ws) => { safeSend(ws, { type: 'account:tierConfig', config: await getTierConfig() }); },
585
  'account:getSessions': (ws, msg, c) => { if (!c.userId) return; safeSend(ws, { type: 'account:deviceSessions', sessions: deviceSessionStore.getForUser(c.userId), currentToken: c.deviceToken }); },
586
  'account:revokeSession': (ws, msg, c, wsClients) => {
@@ -662,15 +851,30 @@ function buildEntry(role, content, toolCalls = [], extraFields = {}) {
662
  result: c.result,
663
  }));
664
  const validContent = (content === undefined || content === null) ? '' : content;
 
 
 
 
 
 
 
 
665
  return {
666
  id: generateMessageId(),
667
  role,
668
  content: validContent,
669
  timestamp: Date.now(),
670
- versions: [{ content: validContent, tail: [], timestamp: Date.now() }],
 
 
 
 
 
 
671
  currentVersionIdx: 0,
672
  ...(normalizedCalls.length ? { toolCalls: normalizedCalls } : {}),
673
- ...extraFields,
 
674
  };
675
  }
676
 
@@ -685,6 +889,82 @@ function buildMediaEntry(role, content) {
685
  };
686
  }
687
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
688
  function extractAssistantGeneratedFiles(text) {
689
  if (!text || typeof text !== 'string') return [];
690
  const files = [];
@@ -746,6 +1026,70 @@ function validateAndRepairTree(rootMessage) {
746
  return rootMessage;
747
  }
748
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
749
  function extractFlatHistory(rootMessage) {
750
  if (!rootMessage) return [];
751
 
@@ -754,7 +1098,7 @@ function extractFlatHistory(rootMessage) {
754
  if (msg.content === undefined || msg.content === null) {
755
  msg.content = '';
756
  }
757
- return msg;
758
  };
759
 
760
  const history = [ensureValidContent(rootMessage)];
 
14
  import { memoryStore } from './memoryStore.js';
15
  import { chatTrashStore } from './chatTrashStore.js';
16
  import { systemPromptStore } from './systemPromptStore.js';
17
+ import { getWebSearchUsage } from './webSearchUsageStore.js';
18
  import crypto from 'crypto';
19
  import path from 'path';
20
 
 
46
  */
47
 
48
  const activeStreams = new Map();
49
+ const VERSION_META_FIELDS = ['toolCalls', 'responseEdits', 'responseSegments', 'error'];
50
+ const CONTINUE_ASSISTANT_PROMPT =
51
+ 'Continue your previous response exactly where it left off. Do not restart, summarize, or repeat the opening. Preserve the same formatting and only add the missing continuation.';
52
+ const FREE_WEB_SEARCH_LIMIT = 15;
53
 
54
  initGuestRequestLimiter().catch(err => console.error('Failed to initialize guest request limiter:', err));
55
 
56
+ function usageOwnerKey(client, clientId = '') {
57
+ if (client?.userId) return `user:${client.userId}`;
58
+ return `guest:${clientId || client?.tempId || 'anonymous'}`;
59
+ }
60
+
61
+ function isFreeSearchPlan(client, usageInfo) {
62
+ if (!client?.userId) return true;
63
+ const planKey = usageInfo?.plan_key || usageInfo?.planKey || null;
64
+ return planKey === 'free';
65
+ }
66
+
67
+ async function buildUsagePayload(client, clientId = '') {
68
+ const usageInfo = await getUsageInfo(client?.accessToken, clientId);
69
+ const webSearchDaily = await getWebSearchUsage(usageOwnerKey(client, clientId), FREE_WEB_SEARCH_LIMIT);
70
+ return {
71
+ ...(usageInfo || {}),
72
+ usage: {
73
+ ...(usageInfo?.usage || {}),
74
+ webSearchDaily,
75
+ },
76
+ };
77
+ }
78
+
79
  export async function handleWsMessage(ws, msg, wsClients) {
80
  const client = wsClients.get(ws); if (!client) return;
81
  // Require turnstile verification for most message types
 
324
  });
325
  }
326
 
327
+ const usagePayload = await buildUsagePayload(client, msg.clientId || '');
328
+ const webSearchLimit = isFreeSearchPlan(client, usagePayload)
329
+ ? { key: usageOwnerKey(client, msg.clientId || ''), limit: FREE_WEB_SEARCH_LIMIT }
330
+ : null;
331
+
332
  await streamChat({
333
  sessionId,
334
  model: session.model,
 
337
  tools: tools || {},
338
  accessToken: client.accessToken,
339
  clientId: msg.clientId,
340
+ webSearchLimit,
341
  owner,
342
  sessionName: session.name,
343
  abortSignal: abort.signal,
 
355
  responseEditsCollected.push(edit);
356
  safeSend(ws, { type: 'chat:draftEdited', edit, text: draftText, sessionId });
357
  },
358
+ async onDone(text, toolCalls, aborted, sessionNameFromTag, responseEdits = [], responseSegments = []) {
359
  activeStreams.delete(ws);
360
  const finalText = text || fullText;
361
 
 
373
  });
374
  const asstEntry = buildEntry('assistant', finalText, mergedCalls, {
375
  responseEdits: [...responseEditsCollected, ...responseEdits],
376
+ responseSegments,
377
  });
378
 
379
  const mediaEntries = assetsCollected.map((asset) =>
 
397
  }
398
  if (generatedFiles.length) safeSend(ws, { type: 'media:changed' });
399
 
400
+ // Rebuild tree structure with new messages appended to the active branch leaf.
401
+ let newRootMessage = rootMessage ? cloneAndRepairTree(rootMessage) : null;
402
 
403
  if (!newRootMessage) {
404
  // First message in session - must have user entry
 
406
  newRootMessage = userEntry;
407
  newRootMessage.versions[0].tail = [{ ...asstEntry }, ...mediaEntries];
408
  } else {
409
+ const appendedEntries = [
410
+ ...(userEntry ? [userEntry] : []),
411
+ asstEntry,
412
+ ...mediaEntries,
413
+ ];
414
+ appendEntriesToActiveLeaf(newRootMessage, appendedEntries);
 
 
 
 
415
  }
416
 
417
  const newHistory = [newRootMessage];
 
457
  }
458
 
459
  // Find the target message in the tree and add new version
460
+ const newRoot = cloneAndRepairTree(rootMessage);
461
  const found = findAndUpdateMessage(newRoot, targetMsg.id, (msgInTree) => {
462
  // Add new version with EMPTY tail (no responses yet for this edited version)
463
  msgInTree.versions.push({
 
505
  if (!targetMsg || !targetMsg.versions || versionIdx >= targetMsg.versions.length) return;
506
 
507
  // Find and update the message in tree, switching to specified version
508
+ const newRoot = cloneAndRepairTree(rootMessage);
509
  const found = findAndUpdateMessage(newRoot, targetMsg.id, (msgInTree) => {
510
  msgInTree.currentVersionIdx = versionIdx;
511
  msgInTree.content = msgInTree.versions[versionIdx].content;
 
525
  safeSend(ws, { type: 'chat:versionSelected', sessionId, messageId: targetMsg.id, messageIndex, history: extractFlatHistory(newRoot) });
526
  },
527
 
528
+ 'chat:assistantAction': async (ws, msg, client) => {
529
+ const { sessionId, messageIndex } = msg;
530
+ const action = msg.action === 'continue' ? 'continue' : 'regenerate';
531
+ const session = client.userId
532
+ ? sessionStore.getUserSession(client.userId, sessionId)
533
+ : sessionStore.getTempSession(client.tempId, sessionId);
534
+ if (!session) return safeSend(ws, { type: 'error', message: 'Session not found' });
535
+
536
+ const rootMessage = session.history?.[0];
537
+ if (!rootMessage) return safeSend(ws, { type: 'error', message: 'No history' });
538
+
539
+ const flatHistory = extractFlatHistory(rootMessage);
540
+ const targetMsg = flatHistory[messageIndex];
541
+ if (!targetMsg || targetMsg.role !== 'assistant') {
542
+ return safeSend(ws, { type: 'error', message: 'Assistant message not found' });
543
+ }
544
+
545
+ if (activeStreams.has(ws)) activeStreams.get(ws).abort();
546
+ const abort = new AbortController();
547
+ activeStreams.set(ws, abort);
548
+
549
+ const owner = getClientOwner(client);
550
+ const historyBeforeTarget = flatHistory.slice(0, messageIndex);
551
+ const baseAssistantText = stripSessionTagText(targetMsg.content || '');
552
+ const actionHistory = action === 'continue'
553
+ ? [...historyBeforeTarget, { role: 'assistant', content: baseAssistantText }]
554
+ : historyBeforeTarget;
555
+ const actionUserMessage = action === 'continue' ? CONTINUE_ASSISTANT_PROMPT : null;
556
+
557
+ safeSend(ws, {
558
+ type: 'chat:start',
559
+ sessionId,
560
+ streamKind: 'assistantAction',
561
+ action,
562
+ messageIndex,
563
+ prefillText: action === 'continue' ? baseAssistantText : '',
564
+ });
565
+
566
+ let fullText = '';
567
+ const assetsCollected = [];
568
+ const toolCallsCollected = [];
569
+ const responseEditsCollected = [];
570
+ const usagePayload = await buildUsagePayload(client, msg.clientId || '');
571
+ const webSearchLimit = isFreeSearchPlan(client, usagePayload)
572
+ ? { key: usageOwnerKey(client, msg.clientId || ''), limit: FREE_WEB_SEARCH_LIMIT }
573
+ : null;
574
+
575
+ await streamChat({
576
+ sessionId,
577
+ model: session.model,
578
+ history: actionHistory,
579
+ userMessage: actionUserMessage,
580
+ tools: msg.tools || {},
581
+ accessToken: client.accessToken,
582
+ clientId: msg.clientId,
583
+ webSearchLimit,
584
+ owner,
585
+ sessionName: session.name,
586
+ abortSignal: abort.signal,
587
+ onToken(t) {
588
+ fullText += t;
589
+ safeSend(ws, { type: 'chat:token', token: t, sessionId });
590
+ },
591
+ onToolCall(call) {
592
+ safeSend(ws, { type: 'chat:toolCall', call, sessionId });
593
+ if (call.state === 'resolved' || call.state === 'canceled') toolCallsCollected.push(call);
594
+ },
595
+ onNewAsset(asset) {
596
+ safeSend(ws, { type: 'chat:asset', asset, sessionId });
597
+ assetsCollected.push(asset);
598
+ safeSend(ws, { type: 'media:changed' });
599
+ },
600
+ onDraftEdit(edit, draftText) {
601
+ responseEditsCollected.push(edit);
602
+ safeSend(ws, { type: 'chat:draftEdited', edit, text: draftText, sessionId });
603
+ },
604
+ async onDone(text, toolCalls, aborted, sessionNameFromTag, responseEdits = [], responseSegments = []) {
605
+ activeStreams.delete(ws);
606
+ if (aborted) {
607
+ return safeSend(ws, { type: 'chat:aborted', sessionId });
608
+ }
609
+
610
+ const rawAssistantText = text || fullText;
611
+ const resolvedMap = new Map(toolCallsCollected.map((call) => [call.id, call]));
612
+ const mergedCalls = (toolCalls || []).map((call) => {
613
+ const resolved = resolvedMap.get(call.id) || {};
614
+ return { ...call, state: resolved.state || 'resolved', result: resolved.result };
615
+ });
616
+
617
+ let finalText = rawAssistantText;
618
+ let finalSegments = responseSegments;
619
+
620
+ if (action === 'continue') {
621
+ const { continuationText, overlapLength } = stripContinuationOverlap(baseAssistantText, rawAssistantText);
622
+ finalText = baseAssistantText + continuationText;
623
+ finalSegments = [
624
+ ...(baseAssistantText ? [{ type: 'text', text: baseAssistantText }] : []),
625
+ ...trimLeadingTextFromSegments(responseSegments, overlapLength),
626
+ ];
627
+ }
628
+
629
+ const mediaEntries = assetsCollected.map((asset) =>
630
+ buildMediaEntry(asset.role, {
631
+ assetId: asset.id,
632
+ mimeType: asset.mimeType,
633
+ name: asset.name,
634
+ })
635
+ );
636
+
637
+ const generatedFiles = extractAssistantGeneratedFiles(finalText);
638
+ for (const file of generatedFiles) {
639
+ await mediaStore.storeBuffer(owner, {
640
+ name: file.name,
641
+ mimeType: file.mimeType,
642
+ buffer: file.buffer,
643
+ sessionId,
644
+ source: 'assistant_generated',
645
+ kind: file.kind,
646
+ }).catch((err) => console.error('Failed to store generated text asset:', err));
647
+ }
648
+ if (generatedFiles.length) safeSend(ws, { type: 'media:changed' });
649
+
650
+ const newRoot = cloneAndRepairTree(rootMessage);
651
+ const found = findAndUpdateMessage(newRoot, targetMsg.id, (msgInTree) => {
652
+ const nextVersion = buildVersionRecord(finalText, {
653
+ tail: mediaEntries,
654
+ toolCalls: mergedCalls,
655
+ responseEdits: [...responseEditsCollected, ...responseEdits],
656
+ responseSegments: finalSegments,
657
+ });
658
+ applyVersionToMessage(msgInTree, nextVersion);
659
+ });
660
+ if (!found) {
661
+ return safeSend(ws, { type: 'error', message: 'Assistant branch could not be updated' });
662
+ }
663
+
664
+ let newName = session.name;
665
+ if (sessionNameFromTag) {
666
+ newName = sessionNameFromTag;
667
+ }
668
+
669
+ const newHistory = [newRoot];
670
+ if (client.userId) {
671
+ await sessionStore.updateUserSession(client.userId, client.accessToken, sessionId, { history: newHistory, name: newName });
672
+ } else {
673
+ sessionStore.updateTempSession(client.tempId, sessionId, { history: newHistory, name: newName });
674
+ }
675
+
676
+ safeSend(ws, { type: 'chat:done', sessionId, name: newName, history: extractFlatHistory(newRoot) });
677
+ },
678
+ onError(err) {
679
+ activeStreams.delete(ws);
680
+ console.error('assistant action streamChat error:', err);
681
+ safeSend(ws, { type: 'chat:error', error: String(err), sessionId });
682
+ },
683
+ });
684
+ },
685
+
686
  'settings:get': async (ws, msg, client) => {
687
  const s = client.userId
688
  ? await getUserSettings(client.userId, client.accessToken)
 
769
  const subInfo = await getSubscriptionInfo(c.accessToken);
770
  safeSend(ws, { type: 'account:subscription', info: subInfo });
771
  },
772
+ 'account:getUsage': async (ws, msg, c) => { safeSend(ws, { type: 'account:usage', usage: await buildUsagePayload(c, msg.clientId || '') }); },
773
  'account:getTierConfig': async (ws) => { safeSend(ws, { type: 'account:tierConfig', config: await getTierConfig() }); },
774
  'account:getSessions': (ws, msg, c) => { if (!c.userId) return; safeSend(ws, { type: 'account:deviceSessions', sessions: deviceSessionStore.getForUser(c.userId), currentToken: c.deviceToken }); },
775
  'account:revokeSession': (ws, msg, c, wsClients) => {
 
851
  result: c.result,
852
  }));
853
  const validContent = (content === undefined || content === null) ? '' : content;
854
+ const versionMeta = {};
855
+ const topLevelExtraFields = { ...extraFields };
856
+ VERSION_META_FIELDS.forEach((key) => {
857
+ if (key in topLevelExtraFields) {
858
+ versionMeta[key] = topLevelExtraFields[key];
859
+ delete topLevelExtraFields[key];
860
+ }
861
+ });
862
  return {
863
  id: generateMessageId(),
864
  role,
865
  content: validContent,
866
  timestamp: Date.now(),
867
+ versions: [{
868
+ content: validContent,
869
+ tail: [],
870
+ timestamp: Date.now(),
871
+ ...(normalizedCalls.length ? { toolCalls: normalizedCalls } : {}),
872
+ ...versionMeta,
873
+ }],
874
  currentVersionIdx: 0,
875
  ...(normalizedCalls.length ? { toolCalls: normalizedCalls } : {}),
876
+ ...versionMeta,
877
+ ...topLevelExtraFields,
878
  };
879
  }
880
 
 
889
  };
890
  }
891
 
892
+ function buildVersionRecord(content, extraFields = {}) {
893
+ const validContent = content === undefined || content === null ? '' : content;
894
+ const version = {
895
+ content: validContent,
896
+ tail: Array.isArray(extraFields.tail) ? extraFields.tail : [],
897
+ timestamp: Date.now(),
898
+ };
899
+ VERSION_META_FIELDS.forEach((key) => {
900
+ if (extraFields[key] !== undefined && extraFields[key] !== null) {
901
+ version[key] = extraFields[key];
902
+ }
903
+ });
904
+ return version;
905
+ }
906
+
907
+ function applyVersionToMessage(message, versionRecord) {
908
+ if (!message?.versions || !Array.isArray(message.versions)) {
909
+ message.versions = [];
910
+ }
911
+ message.versions.push(versionRecord);
912
+ message.currentVersionIdx = message.versions.length - 1;
913
+ syncMessageFromActiveVersion(message);
914
+ return message;
915
+ }
916
+
917
+ function stripSessionTagText(content) {
918
+ return typeof content === 'string'
919
+ ? content.replace(/<session_name>[\s\S]*?<\/session_name>/gi, '').trim()
920
+ : content;
921
+ }
922
+
923
+ function stripContinuationOverlap(baseText = '', generatedText = '') {
924
+ const base = String(baseText || '');
925
+ const generated = String(generatedText || '');
926
+ if (!base) return { continuationText: generated, overlapLength: 0 };
927
+ if (!generated) return { continuationText: '', overlapLength: 0 };
928
+ if (generated.startsWith(base)) {
929
+ return { continuationText: generated.slice(base.length), overlapLength: base.length };
930
+ }
931
+
932
+ const maxWindow = Math.min(base.length, generated.length, 400);
933
+ for (let size = maxWindow; size > 0; size--) {
934
+ if (base.slice(-size) === generated.slice(0, size)) {
935
+ return { continuationText: generated.slice(size), overlapLength: size };
936
+ }
937
+ }
938
+
939
+ return { continuationText: generated, overlapLength: 0 };
940
+ }
941
+
942
+ function trimLeadingTextFromSegments(segments = [], overlapLength = 0) {
943
+ let remaining = Math.max(0, overlapLength);
944
+ const trimmedSegments = [];
945
+
946
+ for (const segment of segments || []) {
947
+ if (!segment || segment.type !== 'text' || remaining <= 0) {
948
+ trimmedSegments.push(segment);
949
+ continue;
950
+ }
951
+
952
+ const text = String(segment.text || '');
953
+ if (remaining >= text.length) {
954
+ remaining -= text.length;
955
+ continue;
956
+ }
957
+
958
+ trimmedSegments.push({
959
+ ...segment,
960
+ text: text.slice(remaining),
961
+ });
962
+ remaining = 0;
963
+ }
964
+
965
+ return trimmedSegments.filter((segment) => segment && (segment.type !== 'text' || segment.text));
966
+ }
967
+
968
  function extractAssistantGeneratedFiles(text) {
969
  if (!text || typeof text !== 'string') return [];
970
  const files = [];
 
1026
  return rootMessage;
1027
  }
1028
 
1029
+ function cloneAndRepairTree(rootMessage) {
1030
+ return validateAndRepairTree(JSON.parse(JSON.stringify(rootMessage)));
1031
+ }
1032
+
1033
+ function getActiveVersion(message) {
1034
+ if (!message) return null;
1035
+ const versions = Array.isArray(message.versions) ? message.versions : [];
1036
+ if (!versions.length) {
1037
+ message.versions = [{ content: message.content ?? '', tail: [], timestamp: Date.now() }];
1038
+ message.currentVersionIdx = 0;
1039
+ return message.versions[0];
1040
+ }
1041
+ const currentVersionIdx = Number.isInteger(message.currentVersionIdx)
1042
+ ? Math.max(0, Math.min(message.currentVersionIdx, versions.length - 1))
1043
+ : 0;
1044
+ message.currentVersionIdx = currentVersionIdx;
1045
+ if (!Array.isArray(message.versions[currentVersionIdx].tail)) {
1046
+ message.versions[currentVersionIdx].tail = [];
1047
+ }
1048
+ if (message.versions[currentVersionIdx].content === undefined || message.versions[currentVersionIdx].content === null) {
1049
+ message.versions[currentVersionIdx].content = message.content ?? '';
1050
+ }
1051
+ return message.versions[currentVersionIdx];
1052
+ }
1053
+
1054
+ function cloneVersionMetaValue(value) {
1055
+ if (value === undefined) return undefined;
1056
+ return JSON.parse(JSON.stringify(value));
1057
+ }
1058
+
1059
+ function syncMessageFromActiveVersion(message) {
1060
+ if (!message) return message;
1061
+ const currentVersion = getActiveVersion(message);
1062
+ if (!currentVersion) return message;
1063
+ message.content = currentVersion.content ?? message.content ?? '';
1064
+ VERSION_META_FIELDS.forEach((key) => {
1065
+ if (key in currentVersion) {
1066
+ message[key] = cloneVersionMetaValue(currentVersion[key]);
1067
+ } else {
1068
+ delete message[key];
1069
+ }
1070
+ });
1071
+ return message;
1072
+ }
1073
+
1074
+ function getActiveLeafMessage(rootMessage) {
1075
+ let current = rootMessage;
1076
+ while (current) {
1077
+ const currentVersion = getActiveVersion(current);
1078
+ const tail = Array.isArray(currentVersion?.tail) ? currentVersion.tail : [];
1079
+ if (!tail.length) return current;
1080
+ current = tail[tail.length - 1];
1081
+ }
1082
+ return rootMessage;
1083
+ }
1084
+
1085
+ function appendEntriesToActiveLeaf(rootMessage, entries = []) {
1086
+ if (!rootMessage || !entries.length) return rootMessage;
1087
+ const leaf = getActiveLeafMessage(rootMessage);
1088
+ const currentVersion = getActiveVersion(leaf);
1089
+ currentVersion.tail = [...(currentVersion.tail || []), ...entries];
1090
+ return rootMessage;
1091
+ }
1092
+
1093
  function extractFlatHistory(rootMessage) {
1094
  if (!rootMessage) return [];
1095
 
 
1098
  if (msg.content === undefined || msg.content === null) {
1099
  msg.content = '';
1100
  }
1101
+ return syncMessageFromActiveVersion(msg);
1102
  };
1103
 
1104
  const history = [ensureValidContent(rootMessage)];
system prompt.md CHANGED
@@ -24,6 +24,24 @@
24
  - Use markdown for everything other than the color spans.
25
  - Tables, lists, and other markdown elements are encouraged when they help.
26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  # Attachment handling
28
 
29
  - Large user prompts, text attachments, conversation history, and image attachments may be staged into separate resources on purpose.
 
24
  - Use markdown for everything other than the color spans.
25
  - Tables, lists, and other markdown elements are encouraged when they help.
26
 
27
+ # Tool guide
28
+
29
+ - Use `list_app_doc_sections` to see which documentation sections are available for the app UI, settings, privacy policy, and terms of service.
30
+ - Use `read_app_doc_section` to read only the specific documentation section that answers the user's question.
31
+ - Use `list_memories` at the start of a chat to check persistent memories for the user.
32
+ - Use `save_memory` only for short, durable information that will still matter in future chats.
33
+ - Use `delete_memory` when a saved memory is wrong, outdated, or the user asks to remove it.
34
+ - Use `edit_response_draft` to fix, replace, append to, or remove text that has already been streamed to the user. Prefer targeted edits when possible.
35
+ - Use `ollama_search` for current information, recent facts, news, or anything you are not sure about.
36
+ - Use `read_web_page` to inspect a specific URL after search or when the user gives you a link.
37
+ - Use `generate_image` when the user wants a new image or image variation.
38
+ - Use `generate_video` when the user wants a generated video, animation, or moving scene.
39
+ - Use `generate_audio` when the user wants music, ambience, or sound effects.
40
+ - Use `list_prompt_resources` to discover staged prompt resources such as large text attachments and separated prompt chunks.
41
+ - Use `read_prompt_chunk` to read staged text exactly instead of guessing about omitted or truncated content.
42
+ - Use `load_prompt_images` to inspect staged images that were attached outside the inline prompt.
43
+ - Use `write_notes` to keep short working notes after reading several prompt chunks or attachments.
44
+
45
  # Attachment handling
46
 
47
  - Large user prompts, text attachments, conversation history, and image attachments may be staged into separate resources on purpose.