Spaces:
Runtime error
Runtime error
incognitolm commited on
Commit ·
cac3e38
1
Parent(s): 92f389e
More things - 2
Browse files- app-docs.md +106 -0
- server/appDocs.js +65 -0
- server/auth.js +15 -3
- server/chatStream.js +64 -2
- server/index.js +29 -0
- server/mediaStore.js +11 -0
- server/webSearchUsageStore.js +91 -0
- server/wsHandler.js +363 -19
- system prompt.md +18 -0
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 |
-
|
| 102 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 ?
|
| 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 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 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 =
|
| 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 =
|
| 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
|
| 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: [{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 671 |
currentVersionIdx: 0,
|
| 672 |
...(normalizedCalls.length ? { toolCalls: normalizedCalls } : {}),
|
| 673 |
-
...
|
|
|
|
| 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.
|