# Elsewhere Elsewhere is an Express + SQLite app for account-based chat, persona management, roleplay chat sessions, and a simple persona market backed by a remote model API. A space for every conversation. Six appearance styles (Grove, Parchment, Aurora, Current, Iris, and Graphite) each support light and dark modes. The Elsewhere identity stays consistent across styles. Internal theme and storage keys remain stable to preserve saved preferences. ## Setup Node.js 22.13 or newer is required. Native dependencies also need a working build toolchain. On Windows, install Python and Visual Studio's **Desktop development with C++** workload before `npm ci`. On Linux, install Python, `make` and a C++ compiler; on macOS, use Xcode Command Line Tools. See the [node-gyp prerequisites](https://github.com/nodejs/node-gyp#installation). 1. Install the locked dependencies with `npm ci`. 2. Copy `.env.example` to `.env` and set at least `SESSION_SECRET`. 3. Start the app with `npm run dev` for local development or `npm start` for a normal server run. Both startup scripts load `.env` when present; environment variables already set by the host take precedence. `.env`, databases, uploads and session files are ignored by Git. Use `npm install` when deliberately updating dependencies and include both `package.json` and `package-lock.json` in the change. CI runs `npm ci`, lint and tests on Node 22 and 24. ## Scripts - `npm start`: run the production server entrypoint. - `npm run dev`: run the server with Node watch mode. - `npm run lint`: syntax-check the backend and browser modules. - `npm test`: run API tests with Node's built-in test runner. - `npm run user:role -- `: assign or revoke a role from the server console. ## Administrative access Accounts have a server-managed `users.role`, constrained to `user` or `admin`. Registration and migrations default to `user`, including accounts named `admin`. Usernames, profile updates and workspace imports never grant roles. Existing installations must explicitly promote their intended administrator after deploying this migration: ```sh npm run user:role -- your-existing-username admin ``` Run this on the server with the application's `DB_PATH` and environment. To revoke access, run the same command with `user`. Authorization reads the database role on each request, so promotion and revocation apply to existing sessions; renaming retains the role. There is no public role assignment endpoint. Async route handlers use `asyncHandler` to forward rejected promises to Express 4's error middleware, following [Express's error handling guidance](https://expressjs.com/en/4x/guide/error-handling/). Duplicate registrations, including concurrent inserts, return HTTP 409 with `code: "USERNAME_TAKEN"`. ## Environment Variables - `PORT`: HTTP port for the Express server. - `DB_PATH`: SQLite database path. Use `:memory:` for isolated tests. - `SESSION_SECRET`: session signing secret. Required outside local throwaway environments. - `COOKIE_SECURE`: set to `true` when serving over HTTPS. Required in production unless `ALLOW_INSECURE_COOKIES=true` is set. - `ALLOW_INSECURE_COOKIES`: explicit production override for non-HTTPS deployments. - `TRUST_PROXY`: Express trust proxy setting for reverse proxy deployments. - `SESSION_MAX_AGE_MS`: cookie lifetime. - `EVENT_LOOP_LAG_*`: enable and tune event-loop lag warnings. - `SLOW_REQUEST_*`: enable and tune slow request warnings. - `CHAT_LIMIT`: per-account maximum chat sessions. - `CHAT_HISTORY_LIMIT`: number of recent messages included in model prompts. - `MODEL_API_BASE_URL`: base URL of the remote model backend. The server expects `/chat` and `/tags`. - `MODEL_REQUEST_TIMEOUT_MS`: legacy fallback for the inactivity timeout (default 180000 ms). - `MODEL_CONNECT_TIMEOUT_MS`: time to receive Ollama response headers (default 30000 ms). - `MODEL_INACTIVITY_TIMEOUT_MS`: maximum silence between received stream chunks (default 180000 ms). - `MODEL_MAX_GENERATION_MS`: overall generation safety limit (default 600000 ms). - `OLLAMA_KEEP_ALIVE`: Ollama model residency after a request (default `30m`). - `MODEL_LIST_TIMEOUT_MS`: timeout for model list requests. - `MODEL_UNLOAD_AFTER_MS`: delay before idle local request bookkeeping is cleared. Model unloading is managed by Ollama through `OLLAMA_KEEP_ALIVE`. - `MODELS_CACHE_TTL_MS`: model list cache TTL. - `AUTH_RATE_LIMIT_*`: rate-limit window and thresholds for registration/login. - `CHAT_RATE_LIMIT_*`: rate-limit window and thresholds for chat and retry endpoints. - `RATE_LIMIT_CLEANUP_INTERVAL_MS`: interval for pruning stale in-memory rate-limit buckets. - `DROP_LEGACY_CHATS`: when `true`, migrate any legacy `chats` table into `chat_sessions` / `chat_messages` and remove the old table. ## Image attachments and vision The composer supports PNG, JPEG and WebP through the image button, dropping files onto the composer, or pasting a screenshot. Text paste is unchanged. Previews show upload status and support retry/removal; sending waits until every remaining image is ready. Images also render in saved messages and open at full size in a protected browser tab. Production configuration (the existing `/app/data` volume remains unchanged): ```dotenv UPLOAD_DIR=/app/data/uploads UPLOAD_MAX_SIZE_MB=10 UPLOAD_MAX_FILES=5 UPLOAD_MAX_PIXELS=30000000 VISION_MODEL=gemma3:4b ``` Outside production, the default directory is `data/uploads`; `UPLOAD_DIR` overrides it. Startup creates the directory and checks write permission, failing clearly rather than falling back to another location. Files use `YYYY/MM/DD/.` keys. Do not publish this directory as a static directory. Back up **both the SQLite database and uploads directory** for server recovery. The workspace's **Download workspace archive** button saves a `.tar.gz` containing `workspace.json` and referenced images; import that archive on another installation to restore chats with attachments. The importer also accepts older JSON exports, which contain attachment references only and reconnect images only when those IDs already belong to the importing account. Archives are limited to 250 MB uncompressed, and each image must meet the destination's upload limit. `CHAT_LIMIT` defaults to 10 active chats per account. Archived chats remain available without using an active slot; restoring one requires a free slot. Authenticated API endpoints: - `GET /uploads/config`: safe client limits, without storage paths. - `POST /uploads`: multipart field `image`, one file per request; returns `{id,type:"image",originalName,mimeType,sizeBytes,width,height}`. - `GET /attachments/:id`: ownership-checked image bytes with private/no-store caching and `nosniff`; attached images also require access to a referencing conversation. - `DELETE /attachments/:id`: removes an owned temporary upload only. - `/chat` and `/chat/stream`: optional `attachments: [id, ...]`; image-only messages receive a default description prompt. Existing text-only payloads remain valid. Multer bounds multipart input in memory; Sharp detects the actual format, checks the pixel limit and fully decodes it. Browser MIME types and filenames are not trusted. SVG, GIF, animated/multipage images, empty, malformed, oversized and fake images are rejected. File counts are checked again during atomic message association. Concurrent uploads per account are capped at `UPLOAD_MAX_FILES`, and pending uploads at four times that limit. Neither arbitrary paths nor remote image URLs are accepted. The additive, idempotent SQLite migration creates `attachments`, `message_attachments` and `upload_deletions`, plus the question-specific `attachment_vision_analyses` cache; existing messages and files are not rewritten. Attachment rows contain owner, original filename, relative storage key, MIME/size/dimensions, temporary/attached status, creation time and optional vision model/analysis/time. Shared references preserve images across branches, edits and same-instance imports. Deleting the last referencing message/conversation (or the account) queues the file for deletion. Cleanup drains this durable queue at startup, after modifying requests, before uploads and hourly; failed file deletion is retried. Temporary uploads expire after 24 hours. Attached images do not expire. No binary or Base64 data is stored in SQLite. Generation order is attachment ownership validation → atomic user message/reference persistence → **Analyzing images…** → existing Web Search decision/retrieval → final selected chat model streaming. Gemma receives each image separately as a server-side Base64 `messages[].images` value, together with the user's question. The existing Ollama client sends `model: VISION_MODEL`, `think: false`, a JSON Schema in `format`, keep-alive and the existing cancellation/timeouts. Its stream is consumed privately; only the final selected chat model's answer and opted-in reasoning reach the user. The configured vision model is the visual perception stage. The user's question guides which visible details it inspects; it does not answer that question. Qwen combines the report with the question, conversation, its own knowledge, and optional Web Search to reason and produce the final answer. The bounded perception record is: ```json { "scene": "Detailed factual overview of what is visible", "subjects": [{"type": "bird", "description": "Fluffy gray down", "location": "center"}], "visualDetails": ["The crown is black", "A narrow dark line runs directly beneath the chin"], "visibleText": ["Clearly readable text"], "spatialRelationships": ["The bird faces the camera"], "colorsAndAppearance": ["Gray body and black head"], "recognizedElements": ["Juvenile penguin"], "notableFeatures": ["Narrow dark line beneath the chin"], "uncertainties": [] } ``` The vision model reports direct observations and visually recognizable elements, while avoiding semantic conclusions such as a species inferred from markings or an unseen backend cause. The prompt asks for precise location, geometry, boundaries, and named regions where visible, with separate entries for distinguishing markings. Uncertainty concerns what can be seen, not the final identification. Each field has a length/item limit that allows detailed screenshots. The generation cap is 2,400 tokens; simple images can use much less. Malformed JSON falls back to a bounded scene plus `rawAnalysis` (up to 6,000 characters) as unstructured visual evidence. Valid JSON is parsed before individual fields are bounded, so truncation cannot corrupt its structure. An empty response or unavailable vision model fails the turn explicitly, without asking the chat model to invent image details. The cache key is attachment, configured vision model, `VISION_PROMPT_VERSION` (currently 4), and trimmed user question; case and internal whitespace are preserved. Records include the analysis and timestamp. Only an exact matching key is reused. Earlier prompt-version reports remain in storage but cannot be cache hits for version 4. Legacy model-only snapshots are retained for compatibility but never used as cache hits. Bump the prompt version when changing the perception contract. Cache records cascade away when the attachment is deleted. Images on the current user message are analyzed individually and labeled in their attachment order. Earlier images are selected only for explicit historical references; they do not compete with an unrelated current image. Regeneration uses the pinned original prompt and its attachments. Repeated questions and regeneration reuse matching results. Different question results coexist, including across shared branches, so a new question can require extra vision-model calls. The final model receives intact structured evidence in a separate untrusted user context message, not a truncated JSON string in a system prompt. Trusted guidance tells Qwen to weigh discriminating observed features, compare plausible candidates, and keep observed details distinct from inferred traits. It must not sharpen vague descriptions into specific markings or invent a backend cause. It should not mention internal tools/models unless explicitly asked. OCR and raw fallback text are data, never instructions. Analysis remains separate from visible messages; the final model never receives image bytes or filesystem paths. These are model instructions, not a deterministic guarantee of identification accuracy or final-answer wording. Automated tests cover the contract and data flow; semantic quality requires evaluation with real images and the deployed models. Web Search and Reasoning remain independent. For requested searches, concise visual evidence from notable features, visual details, visible text, and scene helps form the query without overriding the user's search decision. Current images take priority over historical ones. Search finishes before final generation; source ordering/rendering is unchanged. The final request still uses `stream: true` and the existing capability-checked reasoning toggle. Stop/disconnect cancels the current vision or final generation request. Logging includes IDs, sizes, types and cache events only, never images/Base64/private analysis. Dependencies added: `multer` (bounded multipart handling), `sharp` (content detection, dimensions and full image decoding). Install through the normal `npm install`/`npm ci` workflow, including Sharp's platform-specific optional dependencies in Docker. The application does not pull Ollama models; provision `gemma3:4b` separately. ## Model Backend Expectations The server is configured around an Ollama-compatible API surface: - `POST /chat`: accepts `{ model, messages, stream: true, think: boolean, keep_alive: "30m" }` and returns newline-delimited JSON chunks with optional `message.content` / `message.thinking`, ending in `done: true`. - `GET /tags`: returns the available model list. `qwen3:14b` is the default model for chat and roleplay. The model selector loads available models from `/tags` and selects `qwen3:14b` when available (otherwise the first returned model). Existing chats retain their saved model selection. The composer has independent Web Search and Reasoning toggles, both off by default and reset after a completed reply. It sends `{message, chatId, model, webSearch, reasoning}` using streamed fetch to `POST /chat/stream`. SSE events carry `status` (accepted/analyzing/searching/generating), `thinking` (`{chunk}`), `chunk` (`{chunk}`), `replace` (`{target: "reply"|"thinking", text}`), `done` (reply/sources), and `error` (error/status/code/requestId when available). Text events send only incremental text. A corrective persona pass explicitly replaces provisional output before streaming its replacement. Both sends and regenerations batch Markdown rendering per animation frame. Only the validated complete answer is persisted. Clients consuming SSE must handle `replace` events and accumulate deltas; the browser also accepts the older accumulated event format. Every Ollama request explicitly sets `stream: true`, `think`, and `keep_alive`. Reasoning is requested and forwarded only when `reasoning === true` and the model supports the boolean thinking API. The shared conservative capability check supports Qwen3, DeepSeek R1 and DeepSeek v3.1 model IDs; unknown models, Qwen coder variants and models requiring thinking levels have the toggle disabled. Internal calls, roleplay openers and regeneration default to `think: false`. There is no extra model call to obtain reasoning. Reasoning appears only after actual thinking text arrives, expanded during generation and collapsed when the answer begins. Users can reopen it; stored messages start collapsed. The optional nullable `chat_messages.thinking` field remains separate from answer content and sources, is excluded from future prompts and logs, and survives reloads, exports, imports and branches. Old messages remain valid. Ollama reads reset the inactivity deadline on each received chunk; the overall safety limit still applies to active streams. Client disconnects and the existing Stop action abort upstream generation. Missing `done: true`, invalid streams, timeouts and upstream failures use the existing error path and never save a partial assistant response as complete. Browser EOF without a terminal SSE event is likewise an interruption. The existing UI discards temporary output on failure. The refactor adds explicit upstream timeout handling, cached model listing, and `GET /health` for degraded-state visibility. ## Schema Notes Primary runtime tables are: - `users` - `chat_sessions` - `chat_messages` - `personas` - `user_settings` - `persona_market` Legacy `chats` migration is handled during startup. New code only reads the session/message schema. ## Persona And Market Flow - Assistant and user personas are stored separately through `persona_type`. - Roleplay chats can bind an assistant persona, an optional user persona, and scenario metadata to a chat session. - Publishing copies a local persona into `persona_market`. - Collecting clones a market persona back into the current user's `personas` table with source metadata. ## Frontend Structure The browser UI remains framework-free and uses native ES modules. Shared behavior lives under `public/js/`: - `api.js`: JSON and streaming request helpers with HTTP metadata, cancellation and deadlines - `conversationState.js` / `conversationController.js`: shared state, chat selection, creation, search and drafts - `messageRenderer.js`: sanitized Markdown, message cards, actions and scroll position - `composerController.js`: immediate send locking, captured chat/model, Stop and send recovery - `personaController.js`: persona forms, library actions and roleplay setup - `streamRenderer.js`: delta accumulation, replacement events and batched rendering - `constants.js`: theme and onboarding metadata - `themeController.js`: validated theme/mode persistence and application - `theme-bootstrap.js`: applies stored appearance before page initialization - `personaForm.js`: shared persona form serialization - `statusNotice.js`: shared status and error presentation - `dom.js`: cached chat-page DOM references JSON helpers retain the existing `{error}` result convention and add `status`, `code` and `requestId` on failure. Successful empty responses return `{}`. Pass `{signal, timeoutMs}` as the last argument to `get`, `post`, `put`, `del`, or `request`; the default deadline is 30 seconds. `stream` accepts the same options (or the legacy AbortSignal argument), defaults to 660 seconds, and rejects with `ApiError` unless `onError` handles the failure. A `timeoutMs` of 0 disables the deadline. Cancellation, timeout, network and malformed-response failures have distinct codes. HTTP errors keep their status even if the response is HTML or invalid JSON. DOMPurify is an npm production dependency pinned in the lockfile and served only at `/vendor/dompurify.js`; `node_modules` is not a static directory. The former CDN version 3.0.6 was affected by the maintainer's [mXSS advisory](https://github.com/cure53/DOMPurify/security/advisories/GHSA-gx9m-whjm-85jf). The installed version is 3.4.16. Update it through npm so audits and reproducible installs also cover the browser sanitizer. `public/css/style.css` is a self-contained design system rebuilt around semantic tokens, shared components, and deliberate responsive breakpoints. It contains the six visual identities and their dark variants without legacy imports or precedence layers. `public/css/legal.css` contains only the legal-page layout. Pure chat message, retry, memory, summary, and persona-state formatting lives in `src/services/chatMessageHelpers.js`; `chatService.js` remains responsible for orchestration and persistence. ## Optional Web Search The globe button next to the composer enables search for one message. It defaults to off and resets once the server accepts the message. The browser sends `webSearch: true` (or `false`) through the existing authenticated `POST /chat/stream`; `POST /chat` supports the same optional boolean. Omitting it preserves the normal chat flow. No generic tool/agent system is needed. Search uses `shouldSearch = webSearchEnabled && (explicitSearchIntent || automaticSearchDecision)`. Deterministic commands such as “search the internet”, “look this up online”, and “find sources” override smart skipping. Arithmetic, rewriting, explanations and questions answered by the server clock can otherwise skip search. Query cleanup removes command wording while preserving dates and years; “average lifespan” is normalized to “average life expectancy”. The chat service retrieves up to 15 candidates per query and selects 1–5 useful sources dynamically, rejecting empty evidence and wrong-date results. SearXNG `content` maps to `snippet` (an existing non-empty snippet is also accepted). Exact-date event searches use the general index to include archives and try at most one alternate query if the first returns no usable evidence; candidates are merged and deduplicated. Current-news requests retain the news category. The same numbered selected titles, URLs and snippets go immediately before the current question in Ollama's context, into the response, and into storage. Retrieval finishes before either reasoning or answer streaming starts. Instructions require citations, supported partial answers rather than blanket claims of missing details, and honest disclosure when evidence is insufficient. Result content remains untrusted data; no result pages are fetched. Set environment variables in the server/container environment, or in `.env` (loaded by `npm start` and `npm run dev`): ```dotenv SEARXNG_URL=http://searxng:8080 WEB_SEARCH_TIMEOUT_MS=8000 ``` `SEARXNG_URL` is optional and server-only. It must be an HTTP(S) base URL reachable from Express, for example over the same Docker network. Use the final URL: redirects are rejected. Missing/invalid configuration leaves ordinary chat available and returns `Web search is currently unavailable.` for search requests. `WEB_SEARCH_TIMEOUT_MS` limits the whole search response, including body download (default 8000 ms). Responses are limited to 1 MiB; each source has at most a 300-character title, 2048-character URL and 1500-character snippet. Only HTTP(S) source links without embedded credentials are accepted. No user input can override the provider URL. Enable JSON in SearXNG's `settings.yml`: ```yaml search: formats: - html - json ``` The provider calls `GET {SEARXNG_URL}/search?q=...&format=json` using encoded query parameters. SearXNG must allow requests from the application server; disabled JSON output returns HTTP 403. See the [official Search API documentation](https://docs.searxng.org/dev/search_api.html). Only the current query is sent to SearXNG, not conversation history or personas; configured SearXNG engines can forward that query to their search services. The server-side `WebSearchProvider` JSDoc contract is `search(query, {signal, limit}?) -> Promise`; `SearXNGSearchProvider` is the initial implementation. Results have `{title, url, snippet}`. This repository uses JavaScript ES modules, so types are documented with JSDoc rather than introducing a TypeScript build. Search emits `{status: "searching"}` only when retrieval actually runs, then `{status: "generating", searchState, sourceCount?}` before model generation. The UI initially says “Preparing response…” so skipped searches never claim to search. `searchState` distinguishes `skipped`, `no_results`, `no_relevant_results`, `succeeded`, and `failed`. Successful `done` events and non-streaming responses carry this state and optional `sources`; search failures carry `searchState: "failed"` in the error response. No usable evidence produces `sources: []` and instructions to say that a search found insufficient relevant evidence, without fake citations. Initial retrieval failures use the existing error event (HTTP 503 on `/chat`) and do not generate an answer; an optional fallback failure preserves the successful initial search. The saved failed user message remains visible, and the composer restores its text for resending without search. Sources appear below assistant messages as numbered links. They persist in a nullable SQLite `chat_messages.sources` JSON column, added automatically on startup using the existing additive schema mechanism; no manual migration is needed. Old messages remain valid. Sources are preserved in JSON exports/imports and branches. Regenerating an answer reuses its stored results without performing another search; a fresh search requires sending another message with the toggle enabled. Run `node --test --test-isolation=none` and `npm run lint` to check the feature and existing flows. `test/webSearch.test.js` uses local HTTP protocol fixtures and an isolated in-memory database; no production search results are mocked. Live SearXNG/Ollama verification still requires configured, reachable services. There is no TypeScript or build command in this framework-free project. ## Legal - [Privacy Policy](PRIVACY_POLICY.md) - [Terms of Service](TERMS_OF_SERVICE.md) ## Page navigation - `/` is the public landing page, even for signed-in visitors. - `/login` and `/register` provide dedicated account forms. Registration starts a session automatically. - `/app` is workspace home, with recent chats and optional starters. - `/app/chats/new` opens an empty composer; sending the first message creates the conversation. - `/app/chats/:id` opens a conversation. Legacy `/?chat=:id` links redirect here. - `/app/personas` provides persona management, using the shared settings editor module. - `/app/settings` contains account and appearance settings. `/settings` redirects here. - `/market` allows visitors to browse public summaries and signed-in users to use the full persona market. Protected pages redirect to sign-in with a local return destination. Public discovery only returns published persona summaries; using a persona requires an account. All page routes render the same navigation from `src/pageLayout.js` into the `` placeholder. `public/css/shell.css` owns the shared header, page spacing, and mobile navigation; `public/js/workspaceNavigation.js` owns sign-out. Page-specific headers contain only local titles and actions. The chat sidebar is reserved for conversation history. Public and home content share `public/css/pages.css` and `public/js/pages.js`. Page routes run before static assets and authenticated API routers; rendered pages use private, non-cacheable responses. For environments that block child-process test workers, run `node --test --test-isolation=none`.