incognitolm commited on
Commit ·
0f84d64
1
Parent(s): ccd5191
Update
Browse files- README.md +5 -136
- app-docs.md +106 -0
- package.json +2 -1
- server/appDocs.js +65 -0
- server/auth.js +15 -3
- server/chatStream.js +1539 -176
- server/chatTrashStore.js +149 -0
- server/cryptoUtils.js +102 -28
- server/handleFeedback.js +162 -0
- server/index.js +483 -16
- server/mediaStore.js +517 -0
- server/memoryStore.js +110 -0
- server/sessionStore.js +25 -3
- server/systemPromptStore.js +174 -0
- server/webSearchUsageStore.js +91 -0
- server/wsHandler.js +731 -44
- system prompt.md +85 -0
README.md
CHANGED
|
@@ -1,141 +1,10 @@
|
|
| 1 |
---
|
| 2 |
-
title: Chat
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
-
short_description: Inference Port Web Chat
|
| 9 |
---
|
| 10 |
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
## Environment Variables
|
| 14 |
-
|
| 15 |
-
Set these as **Secrets** in your HuggingFace Space settings:
|
| 16 |
-
|
| 17 |
-
| Variable | Required | Description |
|
| 18 |
-
|---|---|---|
|
| 19 |
-
| `SUPABASE_ANON_KEY` | Optional | Supabase anon key (falls back to hardcoded value from app) |
|
| 20 |
-
| `PUBLIC_URL` | Recommended | Full public URL of your Space, e.g. `https://your-user-your-space.hf.space` |
|
| 21 |
-
|
| 22 |
-
No `SUPABASE_SERVICE_ROLE_KEY` is needed — all DB operations use the user's own access token via RLS.
|
| 23 |
-
|
| 24 |
-
## HuggingFace Space Setup
|
| 25 |
-
|
| 26 |
-
1. Create a new **Docker** Space on HuggingFace
|
| 27 |
-
2. Push this repository to the Space
|
| 28 |
-
3. Set `PUBLIC_URL` secret to your space URL (needed for share links)
|
| 29 |
-
4. The app listens on port `7860` which HuggingFace maps automatically
|
| 30 |
-
|
| 31 |
-
## Supabase Tables Required
|
| 32 |
-
|
| 33 |
-
Run this SQL in your Supabase project's **SQL Editor**:
|
| 34 |
-
|
| 35 |
-
```sql
|
| 36 |
-
-- Web sessions (separate from Electron app's chat_sessions)
|
| 37 |
-
CREATE TABLE IF NOT EXISTS public.web_sessions (
|
| 38 |
-
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
| 39 |
-
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
| 40 |
-
name text NOT NULL DEFAULT 'New Chat',
|
| 41 |
-
history jsonb DEFAULT '[]',
|
| 42 |
-
model text,
|
| 43 |
-
created_at timestamptz DEFAULT now(),
|
| 44 |
-
updated_at timestamptz DEFAULT now()
|
| 45 |
-
);
|
| 46 |
-
|
| 47 |
-
ALTER TABLE public.web_sessions ENABLE ROW LEVEL SECURITY;
|
| 48 |
-
|
| 49 |
-
CREATE POLICY "Users can manage their own web sessions"
|
| 50 |
-
ON public.web_sessions FOR ALL
|
| 51 |
-
USING (auth.uid() = user_id)
|
| 52 |
-
WITH CHECK (auth.uid() = user_id);
|
| 53 |
-
|
| 54 |
-
-- Shared sessions (readable by anyone, writable by owner)
|
| 55 |
-
CREATE TABLE IF NOT EXISTS public.shared_sessions (
|
| 56 |
-
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
| 57 |
-
token text UNIQUE NOT NULL,
|
| 58 |
-
owner_id uuid REFERENCES auth.users(id) ON DELETE CASCADE,
|
| 59 |
-
session_snapshot jsonb NOT NULL,
|
| 60 |
-
created_at timestamptz DEFAULT now()
|
| 61 |
-
);
|
| 62 |
-
|
| 63 |
-
ALTER TABLE public.shared_sessions ENABLE ROW LEVEL SECURITY;
|
| 64 |
-
|
| 65 |
-
CREATE POLICY "Anyone can read shared sessions"
|
| 66 |
-
ON public.shared_sessions FOR SELECT USING (true);
|
| 67 |
-
|
| 68 |
-
CREATE POLICY "Owners can insert shared sessions"
|
| 69 |
-
ON public.shared_sessions FOR INSERT
|
| 70 |
-
WITH CHECK (auth.uid() = owner_id);
|
| 71 |
-
|
| 72 |
-
-- User settings (theme, tool toggles)
|
| 73 |
-
CREATE TABLE IF NOT EXISTS public.user_settings (
|
| 74 |
-
user_id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
| 75 |
-
settings jsonb DEFAULT '{}',
|
| 76 |
-
updated_at timestamptz DEFAULT now()
|
| 77 |
-
);
|
| 78 |
-
|
| 79 |
-
ALTER TABLE public.user_settings ENABLE ROW LEVEL SECURITY;
|
| 80 |
-
|
| 81 |
-
CREATE POLICY "Users manage own settings"
|
| 82 |
-
ON public.user_settings FOR ALL
|
| 83 |
-
USING (auth.uid() = user_id)
|
| 84 |
-
WITH CHECK (auth.uid() = user_id);
|
| 85 |
-
```
|
| 86 |
-
|
| 87 |
-
The `profiles` table already exists from the Electron app and is reused as-is.
|
| 88 |
-
|
| 89 |
-
## File Structure
|
| 90 |
-
|
| 91 |
-
```
|
| 92 |
-
inferenceport-web/
|
| 93 |
-
├── Dockerfile
|
| 94 |
-
├── package.json
|
| 95 |
-
├── README.md
|
| 96 |
-
├── server/
|
| 97 |
-
│ ├── index.js — Express + WebSocket server entry point
|
| 98 |
-
│ ├── wsHandler.js — All WebSocket message routing
|
| 99 |
-
│ ├── sessionStore.js — In-memory + Supabase session storage
|
| 100 |
-
│ ├── chatStream.js — Lightning API streaming + tool execution
|
| 101 |
-
│ ├── auth.js — Supabase auth verification, profile, subscription
|
| 102 |
-
│ └── rateLimiter.js — Simple sliding-window rate limiter
|
| 103 |
-
└── public/
|
| 104 |
-
├── index.html — Single page app shell
|
| 105 |
-
├── oauth-callback.html — OAuth redirect handler
|
| 106 |
-
├── css/
|
| 107 |
-
│ ├── tokens.css — CSS variables (colors, spacing, fonts)
|
| 108 |
-
│ ├── base.css — Reset, global styles, animations
|
| 109 |
-
│ ├── sidebar.css — Collapsible sidebar, session list
|
| 110 |
-
│ ├── chat.css — Chat messages, code blocks, media
|
| 111 |
-
│ ├── input.css — Center and bottom input bars
|
| 112 |
-
│ └── modals.css — Modal overlays, settings, auth forms
|
| 113 |
-
└── js/
|
| 114 |
-
├── ws.js — WebSocket client (auto-reconnect, request/response)
|
| 115 |
-
├── auth.js — Auth state, Supabase sign-in/out, OAuth
|
| 116 |
-
├── sessions.js — Session list management and rendering
|
| 117 |
-
├── chat.js — Chat rendering, streaming, versioning, editing
|
| 118 |
-
├── ui.js — Notifications, context menus, markdown renderer
|
| 119 |
-
├── modals.js — All modal dialogs (auth, share, settings, tool detail)
|
| 120 |
-
├── settings.js — Settings modal, theme application
|
| 121 |
-
└── app.js — Bootstrap, input handling, sidebar, paste/attach
|
| 122 |
-
```
|
| 123 |
-
|
| 124 |
-
## Architecture Notes
|
| 125 |
-
|
| 126 |
-
- **WebSocket** is the primary transport for all chat, auth, and session operations
|
| 127 |
-
- **Server memory** stores active sessions; Supabase is the persistence layer for logged-in users
|
| 128 |
-
- **Guest users** get a `tempId` stored in `localStorage`; sessions live in server memory for 12h inactivity / 24h max, with a 15-message daily limit
|
| 129 |
-
- **On login**, temp sessions are transferred to the user's Supabase account automatically
|
| 130 |
-
- **Chat versioning**: each message stores `versions[]`, where each version contains the message content plus the `tail` (all following messages at time of edit). Navigating versions via the `‹ 1/N ›` arrows replays the stored tail
|
| 131 |
-
- **Device sessions** are tracked in server memory only (lost on restart) — no service role key needed
|
| 132 |
-
- **Lightning** at `https://sharktide-lightning.hf.space/gen` is used for all chat streaming and media generation
|
| 133 |
-
|
| 134 |
-
## Local Development
|
| 135 |
-
|
| 136 |
-
```bash
|
| 137 |
-
npm install
|
| 138 |
-
PUBLIC_URL=http://localhost:7860 npm run dev
|
| 139 |
-
```
|
| 140 |
-
|
| 141 |
-
Then open `http://localhost:7860`.
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Chat Dev
|
| 3 |
+
emoji: 🏃
|
| 4 |
+
colorFrom: purple
|
| 5 |
+
colorTo: gray
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
+
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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.
|
package.json
CHANGED
|
@@ -15,6 +15,7 @@
|
|
| 15 |
"ws": "^8.16.0",
|
| 16 |
"openai": "^6.29.0",
|
| 17 |
"node-fetch": "^3.3.2",
|
| 18 |
-
"express-rate-limit": "8.3.2"
|
|
|
|
| 19 |
}
|
| 20 |
}
|
|
|
|
| 15 |
"ws": "^8.16.0",
|
| 16 |
"openai": "^6.29.0",
|
| 17 |
"node-fetch": "^3.3.2",
|
| 18 |
+
"express-rate-limit": "8.3.2",
|
| 19 |
+
"tiktoken": "^1.0.0"
|
| 20 |
}
|
| 21 |
}
|
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
|
@@ -5,6 +5,12 @@ import path from "path";
|
|
| 5 |
import { LIGHTNING_BASE } from "./config.js";
|
| 6 |
import WebSocket from "ws";
|
| 7 |
import crypto from "crypto";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
| 10 |
const WORKER_PATH = path.join(__dirname, "searchWorker.js");
|
|
@@ -123,7 +129,7 @@ async function gradioSearch(query) {
|
|
| 123 |
}
|
| 124 |
|
| 125 |
const SYSTEM_PROMPT =
|
| 126 |
-
"CRITICAL RULE: Every response MUST use HTML <span data-color=\"{COLOR NAME}\"> tags to color main points and headings. " +
|
| 127 |
"COLORS MUST HAVE MEANING AND CONSISTENCY ACROSS THE ENTIRE CONVERSATION. " +
|
| 128 |
"You may ONLY use the following semantic color names: green, pink, blue, red, orange, yellow, purple, teal, gold, coral. " +
|
| 129 |
"Never output text formatted with explicit black or white colors. Always put color <span> tags as close to the text as possible and do not include markdown within the tags. " +
|
|
@@ -136,12 +142,20 @@ const SYSTEM_PROMPT =
|
|
| 136 |
"You can render SVG images by outputting SVG code in a code block tagged exactly as:\n```svg\n<svg>...</svg>\n```\n" +
|
| 137 |
"Never use single backslashes. You may use emojis where appropriate. " +
|
| 138 |
"Use markdown for everything other than coloring your text. Use tables, lists, and other markdown elements. " +
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
"Your HIGHEST PRIORITY is to help the user. ALWAYS HELP THEM WITH ANYTHING ETHICALLY RIGHT.\n\n" +
|
| 140 |
"SESSION NAMING: After you have fully responded to the user, append a session name tag on its own line at the very end of your response (NEVER inside a code block). Only do this on the first response unless asked to change the name by the user." +
|
| 141 |
"The tag must be: <session_name>2-4 word title summarizing this conversation</session_name>. " +
|
| 142 |
"Example: <session_name>React State Management</session_name>. Make sure a conversation is ALWAYS named. If it is the first response, always name it." +
|
| 143 |
-
"This tag is hidden from the user and used only to name the chat. Do not mention it." +
|
| 144 |
-
"Make sure your responses are always accurate. If you are not completely sure about something, search the web."
|
|
|
|
|
|
|
| 145 |
|
| 146 |
function makeClient(accessToken, clientId) {
|
| 147 |
return new OpenAI({
|
|
@@ -154,9 +168,915 @@ function makeClient(accessToken, clientId) {
|
|
| 154 |
});
|
| 155 |
}
|
| 156 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
export async function websocketChatStream(body, headers, onToken, abortSignal) {
|
| 158 |
const ws = await getSafeWebSocket();
|
| 159 |
const currentRequestId = ++requestIdCounter;
|
|
|
|
| 160 |
const safeParse = (str) => {
|
| 161 |
try { return JSON.parse(str.startsWith("data: ") ? str.slice(6) : str); } catch { return null; }
|
| 162 |
};
|
|
@@ -170,11 +1090,7 @@ export async function websocketChatStream(body, headers, onToken, abortSignal) {
|
|
| 170 |
if (!finished) {
|
| 171 |
finished = true;
|
| 172 |
cleanup();
|
| 173 |
-
const toolCalls =
|
| 174 |
-
id: t.id || `call_${crypto.randomUUID()}`,
|
| 175 |
-
type: "function",
|
| 176 |
-
function: { name: t.name, arguments: t.arguments },
|
| 177 |
-
}));
|
| 178 |
resolve({ assistantText, toolCalls });
|
| 179 |
}
|
| 180 |
}, 120000);
|
|
@@ -195,19 +1111,80 @@ export async function websocketChatStream(body, headers, onToken, abortSignal) {
|
|
| 195 |
if (!payload) return;
|
| 196 |
|
| 197 |
if (payload.error && !payload.choices) {
|
| 198 |
-
if (
|
| 199 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
return;
|
| 201 |
}
|
| 202 |
|
| 203 |
const delta = payload.choices?.[0]?.delta;
|
| 204 |
-
if (delta?.content) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
|
| 206 |
if (delta?.tool_calls) {
|
| 207 |
for (const call of delta.tool_calls) {
|
| 208 |
const entry = toolCallBuffer.get(call.index) ?? { arguments: "" };
|
| 209 |
if (call.id) entry.id = call.id;
|
| 210 |
-
if (call.function?.name)
|
|
|
|
|
|
|
| 211 |
if (call.function?.arguments) entry.arguments += call.function.arguments;
|
| 212 |
toolCallBuffer.set(call.index, entry);
|
| 213 |
}
|
|
@@ -216,11 +1193,7 @@ export async function websocketChatStream(body, headers, onToken, abortSignal) {
|
|
| 216 |
if (payload.choices?.[0]?.finish_reason && !finished) {
|
| 217 |
finished = true;
|
| 218 |
cleanup();
|
| 219 |
-
const toolCalls =
|
| 220 |
-
id: t.id || `call_${crypto.randomUUID()}`,
|
| 221 |
-
type: "function",
|
| 222 |
-
function: { name: t.name, arguments: t.arguments },
|
| 223 |
-
}));
|
| 224 |
resolve({ assistantText, toolCalls });
|
| 225 |
}
|
| 226 |
};
|
|
@@ -259,65 +1232,34 @@ export async function streamChat({
|
|
| 259 |
history = [],
|
| 260 |
userMessage,
|
| 261 |
tools,
|
|
|
|
|
|
|
| 262 |
accessToken,
|
| 263 |
clientId,
|
|
|
|
| 264 |
onToken = () => {},
|
| 265 |
onDone = () => {},
|
| 266 |
onError = () => {},
|
| 267 |
onToolCall = () => {},
|
| 268 |
onNewAsset = () => {},
|
|
|
|
| 269 |
abortSignal,
|
| 270 |
}) {
|
| 271 |
-
|
| 272 |
const enabledTools = buildToolList(tools);
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
normalizedUserMessage = [
|
| 288 |
-
{ type: "text", text: "[Image(s) attached]" },
|
| 289 |
-
...userMessage.filter(item => item.type === "image_url"),
|
| 290 |
-
];
|
| 291 |
-
}
|
| 292 |
-
|
| 293 |
-
} else {
|
| 294 |
-
|
| 295 |
-
normalizedUserMessage =
|
| 296 |
-
userMessage
|
| 297 |
-
.filter(b => b.type === "text")
|
| 298 |
-
.map(b => b.text)
|
| 299 |
-
.join("\n")
|
| 300 |
-
.trim() || "";
|
| 301 |
-
|
| 302 |
-
}
|
| 303 |
-
}
|
| 304 |
-
|
| 305 |
-
const hasUserMessage =
|
| 306 |
-
userMessage !== undefined &&
|
| 307 |
-
userMessage !== null &&
|
| 308 |
-
(typeof userMessage === "string" ? userMessage.trim() !== "" : Array.isArray(userMessage) && userMessage.length > 0);
|
| 309 |
-
|
| 310 |
-
const messages = [
|
| 311 |
-
{ role: "system", content: SYSTEM_PROMPT },
|
| 312 |
-
...history.map(normalizeMessage).filter(Boolean),
|
| 313 |
-
];
|
| 314 |
-
|
| 315 |
-
if (hasUserMessage) {
|
| 316 |
-
messages.push({
|
| 317 |
-
role: "user",
|
| 318 |
-
content: normalizedUserMessage,
|
| 319 |
-
});
|
| 320 |
-
}
|
| 321 |
|
| 322 |
const headers = {
|
| 323 |
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
|
@@ -325,108 +1267,129 @@ export async function streamChat({
|
|
| 325 |
};
|
| 326 |
|
| 327 |
try {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 328 |
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
};
|
| 335 |
-
|
| 336 |
-
let { assistantText, toolCalls } =
|
| 337 |
-
await websocketChatStream(body, headers, onToken, abortSignal);
|
| 338 |
-
|
| 339 |
-
if (toolCalls.length > 0) {
|
| 340 |
-
|
| 341 |
-
const toolResults = await processToolCalls(
|
| 342 |
-
null,
|
| 343 |
-
toolCalls,
|
| 344 |
-
tools,
|
| 345 |
-
accessToken,
|
| 346 |
-
clientId,
|
| 347 |
-
abortSignal,
|
| 348 |
-
onToolCall,
|
| 349 |
-
onNewAsset
|
| 350 |
);
|
| 351 |
|
| 352 |
-
|
| 353 |
-
{
|
| 354 |
-
|
| 355 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 356 |
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 361 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 362 |
}
|
|
|
|
| 363 |
|
| 364 |
-
|
|
|
|
|
|
|
| 365 |
{
|
| 366 |
-
role: "
|
| 367 |
-
content:
|
| 368 |
-
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
|
| 369 |
},
|
| 370 |
-
|
| 371 |
-
);
|
| 372 |
|
| 373 |
-
const
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 379 |
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
// If the model returned new tool calls, process them and update the body
|
| 386 |
-
if (followUp.toolCalls.length > 0) {
|
| 387 |
-
const toolResults = await processToolCalls(
|
| 388 |
-
null,
|
| 389 |
-
followUp.toolCalls,
|
| 390 |
-
tools,
|
| 391 |
-
accessToken,
|
| 392 |
-
clientId,
|
| 393 |
-
abortSignal,
|
| 394 |
-
onToolCall,
|
| 395 |
-
onNewAsset
|
| 396 |
-
);
|
| 397 |
-
followUpMessages.push(...toolResults);
|
| 398 |
-
|
| 399 |
-
// Update body for another pass
|
| 400 |
-
followUpBody.messages = followUpMessages;
|
| 401 |
-
} else {
|
| 402 |
-
break; // no new tool calls, we’re done
|
| 403 |
-
}
|
| 404 |
}
|
| 405 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 406 |
}
|
| 407 |
|
| 408 |
const sessionName = extractSessionName(assistantText);
|
| 409 |
|
| 410 |
if (typeof onDone === "function") {
|
| 411 |
-
onDone(assistantText,
|
| 412 |
}
|
| 413 |
|
| 414 |
-
|
| 415 |
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
) {
|
| 422 |
-
if (typeof onDone === "function") {
|
| 423 |
-
onDone(null, null, true, null);
|
| 424 |
-
}
|
| 425 |
} else {
|
| 426 |
-
|
| 427 |
-
if (typeof onError === "function") {
|
| 428 |
-
onError(String(err));
|
| 429 |
-
}
|
| 430 |
}
|
| 431 |
}
|
| 432 |
}
|
|
@@ -436,8 +1399,22 @@ const VALID_ROLES = new Set(["system", "user", "assistant", "tool"]);
|
|
| 436 |
function normalizeMessage(msg) {
|
| 437 |
if (!VALID_ROLES.has(msg.role)) return null;
|
| 438 |
|
| 439 |
-
if (msg.role === "assistant" && msg.tool_calls) {
|
| 440 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 441 |
}
|
| 442 |
if (Array.isArray(msg.content)) {
|
| 443 |
// If the array contains images, preserve the full array format
|
|
@@ -456,9 +1433,149 @@ function normalizeMessage(msg) {
|
|
| 456 |
}
|
| 457 |
|
| 458 |
function buildToolList(tools) {
|
| 459 |
-
|
| 460 |
const list = [];
|
| 461 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 462 |
list.push({
|
| 463 |
type: "function",
|
| 464 |
function: {
|
|
@@ -484,7 +1601,7 @@ function buildToolList(tools) {
|
|
| 484 |
},
|
| 485 |
});
|
| 486 |
}
|
| 487 |
-
if (
|
| 488 |
list.push({
|
| 489 |
type: "function",
|
| 490 |
function: {
|
|
@@ -502,7 +1619,7 @@ function buildToolList(tools) {
|
|
| 502 |
},
|
| 503 |
});
|
| 504 |
}
|
| 505 |
-
if (
|
| 506 |
list.push({
|
| 507 |
type: "function",
|
| 508 |
function: {
|
|
@@ -522,7 +1639,7 @@ function buildToolList(tools) {
|
|
| 522 |
},
|
| 523 |
});
|
| 524 |
}
|
| 525 |
-
if (
|
| 526 |
list.push({
|
| 527 |
type: "function",
|
| 528 |
function: {
|
|
@@ -539,9 +1656,84 @@ function buildToolList(tools) {
|
|
| 539 |
return list;
|
| 540 |
}
|
| 541 |
|
| 542 |
-
|
| 543 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 544 |
const authHeaders = {};
|
|
|
|
| 545 |
if (accessToken) {
|
| 546 |
authHeaders["Authorization"] = `Bearer ${accessToken}`;
|
| 547 |
}
|
|
@@ -552,17 +1744,168 @@ async function processToolCalls(ws, toolCalls, tools, accessToken, clientId, abo
|
|
| 552 |
for (const call of toolCalls) {
|
| 553 |
let args;
|
| 554 |
try { args = JSON.parse(call.function.arguments || "{}"); } catch { args = {}; }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 555 |
|
| 556 |
-
onToolCall({ id: call.id, name: call.function.name, state: "pending", args });
|
| 557 |
|
| 558 |
let result = "Tool completed.";
|
| 559 |
|
| 560 |
try {
|
| 561 |
-
if (
|
| 562 |
-
result =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 563 |
}
|
| 564 |
|
| 565 |
-
else if (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 566 |
const { convert } = await import("html-to-text");
|
| 567 |
const res = await fetch(args.url, { signal: abortSignal });
|
| 568 |
if (!res.ok) {
|
|
@@ -577,7 +1920,7 @@ async function processToolCalls(ws, toolCalls, tools, accessToken, clientId, abo
|
|
| 577 |
}
|
| 578 |
}
|
| 579 |
|
| 580 |
-
else if (
|
| 581 |
const body = { prompt: args.prompt };
|
| 582 |
if (args.mode) body.mode = args.mode;
|
| 583 |
if (args.image_urls?.length) body.image_urls = args.image_urls;
|
|
@@ -591,9 +1934,15 @@ async function processToolCalls(ws, toolCalls, tools, accessToken, clientId, abo
|
|
| 591 |
if (res.ok) {
|
| 592 |
const buf = await res.arrayBuffer();
|
| 593 |
const ct = res.headers.get("content-type") || "image/png";
|
| 594 |
-
const
|
| 595 |
-
|
| 596 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 597 |
result = "Image generated successfully and shown to the user.";
|
| 598 |
} else if (res.status == 402) {
|
| 599 |
result = "An upgraded plan is required for higher limits.";
|
|
@@ -604,7 +1953,7 @@ async function processToolCalls(ws, toolCalls, tools, accessToken, clientId, abo
|
|
| 604 |
}
|
| 605 |
}
|
| 606 |
|
| 607 |
-
else if (
|
| 608 |
const body = { prompt: args.prompt };
|
| 609 |
if (args.ratio) body.ratio = args.ratio;
|
| 610 |
if (args.mode) body.mode = args.mode;
|
|
@@ -619,9 +1968,16 @@ async function processToolCalls(ws, toolCalls, tools, accessToken, clientId, abo
|
|
| 619 |
});
|
| 620 |
if (res.ok) {
|
| 621 |
const buf = await res.arrayBuffer();
|
| 622 |
-
const
|
| 623 |
-
const
|
| 624 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 625 |
result = "Video generated successfully and shown to the user.";
|
| 626 |
} else if (res.status == 402) {
|
| 627 |
result = "An upgraded plan is required for higher limits.";
|
|
@@ -632,7 +1988,7 @@ async function processToolCalls(ws, toolCalls, tools, accessToken, clientId, abo
|
|
| 632 |
}
|
| 633 |
}
|
| 634 |
|
| 635 |
-
else if (
|
| 636 |
const res = await fetch(`${LIGHTNING_BASE}/gen/sfx`, {
|
| 637 |
method: "POST",
|
| 638 |
headers: { "Content-Type": "application/json", ...authHeaders },
|
|
@@ -641,9 +1997,16 @@ async function processToolCalls(ws, toolCalls, tools, accessToken, clientId, abo
|
|
| 641 |
});
|
| 642 |
if (res.ok) {
|
| 643 |
const buf = await res.arrayBuffer();
|
| 644 |
-
const
|
| 645 |
-
const
|
| 646 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 647 |
result = "Audio generated successfully and shown to the user.";
|
| 648 |
} else if (res.status == 429) {
|
| 649 |
result = "Too many requests. Try again later.";
|
|
@@ -655,14 +2018,14 @@ async function processToolCalls(ws, toolCalls, tools, accessToken, clientId, abo
|
|
| 655 |
result = `Tool error: ${String(err)}`;
|
| 656 |
}
|
| 657 |
|
| 658 |
-
onToolCall({ id: call.id, name: call.function.name, state: "resolved", result });
|
| 659 |
|
| 660 |
-
|
| 661 |
role: "tool",
|
| 662 |
tool_call_id: call.id,
|
| 663 |
content: typeof result === "string" ? result : JSON.stringify(result),
|
| 664 |
});
|
| 665 |
}
|
| 666 |
|
| 667 |
-
return
|
| 668 |
-
}
|
|
|
|
| 5 |
import { LIGHTNING_BASE } from "./config.js";
|
| 6 |
import WebSocket from "ws";
|
| 7 |
import crypto from "crypto";
|
| 8 |
+
import { encoding_for_model } from "tiktoken";
|
| 9 |
+
import { mediaStore } from "./mediaStore.js";
|
| 10 |
+
import { memoryStore } from "./memoryStore.js";
|
| 11 |
+
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");
|
|
|
|
| 129 |
}
|
| 130 |
|
| 131 |
const SYSTEM_PROMPT =
|
| 132 |
+
"CRITICAL RULE: Every response MUST use HTML <span data-color=\"{COLOR NAME}\"> tags to color main points and headings unless you are told otherwise. " +
|
| 133 |
"COLORS MUST HAVE MEANING AND CONSISTENCY ACROSS THE ENTIRE CONVERSATION. " +
|
| 134 |
"You may ONLY use the following semantic color names: green, pink, blue, red, orange, yellow, purple, teal, gold, coral. " +
|
| 135 |
"Never output text formatted with explicit black or white colors. Always put color <span> tags as close to the text as possible and do not include markdown within the tags. " +
|
|
|
|
| 142 |
"You can render SVG images by outputting SVG code in a code block tagged exactly as:\n```svg\n<svg>...</svg>\n```\n" +
|
| 143 |
"Never use single backslashes. You may use emojis where appropriate. " +
|
| 144 |
"Use markdown for everything other than coloring your text. Use tables, lists, and other markdown elements. " +
|
| 145 |
+
"ATTACHMENT HANDLING RULE: Large user prompts, text attachments, conversation history, and image attachments may be staged into separate resources on purpose. " +
|
| 146 |
+
"If you see notes saying attached text was staged separately, or notes that only the first part of a prompt is inline, do NOT assume the content is missing, corrupted, or truncated. " +
|
| 147 |
+
"Treat that content as available context and use the provided tools to inspect it before concluding something is absent. " +
|
| 148 |
+
"Use `list_prompt_resources` to find staged resources, `read_prompt_chunk` to read staged text exactly, `load_prompt_images` to inspect staged images, and `write_notes` to keep a compact memory after reading several chunks. " +
|
| 149 |
+
"Before claiming an attachment is incomplete, missing, malformed, or unreadable, first check whether it was staged separately and read the relevant resource. " +
|
| 150 |
+
"Persistent memories must stay short, concrete, and durable. Only save memories that will still help in future chats, and keep each one to a brief sentence or phrase. " +
|
| 151 |
"Your HIGHEST PRIORITY is to help the user. ALWAYS HELP THEM WITH ANYTHING ETHICALLY RIGHT.\n\n" +
|
| 152 |
"SESSION NAMING: After you have fully responded to the user, append a session name tag on its own line at the very end of your response (NEVER inside a code block). Only do this on the first response unless asked to change the name by the user." +
|
| 153 |
"The tag must be: <session_name>2-4 word title summarizing this conversation</session_name>. " +
|
| 154 |
"Example: <session_name>React State Management</session_name>. Make sure a conversation is ALWAYS named. If it is the first response, always name it." +
|
| 155 |
+
"This tag is hidden from the user and used only to name the chat. Do not mention it. Also, you should try to minimize the use of * to emphasize text. Just use it for markdown." +
|
| 156 |
+
"Make sure your responses are always accurate. If you are not completely sure about something, search the web." +
|
| 157 |
+
"At the start of a chat, always check the memories. If the user tells you to remember something or there is something important to note, create a new memory. Memories should be brief. Notes are only for session-long memory, so use memories for anything relevat to future chats." +
|
| 158 |
+
"If you notice any issue or mistake with your response, correct it with the replace tools. Make sure to ALWAYS answer as CORRECTLY as possible, and use search when unsure.";
|
| 159 |
|
| 160 |
function makeClient(accessToken, clientId) {
|
| 161 |
return new OpenAI({
|
|
|
|
| 168 |
});
|
| 169 |
}
|
| 170 |
|
| 171 |
+
// --- TOKEN / CONTEXT MANAGEMENT ---
|
| 172 |
+
const MODEL_TOKEN_LIMIT = 8000;
|
| 173 |
+
const RESPONSE_TOKEN_RESERVE = 600;
|
| 174 |
+
const WORKING_PROMPT_BUDGET = MODEL_TOKEN_LIMIT - RESPONSE_TOKEN_RESERVE;
|
| 175 |
+
const INLINE_USER_PROMPT_TOKENS = 5000;
|
| 176 |
+
const MAX_AGENT_STEPS = 12;
|
| 177 |
+
const CHUNK_SIZE = 700;
|
| 178 |
+
const MAX_INLINE_IMAGES = 3;
|
| 179 |
+
const RECENT_HISTORY_MAX_MESSAGES = 6;
|
| 180 |
+
const RECENT_HISTORY_TOKEN_BUDGET = 900;
|
| 181 |
+
const HISTORY_SUMMARY_TOKEN_BUDGET = 600;
|
| 182 |
+
const NOTES_TOKEN_BUDGET = 900;
|
| 183 |
+
const MAX_DYNAMIC_MESSAGES = 10;
|
| 184 |
+
const MAX_UPSTREAM_RATE_LIMIT_RETRIES = 4;
|
| 185 |
+
const DEFAULT_UPSTREAM_RETRY_MS = 4000;
|
| 186 |
+
const MAX_UPSTREAM_RETRY_MS = 15000;
|
| 187 |
+
const UPSTREAM_RETRY_BUFFER_MS = 350;
|
| 188 |
+
|
| 189 |
+
// In-memory stores for staged prompt resources and assistant notes
|
| 190 |
+
const promptContextStore = new Map(); // sessionId -> { resources, resourcesById }
|
| 191 |
+
const assistantNotesStore = new Map(); // sessionId -> [{ step, note }]
|
| 192 |
+
|
| 193 |
+
// Initialize tiktoken encoder
|
| 194 |
+
const enc = encoding_for_model("gpt-4"); // maps well to gpt-oss:120b
|
| 195 |
+
const tokenDecoder = new TextDecoder("utf-8");
|
| 196 |
+
|
| 197 |
+
function countTokens(text) {
|
| 198 |
+
return enc.encode(String(text || "")).length;
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
function decodeTokenSlice(tokens) {
|
| 202 |
+
const decoded = enc.decode(tokens);
|
| 203 |
+
return typeof decoded === "string" ? decoded : tokenDecoder.decode(decoded);
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
function countMessageTokens(messages) {
|
| 207 |
+
let total = 0;
|
| 208 |
+
for (const msg of messages) {
|
| 209 |
+
if (typeof msg.content === "string") {
|
| 210 |
+
total += countTokens(msg.content);
|
| 211 |
+
continue;
|
| 212 |
+
}
|
| 213 |
+
if (Array.isArray(msg.content)) {
|
| 214 |
+
for (const block of msg.content) {
|
| 215 |
+
if (block.type === "text") total += countTokens(block.text);
|
| 216 |
+
else if (block.type === "image_url") total += 85;
|
| 217 |
+
}
|
| 218 |
+
}
|
| 219 |
+
}
|
| 220 |
+
return total;
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
function compactWhitespace(text) {
|
| 224 |
+
return String(text || "").replace(/\s+/g, " ").trim();
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
function makePreview(text, maxChars = 160) {
|
| 228 |
+
const compact = compactWhitespace(text);
|
| 229 |
+
if (!compact) return "Empty.";
|
| 230 |
+
return compact.length <= maxChars ? compact : `${compact.slice(0, maxChars - 3)}...`;
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
function stripHtml(text) {
|
| 234 |
+
return String(text || "").replace(/<[^>]+>/g, " ");
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
function splitTextByTokenLimit(text, tokenLimit) {
|
| 238 |
+
const source = String(text || "");
|
| 239 |
+
const tokens = enc.encode(source);
|
| 240 |
+
if (tokens.length <= tokenLimit) {
|
| 241 |
+
return { head: source, tail: "" };
|
| 242 |
+
}
|
| 243 |
+
return {
|
| 244 |
+
head: decodeTokenSlice(tokens.slice(0, tokenLimit)),
|
| 245 |
+
tail: decodeTokenSlice(tokens.slice(tokenLimit)),
|
| 246 |
+
};
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
function chunkTextWithMetadata(text, chunkSize = CHUNK_SIZE) {
|
| 250 |
+
const source = String(text || "");
|
| 251 |
+
const tokens = enc.encode(source);
|
| 252 |
+
const chunks = [];
|
| 253 |
+
|
| 254 |
+
for (let i = 0; i < tokens.length; i += chunkSize) {
|
| 255 |
+
const chunkText = decodeTokenSlice(tokens.slice(i, i + chunkSize));
|
| 256 |
+
chunks.push({
|
| 257 |
+
index: chunks.length,
|
| 258 |
+
text: chunkText,
|
| 259 |
+
tokenCount: countTokens(chunkText),
|
| 260 |
+
preview: makePreview(chunkText, 120),
|
| 261 |
+
});
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
return chunks;
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
function makeResourceSlug(value) {
|
| 268 |
+
return compactWhitespace(value)
|
| 269 |
+
.toLowerCase()
|
| 270 |
+
.replace(/[^a-z0-9]+/g, "_")
|
| 271 |
+
.replace(/^_+|_+$/g, "")
|
| 272 |
+
.slice(0, 40) || "resource";
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
function createPromptState(sessionId) {
|
| 276 |
+
const state = {
|
| 277 |
+
sessionId,
|
| 278 |
+
createdAt: Date.now(),
|
| 279 |
+
resources: [],
|
| 280 |
+
resourcesById: new Map(),
|
| 281 |
+
};
|
| 282 |
+
promptContextStore.set(sessionId, state);
|
| 283 |
+
assistantNotesStore.set(sessionId, []);
|
| 284 |
+
return state;
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
function getPromptState(sessionId) {
|
| 288 |
+
return promptContextStore.get(sessionId);
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
function clearPromptState(sessionId) {
|
| 292 |
+
promptContextStore.delete(sessionId);
|
| 293 |
+
assistantNotesStore.delete(sessionId);
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
function createResourceId(state, kind, name) {
|
| 297 |
+
const base = `${kind}_${makeResourceSlug(name)}`;
|
| 298 |
+
let id = base;
|
| 299 |
+
let suffix = 2;
|
| 300 |
+
|
| 301 |
+
while (state.resourcesById.has(id)) {
|
| 302 |
+
id = `${base}_${suffix++}`;
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
return id;
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
function registerTextResource(state, { kind, name, text }) {
|
| 309 |
+
const normalizedText = String(text ?? "").trim();
|
| 310 |
+
if (!normalizedText) return null;
|
| 311 |
+
|
| 312 |
+
const resource = {
|
| 313 |
+
id: createResourceId(state, kind, name),
|
| 314 |
+
kind,
|
| 315 |
+
name,
|
| 316 |
+
totalTokens: countTokens(normalizedText),
|
| 317 |
+
chunkCount: 0,
|
| 318 |
+
preview: makePreview(normalizedText),
|
| 319 |
+
chunks: chunkTextWithMetadata(normalizedText),
|
| 320 |
+
};
|
| 321 |
+
resource.chunkCount = resource.chunks.length;
|
| 322 |
+
|
| 323 |
+
state.resources.push(resource);
|
| 324 |
+
state.resourcesById.set(resource.id, resource);
|
| 325 |
+
return resource;
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
function registerImageResource(state, { name, images, inlineImageCount = 0 }) {
|
| 329 |
+
const normalizedImages = (images || [])
|
| 330 |
+
.filter(Boolean)
|
| 331 |
+
.map((block, index) => ({
|
| 332 |
+
index,
|
| 333 |
+
block: { ...block },
|
| 334 |
+
}));
|
| 335 |
+
|
| 336 |
+
if (!normalizedImages.length) return null;
|
| 337 |
+
|
| 338 |
+
const resource = {
|
| 339 |
+
id: createResourceId(state, "images", name),
|
| 340 |
+
kind: "images",
|
| 341 |
+
name,
|
| 342 |
+
imageCount: normalizedImages.length,
|
| 343 |
+
inlineImageCount,
|
| 344 |
+
preview: `${normalizedImages.length} image(s)`,
|
| 345 |
+
images: normalizedImages,
|
| 346 |
+
};
|
| 347 |
+
|
| 348 |
+
state.resources.push(resource);
|
| 349 |
+
state.resourcesById.set(resource.id, resource);
|
| 350 |
+
return resource;
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
function parseTextAttachmentsFromDetails(text) {
|
| 354 |
+
const source = String(text || "");
|
| 355 |
+
if (!source.trim()) return { text: "", attachments: [] };
|
| 356 |
+
|
| 357 |
+
const detailsRegex = /<details>\s*<summary>(.*?)<\/summary>\s*([\s\S]*?)<\/details>/gi;
|
| 358 |
+
const codeBlockRegex = /```(?:[\w+-]+)?\n?([\s\S]*?)```/g;
|
| 359 |
+
const attachments = [];
|
| 360 |
+
const parts = [];
|
| 361 |
+
let lastIndex = 0;
|
| 362 |
+
let attachmentCounter = 1;
|
| 363 |
+
let match;
|
| 364 |
+
|
| 365 |
+
while ((match = detailsRegex.exec(source))) {
|
| 366 |
+
parts.push(source.slice(lastIndex, match.index));
|
| 367 |
+
|
| 368 |
+
const summary = compactWhitespace(match[1]) || `attachment_${attachmentCounter}`;
|
| 369 |
+
const innerContent = match[2] || "";
|
| 370 |
+
const codeBlocks = [...innerContent.matchAll(codeBlockRegex)];
|
| 371 |
+
|
| 372 |
+
if (codeBlocks.length) {
|
| 373 |
+
codeBlocks.forEach((codeMatch, index) => {
|
| 374 |
+
attachments.push({
|
| 375 |
+
name: codeBlocks.length > 1 ? `${summary} part ${index + 1}` : summary,
|
| 376 |
+
content: codeMatch[1],
|
| 377 |
+
});
|
| 378 |
+
});
|
| 379 |
+
} else {
|
| 380 |
+
const plainText = stripHtml(innerContent).trim();
|
| 381 |
+
if (plainText) {
|
| 382 |
+
attachments.push({ name: summary, content: plainText });
|
| 383 |
+
}
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
parts.push(`\n[Attached text "${summary}" was staged separately from the inline prompt.]\n`);
|
| 387 |
+
lastIndex = match.index + match[0].length;
|
| 388 |
+
attachmentCounter++;
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
parts.push(source.slice(lastIndex));
|
| 392 |
+
return {
|
| 393 |
+
text: parts.join("").trim(),
|
| 394 |
+
attachments,
|
| 395 |
+
};
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
function contentToText(content, { preview = false } = {}) {
|
| 399 |
+
if (typeof content === "string") {
|
| 400 |
+
return preview ? makePreview(content, 220) : String(content || "");
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
if (!Array.isArray(content)) return "";
|
| 404 |
+
|
| 405 |
+
const textParts = [];
|
| 406 |
+
let imageCount = 0;
|
| 407 |
+
|
| 408 |
+
for (const block of content) {
|
| 409 |
+
if (block.type === "text" && block.text) textParts.push(block.text);
|
| 410 |
+
else if (block.type === "image_url") imageCount++;
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
const pieces = [];
|
| 414 |
+
const text = textParts.join("\n\n").trim();
|
| 415 |
+
if (text) pieces.push(preview ? makePreview(text, 220) : text);
|
| 416 |
+
if (imageCount) pieces.push(`[${imageCount} image attachment(s)]`);
|
| 417 |
+
return pieces.join(preview ? " " : "\n");
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
function getAllowedToolNames(toolDefs = []) {
|
| 421 |
+
return toolDefs
|
| 422 |
+
.map((tool) => tool?.function?.name)
|
| 423 |
+
.filter(Boolean);
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
function sanitizeToolName(rawName, allowedToolNames = []) {
|
| 427 |
+
if (!rawName) return null;
|
| 428 |
+
|
| 429 |
+
let name = String(rawName).trim();
|
| 430 |
+
if (!name) return null;
|
| 431 |
+
|
| 432 |
+
name = name.replace(/<\|[\s\S]*$/, "").trim();
|
| 433 |
+
name = name.replace(/^["'`]+|["'`]+$/g, "");
|
| 434 |
+
|
| 435 |
+
const identifierMatch = name.match(/^[A-Za-z0-9_-]+/);
|
| 436 |
+
if (identifierMatch) {
|
| 437 |
+
name = identifierMatch[0];
|
| 438 |
+
}
|
| 439 |
+
|
| 440 |
+
if (!allowedToolNames.length) {
|
| 441 |
+
return name || null;
|
| 442 |
+
}
|
| 443 |
+
|
| 444 |
+
if (allowedToolNames.includes(name)) {
|
| 445 |
+
return name;
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
const normalizedRaw = String(rawName).toLowerCase();
|
| 449 |
+
const prefixedMatch = allowedToolNames.find((allowedName) =>
|
| 450 |
+
normalizedRaw.startsWith(allowedName.toLowerCase())
|
| 451 |
+
);
|
| 452 |
+
if (prefixedMatch) {
|
| 453 |
+
return prefixedMatch;
|
| 454 |
+
}
|
| 455 |
+
|
| 456 |
+
const embeddedMatch = allowedToolNames.find((allowedName) =>
|
| 457 |
+
normalizedRaw.includes(allowedName.toLowerCase())
|
| 458 |
+
);
|
| 459 |
+
return embeddedMatch || null;
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
function normalizeStoredToolCalls(toolCalls = []) {
|
| 463 |
+
return toolCalls.map((call) => ({
|
| 464 |
+
id: call.id || `call_${crypto.randomUUID()}`,
|
| 465 |
+
type: "function",
|
| 466 |
+
function: {
|
| 467 |
+
name: sanitizeToolName(call.name || call.function?.name || "unknown_tool"),
|
| 468 |
+
arguments: (() => {
|
| 469 |
+
const rawArgs = call.args ?? call.function?.arguments ?? {};
|
| 470 |
+
return typeof rawArgs === "string" ? rawArgs : JSON.stringify(rawArgs);
|
| 471 |
+
})(),
|
| 472 |
+
},
|
| 473 |
+
}));
|
| 474 |
+
}
|
| 475 |
+
|
| 476 |
+
function getMessageToolNames(msg) {
|
| 477 |
+
const rawCalls = Array.isArray(msg.tool_calls)
|
| 478 |
+
? msg.tool_calls
|
| 479 |
+
: Array.isArray(msg.toolCalls)
|
| 480 |
+
? msg.toolCalls
|
| 481 |
+
: [];
|
| 482 |
+
|
| 483 |
+
return rawCalls
|
| 484 |
+
.map((call) => call?.function?.name || call?.name)
|
| 485 |
+
.filter(Boolean);
|
| 486 |
+
}
|
| 487 |
+
|
| 488 |
+
function describeMessageForSummary(msg) {
|
| 489 |
+
const role = (msg.role || "message").toUpperCase();
|
| 490 |
+
const contentPreview = contentToText(msg.content, { preview: true }) || "[no text]";
|
| 491 |
+
const toolNames = getMessageToolNames(msg);
|
| 492 |
+
const suffix = toolNames.length ? ` [tools: ${toolNames.join(", ")}]` : "";
|
| 493 |
+
return `${role}: ${contentPreview}${suffix}`;
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
function messagesToTranscript(messages) {
|
| 497 |
+
return messages
|
| 498 |
+
.map((msg) => {
|
| 499 |
+
const toolNames = getMessageToolNames(msg);
|
| 500 |
+
const lines = [
|
| 501 |
+
`${(msg.role || "message").toUpperCase()}:`,
|
| 502 |
+
contentToText(msg.content) || "[no text]",
|
| 503 |
+
];
|
| 504 |
+
if (toolNames.length) {
|
| 505 |
+
lines.push(`[tool calls: ${toolNames.join(", ")}]`);
|
| 506 |
+
}
|
| 507 |
+
return lines.join("\n");
|
| 508 |
+
})
|
| 509 |
+
.join("\n\n");
|
| 510 |
+
}
|
| 511 |
+
|
| 512 |
+
function buildHistorySummary(messages, tokenBudget = HISTORY_SUMMARY_TOKEN_BUDGET) {
|
| 513 |
+
const lines = [];
|
| 514 |
+
let usedTokens = 0;
|
| 515 |
+
|
| 516 |
+
for (let i = 0; i < messages.length; i++) {
|
| 517 |
+
const line = `- ${describeMessageForSummary(messages[i])}`;
|
| 518 |
+
const lineTokens = countTokens(line);
|
| 519 |
+
if (usedTokens + lineTokens > tokenBudget) {
|
| 520 |
+
lines.push(`- ${messages.length - i} earlier message(s) were omitted from this summary.`);
|
| 521 |
+
break;
|
| 522 |
+
}
|
| 523 |
+
lines.push(line);
|
| 524 |
+
usedTokens += lineTokens;
|
| 525 |
+
}
|
| 526 |
+
|
| 527 |
+
return lines.join("\n");
|
| 528 |
+
}
|
| 529 |
+
|
| 530 |
+
function buildPromptResourceManifest(state) {
|
| 531 |
+
return {
|
| 532 |
+
resource_count: state?.resources?.length || 0,
|
| 533 |
+
resources: (state?.resources || []).map((resource) => {
|
| 534 |
+
if (resource.kind === "images") {
|
| 535 |
+
return {
|
| 536 |
+
id: resource.id,
|
| 537 |
+
kind: resource.kind,
|
| 538 |
+
name: resource.name,
|
| 539 |
+
image_count: resource.imageCount,
|
| 540 |
+
inline_image_count: resource.inlineImageCount,
|
| 541 |
+
preview: resource.preview,
|
| 542 |
+
};
|
| 543 |
+
}
|
| 544 |
+
|
| 545 |
+
return {
|
| 546 |
+
id: resource.id,
|
| 547 |
+
kind: resource.kind,
|
| 548 |
+
name: resource.name,
|
| 549 |
+
chunk_count: resource.chunkCount,
|
| 550 |
+
total_tokens: resource.totalTokens,
|
| 551 |
+
preview: resource.preview,
|
| 552 |
+
};
|
| 553 |
+
}),
|
| 554 |
+
};
|
| 555 |
+
}
|
| 556 |
+
|
| 557 |
+
function formatResourceManifestLine(resource) {
|
| 558 |
+
if (resource.kind === "images") {
|
| 559 |
+
const inlineNote = resource.inlineImageCount
|
| 560 |
+
? `, first ${resource.inlineImageCount} already inline`
|
| 561 |
+
: "";
|
| 562 |
+
return `- \`${resource.id}\` (${resource.kind}, ${resource.imageCount} image(s)${inlineNote})`;
|
| 563 |
+
}
|
| 564 |
+
|
| 565 |
+
return `- \`${resource.id}\` (${resource.kind}, ${resource.chunkCount} chunk(s), ${resource.totalTokens} tokens): ${resource.preview}`;
|
| 566 |
+
}
|
| 567 |
+
|
| 568 |
+
function findPromptResource(state, resourceId) {
|
| 569 |
+
if (!state || !resourceId) return null;
|
| 570 |
+
return (
|
| 571 |
+
state.resourcesById.get(resourceId) ||
|
| 572 |
+
state.resources.find((resource) => resource.name === resourceId) ||
|
| 573 |
+
null
|
| 574 |
+
);
|
| 575 |
+
}
|
| 576 |
+
|
| 577 |
+
function appendAssistantNote(sessionId, note) {
|
| 578 |
+
const normalizedNote = compactWhitespace(note);
|
| 579 |
+
if (!normalizedNote) return;
|
| 580 |
+
|
| 581 |
+
const notes = assistantNotesStore.get(sessionId) || [];
|
| 582 |
+
if (notes[notes.length - 1]?.note === normalizedNote) return;
|
| 583 |
+
|
| 584 |
+
notes.push({ step: notes.length + 1, note: normalizedNote });
|
| 585 |
+
|
| 586 |
+
while (
|
| 587 |
+
notes.length > 1 &&
|
| 588 |
+
countTokens(notes.map((entry) => `- ${entry.note}`).join("\n")) > NOTES_TOKEN_BUDGET
|
| 589 |
+
) {
|
| 590 |
+
notes.shift();
|
| 591 |
+
}
|
| 592 |
+
|
| 593 |
+
assistantNotesStore.set(sessionId, notes);
|
| 594 |
+
}
|
| 595 |
+
|
| 596 |
+
function buildAssistantNotesMessage(sessionId) {
|
| 597 |
+
const notes = assistantNotesStore.get(sessionId) || [];
|
| 598 |
+
if (!notes.length) return null;
|
| 599 |
+
|
| 600 |
+
return {
|
| 601 |
+
role: "system",
|
| 602 |
+
content: [
|
| 603 |
+
"Working notes captured during this response:",
|
| 604 |
+
...notes.map((entry) => `- ${entry.note}`),
|
| 605 |
+
].join("\n"),
|
| 606 |
+
};
|
| 607 |
+
}
|
| 608 |
+
|
| 609 |
+
function buildCompactionSummaryMessage(messages) {
|
| 610 |
+
if (!messages.length) return null;
|
| 611 |
+
|
| 612 |
+
const toolNames = new Set();
|
| 613 |
+
let toolResultCount = 0;
|
| 614 |
+
let imageBatchCount = 0;
|
| 615 |
+
|
| 616 |
+
for (const msg of messages) {
|
| 617 |
+
for (const name of getMessageToolNames(msg)) {
|
| 618 |
+
toolNames.add(name);
|
| 619 |
+
}
|
| 620 |
+
if (msg.role === "tool") toolResultCount++;
|
| 621 |
+
if (
|
| 622 |
+
msg.role === "user" &&
|
| 623 |
+
Array.isArray(msg.content) &&
|
| 624 |
+
msg.content.some((block) => block.type === "image_url")
|
| 625 |
+
) {
|
| 626 |
+
imageBatchCount++;
|
| 627 |
+
}
|
| 628 |
+
}
|
| 629 |
+
|
| 630 |
+
const details = [];
|
| 631 |
+
if (toolNames.size) details.push(`tools: ${[...toolNames].join(", ")}`);
|
| 632 |
+
if (toolResultCount) details.push(`${toolResultCount} prior tool result(s)`);
|
| 633 |
+
if (imageBatchCount) details.push(`${imageBatchCount} prior image batch(es)`);
|
| 634 |
+
|
| 635 |
+
return {
|
| 636 |
+
role: "system",
|
| 637 |
+
content: `Earlier tool interactions in this response were compacted to stay within the context window${details.length ? ` (${details.join("; ")})` : ""}. Reread any chunk or image batch if you need the exact content again.`,
|
| 638 |
+
};
|
| 639 |
+
}
|
| 640 |
+
|
| 641 |
+
function buildModelMessages(baseMessages, workingMessages, sessionId) {
|
| 642 |
+
const notesMessage = buildAssistantNotesMessage(sessionId);
|
| 643 |
+
const messages = [...baseMessages];
|
| 644 |
+
if (notesMessage) messages.push(notesMessage);
|
| 645 |
+
|
| 646 |
+
const budgetLeft = Math.max(0, WORKING_PROMPT_BUDGET - countMessageTokens(messages));
|
| 647 |
+
const keptMessages = [];
|
| 648 |
+
let keptTokens = 0;
|
| 649 |
+
|
| 650 |
+
for (let i = workingMessages.length - 1; i >= 0; i--) {
|
| 651 |
+
const candidate = workingMessages[i];
|
| 652 |
+
const candidateTokens = countMessageTokens([candidate]);
|
| 653 |
+
if (keptMessages.length >= MAX_DYNAMIC_MESSAGES) {
|
| 654 |
+
break;
|
| 655 |
+
}
|
| 656 |
+
if (keptMessages.length > 0 && keptTokens + candidateTokens > budgetLeft) {
|
| 657 |
+
break;
|
| 658 |
+
}
|
| 659 |
+
keptMessages.unshift(candidate);
|
| 660 |
+
keptTokens += candidateTokens;
|
| 661 |
+
if (keptMessages.length === 1 && candidateTokens > budgetLeft) {
|
| 662 |
+
break;
|
| 663 |
+
}
|
| 664 |
+
}
|
| 665 |
+
|
| 666 |
+
const omittedMessages = workingMessages.slice(0, workingMessages.length - keptMessages.length);
|
| 667 |
+
const summaryMessage = buildCompactionSummaryMessage(omittedMessages);
|
| 668 |
+
if (summaryMessage) {
|
| 669 |
+
const withSummary = [...messages, summaryMessage, ...keptMessages];
|
| 670 |
+
if (countMessageTokens(withSummary) <= WORKING_PROMPT_BUDGET) {
|
| 671 |
+
messages.push(summaryMessage);
|
| 672 |
+
}
|
| 673 |
+
}
|
| 674 |
+
|
| 675 |
+
messages.push(...keptMessages);
|
| 676 |
+
return messages;
|
| 677 |
+
}
|
| 678 |
+
|
| 679 |
+
function prepareHistoryContext(history, state) {
|
| 680 |
+
const normalizedHistory = history.filter(Boolean);
|
| 681 |
+
if (!normalizedHistory.length) {
|
| 682 |
+
return { summaryMessages: [], recentMessages: [] };
|
| 683 |
+
}
|
| 684 |
+
|
| 685 |
+
const recentMessages = [];
|
| 686 |
+
let recentTokens = 0;
|
| 687 |
+
|
| 688 |
+
for (let i = normalizedHistory.length - 1; i >= 0; i--) {
|
| 689 |
+
const candidate = normalizedHistory[i];
|
| 690 |
+
const candidateTokens = countMessageTokens([candidate]);
|
| 691 |
+
if (
|
| 692 |
+
recentMessages.length >= RECENT_HISTORY_MAX_MESSAGES ||
|
| 693 |
+
recentTokens + candidateTokens > RECENT_HISTORY_TOKEN_BUDGET
|
| 694 |
+
) {
|
| 695 |
+
break;
|
| 696 |
+
}
|
| 697 |
+
recentMessages.unshift(candidate);
|
| 698 |
+
recentTokens += candidateTokens;
|
| 699 |
+
}
|
| 700 |
+
|
| 701 |
+
const olderMessages = normalizedHistory.slice(0, normalizedHistory.length - recentMessages.length);
|
| 702 |
+
if (!olderMessages.length) {
|
| 703 |
+
return { summaryMessages: [], recentMessages };
|
| 704 |
+
}
|
| 705 |
+
|
| 706 |
+
const historyResource = registerTextResource(state, {
|
| 707 |
+
kind: "history",
|
| 708 |
+
name: "Earlier conversation",
|
| 709 |
+
text: messagesToTranscript(olderMessages),
|
| 710 |
+
});
|
| 711 |
+
|
| 712 |
+
const summaryMessages = historyResource
|
| 713 |
+
? [
|
| 714 |
+
{
|
| 715 |
+
role: "system",
|
| 716 |
+
content: [
|
| 717 |
+
"Earlier conversation was condensed to keep the live prompt smaller.",
|
| 718 |
+
`Summary:\n${buildHistorySummary(olderMessages)}`,
|
| 719 |
+
`If exact earlier wording matters, use \`read_prompt_chunk\` with resource_id "${historyResource.id}".`,
|
| 720 |
+
].join("\n\n"),
|
| 721 |
+
},
|
| 722 |
+
]
|
| 723 |
+
: [];
|
| 724 |
+
|
| 725 |
+
return { summaryMessages, recentMessages };
|
| 726 |
+
}
|
| 727 |
+
|
| 728 |
+
function prepareCurrentUserContext(userMessage, state) {
|
| 729 |
+
const attachments = [];
|
| 730 |
+
const imageBlocks = [];
|
| 731 |
+
const textParts = [];
|
| 732 |
+
|
| 733 |
+
if (typeof userMessage === "string") {
|
| 734 |
+
const parsed = parseTextAttachmentsFromDetails(userMessage);
|
| 735 |
+
if (parsed.text) textParts.push(parsed.text);
|
| 736 |
+
attachments.push(...parsed.attachments);
|
| 737 |
+
} else if (Array.isArray(userMessage)) {
|
| 738 |
+
for (const item of userMessage) {
|
| 739 |
+
if (item?.type === "text" && item.text?.trim()) {
|
| 740 |
+
const parsed = parseTextAttachmentsFromDetails(item.text);
|
| 741 |
+
if (parsed.text) textParts.push(parsed.text);
|
| 742 |
+
attachments.push(...parsed.attachments);
|
| 743 |
+
} else if (item?.type === "image_url") {
|
| 744 |
+
imageBlocks.push(item);
|
| 745 |
+
}
|
| 746 |
+
}
|
| 747 |
+
}
|
| 748 |
+
|
| 749 |
+
const rawPrimaryText = textParts.join("\n\n").trim();
|
| 750 |
+
const fallbackText = imageBlocks.length
|
| 751 |
+
? "[Image(s) attached]"
|
| 752 |
+
: attachments.length
|
| 753 |
+
? "[Text attachment(s) attached]"
|
| 754 |
+
: "";
|
| 755 |
+
const primaryText = rawPrimaryText || fallbackText;
|
| 756 |
+
|
| 757 |
+
const { head: inlineTextHead, tail: overflowText } = splitTextByTokenLimit(
|
| 758 |
+
primaryText,
|
| 759 |
+
INLINE_USER_PROMPT_TOKENS
|
| 760 |
+
);
|
| 761 |
+
|
| 762 |
+
if (overflowText.trim()) {
|
| 763 |
+
registerTextResource(state, {
|
| 764 |
+
kind: "prompt",
|
| 765 |
+
name: "Current user prompt continuation",
|
| 766 |
+
text: overflowText,
|
| 767 |
+
});
|
| 768 |
+
}
|
| 769 |
+
|
| 770 |
+
for (const attachment of attachments) {
|
| 771 |
+
registerTextResource(state, {
|
| 772 |
+
kind: "attachment",
|
| 773 |
+
name: attachment.name,
|
| 774 |
+
text: attachment.content,
|
| 775 |
+
});
|
| 776 |
+
}
|
| 777 |
+
|
| 778 |
+
let inlineImages = imageBlocks;
|
| 779 |
+
if (imageBlocks.length > MAX_INLINE_IMAGES) {
|
| 780 |
+
inlineImages = imageBlocks.slice(0, MAX_INLINE_IMAGES);
|
| 781 |
+
registerImageResource(state, {
|
| 782 |
+
name: "Current user images",
|
| 783 |
+
images: imageBlocks,
|
| 784 |
+
inlineImageCount: inlineImages.length,
|
| 785 |
+
});
|
| 786 |
+
}
|
| 787 |
+
|
| 788 |
+
const stagingNotes = [];
|
| 789 |
+
if (overflowText.trim()) {
|
| 790 |
+
stagingNotes.push(`Only the first ${INLINE_USER_PROMPT_TOKENS} tokens of the current prompt are inline.`);
|
| 791 |
+
}
|
| 792 |
+
if (attachments.length) {
|
| 793 |
+
stagingNotes.push(`${attachments.length} text attachment(s) were staged separately.`);
|
| 794 |
+
}
|
| 795 |
+
if (imageBlocks.length > inlineImages.length) {
|
| 796 |
+
stagingNotes.push(`${imageBlocks.length - inlineImages.length} additional image(s) were staged separately.`);
|
| 797 |
+
}
|
| 798 |
+
|
| 799 |
+
const inlineText = [
|
| 800 |
+
inlineTextHead.trim() || fallbackText,
|
| 801 |
+
stagingNotes.length
|
| 802 |
+
? `[Context note: ${stagingNotes.join(" ")} See the staged context guide for resource IDs.]`
|
| 803 |
+
: null,
|
| 804 |
+
]
|
| 805 |
+
.filter(Boolean)
|
| 806 |
+
.join("\n\n");
|
| 807 |
+
|
| 808 |
+
const userMessages = [];
|
| 809 |
+
if (Array.isArray(userMessage) || inlineImages.length) {
|
| 810 |
+
userMessages.push({
|
| 811 |
+
role: "user",
|
| 812 |
+
content: [
|
| 813 |
+
{ type: "text", text: inlineText || "[Image(s) attached]" },
|
| 814 |
+
...inlineImages,
|
| 815 |
+
],
|
| 816 |
+
});
|
| 817 |
+
} else if (inlineText) {
|
| 818 |
+
userMessages.push({ role: "user", content: inlineText });
|
| 819 |
+
}
|
| 820 |
+
|
| 821 |
+
const contextMessages = state.resources.length
|
| 822 |
+
? [
|
| 823 |
+
{
|
| 824 |
+
role: "system",
|
| 825 |
+
content: [
|
| 826 |
+
"Large prompt context was staged into smaller pieces.",
|
| 827 |
+
"Those staged resources are available context, not missing text.",
|
| 828 |
+
"Start with the inline user message, then use tools to inspect omitted prompt text, attachments, history, or images.",
|
| 829 |
+
"Use `list_prompt_resources` for the full manifest.",
|
| 830 |
+
"Use `read_prompt_chunk` for exact text.",
|
| 831 |
+
state.resources.some((resource) => resource.kind === "images")
|
| 832 |
+
? "Use `load_prompt_images` to inspect omitted images in smaller batches."
|
| 833 |
+
: null,
|
| 834 |
+
"After reading multiple chunks, use `write_notes` to keep a compact working memory.",
|
| 835 |
+
"",
|
| 836 |
+
"Staged resources:",
|
| 837 |
+
...state.resources.map(formatResourceManifestLine),
|
| 838 |
+
]
|
| 839 |
+
.filter(Boolean)
|
| 840 |
+
.join("\n"),
|
| 841 |
+
},
|
| 842 |
+
]
|
| 843 |
+
: [];
|
| 844 |
+
|
| 845 |
+
return { contextMessages, userMessages };
|
| 846 |
+
}
|
| 847 |
+
|
| 848 |
+
function buildMemorySystemMessages(memories = [], sessionName = "") {
|
| 849 |
+
const messages = [];
|
| 850 |
+
if (sessionName) {
|
| 851 |
+
messages.push({
|
| 852 |
+
role: "system",
|
| 853 |
+
content: `Current session name: "${sessionName}". This is hidden metadata and may help with continuity if the user references the chat title.`,
|
| 854 |
+
});
|
| 855 |
+
}
|
| 856 |
+
if (memories.length) {
|
| 857 |
+
messages.push({
|
| 858 |
+
role: "system",
|
| 859 |
+
content: [
|
| 860 |
+
"Persistent memories from earlier chats:",
|
| 861 |
+
...memories.map((memory, index) => `${index + 1}. ${memory.content}`),
|
| 862 |
+
"Treat these as concise background notes. If one is outdated or wrong, prefer the user's current message.",
|
| 863 |
+
].join("\n"),
|
| 864 |
+
});
|
| 865 |
+
}
|
| 866 |
+
return messages;
|
| 867 |
+
}
|
| 868 |
+
|
| 869 |
+
function buildBasePromptMessages({ sessionId, history, userMessage, memories = [], sessionName = "", systemPrompt = "" }) {
|
| 870 |
+
const state = createPromptState(sessionId);
|
| 871 |
+
const normalizedHistory = history.map(normalizeMessage).filter(Boolean);
|
| 872 |
+
const { summaryMessages, recentMessages } = prepareHistoryContext(normalizedHistory, state);
|
| 873 |
+
const { contextMessages, userMessages } = prepareCurrentUserContext(userMessage, state);
|
| 874 |
+
|
| 875 |
+
return [
|
| 876 |
+
{ role: "system", content: systemPrompt || SYSTEM_PROMPT },
|
| 877 |
+
...buildMemorySystemMessages(memories, sessionName),
|
| 878 |
+
...summaryMessages,
|
| 879 |
+
...recentMessages,
|
| 880 |
+
...contextMessages,
|
| 881 |
+
...userMessages,
|
| 882 |
+
];
|
| 883 |
+
}
|
| 884 |
+
|
| 885 |
+
class RetryableRateLimitError extends Error {
|
| 886 |
+
constructor(message, retryAfterMs, internalMessage = null) {
|
| 887 |
+
super(message);
|
| 888 |
+
this.name = "RetryableRateLimitError";
|
| 889 |
+
this.retryAfterMs = retryAfterMs;
|
| 890 |
+
this.internalMessage = internalMessage || message;
|
| 891 |
+
this.publicMessage = "The model provider is temporarily rate limited. Retrying automatically.";
|
| 892 |
+
}
|
| 893 |
+
}
|
| 894 |
+
|
| 895 |
+
class UpstreamProviderError extends Error {
|
| 896 |
+
constructor(publicMessage, internalMessage = null) {
|
| 897 |
+
super(publicMessage);
|
| 898 |
+
this.name = "UpstreamProviderError";
|
| 899 |
+
this.publicMessage = publicMessage;
|
| 900 |
+
this.internalMessage = internalMessage || publicMessage;
|
| 901 |
+
}
|
| 902 |
+
}
|
| 903 |
+
|
| 904 |
+
function getErrorText(errorPayload) {
|
| 905 |
+
if (typeof errorPayload === "string") {
|
| 906 |
+
let text = errorPayload.trim();
|
| 907 |
+
|
| 908 |
+
if (text.startsWith("[ERROR]")) {
|
| 909 |
+
text = text.slice("[ERROR]".length).trim();
|
| 910 |
+
}
|
| 911 |
+
|
| 912 |
+
if (
|
| 913 |
+
(text.startsWith("\"") && text.endsWith("\"")) ||
|
| 914 |
+
(text.startsWith("'") && text.endsWith("'"))
|
| 915 |
+
) {
|
| 916 |
+
try {
|
| 917 |
+
const parsed = JSON.parse(text);
|
| 918 |
+
if (typeof parsed === "string") {
|
| 919 |
+
text = parsed;
|
| 920 |
+
}
|
| 921 |
+
} catch {
|
| 922 |
+
// Keep the original text if it is not valid JSON.
|
| 923 |
+
}
|
| 924 |
+
}
|
| 925 |
+
|
| 926 |
+
return text;
|
| 927 |
+
}
|
| 928 |
+
if (!errorPayload) return "";
|
| 929 |
+
if (typeof errorPayload.message === "string") return errorPayload.message;
|
| 930 |
+
try {
|
| 931 |
+
return JSON.stringify(errorPayload);
|
| 932 |
+
} catch {
|
| 933 |
+
return String(errorPayload);
|
| 934 |
+
}
|
| 935 |
+
}
|
| 936 |
+
|
| 937 |
+
function isRateLimitError(errorPayload) {
|
| 938 |
+
const text = getErrorText(errorPayload).toLowerCase();
|
| 939 |
+
const code = String(errorPayload?.code || errorPayload?.error?.code || "").toLowerCase();
|
| 940 |
+
const type = String(errorPayload?.type || errorPayload?.error?.type || "").toLowerCase();
|
| 941 |
+
const status = Number(errorPayload?.status || errorPayload?.error?.status || 0);
|
| 942 |
+
|
| 943 |
+
return (
|
| 944 |
+
status === 429 ||
|
| 945 |
+
code === "rate_limit_exceeded" ||
|
| 946 |
+
type === "tokens" ||
|
| 947 |
+
text.includes("rate limit") ||
|
| 948 |
+
text.includes("rate limit reached") ||
|
| 949 |
+
text.includes("rate_limit_exceeded") ||
|
| 950 |
+
text.includes("upstream provider error (429)") ||
|
| 951 |
+
text.includes("(429)") ||
|
| 952 |
+
text.includes(" 429")
|
| 953 |
+
);
|
| 954 |
+
}
|
| 955 |
+
|
| 956 |
+
function isEmbeddedProviderErrorText(text) {
|
| 957 |
+
const normalized = getErrorText(text).toLowerCase();
|
| 958 |
+
return (
|
| 959 |
+
normalized.startsWith("upstream provider error") ||
|
| 960 |
+
normalized.startsWith("provider error") ||
|
| 961 |
+
normalized.includes("upstream provider error") ||
|
| 962 |
+
normalized.includes("rate_limit_exceeded") ||
|
| 963 |
+
normalized.includes("rate limit reached")
|
| 964 |
+
);
|
| 965 |
+
}
|
| 966 |
+
|
| 967 |
+
function extractRetryAfterMs(errorPayload) {
|
| 968 |
+
const text = getErrorText(errorPayload);
|
| 969 |
+
const directValue = Number(
|
| 970 |
+
errorPayload?.retryAfterMs ??
|
| 971 |
+
errorPayload?.retry_after_ms ??
|
| 972 |
+
errorPayload?.retry_after ??
|
| 973 |
+
errorPayload?.error?.retryAfterMs ??
|
| 974 |
+
errorPayload?.error?.retry_after_ms ??
|
| 975 |
+
errorPayload?.error?.retry_after
|
| 976 |
+
);
|
| 977 |
+
|
| 978 |
+
if (Number.isFinite(directValue) && directValue > 0) {
|
| 979 |
+
return directValue > 100 ? directValue : directValue * 1000;
|
| 980 |
+
}
|
| 981 |
+
|
| 982 |
+
const secondsMatch =
|
| 983 |
+
text.match(/try again in\s*([\d.]+)\s*s/i) ||
|
| 984 |
+
text.match(/retry after\s*([\d.]+)\s*s/i) ||
|
| 985 |
+
text.match(/in\s*([\d.]+)\s*seconds?/i);
|
| 986 |
+
if (secondsMatch) {
|
| 987 |
+
return Math.ceil(Number(secondsMatch[1]) * 1000);
|
| 988 |
+
}
|
| 989 |
+
|
| 990 |
+
const millisecondsMatch = text.match(/retry after\s*([\d.]+)\s*ms/i);
|
| 991 |
+
if (millisecondsMatch) {
|
| 992 |
+
return Math.ceil(Number(millisecondsMatch[1]));
|
| 993 |
+
}
|
| 994 |
+
|
| 995 |
+
return null;
|
| 996 |
+
}
|
| 997 |
+
|
| 998 |
+
function createAbortError() {
|
| 999 |
+
const err = new Error("AbortError");
|
| 1000 |
+
err.name = "AbortError";
|
| 1001 |
+
return err;
|
| 1002 |
+
}
|
| 1003 |
+
|
| 1004 |
+
async function sleepWithAbort(ms, abortSignal) {
|
| 1005 |
+
if (!ms || ms <= 0) return;
|
| 1006 |
+
if (abortSignal?.aborted) throw createAbortError();
|
| 1007 |
+
|
| 1008 |
+
await new Promise((resolve, reject) => {
|
| 1009 |
+
const timer = setTimeout(() => {
|
| 1010 |
+
abortSignal?.removeEventListener("abort", onAbort);
|
| 1011 |
+
resolve();
|
| 1012 |
+
}, ms);
|
| 1013 |
+
|
| 1014 |
+
const onAbort = () => {
|
| 1015 |
+
clearTimeout(timer);
|
| 1016 |
+
abortSignal?.removeEventListener("abort", onAbort);
|
| 1017 |
+
reject(createAbortError());
|
| 1018 |
+
};
|
| 1019 |
+
|
| 1020 |
+
abortSignal?.addEventListener("abort", onAbort, { once: true });
|
| 1021 |
+
});
|
| 1022 |
+
}
|
| 1023 |
+
|
| 1024 |
+
function getRetryDelayMs(error, retryIndex) {
|
| 1025 |
+
const hintedDelay = Number(error?.retryAfterMs);
|
| 1026 |
+
if (Number.isFinite(hintedDelay) && hintedDelay > 0) {
|
| 1027 |
+
return Math.min(MAX_UPSTREAM_RETRY_MS, hintedDelay + UPSTREAM_RETRY_BUFFER_MS);
|
| 1028 |
+
}
|
| 1029 |
+
|
| 1030 |
+
return Math.min(
|
| 1031 |
+
MAX_UPSTREAM_RETRY_MS,
|
| 1032 |
+
DEFAULT_UPSTREAM_RETRY_MS * Math.max(1, retryIndex + 1)
|
| 1033 |
+
);
|
| 1034 |
+
}
|
| 1035 |
+
|
| 1036 |
+
function getPublicErrorMessage(err) {
|
| 1037 |
+
if (!err) return "The request failed.";
|
| 1038 |
+
if (err.name === "RetryableRateLimitError") {
|
| 1039 |
+
return "The model provider is temporarily rate limited. Please try again in a few seconds.";
|
| 1040 |
+
}
|
| 1041 |
+
if (err.publicMessage) return err.publicMessage;
|
| 1042 |
+
return String(err);
|
| 1043 |
+
}
|
| 1044 |
+
|
| 1045 |
+
async function websocketChatStreamWithRetry(body, headers, onToken, abortSignal) {
|
| 1046 |
+
for (let retryIndex = 0; ; retryIndex++) {
|
| 1047 |
+
try {
|
| 1048 |
+
return await websocketChatStream(body, headers, onToken, abortSignal);
|
| 1049 |
+
} catch (err) {
|
| 1050 |
+
if (err?.name === "AbortError") throw err;
|
| 1051 |
+
|
| 1052 |
+
const retryable = err?.name === "RetryableRateLimitError";
|
| 1053 |
+
if (!retryable || retryIndex >= MAX_UPSTREAM_RATE_LIMIT_RETRIES) {
|
| 1054 |
+
throw err;
|
| 1055 |
+
}
|
| 1056 |
+
|
| 1057 |
+
const waitMs = getRetryDelayMs(err, retryIndex);
|
| 1058 |
+
console.warn(
|
| 1059 |
+
`[streamChat] Upstream rate limited, retrying in ${waitMs}ms (${retryIndex + 1}/${MAX_UPSTREAM_RATE_LIMIT_RETRIES})`
|
| 1060 |
+
);
|
| 1061 |
+
await sleepWithAbort(waitMs, abortSignal);
|
| 1062 |
+
}
|
| 1063 |
+
}
|
| 1064 |
+
}
|
| 1065 |
+
|
| 1066 |
+
function serializeToolCalls(toolCallBuffer) {
|
| 1067 |
+
return [...toolCallBuffer.values()]
|
| 1068 |
+
.filter((toolCall) => toolCall?.name)
|
| 1069 |
+
.map((toolCall) => ({
|
| 1070 |
+
id: toolCall.id || `call_${crypto.randomUUID()}`,
|
| 1071 |
+
type: "function",
|
| 1072 |
+
function: { name: toolCall.name, arguments: toolCall.arguments },
|
| 1073 |
+
}));
|
| 1074 |
+
}
|
| 1075 |
+
|
| 1076 |
export async function websocketChatStream(body, headers, onToken, abortSignal) {
|
| 1077 |
const ws = await getSafeWebSocket();
|
| 1078 |
const currentRequestId = ++requestIdCounter;
|
| 1079 |
+
const allowedToolNames = getAllowedToolNames(body?.tools);
|
| 1080 |
const safeParse = (str) => {
|
| 1081 |
try { return JSON.parse(str.startsWith("data: ") ? str.slice(6) : str); } catch { return null; }
|
| 1082 |
};
|
|
|
|
| 1090 |
if (!finished) {
|
| 1091 |
finished = true;
|
| 1092 |
cleanup();
|
| 1093 |
+
const toolCalls = serializeToolCalls(toolCallBuffer);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1094 |
resolve({ assistantText, toolCalls });
|
| 1095 |
}
|
| 1096 |
}, 120000);
|
|
|
|
| 1111 |
if (!payload) return;
|
| 1112 |
|
| 1113 |
if (payload.error && !payload.choices) {
|
| 1114 |
+
if (!finished) {
|
| 1115 |
+
finished = true;
|
| 1116 |
+
cleanup();
|
| 1117 |
+
|
| 1118 |
+
const errorText = getErrorText(payload.error);
|
| 1119 |
+
const hasPartialOutput = assistantText.length > 0 || toolCallBuffer.size > 0;
|
| 1120 |
+
|
| 1121 |
+
if (!hasPartialOutput && isRateLimitError(payload.error)) {
|
| 1122 |
+
reject(
|
| 1123 |
+
new RetryableRateLimitError(
|
| 1124 |
+
"The model provider is temporarily rate limited.",
|
| 1125 |
+
extractRetryAfterMs(payload.error),
|
| 1126 |
+
errorText
|
| 1127 |
+
)
|
| 1128 |
+
);
|
| 1129 |
+
} else {
|
| 1130 |
+
reject(
|
| 1131 |
+
new UpstreamProviderError(
|
| 1132 |
+
hasPartialOutput
|
| 1133 |
+
? "The model provider interrupted the response. Please try again."
|
| 1134 |
+
: "The model provider returned an error. Please try again.",
|
| 1135 |
+
errorText
|
| 1136 |
+
)
|
| 1137 |
+
);
|
| 1138 |
+
}
|
| 1139 |
+
}
|
| 1140 |
return;
|
| 1141 |
}
|
| 1142 |
|
| 1143 |
const delta = payload.choices?.[0]?.delta;
|
| 1144 |
+
if (delta?.content) {
|
| 1145 |
+
const contentText = String(delta.content);
|
| 1146 |
+
|
| 1147 |
+
if (isEmbeddedProviderErrorText(contentText)) {
|
| 1148 |
+
if (!finished) {
|
| 1149 |
+
finished = true;
|
| 1150 |
+
cleanup();
|
| 1151 |
+
|
| 1152 |
+
const errorText = getErrorText(contentText);
|
| 1153 |
+
const hasPartialOutput = assistantText.trim().length > 0 || toolCallBuffer.size > 0;
|
| 1154 |
+
|
| 1155 |
+
if (!hasPartialOutput && isRateLimitError(errorText)) {
|
| 1156 |
+
reject(
|
| 1157 |
+
new RetryableRateLimitError(
|
| 1158 |
+
"The model provider is temporarily rate limited.",
|
| 1159 |
+
extractRetryAfterMs(errorText),
|
| 1160 |
+
errorText
|
| 1161 |
+
)
|
| 1162 |
+
);
|
| 1163 |
+
} else {
|
| 1164 |
+
reject(
|
| 1165 |
+
new UpstreamProviderError(
|
| 1166 |
+
hasPartialOutput
|
| 1167 |
+
? "The model provider interrupted the response. Please try again."
|
| 1168 |
+
: "The model provider returned an error. Please try again.",
|
| 1169 |
+
errorText
|
| 1170 |
+
)
|
| 1171 |
+
);
|
| 1172 |
+
}
|
| 1173 |
+
}
|
| 1174 |
+
return;
|
| 1175 |
+
}
|
| 1176 |
+
|
| 1177 |
+
assistantText += contentText;
|
| 1178 |
+
if (onToken) onToken(contentText);
|
| 1179 |
+
}
|
| 1180 |
|
| 1181 |
if (delta?.tool_calls) {
|
| 1182 |
for (const call of delta.tool_calls) {
|
| 1183 |
const entry = toolCallBuffer.get(call.index) ?? { arguments: "" };
|
| 1184 |
if (call.id) entry.id = call.id;
|
| 1185 |
+
if (call.function?.name) {
|
| 1186 |
+
entry.name = sanitizeToolName(call.function.name, allowedToolNames);
|
| 1187 |
+
}
|
| 1188 |
if (call.function?.arguments) entry.arguments += call.function.arguments;
|
| 1189 |
toolCallBuffer.set(call.index, entry);
|
| 1190 |
}
|
|
|
|
| 1193 |
if (payload.choices?.[0]?.finish_reason && !finished) {
|
| 1194 |
finished = true;
|
| 1195 |
cleanup();
|
| 1196 |
+
const toolCalls = serializeToolCalls(toolCallBuffer);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1197 |
resolve({ assistantText, toolCalls });
|
| 1198 |
}
|
| 1199 |
};
|
|
|
|
| 1232 |
history = [],
|
| 1233 |
userMessage,
|
| 1234 |
tools,
|
| 1235 |
+
owner = null,
|
| 1236 |
+
sessionName = "",
|
| 1237 |
accessToken,
|
| 1238 |
clientId,
|
| 1239 |
+
webSearchLimit = null,
|
| 1240 |
onToken = () => {},
|
| 1241 |
onDone = () => {},
|
| 1242 |
onError = () => {},
|
| 1243 |
onToolCall = () => {},
|
| 1244 |
onNewAsset = () => {},
|
| 1245 |
+
onDraftEdit = () => {},
|
| 1246 |
abortSignal,
|
| 1247 |
}) {
|
|
|
|
| 1248 |
const enabledTools = buildToolList(tools);
|
| 1249 |
+
const [memories, systemPrompt] = await Promise.all([
|
| 1250 |
+
owner ? memoryStore.list(owner).catch(() => []) : [],
|
| 1251 |
+
owner?.type === "user"
|
| 1252 |
+
? systemPromptStore.getResolvedPrompt(owner.id)
|
| 1253 |
+
: systemPromptStore.getDefaultPrompt(),
|
| 1254 |
+
]);
|
| 1255 |
+
const baseMessages = buildBasePromptMessages({
|
| 1256 |
+
sessionId,
|
| 1257 |
+
history,
|
| 1258 |
+
userMessage,
|
| 1259 |
+
memories,
|
| 1260 |
+
sessionName,
|
| 1261 |
+
systemPrompt,
|
| 1262 |
+
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1263 |
|
| 1264 |
const headers = {
|
| 1265 |
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
|
|
|
| 1267 |
};
|
| 1268 |
|
| 1269 |
try {
|
| 1270 |
+
let assistantText = "";
|
| 1271 |
+
let agentStep = 0;
|
| 1272 |
+
let finished = false;
|
| 1273 |
+
const workingMessages = [];
|
| 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);
|
| 1281 |
+
const body = {
|
| 1282 |
+
model: model || "lightning",
|
| 1283 |
+
messages: effectiveMessages,
|
| 1284 |
+
tools: enabledTools.length ? enabledTools : undefined,
|
| 1285 |
+
stream: true,
|
| 1286 |
+
};
|
| 1287 |
|
| 1288 |
+
const { assistantText: stepText, toolCalls } = await websocketChatStreamWithRetry(
|
| 1289 |
+
body,
|
| 1290 |
+
headers,
|
| 1291 |
+
onToken,
|
| 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 || "",
|
| 1312 |
+
tool_calls: toolCalls,
|
| 1313 |
+
});
|
| 1314 |
|
| 1315 |
+
const { nextMessages } = await processToolCalls({
|
| 1316 |
+
sessionId,
|
| 1317 |
+
toolCalls,
|
| 1318 |
+
tools,
|
| 1319 |
+
owner,
|
| 1320 |
+
accessToken,
|
| 1321 |
+
clientId,
|
| 1322 |
+
webSearchLimit,
|
| 1323 |
+
abortSignal,
|
| 1324 |
+
draftState,
|
| 1325 |
+
onToolCall,
|
| 1326 |
+
onNewAsset,
|
| 1327 |
+
onDraftEdit(edit, text) {
|
| 1328 |
+
responseEdits.push(edit);
|
| 1329 |
+
onDraftEdit(edit, text);
|
| 1330 |
+
},
|
| 1331 |
});
|
| 1332 |
+
assistantText = draftState.text || assistantText;
|
| 1333 |
+
workingMessages.push(...nextMessages);
|
| 1334 |
+
|
| 1335 |
+
agentStep++;
|
| 1336 |
+
continue;
|
| 1337 |
+
} else {
|
| 1338 |
+
if (stepText) {
|
| 1339 |
+
workingMessages.push({ role: "assistant", content: stepText });
|
| 1340 |
+
}
|
| 1341 |
+
finished = true;
|
| 1342 |
}
|
| 1343 |
+
}
|
| 1344 |
|
| 1345 |
+
if (!finished) {
|
| 1346 |
+
const finalMessages = [
|
| 1347 |
+
...buildModelMessages(baseMessages, workingMessages, sessionId),
|
| 1348 |
{
|
| 1349 |
+
role: "system",
|
| 1350 |
+
content: "Tool-use budget is exhausted for this response. Do not call tools. Answer directly using the information already gathered. If something is still missing, briefly say what is missing without calling tools.",
|
|
|
|
| 1351 |
},
|
| 1352 |
+
];
|
|
|
|
| 1353 |
|
| 1354 |
+
const { assistantText: finalStepText } = await websocketChatStreamWithRetry(
|
| 1355 |
+
{
|
| 1356 |
+
model: model || "lightning",
|
| 1357 |
+
messages: finalMessages,
|
| 1358 |
+
stream: true,
|
| 1359 |
+
},
|
| 1360 |
+
headers,
|
| 1361 |
+
onToken,
|
| 1362 |
+
abortSignal
|
| 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 });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1370 |
}
|
| 1371 |
+
finished = true;
|
| 1372 |
+
}
|
| 1373 |
+
|
| 1374 |
+
if (!assistantText.trim()) {
|
| 1375 |
+
assistantText = "I wasn’t able to finish that response cleanly. Please try again.";
|
| 1376 |
}
|
| 1377 |
|
| 1378 |
const sessionName = extractSessionName(assistantText);
|
| 1379 |
|
| 1380 |
if (typeof onDone === "function") {
|
| 1381 |
+
onDone(assistantText, allToolCalls, false, sessionName, responseEdits, responseSegments);
|
| 1382 |
}
|
| 1383 |
|
| 1384 |
+
clearPromptState(sessionId);
|
| 1385 |
|
| 1386 |
+
} catch (err) {
|
| 1387 |
+
console.error("streamChat error:", err?.internalMessage || err);
|
| 1388 |
+
clearPromptState(sessionId);
|
| 1389 |
+
if (err.name === "AbortError" || err.message === "AbortError") {
|
| 1390 |
+
if (typeof onDone === "function") onDone(null, null, true, null);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1391 |
} else {
|
| 1392 |
+
if (typeof onError === "function") onError(getPublicErrorMessage(err));
|
|
|
|
|
|
|
|
|
|
| 1393 |
}
|
| 1394 |
}
|
| 1395 |
}
|
|
|
|
| 1399 |
function normalizeMessage(msg) {
|
| 1400 |
if (!VALID_ROLES.has(msg.role)) return null;
|
| 1401 |
|
| 1402 |
+
if (msg.role === "assistant" && (msg.tool_calls || msg.toolCalls)) {
|
| 1403 |
+
const normalizedToolCalls = (msg.tool_calls || normalizeStoredToolCalls(msg.toolCalls))
|
| 1404 |
+
.map((call) => ({
|
| 1405 |
+
...call,
|
| 1406 |
+
function: {
|
| 1407 |
+
...call.function,
|
| 1408 |
+
name: sanitizeToolName(call.function?.name || call.name || "unknown_tool"),
|
| 1409 |
+
},
|
| 1410 |
+
}))
|
| 1411 |
+
.filter((call) => call.function?.name);
|
| 1412 |
+
|
| 1413 |
+
return {
|
| 1414 |
+
role: "assistant",
|
| 1415 |
+
content: msg.content ?? "",
|
| 1416 |
+
...(normalizedToolCalls.length ? { tool_calls: normalizedToolCalls } : {}),
|
| 1417 |
+
};
|
| 1418 |
}
|
| 1419 |
if (Array.isArray(msg.content)) {
|
| 1420 |
// If the array contains images, preserve the full array format
|
|
|
|
| 1433 |
}
|
| 1434 |
|
| 1435 |
function buildToolList(tools) {
|
| 1436 |
+
const config = tools || {};
|
| 1437 |
const list = [];
|
| 1438 |
+
list.push({
|
| 1439 |
+
type: "function",
|
| 1440 |
+
function: {
|
| 1441 |
+
name: "list_prompt_resources",
|
| 1442 |
+
description: "List the staged prompt resources, including omitted prompt text, attachments, images, and condensed history",
|
| 1443 |
+
parameters: {
|
| 1444 |
+
type: "object",
|
| 1445 |
+
properties: {},
|
| 1446 |
+
},
|
| 1447 |
+
},
|
| 1448 |
+
});
|
| 1449 |
+
|
| 1450 |
+
list.push({
|
| 1451 |
+
type: "function",
|
| 1452 |
+
function: {
|
| 1453 |
+
name: "read_prompt_chunk",
|
| 1454 |
+
description: "Read an exact text chunk from a staged prompt resource",
|
| 1455 |
+
parameters: {
|
| 1456 |
+
type: "object",
|
| 1457 |
+
properties: {
|
| 1458 |
+
resource_id: { type: "string", description: "The resource id from list_prompt_resources" },
|
| 1459 |
+
chunk_index: { type: "number", description: "Zero-based chunk index" },
|
| 1460 |
+
},
|
| 1461 |
+
required: ["resource_id", "chunk_index"]
|
| 1462 |
+
},
|
| 1463 |
+
},
|
| 1464 |
+
});
|
| 1465 |
+
|
| 1466 |
+
list.push({
|
| 1467 |
+
type: "function",
|
| 1468 |
+
function: {
|
| 1469 |
+
name: "load_prompt_images",
|
| 1470 |
+
description: "Load staged images into the next model step in a smaller batch",
|
| 1471 |
+
parameters: {
|
| 1472 |
+
type: "object",
|
| 1473 |
+
properties: {
|
| 1474 |
+
resource_id: { type: "string", description: "The staged image resource id" },
|
| 1475 |
+
indexes: { type: "array", items: { type: "number" }, description: "Zero-based image indexes to load" },
|
| 1476 |
+
start_index: { type: "number", description: "Optional zero-based starting image index when indexes is omitted" },
|
| 1477 |
+
count: { type: "number", description: "Optional number of images to load when indexes is omitted" },
|
| 1478 |
+
},
|
| 1479 |
+
required: ["resource_id"]
|
| 1480 |
+
},
|
| 1481 |
+
},
|
| 1482 |
+
});
|
| 1483 |
+
|
| 1484 |
+
list.push({
|
| 1485 |
+
type: "function",
|
| 1486 |
+
function: {
|
| 1487 |
+
name: "write_notes",
|
| 1488 |
+
description: "Write notes to memory for later reasoning",
|
| 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: {
|
| 1520 |
+
name: "list_memories",
|
| 1521 |
+
description: "List the currently saved persistent memories for this user.",
|
| 1522 |
+
parameters: {
|
| 1523 |
+
type: "object",
|
| 1524 |
+
properties: {},
|
| 1525 |
+
},
|
| 1526 |
+
},
|
| 1527 |
+
});
|
| 1528 |
+
list.push({
|
| 1529 |
+
type: "function",
|
| 1530 |
+
function: {
|
| 1531 |
+
name: "save_memory",
|
| 1532 |
+
description: "Save a short persistent memory that may help in future chats. Keep it brief and only use this for durable user preferences or facts.",
|
| 1533 |
+
parameters: {
|
| 1534 |
+
type: "object",
|
| 1535 |
+
properties: {
|
| 1536 |
+
content: { type: "string", description: "A short durable memory, ideally one sentence or shorter." },
|
| 1537 |
+
},
|
| 1538 |
+
required: ["content"],
|
| 1539 |
+
},
|
| 1540 |
+
},
|
| 1541 |
+
});
|
| 1542 |
+
list.push({
|
| 1543 |
+
type: "function",
|
| 1544 |
+
function: {
|
| 1545 |
+
name: "delete_memory",
|
| 1546 |
+
description: "Delete a previously saved persistent memory when it is outdated or incorrect.",
|
| 1547 |
+
parameters: {
|
| 1548 |
+
type: "object",
|
| 1549 |
+
properties: {
|
| 1550 |
+
memory_id: { type: "string", description: "The memory id to delete." },
|
| 1551 |
+
},
|
| 1552 |
+
required: ["memory_id"],
|
| 1553 |
+
},
|
| 1554 |
+
},
|
| 1555 |
+
});
|
| 1556 |
+
list.push({
|
| 1557 |
+
type: "function",
|
| 1558 |
+
function: {
|
| 1559 |
+
name: "edit_response_draft",
|
| 1560 |
+
description: "Revise text that was already streamed to the user. Use this to correct or remove visible text that has already appeared.",
|
| 1561 |
+
parameters: {
|
| 1562 |
+
type: "object",
|
| 1563 |
+
properties: {
|
| 1564 |
+
operation: {
|
| 1565 |
+
type: "string",
|
| 1566 |
+
enum: ["replace_all", "replace_last_match", "delete_last_match", "delete_last_chars", "append", "clear"],
|
| 1567 |
+
},
|
| 1568 |
+
text: { type: "string", description: "Replacement text for replace_all or text to append." },
|
| 1569 |
+
target_text: { type: "string", description: "Exact text to replace or delete for match-based operations." },
|
| 1570 |
+
replacement_text: { type: "string", description: "Replacement text for replace_last_match." },
|
| 1571 |
+
count: { type: "number", description: "Character count for delete_last_chars." },
|
| 1572 |
+
reason: { type: "string", description: "Brief reason for the visible edit." },
|
| 1573 |
+
},
|
| 1574 |
+
required: ["operation"],
|
| 1575 |
+
},
|
| 1576 |
+
},
|
| 1577 |
+
});
|
| 1578 |
+
if (config.webSearch) {
|
| 1579 |
list.push({
|
| 1580 |
type: "function",
|
| 1581 |
function: {
|
|
|
|
| 1601 |
},
|
| 1602 |
});
|
| 1603 |
}
|
| 1604 |
+
if (config.imageGen) {
|
| 1605 |
list.push({
|
| 1606 |
type: "function",
|
| 1607 |
function: {
|
|
|
|
| 1619 |
},
|
| 1620 |
});
|
| 1621 |
}
|
| 1622 |
+
if (config.videoGen) {
|
| 1623 |
list.push({
|
| 1624 |
type: "function",
|
| 1625 |
function: {
|
|
|
|
| 1639 |
},
|
| 1640 |
});
|
| 1641 |
}
|
| 1642 |
+
if (config.audioGen) {
|
| 1643 |
list.push({
|
| 1644 |
type: "function",
|
| 1645 |
function: {
|
|
|
|
| 1656 |
return list;
|
| 1657 |
}
|
| 1658 |
|
| 1659 |
+
function normalizeRequestedImageIndexes(args, resource) {
|
| 1660 |
+
const explicitIndexes = Array.isArray(args.indexes)
|
| 1661 |
+
? [...new Set(args.indexes.map((value) => Number(value)).filter(Number.isInteger))]
|
| 1662 |
+
: [];
|
| 1663 |
+
|
| 1664 |
+
const validExplicitIndexes = explicitIndexes.filter(
|
| 1665 |
+
(index) => index >= 0 && index < resource.imageCount
|
| 1666 |
+
);
|
| 1667 |
+
if (validExplicitIndexes.length) return validExplicitIndexes;
|
| 1668 |
+
|
| 1669 |
+
const requestedStart = Number.isInteger(Number(args.start_index))
|
| 1670 |
+
? Number(args.start_index)
|
| 1671 |
+
: resource.inlineImageCount || 0;
|
| 1672 |
+
const requestedCount = Number.isInteger(Number(args.count))
|
| 1673 |
+
? Number(args.count)
|
| 1674 |
+
: MAX_INLINE_IMAGES;
|
| 1675 |
+
|
| 1676 |
+
const indexes = [];
|
| 1677 |
+
for (let i = 0; i < requestedCount; i++) {
|
| 1678 |
+
const index = requestedStart + i;
|
| 1679 |
+
if (index >= 0 && index < resource.imageCount) {
|
| 1680 |
+
indexes.push(index);
|
| 1681 |
+
}
|
| 1682 |
+
}
|
| 1683 |
+
|
| 1684 |
+
return indexes;
|
| 1685 |
+
}
|
| 1686 |
+
|
| 1687 |
+
function applyResponseDraftEdit(currentText, args = {}) {
|
| 1688 |
+
const source = String(currentText || "");
|
| 1689 |
+
const operation = args.operation;
|
| 1690 |
+
switch (operation) {
|
| 1691 |
+
case "replace_all":
|
| 1692 |
+
return String(args.text || "");
|
| 1693 |
+
case "append":
|
| 1694 |
+
return source + String(args.text || "");
|
| 1695 |
+
case "clear":
|
| 1696 |
+
return "";
|
| 1697 |
+
case "replace_last_match": {
|
| 1698 |
+
const target = String(args.target_text || "");
|
| 1699 |
+
if (!target) return source;
|
| 1700 |
+
const idx = source.lastIndexOf(target);
|
| 1701 |
+
if (idx === -1) return source;
|
| 1702 |
+
return source.slice(0, idx) + String(args.replacement_text || "") + source.slice(idx + target.length);
|
| 1703 |
+
}
|
| 1704 |
+
case "delete_last_match": {
|
| 1705 |
+
const target = String(args.target_text || "");
|
| 1706 |
+
if (!target) return source;
|
| 1707 |
+
const idx = source.lastIndexOf(target);
|
| 1708 |
+
if (idx === -1) return source;
|
| 1709 |
+
return source.slice(0, idx) + source.slice(idx + target.length);
|
| 1710 |
+
}
|
| 1711 |
+
case "delete_last_chars": {
|
| 1712 |
+
const count = Math.max(0, Number(args.count) || 0);
|
| 1713 |
+
return source.slice(0, Math.max(0, source.length - count));
|
| 1714 |
+
}
|
| 1715 |
+
default:
|
| 1716 |
+
return source;
|
| 1717 |
+
}
|
| 1718 |
+
}
|
| 1719 |
+
|
| 1720 |
+
async function processToolCalls({
|
| 1721 |
+
sessionId,
|
| 1722 |
+
toolCalls,
|
| 1723 |
+
tools,
|
| 1724 |
+
owner,
|
| 1725 |
+
accessToken,
|
| 1726 |
+
clientId,
|
| 1727 |
+
webSearchLimit,
|
| 1728 |
+
abortSignal,
|
| 1729 |
+
draftState,
|
| 1730 |
+
onToolCall,
|
| 1731 |
+
onNewAsset,
|
| 1732 |
+
onDraftEdit,
|
| 1733 |
+
}) {
|
| 1734 |
+
const nextMessages = [];
|
| 1735 |
const authHeaders = {};
|
| 1736 |
+
const allowedToolNames = getAllowedToolNames(buildToolList(tools));
|
| 1737 |
if (accessToken) {
|
| 1738 |
authHeaders["Authorization"] = `Bearer ${accessToken}`;
|
| 1739 |
}
|
|
|
|
| 1744 |
for (const call of toolCalls) {
|
| 1745 |
let args;
|
| 1746 |
try { args = JSON.parse(call.function.arguments || "{}"); } catch { args = {}; }
|
| 1747 |
+
const toolName = sanitizeToolName(call.function?.name, allowedToolNames);
|
| 1748 |
+
if (toolName) {
|
| 1749 |
+
call.function.name = toolName;
|
| 1750 |
+
}
|
| 1751 |
|
| 1752 |
+
onToolCall({ id: call.id, name: toolName || call.function?.name, state: "pending", args });
|
| 1753 |
|
| 1754 |
let result = "Tool completed.";
|
| 1755 |
|
| 1756 |
try {
|
| 1757 |
+
if (!toolName || !allowedToolNames.includes(toolName)) {
|
| 1758 |
+
result = `Invalid tool name "${call.function?.name || "unknown"}".`;
|
| 1759 |
+
}
|
| 1760 |
+
|
| 1761 |
+
else if (toolName === "list_prompt_resources") {
|
| 1762 |
+
const state = getPromptState(sessionId);
|
| 1763 |
+
result = JSON.stringify(buildPromptResourceManifest(state), null, 2);
|
| 1764 |
+
}
|
| 1765 |
+
|
| 1766 |
+
else if (toolName === "read_prompt_chunk") {
|
| 1767 |
+
const state = getPromptState(sessionId);
|
| 1768 |
+
const chunkIndex = Number.isInteger(Number(args.chunk_index))
|
| 1769 |
+
? Number(args.chunk_index)
|
| 1770 |
+
: Number(args.chunk_id);
|
| 1771 |
+
const resourceId = args.resource_id || args.filename;
|
| 1772 |
+
const resource = findPromptResource(state, resourceId);
|
| 1773 |
+
|
| 1774 |
+
if (!resource) {
|
| 1775 |
+
result = `Prompt resource "${resourceId}" not found.`;
|
| 1776 |
+
} else if (resource.kind === "images") {
|
| 1777 |
+
result = `Resource "${resource.id}" contains images, not text. Use load_prompt_images instead.`;
|
| 1778 |
+
} else if (!Number.isInteger(chunkIndex) || chunkIndex < 0 || chunkIndex >= resource.chunkCount) {
|
| 1779 |
+
result = `Chunk ${chunkIndex} is out of range for resource "${resource.id}" (${resource.chunkCount} chunk(s)).`;
|
| 1780 |
+
} else {
|
| 1781 |
+
const chunk = resource.chunks[chunkIndex];
|
| 1782 |
+
result = [
|
| 1783 |
+
`Resource: ${resource.id}`,
|
| 1784 |
+
`Name: ${resource.name}`,
|
| 1785 |
+
`Kind: ${resource.kind}`,
|
| 1786 |
+
`Chunk: ${chunk.index + 1}/${resource.chunkCount}`,
|
| 1787 |
+
"",
|
| 1788 |
+
chunk.text,
|
| 1789 |
+
].join("\n");
|
| 1790 |
+
}
|
| 1791 |
+
}
|
| 1792 |
+
|
| 1793 |
+
else if (toolName === "load_prompt_images") {
|
| 1794 |
+
const state = getPromptState(sessionId);
|
| 1795 |
+
const resource = findPromptResource(state, args.resource_id);
|
| 1796 |
+
|
| 1797 |
+
if (!resource) {
|
| 1798 |
+
result = `Prompt image resource "${args.resource_id}" not found.`;
|
| 1799 |
+
} else if (resource.kind !== "images") {
|
| 1800 |
+
result = `Resource "${resource.id}" is not an image resource.`;
|
| 1801 |
+
} else {
|
| 1802 |
+
const indexes = normalizeRequestedImageIndexes(args, resource);
|
| 1803 |
+
const selectedImages = indexes
|
| 1804 |
+
.map((index) => resource.images[index]?.block)
|
| 1805 |
+
.filter(Boolean);
|
| 1806 |
+
|
| 1807 |
+
if (!selectedImages.length) {
|
| 1808 |
+
result = `No images were loaded from "${resource.id}".`;
|
| 1809 |
+
} else {
|
| 1810 |
+
result = `Loaded image indexes ${indexes.join(", ")} from "${resource.id}".`;
|
| 1811 |
+
nextMessages.push({
|
| 1812 |
+
role: "tool",
|
| 1813 |
+
tool_call_id: call.id,
|
| 1814 |
+
content: result,
|
| 1815 |
+
});
|
| 1816 |
+
nextMessages.push({
|
| 1817 |
+
role: "user",
|
| 1818 |
+
content: [
|
| 1819 |
+
{
|
| 1820 |
+
type: "text",
|
| 1821 |
+
text: `Requested staged images from resource "${resource.id}" (${resource.name}), indexes ${indexes.join(", ")}.`,
|
| 1822 |
+
},
|
| 1823 |
+
...selectedImages,
|
| 1824 |
+
],
|
| 1825 |
+
});
|
| 1826 |
+
onToolCall({ id: call.id, name: toolName, state: "resolved", result });
|
| 1827 |
+
continue;
|
| 1828 |
+
}
|
| 1829 |
+
}
|
| 1830 |
+
}
|
| 1831 |
+
|
| 1832 |
+
else if (toolName === "write_notes") {
|
| 1833 |
+
appendAssistantNote(sessionId, args.note);
|
| 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);
|
| 1852 |
+
}
|
| 1853 |
+
|
| 1854 |
+
else if (toolName === "save_memory") {
|
| 1855 |
+
const memory = owner
|
| 1856 |
+
? await memoryStore.create(owner, {
|
| 1857 |
+
content: args.content,
|
| 1858 |
+
sessionId,
|
| 1859 |
+
source: "assistant",
|
| 1860 |
+
})
|
| 1861 |
+
: null;
|
| 1862 |
+
result = memory
|
| 1863 |
+
? `Saved memory ${memory.id}: ${memory.content}`
|
| 1864 |
+
: "Memory was empty or could not be saved.";
|
| 1865 |
+
}
|
| 1866 |
+
|
| 1867 |
+
else if (toolName === "delete_memory") {
|
| 1868 |
+
const ok = owner ? await memoryStore.delete(owner, args.memory_id) : false;
|
| 1869 |
+
result = ok ? `Deleted memory ${args.memory_id}.` : `Memory ${args.memory_id} was not found.`;
|
| 1870 |
}
|
| 1871 |
|
| 1872 |
+
else if (toolName === "edit_response_draft") {
|
| 1873 |
+
const before = draftState?.text || "";
|
| 1874 |
+
const after = applyResponseDraftEdit(before, args);
|
| 1875 |
+
if (draftState) draftState.text = after;
|
| 1876 |
+
const edit = {
|
| 1877 |
+
operation: args.operation,
|
| 1878 |
+
reason: String(args.reason || "").trim() || "Model revised its draft.",
|
| 1879 |
+
before,
|
| 1880 |
+
after,
|
| 1881 |
+
timestamp: Date.now(),
|
| 1882 |
+
};
|
| 1883 |
+
if (before !== after) {
|
| 1884 |
+
onDraftEdit(edit, after);
|
| 1885 |
+
result = [
|
| 1886 |
+
`Draft updated. The visible response now has ${after.length} character(s).`,
|
| 1887 |
+
"Visible draft:",
|
| 1888 |
+
(after || "[empty]").slice(-4000),
|
| 1889 |
+
].join("\n\n");
|
| 1890 |
+
} else {
|
| 1891 |
+
result = "Draft edit made no visible change.";
|
| 1892 |
+
}
|
| 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") {
|
| 1909 |
const { convert } = await import("html-to-text");
|
| 1910 |
const res = await fetch(args.url, { signal: abortSignal });
|
| 1911 |
if (!res.ok) {
|
|
|
|
| 1920 |
}
|
| 1921 |
}
|
| 1922 |
|
| 1923 |
+
else if (toolName === "generate_image") {
|
| 1924 |
const body = { prompt: args.prompt };
|
| 1925 |
if (args.mode) body.mode = args.mode;
|
| 1926 |
if (args.image_urls?.length) body.image_urls = args.image_urls;
|
|
|
|
| 1934 |
if (res.ok) {
|
| 1935 |
const buf = await res.arrayBuffer();
|
| 1936 |
const ct = res.headers.get("content-type") || "image/png";
|
| 1937 |
+
const stored = await mediaStore.storeBuffer(owner, {
|
| 1938 |
+
name: `generated-image-${Date.now()}.${ct.includes("svg") ? "svg" : ct.split("/")[1] || "png"}`,
|
| 1939 |
+
mimeType: ct,
|
| 1940 |
+
buffer: Buffer.from(buf),
|
| 1941 |
+
sessionId,
|
| 1942 |
+
source: "assistant_generated",
|
| 1943 |
+
kind: "image",
|
| 1944 |
+
});
|
| 1945 |
+
onNewAsset({ id: stored.id, role: "image", mimeType: ct, name: stored.name });
|
| 1946 |
result = "Image generated successfully and shown to the user.";
|
| 1947 |
} else if (res.status == 402) {
|
| 1948 |
result = "An upgraded plan is required for higher limits.";
|
|
|
|
| 1953 |
}
|
| 1954 |
}
|
| 1955 |
|
| 1956 |
+
else if (toolName === "generate_video") {
|
| 1957 |
const body = { prompt: args.prompt };
|
| 1958 |
if (args.ratio) body.ratio = args.ratio;
|
| 1959 |
if (args.mode) body.mode = args.mode;
|
|
|
|
| 1968 |
});
|
| 1969 |
if (res.ok) {
|
| 1970 |
const buf = await res.arrayBuffer();
|
| 1971 |
+
const contentType = res.headers.get("content-type") || "video/mp4";
|
| 1972 |
+
const stored = await mediaStore.storeBuffer(owner, {
|
| 1973 |
+
name: `generated-video-${Date.now()}.${contentType.split("/")[1] || "mp4"}`,
|
| 1974 |
+
mimeType: contentType,
|
| 1975 |
+
buffer: Buffer.from(buf),
|
| 1976 |
+
sessionId,
|
| 1977 |
+
source: "assistant_generated",
|
| 1978 |
+
kind: "video",
|
| 1979 |
+
});
|
| 1980 |
+
onNewAsset({ id: stored.id, role: "video", mimeType: contentType, name: stored.name });
|
| 1981 |
result = "Video generated successfully and shown to the user.";
|
| 1982 |
} else if (res.status == 402) {
|
| 1983 |
result = "An upgraded plan is required for higher limits.";
|
|
|
|
| 1988 |
}
|
| 1989 |
}
|
| 1990 |
|
| 1991 |
+
else if (toolName === "generate_audio") {
|
| 1992 |
const res = await fetch(`${LIGHTNING_BASE}/gen/sfx`, {
|
| 1993 |
method: "POST",
|
| 1994 |
headers: { "Content-Type": "application/json", ...authHeaders },
|
|
|
|
| 1997 |
});
|
| 1998 |
if (res.ok) {
|
| 1999 |
const buf = await res.arrayBuffer();
|
| 2000 |
+
const contentType = res.headers.get("content-type") || "audio/mpeg";
|
| 2001 |
+
const stored = await mediaStore.storeBuffer(owner, {
|
| 2002 |
+
name: `generated-audio-${Date.now()}.${contentType.split("/")[1] || "mp3"}`,
|
| 2003 |
+
mimeType: contentType,
|
| 2004 |
+
buffer: Buffer.from(buf),
|
| 2005 |
+
sessionId,
|
| 2006 |
+
source: "assistant_generated",
|
| 2007 |
+
kind: "audio",
|
| 2008 |
+
});
|
| 2009 |
+
onNewAsset({ id: stored.id, role: "audio", mimeType: contentType, name: stored.name });
|
| 2010 |
result = "Audio generated successfully and shown to the user.";
|
| 2011 |
} else if (res.status == 429) {
|
| 2012 |
result = "Too many requests. Try again later.";
|
|
|
|
| 2018 |
result = `Tool error: ${String(err)}`;
|
| 2019 |
}
|
| 2020 |
|
| 2021 |
+
onToolCall({ id: call.id, name: toolName || call.function?.name, state: "resolved", result });
|
| 2022 |
|
| 2023 |
+
nextMessages.push({
|
| 2024 |
role: "tool",
|
| 2025 |
tool_call_id: call.id,
|
| 2026 |
content: typeof result === "string" ? result : JSON.stringify(result),
|
| 2027 |
});
|
| 2028 |
}
|
| 2029 |
|
| 2030 |
+
return { nextMessages, draftText: draftState?.text || "" };
|
| 2031 |
+
}
|
server/chatTrashStore.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import crypto from 'crypto';
|
| 2 |
+
import path from 'path';
|
| 3 |
+
import { loadEncryptedJson, saveEncryptedJson } from './cryptoUtils.js';
|
| 4 |
+
|
| 5 |
+
const DATA_ROOT = '/data/deleted_chats';
|
| 6 |
+
const INDEX_FILE = path.join(DATA_ROOT, 'index.json');
|
| 7 |
+
const RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
| 8 |
+
|
| 9 |
+
const state = {
|
| 10 |
+
loaded: false,
|
| 11 |
+
index: {
|
| 12 |
+
deletedChats: {},
|
| 13 |
+
},
|
| 14 |
+
};
|
| 15 |
+
|
| 16 |
+
function nowIso() {
|
| 17 |
+
return new Date().toISOString();
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
function ensureOwner(owner) {
|
| 21 |
+
if (!owner?.type || !owner?.id) throw new Error('Invalid deleted chat owner');
|
| 22 |
+
return owner;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
async function ensureLoaded() {
|
| 26 |
+
if (state.loaded) return;
|
| 27 |
+
const stored = await loadEncryptedJson(INDEX_FILE);
|
| 28 |
+
state.index = {
|
| 29 |
+
deletedChats: stored?.deletedChats || {},
|
| 30 |
+
};
|
| 31 |
+
state.loaded = true;
|
| 32 |
+
await thisStore.purgeExpired();
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
async function saveIndex() {
|
| 36 |
+
await saveEncryptedJson(INDEX_FILE, state.index);
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
function matchesOwner(record, owner) {
|
| 40 |
+
return record.ownerType === owner.type && record.ownerId === owner.id;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
function sanitize(record) {
|
| 44 |
+
return {
|
| 45 |
+
id: record.id,
|
| 46 |
+
originalSessionId: record.originalSessionId,
|
| 47 |
+
name: record.name,
|
| 48 |
+
deletedAt: record.deletedAt,
|
| 49 |
+
purgeAt: record.purgeAt,
|
| 50 |
+
created: record.created,
|
| 51 |
+
};
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
const thisStore = {
|
| 55 |
+
async add(owner, sessionSnapshot) {
|
| 56 |
+
ensureOwner(owner);
|
| 57 |
+
await ensureLoaded();
|
| 58 |
+
|
| 59 |
+
const sessionId = sessionSnapshot?.id;
|
| 60 |
+
if (!sessionId) return null;
|
| 61 |
+
|
| 62 |
+
for (const record of Object.values(state.index.deletedChats)) {
|
| 63 |
+
if (
|
| 64 |
+
record.originalSessionId === sessionId &&
|
| 65 |
+
record.ownerType === owner.type &&
|
| 66 |
+
record.ownerId === owner.id
|
| 67 |
+
) {
|
| 68 |
+
record.sessionSnapshot = sessionSnapshot;
|
| 69 |
+
record.name = sessionSnapshot.name || 'Deleted Chat';
|
| 70 |
+
record.created = sessionSnapshot.created || Date.now();
|
| 71 |
+
record.deletedAt = nowIso();
|
| 72 |
+
record.purgeAt = new Date(Date.now() + RETENTION_MS).toISOString();
|
| 73 |
+
await saveIndex();
|
| 74 |
+
return sanitize(record);
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
const deleted = {
|
| 79 |
+
id: crypto.randomUUID(),
|
| 80 |
+
originalSessionId: sessionId,
|
| 81 |
+
ownerType: owner.type,
|
| 82 |
+
ownerId: owner.id,
|
| 83 |
+
name: sessionSnapshot.name || 'Deleted Chat',
|
| 84 |
+
created: sessionSnapshot.created || Date.now(),
|
| 85 |
+
sessionSnapshot,
|
| 86 |
+
deletedAt: nowIso(),
|
| 87 |
+
purgeAt: new Date(Date.now() + RETENTION_MS).toISOString(),
|
| 88 |
+
};
|
| 89 |
+
state.index.deletedChats[deleted.id] = deleted;
|
| 90 |
+
await saveIndex();
|
| 91 |
+
return sanitize(deleted);
|
| 92 |
+
},
|
| 93 |
+
|
| 94 |
+
async list(owner) {
|
| 95 |
+
ensureOwner(owner);
|
| 96 |
+
await ensureLoaded();
|
| 97 |
+
return Object.values(state.index.deletedChats)
|
| 98 |
+
.filter((record) => matchesOwner(record, owner))
|
| 99 |
+
.sort((a, b) => new Date(b.deletedAt).getTime() - new Date(a.deletedAt).getTime())
|
| 100 |
+
.map(sanitize);
|
| 101 |
+
},
|
| 102 |
+
|
| 103 |
+
async restore(owner, ids) {
|
| 104 |
+
ensureOwner(owner);
|
| 105 |
+
await ensureLoaded();
|
| 106 |
+
const restored = [];
|
| 107 |
+
for (const id of ids || []) {
|
| 108 |
+
const record = state.index.deletedChats[id];
|
| 109 |
+
if (!record || !matchesOwner(record, owner)) continue;
|
| 110 |
+
restored.push(record.sessionSnapshot);
|
| 111 |
+
delete state.index.deletedChats[id];
|
| 112 |
+
}
|
| 113 |
+
if (restored.length) await saveIndex();
|
| 114 |
+
return restored;
|
| 115 |
+
},
|
| 116 |
+
|
| 117 |
+
async deleteForever(owner, ids) {
|
| 118 |
+
ensureOwner(owner);
|
| 119 |
+
await ensureLoaded();
|
| 120 |
+
const removed = [];
|
| 121 |
+
for (const id of ids || []) {
|
| 122 |
+
const record = state.index.deletedChats[id];
|
| 123 |
+
if (!record || !matchesOwner(record, owner)) continue;
|
| 124 |
+
delete state.index.deletedChats[id];
|
| 125 |
+
removed.push(id);
|
| 126 |
+
}
|
| 127 |
+
if (removed.length) await saveIndex();
|
| 128 |
+
return removed;
|
| 129 |
+
},
|
| 130 |
+
|
| 131 |
+
async purgeExpired() {
|
| 132 |
+
if (!state.loaded) return;
|
| 133 |
+
const now = Date.now();
|
| 134 |
+
let changed = false;
|
| 135 |
+
for (const [id, record] of Object.entries(state.index.deletedChats)) {
|
| 136 |
+
if (new Date(record.purgeAt).getTime() <= now) {
|
| 137 |
+
delete state.index.deletedChats[id];
|
| 138 |
+
changed = true;
|
| 139 |
+
}
|
| 140 |
+
}
|
| 141 |
+
if (changed) await saveIndex();
|
| 142 |
+
},
|
| 143 |
+
};
|
| 144 |
+
|
| 145 |
+
setInterval(() => {
|
| 146 |
+
thisStore.purgeExpired().catch((err) => console.error('chatTrashStore cleanup failed:', err));
|
| 147 |
+
}, 6 * 60 * 60 * 1000);
|
| 148 |
+
|
| 149 |
+
export const chatTrashStore = thisStore;
|
server/cryptoUtils.js
CHANGED
|
@@ -2,61 +2,135 @@ import crypto from 'crypto';
|
|
| 2 |
import fs from 'fs/promises';
|
| 3 |
import path from 'path';
|
| 4 |
|
|
|
|
|
|
|
| 5 |
const ALGORITHM = 'aes-256-gcm';
|
| 6 |
-
const KEY_LENGTH = 32;
|
| 7 |
-
const IV_LENGTH =
|
| 8 |
-
const AUTH_TAG_LENGTH = 16;
|
| 9 |
|
| 10 |
-
// Derive key from environment variable
|
| 11 |
function getKey() {
|
| 12 |
const keyEnv = process.env.DATA_ENCRYPTION_KEY;
|
| 13 |
if (!keyEnv) throw new Error('DATA_ENCRYPTION_KEY environment variable not set');
|
| 14 |
-
|
| 15 |
-
return crypto.createHash('sha256').update(keyEnv).digest();
|
| 16 |
}
|
| 17 |
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
const key = getKey();
|
| 20 |
const iv = crypto.randomBytes(IV_LENGTH);
|
| 21 |
-
const cipher = crypto.
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
encrypted += cipher.final('hex');
|
| 26 |
-
|
| 27 |
const authTag = cipher.getAuthTag();
|
| 28 |
return {
|
| 29 |
-
|
|
|
|
|
|
|
| 30 |
encrypted,
|
| 31 |
-
authTag: authTag.toString('hex'),
|
| 32 |
};
|
| 33 |
}
|
| 34 |
|
| 35 |
-
export function
|
| 36 |
const key = getKey();
|
| 37 |
-
const
|
| 38 |
-
const
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
decrypted += decipher.final('utf8');
|
| 44 |
return JSON.parse(decrypted);
|
| 45 |
}
|
| 46 |
|
| 47 |
-
export
|
| 48 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
| 50 |
-
await fs.writeFile(filePath, JSON.stringify(encrypted));
|
| 51 |
}
|
| 52 |
|
| 53 |
-
export async function loadEncryptedJson(filePath) {
|
| 54 |
try {
|
| 55 |
const content = await fs.readFile(filePath, 'utf8');
|
| 56 |
const encrypted = JSON.parse(content);
|
| 57 |
-
return decryptJson(encrypted);
|
| 58 |
} catch (err) {
|
| 59 |
-
if (err.code === 'ENOENT') return null;
|
| 60 |
throw err;
|
| 61 |
}
|
| 62 |
-
}
|
|
|
|
| 2 |
import fs from 'fs/promises';
|
| 3 |
import path from 'path';
|
| 4 |
|
| 5 |
+
const JSON_FORMAT_VERSION = 2;
|
| 6 |
+
const BINARY_FORMAT_VERSION = 1;
|
| 7 |
const ALGORITHM = 'aes-256-gcm';
|
| 8 |
+
const KEY_LENGTH = 32;
|
| 9 |
+
const IV_LENGTH = 12;
|
| 10 |
+
const AUTH_TAG_LENGTH = 16;
|
| 11 |
|
|
|
|
| 12 |
function getKey() {
|
| 13 |
const keyEnv = process.env.DATA_ENCRYPTION_KEY;
|
| 14 |
if (!keyEnv) throw new Error('DATA_ENCRYPTION_KEY environment variable not set');
|
| 15 |
+
return crypto.createHash('sha256').update(keyEnv).digest().subarray(0, KEY_LENGTH);
|
|
|
|
| 16 |
}
|
| 17 |
|
| 18 |
+
function normalizeAad(aad = '') {
|
| 19 |
+
if (Buffer.isBuffer(aad)) return aad;
|
| 20 |
+
return Buffer.from(String(aad || ''), 'utf8');
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
export function encryptBuffer(buffer, aad = '') {
|
| 24 |
const key = getKey();
|
| 25 |
const iv = crypto.randomBytes(IV_LENGTH);
|
| 26 |
+
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
| 27 |
+
const aadBuffer = normalizeAad(aad);
|
| 28 |
+
if (aadBuffer.length) cipher.setAAD(aadBuffer);
|
| 29 |
+
const encrypted = Buffer.concat([cipher.update(buffer), cipher.final()]);
|
|
|
|
|
|
|
| 30 |
const authTag = cipher.getAuthTag();
|
| 31 |
return {
|
| 32 |
+
version: BINARY_FORMAT_VERSION,
|
| 33 |
+
iv,
|
| 34 |
+
authTag,
|
| 35 |
encrypted,
|
|
|
|
| 36 |
};
|
| 37 |
}
|
| 38 |
|
| 39 |
+
export function decryptBuffer(payload, aad = '') {
|
| 40 |
const key = getKey();
|
| 41 |
+
const iv = Buffer.isBuffer(payload?.iv) ? payload.iv : Buffer.from(payload?.iv || '', 'hex');
|
| 42 |
+
const authTag = Buffer.isBuffer(payload?.authTag)
|
| 43 |
+
? payload.authTag
|
| 44 |
+
: Buffer.from(payload?.authTag || '', 'hex');
|
| 45 |
+
const encrypted = Buffer.isBuffer(payload?.encrypted)
|
| 46 |
+
? payload.encrypted
|
| 47 |
+
: Buffer.from(payload?.encrypted || '', 'hex');
|
| 48 |
+
|
| 49 |
+
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
| 50 |
+
const aadBuffer = normalizeAad(aad);
|
| 51 |
+
if (aadBuffer.length) decipher.setAAD(aadBuffer);
|
| 52 |
+
decipher.setAuthTag(authTag);
|
| 53 |
+
return Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
| 54 |
+
}
|
| 55 |
|
| 56 |
+
export function packEncryptedBuffer(payload) {
|
| 57 |
+
const header = Buffer.allocUnsafe(1 + 1 + payload.iv.length + payload.authTag.length);
|
| 58 |
+
header.writeUInt8(payload.version || BINARY_FORMAT_VERSION, 0);
|
| 59 |
+
header.writeUInt8(payload.iv.length, 1);
|
| 60 |
+
payload.iv.copy(header, 2);
|
| 61 |
+
payload.authTag.copy(header, 2 + payload.iv.length);
|
| 62 |
+
return Buffer.concat([header, payload.encrypted]);
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
export function unpackEncryptedBuffer(buffer) {
|
| 66 |
+
const version = buffer.readUInt8(0);
|
| 67 |
+
if (version !== BINARY_FORMAT_VERSION) {
|
| 68 |
+
throw new Error(`Unsupported encrypted buffer version: ${version}`);
|
| 69 |
+
}
|
| 70 |
+
const ivLength = buffer.readUInt8(1);
|
| 71 |
+
const ivStart = 2;
|
| 72 |
+
const ivEnd = ivStart + ivLength;
|
| 73 |
+
const tagEnd = ivEnd + AUTH_TAG_LENGTH;
|
| 74 |
+
return {
|
| 75 |
+
version,
|
| 76 |
+
iv: buffer.subarray(ivStart, ivEnd),
|
| 77 |
+
authTag: buffer.subarray(ivEnd, tagEnd),
|
| 78 |
+
encrypted: buffer.subarray(tagEnd),
|
| 79 |
+
};
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
export async function writeEncryptedFile(filePath, buffer, aad = '') {
|
| 83 |
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
| 84 |
+
const payload = encryptBuffer(buffer, aad);
|
| 85 |
+
await fs.writeFile(filePath, packEncryptedBuffer(payload));
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
export async function readEncryptedFile(filePath, aad = '') {
|
| 89 |
+
const packed = await fs.readFile(filePath);
|
| 90 |
+
return decryptBuffer(unpackEncryptedBuffer(packed), aad);
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
function legacyDecryptJson(encryptedData) {
|
| 94 |
+
const key = getKey();
|
| 95 |
+
const decipher = crypto.createDecipher(ALGORITHM, key);
|
| 96 |
+
decipher.setAuthTag(Buffer.from(encryptedData.authTag, 'hex'));
|
| 97 |
+
let decrypted = decipher.update(encryptedData.encrypted, 'hex', 'utf8');
|
| 98 |
decrypted += decipher.final('utf8');
|
| 99 |
return JSON.parse(decrypted);
|
| 100 |
}
|
| 101 |
|
| 102 |
+
export function encryptJson(data, aad = '') {
|
| 103 |
+
const payload = encryptBuffer(Buffer.from(JSON.stringify(data), 'utf8'), aad);
|
| 104 |
+
return {
|
| 105 |
+
version: JSON_FORMAT_VERSION,
|
| 106 |
+
iv: payload.iv.toString('hex'),
|
| 107 |
+
authTag: payload.authTag.toString('hex'),
|
| 108 |
+
encrypted: payload.encrypted.toString('hex'),
|
| 109 |
+
};
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
export function decryptJson(encryptedData, aad = '') {
|
| 113 |
+
if (!encryptedData) return null;
|
| 114 |
+
if ((encryptedData.version || 0) >= JSON_FORMAT_VERSION) {
|
| 115 |
+
const decrypted = decryptBuffer(encryptedData, aad);
|
| 116 |
+
return JSON.parse(decrypted.toString('utf8'));
|
| 117 |
+
}
|
| 118 |
+
return legacyDecryptJson(encryptedData);
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
export async function saveEncryptedJson(filePath, data, aad = '') {
|
| 122 |
+
const encrypted = encryptJson(data, aad);
|
| 123 |
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
| 124 |
+
await fs.writeFile(filePath, JSON.stringify(encrypted, null, 2), 'utf8');
|
| 125 |
}
|
| 126 |
|
| 127 |
+
export async function loadEncryptedJson(filePath, aad = '') {
|
| 128 |
try {
|
| 129 |
const content = await fs.readFile(filePath, 'utf8');
|
| 130 |
const encrypted = JSON.parse(content);
|
| 131 |
+
return decryptJson(encrypted, aad);
|
| 132 |
} catch (err) {
|
| 133 |
+
if (err.code === 'ENOENT') return null;
|
| 134 |
throw err;
|
| 135 |
}
|
| 136 |
+
}
|
server/handleFeedback.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import path from 'path';
|
| 2 |
+
import crypto from 'crypto';
|
| 3 |
+
import { saveEncryptedJson, loadEncryptedJson } from './cryptoUtils.js';
|
| 4 |
+
|
| 5 |
+
const DATA_DIR = '/data';
|
| 6 |
+
const TICKETS_FILE = path.join(DATA_DIR, 'feedback_tickets.json');
|
| 7 |
+
const FEEDBACK_AAD = 'feedback_tickets_v1';
|
| 8 |
+
|
| 9 |
+
const MAX_TITLE_LENGTH = 100;
|
| 10 |
+
const MAX_BODY_LENGTH = 10000;
|
| 11 |
+
|
| 12 |
+
function generateTicketId() {
|
| 13 |
+
return `tkt_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
function sanitizeString(value, maxLength) {
|
| 17 |
+
if (typeof value !== 'string') return '';
|
| 18 |
+
return value.trim().slice(0, maxLength);
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
async function loadTickets() {
|
| 22 |
+
const data = await loadEncryptedJson(TICKETS_FILE, FEEDBACK_AAD);
|
| 23 |
+
if (!data || !Array.isArray(data.tickets)) return { tickets: [] };
|
| 24 |
+
return data;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
async function saveTickets(data) {
|
| 28 |
+
await saveEncryptedJson(TICKETS_FILE, data, FEEDBACK_AAD);
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
export function registerFeedbackRoutes(app, { requireAdminTurnstile, verifyLimiter, logAdminEvent, ADMIN_TOKEN, getRequestIp }) {
|
| 32 |
+
|
| 33 |
+
// POST /api/feedback/submit — public, no auth required
|
| 34 |
+
app.post('/api/feedback/submit', async (req, res) => {
|
| 35 |
+
try {
|
| 36 |
+
const type = req.body?.type === 'improvement' ? 'improvement' : 'issue';
|
| 37 |
+
const environment = type === 'issue'
|
| 38 |
+
? (req.body?.environment === 'beta' ? 'beta' : 'production')
|
| 39 |
+
: null;
|
| 40 |
+
|
| 41 |
+
const title = sanitizeString(req.body?.title, MAX_TITLE_LENGTH);
|
| 42 |
+
const body = sanitizeString(req.body?.body, MAX_BODY_LENGTH);
|
| 43 |
+
|
| 44 |
+
if (!title) {
|
| 45 |
+
return res.status(400).json({ error: 'feedback:title_required', message: 'A title is required.' });
|
| 46 |
+
}
|
| 47 |
+
if (!body) {
|
| 48 |
+
return res.status(400).json({ error: 'feedback:body_required', message: 'A description is required.' });
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
const ticket = {
|
| 52 |
+
id: generateTicketId(),
|
| 53 |
+
type,
|
| 54 |
+
environment,
|
| 55 |
+
title,
|
| 56 |
+
body,
|
| 57 |
+
status: 'open',
|
| 58 |
+
submittedAt: new Date().toISOString(),
|
| 59 |
+
resolvedAt: null,
|
| 60 |
+
ip: getRequestIp(req),
|
| 61 |
+
userAgent: (req.headers['user-agent'] || '').slice(0, 200),
|
| 62 |
+
};
|
| 63 |
+
|
| 64 |
+
const data = await loadTickets();
|
| 65 |
+
data.tickets.push(ticket);
|
| 66 |
+
await saveTickets(data);
|
| 67 |
+
|
| 68 |
+
console.log(`[FEEDBACK] New ${type} ticket submitted: "${title.slice(0, 60)}" id=${ticket.id}`);
|
| 69 |
+
|
| 70 |
+
return res.json({ success: true, id: ticket.id });
|
| 71 |
+
} catch (err) {
|
| 72 |
+
console.error('feedback submit error', err);
|
| 73 |
+
return res.status(500).json({ error: 'feedback:server_error', message: 'Failed to submit ticket.' });
|
| 74 |
+
}
|
| 75 |
+
});
|
| 76 |
+
|
| 77 |
+
// GET /admin/feedback — list tickets, requires admin auth + turnstile
|
| 78 |
+
app.get('/admin/feedback', requireAdminTurnstile, verifyLimiter, async (req, res) => {
|
| 79 |
+
const token = req.query.token;
|
| 80 |
+
if (token !== ADMIN_TOKEN) {
|
| 81 |
+
logAdminEvent(req, 'feedback_list_denied', { reason: 'bad_token' });
|
| 82 |
+
return res.status(403).json({ error: 'Forbidden' });
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
try {
|
| 86 |
+
const data = await loadTickets();
|
| 87 |
+
const tickets = data.tickets.map(t => ({
|
| 88 |
+
id: t.id,
|
| 89 |
+
type: t.type,
|
| 90 |
+
environment: t.environment,
|
| 91 |
+
title: t.title,
|
| 92 |
+
body: t.body,
|
| 93 |
+
status: t.status,
|
| 94 |
+
submittedAt: t.submittedAt,
|
| 95 |
+
resolvedAt: t.resolvedAt,
|
| 96 |
+
}));
|
| 97 |
+
logAdminEvent(req, 'feedback_list_view', { count: tickets.length });
|
| 98 |
+
return res.json({ tickets });
|
| 99 |
+
} catch (err) {
|
| 100 |
+
console.error('feedback list error', err);
|
| 101 |
+
return res.status(500).json({ error: 'feedback:server_error' });
|
| 102 |
+
}
|
| 103 |
+
});
|
| 104 |
+
|
| 105 |
+
// POST /admin/feedback/:id/resolve — mark a ticket resolved
|
| 106 |
+
app.post('/admin/feedback/:id/resolve', requireAdminTurnstile, verifyLimiter, async (req, res) => {
|
| 107 |
+
const token = req.body?.token || req.query.token;
|
| 108 |
+
if (token !== ADMIN_TOKEN) {
|
| 109 |
+
logAdminEvent(req, 'feedback_resolve_denied', { reason: 'bad_token', ticketId: req.params.id });
|
| 110 |
+
return res.status(403).json({ error: 'Forbidden' });
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
try {
|
| 114 |
+
const data = await loadTickets();
|
| 115 |
+
const ticket = data.tickets.find(t => t.id === req.params.id);
|
| 116 |
+
if (!ticket) {
|
| 117 |
+
return res.status(404).json({ error: 'feedback:not_found' });
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
ticket.status = 'resolved';
|
| 121 |
+
ticket.resolvedAt = new Date().toISOString();
|
| 122 |
+
await saveTickets(data);
|
| 123 |
+
|
| 124 |
+
logAdminEvent(req, 'feedback_resolved', { ticketId: ticket.id, title: ticket.title.slice(0, 60) });
|
| 125 |
+
return res.json({ success: true, ticket: {
|
| 126 |
+
id: ticket.id, status: ticket.status, resolvedAt: ticket.resolvedAt
|
| 127 |
+
}});
|
| 128 |
+
} catch (err) {
|
| 129 |
+
console.error('feedback resolve error', err);
|
| 130 |
+
return res.status(500).json({ error: 'feedback:server_error' });
|
| 131 |
+
}
|
| 132 |
+
});
|
| 133 |
+
|
| 134 |
+
// POST /admin/feedback/:id/reopen — reopen a resolved ticket
|
| 135 |
+
app.post('/admin/feedback/:id/reopen', requireAdminTurnstile, verifyLimiter, async (req, res) => {
|
| 136 |
+
const token = req.body?.token || req.query.token;
|
| 137 |
+
if (token !== ADMIN_TOKEN) {
|
| 138 |
+
logAdminEvent(req, 'feedback_reopen_denied', { reason: 'bad_token', ticketId: req.params.id });
|
| 139 |
+
return res.status(403).json({ error: 'Forbidden' });
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
try {
|
| 143 |
+
const data = await loadTickets();
|
| 144 |
+
const ticket = data.tickets.find(t => t.id === req.params.id);
|
| 145 |
+
if (!ticket) {
|
| 146 |
+
return res.status(404).json({ error: 'feedback:not_found' });
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
ticket.status = 'open';
|
| 150 |
+
ticket.resolvedAt = null;
|
| 151 |
+
await saveTickets(data);
|
| 152 |
+
|
| 153 |
+
logAdminEvent(req, 'feedback_reopened', { ticketId: ticket.id });
|
| 154 |
+
return res.json({ success: true, ticket: {
|
| 155 |
+
id: ticket.id, status: ticket.status, resolvedAt: ticket.resolvedAt
|
| 156 |
+
}});
|
| 157 |
+
} catch (err) {
|
| 158 |
+
console.error('feedback reopen error', err);
|
| 159 |
+
return res.status(500).json({ error: 'feedback:server_error' });
|
| 160 |
+
}
|
| 161 |
+
});
|
| 162 |
+
}
|
server/index.js
CHANGED
|
@@ -8,11 +8,13 @@ import { fileURLToPath } from 'url';
|
|
| 8 |
import fetch from 'node-fetch';
|
| 9 |
import rateLimit from 'express-rate-limit';
|
| 10 |
import fs from 'fs';
|
| 11 |
-
|
| 12 |
import { handleWsMessage } from './wsHandler.js';
|
| 13 |
import { sessionStore, initStoreConfig } from './sessionStore.js';
|
| 14 |
import { SUPABASE_URL, SUPABASE_ANON_KEY } from './config.js';
|
| 15 |
import { safeSend } from './helpers.js';
|
|
|
|
|
|
|
| 16 |
|
| 17 |
export { SUPABASE_URL, SUPABASE_ANON_KEY };
|
| 18 |
export { LIGHTNING_BASE, PUBLIC_URL } from './config.js';
|
|
@@ -29,14 +31,131 @@ const GITHUB_REPO = 'incognitolm/InferencePort-Pages';
|
|
| 29 |
const CDN_BASE = `https://cdn.jsdelivr.net/gh/${GITHUB_REPO}`;
|
| 30 |
let latestSHA = null;
|
| 31 |
const ADMIN_TOKEN = process.env.ADMIN_TOKEN || 'supersecret';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
// Rate limiter for admin endpoints (5 attempts per IP per minute)
|
| 34 |
-
const verifyLimiter = rateLimit({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
const DATA_DIR = "/data";
|
| 37 |
const VERSION_FILE = path.join(DATA_DIR, 'version.json');
|
| 38 |
const PUBLIC_URL = process.env.PUBLIC_URL || 'default';
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
function loadStoredSHA() {
|
| 42 |
try {
|
|
@@ -98,6 +217,7 @@ function saveStoredSHA(sha) {
|
|
| 98 |
|
| 99 |
app.use(express.json({ limit: '10mb' }));
|
| 100 |
|
|
|
|
| 101 |
app.use('/api', (req, res, next) => {
|
| 102 |
const exempt = ['/turnstile', '/health'];
|
| 103 |
if (exempt.includes(req.path)) return next();
|
|
@@ -106,6 +226,45 @@ app.use('/api', (req, res, next) => {
|
|
| 106 |
return res.status(403).json({ error: 'turnstile:required' });
|
| 107 |
});
|
| 108 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
app.get('/health', (_req,res) => res.json({ok:true}));
|
| 110 |
|
| 111 |
app.get('/api/share/:token', async (req,res) => {
|
|
@@ -123,17 +282,237 @@ app.get('/api/share/:token', async (req,res) => {
|
|
| 123 |
} catch { res.status(500).json({error:'Server error'}); }
|
| 124 |
});
|
| 125 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
app.post('/api/turnstile', async (req,res)=>{
|
| 127 |
try{
|
| 128 |
const token = req.body?.token;
|
| 129 |
-
const
|
| 130 |
-
if(!token||!secret) return res.status(400).json({error:'Missing token or server not configured'});
|
| 131 |
-
const params = new URLSearchParams();
|
| 132 |
-
params.append('secret',secret);
|
| 133 |
-
params.append('response',token);
|
| 134 |
-
if(req.ip) params.append('remoteip',req.ip);
|
| 135 |
-
const r = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify',{method:'POST',body:params});
|
| 136 |
-
const j = await r.json();
|
| 137 |
if(j?.success){
|
| 138 |
res.cookie('turnstile','1',{ maxAge:24*3600*1000, path:'/', sameSite:'lax'});
|
| 139 |
return res.json({success:true});
|
|
@@ -163,7 +542,10 @@ if (!latestSHA) {
|
|
| 163 |
console.log(`[${PUBLIC_URL}] Using stored SHA: ${latestSHA}`);
|
| 164 |
}
|
| 165 |
|
|
|
|
| 166 |
app.get('/admin.html', async (req, res) => {
|
|
|
|
|
|
|
| 167 |
if (!latestSHA) return res.status(500).send('Server not ready');
|
| 168 |
|
| 169 |
const url = `${CDN_BASE}@${latestSHA}/admin.html`;
|
|
@@ -181,28 +563,90 @@ app.get('/admin.html', async (req, res) => {
|
|
| 181 |
}
|
| 182 |
});
|
| 183 |
|
| 184 |
-
app.get('/admin/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
const token = req.query.token;
|
| 186 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
});
|
| 188 |
|
| 189 |
-
app.get('/admin/refresh', verifyLimiter, async (req, res) => {
|
| 190 |
const token = req.query.token;
|
| 191 |
-
if (token !== ADMIN_TOKEN)
|
|
|
|
|
|
|
|
|
|
| 192 |
|
| 193 |
const sha = req.query.sha?.trim();
|
| 194 |
if (sha) {
|
| 195 |
-
if (!/^[0-9a-f]{7,40}$/.test(sha))
|
|
|
|
|
|
|
|
|
|
| 196 |
latestSHA = sha;
|
| 197 |
saveStoredSHA(latestSHA);
|
|
|
|
| 198 |
console.log(`[${PUBLIC_URL}] Manual SHA set by admin: ${latestSHA}`);
|
| 199 |
return res.send(`Version set to commit ${latestSHA}`);
|
| 200 |
}
|
| 201 |
|
| 202 |
await fetchLatestSHA();
|
|
|
|
| 203 |
res.send(`Latest version refreshed: ${latestSHA}`);
|
| 204 |
});
|
| 205 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
function getMimeType(filePath){
|
| 207 |
const ext = path.extname(filePath).toLowerCase();
|
| 208 |
switch(ext){
|
|
@@ -220,10 +664,31 @@ function getMimeType(filePath){
|
|
| 220 |
}
|
| 221 |
}
|
| 222 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
app.get('*', async (req,res)=>{
|
| 224 |
if(req.path.startsWith('/api/')) return res.status(404).send('Not found');
|
| 225 |
|
|
|
|
| 226 |
const filePath = req.path === '/' ? '/index.html' : req.path;
|
|
|
|
| 227 |
const url = `${CDN_BASE}@${latestSHA}${filePath}`;
|
| 228 |
|
| 229 |
try{
|
|
@@ -233,6 +698,7 @@ app.get('*', async (req,res)=>{
|
|
| 233 |
const mimeType = getMimeType(filePath);
|
| 234 |
res.setHeader('Content-Type', mimeType);
|
| 235 |
|
|
|
|
| 236 |
if(mimeType.startsWith('text') || mimeType==='application/javascript' || mimeType==='application/json'){
|
| 237 |
const text = await response.text();
|
| 238 |
res.send(text);
|
|
@@ -246,6 +712,7 @@ app.get('*', async (req,res)=>{
|
|
| 246 |
}
|
| 247 |
});
|
| 248 |
|
|
|
|
| 249 |
const httpServer = createServer(app);
|
| 250 |
const wss = new WebSocketServer({server:httpServer,path:'/ws'});
|
| 251 |
|
|
@@ -270,4 +737,4 @@ wss.on('connection',(ws,req)=>{
|
|
| 270 |
safeSend(ws,{type:'connected', tempId:wsClients.get(ws)?.tempId});
|
| 271 |
});
|
| 272 |
|
| 273 |
-
httpServer.listen(PORT,'0.0.0.0',()=>console.log(`Running on port ${PORT}`));
|
|
|
|
| 8 |
import fetch from 'node-fetch';
|
| 9 |
import rateLimit from 'express-rate-limit';
|
| 10 |
import fs from 'fs';
|
| 11 |
+
import { registerFeedbackRoutes } from './handleFeedback.js';
|
| 12 |
import { handleWsMessage } from './wsHandler.js';
|
| 13 |
import { sessionStore, initStoreConfig } from './sessionStore.js';
|
| 14 |
import { SUPABASE_URL, SUPABASE_ANON_KEY } from './config.js';
|
| 15 |
import { safeSend } from './helpers.js';
|
| 16 |
+
import { verifySupabaseToken } from './auth.js';
|
| 17 |
+
import { mediaStore } from './mediaStore.js';
|
| 18 |
|
| 19 |
export { SUPABASE_URL, SUPABASE_ANON_KEY };
|
| 20 |
export { LIGHTNING_BASE, PUBLIC_URL } from './config.js';
|
|
|
|
| 31 |
const CDN_BASE = `https://cdn.jsdelivr.net/gh/${GITHUB_REPO}`;
|
| 32 |
let latestSHA = null;
|
| 33 |
const ADMIN_TOKEN = process.env.ADMIN_TOKEN || 'supersecret';
|
| 34 |
+
const TURNSTILE_SITE_KEY = process.env.TURNSTILE_SITE_KEY || '0x4AAAAAAC1ZXKIhZ9Kdz8j9';
|
| 35 |
+
const MAX_TEXT_UPLOAD_BYTES = 100 * 1024;
|
| 36 |
+
|
| 37 |
+
const LOCAL_UI_DIR = [
|
| 38 |
+
process.env.UI_LOCAL_PATH,
|
| 39 |
+
path.resolve(__dirname, '..', '..', 'InferencePort-Pages'),
|
| 40 |
+
path.resolve(process.cwd(), '..', 'InferencePort-Pages'),
|
| 41 |
+
].filter(Boolean).find((dir) => {
|
| 42 |
+
try {
|
| 43 |
+
return fs.existsSync(path.join(dir, 'index.html'));
|
| 44 |
+
} catch {
|
| 45 |
+
return false;
|
| 46 |
+
}
|
| 47 |
+
}) || null;
|
| 48 |
|
| 49 |
// Rate limiter for admin endpoints (5 attempts per IP per minute)
|
| 50 |
+
const verifyLimiter = rateLimit({
|
| 51 |
+
windowMs: 60 * 1000,
|
| 52 |
+
max: 5,
|
| 53 |
+
standardHeaders: true,
|
| 54 |
+
legacyHeaders: false,
|
| 55 |
+
handler: (req, res) => {
|
| 56 |
+
logAdminEvent(req, 'rate_limited');
|
| 57 |
+
res.status(429).json({ error: 'rate_limited' });
|
| 58 |
+
},
|
| 59 |
+
});
|
| 60 |
|
| 61 |
const DATA_DIR = "/data";
|
| 62 |
const VERSION_FILE = path.join(DATA_DIR, 'version.json');
|
| 63 |
const PUBLIC_URL = process.env.PUBLIC_URL || 'default';
|
| 64 |
|
| 65 |
+
function getRequestIp(req) {
|
| 66 |
+
return (req.headers['x-forwarded-for'] || '').split(',')[0].trim()
|
| 67 |
+
|| req.socket?.remoteAddress
|
| 68 |
+
|| req.ip
|
| 69 |
+
|| 'unknown';
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
function truncateForLog(value, max = 180) {
|
| 73 |
+
const text = String(value ?? '').replace(/\s+/g, ' ').trim();
|
| 74 |
+
if (!text) return '';
|
| 75 |
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
function extensionFromName(name = '') {
|
| 79 |
+
const ext = path.extname(String(name || '')).toLowerCase();
|
| 80 |
+
return ext.startsWith('.') ? ext.slice(1) : ext;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
function isTextLikeUpload(name = '', mimeType = '', kind = '') {
|
| 84 |
+
const normalizedKind = String(kind || '').toLowerCase();
|
| 85 |
+
const mime = String(mimeType || '').toLowerCase();
|
| 86 |
+
const ext = extensionFromName(name);
|
| 87 |
+
if (normalizedKind === 'text' || normalizedKind === 'rich_text') return true;
|
| 88 |
+
if (mime.startsWith('text/')) return true;
|
| 89 |
+
if (['application/json', 'application/javascript', 'application/xml'].includes(mime)) return true;
|
| 90 |
+
return ['txt', 'md', 'json', 'js', 'ts', 'css', 'py', 'html', 'htm', 'xml', 'csv', 'rtf'].includes(ext);
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
function getCookieMap(req) {
|
| 94 |
+
const cookies = (req.headers.cookie || '')
|
| 95 |
+
.split(';')
|
| 96 |
+
.map((value) => value.trim())
|
| 97 |
+
.filter(Boolean)
|
| 98 |
+
.map((entry) => {
|
| 99 |
+
const idx = entry.indexOf('=');
|
| 100 |
+
if (idx === -1) return null;
|
| 101 |
+
return [entry.slice(0, idx), entry.slice(idx + 1)];
|
| 102 |
+
})
|
| 103 |
+
.filter(Boolean);
|
| 104 |
+
return Object.fromEntries(cookies);
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
function hasAdminTurnstile(req) {
|
| 108 |
+
return getCookieMap(req).admin_turnstile === '1';
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
function requireAdminTurnstile(req, res, next) {
|
| 112 |
+
if (hasAdminTurnstile(req)) return next();
|
| 113 |
+
logAdminEvent(req, 'blocked_missing_turnstile');
|
| 114 |
+
return res.status(403).json({ error: 'turnstile:required' });
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
function logAdminEvent(req, action, detail = null) {
|
| 118 |
+
const parts = [
|
| 119 |
+
`[ADMIN ${new Date().toISOString()}]`,
|
| 120 |
+
`action=${action}`,
|
| 121 |
+
`ip=${getRequestIp(req)}`,
|
| 122 |
+
`method=${req.method}`,
|
| 123 |
+
`path=${truncateForLog(req.originalUrl || req.url, 220)}`,
|
| 124 |
+
];
|
| 125 |
+
const userAgent = truncateForLog(req.headers['user-agent'] || 'unknown', 140);
|
| 126 |
+
if (userAgent) parts.push(`ua="${userAgent}"`);
|
| 127 |
+
if (typeof detail === 'string' && detail.trim()) {
|
| 128 |
+
parts.push(detail.trim());
|
| 129 |
+
} else if (detail && typeof detail === 'object') {
|
| 130 |
+
Object.entries(detail).forEach(([key, value]) => {
|
| 131 |
+
if (value === undefined || value === null || value === '') return;
|
| 132 |
+
parts.push(`${key}=${JSON.stringify(String(value))}`);
|
| 133 |
+
});
|
| 134 |
+
}
|
| 135 |
+
console.log(parts.join(' | '));
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
function respondTextUploadTooLarge(res) {
|
| 139 |
+
return res.status(413).json({
|
| 140 |
+
error: 'media:text_too_large',
|
| 141 |
+
message: 'Text files must be 100 KB or smaller.',
|
| 142 |
+
});
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
async function verifyTurnstileToken(token, remoteIp) {
|
| 146 |
+
const secret = process.env.TURNSTILE_SECRET_KEY;
|
| 147 |
+
if (!token || !secret) throw new Error('Missing token or server not configured');
|
| 148 |
+
const params = new URLSearchParams();
|
| 149 |
+
params.append('secret', secret);
|
| 150 |
+
params.append('response', token);
|
| 151 |
+
if (remoteIp) params.append('remoteip', remoteIp);
|
| 152 |
+
const response = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
|
| 153 |
+
method: 'POST',
|
| 154 |
+
body: params,
|
| 155 |
+
});
|
| 156 |
+
return response.json();
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
|
| 160 |
function loadStoredSHA() {
|
| 161 |
try {
|
|
|
|
| 217 |
|
| 218 |
app.use(express.json({ limit: '10mb' }));
|
| 219 |
|
| 220 |
+
// --- API Turnstile Protection ---
|
| 221 |
app.use('/api', (req, res, next) => {
|
| 222 |
const exempt = ['/turnstile', '/health'];
|
| 223 |
if (exempt.includes(req.path)) return next();
|
|
|
|
| 226 |
return res.status(403).json({ error: 'turnstile:required' });
|
| 227 |
});
|
| 228 |
|
| 229 |
+
registerFeedbackRoutes(app, {
|
| 230 |
+
requireAdminTurnstile,
|
| 231 |
+
verifyLimiter,
|
| 232 |
+
logAdminEvent,
|
| 233 |
+
ADMIN_TOKEN,
|
| 234 |
+
getRequestIp,
|
| 235 |
+
});
|
| 236 |
+
|
| 237 |
+
async function getRequestOwner(req) {
|
| 238 |
+
const authHeader = req.headers.authorization || '';
|
| 239 |
+
const accessToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : '';
|
| 240 |
+
if (accessToken) {
|
| 241 |
+
const user = await verifySupabaseToken(accessToken);
|
| 242 |
+
if (user) return { owner: { type: 'user', id: user.id }, accessToken };
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
const tempId = String(req.headers['x-temp-id'] || req.query.tempId || '').trim();
|
| 246 |
+
if (tempId) {
|
| 247 |
+
sessionStore.initTemp(tempId);
|
| 248 |
+
return { owner: { type: 'guest', id: tempId }, accessToken: null };
|
| 249 |
+
}
|
| 250 |
+
return null;
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
async function requireRequestOwner(req, res) {
|
| 254 |
+
const resolved = await getRequestOwner(req);
|
| 255 |
+
if (!resolved?.owner) {
|
| 256 |
+
res.status(401).json({ error: 'auth:required' });
|
| 257 |
+
return null;
|
| 258 |
+
}
|
| 259 |
+
return resolved;
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
function requireSignedInMediaOwner(resolved, res, message = 'Sign in to upload files.') {
|
| 263 |
+
if (resolved?.owner?.type === 'user') return true;
|
| 264 |
+
res.status(403).json({ error: 'media:auth_required', message });
|
| 265 |
+
return false;
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
app.get('/health', (_req,res) => res.json({ok:true}));
|
| 269 |
|
| 270 |
app.get('/api/share/:token', async (req,res) => {
|
|
|
|
| 282 |
} catch { res.status(500).json({error:'Server error'}); }
|
| 283 |
});
|
| 284 |
|
| 285 |
+
app.get('/api/media', async (req, res) => {
|
| 286 |
+
const resolved = await requireRequestOwner(req, res);
|
| 287 |
+
if (!resolved) return;
|
| 288 |
+
try {
|
| 289 |
+
const result = await mediaStore.list(resolved.owner, {
|
| 290 |
+
view: req.query.view === 'trash' ? 'trash' : 'active',
|
| 291 |
+
parentId: req.query.parentId ? String(req.query.parentId) : null,
|
| 292 |
+
});
|
| 293 |
+
res.json(result);
|
| 294 |
+
} catch (err) {
|
| 295 |
+
console.error('media list error', err);
|
| 296 |
+
res.status(500).json({ error: 'media:list_failed' });
|
| 297 |
+
}
|
| 298 |
+
});
|
| 299 |
+
|
| 300 |
+
app.post('/api/media/upload', express.raw({ type: () => true, limit: '100mb' }), async (req, res) => {
|
| 301 |
+
const resolved = await requireRequestOwner(req, res);
|
| 302 |
+
if (!resolved) return;
|
| 303 |
+
if (!requireSignedInMediaOwner(resolved, res, 'Sign in to upload files.')) return;
|
| 304 |
+
try {
|
| 305 |
+
const name = decodeURIComponent(String(req.headers['x-file-name'] || 'upload.bin'));
|
| 306 |
+
const mimeType = String(req.headers['x-mime-type'] || 'application/octet-stream');
|
| 307 |
+
const parentId = req.headers['x-parent-id'] ? String(req.headers['x-parent-id']) : null;
|
| 308 |
+
const sessionId = req.headers['x-session-id'] ? String(req.headers['x-session-id']) : null;
|
| 309 |
+
const kindHeader = req.headers['x-file-kind'] ? String(req.headers['x-file-kind']) : null;
|
| 310 |
+
const buffer = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || []);
|
| 311 |
+
if (isTextLikeUpload(name, mimeType, kindHeader) && buffer.byteLength > MAX_TEXT_UPLOAD_BYTES) {
|
| 312 |
+
return respondTextUploadTooLarge(res);
|
| 313 |
+
}
|
| 314 |
+
const item = await mediaStore.storeBuffer(resolved.owner, {
|
| 315 |
+
name,
|
| 316 |
+
mimeType,
|
| 317 |
+
buffer,
|
| 318 |
+
parentId,
|
| 319 |
+
sessionId,
|
| 320 |
+
source: 'user_upload',
|
| 321 |
+
kind: kindHeader || null,
|
| 322 |
+
});
|
| 323 |
+
const usage = await mediaStore.getUsage(resolved.owner);
|
| 324 |
+
res.json({ item, usage });
|
| 325 |
+
} catch (err) {
|
| 326 |
+
console.error('media upload error', err);
|
| 327 |
+
res.status(err.status || 500).json({
|
| 328 |
+
error: err.code || 'media:upload_failed',
|
| 329 |
+
message: err.message || 'Upload failed',
|
| 330 |
+
usage: err.usage || null,
|
| 331 |
+
});
|
| 332 |
+
}
|
| 333 |
+
});
|
| 334 |
+
|
| 335 |
+
app.post('/api/media/folders', async (req, res) => {
|
| 336 |
+
const resolved = await requireRequestOwner(req, res);
|
| 337 |
+
if (!resolved) return;
|
| 338 |
+
if (!requireSignedInMediaOwner(resolved, res, 'Sign in to create folders.')) return;
|
| 339 |
+
try {
|
| 340 |
+
const item = await mediaStore.createFolder(resolved.owner, {
|
| 341 |
+
name: req.body?.name || 'New Folder',
|
| 342 |
+
parentId: req.body?.parentId || null,
|
| 343 |
+
});
|
| 344 |
+
const usage = await mediaStore.getUsage(resolved.owner);
|
| 345 |
+
res.json({ item, usage });
|
| 346 |
+
} catch (err) {
|
| 347 |
+
console.error('media create folder error', err);
|
| 348 |
+
res.status(500).json({ error: 'media:create_folder_failed' });
|
| 349 |
+
}
|
| 350 |
+
});
|
| 351 |
+
|
| 352 |
+
app.post('/api/media/documents', async (req, res) => {
|
| 353 |
+
const resolved = await requireRequestOwner(req, res);
|
| 354 |
+
if (!resolved) return;
|
| 355 |
+
if (!requireSignedInMediaOwner(resolved, res, 'Sign in to create documents.')) return;
|
| 356 |
+
try {
|
| 357 |
+
const richText = !!req.body?.richText;
|
| 358 |
+
const content = String(req.body?.content || '');
|
| 359 |
+
if (Buffer.byteLength(content, 'utf8') > MAX_TEXT_UPLOAD_BYTES) {
|
| 360 |
+
return respondTextUploadTooLarge(res);
|
| 361 |
+
}
|
| 362 |
+
const item = await mediaStore.createDocument(resolved.owner, {
|
| 363 |
+
name: req.body?.name,
|
| 364 |
+
parentId: req.body?.parentId || null,
|
| 365 |
+
richText,
|
| 366 |
+
content,
|
| 367 |
+
source: 'user_upload',
|
| 368 |
+
sessionId: req.body?.sessionId || null,
|
| 369 |
+
});
|
| 370 |
+
const usage = await mediaStore.getUsage(resolved.owner);
|
| 371 |
+
res.json({ item, usage });
|
| 372 |
+
} catch (err) {
|
| 373 |
+
console.error('media create document error', err);
|
| 374 |
+
res.status(err.status || 500).json({
|
| 375 |
+
error: err.code || 'media:create_document_failed',
|
| 376 |
+
message: err.message || 'Document creation failed',
|
| 377 |
+
usage: err.usage || null,
|
| 378 |
+
});
|
| 379 |
+
}
|
| 380 |
+
});
|
| 381 |
+
|
| 382 |
+
app.post('/api/media/move', async (req, res) => {
|
| 383 |
+
const resolved = await requireRequestOwner(req, res);
|
| 384 |
+
if (!resolved) return;
|
| 385 |
+
if (!requireSignedInMediaOwner(resolved, res, 'Sign in to move files.')) return;
|
| 386 |
+
try {
|
| 387 |
+
const items = await mediaStore.move(resolved.owner, req.body?.ids || [], req.body?.parentId || null);
|
| 388 |
+
const usage = await mediaStore.getUsage(resolved.owner);
|
| 389 |
+
res.json({ items, usage });
|
| 390 |
+
} catch (err) {
|
| 391 |
+
console.error('media move error', err);
|
| 392 |
+
res.status(500).json({ error: 'media:move_failed' });
|
| 393 |
+
}
|
| 394 |
+
});
|
| 395 |
+
|
| 396 |
+
app.post('/api/media/trash', async (req, res) => {
|
| 397 |
+
const resolved = await requireRequestOwner(req, res);
|
| 398 |
+
if (!resolved) return;
|
| 399 |
+
try {
|
| 400 |
+
const items = await mediaStore.moveToTrash(resolved.owner, req.body?.ids || []);
|
| 401 |
+
const usage = await mediaStore.getUsage(resolved.owner);
|
| 402 |
+
res.json({ items, usage });
|
| 403 |
+
} catch (err) {
|
| 404 |
+
console.error('media trash error', err);
|
| 405 |
+
res.status(500).json({ error: 'media:trash_failed' });
|
| 406 |
+
}
|
| 407 |
+
});
|
| 408 |
+
|
| 409 |
+
app.post('/api/media/restore', async (req, res) => {
|
| 410 |
+
const resolved = await requireRequestOwner(req, res);
|
| 411 |
+
if (!resolved) return;
|
| 412 |
+
if (!requireSignedInMediaOwner(resolved, res, 'Sign in to restore files.')) return;
|
| 413 |
+
try {
|
| 414 |
+
const items = await mediaStore.restore(resolved.owner, req.body?.ids || []);
|
| 415 |
+
const usage = await mediaStore.getUsage(resolved.owner);
|
| 416 |
+
res.json({ items, usage });
|
| 417 |
+
} catch (err) {
|
| 418 |
+
console.error('media restore error', err);
|
| 419 |
+
res.status(500).json({ error: 'media:restore_failed' });
|
| 420 |
+
}
|
| 421 |
+
});
|
| 422 |
+
|
| 423 |
+
app.post('/api/media/deleteForever', async (req, res) => {
|
| 424 |
+
const resolved = await requireRequestOwner(req, res);
|
| 425 |
+
if (!resolved) return;
|
| 426 |
+
try {
|
| 427 |
+
const ids = await mediaStore.deleteForever(resolved.owner, req.body?.ids || []);
|
| 428 |
+
const usage = await mediaStore.getUsage(resolved.owner);
|
| 429 |
+
res.json({ ids, usage });
|
| 430 |
+
} catch (err) {
|
| 431 |
+
console.error('media delete forever error', err);
|
| 432 |
+
res.status(500).json({ error: 'media:delete_failed' });
|
| 433 |
+
}
|
| 434 |
+
});
|
| 435 |
+
|
| 436 |
+
app.get('/api/media/:id/text', async (req, res) => {
|
| 437 |
+
const resolved = await requireRequestOwner(req, res);
|
| 438 |
+
if (!resolved) return;
|
| 439 |
+
try {
|
| 440 |
+
const loaded = await mediaStore.readText(resolved.owner, req.params.id);
|
| 441 |
+
if (!loaded) return res.status(404).json({ error: 'media:not_found' });
|
| 442 |
+
res.json({ item: loaded.entry, content: loaded.text });
|
| 443 |
+
} catch (err) {
|
| 444 |
+
console.error('media read text error', err);
|
| 445 |
+
res.status(500).json({ error: 'media:read_failed' });
|
| 446 |
+
}
|
| 447 |
+
});
|
| 448 |
+
|
| 449 |
+
app.put('/api/media/:id/text', async (req, res) => {
|
| 450 |
+
const resolved = await requireRequestOwner(req, res);
|
| 451 |
+
if (!resolved) return;
|
| 452 |
+
if (!requireSignedInMediaOwner(resolved, res, 'Sign in to edit files.')) return;
|
| 453 |
+
try {
|
| 454 |
+
const buffer = Buffer.from(String(req.body?.content || ''), 'utf8');
|
| 455 |
+
if (buffer.byteLength > MAX_TEXT_UPLOAD_BYTES) {
|
| 456 |
+
return respondTextUploadTooLarge(res);
|
| 457 |
+
}
|
| 458 |
+
const item = await mediaStore.updateContent(resolved.owner, req.params.id, {
|
| 459 |
+
buffer,
|
| 460 |
+
mimeType: req.body?.mimeType || null,
|
| 461 |
+
kind: req.body?.richText ? 'rich_text' : null,
|
| 462 |
+
name: req.body?.name || null,
|
| 463 |
+
});
|
| 464 |
+
if (!item) return res.status(404).json({ error: 'media:not_found' });
|
| 465 |
+
const usage = await mediaStore.getUsage(resolved.owner);
|
| 466 |
+
res.json({ item, usage });
|
| 467 |
+
} catch (err) {
|
| 468 |
+
console.error('media update text error', err);
|
| 469 |
+
res.status(err.status || 500).json({
|
| 470 |
+
error: err.code || 'media:update_failed',
|
| 471 |
+
message: err.message || 'Update failed',
|
| 472 |
+
usage: err.usage || null,
|
| 473 |
+
});
|
| 474 |
+
}
|
| 475 |
+
});
|
| 476 |
+
|
| 477 |
+
app.post('/api/media/:id/rename', async (req, res) => {
|
| 478 |
+
const resolved = await requireRequestOwner(req, res);
|
| 479 |
+
if (!resolved) return;
|
| 480 |
+
if (!requireSignedInMediaOwner(resolved, res, 'Sign in to rename files.')) return;
|
| 481 |
+
try {
|
| 482 |
+
const name = String(req.body?.name || '').trim();
|
| 483 |
+
if (!name) return res.status(400).json({ error: 'media:name_required', message: 'A file name is required.' });
|
| 484 |
+
const item = await mediaStore.rename(resolved.owner, req.params.id, name);
|
| 485 |
+
if (!item) return res.status(404).json({ error: 'media:not_found' });
|
| 486 |
+
const usage = await mediaStore.getUsage(resolved.owner);
|
| 487 |
+
res.json({ item, usage });
|
| 488 |
+
} catch (err) {
|
| 489 |
+
console.error('media rename error', err);
|
| 490 |
+
res.status(500).json({ error: 'media:rename_failed', message: err.message || 'Rename failed' });
|
| 491 |
+
}
|
| 492 |
+
});
|
| 493 |
+
|
| 494 |
+
app.get('/api/media/:id/content', async (req, res) => {
|
| 495 |
+
const resolved = await requireRequestOwner(req, res);
|
| 496 |
+
if (!resolved) return;
|
| 497 |
+
try {
|
| 498 |
+
const loaded = await mediaStore.readBuffer(resolved.owner, req.params.id);
|
| 499 |
+
if (!loaded) return res.status(404).json({ error: 'media:not_found' });
|
| 500 |
+
res.setHeader('Content-Type', loaded.entry.mimeType || 'application/octet-stream');
|
| 501 |
+
res.setHeader('Cache-Control', 'private, max-age=60');
|
| 502 |
+
if (req.query.download === '1') {
|
| 503 |
+
res.setHeader('Content-Disposition', `attachment; filename="${loaded.entry.name}"`);
|
| 504 |
+
}
|
| 505 |
+
res.send(loaded.buffer);
|
| 506 |
+
} catch (err) {
|
| 507 |
+
console.error('media read binary error', err);
|
| 508 |
+
res.status(500).json({ error: 'media:read_failed' });
|
| 509 |
+
}
|
| 510 |
+
});
|
| 511 |
+
|
| 512 |
app.post('/api/turnstile', async (req,res)=>{
|
| 513 |
try{
|
| 514 |
const token = req.body?.token;
|
| 515 |
+
const j = await verifyTurnstileToken(token, getRequestIp(req));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 516 |
if(j?.success){
|
| 517 |
res.cookie('turnstile','1',{ maxAge:24*3600*1000, path:'/', sameSite:'lax'});
|
| 518 |
return res.json({success:true});
|
|
|
|
| 542 |
console.log(`[${PUBLIC_URL}] Using stored SHA: ${latestSHA}`);
|
| 543 |
}
|
| 544 |
|
| 545 |
+
// --- Admin endpoints ---
|
| 546 |
app.get('/admin.html', async (req, res) => {
|
| 547 |
+
logAdminEvent(req, 'page_view');
|
| 548 |
+
if (serveLocalUiFile('/admin.html', res)) return;
|
| 549 |
if (!latestSHA) return res.status(500).send('Server not ready');
|
| 550 |
|
| 551 |
const url = `${CDN_BASE}@${latestSHA}/admin.html`;
|
|
|
|
| 563 |
}
|
| 564 |
});
|
| 565 |
|
| 566 |
+
app.get('/admin/config', (req, res) => {
|
| 567 |
+
logAdminEvent(req, 'config_view', { verified: hasAdminTurnstile(req) });
|
| 568 |
+
res.json({
|
| 569 |
+
siteKey: TURNSTILE_SITE_KEY || null,
|
| 570 |
+
verified: hasAdminTurnstile(req),
|
| 571 |
+
});
|
| 572 |
+
});
|
| 573 |
+
|
| 574 |
+
app.post('/admin/turnstile', async (req, res) => {
|
| 575 |
+
try {
|
| 576 |
+
const token = req.body?.token;
|
| 577 |
+
const result = await verifyTurnstileToken(token, getRequestIp(req));
|
| 578 |
+
if (!result?.success) {
|
| 579 |
+
logAdminEvent(req, 'turnstile_failed', {
|
| 580 |
+
errorCodes: Array.isArray(result?.['error-codes']) ? result['error-codes'].join(',') : '',
|
| 581 |
+
});
|
| 582 |
+
return res.status(403).json({ error: 'Verification failed' });
|
| 583 |
+
}
|
| 584 |
+
res.cookie('admin_turnstile', '1', { maxAge: 2 * 3600 * 1000, path: '/', sameSite: 'lax' });
|
| 585 |
+
logAdminEvent(req, 'turnstile_verified');
|
| 586 |
+
return res.json({ success: true });
|
| 587 |
+
} catch (err) {
|
| 588 |
+
console.error('admin turnstile verify', err);
|
| 589 |
+
logAdminEvent(req, 'turnstile_error', { message: err.message || 'unknown' });
|
| 590 |
+
return res.status(500).json({ error: err.message || 'Server error' });
|
| 591 |
+
}
|
| 592 |
+
});
|
| 593 |
+
|
| 594 |
+
app.get('/admin/verify', requireAdminTurnstile, verifyLimiter, (req,res)=>{
|
| 595 |
const token = req.query.token;
|
| 596 |
+
const success = token===ADMIN_TOKEN;
|
| 597 |
+
logAdminEvent(req, success ? 'login_success' : 'login_failed', {
|
| 598 |
+
turnstile: hasAdminTurnstile(req),
|
| 599 |
+
tokenProvided: !!token,
|
| 600 |
+
});
|
| 601 |
+
res.json({success});
|
| 602 |
});
|
| 603 |
|
| 604 |
+
app.get('/admin/refresh', requireAdminTurnstile, verifyLimiter, async (req, res) => {
|
| 605 |
const token = req.query.token;
|
| 606 |
+
if (token !== ADMIN_TOKEN) {
|
| 607 |
+
logAdminEvent(req, 'refresh_denied', { reason: 'bad_token' });
|
| 608 |
+
return res.status(403).send('Forbidden');
|
| 609 |
+
}
|
| 610 |
|
| 611 |
const sha = req.query.sha?.trim();
|
| 612 |
if (sha) {
|
| 613 |
+
if (!/^[0-9a-f]{7,40}$/.test(sha)) {
|
| 614 |
+
logAdminEvent(req, 'set_sha_invalid', { requestedSha: sha });
|
| 615 |
+
return res.status(400).send('Invalid SHA');
|
| 616 |
+
}
|
| 617 |
latestSHA = sha;
|
| 618 |
saveStoredSHA(latestSHA);
|
| 619 |
+
logAdminEvent(req, 'set_sha', { requestedSha: latestSHA });
|
| 620 |
console.log(`[${PUBLIC_URL}] Manual SHA set by admin: ${latestSHA}`);
|
| 621 |
return res.send(`Version set to commit ${latestSHA}`);
|
| 622 |
}
|
| 623 |
|
| 624 |
await fetchLatestSHA();
|
| 625 |
+
logAdminEvent(req, 'refresh_latest', { resolvedSha: latestSHA });
|
| 626 |
res.send(`Latest version refreshed: ${latestSHA}`);
|
| 627 |
});
|
| 628 |
|
| 629 |
+
app.get('/admin/status', requireAdminTurnstile, verifyLimiter, async (req, res) => {
|
| 630 |
+
const token = req.query.token;
|
| 631 |
+
if (token !== ADMIN_TOKEN) {
|
| 632 |
+
logAdminEvent(req, 'status_denied', { reason: 'bad_token' });
|
| 633 |
+
return res.status(403).json({ error: 'Forbidden' });
|
| 634 |
+
}
|
| 635 |
+
logAdminEvent(req, 'status_view', {
|
| 636 |
+
currentSha: latestSHA,
|
| 637 |
+
servingMode: LOCAL_UI_DIR ? 'local-ui' : 'cdn',
|
| 638 |
+
});
|
| 639 |
+
res.json({
|
| 640 |
+
publicUrl: PUBLIC_URL,
|
| 641 |
+
currentSha: latestSHA,
|
| 642 |
+
servingMode: LOCAL_UI_DIR ? 'local-ui' : 'cdn',
|
| 643 |
+
localUiDir: LOCAL_UI_DIR,
|
| 644 |
+
repo: GITHUB_REPO,
|
| 645 |
+
uiSource: LOCAL_UI_DIR ? 'Local InferencePort-Pages checkout is active' : 'Serving from CDN SHA',
|
| 646 |
+
});
|
| 647 |
+
});
|
| 648 |
+
|
| 649 |
+
// --- MIME type helper ---
|
| 650 |
function getMimeType(filePath){
|
| 651 |
const ext = path.extname(filePath).toLowerCase();
|
| 652 |
switch(ext){
|
|
|
|
| 664 |
}
|
| 665 |
}
|
| 666 |
|
| 667 |
+
function resolveLocalUiFile(filePath) {
|
| 668 |
+
if (!LOCAL_UI_DIR) return null;
|
| 669 |
+
const resolvedRoot = path.resolve(LOCAL_UI_DIR);
|
| 670 |
+
const relativePath = String(filePath || '/index.html').replace(/^[/\\]+/, '');
|
| 671 |
+
const resolvedFile = path.resolve(resolvedRoot, relativePath);
|
| 672 |
+
if (resolvedFile !== resolvedRoot && !resolvedFile.startsWith(`${resolvedRoot}${path.sep}`)) return null;
|
| 673 |
+
if (!fs.existsSync(resolvedFile) || fs.statSync(resolvedFile).isDirectory()) return null;
|
| 674 |
+
return resolvedFile;
|
| 675 |
+
}
|
| 676 |
+
|
| 677 |
+
function serveLocalUiFile(filePath, res) {
|
| 678 |
+
const resolvedFile = resolveLocalUiFile(filePath);
|
| 679 |
+
if (!resolvedFile) return false;
|
| 680 |
+
res.setHeader('Content-Type', getMimeType(resolvedFile));
|
| 681 |
+
res.send(fs.readFileSync(resolvedFile));
|
| 682 |
+
return true;
|
| 683 |
+
}
|
| 684 |
+
|
| 685 |
+
// Hybrid serving: everything via latest SHA (auto-refresh / manual refresh)
|
| 686 |
app.get('*', async (req,res)=>{
|
| 687 |
if(req.path.startsWith('/api/')) return res.status(404).send('Not found');
|
| 688 |
|
| 689 |
+
// All client files are fetched from latest SHA
|
| 690 |
const filePath = req.path === '/' ? '/index.html' : req.path;
|
| 691 |
+
if (serveLocalUiFile(filePath, res)) return;
|
| 692 |
const url = `${CDN_BASE}@${latestSHA}${filePath}`;
|
| 693 |
|
| 694 |
try{
|
|
|
|
| 698 |
const mimeType = getMimeType(filePath);
|
| 699 |
res.setHeader('Content-Type', mimeType);
|
| 700 |
|
| 701 |
+
// Stream text files; redirect others (images/fonts) optional
|
| 702 |
if(mimeType.startsWith('text') || mimeType==='application/javascript' || mimeType==='application/json'){
|
| 703 |
const text = await response.text();
|
| 704 |
res.send(text);
|
|
|
|
| 712 |
}
|
| 713 |
});
|
| 714 |
|
| 715 |
+
// --- WebSocket ---
|
| 716 |
const httpServer = createServer(app);
|
| 717 |
const wss = new WebSocketServer({server:httpServer,path:'/ws'});
|
| 718 |
|
|
|
|
| 737 |
safeSend(ws,{type:'connected', tempId:wsClients.get(ws)?.tempId});
|
| 738 |
});
|
| 739 |
|
| 740 |
+
httpServer.listen(PORT,'0.0.0.0',()=>console.log(`Running on port ${PORT}`));
|
server/mediaStore.js
ADDED
|
@@ -0,0 +1,517 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import crypto from 'crypto';
|
| 2 |
+
import fs from 'fs/promises';
|
| 3 |
+
import path from 'path';
|
| 4 |
+
import { loadEncryptedJson, saveEncryptedJson, readEncryptedFile, writeEncryptedFile } from './cryptoUtils.js';
|
| 5 |
+
|
| 6 |
+
const DATA_ROOT = '/data/media';
|
| 7 |
+
const INDEX_FILE = path.join(DATA_ROOT, 'index.json');
|
| 8 |
+
const BLOBS_DIR = path.join(DATA_ROOT, 'blobs');
|
| 9 |
+
const TRASH_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
| 10 |
+
const GUEST_RETENTION_MS = 24 * 60 * 60 * 1000;
|
| 11 |
+
const MEDIA_QUOTA_BYTES = 5 * 1024 * 1024 * 1024;
|
| 12 |
+
|
| 13 |
+
const state = {
|
| 14 |
+
loaded: false,
|
| 15 |
+
index: {
|
| 16 |
+
entries: {},
|
| 17 |
+
},
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
function nowIso() {
|
| 21 |
+
return new Date().toISOString();
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
function ownerKey(owner) {
|
| 25 |
+
return `${owner.type}:${owner.id}`;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
function ensureOwner(owner) {
|
| 29 |
+
if (!owner?.type || !owner?.id) throw new Error('Invalid media owner');
|
| 30 |
+
return owner;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
function normalizeName(name, fallback = 'Untitled') {
|
| 34 |
+
const clean = String(name || '').trim().replace(/[\\/:*?"<>|]+/g, '_');
|
| 35 |
+
return clean || fallback;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
function extensionFromName(name = '') {
|
| 39 |
+
const ext = path.extname(String(name || '')).toLowerCase();
|
| 40 |
+
return ext.startsWith('.') ? ext.slice(1) : ext;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
function inferKind(name, mimeType = '') {
|
| 44 |
+
const mime = String(mimeType || '').toLowerCase();
|
| 45 |
+
const ext = extensionFromName(name);
|
| 46 |
+
if (mime.startsWith('image/') || ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext)) return 'image';
|
| 47 |
+
if (mime.startsWith('video/') || ['mp4', 'webm', 'mov'].includes(ext)) return 'video';
|
| 48 |
+
if (mime.startsWith('audio/') || ['mp3', 'wav', 'ogg', 'm4a'].includes(ext)) return 'audio';
|
| 49 |
+
if (mime === 'text/html' || ['html', 'htm'].includes(ext)) return 'rich_text';
|
| 50 |
+
if (mime.startsWith('text/') || ['txt', 'md', 'json', 'js', 'ts', 'css', 'py', 'html', 'xml', 'csv', 'rtf'].includes(ext)) return 'text';
|
| 51 |
+
return 'file';
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
function guessMimeType(name, fallbackKind = 'file') {
|
| 55 |
+
const ext = extensionFromName(name);
|
| 56 |
+
switch (ext) {
|
| 57 |
+
case 'txt': return 'text/plain';
|
| 58 |
+
case 'md': return 'text/markdown';
|
| 59 |
+
case 'json': return 'application/json';
|
| 60 |
+
case 'html':
|
| 61 |
+
case 'htm': return 'text/html';
|
| 62 |
+
case 'css': return 'text/css';
|
| 63 |
+
case 'js': return 'application/javascript';
|
| 64 |
+
case 'ts': return 'text/plain';
|
| 65 |
+
case 'svg': return 'image/svg+xml';
|
| 66 |
+
case 'png': return 'image/png';
|
| 67 |
+
case 'jpg':
|
| 68 |
+
case 'jpeg': return 'image/jpeg';
|
| 69 |
+
case 'gif': return 'image/gif';
|
| 70 |
+
case 'webp': return 'image/webp';
|
| 71 |
+
case 'mp4': return 'video/mp4';
|
| 72 |
+
case 'webm': return 'video/webm';
|
| 73 |
+
case 'mp3': return 'audio/mpeg';
|
| 74 |
+
case 'wav': return 'audio/wav';
|
| 75 |
+
case 'ogg': return 'audio/ogg';
|
| 76 |
+
case 'csv': return 'text/csv';
|
| 77 |
+
default:
|
| 78 |
+
if (fallbackKind === 'rich_text') return 'text/html';
|
| 79 |
+
if (fallbackKind === 'text') return 'text/plain';
|
| 80 |
+
return 'application/octet-stream';
|
| 81 |
+
}
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
async function ensureLoaded() {
|
| 85 |
+
if (state.loaded) return;
|
| 86 |
+
const stored = await loadEncryptedJson(INDEX_FILE);
|
| 87 |
+
state.index = {
|
| 88 |
+
entries: stored?.entries || {},
|
| 89 |
+
};
|
| 90 |
+
state.loaded = true;
|
| 91 |
+
await purgeExpiredInternal();
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
async function saveIndex() {
|
| 95 |
+
await saveEncryptedJson(INDEX_FILE, state.index);
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
function getEntry(id) {
|
| 99 |
+
return state.index.entries[id] || null;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
function canAccess(entry, owner) {
|
| 103 |
+
return !!entry && entry.ownerType === owner.type && entry.ownerId === owner.id;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
function sanitizeEntry(entry) {
|
| 107 |
+
return {
|
| 108 |
+
id: entry.id,
|
| 109 |
+
type: entry.type,
|
| 110 |
+
name: entry.name,
|
| 111 |
+
parentId: entry.parentId || null,
|
| 112 |
+
ownerType: entry.ownerType,
|
| 113 |
+
ownerId: entry.ownerId,
|
| 114 |
+
mimeType: entry.mimeType || null,
|
| 115 |
+
kind: entry.kind || null,
|
| 116 |
+
size: entry.size || 0,
|
| 117 |
+
source: entry.source || null,
|
| 118 |
+
createdAt: entry.createdAt,
|
| 119 |
+
updatedAt: entry.updatedAt,
|
| 120 |
+
trashedAt: entry.trashedAt || null,
|
| 121 |
+
purgeAt: entry.purgeAt || null,
|
| 122 |
+
sessionIds: entry.sessionIds || [],
|
| 123 |
+
deletedByAssistant: !!entry.deletedByAssistant,
|
| 124 |
+
};
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
function blobPathFor(id) {
|
| 128 |
+
return path.join(BLOBS_DIR, `${id}.bin`);
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
function buildAad(entry) {
|
| 132 |
+
return `media:${entry.id}:${entry.ownerType}:${entry.ownerId}`;
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
function isOwnedFile(entry, owner) {
|
| 136 |
+
return entry?.type === 'file' && entry.ownerType === owner.type && entry.ownerId === owner.id;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
function getChildren(owner, parentId, includeTrash = true) {
|
| 140 |
+
return Object.values(state.index.entries).filter((entry) =>
|
| 141 |
+
entry.ownerType === owner.type &&
|
| 142 |
+
entry.ownerId === owner.id &&
|
| 143 |
+
(entry.parentId || null) === (parentId || null) &&
|
| 144 |
+
(includeTrash || !entry.trashedAt)
|
| 145 |
+
);
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
function collectDescendantIds(owner, rootId, acc = new Set()) {
|
| 149 |
+
acc.add(rootId);
|
| 150 |
+
const children = getChildren(owner, rootId, true);
|
| 151 |
+
for (const child of children) {
|
| 152 |
+
collectDescendantIds(owner, child.id, acc);
|
| 153 |
+
}
|
| 154 |
+
return [...acc];
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
function resolveParentFolder(owner, parentId) {
|
| 158 |
+
if (!parentId) return null;
|
| 159 |
+
const parent = getEntry(parentId);
|
| 160 |
+
if (!parent || !canAccess(parent, owner) || parent.type !== 'folder') return null;
|
| 161 |
+
return parent.id;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
function wouldCreateCycle(owner, entryId, candidateParentId) {
|
| 165 |
+
let currentId = candidateParentId || null;
|
| 166 |
+
while (currentId) {
|
| 167 |
+
if (currentId === entryId) return true;
|
| 168 |
+
const current = getEntry(currentId);
|
| 169 |
+
if (!current || !canAccess(current, owner)) return false;
|
| 170 |
+
currentId = current.parentId || null;
|
| 171 |
+
}
|
| 172 |
+
return false;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
function createQuotaError(owner, usage) {
|
| 176 |
+
const err = new Error('Cloud storage limit reached. Delete files or empty trash to free space.');
|
| 177 |
+
err.code = 'media:quota_exceeded';
|
| 178 |
+
err.status = 413;
|
| 179 |
+
err.usage = usage || null;
|
| 180 |
+
err.owner = owner;
|
| 181 |
+
return err;
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
function computeUsage(owner) {
|
| 185 |
+
const files = Object.values(state.index.entries).filter((entry) => isOwnedFile(entry, owner));
|
| 186 |
+
const totalBytes = files.reduce((sum, entry) => sum + (entry.size || 0), 0);
|
| 187 |
+
const trashBytes = files.reduce((sum, entry) => sum + (entry.trashedAt ? (entry.size || 0) : 0), 0);
|
| 188 |
+
const activeBytes = totalBytes - trashBytes;
|
| 189 |
+
const quotaBytes = MEDIA_QUOTA_BYTES;
|
| 190 |
+
return {
|
| 191 |
+
quotaBytes,
|
| 192 |
+
totalBytes,
|
| 193 |
+
activeBytes,
|
| 194 |
+
trashBytes,
|
| 195 |
+
remainingBytes: Math.max(0, quotaBytes - totalBytes),
|
| 196 |
+
percentUsed: quotaBytes > 0 ? Math.min(100, (totalBytes / quotaBytes) * 100) : 0,
|
| 197 |
+
fileCount: files.length,
|
| 198 |
+
trashFileCount: files.filter((entry) => !!entry.trashedAt).length,
|
| 199 |
+
};
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
function assertQuotaAvailable(owner, additionalBytes = 0) {
|
| 203 |
+
const usage = computeUsage(owner);
|
| 204 |
+
if ((usage.totalBytes + Math.max(0, additionalBytes)) > usage.quotaBytes) {
|
| 205 |
+
throw createQuotaError(owner, usage);
|
| 206 |
+
}
|
| 207 |
+
return usage;
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
async function purgeEntry(id) {
|
| 211 |
+
const entry = getEntry(id);
|
| 212 |
+
if (!entry) return;
|
| 213 |
+
if (entry.type === 'file') {
|
| 214 |
+
await fs.rm(blobPathFor(id), { force: true }).catch(() => {});
|
| 215 |
+
}
|
| 216 |
+
delete state.index.entries[id];
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
async function purgeExpiredInternal() {
|
| 220 |
+
const now = Date.now();
|
| 221 |
+
let changed = false;
|
| 222 |
+
for (const entry of Object.values(state.index.entries)) {
|
| 223 |
+
const shouldPurge =
|
| 224 |
+
(entry.purgeAt && new Date(entry.purgeAt).getTime() <= now) ||
|
| 225 |
+
(entry.expiresAt && new Date(entry.expiresAt).getTime() <= now);
|
| 226 |
+
if (!shouldPurge) continue;
|
| 227 |
+
for (const id of collectDescendantIds({ type: entry.ownerType, id: entry.ownerId }, entry.id)) {
|
| 228 |
+
await purgeEntry(id);
|
| 229 |
+
}
|
| 230 |
+
changed = true;
|
| 231 |
+
}
|
| 232 |
+
if (changed) await saveIndex();
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
setInterval(() => {
|
| 236 |
+
purgeExpiredInternal().catch((err) => console.error('mediaStore cleanup failed:', err));
|
| 237 |
+
}, 6 * 60 * 60 * 1000);
|
| 238 |
+
|
| 239 |
+
export const mediaStore = {
|
| 240 |
+
async list(owner, { view = 'active', parentId = null } = {}) {
|
| 241 |
+
ensureOwner(owner);
|
| 242 |
+
await ensureLoaded();
|
| 243 |
+
|
| 244 |
+
const includeTrash = view === 'trash';
|
| 245 |
+
const items = getChildren(owner, parentId, true)
|
| 246 |
+
.filter((entry) => includeTrash ? !!entry.trashedAt : !entry.trashedAt)
|
| 247 |
+
.sort((a, b) => {
|
| 248 |
+
if (a.type !== b.type) return a.type === 'folder' ? -1 : 1;
|
| 249 |
+
return new Date(b.updatedAt || b.createdAt).getTime() - new Date(a.updatedAt || a.createdAt).getTime();
|
| 250 |
+
})
|
| 251 |
+
.map(sanitizeEntry);
|
| 252 |
+
|
| 253 |
+
const breadcrumbs = [];
|
| 254 |
+
let currentId = parentId;
|
| 255 |
+
while (currentId) {
|
| 256 |
+
const entry = getEntry(currentId);
|
| 257 |
+
if (!entry || !canAccess(entry, owner)) break;
|
| 258 |
+
breadcrumbs.unshift({ id: entry.id, name: entry.name });
|
| 259 |
+
currentId = entry.parentId || null;
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
return { items, breadcrumbs, usage: computeUsage(owner) };
|
| 263 |
+
},
|
| 264 |
+
|
| 265 |
+
async get(owner, id) {
|
| 266 |
+
ensureOwner(owner);
|
| 267 |
+
await ensureLoaded();
|
| 268 |
+
const entry = getEntry(id);
|
| 269 |
+
if (!canAccess(entry, owner)) return null;
|
| 270 |
+
return sanitizeEntry(entry);
|
| 271 |
+
},
|
| 272 |
+
|
| 273 |
+
async storeBuffer(owner, {
|
| 274 |
+
name,
|
| 275 |
+
mimeType,
|
| 276 |
+
buffer,
|
| 277 |
+
parentId = null,
|
| 278 |
+
sessionId = null,
|
| 279 |
+
source = 'upload',
|
| 280 |
+
kind = null,
|
| 281 |
+
deletedByAssistant = false,
|
| 282 |
+
}) {
|
| 283 |
+
ensureOwner(owner);
|
| 284 |
+
await ensureLoaded();
|
| 285 |
+
assertQuotaAvailable(owner, Buffer.byteLength(buffer));
|
| 286 |
+
|
| 287 |
+
const entryId = crypto.randomUUID();
|
| 288 |
+
const resolvedParentId = resolveParentFolder(owner, parentId);
|
| 289 |
+
const fileName = normalizeName(name, 'Untitled');
|
| 290 |
+
const inferredKind = kind || inferKind(fileName, mimeType);
|
| 291 |
+
const entry = {
|
| 292 |
+
id: entryId,
|
| 293 |
+
type: 'file',
|
| 294 |
+
name: fileName,
|
| 295 |
+
ownerType: owner.type,
|
| 296 |
+
ownerId: owner.id,
|
| 297 |
+
mimeType: mimeType || guessMimeType(fileName, inferredKind),
|
| 298 |
+
kind: inferredKind,
|
| 299 |
+
size: buffer.byteLength,
|
| 300 |
+
parentId: resolvedParentId,
|
| 301 |
+
source,
|
| 302 |
+
createdAt: nowIso(),
|
| 303 |
+
updatedAt: nowIso(),
|
| 304 |
+
trashedAt: null,
|
| 305 |
+
purgeAt: null,
|
| 306 |
+
sessionIds: sessionId ? [sessionId] : [],
|
| 307 |
+
expiresAt: owner.type === 'guest' ? new Date(Date.now() + GUEST_RETENTION_MS).toISOString() : null,
|
| 308 |
+
deletedByAssistant,
|
| 309 |
+
};
|
| 310 |
+
|
| 311 |
+
await writeEncryptedFile(blobPathFor(entry.id), Buffer.from(buffer), buildAad(entry));
|
| 312 |
+
state.index.entries[entry.id] = entry;
|
| 313 |
+
await saveIndex();
|
| 314 |
+
return sanitizeEntry(entry);
|
| 315 |
+
},
|
| 316 |
+
|
| 317 |
+
async createFolder(owner, { name, parentId = null }) {
|
| 318 |
+
ensureOwner(owner);
|
| 319 |
+
await ensureLoaded();
|
| 320 |
+
const resolvedParentId = resolveParentFolder(owner, parentId);
|
| 321 |
+
|
| 322 |
+
const folder = {
|
| 323 |
+
id: crypto.randomUUID(),
|
| 324 |
+
type: 'folder',
|
| 325 |
+
name: normalizeName(name, 'New Folder'),
|
| 326 |
+
ownerType: owner.type,
|
| 327 |
+
ownerId: owner.id,
|
| 328 |
+
parentId: resolvedParentId,
|
| 329 |
+
createdAt: nowIso(),
|
| 330 |
+
updatedAt: nowIso(),
|
| 331 |
+
trashedAt: null,
|
| 332 |
+
purgeAt: null,
|
| 333 |
+
sessionIds: [],
|
| 334 |
+
expiresAt: owner.type === 'guest' ? new Date(Date.now() + GUEST_RETENTION_MS).toISOString() : null,
|
| 335 |
+
};
|
| 336 |
+
|
| 337 |
+
state.index.entries[folder.id] = folder;
|
| 338 |
+
await saveIndex();
|
| 339 |
+
return sanitizeEntry(folder);
|
| 340 |
+
},
|
| 341 |
+
|
| 342 |
+
async createDocument(owner, {
|
| 343 |
+
name,
|
| 344 |
+
parentId = null,
|
| 345 |
+
richText = false,
|
| 346 |
+
content = '',
|
| 347 |
+
source = 'upload',
|
| 348 |
+
sessionId = null,
|
| 349 |
+
}) {
|
| 350 |
+
const normalizedName = normalizeName(
|
| 351 |
+
name,
|
| 352 |
+
richText ? 'Untitled Document.html' : 'Untitled Document.txt'
|
| 353 |
+
);
|
| 354 |
+
return this.storeBuffer(owner, {
|
| 355 |
+
name: normalizedName,
|
| 356 |
+
mimeType: richText ? 'text/html' : 'text/plain',
|
| 357 |
+
buffer: Buffer.from(content || (richText ? '<p></p>' : ''), 'utf8'),
|
| 358 |
+
parentId,
|
| 359 |
+
sessionId,
|
| 360 |
+
source,
|
| 361 |
+
kind: richText ? 'rich_text' : 'text',
|
| 362 |
+
});
|
| 363 |
+
},
|
| 364 |
+
|
| 365 |
+
async readBuffer(owner, id) {
|
| 366 |
+
ensureOwner(owner);
|
| 367 |
+
await ensureLoaded();
|
| 368 |
+
const entry = getEntry(id);
|
| 369 |
+
if (!entry || entry.type !== 'file' || !canAccess(entry, owner)) return null;
|
| 370 |
+
return {
|
| 371 |
+
entry: sanitizeEntry(entry),
|
| 372 |
+
buffer: await readEncryptedFile(blobPathFor(id), buildAad(entry)),
|
| 373 |
+
};
|
| 374 |
+
},
|
| 375 |
+
|
| 376 |
+
async readText(owner, id) {
|
| 377 |
+
const loaded = await this.readBuffer(owner, id);
|
| 378 |
+
if (!loaded) return null;
|
| 379 |
+
return {
|
| 380 |
+
entry: loaded.entry,
|
| 381 |
+
text: loaded.buffer.toString('utf8'),
|
| 382 |
+
};
|
| 383 |
+
},
|
| 384 |
+
|
| 385 |
+
async updateContent(owner, id, { buffer, name = null, mimeType = null, kind = null }) {
|
| 386 |
+
ensureOwner(owner);
|
| 387 |
+
await ensureLoaded();
|
| 388 |
+
const entry = getEntry(id);
|
| 389 |
+
if (!entry || entry.type !== 'file' || !canAccess(entry, owner)) return null;
|
| 390 |
+
const nextSize = Buffer.byteLength(buffer);
|
| 391 |
+
const delta = nextSize - (entry.size || 0);
|
| 392 |
+
if (delta > 0) assertQuotaAvailable(owner, delta);
|
| 393 |
+
|
| 394 |
+
if (name) entry.name = normalizeName(name, entry.name);
|
| 395 |
+
if (mimeType) entry.mimeType = mimeType;
|
| 396 |
+
if (kind) entry.kind = kind;
|
| 397 |
+
entry.size = nextSize;
|
| 398 |
+
entry.updatedAt = nowIso();
|
| 399 |
+
|
| 400 |
+
await writeEncryptedFile(blobPathFor(entry.id), Buffer.from(buffer), buildAad(entry));
|
| 401 |
+
await saveIndex();
|
| 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();
|
| 419 |
+
const destinationId = parentId ? resolveParentFolder(owner, parentId) : null;
|
| 420 |
+
if (parentId && !destinationId) throw new Error('Invalid destination folder');
|
| 421 |
+
const updated = [];
|
| 422 |
+
for (const id of ids || []) {
|
| 423 |
+
const entry = getEntry(id);
|
| 424 |
+
if (!entry || !canAccess(entry, owner)) continue;
|
| 425 |
+
if (destinationId === entry.id) continue;
|
| 426 |
+
if (wouldCreateCycle(owner, entry.id, destinationId)) continue;
|
| 427 |
+
entry.parentId = destinationId;
|
| 428 |
+
entry.updatedAt = nowIso();
|
| 429 |
+
updated.push(sanitizeEntry(entry));
|
| 430 |
+
}
|
| 431 |
+
if (updated.length) await saveIndex();
|
| 432 |
+
return updated;
|
| 433 |
+
},
|
| 434 |
+
|
| 435 |
+
async moveToTrash(owner, ids) {
|
| 436 |
+
ensureOwner(owner);
|
| 437 |
+
await ensureLoaded();
|
| 438 |
+
const trashed = [];
|
| 439 |
+
const now = nowIso();
|
| 440 |
+
const purgeAt = new Date(Date.now() + TRASH_RETENTION_MS).toISOString();
|
| 441 |
+
|
| 442 |
+
for (const id of ids || []) {
|
| 443 |
+
const entry = getEntry(id);
|
| 444 |
+
if (!entry || !canAccess(entry, owner)) continue;
|
| 445 |
+
for (const targetId of collectDescendantIds(owner, id)) {
|
| 446 |
+
const target = getEntry(targetId);
|
| 447 |
+
if (!target) continue;
|
| 448 |
+
target.trashedAt = now;
|
| 449 |
+
target.purgeAt = purgeAt;
|
| 450 |
+
target.updatedAt = now;
|
| 451 |
+
trashed.push(sanitizeEntry(target));
|
| 452 |
+
}
|
| 453 |
+
}
|
| 454 |
+
if (trashed.length) await saveIndex();
|
| 455 |
+
return trashed;
|
| 456 |
+
},
|
| 457 |
+
|
| 458 |
+
async restore(owner, ids) {
|
| 459 |
+
ensureOwner(owner);
|
| 460 |
+
await ensureLoaded();
|
| 461 |
+
const restored = [];
|
| 462 |
+
for (const id of ids || []) {
|
| 463 |
+
const entry = getEntry(id);
|
| 464 |
+
if (!entry || !canAccess(entry, owner)) continue;
|
| 465 |
+
for (const targetId of collectDescendantIds(owner, id)) {
|
| 466 |
+
const target = getEntry(targetId);
|
| 467 |
+
if (!target) continue;
|
| 468 |
+
target.trashedAt = null;
|
| 469 |
+
target.purgeAt = null;
|
| 470 |
+
target.updatedAt = nowIso();
|
| 471 |
+
restored.push(sanitizeEntry(target));
|
| 472 |
+
}
|
| 473 |
+
}
|
| 474 |
+
if (restored.length) await saveIndex();
|
| 475 |
+
return restored;
|
| 476 |
+
},
|
| 477 |
+
|
| 478 |
+
async deleteForever(owner, ids) {
|
| 479 |
+
ensureOwner(owner);
|
| 480 |
+
await ensureLoaded();
|
| 481 |
+
const removedIds = new Set();
|
| 482 |
+
for (const id of ids || []) {
|
| 483 |
+
const entry = getEntry(id);
|
| 484 |
+
if (!entry || !canAccess(entry, owner)) continue;
|
| 485 |
+
for (const targetId of collectDescendantIds(owner, id)) {
|
| 486 |
+
removedIds.add(targetId);
|
| 487 |
+
}
|
| 488 |
+
}
|
| 489 |
+
for (const targetId of removedIds) {
|
| 490 |
+
await purgeEntry(targetId);
|
| 491 |
+
}
|
| 492 |
+
if (removedIds.size) await saveIndex();
|
| 493 |
+
return [...removedIds];
|
| 494 |
+
},
|
| 495 |
+
|
| 496 |
+
async attachToSession(owner, ids, sessionId) {
|
| 497 |
+
ensureOwner(owner);
|
| 498 |
+
if (!sessionId) return [];
|
| 499 |
+
await ensureLoaded();
|
| 500 |
+
const updated = [];
|
| 501 |
+
for (const id of ids || []) {
|
| 502 |
+
const entry = getEntry(id);
|
| 503 |
+
if (!entry || !canAccess(entry, owner)) continue;
|
| 504 |
+
entry.sessionIds = [...new Set([...(entry.sessionIds || []), sessionId])];
|
| 505 |
+
entry.updatedAt = nowIso();
|
| 506 |
+
updated.push(sanitizeEntry(entry));
|
| 507 |
+
}
|
| 508 |
+
if (updated.length) await saveIndex();
|
| 509 |
+
return updated;
|
| 510 |
+
},
|
| 511 |
+
|
| 512 |
+
async getUsage(owner) {
|
| 513 |
+
ensureOwner(owner);
|
| 514 |
+
await ensureLoaded();
|
| 515 |
+
return computeUsage(owner);
|
| 516 |
+
},
|
| 517 |
+
};
|
server/memoryStore.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import crypto from 'crypto';
|
| 2 |
+
import path from 'path';
|
| 3 |
+
import { loadEncryptedJson, saveEncryptedJson } from './cryptoUtils.js';
|
| 4 |
+
|
| 5 |
+
const DATA_ROOT = '/data/memories';
|
| 6 |
+
const INDEX_FILE = path.join(DATA_ROOT, 'index.json');
|
| 7 |
+
const MAX_MEMORY_LENGTH = 220;
|
| 8 |
+
|
| 9 |
+
const state = {
|
| 10 |
+
loaded: false,
|
| 11 |
+
index: {
|
| 12 |
+
memories: {},
|
| 13 |
+
},
|
| 14 |
+
};
|
| 15 |
+
|
| 16 |
+
function ensureOwner(owner) {
|
| 17 |
+
if (!owner?.type || !owner?.id) throw new Error('Invalid memory owner');
|
| 18 |
+
return owner;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
function nowIso() {
|
| 22 |
+
return new Date().toISOString();
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
function sanitizeText(text) {
|
| 26 |
+
return String(text || '').replace(/\s+/g, ' ').trim().slice(0, MAX_MEMORY_LENGTH);
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
async function ensureLoaded() {
|
| 30 |
+
if (state.loaded) return;
|
| 31 |
+
const stored = await loadEncryptedJson(INDEX_FILE);
|
| 32 |
+
state.index = {
|
| 33 |
+
memories: stored?.memories || {},
|
| 34 |
+
};
|
| 35 |
+
state.loaded = true;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
async function saveIndex() {
|
| 39 |
+
await saveEncryptedJson(INDEX_FILE, state.index);
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
function matchesOwner(memory, owner) {
|
| 43 |
+
return memory.ownerType === owner.type && memory.ownerId === owner.id;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
function sanitize(memory) {
|
| 47 |
+
return {
|
| 48 |
+
id: memory.id,
|
| 49 |
+
content: memory.content,
|
| 50 |
+
source: memory.source || 'assistant',
|
| 51 |
+
sessionId: memory.sessionId || null,
|
| 52 |
+
createdAt: memory.createdAt,
|
| 53 |
+
updatedAt: memory.updatedAt,
|
| 54 |
+
};
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
export const memoryStore = {
|
| 58 |
+
async list(owner) {
|
| 59 |
+
ensureOwner(owner);
|
| 60 |
+
await ensureLoaded();
|
| 61 |
+
return Object.values(state.index.memories)
|
| 62 |
+
.filter((memory) => matchesOwner(memory, owner))
|
| 63 |
+
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
|
| 64 |
+
.map(sanitize);
|
| 65 |
+
},
|
| 66 |
+
|
| 67 |
+
async create(owner, { content, sessionId = null, source = 'assistant' }) {
|
| 68 |
+
ensureOwner(owner);
|
| 69 |
+
await ensureLoaded();
|
| 70 |
+
const normalized = sanitizeText(content);
|
| 71 |
+
if (!normalized) return null;
|
| 72 |
+
|
| 73 |
+
const memory = {
|
| 74 |
+
id: crypto.randomUUID(),
|
| 75 |
+
ownerType: owner.type,
|
| 76 |
+
ownerId: owner.id,
|
| 77 |
+
content: normalized,
|
| 78 |
+
sessionId,
|
| 79 |
+
source,
|
| 80 |
+
createdAt: nowIso(),
|
| 81 |
+
updatedAt: nowIso(),
|
| 82 |
+
};
|
| 83 |
+
state.index.memories[memory.id] = memory;
|
| 84 |
+
await saveIndex();
|
| 85 |
+
return sanitize(memory);
|
| 86 |
+
},
|
| 87 |
+
|
| 88 |
+
async update(owner, id, content) {
|
| 89 |
+
ensureOwner(owner);
|
| 90 |
+
await ensureLoaded();
|
| 91 |
+
const memory = state.index.memories[id];
|
| 92 |
+
if (!memory || !matchesOwner(memory, owner)) return null;
|
| 93 |
+
const normalized = sanitizeText(content);
|
| 94 |
+
if (!normalized) return null;
|
| 95 |
+
memory.content = normalized;
|
| 96 |
+
memory.updatedAt = nowIso();
|
| 97 |
+
await saveIndex();
|
| 98 |
+
return sanitize(memory);
|
| 99 |
+
},
|
| 100 |
+
|
| 101 |
+
async delete(owner, id) {
|
| 102 |
+
ensureOwner(owner);
|
| 103 |
+
await ensureLoaded();
|
| 104 |
+
const memory = state.index.memories[id];
|
| 105 |
+
if (!memory || !matchesOwner(memory, owner)) return false;
|
| 106 |
+
delete state.index.memories[id];
|
| 107 |
+
await saveIndex();
|
| 108 |
+
return true;
|
| 109 |
+
},
|
| 110 |
+
};
|
server/sessionStore.js
CHANGED
|
@@ -89,8 +89,24 @@ export const sessionStore = {
|
|
| 89 |
saveTempStore().catch(err => console.error('Failed to save temp store:', err));
|
| 90 |
return s;
|
| 91 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
deleteTempSession(t, id) { tempStore.get(t)?.sessions.delete(id); saveTempStore().catch(err => console.error('Failed to save temp store:', err)); },
|
| 93 |
deleteTempAll(t) { tempStore.get(t)?.sessions.clear(); saveTempStore().catch(err => console.error('Failed to save temp store:', err)); },
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
|
| 95 |
/**
|
| 96 |
* Copy temp sessions into the user's account on login.
|
|
@@ -146,6 +162,12 @@ export const sessionStore = {
|
|
| 146 |
await this._persist(userClient(accessToken), userId, s).catch(() => {});
|
| 147 |
return s;
|
| 148 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
async updateUserSession(userId, accessToken, sessionId, patch) {
|
| 150 |
const user = userCache.get(userId); if (!user) { console.error("No user for " + userId); return null; }
|
| 151 |
const s = user.sessions.get(sessionId); if (!s) { console.error ("No session found for " + sessionId); return null; }
|
|
@@ -228,8 +250,8 @@ export const deviceSessionStore = {
|
|
| 228 |
createdAt: new Date().toISOString(), lastSeen: new Date().toISOString(), active: true });
|
| 229 |
return token;
|
| 230 |
},
|
| 231 |
-
getForUser(uid) { return [...devSessions.values()].filter(s => s.userId === uid); },
|
| 232 |
-
revoke(token) { const s = devSessions.get(token); if (s) s.active = false; },
|
| 233 |
revokeAllExcept(uid, except) {
|
| 234 |
for (const [t, s] of devSessions) if (s.userId === uid && t !== except) s.active = false;
|
| 235 |
},
|
|
@@ -237,4 +259,4 @@ export const deviceSessionStore = {
|
|
| 237 |
const s = devSessions.get(token); if (!s || !s.active) return null;
|
| 238 |
s.lastSeen = new Date().toISOString(); return s;
|
| 239 |
},
|
| 240 |
-
};
|
|
|
|
| 89 |
saveTempStore().catch(err => console.error('Failed to save temp store:', err));
|
| 90 |
return s;
|
| 91 |
},
|
| 92 |
+
restoreTempSession(t, session) {
|
| 93 |
+
const d = this.initTemp(t);
|
| 94 |
+
const restored = JSON.parse(JSON.stringify(session));
|
| 95 |
+
d.sessions.set(restored.id, restored);
|
| 96 |
+
d.lastActive = Date.now();
|
| 97 |
+
saveTempStore().catch(err => console.error('Failed to save temp store:', err));
|
| 98 |
+
return restored;
|
| 99 |
+
},
|
| 100 |
deleteTempSession(t, id) { tempStore.get(t)?.sessions.delete(id); saveTempStore().catch(err => console.error('Failed to save temp store:', err)); },
|
| 101 |
deleteTempAll(t) { tempStore.get(t)?.sessions.clear(); saveTempStore().catch(err => console.error('Failed to save temp store:', err)); },
|
| 102 |
+
deleteTempSessionEverywhere(id) {
|
| 103 |
+
let changed = false;
|
| 104 |
+
for (const temp of tempStore.values()) {
|
| 105 |
+
if (temp.sessions.delete(id)) changed = true;
|
| 106 |
+
}
|
| 107 |
+
if (changed) saveTempStore().catch(err => console.error('Failed to save temp store:', err));
|
| 108 |
+
return changed;
|
| 109 |
+
},
|
| 110 |
|
| 111 |
/**
|
| 112 |
* Copy temp sessions into the user's account on login.
|
|
|
|
| 162 |
await this._persist(userClient(accessToken), userId, s).catch(() => {});
|
| 163 |
return s;
|
| 164 |
},
|
| 165 |
+
async restoreUserSession(userId, accessToken, session) {
|
| 166 |
+
const restored = JSON.parse(JSON.stringify(session));
|
| 167 |
+
this._ensureUser(userId).sessions.set(restored.id, restored);
|
| 168 |
+
await this._persist(userClient(accessToken), userId, restored).catch(() => {});
|
| 169 |
+
return restored;
|
| 170 |
+
},
|
| 171 |
async updateUserSession(userId, accessToken, sessionId, patch) {
|
| 172 |
const user = userCache.get(userId); if (!user) { console.error("No user for " + userId); return null; }
|
| 173 |
const s = user.sessions.get(sessionId); if (!s) { console.error ("No session found for " + sessionId); return null; }
|
|
|
|
| 250 |
createdAt: new Date().toISOString(), lastSeen: new Date().toISOString(), active: true });
|
| 251 |
return token;
|
| 252 |
},
|
| 253 |
+
getForUser(uid) { return [...devSessions.values()].filter(s => s.userId === uid && s.active); },
|
| 254 |
+
revoke(token) { const s = devSessions.get(token); if (s) { s.active = false; return s; } return null; },
|
| 255 |
revokeAllExcept(uid, except) {
|
| 256 |
for (const [t, s] of devSessions) if (s.userId === uid && t !== except) s.active = false;
|
| 257 |
},
|
|
|
|
| 259 |
const s = devSessions.get(token); if (!s || !s.active) return null;
|
| 260 |
s.lastSeen = new Date().toISOString(); return s;
|
| 261 |
},
|
| 262 |
+
};
|
server/systemPromptStore.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import fs from 'fs/promises';
|
| 2 |
+
import path from 'path';
|
| 3 |
+
import { fileURLToPath } from 'url';
|
| 4 |
+
import { loadEncryptedJson, saveEncryptedJson } from './cryptoUtils.js';
|
| 5 |
+
|
| 6 |
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
| 7 |
+
const SYSTEM_PROMPT_FILE = path.resolve(__dirname, '..', 'system prompt.md');
|
| 8 |
+
const DATA_ROOT = '/data/system-prompts';
|
| 9 |
+
const INDEX_FILE = path.join(DATA_ROOT, 'index.json');
|
| 10 |
+
const MAX_PROMPT_LENGTH = 60000;
|
| 11 |
+
|
| 12 |
+
const FALLBACK_PROMPT = `
|
| 13 |
+
# Response formatting
|
| 14 |
+
|
| 15 |
+
- Every response must use HTML <span data-color="{COLOR NAME}">...</span> tags to color main points and headings unless the user asks otherwise.
|
| 16 |
+
- Colors must have meaning and stay consistent across the conversation.
|
| 17 |
+
- Only use these semantic color names: green, pink, blue, red, orange, yellow, purple, teal, gold, coral.
|
| 18 |
+
- Never output explicit black or white colors.
|
| 19 |
+
- Put color spans as close to the text as possible, and do not place markdown syntax inside the span tags.
|
| 20 |
+
- Keep code blocks plain, but color important surrounding headings and key points.
|
| 21 |
+
- Do not over-color responses. Use color intentionally and sparingly.
|
| 22 |
+
- Markdown markers such as #, ##, ###, **, and * must stay outside the color spans.
|
| 23 |
+
|
| 24 |
+
# Core behavior
|
| 25 |
+
|
| 26 |
+
- You are a helpful, friendly AI assistant.
|
| 27 |
+
- Use tools when appropriate to help the user, and if you are told to generate something, use a tool to complete the task.
|
| 28 |
+
- When generating media, do not include URLs because the media is displayed automatically.
|
| 29 |
+
- You can render SVG images by outputting SVG code in a code block tagged exactly like this:
|
| 30 |
+
|
| 31 |
+
\`\`\`svg
|
| 32 |
+
<svg>...</svg>
|
| 33 |
+
\`\`\`
|
| 34 |
+
|
| 35 |
+
- Never use single backslashes.
|
| 36 |
+
- Use markdown for everything other than the color spans.
|
| 37 |
+
- Tables, lists, and other markdown elements are encouraged when they help.
|
| 38 |
+
|
| 39 |
+
# Attachment handling
|
| 40 |
+
|
| 41 |
+
- Large user prompts, text attachments, conversation history, and image attachments may be staged into separate resources on purpose.
|
| 42 |
+
- If notes say attached text was staged separately, or that only the first part of a prompt is inline, do not assume the content is missing, corrupted, or truncated.
|
| 43 |
+
- Treat staged content as available context.
|
| 44 |
+
- Use list_prompt_resources to find staged resources.
|
| 45 |
+
- Use read_prompt_chunk to read staged text exactly.
|
| 46 |
+
- Use load_prompt_images to inspect staged images.
|
| 47 |
+
- Use write_notes to keep a compact working memory after reading several chunks.
|
| 48 |
+
- Before claiming an attachment is incomplete, missing, malformed, or unreadable, first check whether it was staged separately and read the relevant resource.
|
| 49 |
+
|
| 50 |
+
# Memory
|
| 51 |
+
|
| 52 |
+
- Persistent memories must stay short, concrete, and durable.
|
| 53 |
+
- Only save memories that will still help in future chats.
|
| 54 |
+
- Keep each memory to a brief sentence or phrase.
|
| 55 |
+
- At the start of a chat, always check the memories.
|
| 56 |
+
- If the user tells you to remember something, or there is something important to note for future chats, create a new memory.
|
| 57 |
+
- Memories should be brief.
|
| 58 |
+
- Notes are only for session-long memory, so use memories for anything relevant to future chats.
|
| 59 |
+
|
| 60 |
+
# Priorities
|
| 61 |
+
|
| 62 |
+
- Your highest priority is to help the user.
|
| 63 |
+
- Always help with anything ethically right.
|
| 64 |
+
- Make sure your responses are always accurate.
|
| 65 |
+
- If you are not completely sure about something, search the web.
|
| 66 |
+
- If you notice any issue or mistake with your response, correct it with the replace tools.
|
| 67 |
+
- Always answer as correctly as possible, and use search when unsure.
|
| 68 |
+
- Try to minimize the use of * for emphasis. Use it mainly for markdown structure.
|
| 69 |
+
|
| 70 |
+
# Session naming
|
| 71 |
+
|
| 72 |
+
- After you have fully responded to the user, append a session name tag on its own line at the very end of your response, never inside a code block.
|
| 73 |
+
- Only do this on the first response unless the user asks to change the name.
|
| 74 |
+
- The tag must be <session_name>2-4 word title summarizing this conversation</session_name>.
|
| 75 |
+
- Example: <session_name>React State Management</session_name>.
|
| 76 |
+
- A conversation must always be named on the first response.
|
| 77 |
+
- This tag is hidden from the user and is used only to name the chat.
|
| 78 |
+
- Do not mention the tag to the user.
|
| 79 |
+
`.trim();
|
| 80 |
+
|
| 81 |
+
const state = {
|
| 82 |
+
loaded: false,
|
| 83 |
+
prompts: {},
|
| 84 |
+
};
|
| 85 |
+
|
| 86 |
+
let defaultPromptPromise = null;
|
| 87 |
+
|
| 88 |
+
function normalizePrompt(markdown) {
|
| 89 |
+
return String(markdown || '')
|
| 90 |
+
.replace(/\r\n/g, '\n')
|
| 91 |
+
.trim()
|
| 92 |
+
.slice(0, MAX_PROMPT_LENGTH);
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
async function ensureLoaded() {
|
| 96 |
+
if (state.loaded) return;
|
| 97 |
+
const stored = await loadEncryptedJson(INDEX_FILE, 'system-prompts');
|
| 98 |
+
state.prompts = stored?.prompts || {};
|
| 99 |
+
state.loaded = true;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
async function saveIndex() {
|
| 103 |
+
await saveEncryptedJson(INDEX_FILE, { prompts: state.prompts }, 'system-prompts');
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
async function loadDefaultPrompt() {
|
| 107 |
+
if (!defaultPromptPromise) {
|
| 108 |
+
defaultPromptPromise = fs.readFile(SYSTEM_PROMPT_FILE, 'utf8')
|
| 109 |
+
.then((content) => normalizePrompt(content) || FALLBACK_PROMPT)
|
| 110 |
+
.catch(() => FALLBACK_PROMPT);
|
| 111 |
+
}
|
| 112 |
+
return defaultPromptPromise;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
function sanitizeRecord(record) {
|
| 116 |
+
if (!record?.markdown) return null;
|
| 117 |
+
return {
|
| 118 |
+
markdown: normalizePrompt(record.markdown),
|
| 119 |
+
updatedAt: record.updatedAt || null,
|
| 120 |
+
};
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
export const systemPromptStore = {
|
| 124 |
+
async getDefaultPrompt() {
|
| 125 |
+
return loadDefaultPrompt();
|
| 126 |
+
},
|
| 127 |
+
|
| 128 |
+
async getUserPrompt(userId) {
|
| 129 |
+
if (!userId) return null;
|
| 130 |
+
await ensureLoaded();
|
| 131 |
+
return sanitizeRecord(state.prompts[userId]);
|
| 132 |
+
},
|
| 133 |
+
|
| 134 |
+
async getResolvedPrompt(userId) {
|
| 135 |
+
const custom = await this.getUserPrompt(userId);
|
| 136 |
+
if (custom?.markdown) return custom.markdown;
|
| 137 |
+
return this.getDefaultPrompt();
|
| 138 |
+
},
|
| 139 |
+
|
| 140 |
+
async getPersonalization(userId) {
|
| 141 |
+
const [defaultPrompt, custom] = await Promise.all([
|
| 142 |
+
this.getDefaultPrompt(),
|
| 143 |
+
this.getUserPrompt(userId),
|
| 144 |
+
]);
|
| 145 |
+
return {
|
| 146 |
+
defaultPrompt,
|
| 147 |
+
customPrompt: custom?.markdown || null,
|
| 148 |
+
resolvedPrompt: custom?.markdown || defaultPrompt,
|
| 149 |
+
isCustom: !!custom?.markdown,
|
| 150 |
+
updatedAt: custom?.updatedAt || null,
|
| 151 |
+
};
|
| 152 |
+
},
|
| 153 |
+
|
| 154 |
+
async setUserPrompt(userId, markdown) {
|
| 155 |
+
if (!userId) throw new Error('Missing user id');
|
| 156 |
+
const normalized = normalizePrompt(markdown);
|
| 157 |
+
if (!normalized) throw new Error('System prompt cannot be empty');
|
| 158 |
+
await ensureLoaded();
|
| 159 |
+
state.prompts[userId] = {
|
| 160 |
+
markdown: normalized,
|
| 161 |
+
updatedAt: new Date().toISOString(),
|
| 162 |
+
};
|
| 163 |
+
await saveIndex();
|
| 164 |
+
return this.getPersonalization(userId);
|
| 165 |
+
},
|
| 166 |
+
|
| 167 |
+
async resetUserPrompt(userId) {
|
| 168 |
+
if (!userId) throw new Error('Missing user id');
|
| 169 |
+
await ensureLoaded();
|
| 170 |
+
delete state.prompts[userId];
|
| 171 |
+
await saveIndex();
|
| 172 |
+
return this.getPersonalization(userId);
|
| 173 |
+
},
|
| 174 |
+
};
|
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
|
@@ -9,8 +9,14 @@ import {
|
|
| 9 |
getUserProfile, setUsername, getSubscriptionInfo,
|
| 10 |
getTierConfig, getUsageInfo,
|
| 11 |
} from './auth.js';
|
| 12 |
-
import { streamChat
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
import crypto from 'crypto';
|
|
|
|
| 14 |
|
| 15 |
/**
|
| 16 |
* Message Structure: Tree-based with versioned tails
|
|
@@ -40,9 +46,36 @@ import crypto from 'crypto';
|
|
| 40 |
*/
|
| 41 |
|
| 42 |
const activeStreams = new Map();
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
initGuestRequestLimiter().catch(err => console.error('Failed to initialize guest request limiter:', err));
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
export async function handleWsMessage(ws, msg, wsClients) {
|
| 47 |
const client = wsClients.get(ws); if (!client) return;
|
| 48 |
// Require turnstile verification for most message types
|
|
@@ -50,6 +83,23 @@ export async function handleWsMessage(ws, msg, wsClients) {
|
|
| 50 |
return safeSend(ws, { type: 'error', message: 'turnstile:required' });
|
| 51 |
}
|
| 52 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
const h = handlers[msg.type];
|
| 54 |
if (h) return h(ws, msg, client, wsClients);
|
| 55 |
safeSend(ws, { type: 'error', message: `Unknown: ${msg.type}` });
|
|
@@ -77,13 +127,30 @@ const handlers = {
|
|
| 77 |
},
|
| 78 |
|
| 79 |
'auth:login': async (ws, msg, client, wsClients) => {
|
| 80 |
-
const { accessToken, tempId: clientTempId } = msg;
|
| 81 |
if (!accessToken) return safeSend(ws, { type: 'auth:error', message: 'Missing token' });
|
| 82 |
const user = await verifySupabaseToken(accessToken);
|
| 83 |
if (!user) return safeSend(ws, { type: 'auth:error', message: 'Invalid token' });
|
| 84 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
client.userId = user.id; client.accessToken = accessToken; client.authenticated = true;
|
| 86 |
-
client.deviceToken =
|
| 87 |
sessionStore.markOnline(user.id, ws);
|
| 88 |
|
| 89 |
if (clientTempId) client.tempId = clientTempId;
|
|
@@ -101,13 +168,17 @@ const handlers = {
|
|
| 101 |
deviceToken: client.deviceToken, sessions: sessions.map(ser), settings, profile, subscription };
|
| 102 |
safeSend(ws, authOkMsg);
|
| 103 |
|
| 104 |
-
|
| 105 |
-
|
|
|
|
|
|
|
| 106 |
},
|
| 107 |
|
| 108 |
'auth:logout': (ws, msg, client) => {
|
|
|
|
| 109 |
if (client.deviceToken) deviceSessionStore.revoke(client.deviceToken);
|
| 110 |
Object.assign(client, { userId: null, authenticated: false, accessToken: null, deviceToken: null });
|
|
|
|
| 111 |
safeSend(ws, { type: 'auth:loggedOut' });
|
| 112 |
},
|
| 113 |
|
|
@@ -134,15 +205,36 @@ const handlers = {
|
|
| 134 |
},
|
| 135 |
|
| 136 |
'sessions:delete': async (ws, msg, client) => {
|
| 137 |
-
|
| 138 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
safeSend(ws, { type: 'sessions:deleted', sessionId: msg.sessionId });
|
|
|
|
| 140 |
},
|
| 141 |
|
| 142 |
'sessions:deleteAll': async (ws, msg, client) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
if (client.userId) await sessionStore.deleteAllUserSessions(client.userId, client.accessToken);
|
| 144 |
else sessionStore.deleteTempAll(client.tempId);
|
| 145 |
safeSend(ws, { type: 'sessions:deletedAll' });
|
|
|
|
| 146 |
},
|
| 147 |
|
| 148 |
'sessions:rename': async (ws, msg, client) => {
|
|
@@ -175,8 +267,34 @@ const handlers = {
|
|
| 175 |
safeSend(ws, { type: 'sessions:imported', session: ser(s) });
|
| 176 |
},
|
| 177 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
'chat:send': async (ws, msg, client) => {
|
| 179 |
const { sessionId, content, tools } = msg;
|
|
|
|
| 180 |
if (!client.userId) {
|
| 181 |
const allowed = await consumeGuestRequest(client.ip || 'unknown');
|
| 182 |
if (!allowed) return safeSend(ws, { type: 'guest:rateLimit', message: 'Guest request limit exceeded' });
|
|
@@ -200,27 +318,48 @@ const handlers = {
|
|
| 200 |
const rootMessage = session.history?.[0];
|
| 201 |
const flatHistory = rootMessage ? extractFlatHistory(rootMessage) : [];
|
| 202 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
await streamChat({
|
| 204 |
sessionId,
|
| 205 |
model: session.model,
|
| 206 |
-
history: flatHistory,
|
| 207 |
-
userMessage: content,
|
| 208 |
tools: tools || {},
|
| 209 |
-
accessToken: client.accessToken,
|
| 210 |
-
clientId: msg.clientId,
|
|
|
|
|
|
|
|
|
|
| 211 |
abortSignal: abort.signal,
|
| 212 |
onToken(t) { fullText += t; safeSend(ws, { type: 'chat:token', token: t, sessionId }); },
|
| 213 |
onToolCall(call) {
|
| 214 |
safeSend(ws, { type: 'chat:toolCall', call, sessionId });
|
| 215 |
if (call.state === 'resolved' || call.state === 'canceled') toolCallsCollected.push(call);
|
| 216 |
},
|
| 217 |
-
onNewAsset(asset) {
|
| 218 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
activeStreams.delete(ws);
|
| 220 |
const finalText = text || fullText;
|
| 221 |
-
|
| 222 |
// Only create user entry if content was actually provided
|
| 223 |
-
const hasContent = content !== undefined && content !== null && content !== '' &&
|
| 224 |
!(Array.isArray(content) && content.length === 0);
|
| 225 |
const userEntry = hasContent
|
| 226 |
? buildEntry('user', content)
|
|
@@ -231,27 +370,48 @@ const handlers = {
|
|
| 231 |
const resolved = resolvedMap.get(c.id) || {};
|
| 232 |
return { ...c, state: resolved.state || 'resolved', result: resolved.result };
|
| 233 |
});
|
| 234 |
-
const asstEntry = buildEntry('assistant', finalText, mergedCalls
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
|
| 236 |
-
// Rebuild tree structure with new messages appended
|
| 237 |
-
let newRootMessage = rootMessage ? validateAndRepairTree(JSON.parse(JSON.stringify(rootMessage))) : null;
|
| 238 |
-
|
| 239 |
if (!newRootMessage) {
|
| 240 |
// First message in session - must have user entry
|
| 241 |
if (!userEntry) return safeSend(ws, { type: 'error', message: 'No content for first message' });
|
| 242 |
newRootMessage = userEntry;
|
| 243 |
-
|
| 244 |
-
|
|
|
|
| 245 |
} else {
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
currentTail.push(userEntry);
|
| 252 |
-
}
|
| 253 |
-
currentTail.push(asstEntry);
|
| 254 |
-
newRootMessage.versions[currentVerIdx].tail = currentTail;
|
| 255 |
}
|
| 256 |
|
| 257 |
const newHistory = [newRootMessage];
|
|
@@ -266,13 +426,13 @@ const handlers = {
|
|
| 266 |
if (client.userId)
|
| 267 |
await sessionStore.updateUserSession(client.userId, client.accessToken, sessionId, { history: newHistory, name: newName });
|
| 268 |
else sessionStore.updateTempSession(client.tempId, sessionId, { history: newHistory, name: newName });
|
| 269 |
-
|
| 270 |
safeSend(ws, { type: aborted ? 'chat:aborted' : 'chat:done', sessionId, name: newName, history: extractFlatHistory(newRootMessage) });
|
| 271 |
},
|
| 272 |
-
onError(err) {
|
| 273 |
activeStreams.delete(ws);
|
| 274 |
console.error('streamChat error:', err);
|
| 275 |
-
safeSend(ws, { type: 'chat:error', error: String(err), sessionId });
|
| 276 |
},
|
| 277 |
});
|
| 278 |
},
|
|
@@ -297,8 +457,18 @@ const handlers = {
|
|
| 297 |
}
|
| 298 |
|
| 299 |
// Find the target message in the tree and add new version
|
| 300 |
-
const newRoot =
|
|
|
|
|
|
|
|
|
|
| 301 |
const found = findAndUpdateMessage(newRoot, targetMsg.id, (msgInTree) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 302 |
// Add new version with EMPTY tail (no responses yet for this edited version)
|
| 303 |
msgInTree.versions.push({
|
| 304 |
content: newContent,
|
|
@@ -345,7 +515,7 @@ const handlers = {
|
|
| 345 |
if (!targetMsg || !targetMsg.versions || versionIdx >= targetMsg.versions.length) return;
|
| 346 |
|
| 347 |
// Find and update the message in tree, switching to specified version
|
| 348 |
-
const newRoot =
|
| 349 |
const found = findAndUpdateMessage(newRoot, targetMsg.id, (msgInTree) => {
|
| 350 |
msgInTree.currentVersionIdx = versionIdx;
|
| 351 |
msgInTree.content = msgInTree.versions[versionIdx].content;
|
|
@@ -365,6 +535,162 @@ const handlers = {
|
|
| 365 |
safeSend(ws, { type: 'chat:versionSelected', sessionId, messageId: targetMsg.id, messageIndex, history: extractFlatHistory(newRoot) });
|
| 366 |
},
|
| 367 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 368 |
'settings:get': async (ws, msg, client) => {
|
| 369 |
const s = client.userId
|
| 370 |
? await getUserSettings(client.userId, client.accessToken)
|
|
@@ -378,6 +704,72 @@ const handlers = {
|
|
| 378 |
bcast(wsClients, client.userId, { type: 'settings:updated', settings: msg.settings }, ws);
|
| 379 |
},
|
| 380 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 381 |
'account:getProfile': async (ws, msg, c) => { if (!c.userId) return; safeSend(ws, { type: 'account:profile', profile: await getUserProfile(c.userId, c.accessToken) }); },
|
| 382 |
'account:setUsername': async (ws, msg, c) => { if (!c.userId) return; safeSend(ws, { type: 'account:usernameResult', ...await setUsername(c.userId, c.accessToken, msg.username) }); },
|
| 383 |
'account:getSubscription': async (ws, msg, c) => {
|
|
@@ -385,26 +777,80 @@ const handlers = {
|
|
| 385 |
const subInfo = await getSubscriptionInfo(c.accessToken);
|
| 386 |
safeSend(ws, { type: 'account:subscription', info: subInfo });
|
| 387 |
},
|
| 388 |
-
'account:getUsage': async (ws, msg, c) => { safeSend(ws, { type: 'account:usage', usage: await
|
| 389 |
'account:getTierConfig': async (ws) => { safeSend(ws, { type: 'account:tierConfig', config: await getTierConfig() }); },
|
| 390 |
'account:getSessions': (ws, msg, c) => { if (!c.userId) return; safeSend(ws, { type: 'account:deviceSessions', sessions: deviceSessionStore.getForUser(c.userId), currentToken: c.deviceToken }); },
|
| 391 |
-
'account:revokeSession': (ws, msg, c)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 392 |
'account:revokeAllOthers': (ws, msg, c, wsClients) => {
|
| 393 |
if (!c.userId) return;
|
| 394 |
deviceSessionStore.revokeAllExcept(c.userId, c.deviceToken);
|
| 395 |
-
|
| 396 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 397 |
safeSend(ws, { type: 'account:allOthersRevoked' });
|
| 398 |
},
|
| 399 |
};
|
| 400 |
|
| 401 |
function ser(s) { return { id: s.id, name: s.name, created: s.created, history: s.history || [], model: s.model }; }
|
| 402 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 403 |
function generateMessageId() {
|
| 404 |
return `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
| 405 |
}
|
| 406 |
|
| 407 |
-
function buildEntry(role, content, toolCalls = []) {
|
| 408 |
const normalizedCalls = toolCalls.map(c => ({
|
| 409 |
id: c.id,
|
| 410 |
name: c.name || c.function?.name,
|
|
@@ -413,17 +859,151 @@ function buildEntry(role, content, toolCalls = []) {
|
|
| 413 |
result: c.result,
|
| 414 |
}));
|
| 415 |
const validContent = (content === undefined || content === null) ? '' : content;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 416 |
return {
|
| 417 |
id: generateMessageId(),
|
| 418 |
role,
|
| 419 |
content: validContent,
|
| 420 |
timestamp: Date.now(),
|
| 421 |
-
versions: [{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 422 |
currentVersionIdx: 0,
|
| 423 |
-
...(normalizedCalls.length ? { toolCalls: normalizedCalls } : {})
|
| 424 |
};
|
| 425 |
}
|
| 426 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 427 |
/**
|
| 428 |
* Validate and repair tree structure after cloning/modification
|
| 429 |
* Ensures all messages and versions have valid content property
|
|
@@ -454,6 +1034,84 @@ function validateAndRepairTree(rootMessage) {
|
|
| 454 |
return rootMessage;
|
| 455 |
}
|
| 456 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 457 |
function extractFlatHistory(rootMessage) {
|
| 458 |
if (!rootMessage) return [];
|
| 459 |
|
|
@@ -462,7 +1120,7 @@ function extractFlatHistory(rootMessage) {
|
|
| 462 |
if (msg.content === undefined || msg.content === null) {
|
| 463 |
msg.content = '';
|
| 464 |
}
|
| 465 |
-
return msg;
|
| 466 |
};
|
| 467 |
|
| 468 |
const history = [ensureValidContent(rootMessage)];
|
|
@@ -482,12 +1140,16 @@ function extractFlatHistory(rootMessage) {
|
|
| 482 |
|
| 483 |
if (currentTail && Array.isArray(currentTail)) {
|
| 484 |
const walkTail = (tail) => {
|
| 485 |
-
for (
|
|
|
|
| 486 |
history.push(ensureValidContent(msg));
|
| 487 |
const ver = msg.versions?.[msg.currentVersionIdx ?? 0];
|
| 488 |
if (ver?.tail && Array.isArray(ver.tail)) {
|
| 489 |
walkTail(ver.tail);
|
| 490 |
}
|
|
|
|
|
|
|
|
|
|
| 491 |
}
|
| 492 |
};
|
| 493 |
walkTail(currentTail);
|
|
@@ -495,6 +1157,31 @@ function extractFlatHistory(rootMessage) {
|
|
| 495 |
return history;
|
| 496 |
}
|
| 497 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 498 |
function findAndUpdateMessage(rootMessage, targetId, updateFn) {
|
| 499 |
if (rootMessage.id === targetId) {
|
| 500 |
updateFn(rootMessage);
|
|
|
|
| 9 |
getUserProfile, setUsername, getSubscriptionInfo,
|
| 10 |
getTierConfig, getUsageInfo,
|
| 11 |
} from './auth.js';
|
| 12 |
+
import { streamChat } from './chatStream.js';
|
| 13 |
+
import { mediaStore } from './mediaStore.js';
|
| 14 |
+
import { memoryStore } from './memoryStore.js';
|
| 15 |
+
import { chatTrashStore } from './chatTrashStore.js';
|
| 16 |
+
import { systemPromptStore } from './systemPromptStore.js';
|
| 17 |
+
import { getWebSearchUsage } from './webSearchUsageStore.js';
|
| 18 |
import crypto from 'crypto';
|
| 19 |
+
import path from 'path';
|
| 20 |
|
| 21 |
/**
|
| 22 |
* Message Structure: Tree-based with versioned tails
|
|
|
|
| 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
|
|
|
|
| 83 |
return safeSend(ws, { type: 'error', message: 'turnstile:required' });
|
| 84 |
}
|
| 85 |
|
| 86 |
+
const bypassDeviceValidation = new Set([
|
| 87 |
+
'ping',
|
| 88 |
+
'turnstile:verify',
|
| 89 |
+
'auth:login',
|
| 90 |
+
'auth:guest',
|
| 91 |
+
'auth:logout',
|
| 92 |
+
]);
|
| 93 |
+
if (client.userId && client.deviceToken && !bypassDeviceValidation.has(msg.type)) {
|
| 94 |
+
const activeDeviceSession = deviceSessionStore.validate(client.deviceToken);
|
| 95 |
+
if (!activeDeviceSession) {
|
| 96 |
+
const priorUserId = client.userId;
|
| 97 |
+
Object.assign(client, { userId: null, authenticated: false, accessToken: null, deviceToken: null });
|
| 98 |
+
sessionStore.markOffline(priorUserId, ws);
|
| 99 |
+
return safeSend(ws, { type: 'auth:forcedLogout', reason: 'Session revoked by another device' });
|
| 100 |
+
}
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
const h = handlers[msg.type];
|
| 104 |
if (h) return h(ws, msg, client, wsClients);
|
| 105 |
safeSend(ws, { type: 'error', message: `Unknown: ${msg.type}` });
|
|
|
|
| 127 |
},
|
| 128 |
|
| 129 |
'auth:login': async (ws, msg, client, wsClients) => {
|
| 130 |
+
const { accessToken, tempId: clientTempId, deviceToken: requestedDeviceToken } = msg;
|
| 131 |
if (!accessToken) return safeSend(ws, { type: 'auth:error', message: 'Missing token' });
|
| 132 |
const user = await verifySupabaseToken(accessToken);
|
| 133 |
if (!user) return safeSend(ws, { type: 'auth:error', message: 'Invalid token' });
|
| 134 |
|
| 135 |
+
let nextDeviceToken = null;
|
| 136 |
+
let reusedDeviceSession = false;
|
| 137 |
+
if (requestedDeviceToken) {
|
| 138 |
+
const existingDevice = deviceSessionStore.validate(requestedDeviceToken);
|
| 139 |
+
if (existingDevice?.userId === user.id) {
|
| 140 |
+
existingDevice.ip = client.ip;
|
| 141 |
+
existingDevice.userAgent = client.userAgent;
|
| 142 |
+
nextDeviceToken = existingDevice.token;
|
| 143 |
+
reusedDeviceSession = true;
|
| 144 |
+
}
|
| 145 |
+
}
|
| 146 |
+
if (!nextDeviceToken) {
|
| 147 |
+
nextDeviceToken = deviceSessionStore.create(user.id, client.ip, client.userAgent);
|
| 148 |
+
}
|
| 149 |
+
if (client.deviceToken && client.deviceToken !== nextDeviceToken) {
|
| 150 |
+
deviceSessionStore.revoke(client.deviceToken);
|
| 151 |
+
}
|
| 152 |
client.userId = user.id; client.accessToken = accessToken; client.authenticated = true;
|
| 153 |
+
client.deviceToken = nextDeviceToken;
|
| 154 |
sessionStore.markOnline(user.id, ws);
|
| 155 |
|
| 156 |
if (clientTempId) client.tempId = clientTempId;
|
|
|
|
| 168 |
deviceToken: client.deviceToken, sessions: sessions.map(ser), settings, profile, subscription };
|
| 169 |
safeSend(ws, authOkMsg);
|
| 170 |
|
| 171 |
+
if (!reusedDeviceSession) {
|
| 172 |
+
bcast(wsClients, user.id, { type: 'auth:newLogin', message: 'New login on your account.',
|
| 173 |
+
ip: client.ip, userAgent: client.userAgent, timestamp: new Date().toISOString() }, ws);
|
| 174 |
+
}
|
| 175 |
},
|
| 176 |
|
| 177 |
'auth:logout': (ws, msg, client) => {
|
| 178 |
+
const priorUserId = client.userId;
|
| 179 |
if (client.deviceToken) deviceSessionStore.revoke(client.deviceToken);
|
| 180 |
Object.assign(client, { userId: null, authenticated: false, accessToken: null, deviceToken: null });
|
| 181 |
+
if (priorUserId) sessionStore.markOffline(priorUserId, ws);
|
| 182 |
safeSend(ws, { type: 'auth:loggedOut' });
|
| 183 |
},
|
| 184 |
|
|
|
|
| 205 |
},
|
| 206 |
|
| 207 |
'sessions:delete': async (ws, msg, client) => {
|
| 208 |
+
const owner = getClientOwner(client);
|
| 209 |
+
const session = client.userId
|
| 210 |
+
? sessionStore.getUserSession(client.userId, msg.sessionId)
|
| 211 |
+
: sessionStore.getTempSession(client.tempId, msg.sessionId);
|
| 212 |
+
if (!session) return safeSend(ws, { type: 'error', message: 'Session not found' });
|
| 213 |
+
|
| 214 |
+
await chatTrashStore.add(owner, JSON.parse(JSON.stringify(session)));
|
| 215 |
+
if (client.userId) {
|
| 216 |
+
await sessionStore.deleteUserSession(client.userId, client.accessToken, msg.sessionId);
|
| 217 |
+
sessionStore.deleteTempSessionEverywhere(msg.sessionId);
|
| 218 |
+
} else {
|
| 219 |
+
sessionStore.deleteTempSession(client.tempId, msg.sessionId);
|
| 220 |
+
}
|
| 221 |
safeSend(ws, { type: 'sessions:deleted', sessionId: msg.sessionId });
|
| 222 |
+
safeSend(ws, { type: 'trash:chats:changed' });
|
| 223 |
},
|
| 224 |
|
| 225 |
'sessions:deleteAll': async (ws, msg, client) => {
|
| 226 |
+
const owner = getClientOwner(client);
|
| 227 |
+
const sessions = client.userId
|
| 228 |
+
? sessionStore.getUserSessions(client.userId)
|
| 229 |
+
: sessionStore.getTempSessions(client.tempId);
|
| 230 |
+
for (const session of sessions) {
|
| 231 |
+
await chatTrashStore.add(owner, JSON.parse(JSON.stringify(session)));
|
| 232 |
+
if (client.userId) sessionStore.deleteTempSessionEverywhere(session.id);
|
| 233 |
+
}
|
| 234 |
if (client.userId) await sessionStore.deleteAllUserSessions(client.userId, client.accessToken);
|
| 235 |
else sessionStore.deleteTempAll(client.tempId);
|
| 236 |
safeSend(ws, { type: 'sessions:deletedAll' });
|
| 237 |
+
safeSend(ws, { type: 'trash:chats:changed' });
|
| 238 |
},
|
| 239 |
|
| 240 |
'sessions:rename': async (ws, msg, client) => {
|
|
|
|
| 267 |
safeSend(ws, { type: 'sessions:imported', session: ser(s) });
|
| 268 |
},
|
| 269 |
|
| 270 |
+
'trash:chats:list': async (ws, msg, client) => {
|
| 271 |
+
const owner = getClientOwner(client);
|
| 272 |
+
const items = await chatTrashStore.list(owner);
|
| 273 |
+
safeSend(ws, { type: 'trash:chats:list', items });
|
| 274 |
+
},
|
| 275 |
+
|
| 276 |
+
'trash:chats:restore': async (ws, msg, client) => {
|
| 277 |
+
const owner = getClientOwner(client);
|
| 278 |
+
const restored = await chatTrashStore.restore(owner, msg.ids || []);
|
| 279 |
+
const sessions = [];
|
| 280 |
+
for (const snapshot of restored) {
|
| 281 |
+
const restoredSession = await restoreDeletedSession(client, snapshot);
|
| 282 |
+
if (restoredSession) sessions.push(ser(restoredSession));
|
| 283 |
+
}
|
| 284 |
+
safeSend(ws, { type: 'trash:chats:restored', sessions });
|
| 285 |
+
safeSend(ws, { type: 'trash:chats:changed' });
|
| 286 |
+
},
|
| 287 |
+
|
| 288 |
+
'trash:chats:deleteForever': async (ws, msg, client) => {
|
| 289 |
+
const owner = getClientOwner(client);
|
| 290 |
+
const removedIds = await chatTrashStore.deleteForever(owner, msg.ids || []);
|
| 291 |
+
safeSend(ws, { type: 'trash:chats:deletedForever', ids: removedIds });
|
| 292 |
+
safeSend(ws, { type: 'trash:chats:changed' });
|
| 293 |
+
},
|
| 294 |
+
|
| 295 |
'chat:send': async (ws, msg, client) => {
|
| 296 |
const { sessionId, content, tools } = msg;
|
| 297 |
+
const owner = getClientOwner(client);
|
| 298 |
if (!client.userId) {
|
| 299 |
const allowed = await consumeGuestRequest(client.ip || 'unknown');
|
| 300 |
if (!allowed) return safeSend(ws, { type: 'guest:rateLimit', message: 'Guest request limit exceeded' });
|
|
|
|
| 318 |
const rootMessage = session.history?.[0];
|
| 319 |
const flatHistory = rootMessage ? extractFlatHistory(rootMessage) : [];
|
| 320 |
|
| 321 |
+
if (Array.isArray(msg.linkedMediaIds) && msg.linkedMediaIds.length) {
|
| 322 |
+
await mediaStore.attachToSession(owner, msg.linkedMediaIds, sessionId).catch((err) => {
|
| 323 |
+
console.error('Failed to link uploaded media to session:', err);
|
| 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,
|
| 335 |
+
history: flatHistory,
|
| 336 |
+
userMessage: content,
|
| 337 |
tools: tools || {},
|
| 338 |
+
accessToken: client.accessToken,
|
| 339 |
+
clientId: msg.clientId,
|
| 340 |
+
webSearchLimit,
|
| 341 |
+
owner,
|
| 342 |
+
sessionName: session.name,
|
| 343 |
abortSignal: abort.signal,
|
| 344 |
onToken(t) { fullText += t; safeSend(ws, { type: 'chat:token', token: t, sessionId }); },
|
| 345 |
onToolCall(call) {
|
| 346 |
safeSend(ws, { type: 'chat:toolCall', call, sessionId });
|
| 347 |
if (call.state === 'resolved' || call.state === 'canceled') toolCallsCollected.push(call);
|
| 348 |
},
|
| 349 |
+
onNewAsset(asset) {
|
| 350 |
+
safeSend(ws, { type: 'chat:asset', asset, sessionId });
|
| 351 |
+
assetsCollected.push(asset);
|
| 352 |
+
safeSend(ws, { type: 'media:changed' });
|
| 353 |
+
},
|
| 354 |
+
onDraftEdit(edit, draftText) {
|
| 355 |
+
safeSend(ws, { type: 'chat:draftEdited', edit, text: draftText, sessionId });
|
| 356 |
+
},
|
| 357 |
+
async onDone(text, toolCalls, aborted, sessionNameFromTag, responseEdits = [], responseSegments = []) {
|
| 358 |
activeStreams.delete(ws);
|
| 359 |
const finalText = text || fullText;
|
| 360 |
+
|
| 361 |
// Only create user entry if content was actually provided
|
| 362 |
+
const hasContent = content !== undefined && content !== null && content !== '' &&
|
| 363 |
!(Array.isArray(content) && content.length === 0);
|
| 364 |
const userEntry = hasContent
|
| 365 |
? buildEntry('user', content)
|
|
|
|
| 370 |
const resolved = resolvedMap.get(c.id) || {};
|
| 371 |
return { ...c, state: resolved.state || 'resolved', result: resolved.result };
|
| 372 |
});
|
| 373 |
+
const asstEntry = buildEntry('assistant', finalText, mergedCalls, {
|
| 374 |
+
responseEdits,
|
| 375 |
+
responseSegments,
|
| 376 |
+
});
|
| 377 |
+
|
| 378 |
+
const mediaEntries = assetsCollected.map((asset) =>
|
| 379 |
+
buildMediaEntry(asset.role, {
|
| 380 |
+
assetId: asset.id,
|
| 381 |
+
mimeType: asset.mimeType,
|
| 382 |
+
name: asset.name,
|
| 383 |
+
})
|
| 384 |
+
);
|
| 385 |
+
|
| 386 |
+
const generatedFiles = extractAssistantGeneratedFiles(finalText);
|
| 387 |
+
for (const file of generatedFiles) {
|
| 388 |
+
await mediaStore.storeBuffer(owner, {
|
| 389 |
+
name: file.name,
|
| 390 |
+
mimeType: file.mimeType,
|
| 391 |
+
buffer: file.buffer,
|
| 392 |
+
sessionId,
|
| 393 |
+
source: 'assistant_generated',
|
| 394 |
+
kind: file.kind,
|
| 395 |
+
}).catch((err) => console.error('Failed to store generated text asset:', err));
|
| 396 |
+
}
|
| 397 |
+
if (generatedFiles.length) safeSend(ws, { type: 'media:changed' });
|
| 398 |
+
|
| 399 |
+
// Rebuild tree structure with new messages appended to the active branch leaf.
|
| 400 |
+
let newRootMessage = rootMessage ? cloneAndRepairTree(rootMessage) : null;
|
| 401 |
|
|
|
|
|
|
|
|
|
|
| 402 |
if (!newRootMessage) {
|
| 403 |
// First message in session - must have user entry
|
| 404 |
if (!userEntry) return safeSend(ws, { type: 'error', message: 'No content for first message' });
|
| 405 |
newRootMessage = userEntry;
|
| 406 |
+
newRootMessage.versions[0].tail = [{ ...asstEntry }, ...mediaEntries];
|
| 407 |
+
} else if (userEntry) {
|
| 408 |
+
appendConversationTurn(newRootMessage, userEntry, asstEntry, mediaEntries);
|
| 409 |
} else {
|
| 410 |
+
const appendedEntries = [
|
| 411 |
+
asstEntry,
|
| 412 |
+
...mediaEntries,
|
| 413 |
+
];
|
| 414 |
+
appendEntriesToActiveLeaf(newRootMessage, appendedEntries);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 415 |
}
|
| 416 |
|
| 417 |
const newHistory = [newRootMessage];
|
|
|
|
| 426 |
if (client.userId)
|
| 427 |
await sessionStore.updateUserSession(client.userId, client.accessToken, sessionId, { history: newHistory, name: newName });
|
| 428 |
else sessionStore.updateTempSession(client.tempId, sessionId, { history: newHistory, name: newName });
|
| 429 |
+
|
| 430 |
safeSend(ws, { type: aborted ? 'chat:aborted' : 'chat:done', sessionId, name: newName, history: extractFlatHistory(newRootMessage) });
|
| 431 |
},
|
| 432 |
+
onError(err) {
|
| 433 |
activeStreams.delete(ws);
|
| 434 |
console.error('streamChat error:', err);
|
| 435 |
+
safeSend(ws, { type: 'chat:error', error: String(err), sessionId });
|
| 436 |
},
|
| 437 |
});
|
| 438 |
},
|
|
|
|
| 457 |
}
|
| 458 |
|
| 459 |
// Find the target message in the tree and add new version
|
| 460 |
+
const newRoot = cloneAndRepairTree(rootMessage);
|
| 461 |
+
const context = findMessageContext(newRoot, targetMsg.id);
|
| 462 |
+
if (!context?.message) return safeSend(ws, { type: 'error', message: 'Message not found in tree' });
|
| 463 |
+
|
| 464 |
const found = findAndUpdateMessage(newRoot, targetMsg.id, (msgInTree) => {
|
| 465 |
+
if (msgInTree.role === 'user' && Array.isArray(context.parentTail) && context.index >= 0) {
|
| 466 |
+
const trailing = context.parentTail.splice(context.index + 1);
|
| 467 |
+
if (trailing.length) {
|
| 468 |
+
const currentVersion = getActiveVersion(msgInTree);
|
| 469 |
+
currentVersion.tail = [...(currentVersion.tail || []), ...trailing];
|
| 470 |
+
}
|
| 471 |
+
}
|
| 472 |
// Add new version with EMPTY tail (no responses yet for this edited version)
|
| 473 |
msgInTree.versions.push({
|
| 474 |
content: newContent,
|
|
|
|
| 515 |
if (!targetMsg || !targetMsg.versions || versionIdx >= targetMsg.versions.length) return;
|
| 516 |
|
| 517 |
// Find and update the message in tree, switching to specified version
|
| 518 |
+
const newRoot = cloneAndRepairTree(rootMessage);
|
| 519 |
const found = findAndUpdateMessage(newRoot, targetMsg.id, (msgInTree) => {
|
| 520 |
msgInTree.currentVersionIdx = versionIdx;
|
| 521 |
msgInTree.content = msgInTree.versions[versionIdx].content;
|
|
|
|
| 535 |
safeSend(ws, { type: 'chat:versionSelected', sessionId, messageId: targetMsg.id, messageIndex, history: extractFlatHistory(newRoot) });
|
| 536 |
},
|
| 537 |
|
| 538 |
+
'chat:assistantAction': async (ws, msg, client) => {
|
| 539 |
+
const { sessionId, messageIndex } = msg;
|
| 540 |
+
const action = msg.action === 'continue' ? 'continue' : 'regenerate';
|
| 541 |
+
const session = client.userId
|
| 542 |
+
? sessionStore.getUserSession(client.userId, sessionId)
|
| 543 |
+
: sessionStore.getTempSession(client.tempId, sessionId);
|
| 544 |
+
if (!session) return safeSend(ws, { type: 'error', message: 'Session not found' });
|
| 545 |
+
|
| 546 |
+
const rootMessage = session.history?.[0];
|
| 547 |
+
if (!rootMessage) return safeSend(ws, { type: 'error', message: 'No history' });
|
| 548 |
+
|
| 549 |
+
const flatHistory = extractFlatHistory(rootMessage);
|
| 550 |
+
const targetMsg = flatHistory[messageIndex];
|
| 551 |
+
if (!targetMsg || targetMsg.role !== 'assistant') {
|
| 552 |
+
return safeSend(ws, { type: 'error', message: 'Assistant message not found' });
|
| 553 |
+
}
|
| 554 |
+
|
| 555 |
+
if (activeStreams.has(ws)) activeStreams.get(ws).abort();
|
| 556 |
+
const abort = new AbortController();
|
| 557 |
+
activeStreams.set(ws, abort);
|
| 558 |
+
|
| 559 |
+
const owner = getClientOwner(client);
|
| 560 |
+
const historyBeforeTarget = flatHistory.slice(0, messageIndex);
|
| 561 |
+
const baseAssistantText = stripSessionTagText(targetMsg.content || '');
|
| 562 |
+
const actionHistory = action === 'continue'
|
| 563 |
+
? [...historyBeforeTarget, { role: 'assistant', content: baseAssistantText }]
|
| 564 |
+
: historyBeforeTarget;
|
| 565 |
+
const actionUserMessage = action === 'continue' ? CONTINUE_ASSISTANT_PROMPT : null;
|
| 566 |
+
|
| 567 |
+
safeSend(ws, {
|
| 568 |
+
type: 'chat:start',
|
| 569 |
+
sessionId,
|
| 570 |
+
streamKind: 'assistantAction',
|
| 571 |
+
action,
|
| 572 |
+
messageIndex,
|
| 573 |
+
prefillText: action === 'continue' ? baseAssistantText : '',
|
| 574 |
+
});
|
| 575 |
+
|
| 576 |
+
let fullText = '';
|
| 577 |
+
const assetsCollected = [];
|
| 578 |
+
const toolCallsCollected = [];
|
| 579 |
+
const usagePayload = await buildUsagePayload(client, msg.clientId || '');
|
| 580 |
+
const webSearchLimit = isFreeSearchPlan(client, usagePayload)
|
| 581 |
+
? { key: usageOwnerKey(client, msg.clientId || ''), limit: FREE_WEB_SEARCH_LIMIT }
|
| 582 |
+
: null;
|
| 583 |
+
|
| 584 |
+
await streamChat({
|
| 585 |
+
sessionId,
|
| 586 |
+
model: session.model,
|
| 587 |
+
history: actionHistory,
|
| 588 |
+
userMessage: actionUserMessage,
|
| 589 |
+
tools: msg.tools || {},
|
| 590 |
+
accessToken: client.accessToken,
|
| 591 |
+
clientId: msg.clientId,
|
| 592 |
+
webSearchLimit,
|
| 593 |
+
owner,
|
| 594 |
+
sessionName: session.name,
|
| 595 |
+
abortSignal: abort.signal,
|
| 596 |
+
onToken(t) {
|
| 597 |
+
fullText += t;
|
| 598 |
+
safeSend(ws, { type: 'chat:token', token: t, sessionId });
|
| 599 |
+
},
|
| 600 |
+
onToolCall(call) {
|
| 601 |
+
safeSend(ws, { type: 'chat:toolCall', call, sessionId });
|
| 602 |
+
if (call.state === 'resolved' || call.state === 'canceled') toolCallsCollected.push(call);
|
| 603 |
+
},
|
| 604 |
+
onNewAsset(asset) {
|
| 605 |
+
safeSend(ws, { type: 'chat:asset', asset, sessionId });
|
| 606 |
+
assetsCollected.push(asset);
|
| 607 |
+
safeSend(ws, { type: 'media:changed' });
|
| 608 |
+
},
|
| 609 |
+
onDraftEdit(edit, draftText) {
|
| 610 |
+
safeSend(ws, { type: 'chat:draftEdited', edit, text: draftText, sessionId });
|
| 611 |
+
},
|
| 612 |
+
async onDone(text, toolCalls, aborted, sessionNameFromTag, responseEdits = [], responseSegments = []) {
|
| 613 |
+
activeStreams.delete(ws);
|
| 614 |
+
if (aborted) {
|
| 615 |
+
return safeSend(ws, { type: 'chat:aborted', sessionId });
|
| 616 |
+
}
|
| 617 |
+
|
| 618 |
+
const rawAssistantText = text || fullText;
|
| 619 |
+
const resolvedMap = new Map(toolCallsCollected.map((call) => [call.id, call]));
|
| 620 |
+
const mergedCalls = (toolCalls || []).map((call) => {
|
| 621 |
+
const resolved = resolvedMap.get(call.id) || {};
|
| 622 |
+
return { ...call, state: resolved.state || 'resolved', result: resolved.result };
|
| 623 |
+
});
|
| 624 |
+
|
| 625 |
+
let finalText = rawAssistantText;
|
| 626 |
+
let finalSegments = responseSegments;
|
| 627 |
+
|
| 628 |
+
if (action === 'continue') {
|
| 629 |
+
const { continuationText, overlapLength } = stripContinuationOverlap(baseAssistantText, rawAssistantText);
|
| 630 |
+
finalText = baseAssistantText + continuationText;
|
| 631 |
+
finalSegments = [
|
| 632 |
+
...(baseAssistantText ? [{ type: 'text', text: baseAssistantText }] : []),
|
| 633 |
+
...trimLeadingTextFromSegments(responseSegments, overlapLength),
|
| 634 |
+
];
|
| 635 |
+
}
|
| 636 |
+
|
| 637 |
+
const mediaEntries = assetsCollected.map((asset) =>
|
| 638 |
+
buildMediaEntry(asset.role, {
|
| 639 |
+
assetId: asset.id,
|
| 640 |
+
mimeType: asset.mimeType,
|
| 641 |
+
name: asset.name,
|
| 642 |
+
})
|
| 643 |
+
);
|
| 644 |
+
|
| 645 |
+
const generatedFiles = extractAssistantGeneratedFiles(finalText);
|
| 646 |
+
for (const file of generatedFiles) {
|
| 647 |
+
await mediaStore.storeBuffer(owner, {
|
| 648 |
+
name: file.name,
|
| 649 |
+
mimeType: file.mimeType,
|
| 650 |
+
buffer: file.buffer,
|
| 651 |
+
sessionId,
|
| 652 |
+
source: 'assistant_generated',
|
| 653 |
+
kind: file.kind,
|
| 654 |
+
}).catch((err) => console.error('Failed to store generated text asset:', err));
|
| 655 |
+
}
|
| 656 |
+
if (generatedFiles.length) safeSend(ws, { type: 'media:changed' });
|
| 657 |
+
|
| 658 |
+
const newRoot = cloneAndRepairTree(rootMessage);
|
| 659 |
+
const found = findAndUpdateMessage(newRoot, targetMsg.id, (msgInTree) => {
|
| 660 |
+
const nextVersion = buildVersionRecord(finalText, {
|
| 661 |
+
tail: mediaEntries,
|
| 662 |
+
toolCalls: mergedCalls,
|
| 663 |
+
responseEdits,
|
| 664 |
+
responseSegments: finalSegments,
|
| 665 |
+
});
|
| 666 |
+
applyVersionToMessage(msgInTree, nextVersion);
|
| 667 |
+
});
|
| 668 |
+
if (!found) {
|
| 669 |
+
return safeSend(ws, { type: 'error', message: 'Assistant branch could not be updated' });
|
| 670 |
+
}
|
| 671 |
+
|
| 672 |
+
let newName = session.name;
|
| 673 |
+
if (sessionNameFromTag) {
|
| 674 |
+
newName = sessionNameFromTag;
|
| 675 |
+
}
|
| 676 |
+
|
| 677 |
+
const newHistory = [newRoot];
|
| 678 |
+
if (client.userId) {
|
| 679 |
+
await sessionStore.updateUserSession(client.userId, client.accessToken, sessionId, { history: newHistory, name: newName });
|
| 680 |
+
} else {
|
| 681 |
+
sessionStore.updateTempSession(client.tempId, sessionId, { history: newHistory, name: newName });
|
| 682 |
+
}
|
| 683 |
+
|
| 684 |
+
safeSend(ws, { type: 'chat:done', sessionId, name: newName, history: extractFlatHistory(newRoot) });
|
| 685 |
+
},
|
| 686 |
+
onError(err) {
|
| 687 |
+
activeStreams.delete(ws);
|
| 688 |
+
console.error('assistant action streamChat error:', err);
|
| 689 |
+
safeSend(ws, { type: 'chat:error', error: String(err), sessionId });
|
| 690 |
+
},
|
| 691 |
+
});
|
| 692 |
+
},
|
| 693 |
+
|
| 694 |
'settings:get': async (ws, msg, client) => {
|
| 695 |
const s = client.userId
|
| 696 |
? await getUserSettings(client.userId, client.accessToken)
|
|
|
|
| 704 |
bcast(wsClients, client.userId, { type: 'settings:updated', settings: msg.settings }, ws);
|
| 705 |
},
|
| 706 |
|
| 707 |
+
'personalization:get': async (ws, msg, client) => {
|
| 708 |
+
const defaultPrompt = await systemPromptStore.getDefaultPrompt();
|
| 709 |
+
const personalization = client.userId
|
| 710 |
+
? await systemPromptStore.getPersonalization(client.userId)
|
| 711 |
+
: {
|
| 712 |
+
defaultPrompt,
|
| 713 |
+
customPrompt: null,
|
| 714 |
+
resolvedPrompt: defaultPrompt,
|
| 715 |
+
isCustom: false,
|
| 716 |
+
updatedAt: null,
|
| 717 |
+
canEdit: false,
|
| 718 |
+
};
|
| 719 |
+
safeSend(ws, { type: 'personalization:data', personalization });
|
| 720 |
+
},
|
| 721 |
+
'personalization:saveSystemPrompt': async (ws, msg, client, wsClients) => {
|
| 722 |
+
if (!client.userId) return safeSend(ws, { type: 'personalization:error', message: 'Sign in to customize your system prompt' });
|
| 723 |
+
try {
|
| 724 |
+
const personalization = await systemPromptStore.setUserPrompt(client.userId, msg.markdown);
|
| 725 |
+
safeSend(ws, { type: 'personalization:updated', personalization });
|
| 726 |
+
bcast(wsClients, client.userId, { type: 'personalization:updated', personalization }, ws);
|
| 727 |
+
} catch (err) {
|
| 728 |
+
safeSend(ws, { type: 'personalization:error', message: err.message || 'Unable to save system prompt' });
|
| 729 |
+
}
|
| 730 |
+
},
|
| 731 |
+
'personalization:resetSystemPrompt': async (ws, msg, client, wsClients) => {
|
| 732 |
+
if (!client.userId) return safeSend(ws, { type: 'personalization:error', message: 'Sign in to customize your system prompt' });
|
| 733 |
+
try {
|
| 734 |
+
const personalization = await systemPromptStore.resetUserPrompt(client.userId);
|
| 735 |
+
safeSend(ws, { type: 'personalization:updated', personalization });
|
| 736 |
+
bcast(wsClients, client.userId, { type: 'personalization:updated', personalization }, ws);
|
| 737 |
+
} catch (err) {
|
| 738 |
+
safeSend(ws, { type: 'personalization:error', message: err.message || 'Unable to reset system prompt' });
|
| 739 |
+
}
|
| 740 |
+
},
|
| 741 |
+
|
| 742 |
+
'memories:list': async (ws, msg, client) => {
|
| 743 |
+
const owner = getClientOwner(client);
|
| 744 |
+
const items = await memoryStore.list(owner);
|
| 745 |
+
safeSend(ws, { type: 'memories:list', items });
|
| 746 |
+
},
|
| 747 |
+
|
| 748 |
+
'memories:create': async (ws, msg, client, wsClients) => {
|
| 749 |
+
const owner = getClientOwner(client);
|
| 750 |
+
const memory = await memoryStore.create(owner, {
|
| 751 |
+
content: msg.content,
|
| 752 |
+
sessionId: msg.sessionId || null,
|
| 753 |
+
source: msg.source || 'manual',
|
| 754 |
+
});
|
| 755 |
+
safeSend(ws, { type: 'memories:created', memory });
|
| 756 |
+
if (client.userId) bcast(wsClients, client.userId, { type: 'memories:changed' }, ws);
|
| 757 |
+
},
|
| 758 |
+
|
| 759 |
+
'memories:update': async (ws, msg, client, wsClients) => {
|
| 760 |
+
const owner = getClientOwner(client);
|
| 761 |
+
const memory = await memoryStore.update(owner, msg.id, msg.content);
|
| 762 |
+
safeSend(ws, { type: 'memories:updated', memory });
|
| 763 |
+
if (client.userId) bcast(wsClients, client.userId, { type: 'memories:changed' }, ws);
|
| 764 |
+
},
|
| 765 |
+
|
| 766 |
+
'memories:delete': async (ws, msg, client, wsClients) => {
|
| 767 |
+
const owner = getClientOwner(client);
|
| 768 |
+
const ok = await memoryStore.delete(owner, msg.id);
|
| 769 |
+
safeSend(ws, { type: 'memories:deleted', id: ok ? msg.id : null });
|
| 770 |
+
if (client.userId) bcast(wsClients, client.userId, { type: 'memories:changed' }, ws);
|
| 771 |
+
},
|
| 772 |
+
|
| 773 |
'account:getProfile': async (ws, msg, c) => { if (!c.userId) return; safeSend(ws, { type: 'account:profile', profile: await getUserProfile(c.userId, c.accessToken) }); },
|
| 774 |
'account:setUsername': async (ws, msg, c) => { if (!c.userId) return; safeSend(ws, { type: 'account:usernameResult', ...await setUsername(c.userId, c.accessToken, msg.username) }); },
|
| 775 |
'account:getSubscription': async (ws, msg, c) => {
|
|
|
|
| 777 |
const subInfo = await getSubscriptionInfo(c.accessToken);
|
| 778 |
safeSend(ws, { type: 'account:subscription', info: subInfo });
|
| 779 |
},
|
| 780 |
+
'account:getUsage': async (ws, msg, c) => { safeSend(ws, { type: 'account:usage', usage: await buildUsagePayload(c, msg.clientId || '') }); },
|
| 781 |
'account:getTierConfig': async (ws) => { safeSend(ws, { type: 'account:tierConfig', config: await getTierConfig() }); },
|
| 782 |
'account:getSessions': (ws, msg, c) => { if (!c.userId) return; safeSend(ws, { type: 'account:deviceSessions', sessions: deviceSessionStore.getForUser(c.userId), currentToken: c.deviceToken }); },
|
| 783 |
+
'account:revokeSession': (ws, msg, c, wsClients) => {
|
| 784 |
+
if (!c.userId || !msg.token) return;
|
| 785 |
+
const revoked = deviceSessionStore.revoke(msg.token);
|
| 786 |
+
if (revoked) {
|
| 787 |
+
const activeSessions = deviceSessionStore.getForUser(c.userId);
|
| 788 |
+
for (const [ows, oc] of wsClients) {
|
| 789 |
+
if (oc.userId !== c.userId) continue;
|
| 790 |
+
if (oc.deviceToken === msg.token) {
|
| 791 |
+
safeSend(ows, { type: 'auth:forcedLogout', reason: 'Session revoked by another device' });
|
| 792 |
+
sessionStore.markOffline(oc.userId, ows);
|
| 793 |
+
Object.assign(oc, { userId: null, authenticated: false, accessToken: null, deviceToken: null });
|
| 794 |
+
continue;
|
| 795 |
+
}
|
| 796 |
+
safeSend(ows, {
|
| 797 |
+
type: 'account:deviceSessions',
|
| 798 |
+
sessions: activeSessions,
|
| 799 |
+
currentToken: oc.deviceToken,
|
| 800 |
+
});
|
| 801 |
+
}
|
| 802 |
+
}
|
| 803 |
+
safeSend(ws, { type: 'account:sessionRevoked', token: msg.token });
|
| 804 |
+
},
|
| 805 |
'account:revokeAllOthers': (ws, msg, c, wsClients) => {
|
| 806 |
if (!c.userId) return;
|
| 807 |
deviceSessionStore.revokeAllExcept(c.userId, c.deviceToken);
|
| 808 |
+
const activeSessions = deviceSessionStore.getForUser(c.userId);
|
| 809 |
+
for (const [ows, oc] of wsClients) {
|
| 810 |
+
if (oc.userId !== c.userId) continue;
|
| 811 |
+
if (oc.deviceToken && oc.deviceToken !== c.deviceToken) {
|
| 812 |
+
safeSend(ows, { type: 'auth:forcedLogout', reason: 'Session revoked by another device' });
|
| 813 |
+
sessionStore.markOffline(oc.userId, ows);
|
| 814 |
+
Object.assign(oc, { userId: null, authenticated: false, accessToken: null, deviceToken: null });
|
| 815 |
+
continue;
|
| 816 |
+
}
|
| 817 |
+
safeSend(ows, {
|
| 818 |
+
type: 'account:deviceSessions',
|
| 819 |
+
sessions: activeSessions,
|
| 820 |
+
currentToken: oc.deviceToken,
|
| 821 |
+
});
|
| 822 |
+
}
|
| 823 |
safeSend(ws, { type: 'account:allOthersRevoked' });
|
| 824 |
},
|
| 825 |
};
|
| 826 |
|
| 827 |
function ser(s) { return { id: s.id, name: s.name, created: s.created, history: s.history || [], model: s.model }; }
|
| 828 |
|
| 829 |
+
function getClientOwner(client) {
|
| 830 |
+
return client.userId
|
| 831 |
+
? { type: 'user', id: client.userId }
|
| 832 |
+
: { type: 'guest', id: client.tempId };
|
| 833 |
+
}
|
| 834 |
+
|
| 835 |
+
async function restoreDeletedSession(client, snapshot) {
|
| 836 |
+
if (!snapshot) return null;
|
| 837 |
+
const restored = JSON.parse(JSON.stringify(snapshot));
|
| 838 |
+
const existing = client.userId
|
| 839 |
+
? sessionStore.getUserSession(client.userId, restored.id)
|
| 840 |
+
: sessionStore.getTempSession(client.tempId, restored.id);
|
| 841 |
+
if (existing) restored.id = crypto.randomUUID();
|
| 842 |
+
restored.created = restored.created || Date.now();
|
| 843 |
+
if (client.userId) {
|
| 844 |
+
return sessionStore.restoreUserSession(client.userId, client.accessToken, restored);
|
| 845 |
+
}
|
| 846 |
+
return sessionStore.restoreTempSession(client.tempId, restored);
|
| 847 |
+
}
|
| 848 |
+
|
| 849 |
function generateMessageId() {
|
| 850 |
return `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
| 851 |
}
|
| 852 |
|
| 853 |
+
function buildEntry(role, content, toolCalls = [], extraFields = {}) {
|
| 854 |
const normalizedCalls = toolCalls.map(c => ({
|
| 855 |
id: c.id,
|
| 856 |
name: c.name || c.function?.name,
|
|
|
|
| 859 |
result: c.result,
|
| 860 |
}));
|
| 861 |
const validContent = (content === undefined || content === null) ? '' : content;
|
| 862 |
+
const versionMeta = {};
|
| 863 |
+
const topLevelExtraFields = { ...extraFields };
|
| 864 |
+
VERSION_META_FIELDS.forEach((key) => {
|
| 865 |
+
if (key in topLevelExtraFields) {
|
| 866 |
+
versionMeta[key] = topLevelExtraFields[key];
|
| 867 |
+
delete topLevelExtraFields[key];
|
| 868 |
+
}
|
| 869 |
+
});
|
| 870 |
return {
|
| 871 |
id: generateMessageId(),
|
| 872 |
role,
|
| 873 |
content: validContent,
|
| 874 |
timestamp: Date.now(),
|
| 875 |
+
versions: [{
|
| 876 |
+
content: validContent,
|
| 877 |
+
tail: [],
|
| 878 |
+
timestamp: Date.now(),
|
| 879 |
+
...(normalizedCalls.length ? { toolCalls: normalizedCalls } : {}),
|
| 880 |
+
...versionMeta,
|
| 881 |
+
}],
|
| 882 |
+
currentVersionIdx: 0,
|
| 883 |
+
...(normalizedCalls.length ? { toolCalls: normalizedCalls } : {}),
|
| 884 |
+
...versionMeta,
|
| 885 |
+
...topLevelExtraFields,
|
| 886 |
+
};
|
| 887 |
+
}
|
| 888 |
+
|
| 889 |
+
function buildMediaEntry(role, content) {
|
| 890 |
+
return {
|
| 891 |
+
id: generateMessageId(),
|
| 892 |
+
role,
|
| 893 |
+
content,
|
| 894 |
+
timestamp: Date.now(),
|
| 895 |
+
versions: [{ content, tail: [], timestamp: Date.now() }],
|
| 896 |
currentVersionIdx: 0,
|
|
|
|
| 897 |
};
|
| 898 |
}
|
| 899 |
|
| 900 |
+
function buildVersionRecord(content, extraFields = {}) {
|
| 901 |
+
const validContent = content === undefined || content === null ? '' : content;
|
| 902 |
+
const version = {
|
| 903 |
+
content: validContent,
|
| 904 |
+
tail: Array.isArray(extraFields.tail) ? extraFields.tail : [],
|
| 905 |
+
timestamp: Date.now(),
|
| 906 |
+
};
|
| 907 |
+
VERSION_META_FIELDS.forEach((key) => {
|
| 908 |
+
if (extraFields[key] !== undefined && extraFields[key] !== null) {
|
| 909 |
+
version[key] = extraFields[key];
|
| 910 |
+
}
|
| 911 |
+
});
|
| 912 |
+
return version;
|
| 913 |
+
}
|
| 914 |
+
|
| 915 |
+
function applyVersionToMessage(message, versionRecord) {
|
| 916 |
+
if (!message?.versions || !Array.isArray(message.versions)) {
|
| 917 |
+
message.versions = [];
|
| 918 |
+
}
|
| 919 |
+
message.versions.push(versionRecord);
|
| 920 |
+
message.currentVersionIdx = message.versions.length - 1;
|
| 921 |
+
syncMessageFromActiveVersion(message);
|
| 922 |
+
return message;
|
| 923 |
+
}
|
| 924 |
+
|
| 925 |
+
function stripSessionTagText(content) {
|
| 926 |
+
return typeof content === 'string'
|
| 927 |
+
? content.replace(/<session_name>[\s\S]*?<\/session_name>/gi, '').trim()
|
| 928 |
+
: content;
|
| 929 |
+
}
|
| 930 |
+
|
| 931 |
+
function stripContinuationOverlap(baseText = '', generatedText = '') {
|
| 932 |
+
const base = String(baseText || '');
|
| 933 |
+
const generated = String(generatedText || '');
|
| 934 |
+
if (!base) return { continuationText: generated, overlapLength: 0 };
|
| 935 |
+
if (!generated) return { continuationText: '', overlapLength: 0 };
|
| 936 |
+
if (generated.startsWith(base)) {
|
| 937 |
+
return { continuationText: generated.slice(base.length), overlapLength: base.length };
|
| 938 |
+
}
|
| 939 |
+
|
| 940 |
+
const maxWindow = Math.min(base.length, generated.length, 400);
|
| 941 |
+
for (let size = maxWindow; size > 0; size--) {
|
| 942 |
+
if (base.slice(-size) === generated.slice(0, size)) {
|
| 943 |
+
return { continuationText: generated.slice(size), overlapLength: size };
|
| 944 |
+
}
|
| 945 |
+
}
|
| 946 |
+
|
| 947 |
+
return { continuationText: generated, overlapLength: 0 };
|
| 948 |
+
}
|
| 949 |
+
|
| 950 |
+
function trimLeadingTextFromSegments(segments = [], overlapLength = 0) {
|
| 951 |
+
let remaining = Math.max(0, overlapLength);
|
| 952 |
+
const trimmedSegments = [];
|
| 953 |
+
|
| 954 |
+
for (const segment of segments || []) {
|
| 955 |
+
if (!segment || segment.type !== 'text' || remaining <= 0) {
|
| 956 |
+
trimmedSegments.push(segment);
|
| 957 |
+
continue;
|
| 958 |
+
}
|
| 959 |
+
|
| 960 |
+
const text = String(segment.text || '');
|
| 961 |
+
if (remaining >= text.length) {
|
| 962 |
+
remaining -= text.length;
|
| 963 |
+
continue;
|
| 964 |
+
}
|
| 965 |
+
|
| 966 |
+
trimmedSegments.push({
|
| 967 |
+
...segment,
|
| 968 |
+
text: text.slice(remaining),
|
| 969 |
+
});
|
| 970 |
+
remaining = 0;
|
| 971 |
+
}
|
| 972 |
+
|
| 973 |
+
return trimmedSegments.filter((segment) => segment && (segment.type !== 'text' || segment.text));
|
| 974 |
+
}
|
| 975 |
+
|
| 976 |
+
function extractAssistantGeneratedFiles(text) {
|
| 977 |
+
if (!text || typeof text !== 'string') return [];
|
| 978 |
+
const files = [];
|
| 979 |
+
const detailsRe = /<details><summary>([^<]+?)<\/summary>\s*```(?:\w*)\n([\s\S]*?)\n```\s*<\/details>/g;
|
| 980 |
+
const svgRe = /```svg\n([\s\S]*?)\n```/g;
|
| 981 |
+
let match;
|
| 982 |
+
|
| 983 |
+
while ((match = detailsRe.exec(text)) !== null) {
|
| 984 |
+
const name = String(match[1] || '').trim();
|
| 985 |
+
const ext = path.extname(name).toLowerCase();
|
| 986 |
+
files.push({
|
| 987 |
+
name: name || 'generated-file.txt',
|
| 988 |
+
mimeType: ext === '.html' || ext === '.htm' ? 'text/html' : 'text/plain',
|
| 989 |
+
kind: ext === '.html' || ext === '.htm' ? 'rich_text' : 'text',
|
| 990 |
+
buffer: Buffer.from(match[2], 'utf8'),
|
| 991 |
+
});
|
| 992 |
+
}
|
| 993 |
+
|
| 994 |
+
let svgIndex = 1;
|
| 995 |
+
while ((match = svgRe.exec(text)) !== null) {
|
| 996 |
+
files.push({
|
| 997 |
+
name: `generated-image-${svgIndex++}.svg`,
|
| 998 |
+
mimeType: 'image/svg+xml',
|
| 999 |
+
kind: 'image',
|
| 1000 |
+
buffer: Buffer.from(match[1], 'utf8'),
|
| 1001 |
+
});
|
| 1002 |
+
}
|
| 1003 |
+
|
| 1004 |
+
return files;
|
| 1005 |
+
}
|
| 1006 |
+
|
| 1007 |
/**
|
| 1008 |
* Validate and repair tree structure after cloning/modification
|
| 1009 |
* Ensures all messages and versions have valid content property
|
|
|
|
| 1034 |
return rootMessage;
|
| 1035 |
}
|
| 1036 |
|
| 1037 |
+
function cloneAndRepairTree(rootMessage) {
|
| 1038 |
+
return validateAndRepairTree(JSON.parse(JSON.stringify(rootMessage)));
|
| 1039 |
+
}
|
| 1040 |
+
|
| 1041 |
+
function getActiveVersion(message) {
|
| 1042 |
+
if (!message) return null;
|
| 1043 |
+
const versions = Array.isArray(message.versions) ? message.versions : [];
|
| 1044 |
+
if (!versions.length) {
|
| 1045 |
+
message.versions = [{ content: message.content ?? '', tail: [], timestamp: Date.now() }];
|
| 1046 |
+
message.currentVersionIdx = 0;
|
| 1047 |
+
return message.versions[0];
|
| 1048 |
+
}
|
| 1049 |
+
const currentVersionIdx = Number.isInteger(message.currentVersionIdx)
|
| 1050 |
+
? Math.max(0, Math.min(message.currentVersionIdx, versions.length - 1))
|
| 1051 |
+
: 0;
|
| 1052 |
+
message.currentVersionIdx = currentVersionIdx;
|
| 1053 |
+
if (!Array.isArray(message.versions[currentVersionIdx].tail)) {
|
| 1054 |
+
message.versions[currentVersionIdx].tail = [];
|
| 1055 |
+
}
|
| 1056 |
+
if (message.versions[currentVersionIdx].content === undefined || message.versions[currentVersionIdx].content === null) {
|
| 1057 |
+
message.versions[currentVersionIdx].content = message.content ?? '';
|
| 1058 |
+
}
|
| 1059 |
+
return message.versions[currentVersionIdx];
|
| 1060 |
+
}
|
| 1061 |
+
|
| 1062 |
+
function cloneVersionMetaValue(value) {
|
| 1063 |
+
if (value === undefined) return undefined;
|
| 1064 |
+
return JSON.parse(JSON.stringify(value));
|
| 1065 |
+
}
|
| 1066 |
+
|
| 1067 |
+
function syncMessageFromActiveVersion(message) {
|
| 1068 |
+
if (!message) return message;
|
| 1069 |
+
const currentVersion = getActiveVersion(message);
|
| 1070 |
+
if (!currentVersion) return message;
|
| 1071 |
+
message.content = currentVersion.content ?? message.content ?? '';
|
| 1072 |
+
VERSION_META_FIELDS.forEach((key) => {
|
| 1073 |
+
if (key in currentVersion) {
|
| 1074 |
+
message[key] = cloneVersionMetaValue(currentVersion[key]);
|
| 1075 |
+
} else {
|
| 1076 |
+
delete message[key];
|
| 1077 |
+
}
|
| 1078 |
+
});
|
| 1079 |
+
return message;
|
| 1080 |
+
}
|
| 1081 |
+
|
| 1082 |
+
function getActiveLeafMessage(rootMessage) {
|
| 1083 |
+
let current = rootMessage;
|
| 1084 |
+
while (current) {
|
| 1085 |
+
const currentVersion = getActiveVersion(current);
|
| 1086 |
+
const tail = Array.isArray(currentVersion?.tail) ? currentVersion.tail : [];
|
| 1087 |
+
if (!tail.length) return current;
|
| 1088 |
+
current = tail[tail.length - 1];
|
| 1089 |
+
}
|
| 1090 |
+
return rootMessage;
|
| 1091 |
+
}
|
| 1092 |
+
|
| 1093 |
+
function appendEntriesToActiveLeaf(rootMessage, entries = []) {
|
| 1094 |
+
if (!rootMessage || !entries.length) return rootMessage;
|
| 1095 |
+
const leaf = getActiveLeafMessage(rootMessage);
|
| 1096 |
+
const currentVersion = getActiveVersion(leaf);
|
| 1097 |
+
currentVersion.tail = [...(currentVersion.tail || []), ...entries];
|
| 1098 |
+
return rootMessage;
|
| 1099 |
+
}
|
| 1100 |
+
|
| 1101 |
+
function appendConversationTurn(rootMessage, userEntry, assistantEntry, mediaEntries = []) {
|
| 1102 |
+
if (!rootMessage || !userEntry) return rootMessage;
|
| 1103 |
+
const leaf = getActiveLeafMessage(rootMessage);
|
| 1104 |
+
const currentVersion = getActiveVersion(leaf);
|
| 1105 |
+
const userVersion = getActiveVersion(userEntry);
|
| 1106 |
+
userVersion.tail = [
|
| 1107 |
+
...(assistantEntry ? [assistantEntry] : []),
|
| 1108 |
+
...(Array.isArray(mediaEntries) ? mediaEntries : []),
|
| 1109 |
+
];
|
| 1110 |
+
syncMessageFromActiveVersion(userEntry);
|
| 1111 |
+
currentVersion.tail = [...(currentVersion.tail || []), userEntry];
|
| 1112 |
+
return rootMessage;
|
| 1113 |
+
}
|
| 1114 |
+
|
| 1115 |
function extractFlatHistory(rootMessage) {
|
| 1116 |
if (!rootMessage) return [];
|
| 1117 |
|
|
|
|
| 1120 |
if (msg.content === undefined || msg.content === null) {
|
| 1121 |
msg.content = '';
|
| 1122 |
}
|
| 1123 |
+
return syncMessageFromActiveVersion(msg);
|
| 1124 |
};
|
| 1125 |
|
| 1126 |
const history = [ensureValidContent(rootMessage)];
|
|
|
|
| 1140 |
|
| 1141 |
if (currentTail && Array.isArray(currentTail)) {
|
| 1142 |
const walkTail = (tail) => {
|
| 1143 |
+
for (let i = 0; i < tail.length; i++) {
|
| 1144 |
+
const msg = tail[i];
|
| 1145 |
history.push(ensureValidContent(msg));
|
| 1146 |
const ver = msg.versions?.[msg.currentVersionIdx ?? 0];
|
| 1147 |
if (ver?.tail && Array.isArray(ver.tail)) {
|
| 1148 |
walkTail(ver.tail);
|
| 1149 |
}
|
| 1150 |
+
if (msg.role === 'user' && Array.isArray(msg.versions) && msg.versions.length > 1) {
|
| 1151 |
+
break;
|
| 1152 |
+
}
|
| 1153 |
}
|
| 1154 |
};
|
| 1155 |
walkTail(currentTail);
|
|
|
|
| 1157 |
return history;
|
| 1158 |
}
|
| 1159 |
|
| 1160 |
+
function findMessageContext(rootMessage, targetId) {
|
| 1161 |
+
if (!rootMessage) return null;
|
| 1162 |
+
if (rootMessage.id === targetId) {
|
| 1163 |
+
return { message: rootMessage, parent: null, parentTail: null, index: -1 };
|
| 1164 |
+
}
|
| 1165 |
+
|
| 1166 |
+
const search = (msg) => {
|
| 1167 |
+
const verIdx = msg.currentVersionIdx ?? 0;
|
| 1168 |
+
const tail = msg.versions?.[verIdx]?.tail;
|
| 1169 |
+
if (!tail || !Array.isArray(tail)) return null;
|
| 1170 |
+
|
| 1171 |
+
for (let i = 0; i < tail.length; i++) {
|
| 1172 |
+
const child = tail[i];
|
| 1173 |
+
if (child.id === targetId) {
|
| 1174 |
+
return { message: child, parent: msg, parentTail: tail, index: i };
|
| 1175 |
+
}
|
| 1176 |
+
const nested = search(child);
|
| 1177 |
+
if (nested) return nested;
|
| 1178 |
+
}
|
| 1179 |
+
return null;
|
| 1180 |
+
};
|
| 1181 |
+
|
| 1182 |
+
return search(rootMessage);
|
| 1183 |
+
}
|
| 1184 |
+
|
| 1185 |
function findAndUpdateMessage(rootMessage, targetId, updateFn) {
|
| 1186 |
if (rootMessage.id === targetId) {
|
| 1187 |
updateFn(rootMessage);
|
system prompt.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Response formatting
|
| 2 |
+
|
| 3 |
+
- Every response must use HTML `<span data-color="{COLOR NAME}">...</span>` tags to color main points and headings unless the user asks otherwise.
|
| 4 |
+
- Colors must have meaning and stay consistent across the conversation.
|
| 5 |
+
- Only use these semantic color names: `green`, `pink`, `blue`, `red`, `orange`, `yellow`, `purple`, `teal`, `gold`, `coral`.
|
| 6 |
+
- Never output explicit black or white colors.
|
| 7 |
+
- Put color spans as close to the text as possible, and do not place markdown syntax inside the span tags.
|
| 8 |
+
- Keep code blocks plain, but color important surrounding headings and key points.
|
| 9 |
+
- Do not over-color responses. Use color intentionally and sparingly.
|
| 10 |
+
- Markdown markers such as `#`, `##`, `###`, `**`, and `*` must stay outside the color spans.
|
| 11 |
+
|
| 12 |
+
# Core behavior
|
| 13 |
+
|
| 14 |
+
- You are a helpful, friendly AI assistant.
|
| 15 |
+
- Use tools when appropriate to help the user, and if you are told to generate something, use a tool to complete the task.
|
| 16 |
+
- When generating media, do not include URLs because the media is displayed automatically.
|
| 17 |
+
- You can render SVG images by outputting SVG code in a code block tagged exactly like this:
|
| 18 |
+
|
| 19 |
+
```svg
|
| 20 |
+
<svg>...</svg>
|
| 21 |
+
```
|
| 22 |
+
|
| 23 |
+
- Never use single backslashes.
|
| 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.
|
| 48 |
+
- If notes say attached text was staged separately, or that only the first part of a prompt is inline, do not assume the content is missing, corrupted, or truncated.
|
| 49 |
+
- Treat staged content as available context.
|
| 50 |
+
- Use `list_prompt_resources` to find staged resources.
|
| 51 |
+
- Use `read_prompt_chunk` to read staged text exactly.
|
| 52 |
+
- Use `load_prompt_images` to inspect staged images.
|
| 53 |
+
- Use `write_notes` to keep a compact working memory after reading several chunks.
|
| 54 |
+
- Before claiming an attachment is incomplete, missing, malformed, or unreadable, first check whether it was staged separately and read the relevant resource.
|
| 55 |
+
|
| 56 |
+
# Memory
|
| 57 |
+
|
| 58 |
+
- Persistent memories must stay short, concrete, and durable.
|
| 59 |
+
- Only save memories that will still help in future chats.
|
| 60 |
+
- Keep each memory to a brief sentence or phrase.
|
| 61 |
+
- At the start of a chat, always check the memories.
|
| 62 |
+
- If the user tells you to remember something, or there is something important to note for future chats, create a new memory.
|
| 63 |
+
- Memories should be brief.
|
| 64 |
+
- Notes are only for session-long memory, so use memories for anything relevant to future chats.
|
| 65 |
+
|
| 66 |
+
# Priorities
|
| 67 |
+
|
| 68 |
+
- Your highest priority is to help the user.
|
| 69 |
+
- Always help with anything ethically right.
|
| 70 |
+
- Make sure your responses are always accurate.
|
| 71 |
+
- If you are not completely sure about something, search the web.
|
| 72 |
+
- If you notice any issue or mistake with your response, correct it with the replace tools.
|
| 73 |
+
- Always answer as correctly as possible, and use search when unsure.
|
| 74 |
+
- Try to minimize the use of `*` for emphasis. Use it mainly for markdown structure.
|
| 75 |
+
|
| 76 |
+
# Session naming
|
| 77 |
+
|
| 78 |
+
- After you have fully responded to the user, append a session name tag on its own line at the very end of your response, never inside a code block.
|
| 79 |
+
- Only do this on the first response unless the user asks to change the name.
|
| 80 |
+
- The tag must be `<session_name>2-4 word title summarizing this conversation</session_name>`.
|
| 81 |
+
- Example: `<session_name>React State Management</session_name>`.
|
| 82 |
+
- A conversation must always be named on the first response.
|
| 83 |
+
- This tag is hidden from the user and is used only to name the chat.
|
| 84 |
+
- Do not mention the tag to the user.
|
| 85 |
+
- Do not name your session "New Chat". Choose a descriptive name.
|