Compare commits
38 Commits
393b57e9c2
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 72d5c8b2dc | |||
| 448fe53811 | |||
| be4cce67f7 | |||
| 99aff83a23 | |||
| b8b06a8a33 | |||
| 42e71fb502 | |||
| 5765922765 | |||
| d5d886ac5f | |||
| 068843beda | |||
| 5489e03138 | |||
| dd23a19ed2 | |||
| 7723125c93 | |||
| f67d918d09 | |||
| 8666e09049 | |||
| 0f58a996ec | |||
| 2275ddfb90 | |||
| 5624eead82 | |||
| 5ca611c113 | |||
| 10ec76d437 | |||
| 0ead7fa30e | |||
| 7be654af11 | |||
| 674abf78f6 | |||
| 387c0ac2e7 | |||
| 1c0f06716e | |||
| c48e1b17c4 | |||
| e8cd13a0af | |||
| 20ad835205 | |||
| c69070a6e6 | |||
| d15f7867d5 | |||
| 7a1b152f20 | |||
| 0a24035b27 | |||
| 9020356e62 | |||
| 85e76b5d4c | |||
| 80a30e2ce0 | |||
| 85e6d0c957 | |||
| 67f7d47128 | |||
| 7eaf54ae11 | |||
| 53160b3ea4 |
+9
-1
@@ -2,9 +2,17 @@ SESSION_SECRET=replace-with-a-long-random-secret
|
|||||||
# Optional: reachable from the Express server, never from the browser.
|
# Optional: reachable from the Express server, never from the browser.
|
||||||
# SEARXNG_URL=http://searxng:8080
|
# SEARXNG_URL=http://searxng:8080
|
||||||
WEB_SEARCH_TIMEOUT_MS=8000
|
WEB_SEARCH_TIMEOUT_MS=8000
|
||||||
UPLOAD_DIR=/app/data/uploads
|
# Production containers can set UPLOAD_DIR=/app/data/uploads.
|
||||||
|
# Locally, omitting UPLOAD_DIR uses data/uploads.
|
||||||
UPLOAD_MAX_SIZE_MB=10
|
UPLOAD_MAX_SIZE_MB=10
|
||||||
UPLOAD_MAX_FILES=5
|
UPLOAD_MAX_FILES=5
|
||||||
UPLOAD_MAX_PIXELS=30000000
|
UPLOAD_MAX_PIXELS=30000000
|
||||||
VISION_MODEL=gemma3:4b
|
VISION_MODEL=gemma3:4b
|
||||||
|
VISION_DEBUG=false
|
||||||
VISION_DEBUG_SHA256_ENABLED=false
|
VISION_DEBUG_SHA256_ENABLED=false
|
||||||
|
# Required to enable automatic context compaction; use the exact hosted Ollama tag.
|
||||||
|
CONTEXT_MODEL=
|
||||||
|
CONTEXT_COMPACTION_TRIGGER_TOKENS=6000
|
||||||
|
CONTEXT_COMPACTION_RECENT_TOKENS=2500
|
||||||
|
CONTEXT_COMPACTION_MIN_MESSAGES=4
|
||||||
|
CONTEXT_COMPACTION_MAX_CHARS=8000
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
name: CI
|
||||||
|
on: [push, pull_request]
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
node: [22, 24]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ matrix.node }}
|
||||||
|
cache: npm
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run lint
|
||||||
|
- run: npm test
|
||||||
+12
-1
@@ -1,2 +1,13 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
data/uploads/
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
data/
|
||||||
|
sessions/
|
||||||
|
*.db
|
||||||
|
*.db-*
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite-*
|
||||||
|
*.sqlite3
|
||||||
|
*.sqlite3-*
|
||||||
|
.idea/
|
||||||
|
|||||||
@@ -1,23 +1,67 @@
|
|||||||
# Elsewhere
|
# 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.
|
Elsewhere is an Express + SQLite app for account-based chat with centrally curated AI personalities, 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.
|
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.
|
||||||
|
|
||||||
|
## Conversation workspace
|
||||||
|
|
||||||
|
Conversation drafts are saved in this browser per account and conversation, including references to completed image uploads. Uploads still in progress cannot be recovered after a reload. If a recovered image has expired or is unavailable, remove it and attach it again. Drafts stay local to the browser and are cleared after a successful send and history refresh.
|
||||||
|
|
||||||
|
Chat settings include **Branches & compare** for browsing the conversation tree, opening related chats, and comparing response versions side by side. **Conversation memory** provides editable facts, a running summary, and an approximate context-token count. Summary edits detect conflicting updates; subsequent automatic summaries build on saved corrections.
|
||||||
|
|
||||||
|
History opens with the latest 50 messages. **Load older messages** fetches earlier pages without losing the reading position. `GET /chats/:id/messages` accepts `limit` (1–100), `before`, and `leaf` cursors; reuse the response's `pagination.before` and `pagination.leafId`. A changed branch returns 409. `around` opens a page ending at a search match, with **Back to latest messages** available in the UI.
|
||||||
|
|
||||||
|
`GET /health` is public and returns `{ok: true}` (200) for a healthy model backend or `{ok: false}` (503) when it is unavailable. Detailed diagnostics remain protected. Registration and password changes reject new passwords over 72 UTF-8 bytes, matching [bcrypt's documented limit](https://github.com/kelektiv/node.bcrypt.js#security-issues-and-concerns). Existing passwords remain usable for login and current-password checks.
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
Node.js 22 or newer is required.
|
Node.js 22.13 or newer is required.
|
||||||
|
|
||||||
1. Install dependencies with `npm install`.
|
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`.
|
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.
|
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
|
## Scripts
|
||||||
|
|
||||||
- `npm start`: run the production server entrypoint.
|
- `npm start`: run the production server entrypoint.
|
||||||
- `npm run dev`: run the server with Node watch mode.
|
- `npm run dev`: run the server with Node watch mode.
|
||||||
- `npm run lint`: syntax-check the backend and browser modules.
|
- `npm run lint`: syntax-check the backend and browser modules.
|
||||||
- `npm test`: run API tests with Node's built-in test runner.
|
- `npm test`: run API tests with Node's built-in test runner.
|
||||||
|
- `npm run test:browser`: serve the navigation regression harness locally; open the printed URL in a browser. The same cases run with a lightweight DOM fixture in `npm test`.
|
||||||
|
- `npm run user:role -- <username> <user|admin>`: 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
|
## Environment Variables
|
||||||
|
|
||||||
@@ -42,7 +86,7 @@ Node.js 22 or newer is required.
|
|||||||
- `MODEL_UNLOAD_AFTER_MS`: delay before idle local request bookkeeping is cleared. Model unloading is managed by Ollama through `OLLAMA_KEEP_ALIVE`.
|
- `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.
|
- `MODELS_CACHE_TTL_MS`: model list cache TTL.
|
||||||
- `AUTH_RATE_LIMIT_*`: rate-limit window and thresholds for registration/login.
|
- `AUTH_RATE_LIMIT_*`: rate-limit window and thresholds for registration/login.
|
||||||
- `CHAT_RATE_LIMIT_*`: rate-limit window and thresholds for chat and retry endpoints.
|
- `CHAT_RATE_LIMIT_*`: shared rate-limit window and thresholds for chat, retry, roleplay openers, marketplace openers, and edit/resend endpoints.
|
||||||
- `RATE_LIMIT_CLEANUP_INTERVAL_MS`: interval for pruning stale in-memory rate-limit buckets.
|
- `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.
|
- `DROP_LEGACY_CHATS`: when `true`, migrate any legacy `chats` table into `chat_sessions` / `chat_messages` and remove the old table.
|
||||||
|
|
||||||
@@ -67,8 +111,20 @@ Outside production, the default directory is `data/uploads`; `UPLOAD_DIR` overri
|
|||||||
it. Startup creates the directory and checks write permission, failing clearly rather
|
it. Startup creates the directory and checks write permission, failing clearly rather
|
||||||
than falling back to another location. Files use `YYYY/MM/DD/<uuid>.<format>` keys.
|
than falling back to another location. Files use `YYYY/MM/DD/<uuid>.<format>` keys.
|
||||||
Do not publish this directory as a static directory. Back up **both the SQLite database
|
Do not publish this directory as a static directory. Back up **both the SQLite database
|
||||||
and uploads directory**. JSON workspace exports contain attachment references only;
|
and uploads directory** for server recovery. The workspace's **Download workspace archive**
|
||||||
imports reconnect existing images owned by the same account, not portable image bytes.
|
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.
|
||||||
|
|
||||||
|
Imports remap persona, chat and message IDs, including template, memory, continuity,
|
||||||
|
branch, retry and summary references. References absent from the import are left
|
||||||
|
unassigned. Legacy histories without parent fields are restored as ordered message
|
||||||
|
chains; explicit branch roots remain separate. Unset generation settings remain unset.
|
||||||
|
|
||||||
|
`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:
|
Authenticated API endpoints:
|
||||||
|
|
||||||
@@ -107,52 +163,59 @@ Ollama client sends `model: VISION_MODEL`, `think: false`, a JSON Schema in `for
|
|||||||
and the existing cancellation/timeouts. Its stream is consumed privately; only the
|
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.
|
final selected chat model's answer and opted-in reasoning reach the user.
|
||||||
|
|
||||||
The bounded, validated vision record is:
|
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
|
```json
|
||||||
{
|
{
|
||||||
"summary": "Concise factual overview",
|
"scene": "Detailed factual overview of what is visible",
|
||||||
"answer": "Direct visual conclusion relevant to the user's question",
|
"subjects": [{"type": "bird", "description": "Fluffy gray down", "location": "center"}],
|
||||||
"confidence": 0.9,
|
"visualDetails": ["The crown is black", "A narrow dark line runs directly beneath the chin"],
|
||||||
"evidence": ["Visible details supporting the conclusion"],
|
|
||||||
"visibleText": ["Clearly readable text"],
|
"visibleText": ["Clearly readable text"],
|
||||||
"observations": [],
|
"spatialRelationships": ["The bird faces the camera"],
|
||||||
"alternatives": [],
|
"colorsAndAppearance": ["Gray body and black head"],
|
||||||
|
"recognizedElements": ["Juvenile penguin"],
|
||||||
|
"notableFeatures": ["Narrow dark line beneath the chin"],
|
||||||
"uncertainties": []
|
"uncertainties": []
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Gemma adapts its emphasis to identification, UI debugging, OCR, counting, comparison,
|
The vision model reports direct observations and visually recognizable elements,
|
||||||
or scene understanding. It should give the most likely supported answer, qualified
|
while avoiding semantic conclusions such as a species inferred from markings or an
|
||||||
when appropriate, rather than refusing because absolute certainty is impossible.
|
unseen backend cause. The prompt asks for precise location, geometry, boundaries,
|
||||||
`answer` can be null for genuinely insufficient evidence. `confidence` is a number
|
and named regions where visible, with separate entries for distinguishing markings.
|
||||||
from 0 to 1 (or null), not a calibrated probability. Alternatives require actual
|
Uncertainty concerns what can be seen, not the final identification. Each field has
|
||||||
support; empty arrays are preferred to speculative lists or boilerplate limitations.
|
a length/item limit that allows detailed screenshots. The
|
||||||
Each field has a length/item limit, with at most eight evidence items and three
|
generation cap is 2,400 tokens; simple images can use much less.
|
||||||
alternatives. The helper retains its 1,600-token output budget.
|
|
||||||
|
|
||||||
Malformed JSON falls back to a bounded summary plus `rawAnalysis` (up to 6,000
|
Malformed JSON falls back to a bounded scene plus `rawAnalysis` (up to 6,000
|
||||||
characters), with no invented answer or confidence. Valid JSON is parsed before
|
characters) as unstructured visual evidence. Valid JSON is parsed before
|
||||||
individual fields are bounded, so truncation cannot corrupt its structure.
|
individual fields are bounded, so truncation cannot corrupt its structure.
|
||||||
An empty response or unavailable Gemma fails the turn explicitly, without asking the
|
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
|
chat model to invent image details. The cache key is attachment, configured vision
|
||||||
model, `VISION_PROMPT_VERSION` (currently 2), and trimmed user question; case and
|
model, `VISION_PROMPT_VERSION` (currently 4), and trimmed user question; case and
|
||||||
internal whitespace are preserved. Records include the analysis and timestamp.
|
internal whitespace are preserved. Records include the analysis and timestamp.
|
||||||
Only an exact matching key is reused. Legacy model-only snapshots are retained for
|
Only an exact matching key is reused. Earlier prompt-version reports remain in
|
||||||
compatibility but never used as cache hits. Bump the prompt version when changing
|
storage but cannot be cache hits for version 4. Legacy model-only snapshots are
|
||||||
the analysis contract. Cache records cascade away when the attachment is deleted.
|
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.
|
||||||
|
|
||||||
Recent-history image context remains limited to the configured image count. A new
|
Images on the current user message are analyzed individually and labeled in their
|
||||||
question, including a follow-up without another upload, analyzes retained images
|
attachment order. Earlier images are selected only for explicit historical references;
|
||||||
for that question. Repeated questions and regeneration reuse matching results.
|
they do not compete with an unrelated current image. Regeneration uses the pinned
|
||||||
Different question results coexist, including across shared branches. A new question
|
original prompt and its attachments. Repeated questions and regeneration reuse
|
||||||
can therefore require extra Gemma calls, bounded by the existing image count.
|
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
|
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
|
context message, not a truncated JSON string in a system prompt. Trusted guidance
|
||||||
tells it to answer directly using the answer, confidence and evidence, retain real
|
tells Qwen to weigh discriminating observed features, compare plausible candidates,
|
||||||
ambiguity, avoid inventing alternatives, and distinguish visible UI state from unseen
|
and keep observed details distinct from inferred traits. It must not sharpen vague
|
||||||
backend causes. It should not mention internal tools/models unless explicitly asked.
|
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
|
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.
|
from visible messages; the final model never receives image bytes or filesystem paths.
|
||||||
These are model instructions, not a deterministic guarantee of identification accuracy
|
These are model instructions, not a deterministic guarantee of identification accuracy
|
||||||
@@ -160,12 +223,19 @@ or final-answer wording. Automated tests cover the contract and data flow; seman
|
|||||||
quality requires evaluation with real images and the deployed models.
|
quality requires evaluation with real images and the deployed models.
|
||||||
|
|
||||||
Web Search and Reasoning remain independent. For requested searches, concise visual
|
Web Search and Reasoning remain independent. For requested searches, concise visual
|
||||||
evidence helps form the query without overriding the user's search decision. Search
|
evidence from notable features, visual details, visible text, and scene helps
|
||||||
finishes before final generation; source ordering/rendering is unchanged. The final
|
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.
|
request still uses `stream: true` and the existing capability-checked reasoning toggle.
|
||||||
Stop/disconnect cancels the current vision or final generation request. Logging includes
|
Stop/disconnect cancels the current vision or final generation request. Logging includes
|
||||||
IDs, sizes, types and cache events only, never images/Base64/private analysis.
|
IDs, sizes, types and cache events only, never images/Base64/private analysis.
|
||||||
|
|
||||||
|
Roleplay and marketplace initialization use the same chat generation lock and
|
||||||
|
cancellation tracking as other replies. Concurrent initializations return HTTP 409
|
||||||
|
and save at most one opener. Edit/resend holds the source chat's lock while generating
|
||||||
|
its branch, and Stop can cancel it through the source chat or the new branch.
|
||||||
|
|
||||||
Dependencies added: `multer` (bounded multipart handling), `sharp` (content detection,
|
Dependencies added: `multer` (bounded multipart handling), `sharp` (content detection,
|
||||||
dimensions and full image decoding). Install through the normal `npm install`/`npm ci`
|
dimensions and full image decoding). Install through the normal `npm install`/`npm ci`
|
||||||
workflow, including Sharp's platform-specific optional dependencies in Docker. The
|
workflow, including Sharp's platform-specific optional dependencies in Docker. The
|
||||||
@@ -180,7 +250,7 @@ The server is configured around an Ollama-compatible API surface:
|
|||||||
|
|
||||||
`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.
|
`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 on server acceptance. It sends `{message, chatId, model, webSearch, reasoning}` using streamed fetch to `POST /chat/stream`. Existing SSE events carry `status` (accepted/searching/generating), `thinking` (chunk/fullThinking), `chunk` (chunk/fullReply), `done` (reply/sources), and `error`. Final answers stream incrementally, including persona responses. Persona validation still runs; a corrective pass resets provisional output before streaming its replacement. Only the validated complete answer is persisted.
|
The composer has independent Web Search and Reasoning toggles. Automatic web search starts enabled and keeps the selected on/off state across messages until the page reloads. Reasoning starts off and resets 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.
|
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.
|
||||||
|
|
||||||
@@ -214,7 +284,12 @@ Legacy `chats` migration is handled during startup. New code only reads the sess
|
|||||||
|
|
||||||
The browser UI remains framework-free and uses native ES modules. Shared behavior lives under `public/js/`:
|
The browser UI remains framework-free and uses native ES modules. Shared behavior lives under `public/js/`:
|
||||||
|
|
||||||
- `api.js`: JSON and streaming request helpers
|
- `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
|
- `constants.js`: theme and onboarding metadata
|
||||||
- `themeController.js`: validated theme/mode persistence and application
|
- `themeController.js`: validated theme/mode persistence and application
|
||||||
- `theme-bootstrap.js`: applies stored appearance before page initialization
|
- `theme-bootstrap.js`: applies stored appearance before page initialization
|
||||||
@@ -222,19 +297,34 @@ The browser UI remains framework-free and uses native ES modules. Shared behavio
|
|||||||
- `statusNotice.js`: shared status and error presentation
|
- `statusNotice.js`: shared status and error presentation
|
||||||
- `dom.js`: cached chat-page DOM references
|
- `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.
|
`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.
|
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
|
## 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.
|
Automatic web search is enabled by default. The globe button switches it off or back on; that choice persists across sends and regenerations until the page reloads. The browser sends `webSearch: true` (or `false`) through the authenticated `POST /chat/stream`. `POST /chat`, retries, and edit/resend use the same default: omitting `webSearch` enables automatic search, while `false` bypasses both search planning and retrieval.
|
||||||
|
|
||||||
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”.
|
When search is enabled, the selected AI model makes a short structured decision about whether external evidence would help and, if so, supplies a search query. It sees the current message, up to six recent messages, and available image observations so it can resolve follow-up references. This internal decision is never streamed or saved as a chat reply. Explicit commands such as `search the internet` bypass planning and search directly; questions answered by the server clock skip retrieval. Malformed decisions, unsupported structured output, or a planning failure fall back to the existing keyword rules. Planning is limited to 160 output tokens and 10 seconds, and honors Stop/disconnection. Query cleanup preserves requested dates and years.
|
||||||
|
|
||||||
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.
|
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 load the example explicitly with `node --env-file=.env server.js` (the existing npm start command does not load `.env` automatically):
|
Set environment variables in the server/container environment, or in `.env` (loaded by `npm start` and `npm run dev`):
|
||||||
|
|
||||||
```dotenv
|
```dotenv
|
||||||
SEARXNG_URL=http://searxng:8080
|
SEARXNG_URL=http://searxng:8080
|
||||||
@@ -252,15 +342,15 @@ search:
|
|||||||
- json
|
- 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 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 resulting search query is sent to SearXNG; it may include relevant details from recent conversation or image observations when needed to resolve the question. Configured SearXNG engines can forward that query to their search services.
|
||||||
|
|
||||||
The server-side `WebSearchProvider` JSDoc contract is `search(query, {signal, limit}?) -> Promise<SearchResult[]>`; `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.
|
The server-side `WebSearchProvider` JSDoc contract is `search(query, {signal, limit}?) -> Promise<SearchResult[]>`; `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.
|
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.
|
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 or editing/resending a message makes a fresh search decision when web search is enabled, using only the conversation through the corresponding user prompt. Earlier response versions keep their own saved sources.
|
||||||
|
|
||||||
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.
|
Run `npm test` 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
|
## Legal
|
||||||
|
|
||||||
@@ -282,4 +372,11 @@ Protected pages redirect to sign-in with a local return destination. Public disc
|
|||||||
|
|
||||||
All page routes render the same navigation from `src/pageLayout.js` into the `<!-- site-header -->` 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.
|
All page routes render the same navigation from `src/pageLayout.js` into the `<!-- site-header -->` 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`.
|
Run the full suite with `npm test` and `npm run lint`. The browser navigation harness is tracked in `browser-tests/`; `npm run test:browser` prints its local URL. It shares its cases with `test/conversation.test.js`.
|
||||||
|
|
||||||
|
For environments that block child-process test workers, run one test file at a time with `node --test --test-isolation=none test/<name>.test.js`; combining all files without isolation can leak their browser globals and mocks between suites.
|
||||||
|
# AI personalities
|
||||||
|
|
||||||
|
Conversation personalities are centrally defined in `src/services/personalityRegistry.js`. The registry exports stable IDs, public metadata, and private system prompts; `/personalities` returns metadata only. Each chat stores its selected `personality_id` in SQLite, defaulting to `general-assistant`. The chat service resolves that ID on the server for regular replies, streaming, and regeneration. The migration adds this column with a General Assistant default and leaves legacy persona tables and records intact.
|
||||||
|
|
||||||
|
To add a personality, add one registry entry with a unique kebab-case ID, display name, purpose description, icon, category, and complete `systemPrompt`. Do not expose the prompt in public metadata. No schema migration is needed for a new registry entry. Optional generation parameters are not currently applied; explicit per-chat model settings and the configured Ollama model remain authoritative.
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Conversation navigation regression tests</title>
|
||||||
|
<h1>Conversation navigation regression tests</h1>
|
||||||
|
<div id="testTitle"></div>
|
||||||
|
<input id="testModel" aria-label="Model">
|
||||||
|
<input id="chatSearchInput" aria-label="Search">
|
||||||
|
<textarea id="msgInput" aria-label="Draft"></textarea>
|
||||||
|
<div id="messages"></div>
|
||||||
|
<pre id="results">Running...</pre>
|
||||||
|
<script type="module">
|
||||||
|
import {registerConversationTests} from "/browser-tests/conversationCases.js";
|
||||||
|
const cases = [], results = [];
|
||||||
|
const assert = {
|
||||||
|
equal(actual, expected) { if (!Object.is(actual, expected)) throw new Error(`Expected ${expected}, received ${actual}`); },
|
||||||
|
deepEqual(actual, expected) { this.equal(JSON.stringify(actual), JSON.stringify(expected)); }
|
||||||
|
};
|
||||||
|
registerConversationTests((name, run) => cases.push({name, run}), assert);
|
||||||
|
for (const {name, run} of cases) {
|
||||||
|
try { await run(); results.push(`PASS ${name}`); }
|
||||||
|
catch (error) { results.push(`FAIL ${name}: ${error.stack}`); }
|
||||||
|
}
|
||||||
|
document.getElementById("results").textContent = results.join("\n");
|
||||||
|
document.title = `${results.filter(result => result.startsWith("PASS")).length}/${cases.length} navigation tests passed`;
|
||||||
|
</script>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
/** The same navigation regressions run in a real browser and Node's lightweight DOM fixture. */
|
||||||
|
import {createConversationController} from "../public/js/conversationController.js";
|
||||||
|
import {createConversationState} from "../public/js/conversationState.js";
|
||||||
|
|
||||||
|
const noop = () => {};
|
||||||
|
|
||||||
|
function fixture() {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const pending = [];
|
||||||
|
globalThis.fetch = (url, options) => new Promise(resolve => pending.push({url, options, resolve}));
|
||||||
|
const state = createConversationState();
|
||||||
|
state.chatSessions = [1, 2, 3].map(id => ({id, title: `Chat ${id}`}));
|
||||||
|
state.activeChatId = 1;
|
||||||
|
state.currentMessages = [{id: 11, content: "Chat A's answer"}];
|
||||||
|
state.messageRetryState.set(11, {retryActiveIndex: 1});
|
||||||
|
state.chatDrafts.set(2, "Draft B");
|
||||||
|
state.chatDrafts.set(3, "Draft C");
|
||||||
|
history.replaceState(null, "", "/app/chats/1");
|
||||||
|
const input = document.getElementById("msgInput");
|
||||||
|
const messages = document.getElementById("messages");
|
||||||
|
const title = document.getElementById("testTitle");
|
||||||
|
const model = document.getElementById("testModel");
|
||||||
|
input.value = "Draft A";
|
||||||
|
messages.textContent = "Chat A's answer";
|
||||||
|
title.textContent = "Chat 1";
|
||||||
|
model.value = "Model A";
|
||||||
|
const attachmentDrafts = new Map([[2, ["Image B"]], [3, ["Image C"]]]);
|
||||||
|
const attachments = {pending: false, attachments: ["Image A"],
|
||||||
|
saveDraft(id) { attachmentDrafts.set(id, this.attachments); },
|
||||||
|
restoreDraft(id) { this.attachments = attachmentDrafts.get(id) || []; }};
|
||||||
|
const ui = {loading: false, activity: false, notices: [], renders: []};
|
||||||
|
const controller = createConversationController({state, attachmentComposer: attachments,
|
||||||
|
renderChatList: noop, resizeComposerInput: noop, isMobileLayout: () => false, closeMobileSidebar: noop,
|
||||||
|
setChatLoading: value => { ui.loading = value; }, setChatActivity: value => { ui.activity = value; },
|
||||||
|
updateWorkspaceCopy: () => { title.textContent = `Chat ${state.activeChatId}`; }, updateChatActionState: noop,
|
||||||
|
setNotice: message => ui.notices.push(message),
|
||||||
|
populateGenerationSettings: chat => { model.value = chat.preferred_model; },
|
||||||
|
renderMessages: () => { messages.textContent = state.currentMessages.map(message => message.content).join("\n"); ui.renders.push(state.activeChatId); },
|
||||||
|
updateJumpToLatest: noop, updateSendState: noop, loadSummary: noop});
|
||||||
|
const reply = async (index, body, status = 200) => {
|
||||||
|
const response = new Response(JSON.stringify(body), {status, headers: {"Content-Type": "application/json"}});
|
||||||
|
// Avoid browser Blob-reading tasks: this fixture controls navigation order,
|
||||||
|
// while api.test.js covers actual response-body reads and their deadlines.
|
||||||
|
response.text = async () => JSON.stringify(body);
|
||||||
|
pending[index].resolve(response);
|
||||||
|
// Fetch, response body parsing and the controller each await a promise.
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
};
|
||||||
|
const result = id => ({chat: {id, preferred_model: `Model ${id}`}, messages: [{id: id * 10 + 1, content: `Answer ${id}`}]});
|
||||||
|
return {state, ui, input, messages, title, model, attachments, attachmentDrafts, pending, reply, result, ...controller,
|
||||||
|
cleanup: () => { globalThis.fetch = originalFetch; }};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerConversationTests(test, assert) {
|
||||||
|
const unchanged = s => {
|
||||||
|
assert.equal(s.state.activeChatId, 1);
|
||||||
|
assert.equal(location.pathname, "/app/chats/1");
|
||||||
|
assert.equal(s.messages.textContent, "Chat A's answer");
|
||||||
|
assert.equal(s.title.textContent, "Chat 1");
|
||||||
|
assert.equal(s.model.value, "Model A");
|
||||||
|
assert.equal(s.input.value, "Draft A");
|
||||||
|
assert.deepEqual(s.attachments.attachments, ["Image A"]);
|
||||||
|
assert.equal(s.state.messageRetryState.get(11).retryActiveIndex, 1);
|
||||||
|
};
|
||||||
|
test("failed navigation preserves chat identity, URL, messages, settings and both drafts", async () => {
|
||||||
|
const s = fixture();
|
||||||
|
try {
|
||||||
|
const opening = s.setActiveChat(2);
|
||||||
|
unchanged(s);
|
||||||
|
await s.reply(0, {error: "Chat B failed to load"}, 503);
|
||||||
|
const result = await opening;
|
||||||
|
assert.equal(result.error, "Chat B failed to load");
|
||||||
|
assert.equal(result.status, 503);
|
||||||
|
unchanged(s);
|
||||||
|
assert.equal(s.state.chatDrafts.get(2), "Draft B");
|
||||||
|
assert.deepEqual(s.attachmentDrafts.get(2), ["Image B"]);
|
||||||
|
assert.deepEqual(s.ui.notices, ["Chat B failed to load"]);
|
||||||
|
assert.equal(s.ui.loading, false);
|
||||||
|
assert.equal(s.ui.activity, false);
|
||||||
|
} finally { s.cleanup(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const order of ["older-first", "newer-first"]) {
|
||||||
|
for (const staleFails of [false, true]) {
|
||||||
|
test(`overlapping navigation (${order}, stale ${staleFails ? "failure" : "success"}) commits only the latest chat`, async () => {
|
||||||
|
const s = fixture();
|
||||||
|
try {
|
||||||
|
const older = s.setActiveChat(2), newer = s.setActiveChat(3);
|
||||||
|
unchanged(s);
|
||||||
|
const replyOlder = () => s.reply(0, staleFails ? {error: "Stale error"} : s.result(2), staleFails ? 500 : 200);
|
||||||
|
if (order === "older-first") {
|
||||||
|
await replyOlder(); await older;
|
||||||
|
unchanged(s);
|
||||||
|
assert.equal(s.ui.loading, true);
|
||||||
|
assert.equal(s.ui.activity, true);
|
||||||
|
// Edits made while loading belong to the still-visible chat A.
|
||||||
|
s.input.value = "Draft A updated";
|
||||||
|
await s.reply(1, s.result(3)); await newer;
|
||||||
|
} else {
|
||||||
|
await s.reply(1, s.result(3)); await newer;
|
||||||
|
await replyOlder(); await older;
|
||||||
|
}
|
||||||
|
assert.equal(s.state.activeChatId, 3);
|
||||||
|
assert.equal(location.pathname, "/app/chats/3");
|
||||||
|
assert.equal(s.messages.textContent, "Answer 3");
|
||||||
|
assert.equal(s.title.textContent, "Chat 3");
|
||||||
|
assert.equal(s.model.value, "Model 3");
|
||||||
|
assert.equal(s.input.value, "Draft C");
|
||||||
|
assert.equal(s.state.chatDrafts.get(1), order === "older-first" ? "Draft A updated" : "Draft A");
|
||||||
|
assert.equal(s.state.chatDrafts.get(2), "Draft B");
|
||||||
|
assert.deepEqual(s.attachments.attachments, ["Image C"]);
|
||||||
|
assert.deepEqual(s.attachmentDrafts.get(1), ["Image A"]);
|
||||||
|
assert.deepEqual(s.ui.renders, [3]);
|
||||||
|
assert.deepEqual(s.ui.notices, []);
|
||||||
|
assert.equal(s.state.messageRetryState.size, 0);
|
||||||
|
assert.equal(s.ui.loading, false);
|
||||||
|
} finally { s.cleanup(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a late success cannot replace A after the newer navigation fails", async () => {
|
||||||
|
const s = fixture();
|
||||||
|
try {
|
||||||
|
const older = s.setActiveChat(2), newer = s.setActiveChat(3);
|
||||||
|
await s.reply(1, {error: "Chat C failed"}, 500); await newer;
|
||||||
|
await s.reply(0, s.result(2)); await older;
|
||||||
|
unchanged(s);
|
||||||
|
assert.deepEqual(s.ui.notices, ["Chat C failed"]);
|
||||||
|
assert.equal(s.ui.loading, false);
|
||||||
|
} finally { s.cleanup(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const failure of ["activation", "reload"]) {
|
||||||
|
test(`search-result branch ${failure} failure preserves the displayed chat`, async () => {
|
||||||
|
const s = fixture();
|
||||||
|
try {
|
||||||
|
const opening = s.setActiveChat(2, {matchMessageId: 25});
|
||||||
|
await s.reply(0, s.result(2));
|
||||||
|
assert.equal(s.pending[1].url, "/chats/2/branches/25/activate");
|
||||||
|
await s.reply(1, failure === "activation" ? {error: "Branch failed"} : {}, failure === "activation" ? 500 : 200);
|
||||||
|
if (failure === "reload") await s.reply(2, {error: "Reload failed"}, 500);
|
||||||
|
await opening;
|
||||||
|
unchanged(s);
|
||||||
|
assert.equal(s.ui.notices.length, 1);
|
||||||
|
assert.equal(s.ui.loading, false);
|
||||||
|
} finally { s.cleanup(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a stale branch activation cannot reload or clear a newer navigation's activity", async () => {
|
||||||
|
const s = fixture();
|
||||||
|
try {
|
||||||
|
const older = s.setActiveChat(2, {matchMessageId: 25});
|
||||||
|
await s.reply(0, s.result(2));
|
||||||
|
const newer = s.setActiveChat(3);
|
||||||
|
await s.reply(1, {}); await older;
|
||||||
|
assert.equal(s.pending.length, 3);
|
||||||
|
assert.equal(s.ui.loading, true);
|
||||||
|
unchanged(s);
|
||||||
|
await s.reply(2, s.result(3)); await newer;
|
||||||
|
assert.equal(s.state.activeChatId, 3);
|
||||||
|
} finally { s.cleanup(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test("older messages prepend once and a failed page keeps the current history and draft", async () => {
|
||||||
|
const s = fixture();
|
||||||
|
try {
|
||||||
|
s.state.historyPage = {hasMore: true, before: 11, leafId: 11};
|
||||||
|
const older = s.loadOlderMessages();
|
||||||
|
await s.loadOlderMessages();
|
||||||
|
assert.equal(s.pending.length, 1);
|
||||||
|
await s.reply(0, {messages: [{id: 9, content: "Older answer"}], pagination: {hasMore: true, before: 9, leafId: 11}});
|
||||||
|
await older;
|
||||||
|
assert.deepEqual(s.state.currentMessages.map(message => message.id), [9, 11]);
|
||||||
|
assert.equal(s.input.value, "Draft A");
|
||||||
|
const failed = s.loadOlderMessages();
|
||||||
|
await s.reply(1, {error: "Page failed"}, 503); await failed;
|
||||||
|
assert.deepEqual(s.state.currentMessages.map(message => message.id), [9, 11]);
|
||||||
|
assert.equal(s.state.historyLoading, false);
|
||||||
|
assert.equal(s.ui.notices.at(-1), "Page failed");
|
||||||
|
} finally { s.cleanup(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an older page arriving after navigation cannot contaminate the new conversation", async () => {
|
||||||
|
const s = fixture();
|
||||||
|
try {
|
||||||
|
s.state.historyPage = {hasMore: true, before: 11, leafId: 11};
|
||||||
|
const older = s.loadOlderMessages(), opening = s.setActiveChat(2);
|
||||||
|
await s.reply(1, s.result(2)); await opening;
|
||||||
|
await s.reply(0, {messages: [{id: 9, content: "Stale answer"}], pagination: {hasMore: false}}); await older;
|
||||||
|
assert.deepEqual(s.state.currentMessages.map(message => message.id), [21]);
|
||||||
|
assert.equal(s.state.historyLoading, false);
|
||||||
|
assert.equal(s.state.activeChatId, 2);
|
||||||
|
} finally { s.cleanup(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
+8
-1
@@ -1,7 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* @file Konfiguriert ESLint-Regeln und Browser-Globals für JavaScript-Dateien.
|
||||||
|
*/
|
||||||
import js from "@eslint/js";
|
import js from "@eslint/js";
|
||||||
import globals from "globals";
|
import globals from "globals";
|
||||||
import { defineConfig } from "eslint/config";
|
import { defineConfig } from "eslint/config";
|
||||||
|
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
{ files: ["**/*.{js,mjs,cjs}"], plugins: { js }, extends: ["js/recommended"], languageOptions: { globals: globals.browser } },
|
{ files: ["**/*.{js,mjs,cjs}"], plugins: { js }, extends: ["js/recommended"], languageOptions: { globals: {...globals.browser, ...globals.node} } },
|
||||||
|
{
|
||||||
|
files: ["public/js/script.js", "public/js/composerController.js", "public/js/conversationController.js", "public/js/messageRenderer.js", "public/js/personaController.js", "public/js/messageActions.js", "public/js/workspaceTransfer.js"],
|
||||||
|
rules: {"no-shadow": "error"}
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[phases.setup]
|
||||||
|
nixPkgs = ["...", "python3", "gcc", "gnumake"]
|
||||||
Generated
+2608
File diff suppressed because it is too large
Load Diff
+11
-6
@@ -4,23 +4,28 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22"
|
"node": ">=22.13.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js",
|
"start": "node --env-file-if-exists=.env scripts/start.js",
|
||||||
"dev": "node --watch server.js",
|
"dev": "node --env-file-if-exists=.env --watch scripts/dev-server.js",
|
||||||
"lint": "node scripts/lint.mjs",
|
"user:role": "node --env-file-if-exists=.env scripts/set-role.js",
|
||||||
"test": "node --test"
|
"lint": "eslint .",
|
||||||
|
"test": "npm run test:ci",
|
||||||
|
"test:backend": "node scripts/run-tests.js backend",
|
||||||
|
"test:frontend": "node scripts/run-tests.js frontend",
|
||||||
|
"test:ci": "npm run test:backend && npm run test:frontend",
|
||||||
|
"test:browser": "node scripts/browser-tests.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"better-sqlite3": "^13.0.3",
|
"better-sqlite3": "^13.0.3",
|
||||||
"body-parser": "^1.20.2",
|
"body-parser": "^1.20.2",
|
||||||
|
"dompurify": "3.4.16",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"express-session": "^1.17.3",
|
"express-session": "^1.17.3",
|
||||||
"multer": "^2.4.0",
|
"multer": "^2.4.0",
|
||||||
"qs": "^6.15.0",
|
"qs": "^6.15.0",
|
||||||
"session-file-store": "^1.5.0",
|
|
||||||
"sharp": "^0.35.4"
|
"sharp": "^0.35.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
+65
-65
@@ -12,77 +12,77 @@
|
|||||||
<link rel="stylesheet" href="/css/pages.css?v=20260909-account-controls">
|
<link rel="stylesheet" href="/css/pages.css?v=20260909-account-controls">
|
||||||
<link rel="stylesheet" href="/css/shell.css?v=20260909-account-controls">
|
<link rel="stylesheet" href="/css/shell.css?v=20260909-account-controls">
|
||||||
<link
|
<link
|
||||||
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=Manrope:wght@400;500;600;700;800&display=swap"
|
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=Manrope:wght@400;500;600;700;800&display=swap"
|
||||||
rel="stylesheet">
|
rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="site-page account-page">
|
<body class="site-page account-page">
|
||||||
<!-- site-header -->
|
<!-- site-header -->
|
||||||
<main id="main" class="account-layout page-content">
|
<main id="main" class="account-layout page-content">
|
||||||
<section class="account-intro">
|
<section class="account-intro">
|
||||||
<p class="eyebrow">Welcome to elsewhere</p>
|
<p class="eyebrow">Welcome to elsewhere</p>
|
||||||
<h1 id="accountHeadline">Pick up where<br>curiosity left off.</h1>
|
<h1 id="accountHeadline">Pick up where<br>curiosity left off.</h1>
|
||||||
<p>A space for your ideas, your characters, and the conversations worth coming back to.</p>
|
<p>A space for your ideas, your characters, and the conversations worth coming back to.</p>
|
||||||
<a href="/">← Back to Elsewhere</a>
|
<a href="/">← Back to Elsewhere</a>
|
||||||
</section>
|
</section>
|
||||||
<section class="account-box">
|
<section class="account-box">
|
||||||
<h2 id="accountTitle">Sign in</h2>
|
<h2 id="accountTitle">Sign in</h2>
|
||||||
<p id="accountDescription" class="muted">Welcome back. Your conversations are waiting.</p>
|
<p id="accountDescription" class="muted">Welcome back. Your conversations are waiting.</p>
|
||||||
<form id="accountForm">
|
<form id="accountForm">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Username</span>
|
<span>Username</span>
|
||||||
<input name="username" autocomplete="username" required minlength="3">
|
<input name="username" autocomplete="username" required minlength="3">
|
||||||
</label>
|
</label>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="accountPassword">Password</label>
|
<label for="accountPassword">Password</label>
|
||||||
<div class="password-input">
|
<div class="password-input">
|
||||||
<input id="accountPassword" name="password" type="password" autocomplete="current-password"
|
<input id="accountPassword" name="password" type="password" autocomplete="current-password"
|
||||||
required minlength="6">
|
required minlength="6">
|
||||||
<button type="button" id="showPassword" class="icon-button password-toggle"
|
<button type="button" id="showPassword" class="icon-button password-toggle"
|
||||||
aria-label="Show password" title="Show password" aria-controls="accountPassword"
|
aria-label="Show password" title="Show password" aria-controls="accountPassword"
|
||||||
aria-pressed="false">
|
aria-pressed="false">
|
||||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor"
|
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor"
|
||||||
stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z" />
|
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z"/>
|
||||||
<circle cx="12" cy="12" r="3" />
|
<circle cx="12" cy="12" r="3"/>
|
||||||
<path class="eye-slash" d="m3 3 18 18" />
|
<path class="eye-slash" d="m3 3 18 18"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<label id="inviteField" class="field hidden">
|
</div>
|
||||||
<span>Invite code</span>
|
<label id="inviteField" class="field hidden">
|
||||||
<input name="inviteCode" autocomplete="off">
|
<span>Invite code</span>
|
||||||
</label>
|
<input name="inviteCode" autocomplete="off">
|
||||||
<label id="consentField" class="consent-container hidden">
|
</label>
|
||||||
<input name="consent" type="checkbox">
|
<label id="consentField" class="consent-container hidden">
|
||||||
<span>I agree to the <a href="/tos.html" target="_blank" rel="noopener">Terms</a> and <a
|
<input name="consent" type="checkbox">
|
||||||
href="/privacy.html" target="_blank" rel="noopener">Privacy Policy</a>.</span>
|
<span>I agree to the <a href="/tos.html" target="_blank" rel="noopener">Terms</a> and <a
|
||||||
</label>
|
href="/privacy.html" target="_blank" rel="noopener">Privacy Policy</a>.</span>
|
||||||
<div id="accountStatus" class="account-status hidden" role="status" aria-live="polite"
|
</label>
|
||||||
aria-atomic="true" tabindex="-1">
|
<div id="accountStatus" class="account-status hidden" role="status" aria-live="polite"
|
||||||
<svg class="account-error-icon" viewBox="0 0 24 24" width="20" height="20" fill="none"
|
aria-atomic="true" tabindex="-1">
|
||||||
stroke="currentColor" stroke-width="2" aria-hidden="true">
|
<svg class="account-error-icon" viewBox="0 0 24 24" width="20" height="20" fill="none"
|
||||||
<circle cx="12" cy="12" r="9" />
|
stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||||
<path d="M12 7v6m0 3v1" />
|
<circle cx="12" cy="12" r="9"/>
|
||||||
</svg>
|
<path d="M12 7v6m0 3v1"/>
|
||||||
<span id="accountStatusText"></span>
|
</svg>
|
||||||
</div>
|
<span id="accountStatusText"></span>
|
||||||
<button id="accountSubmit" class="primary-action" type="submit">Sign in</button>
|
</div>
|
||||||
</form>
|
<button id="accountSubmit" class="primary-action" type="submit">Sign in</button>
|
||||||
<p id="accountSwitch">
|
</form>
|
||||||
</p>
|
<p id="accountSwitch">
|
||||||
</section>
|
</p>
|
||||||
</main>
|
</section>
|
||||||
<footer class="site-footer">
|
</main>
|
||||||
<a class="site-brand" href="/">elsewhere</a>
|
<footer class="site-footer">
|
||||||
<span>A space for every conversation.</span>
|
<a class="site-brand" href="/">elsewhere</a>
|
||||||
<a href="/privacy.html">Privacy</a>
|
<span>A space for every conversation.</span>
|
||||||
<a href="/tos.html">Terms</a>
|
<a href="/privacy.html">Privacy</a>
|
||||||
</footer>
|
<a href="/tos.html">Terms</a>
|
||||||
<script type="module" src="/js/pages.js?v=20260909-account-controls">
|
</footer>
|
||||||
</script>
|
<script type="module" src="/js/pages.js?v=20260909-account-controls">
|
||||||
<script type="module" src="/js/workspaceNavigation.js?v=20260909-account-controls"></script>
|
</script>
|
||||||
|
<script type="module" src="/js/workspaceNavigation.js?v=20260909-account-controls"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+427
-388
@@ -2,466 +2,505 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8"/>
|
||||||
<link rel="icon" type="image/svg+xml" href="/img/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/img/favicon.svg">
|
||||||
<title>Elsewhere</title>
|
<title>Elsewhere</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link
|
<link
|
||||||
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=Manrope:wght@400;500;600;700;800&display=swap"
|
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=Manrope:wght@400;500;600;700;800&display=swap"
|
||||||
rel="stylesheet">
|
rel="stylesheet">
|
||||||
<script src="/js/theme-bootstrap.js?v=20260909-account-controls"></script>
|
<script src="/js/theme-bootstrap.js?v=20260909-account-controls"></script>
|
||||||
<link rel="stylesheet" href="/css/style.css?v=20260920-web-search-composer" />
|
<link rel="stylesheet" href="/css/style.css?v=20261012-chat-usability"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<link rel="stylesheet" href="/css/pages.css?v=20260909-account-controls">
|
<link rel="stylesheet" href="/css/pages.css?v=20260909-account-controls">
|
||||||
<link rel="stylesheet" href="/css/shell.css?v=20260909-account-controls">
|
<link rel="stylesheet" href="/css/shell.css?v=20260909-account-controls">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="site-page chat-page" data-theme="fakegpt" data-theme-mode="light">
|
<body class="site-page chat-page" data-theme="fakegpt" data-theme-mode="light">
|
||||||
<!-- site-header -->
|
<!-- site-header -->
|
||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
<section id="chat" class="card chat-card chat-focus-card hidden" aria-label="AI chat workspace">
|
<section id="chat" class="card chat-card chat-focus-card hidden" aria-label="AI chat workspace">
|
||||||
<div class="chat-layout chat-layout-focus">
|
<div class="chat-layout chat-layout-focus">
|
||||||
<aside id="chatSidebar" class="chat-sidebar" aria-label="Conversation history">
|
<aside id="chatSidebar" class="chat-sidebar" aria-label="Conversation history">
|
||||||
<div class="sidebar-top">
|
<div class="sidebar-top">
|
||||||
<div class="sidebar-workspace-card">
|
<div class="sidebar-workspace-card">
|
||||||
<h2>Conversations</h2><button id="newChat" class="primary-action" type="button">New
|
<h2>Conversations</h2>
|
||||||
chat</button>
|
<button id="newChat" class="primary-action" type="button">New
|
||||||
</div>
|
chat
|
||||||
<label class="field compact-field">
|
</button>
|
||||||
<span>Search</span>
|
|
||||||
<input id="chatSearch" type="search" placeholder="Search chats" />
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebar-middle">
|
<label class="field compact-field">
|
||||||
<div id="chatList" class="chat-list chat-list-grouped"></div>
|
<span>Search</span>
|
||||||
<div id="chatListLoading" class="chat-loading hidden" role="status" aria-live="polite">
|
<input id="chatSearch" type="search" placeholder="Search chats"/>
|
||||||
<span class="chat-loading-dot" aria-hidden="true"></span>
|
</label>
|
||||||
<span id="chatListLoadingText">Loading chats...</span>
|
</div>
|
||||||
</div>
|
<div class="sidebar-middle">
|
||||||
|
<div id="chatList" class="chat-list chat-list-grouped"></div>
|
||||||
|
<div id="chatListLoading" class="chat-loading hidden" role="status" aria-live="polite">
|
||||||
|
<span class="chat-loading-dot" aria-hidden="true"></span>
|
||||||
|
<span id="chatListLoadingText">Loading chats...</span>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</div>
|
||||||
|
</aside>
|
||||||
|
<button id="chatSidebarBackdrop" class="chat-sidebar-backdrop" type="button"
|
||||||
|
aria-label="Close conversation history" hidden></button>
|
||||||
|
|
||||||
<section id="main" class="chat-main chat-main-focus" aria-label="Active conversation">
|
<section id="main" class="chat-main chat-main-focus" aria-label="Active conversation">
|
||||||
<div class="chat-heading">
|
<div class="chat-heading">
|
||||||
<p id="workspaceNotice" class="status error hidden" role="status" aria-live="polite"></p>
|
<p id="workspaceNotice" class="status hidden" role="status" aria-live="polite"></p>
|
||||||
<header class="chat-header chat-header-focus">
|
<header class="chat-header chat-header-focus">
|
||||||
<button id="chatSidebarToggle" class="icon-button sidebar-toggle" type="button"
|
<button id="chatSidebarToggle" class="icon-button sidebar-toggle" type="button"
|
||||||
aria-controls="chatSidebar" aria-expanded="false"
|
aria-controls="chatSidebar" aria-expanded="false"
|
||||||
aria-label="Open conversation history">History</button>
|
aria-label="Open conversation history">History
|
||||||
<div class="chat-title-block">
|
</button>
|
||||||
<div class="chat-title">
|
<div class="chat-title-block">
|
||||||
<h2 id="activeChatTitle">New chat</h2>
|
<div class="chat-title">
|
||||||
</div>
|
<h2 id="activeChatTitle">New chat</h2>
|
||||||
<div class="chat-header-context" aria-label="Current chat setup">
|
</div>
|
||||||
<button id="headerModelButton"
|
<div class="chat-header-context" aria-label="Current chat setup">
|
||||||
|
<button id="headerModelButton"
|
||||||
class="chat-context-item chat-context-button secondary-action" type="button"
|
class="chat-context-item chat-context-button secondary-action" type="button"
|
||||||
aria-controls="chatDrawer"
|
aria-controls="chatDrawer"
|
||||||
aria-label="Change AI model"><span>Model</span><strong
|
aria-label="Change AI model"><span>Model</span><strong
|
||||||
id="headerModelName">Loading...</strong></button>
|
id="headerModelName">Loading...</strong></button>
|
||||||
<span class="chat-context-item"><span>Persona</span><strong
|
<label class="chat-context-item"><span>Chat with</span><select id="personalitySelect"
|
||||||
id="headerPersonaName">None</strong></span>
|
aria-label="Conversation personality"></select></label>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="chat-header-actions">
|
</div>
|
||||||
<div class="chat-actions-menu">
|
<div class="chat-header-actions">
|
||||||
<button id="chatDrawerButton" class="secondary-action overflow-trigger"
|
<div class="chat-actions-menu">
|
||||||
|
<button id="chatDrawerButton" class="secondary-action overflow-trigger"
|
||||||
type="button" aria-expanded="false" aria-controls="chatDrawer">
|
type="button" aria-expanded="false" aria-controls="chatDrawer">
|
||||||
<span class="overflow-trigger-label">Chat settings</span>
|
<span class="overflow-trigger-label">Chat settings</span>
|
||||||
</button>
|
</button>
|
||||||
<div id="chatDrawer" class="chat-drawer hidden" role="dialog" aria-modal="true"
|
<div id="chatDrawer" class="chat-drawer hidden" role="dialog" aria-modal="true"
|
||||||
aria-labelledby="chatDrawerTitle">
|
aria-labelledby="chatDrawerTitle">
|
||||||
<div class="chat-drawer-panel">
|
<div class="chat-drawer-panel">
|
||||||
<div class="chat-drawer-header">
|
<div class="chat-drawer-header">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Conversation</p>
|
<p class="eyebrow">Conversation</p>
|
||||||
<h2 id="chatDrawerTitle">Settings</h2>
|
<h2 id="chatDrawerTitle">Settings</h2>
|
||||||
</div>
|
|
||||||
<button id="chatDrawerClose" class="icon-button" type="button"
|
|
||||||
aria-label="Close chat settings">×</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="chat-drawer-body">
|
<button id="chatDrawerClose" class="icon-button" type="button"
|
||||||
<div class="chat-drawer-summary">
|
aria-label="Close chat settings">×
|
||||||
<div class="chat-drawer-pills">
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="chat-drawer-body">
|
||||||
|
<div class="chat-drawer-summary">
|
||||||
|
<div class="chat-drawer-pills">
|
||||||
<span id="chatCharacterPill"
|
<span id="chatCharacterPill"
|
||||||
class="context-pill hidden">Character: none</span>
|
class="context-pill hidden">Character: none</span>
|
||||||
<span id="chatUserPersonaPill" class="context-pill hidden">User
|
<span id="chatUserPersonaPill" class="context-pill hidden">User
|
||||||
persona: none</span>
|
persona: none</span>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<details
|
</div>
|
||||||
|
<details
|
||||||
class="chat-drawer-section chat-drawer-disclosure chat-drawer-section--model"
|
class="chat-drawer-section chat-drawer-disclosure chat-drawer-section--model"
|
||||||
open>
|
open>
|
||||||
<summary class="chat-drawer-row">
|
<summary class="chat-drawer-row">
|
||||||
<span class="chat-drawer-row-label">
|
<span class="chat-drawer-row-label">
|
||||||
<span class="eyebrow">AI model</span>
|
<span class="eyebrow">AI model</span>
|
||||||
<strong>Model</strong>
|
<strong>Model</strong>
|
||||||
</span>
|
</span>
|
||||||
<span class="chat-drawer-row-meta">
|
<span class="chat-drawer-row-meta">
|
||||||
<strong id="modelBadgeName">Loading...</strong>
|
<strong id="modelBadgeName">Loading...</strong>
|
||||||
<span class="chat-drawer-row-hint">Change</span>
|
<span class="chat-drawer-row-hint">Change</span>
|
||||||
</span>
|
</span>
|
||||||
</summary>
|
</summary>
|
||||||
<div id="modelPanel"
|
<div id="modelPanel"
|
||||||
class="model-panel chat-drawer-disclosure-panel"
|
class="model-panel chat-drawer-disclosure-panel"
|
||||||
data-static-panel="true">
|
data-static-panel="true">
|
||||||
<div class="model-select">
|
<div class="model-select">
|
||||||
<div class="model-select-header">
|
<div class="model-select-header">
|
||||||
<label for="model">Model</label>
|
<label for="model">Model</label>
|
||||||
<span id="modelCount" class="model-count">Loading</span>
|
<span id="modelCount" class="model-count">Loading</span>
|
||||||
</div>
|
|
||||||
<div class="model-select-control">
|
|
||||||
<select id="model">
|
|
||||||
<option disabled selected>Loading models...</option>
|
|
||||||
</select>
|
|
||||||
<span class="model-select-chevron"
|
|
||||||
aria-hidden="true"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="modelHelp" class="model-help" aria-live="polite">
|
<div class="model-select-control">
|
||||||
<div class="model-help-header">
|
<select id="model">
|
||||||
<strong id="modelHelpTitle">Choose a model</strong>
|
<option disabled selected>Loading models...</option>
|
||||||
<span id="modelHelpBadge"
|
</select>
|
||||||
class="model-help-badge">Waiting</span>
|
<span class="model-select-chevron"
|
||||||
</div>
|
aria-hidden="true"></span>
|
||||||
<p id="modelHelpSummary" class="model-help-summary">Pick a
|
|
||||||
model to see what it is best suited for.</p>
|
|
||||||
</div>
|
|
||||||
<div class="generation-settings-grid">
|
|
||||||
<label class="field"><span>Temperature</span><input
|
|
||||||
id="chatTemperature" type="number" min="0" max="2"
|
|
||||||
step="0.1" placeholder="Default" /></label>
|
|
||||||
<label class="field"><span>Context length</span><input
|
|
||||||
id="chatContextLength" type="number" min="256"
|
|
||||||
max="131072" placeholder="Default" /></label>
|
|
||||||
<label class="field"><span>Response length</span><input
|
|
||||||
id="chatResponseLength" type="number" min="1"
|
|
||||||
max="32768" placeholder="Default" /></label>
|
|
||||||
<label class="field field-wide"><span>System
|
|
||||||
instruction</span><textarea
|
|
||||||
id="chatSystemInstruction" rows="4" maxlength="8000"
|
|
||||||
placeholder="Optional instructions for this chat"></textarea></label>
|
|
||||||
<button id="saveGenerationSettings" type="button">Save
|
|
||||||
generation settings</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
<div id="modelHelp" class="model-help" aria-live="polite">
|
||||||
<details
|
<div class="model-help-header">
|
||||||
class="chat-drawer-section chat-drawer-disclosure chat-drawer-section--persona">
|
<strong id="modelHelpTitle">Choose a model</strong>
|
||||||
<summary class="chat-drawer-row">
|
<span id="modelHelpBadge"
|
||||||
<span class="chat-drawer-row-label">
|
class="model-help-badge">Waiting</span>
|
||||||
<span class="eyebrow">Your identity</span>
|
|
||||||
<strong>Persona</strong>
|
|
||||||
</span>
|
|
||||||
<span class="chat-drawer-row-meta">
|
|
||||||
<strong id="chatDrawerPersonaValue">None</strong>
|
|
||||||
<span class="chat-drawer-row-hint">Change</span>
|
|
||||||
</span>
|
|
||||||
</summary>
|
|
||||||
<div id="personaPanel"
|
|
||||||
class="persona-panel chat-drawer-disclosure-panel"
|
|
||||||
data-static-panel="true">
|
|
||||||
<div class="persona-section">
|
|
||||||
<div class="persona-section-header">
|
|
||||||
<div>
|
|
||||||
<h2>Your persona</h2>
|
|
||||||
</div>
|
|
||||||
<div class="persona-section-actions">
|
|
||||||
<button id="clearUserPersona"
|
|
||||||
class="secondary-action"
|
|
||||||
type="button">Clear</button>
|
|
||||||
<a href="/app/personas"
|
|
||||||
class="secondary-action">Manage</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p id="activeUserPersonaStatus"
|
|
||||||
class="status persona-status">No default user persona
|
|
||||||
set.</p>
|
|
||||||
<div id="userPersonaList" class="persona-list"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<p id="modelHelpSummary" class="model-help-summary">Pick a
|
||||||
</details>
|
model to see what it is best suited for.</p>
|
||||||
<div class="chat-drawer-section">
|
|
||||||
<p class="dropdown-section-label">Chat</p>
|
|
||||||
<div class="chat-drawer-action-list">
|
|
||||||
<button id="renameChat"
|
|
||||||
class="secondary-action chat-drawer-action" type="button"
|
|
||||||
data-chat-action="rename">Rename chat</button>
|
|
||||||
<button id="pinChat" class="secondary-action chat-drawer-action"
|
|
||||||
type="button" data-chat-action="pin">Pin</button>
|
|
||||||
<button id="archiveChat"
|
|
||||||
class="secondary-action chat-drawer-action" type="button"
|
|
||||||
data-chat-action="archive">Archive</button>
|
|
||||||
<button id="exportChat"
|
|
||||||
class="secondary-action chat-drawer-action" type="button"
|
|
||||||
data-chat-action="export">Export</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<details class="chat-drawer-section chat-drawer-more">
|
</details>
|
||||||
<summary class="chat-drawer-row chat-drawer-row--compact">
|
<details class="chat-drawer-section generation-advanced">
|
||||||
<span class="chat-drawer-row-label">
|
<summary class="chat-drawer-row"><span
|
||||||
<strong>Tools & workspace</strong>
|
class="chat-drawer-row-label"><strong>Generation settings</strong></span><span
|
||||||
</span>
|
class="chat-drawer-row-hint">Advanced</span></summary>
|
||||||
<span class="chat-drawer-row-hint">Show</span>
|
<div class="generation-settings-grid chat-drawer-disclosure-panel">
|
||||||
</summary>
|
<label class="field"><span>Temperature</span><input
|
||||||
<div class="chat-drawer-action-list chat-drawer-disclosure-panel">
|
id="chatTemperature" type="number" min="0" max="2"
|
||||||
<button id="moveChatFolder"
|
step="0.1" placeholder="Default"/></label>
|
||||||
|
<label class="field"><span>Context length</span><input
|
||||||
|
id="chatContextLength" type="number" min="256"
|
||||||
|
max="131072" placeholder="Default"/></label>
|
||||||
|
<label class="field"><span>Response length</span><input
|
||||||
|
id="chatResponseLength" type="number" min="1"
|
||||||
|
max="32768" placeholder="Default"/></label>
|
||||||
|
<label class="field field-wide"><span>System
|
||||||
|
instruction</span><textarea
|
||||||
|
id="chatSystemInstruction" rows="4" maxlength="8000"
|
||||||
|
placeholder="Optional instructions for this chat"></textarea></label>
|
||||||
|
<button id="saveGenerationSettings" type="button">Save
|
||||||
|
generation settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<div class="chat-drawer-section">
|
||||||
|
<p class="dropdown-section-label">Conversation actions</p>
|
||||||
|
<div class="chat-drawer-action-list">
|
||||||
|
<button id="renameChat"
|
||||||
class="secondary-action chat-drawer-action" type="button"
|
class="secondary-action chat-drawer-action" type="button"
|
||||||
data-chat-action="folder">Move to folder</button>
|
data-chat-action="rename">Rename chat
|
||||||
<button id="manageChatMemory"
|
</button>
|
||||||
|
<button id="pinChat" class="secondary-action chat-drawer-action"
|
||||||
|
type="button" data-chat-action="pin">Pin
|
||||||
|
</button>
|
||||||
|
<button id="archiveChat"
|
||||||
class="secondary-action chat-drawer-action" type="button"
|
class="secondary-action chat-drawer-action" type="button"
|
||||||
data-chat-action="memory">Memory facts</button>
|
data-chat-action="archive">Archive
|
||||||
<button id="managePersonaState"
|
</button>
|
||||||
|
<button id="exportChat"
|
||||||
class="secondary-action chat-drawer-action" type="button"
|
class="secondary-action chat-drawer-action" type="button"
|
||||||
data-chat-action="personaState">Roleplay state</button>
|
data-chat-action="export">Export
|
||||||
<button id="backupWorkspace"
|
</button>
|
||||||
class="secondary-action chat-drawer-action"
|
</div>
|
||||||
type="button">Backup workspace</button>
|
|
||||||
<button id="importWorkspace"
|
|
||||||
class="secondary-action chat-drawer-action"
|
|
||||||
type="button">Import workspace</button>
|
|
||||||
<input id="importWorkspaceFile" class="hidden" type="file"
|
|
||||||
accept="application/json,.json" />
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
<details class="chat-drawer-section chat-drawer-danger">
|
|
||||||
<summary class="chat-drawer-row chat-drawer-row--compact">
|
|
||||||
<span class="chat-drawer-row-label">
|
|
||||||
<strong>Clear conversation</strong>
|
|
||||||
</span>
|
|
||||||
<span class="chat-drawer-row-hint">Show</span>
|
|
||||||
</summary>
|
|
||||||
<div class="chat-drawer-action-list chat-drawer-disclosure-panel">
|
|
||||||
<button id="clearChat"
|
|
||||||
class="secondary-action chat-drawer-action chat-drawer-action-danger"
|
|
||||||
type="button" data-chat-action="clear">Clear chat</button>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</div>
|
</div>
|
||||||
|
<details class="chat-drawer-section chat-drawer-more">
|
||||||
|
<summary class="chat-drawer-row chat-drawer-row--compact">
|
||||||
|
<span class="chat-drawer-row-label">
|
||||||
|
<strong>Conversation tools</strong>
|
||||||
|
</span>
|
||||||
|
<span class="chat-drawer-row-hint">Show</span>
|
||||||
|
</summary>
|
||||||
|
<div class="chat-drawer-action-list chat-drawer-disclosure-panel">
|
||||||
|
<button id="moveChatFolder"
|
||||||
|
class="secondary-action chat-drawer-action" type="button"
|
||||||
|
data-chat-action="folder">Move to folder
|
||||||
|
</button>
|
||||||
|
<button id="manageChatMemory"
|
||||||
|
class="secondary-action chat-drawer-action" type="button"
|
||||||
|
data-chat-action="memory">Conversation memory
|
||||||
|
</button>
|
||||||
|
<button id="exploreBranches" type="button"
|
||||||
|
class="secondary-action chat-drawer-action"
|
||||||
|
data-chat-action="branches">Branches & compare
|
||||||
|
</button>
|
||||||
|
<button id="managePersonaState"
|
||||||
|
class="secondary-action chat-drawer-action" type="button"
|
||||||
|
data-chat-action="personaState">Roleplay state
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<details class="chat-drawer-section chat-drawer-more">
|
||||||
|
<summary class="chat-drawer-row chat-drawer-row--compact"><span
|
||||||
|
class="chat-drawer-row-label"><strong>Workspace files</strong></span><span
|
||||||
|
class="chat-drawer-row-hint">Show</span></summary>
|
||||||
|
<div class="chat-drawer-action-list chat-drawer-disclosure-panel">
|
||||||
|
<button id="backupWorkspace"
|
||||||
|
class="secondary-action chat-drawer-action"
|
||||||
|
type="button">Download workspace archive
|
||||||
|
</button>
|
||||||
|
<button id="importWorkspace"
|
||||||
|
class="secondary-action chat-drawer-action"
|
||||||
|
type="button">Import workspace
|
||||||
|
</button>
|
||||||
|
<input id="importWorkspaceFile" class="hidden" type="file"
|
||||||
|
accept="application/json,.json,.tar.gz,application/gzip"/>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<details class="chat-drawer-section chat-drawer-danger">
|
||||||
|
<summary class="chat-drawer-row chat-drawer-row--compact">
|
||||||
|
<span class="chat-drawer-row-label">
|
||||||
|
<strong>Delete chat content</strong>
|
||||||
|
</span>
|
||||||
|
<span class="chat-drawer-row-hint">Show</span>
|
||||||
|
</summary>
|
||||||
|
<div class="chat-drawer-action-list chat-drawer-disclosure-panel">
|
||||||
|
<button id="clearChat"
|
||||||
|
class="secondary-action chat-drawer-action chat-drawer-action-danger"
|
||||||
|
type="button" data-chat-action="clear">Clear chat
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="chatActivityOverlay" class="chat-activity-overlay hidden" role="status" aria-live="polite"
|
|
||||||
aria-atomic="true">
|
|
||||||
<div class="chat-activity-card">
|
|
||||||
<div class="chat-activity-mark" aria-hidden="true">
|
|
||||||
<span></span>
|
|
||||||
<span></span>
|
|
||||||
<span></span>
|
|
||||||
</div>
|
|
||||||
<p id="chatActivityEyebrow" class="eyebrow">Please wait</p>
|
|
||||||
<h3 id="chatActivityTitle">Preparing chat</h3>
|
|
||||||
<p id="chatActivityDetail" class="subtitle">The assistant is still working.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="messages" class="messages-panel messages-panel-focus" role="log" aria-live="polite"
|
<div id="chatActivityOverlay" class="chat-activity-overlay hidden" role="status" aria-live="polite"
|
||||||
aria-relevant="additions text"></div>
|
aria-atomic="true">
|
||||||
|
<div class="chat-activity-card">
|
||||||
<div class="input-shell input-shell-focus">
|
<div class="chat-activity-mark" aria-hidden="true">
|
||||||
<div class="input-meta">
|
<span></span>
|
||||||
<p class="composer-hint">Enter to send · Shift+Enter for new line</p>
|
<span></span>
|
||||||
|
<span></span>
|
||||||
</div>
|
</div>
|
||||||
<div id="uploadPreviews" class="upload-previews" aria-live="polite" hidden></div>
|
<p id="chatActivityEyebrow" class="eyebrow">Please wait</p>
|
||||||
<div class="input-container">
|
<h3 id="chatActivityTitle">Preparing chat</h3>
|
||||||
<input id="imagePicker" type="file" accept="image/png,image/jpeg,image/webp" multiple
|
<p id="chatActivityDetail" class="subtitle">The assistant is still working.</p>
|
||||||
hidden>
|
</div>
|
||||||
<button id="attachImage" class="icon-button" type="button" aria-label="Attach image"
|
</div>
|
||||||
|
|
||||||
|
<div class="messages-wrap">
|
||||||
|
<div class="history-controls">
|
||||||
|
<button id="loadOlderMessages" class="secondary-action" type="button" hidden>Load older
|
||||||
|
messages
|
||||||
|
</button>
|
||||||
|
<button id="loadLatestMessages" class="secondary-action" type="button" hidden>Back to latest
|
||||||
|
messages
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button id="conversationSearchToggle" class="conversation-search-toggle secondary-action"
|
||||||
|
type="button" aria-controls="conversationSearchBar" aria-expanded="false">Find in this chat
|
||||||
|
</button>
|
||||||
|
<div id="conversationSearchBar" class="conversation-search" role="search"
|
||||||
|
aria-label="Search this conversation">
|
||||||
|
<label for="conversationSearch">Find in this chat</label>
|
||||||
|
<input id="conversationSearch" type="search" placeholder="Search messages" autocomplete="off"
|
||||||
|
maxlength="200">
|
||||||
|
<span id="conversationSearchCount" role="status" aria-live="polite">0 matches</span>
|
||||||
|
<button id="conversationSearchPrevious" class="secondary-action" type="button"
|
||||||
|
aria-label="Previous match" disabled>Previous
|
||||||
|
</button>
|
||||||
|
<button id="conversationSearchNext" class="secondary-action" type="button"
|
||||||
|
aria-label="Next match" disabled>Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="messages" class="messages-panel messages-panel-focus" role="log" aria-live="off"></div>
|
||||||
|
<div id="replyAnnouncement" class="visually-hidden" role="status" aria-live="polite"
|
||||||
|
aria-atomic="true"></div>
|
||||||
|
<button id="jumpToLatest" class="jump-to-latest secondary-action hidden" type="button">Jump to
|
||||||
|
latest
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-shell input-shell-focus">
|
||||||
|
<div class="input-meta">
|
||||||
|
<p id="composerHint" class="composer-hint">Enter to send · Shift+Enter for new line</p>
|
||||||
|
</div>
|
||||||
|
<div id="uploadPreviews" class="upload-previews" aria-live="polite" hidden></div>
|
||||||
|
<div class="input-container">
|
||||||
|
<input id="imagePicker" type="file" accept="image/png,image/jpeg,image/webp" multiple
|
||||||
|
hidden>
|
||||||
|
<button id="attachImage" class="icon-button" type="button" aria-label="Attach image"
|
||||||
title="Attach image">
|
title="Attach image">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
stroke-width="1.6" aria-hidden="true">
|
stroke-width="1.6" aria-hidden="true">
|
||||||
<rect x="3" y="3" width="18" height="18" rx="3" />
|
<rect x="3" y="3" width="18" height="18" rx="3"/>
|
||||||
<circle cx="8" cy="8" r="1.5" />
|
<circle cx="8" cy="8" r="1.5"/>
|
||||||
<path d="m3 17 5-5 4 4 4-6 5 7" />
|
<path d="m3 17 5-5 4 4 4-6 5 7"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
<span class="composer-tool-label">Attach</span>
|
||||||
<button id="webSearch" class="icon-button" type="button" aria-label="Search the web"
|
</button>
|
||||||
title="Search the web" aria-pressed="false">
|
<button id="webSearch" class="icon-button" type="button"
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
aria-label="Web search allowed. The assistant searches when useful. Click to turn off."
|
||||||
stroke-width="1.6" aria-hidden="true">
|
title="Web search allowed. The assistant searches when useful. Click to turn off."
|
||||||
<circle cx="12" cy="12" r="9" />
|
aria-pressed="true">
|
||||||
<ellipse cx="12" cy="12" rx="4" ry="9" />
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
<path d="M3 12h18" />
|
stroke-width="1.6" aria-hidden="true">
|
||||||
</svg>
|
<circle cx="12" cy="12" r="9"/>
|
||||||
</button>
|
<ellipse cx="12" cy="12" rx="4" ry="9"/>
|
||||||
<button id="reasoning" class="icon-button" type="button" aria-label="Enable reasoning"
|
<path d="M3 12h18"/>
|
||||||
title="Enable reasoning" aria-pressed="false" disabled>
|
</svg>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
<span class="composer-tool-label">Web search</span>
|
||||||
stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"
|
</button>
|
||||||
aria-hidden="true">
|
<button id="reasoning" class="icon-button" type="button" aria-label="Enable reasoning"
|
||||||
<path
|
title="Enable reasoning" aria-pressed="false" disabled hidden>
|
||||||
d="M12 18V5a3 3 0 0 0-5.8-1A4 4 0 0 0 3 10a4 4 0 0 0 1 7.8A4 4 0 0 0 12 18ZM12 5a3 3 0 0 1 5.8-1A4 4 0 0 1 21 10a4 4 0 0 1-1 7.8A4 4 0 0 1 12 18M8 8a4 4 0 0 1-5 2m13-2a4 4 0 0 0 5 2M8 14a4 4 0 0 0-4 4m12-4a4 4 0 0 1 4 4" />
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
</svg>
|
stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"
|
||||||
</button>
|
aria-hidden="true">
|
||||||
<label class="visually-hidden" for="msgInput">Message</label>
|
<path
|
||||||
<textarea id="msgInput" rows="1" placeholder="Type your message..."></textarea>
|
d="M12 18V5a3 3 0 0 0-5.8-1A4 4 0 0 0 3 10a4 4 0 0 0 1 7.8A4 4 0 0 0 12 18ZM12 5a3 3 0 0 1 5.8-1A4 4 0 0 1 21 10a4 4 0 0 1-1 7.8A4 4 0 0 1 12 18M8 8a4 4 0 0 1-5 2m13-2a4 4 0 0 0 5 2M8 14a4 4 0 0 0-4 4m12-4a4 4 0 0 1 4 4"/>
|
||||||
<button id="send" type="button" aria-label="Send message"></button>
|
</svg>
|
||||||
</div>
|
<span class="composer-tool-label">Reasoning</span>
|
||||||
|
</button>
|
||||||
|
<label class="visually-hidden" for="msgInput">Message</label>
|
||||||
|
<textarea id="msgInput" rows="1" placeholder="Type your message..."></textarea>
|
||||||
|
<button id="send" type="button" aria-label="Send message"></button>
|
||||||
|
<button id="stopGeneration" class="secondary-action" type="button"
|
||||||
|
aria-label="Stop generating reply" hidden>Stop
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</section>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="personaModal" class="modal-overlay side-panel-overlay hidden">
|
<div id="personaModal" class="modal-overlay side-panel-overlay hidden">
|
||||||
<div class="modal-card persona-modal-card side-panel-card">
|
<div class="modal-card persona-modal-card side-panel-card" role="dialog" aria-modal="true"
|
||||||
<div class="modal-header">
|
aria-labelledby="personaFormTitle" tabindex="-1">
|
||||||
<div class="persona-modal-heading">
|
<div class="modal-header">
|
||||||
<p class="eyebrow">Persona editor</p>
|
<div class="persona-modal-heading">
|
||||||
<h3 id="personaFormTitle">Create persona</h3>
|
<p class="eyebrow">Persona editor</p>
|
||||||
</div>
|
<h3 id="personaFormTitle">Create persona</h3>
|
||||||
<button id="personaClose" type="button" class="icon-button">Close</button>
|
|
||||||
</div>
|
</div>
|
||||||
<form id="personaForm" class="persona-form">
|
<button id="personaClose" type="button" class="icon-button">Close</button>
|
||||||
<div class="persona-form-intro">
|
</div>
|
||||||
<p class="persona-helper">AI Characters shape who you talk to. User personas add context for who you
|
<form id="personaForm" class="persona-form">
|
||||||
are in the scene.</p>
|
<div class="persona-form-intro">
|
||||||
</div>
|
<p class="persona-helper">AI Characters shape who you talk to. User personas add context for who you
|
||||||
<p id="personaFormNotice" class="status hidden"></p>
|
are in the scene.</p>
|
||||||
<div class="persona-form-grid">
|
</div>
|
||||||
<label class="field field-wide">
|
<p id="personaFormNotice" class="status hidden"></p>
|
||||||
<span>Persona for</span>
|
<div class="persona-form-grid">
|
||||||
<select id="personaType">
|
<label class="field field-wide">
|
||||||
<option value="assistant">AI Character</option>
|
<span>Persona for</span>
|
||||||
<option value="user">Your persona</option>
|
<select id="personaType">
|
||||||
</select>
|
<option value="assistant">AI Character</option>
|
||||||
</label>
|
<option value="user">Your persona</option>
|
||||||
<label class="field">
|
</select>
|
||||||
<span>Persona name</span>
|
</label>
|
||||||
<input id="personaName" placeholder="Persona name" required />
|
<label class="field">
|
||||||
</label>
|
<span>Persona name</span>
|
||||||
<label class="field">
|
<input id="personaName" placeholder="Persona name" required/>
|
||||||
<span>Persona pronouns</span>
|
</label>
|
||||||
<input id="personaPronouns" placeholder="e.g. she/her, they/them" />
|
<label class="field">
|
||||||
</label>
|
<span>Persona pronouns</span>
|
||||||
<label class="field field-wide">
|
<input id="personaPronouns" placeholder="e.g. she/her, they/them"/>
|
||||||
<span>Appearance</span>
|
</label>
|
||||||
<textarea id="personaAppearance" rows="3" placeholder="Describe their look"></textarea>
|
<label class="field field-wide">
|
||||||
</label>
|
<span>Appearance</span>
|
||||||
<label class="field field-wide">
|
<textarea id="personaAppearance" rows="3" placeholder="Describe their look"></textarea>
|
||||||
<span>Background</span>
|
</label>
|
||||||
<textarea id="personaBackground" rows="3"
|
<label class="field field-wide">
|
||||||
placeholder="Backstory, history, role, or origins"></textarea>
|
<span>Background</span>
|
||||||
</label>
|
<textarea id="personaBackground" rows="3"
|
||||||
<label class="field field-wide">
|
placeholder="Backstory, history, role, or origins"></textarea>
|
||||||
<span>Details</span>
|
</label>
|
||||||
<textarea id="personaDetails" rows="4"
|
<label class="field field-wide">
|
||||||
placeholder="Personality traits, voice, goals, quirks, boundaries, style"></textarea>
|
<span>Details</span>
|
||||||
</label>
|
<textarea id="personaDetails" rows="4"
|
||||||
<label id="personaExamplesField" class="field field-wide hidden">
|
placeholder="Personality traits, voice, goals, quirks, boundaries, style"></textarea>
|
||||||
<span>Example dialogues</span>
|
</label>
|
||||||
<div class="dialogue-format-card" aria-hidden="true">
|
<label id="personaExamplesField" class="field field-wide hidden">
|
||||||
<p class="dialogue-format-label">Format</p>
|
<span>Example dialogues</span>
|
||||||
<pre class="dialogue-format-example">{{character}}: Hello, my name is marc
|
<div class="dialogue-format-card" aria-hidden="true">
|
||||||
|
<p class="dialogue-format-label">Format</p>
|
||||||
|
<pre class="dialogue-format-example">{{character}}: Hello, my name is marc
|
||||||
{{user}}: Hi, Marc!
|
{{user}}: Hi, Marc!
|
||||||
{{character}}: Nice to meet you</pre>
|
{{character}}: Nice to meet you</pre>
|
||||||
</div>
|
</div>
|
||||||
<textarea id="personaExampleDialogues" rows="5"
|
<textarea id="personaExampleDialogues" rows="5"
|
||||||
placeholder="Write example exchanges that define the AI's tone, phrasing, pacing, and personality."></textarea>
|
placeholder="Write example exchanges that define the AI's tone, phrasing, pacing, and personality."></textarea>
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="persona-actions">
|
|
||||||
<button id="personaSave" type="submit" class="primary-action">Save persona</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="roleplayStarterModal" class="modal-overlay side-panel-overlay hidden">
|
|
||||||
<div class="modal-card roleplay-starter-card side-panel-card">
|
|
||||||
<div class="modal-header">
|
|
||||||
<div class="persona-modal-heading">
|
|
||||||
<p class="eyebrow">Start roleplay</p>
|
|
||||||
<h3 id="roleplayStarterTitle">Choose your roleplay</h3>
|
|
||||||
</div>
|
|
||||||
<button id="roleplayStarterClose" type="button" class="icon-button">Close</button>
|
|
||||||
</div>
|
|
||||||
<div class="roleplay-starter-intro">
|
|
||||||
<p id="roleplayStarterIntroCopy" class="persona-helper">Pick a character and choose who you are before
|
|
||||||
moving on to the opening scene.</p>
|
|
||||||
</div>
|
|
||||||
<p id="roleplayStarterNotice" class="status hidden"></p>
|
|
||||||
<div class="roleplay-starter-actions">
|
|
||||||
<button id="roleplayNewPersona" class="secondary-action" type="button">Create AI Character</button>
|
|
||||||
</div>
|
|
||||||
<div id="roleplayStarterStepOne" class="persona-form-grid">
|
|
||||||
<label class="field field-wide">
|
|
||||||
<span>1. Character</span>
|
|
||||||
<select id="roleplayCharacterSelect"></select>
|
|
||||||
</label>
|
|
||||||
<label class="field field-wide">
|
|
||||||
<span>2. You in the scene</span>
|
|
||||||
<select id="roleplayUserPersonaSelect"></select>
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div id="roleplayStarterStepTwo" class="roleplay-starter-page hidden">
|
<div class="persona-actions">
|
||||||
<label class="field field-wide">
|
<button id="personaSave" type="submit" class="primary-action">Save persona</button>
|
||||||
<span>Optional opening direction</span>
|
|
||||||
<textarea id="roleplayScenarioInput" rows="4"
|
|
||||||
placeholder="Describe the kind of opening scene you want, such as a setting, conflict, relationship dynamic, or mood. Leave it empty and the AI will invent its own scenario."></textarea>
|
|
||||||
</label>
|
|
||||||
<p class="subtitle">Leave this empty if you want the AI Character to invent the scenario on its own. No
|
|
||||||
character limit.</p>
|
|
||||||
<div id="roleplayStarterSuggestions" class="quick-prompts" aria-label="Scenario suggestions"></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="popup-actions">
|
</form>
|
||||||
<button id="roleplayStarterCancel" type="button" class="secondary-action">Cancel</button>
|
</div>
|
||||||
<button id="roleplayStarterBack" type="button" class="secondary-action hidden">Back</button>
|
</div>
|
||||||
<button id="roleplayStarterConfirm" type="button">Continue</button>
|
|
||||||
|
<div id="roleplayStarterModal" class="modal-overlay side-panel-overlay hidden">
|
||||||
|
<div class="modal-card roleplay-starter-card side-panel-card" role="dialog" aria-modal="true"
|
||||||
|
aria-labelledby="roleplayStarterTitle" tabindex="-1">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="persona-modal-heading">
|
||||||
|
<p class="eyebrow">Start roleplay</p>
|
||||||
|
<h3 id="roleplayStarterTitle">Choose your roleplay</h3>
|
||||||
</div>
|
</div>
|
||||||
|
<button id="roleplayStarterClose" type="button" class="icon-button">Close</button>
|
||||||
|
</div>
|
||||||
|
<div class="roleplay-starter-intro">
|
||||||
|
<p id="roleplayStarterIntroCopy" class="persona-helper">Pick a character and choose who you are before
|
||||||
|
moving on to the opening scene.</p>
|
||||||
|
</div>
|
||||||
|
<p id="roleplayStarterNotice" class="status hidden"></p>
|
||||||
|
<div class="roleplay-starter-actions">
|
||||||
|
<a id="roleplayNewPersona" class="secondary-action" href="/app/personas">Browse Personalities</a>
|
||||||
|
</div>
|
||||||
|
<div id="roleplayStarterStepOne" class="persona-form-grid">
|
||||||
|
<label class="field field-wide">
|
||||||
|
<span>1. Character</span>
|
||||||
|
<select id="roleplayCharacterSelect"></select>
|
||||||
|
</label>
|
||||||
|
<label class="field field-wide">
|
||||||
|
<span>2. You in the scene</span>
|
||||||
|
<select id="roleplayUserPersonaSelect"></select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="roleplayStarterStepTwo" class="roleplay-starter-page hidden">
|
||||||
|
<label class="field field-wide">
|
||||||
|
<span>Optional opening direction</span>
|
||||||
|
<textarea id="roleplayScenarioInput" rows="4"
|
||||||
|
placeholder="Describe the kind of opening scene you want, such as a setting, conflict, relationship dynamic, or mood. Leave it empty and the AI will invent its own scenario."></textarea>
|
||||||
|
</label>
|
||||||
|
<p class="subtitle">Leave this empty if you want the AI Character to invent the scenario on its own. No
|
||||||
|
character limit.</p>
|
||||||
|
<div id="roleplayStarterSuggestions" class="quick-prompts" aria-label="Scenario suggestions"></div>
|
||||||
|
</div>
|
||||||
|
<div class="popup-actions">
|
||||||
|
<button id="roleplayStarterCancel" type="button" class="secondary-action">Cancel</button>
|
||||||
|
<button id="roleplayStarterBack" type="button" class="secondary-action hidden">Back</button>
|
||||||
|
<button id="roleplayStarterConfirm" type="button">Continue</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="popupModal" class="modal-overlay hidden">
|
<div id="popupModal" class="modal-overlay hidden">
|
||||||
<div class="modal-card popup-card" role="dialog" aria-modal="true" aria-labelledby="popupTitle"
|
<div class="modal-card popup-card" role="dialog" aria-modal="true" aria-labelledby="popupTitle"
|
||||||
aria-describedby="popupDescription">
|
aria-describedby="popupDescription">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<div class="persona-modal-heading">
|
<div class="persona-modal-heading">
|
||||||
<p id="popupEyebrow" class="eyebrow">Action</p>
|
<p id="popupEyebrow" class="eyebrow">Action</p>
|
||||||
<h3 id="popupTitle">Confirm action</h3>
|
<h3 id="popupTitle">Confirm action</h3>
|
||||||
</div>
|
|
||||||
<button id="popupClose" type="button" class="icon-button">Close</button>
|
|
||||||
</div>
|
|
||||||
<div class="popup-content">
|
|
||||||
<p id="popupDescription" class="subtitle">Please confirm this action.</p>
|
|
||||||
<label id="popupField" class="field hidden">
|
|
||||||
<span id="popupInputLabel">Value</span>
|
|
||||||
<input id="popupInput" type="text" autocomplete="off" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="popup-actions">
|
|
||||||
<button id="popupCancel" type="button" class="secondary-action">Cancel</button>
|
|
||||||
<button id="popupConfirm" type="button">Confirm</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<button id="popupClose" type="button" class="icon-button">Close</button>
|
||||||
|
</div>
|
||||||
|
<div class="popup-content">
|
||||||
|
<p id="popupDescription" class="subtitle">Please confirm this action.</p>
|
||||||
|
<label id="popupField" class="field hidden">
|
||||||
|
<span id="popupInputLabel">Value</span>
|
||||||
|
<input id="popupInput" type="text" autocomplete="off"/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="popup-actions">
|
||||||
|
<button id="popupCancel" type="button" class="secondary-action">Cancel</button>
|
||||||
|
<button id="popupConfirm" type="button">Confirm</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="onboardingModal" class="modal-overlay hidden">
|
<div id="onboardingModal" class="modal-overlay hidden">
|
||||||
<div class="modal-card onboarding-card">
|
<div class="modal-card onboarding-card" role="dialog" aria-modal="true" aria-labelledby="onboardingTitle"
|
||||||
<div class="modal-header">
|
aria-describedby="onboardingSubtitle" tabindex="-1">
|
||||||
<div class="persona-modal-heading">
|
<div class="modal-header">
|
||||||
<p class="eyebrow">Get started</p>
|
<div class="persona-modal-heading">
|
||||||
<h3 id="onboardingTitle">Where shall we start?</h3>
|
<p class="eyebrow">Get started</p>
|
||||||
</div>
|
<h3 id="onboardingTitle">Where shall we start?</h3>
|
||||||
<button id="onboardingSkip" type="button" class="icon-button">Skip</button>
|
|
||||||
</div>
|
|
||||||
<p id="onboardingSubtitle" class="subtitle">Ask a question, explore an idea, or step into a story.</p>
|
|
||||||
<div id="onboardingChoices" class="onboarding-choices"></div>
|
|
||||||
<div class="popup-actions">
|
|
||||||
<button id="onboardingBack" type="button" class="secondary-action hidden">Back</button>
|
|
||||||
<button id="onboardingContinue" type="button">Continue</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<button id="onboardingSkip" type="button" class="icon-button">Skip</button>
|
||||||
|
</div>
|
||||||
|
<p id="onboardingSubtitle" class="subtitle">Ask a question, explore an idea, or step into a story.</p>
|
||||||
|
<div id="onboardingChoices" class="onboarding-choices"></div>
|
||||||
|
<div class="popup-actions">
|
||||||
|
<button id="onboardingBack" type="button" class="secondary-action hidden">Back</button>
|
||||||
|
<button id="onboardingContinue" type="button">Continue</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js" defer></script>
|
<script src="/vendor/marked.min.js" defer></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.6/dist/purify.min.js" defer></script>
|
<script src="/vendor/dompurify.js" defer></script>
|
||||||
<script type="module" src="/js/script.js?v=20260923-vision-provenance"></script>
|
<script type="module" src="/js/script.js?v=20261012-chat-usability"></script>
|
||||||
<script type="module" src="/js/workspaceNavigation.js?v=20260909-account-controls"></script>
|
<script type="module" src="/js/workspaceNavigation.js?v=20260909-account-controls"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -68,3 +68,26 @@ body[data-theme-mode="dark"] #accountForm input[aria-invalid="true"] { border-co
|
|||||||
#accountForm .field > label { font-size:.82rem; font-weight:600; color:var(--text-soft); }
|
#accountForm .field > label { font-size:.82rem; font-weight:600; color:var(--text-soft); }
|
||||||
.account-status:focus { outline:none; }
|
.account-status:focus { outline:none; }
|
||||||
#accountSwitch { margin-top:20px; }
|
#accountSwitch { margin-top:20px; }
|
||||||
|
.personality-shell { width:min(1160px,calc(100% - 64px)); margin:0 auto; padding:clamp(34px,5vw,64px) 0 72px; }
|
||||||
|
.personality-page-header { max-width:680px; padding-bottom:30px; }
|
||||||
|
.personality-page-header .eyebrow { margin:0 0 10px; color:var(--brand-strong); }
|
||||||
|
.personality-page-header h1 { margin:0; font-size:clamp(2rem,4vw,2.75rem); letter-spacing:-.045em; }
|
||||||
|
.personality-page-header .subtitle { margin:10px 0 0; color:var(--text-soft); line-height:1.65; }
|
||||||
|
.personality-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:18px; }
|
||||||
|
.personality-card { position:relative; min-width:0; min-height:295px; padding:25px; display:flex; flex-direction:column; align-items:flex-start; gap:16px; overflow:hidden; background:var(--surface-raised); border:1px solid var(--border); border-radius:var(--radius-lg); box-shadow:var(--shadow-sm); transition:transform .18s ease,border-color .18s ease,box-shadow .18s ease; }
|
||||||
|
.personality-card::before { content:""; position:absolute; inset:0 auto 0 0; width:3px; background:var(--personality-accent); opacity:.8; }
|
||||||
|
.personality-card:hover { transform:translateY(-2px); border-color:color-mix(in srgb,var(--personality-accent) 48%,var(--border)); box-shadow:var(--shadow-md); }
|
||||||
|
.personality-icon { width:48px; height:48px; flex:0 0 48px; display:grid; place-items:center; border-radius:15px; background:color-mix(in srgb,var(--personality-accent) 12%,var(--surface)); color:var(--personality-accent); }
|
||||||
|
.personality-icon svg { width:25px; height:25px; }
|
||||||
|
.personality-identity h2,.personality-identity p,.personality-card p { margin:0; }
|
||||||
|
.personality-identity { display:grid; gap:5px; }
|
||||||
|
.personality-identity h2 { font-size:1.35rem; letter-spacing:-.025em; }
|
||||||
|
.personality-specialization { color:var(--personality-accent); font-size:.75rem; font-weight:800; letter-spacing:.075em; text-transform:uppercase; }
|
||||||
|
.personality-description { min-height:3.2em; color:var(--text-soft); font-size:.92rem; line-height:1.6; }
|
||||||
|
.personality-details { width:100%; color:var(--text-soft); font-size:.84rem; line-height:1.55; }
|
||||||
|
.personality-details summary { color:var(--personality-accent); cursor:pointer; font-weight:700; }
|
||||||
|
.personality-details p { margin-top:9px; }
|
||||||
|
.personality-inspiration { font-size:.78rem; opacity:.8; }
|
||||||
|
.personality-card .primary-action { width:100%; min-height:44px; margin-top:auto; display:flex; align-items:center; justify-content:space-between; padding:10px 15px; text-decoration:none; }
|
||||||
|
@media (max-width:900px) { .personality-grid { grid-template-columns:repeat(2,minmax(0,1fr)); gap:14px; }.personality-shell { width:calc(100% - 40px); } }
|
||||||
|
@media (max-width:580px) { .personality-grid { grid-template-columns:1fr; }.personality-shell { width:calc(100% - 32px); padding-top:28px; }.personality-page-header { padding-bottom:22px; }.personality-card { min-height:260px; padding:22px; } }
|
||||||
|
|||||||
@@ -33,17 +33,19 @@ body.site-page { width:100%; max-width:none; margin:0; padding:0; background:var
|
|||||||
.chat-page .chat-card { height:calc(100dvh - var(--shell-header-height)); min-height:0; border:0; border-radius:0; box-shadow:none; }
|
.chat-page .chat-card { height:calc(100dvh - var(--shell-header-height)); min-height:0; border:0; border-radius:0; box-shadow:none; }
|
||||||
.chat-page .chat-layout { grid-template-columns:260px minmax(0,1fr); }
|
.chat-page .chat-layout { grid-template-columns:260px minmax(0,1fr); }
|
||||||
.chat-page .chat-sidebar { position:static; inset:auto; width:auto; transform:none; box-shadow:none; grid-template-rows:auto minmax(0,1fr); padding:24px 18px; gap:20px; }
|
.chat-page .chat-sidebar { position:static; inset:auto; width:auto; transform:none; box-shadow:none; grid-template-rows:auto minmax(0,1fr); padding:24px 18px; gap:20px; }
|
||||||
|
.chat-page .chat-sidebar-backdrop { display:none; }
|
||||||
.chat-page .sidebar-workspace-card { gap:16px; border:0; padding:0; }
|
.chat-page .sidebar-workspace-card { gap:16px; border:0; padding:0; }
|
||||||
.chat-page .sidebar-workspace-card h2 { margin:0; font-size:.85rem; letter-spacing:0; font-family:var(--font-body); color:var(--text-soft); }
|
.chat-page .sidebar-workspace-card h2 { margin:0; font-size:.85rem; letter-spacing:0; font-family:var(--font-body); color:var(--text-soft); }
|
||||||
.chat-page .chat-main { grid-template-rows:auto minmax(0,1fr) auto; }
|
.chat-page .chat-main { grid-template-rows:auto minmax(0,1fr) auto; }
|
||||||
.chat-heading { min-width:0; }
|
.chat-heading { min-width:0; }
|
||||||
.chat-heading #workspaceNotice { margin:0; padding:10px 24px; border-width:0 0 1px; border-radius:0; font-size:.85rem; }
|
.chat-heading #workspaceNotice { position:absolute; top:12px; right:16px; z-index:25; width:min(420px,calc(100% - 32px)); margin:0; padding:10px 14px; border-width:1px; border-radius:var(--radius-md); font-size:.85rem; box-shadow:var(--shadow-md); }
|
||||||
.chat-page .chat-header { min-height:76px; }
|
.chat-page .chat-header { min-height:76px; }
|
||||||
@media(max-width:1100px) { .shell-header { grid-template-columns:170px minmax(0,1fr) auto; gap:12px; padding:0 24px; }.shell-nav a { padding:0 10px; }.shell-username { max-width:90px; } }
|
@media(max-width:1100px) { .shell-header { grid-template-columns:170px minmax(0,1fr) auto; gap:12px; padding:0 24px; }.shell-nav a { padding:0 10px; }.shell-username { max-width:90px; } }
|
||||||
@media(max-width:980px) {
|
@media(max-width:980px) {
|
||||||
.chat-page .chat-layout { grid-template-columns:1fr; }
|
.chat-page .chat-layout { grid-template-columns:1fr; }
|
||||||
.chat-page .chat-sidebar { position:fixed; inset:var(--shell-header-height) auto 0 0; width:min(320px,88vw); z-index:80; transform:translateX(-104%); box-shadow:var(--shadow-lg); }
|
.chat-page .chat-sidebar { position:fixed; inset:var(--shell-header-height) auto 0 0; width:min(320px,88vw); z-index:80; transform:translateX(-104%); box-shadow:var(--shadow-lg); }
|
||||||
.chat-page.sidebar-open .chat-sidebar { transform:translateX(0); }
|
.chat-page.sidebar-open .chat-sidebar { transform:translateX(0); }
|
||||||
|
.chat-page .chat-sidebar-backdrop:not([hidden]) { display:block; position:fixed; inset:var(--shell-header-height) 0 0; z-index:79; width:100%; height:auto; padding:0; border:0; border-radius:0; background:rgba(0,0,0,.45); }
|
||||||
.chat-page .sidebar-toggle { display:inline-flex; width:auto; min-width:0; padding:8px 12px; font-size:.8rem; border-radius:10px; }
|
.chat-page .sidebar-toggle { display:inline-flex; width:auto; min-width:0; padding:8px 12px; font-size:.8rem; border-radius:10px; }
|
||||||
.site-page .settings-layout { grid-template-columns:1fr; }
|
.site-page .settings-layout { grid-template-columns:1fr; }
|
||||||
.site-page .settings-nav { display:flex; flex-wrap:wrap; }
|
.site-page .settings-nav { display:flex; flex-wrap:wrap; }
|
||||||
|
|||||||
+78
-5
@@ -1,5 +1,30 @@
|
|||||||
/* Elsewhere design system. */
|
/* Elsewhere design system. */
|
||||||
|
|
||||||
|
.history-controls { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; }
|
||||||
|
.history-controls:has(button:not([hidden])) { padding: 8px; }
|
||||||
|
.history-controls [hidden] { display: none; }
|
||||||
|
.modal-card.conversation-panel { width: min(1180px, 100%); min-width: 0; max-height: 90dvh; overflow-y: auto; }
|
||||||
|
.conversation-panel .panel-content { display: grid; gap: 16px; min-width: 0; }
|
||||||
|
.conversation-panel .panel-notice:empty { display: none; }
|
||||||
|
.conversation-panel textarea { width: 100%; resize: vertical; min-height: 80px; }
|
||||||
|
.conversation-panel meter { width: 100%; height: 16px; accent-color: var(--brand); }
|
||||||
|
.memory-fact { padding: 16px; border: 1px solid var(--border); border-radius: var(--radius-sm); }
|
||||||
|
.panel-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
|
||||||
|
.branch-layout { display: grid; grid-template-columns: minmax(200px, 1fr) minmax(0, 2fr); gap: 20px; }
|
||||||
|
.branch-sidebar { min-width: 0; max-height: 65dvh; overflow: auto; }
|
||||||
|
.branch-tree { padding: 0; list-style: none; }
|
||||||
|
.branch-tree li { margin: 5px 0 5px calc(var(--branch-depth) * 8px); padding-left: 6px; border-left: 2px solid var(--border); }
|
||||||
|
.branch-tree button { text-align: left; width: 100%; white-space: normal; overflow-wrap: anywhere; }
|
||||||
|
.branch-tree [aria-current="true"] { border-color: var(--brand); background: var(--brand-soft); }
|
||||||
|
.answer-comparison { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; align-items: start; }
|
||||||
|
.comparison-pane { min-width: 0; display: grid; gap: 10px; padding: 14px; background: var(--surface-soft); border: 1px solid var(--border); border-radius: var(--radius-sm); }
|
||||||
|
.comparison-pane select { width: 100%; min-width: 0; }
|
||||||
|
.comparison-pane .msg-content { overflow-wrap: anywhere; }
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.branch-layout, .answer-comparison { grid-template-columns: 1fr; }
|
||||||
|
.branch-sidebar { max-height: 240px; }
|
||||||
|
}
|
||||||
|
|
||||||
.msg-reasoning {
|
.msg-reasoning {
|
||||||
margin: 0 0 12px;
|
margin: 0 0 12px;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
@@ -363,7 +388,11 @@ input:focus, textarea:focus, select:focus { border-color: var(--brand); }
|
|||||||
}
|
}
|
||||||
.chat-item:hover { background: var(--surface-hover); }
|
.chat-item:hover { background: var(--surface-hover); }
|
||||||
.chat-item.active { background: var(--brand-soft); border-color: color-mix(in srgb, var(--brand) 35%, var(--border)); }
|
.chat-item.active { background: var(--brand-soft); border-color: color-mix(in srgb, var(--brand) 35%, var(--border)); }
|
||||||
.chat-item-copy { min-width: 0; display: grid; gap: 2px; }
|
.chat-item-copy { min-width: 0; width: 100%; padding: 0; display: grid; gap: 2px; justify-items: start; color: inherit; background: transparent; border: 0; text-align: left; font: inherit; }
|
||||||
|
.chat-item-copy:hover:not(:disabled) { background: transparent; }
|
||||||
|
.chat-item-copy > span, .chat-item-excerpt { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.chat-item-excerpt { color: var(--text-soft); font-size: .73rem; }
|
||||||
|
.chat-item:focus-within { border-color: var(--brand); }
|
||||||
.chat-item-copy strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.chat-item-copy strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.chat-item-meta { color: var(--text-muted); font-size: .75rem; }
|
.chat-item-meta { color: var(--text-muted); font-size: .75rem; }
|
||||||
.chat-item-delete { min-height: 30px; padding: 5px 8px; color: var(--text-muted); background: transparent; border: 0; font-size: .72rem; }
|
.chat-item-delete { min-height: 30px; padding: 5px 8px; color: var(--text-muted); background: transparent; border: 0; font-size: .72rem; }
|
||||||
@@ -389,16 +418,30 @@ input:focus, textarea:focus, select:focus { border-color: var(--brand); }
|
|||||||
}
|
}
|
||||||
.chat-context-item > span { color: var(--text-muted); font-size: .58rem; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; }
|
.chat-context-item > span { color: var(--text-muted); font-size: .58rem; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; }
|
||||||
.chat-context-item > strong { max-width: 140px; overflow: hidden; color: var(--text-soft); font-size: .72rem; text-overflow: ellipsis; white-space: nowrap; }
|
.chat-context-item > strong { max-width: 140px; overflow: hidden; color: var(--text-soft); font-size: .72rem; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.chat-context-item select { min-width: 0; max-width: 260px; padding: 0; border: 0; background: transparent; color: var(--personality-accent,var(--text-soft)); font: 700 .72rem "Manrope",sans-serif; }
|
||||||
.chat-context-button { min-height: 0; border: 0; color: inherit; text-align: left; justify-content: start; }
|
.chat-context-button { min-height: 0; border: 0; color: inherit; text-align: left; justify-content: start; }
|
||||||
.chat-context-button:hover:not(:disabled) { background: var(--surface-hover); }
|
.chat-context-button:hover:not(:disabled) { background: var(--surface-hover); }
|
||||||
.chat-header-actions { display: flex; align-items: center; }
|
.chat-header-actions { display: flex; align-items: center; }
|
||||||
.overflow-trigger { min-height: 38px; padding: 8px 12px; border-radius: 999px; font-size: .8rem; }
|
.overflow-trigger { min-height: 38px; padding: 8px 12px; border-radius: 999px; font-size: .8rem; }
|
||||||
|
|
||||||
|
.messages-wrap { min-height: 0; position: relative; display: grid; grid-template-rows: auto auto auto minmax(0, 1fr); }
|
||||||
|
.messages-wrap > .messages-panel { grid-row: 4; }
|
||||||
|
.conversation-search-toggle { display: block; justify-self: start; min-height: 32px; margin: 4px 10px; padding: 4px 10px; font-size: .75rem; }
|
||||||
|
.conversation-search { display: none; padding: 8px max(16px, calc((100% - 940px) / 2)); align-items: center; gap: 8px; border-bottom: 1px solid var(--border); }
|
||||||
|
.conversation-search.is-open { display: flex; }
|
||||||
|
.conversation-search label { flex: 0 0 auto; color: var(--text-soft); font-size: .76rem; font-weight: 700; }
|
||||||
|
.conversation-search input { min-width: 0; width: min(280px, 100%); min-height: 34px; }
|
||||||
|
.conversation-search span { min-width: 64px; color: var(--text-muted); font-size: .72rem; white-space: nowrap; }
|
||||||
|
.conversation-search button { min-height: 34px; padding: 5px 9px; font-size: .72rem; }
|
||||||
|
.conversation-search-match { background: #facc15; color: #1f2937; border-radius: 2px; }
|
||||||
|
.conversation-search-match.current { outline: 2px solid var(--brand); outline-offset: 1px; }
|
||||||
.messages-panel {
|
.messages-panel {
|
||||||
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 28px max(16px, calc((100% - 940px) / 2));
|
padding: 28px max(16px, calc((100% - 940px) / 2));
|
||||||
display: flex; flex-direction: column; gap: 38px; overflow-y: auto;
|
display: flex; flex-direction: column; gap: 38px; overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
.jump-to-latest { position: absolute; left: 50%; bottom: 14px; z-index: 3; transform: translateX(-50%); box-shadow: var(--shadow-md); white-space: nowrap; }
|
||||||
.messages-empty {
|
.messages-empty {
|
||||||
width: min(680px, 100%); margin: auto; padding: clamp(24px, 5vw, 44px);
|
width: min(680px, 100%); margin: auto; padding: clamp(24px, 5vw, 44px);
|
||||||
text-align: center; background: var(--surface-soft); border: 1px solid var(--border); border-radius: var(--radius-lg);
|
text-align: center; background: var(--surface-soft); border: 1px solid var(--border); border-radius: var(--radius-lg);
|
||||||
@@ -411,6 +454,7 @@ input:focus, textarea:focus, select:focus { border-color: var(--brand); }
|
|||||||
.msg.user { align-self: flex-end; padding: 10px 13px; color: var(--message-user-text); background: var(--message-user); border-radius: 17px 17px 5px 17px; }
|
.msg.user { align-self: flex-end; padding: 10px 13px; color: var(--message-user-text); background: var(--message-user); border-radius: 17px 17px 5px 17px; }
|
||||||
.msg.bot, .msg.loading { width: fit-content; min-width: min(280px, 80%); align-self: flex-start; padding: 12px 14px; background: var(--message-bot); border: 1px solid var(--border); border-radius: var(--radius-md); box-shadow: var(--shadow-sm); }
|
.msg.bot, .msg.loading { width: fit-content; min-width: min(280px, 80%); align-self: flex-start; padding: 12px 14px; background: var(--message-bot); border: 1px solid var(--border); border-radius: var(--radius-md); box-shadow: var(--shadow-sm); }
|
||||||
.msg-header { display: flex; align-items: center; justify-content: space-between; color: var(--text-muted); font-size: .72rem; }
|
.msg-header { display: flex; align-items: center; justify-content: space-between; color: var(--text-muted); font-size: .72rem; }
|
||||||
|
.msg-web-badge { margin-left: 12px; color: var(--brand-strong); font-weight: 700; white-space: nowrap; }
|
||||||
.msg-content { min-width: 0; overflow-wrap: anywhere; }
|
.msg-content { min-width: 0; overflow-wrap: anywhere; }
|
||||||
.msg-content > :last-child { margin-bottom: 0; }
|
.msg-content > :last-child { margin-bottom: 0; }
|
||||||
.msg-content pre { max-width: 100%; padding: 14px; overflow: auto; background: var(--canvas); border-radius: var(--radius-sm); }
|
.msg-content pre { max-width: 100%; padding: 14px; overflow: auto; background: var(--canvas); border-radius: var(--radius-sm); }
|
||||||
@@ -419,6 +463,14 @@ input:focus, textarea:focus, select:focus { border-color: var(--brand); }
|
|||||||
.msg.user .msg-actions { right: 0; left: auto; justify-content: flex-end; }
|
.msg.user .msg-actions { right: 0; left: auto; justify-content: flex-end; }
|
||||||
.msg:hover .msg-actions, .msg:focus-within .msg-actions { opacity: 1; }
|
.msg:hover .msg-actions, .msg:focus-within .msg-actions { opacity: 1; }
|
||||||
.msg-action-btn { min-height: 30px; padding: 5px 8px; color: var(--text-soft) !important; background: transparent !important; border: 1px solid var(--border); border-radius: 8px; font-size: .7rem; }
|
.msg-action-btn { min-height: 30px; padding: 5px 8px; color: var(--text-soft) !important; background: transparent !important; border: 1px solid var(--border); border-radius: 8px; font-size: .7rem; }
|
||||||
|
.msg-action-menu { position: relative; }
|
||||||
|
.msg-action-menu > summary { display: flex; align-items: center; cursor: pointer; list-style: none; }
|
||||||
|
.msg-action-menu > summary::-webkit-details-marker { display: none; }
|
||||||
|
.msg-action-menu-items { position: absolute; bottom: calc(100% + 4px); left: 0; min-width: 130px; padding: 5px; display: grid; gap: 3px; background: var(--surface-raised); border: 1px solid var(--border); border-radius: 9px; box-shadow: var(--shadow-lg); }
|
||||||
|
.msg.user .msg-action-menu-items { left: auto; right: 0; }
|
||||||
|
.msg-action-menu-items .msg-action-btn { width: 100%; text-align: left; border: 0; }
|
||||||
|
.msg-action-menu-items .msg-action-btn:hover, .msg-action-menu-items .msg-action-btn:focus-visible { background: var(--surface-hover) !important; }
|
||||||
|
.msg-action-menu[open] { z-index: 5; }
|
||||||
.msg-variant-index { align-self: center; color: var(--text-muted); font-size: .7rem; }
|
.msg-variant-index { align-self: center; color: var(--text-muted); font-size: .7rem; }
|
||||||
.typing { display: flex; gap: 5px; }
|
.typing { display: flex; gap: 5px; }
|
||||||
.typing-dot { width: 6px; height: 6px; background: var(--brand); border-radius: 50%; animation: typing 1.2s infinite; }
|
.typing-dot { width: 6px; height: 6px; background: var(--brand); border-radius: 50%; animation: typing 1.2s infinite; }
|
||||||
@@ -456,7 +508,12 @@ input:focus, textarea:focus, select:focus { border-color: var(--brand); }
|
|||||||
.input-meta, .input-container { width: min(940px, 100%); margin-inline: auto; }
|
.input-meta, .input-container { width: min(940px, 100%); margin-inline: auto; }
|
||||||
.input-meta { margin-bottom: 5px; padding-left: 10px; }
|
.input-meta { margin-bottom: 5px; padding-left: 10px; }
|
||||||
.composer-hint { margin: 0; color: var(--text-muted); font-size: .72rem; }
|
.composer-hint { margin: 0; color: var(--text-muted); font-size: .72rem; }
|
||||||
.input-container { padding: 7px; display: grid; grid-template-columns: 44px 44px 44px minmax(0, 1fr) 44px; grid-template-areas: "attach search reasoning message send"; gap: 7px; align-items: end; background: var(--input-bg); border: 1px solid var(--border-strong); border-radius: 24px; box-shadow: var(--shadow-sm); }
|
.input-container { padding: 7px; display: grid; grid-template-columns: 44px 44px 44px minmax(0, 1fr) 44px 58px; grid-template-areas: "attach search reasoning message send stop"; gap: 7px; align-items: end; background: var(--input-bg); border: 1px solid var(--border-strong); border-radius: 24px; box-shadow: var(--shadow-sm); }
|
||||||
|
.input-container:not(:has(#stopGeneration:not([hidden]))) { grid-template-columns: 44px 44px 44px minmax(0, 1fr) 44px; grid-template-areas: "attach search reasoning message send"; }
|
||||||
|
.input-container:not(:has(#reasoning:not([hidden]))) { grid-template-columns: 44px 44px minmax(0, 1fr) 44px 58px; grid-template-areas: "attach search message send stop"; }
|
||||||
|
.input-container:not(:has(#reasoning:not([hidden]))):not(:has(#stopGeneration:not([hidden]))) { grid-template-columns: 44px 44px minmax(0, 1fr) 44px; grid-template-areas: "attach search message send"; }
|
||||||
|
.input-container [hidden] { display: none !important; }
|
||||||
|
.composer-tool-label { display: none; }
|
||||||
#attachImage { grid-area: attach; }
|
#attachImage { grid-area: attach; }
|
||||||
.upload-previews { width: min(940px, 100%); margin: 0 auto 8px; display: flex; gap: 8px; flex-wrap: wrap; }
|
.upload-previews { width: min(940px, 100%); margin: 0 auto 8px; display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
.upload-previews[hidden] { display: none; }
|
.upload-previews[hidden] { display: none; }
|
||||||
@@ -467,7 +524,6 @@ input:focus, textarea:focus, select:focus { border-color: var(--brand); }
|
|||||||
.message-images img { max-width: min(360px, 100%); max-height: 300px; object-fit: contain; border-radius: 12px; }
|
.message-images img { max-width: min(360px, 100%); max-height: 300px; object-fit: contain; border-radius: 12px; }
|
||||||
.message-images a { max-width: 100%; }
|
.message-images a { max-width: 100%; }
|
||||||
.image-drag-target { outline: 2px solid currentColor; }
|
.image-drag-target { outline: 2px solid currentColor; }
|
||||||
@media (max-width: 480px) { .input-container { grid-template-columns: 36px 36px 36px minmax(0, 1fr) 36px; gap: 3px; } }
|
|
||||||
#webSearch, #reasoning { width: 44px; height: 44px; display: grid; place-items: center; border-radius: 17px; }
|
#webSearch, #reasoning { width: 44px; height: 44px; display: grid; place-items: center; border-radius: 17px; }
|
||||||
#webSearch { grid-area: search; }
|
#webSearch { grid-area: search; }
|
||||||
#reasoning { grid-area: reasoning; }
|
#reasoning { grid-area: reasoning; }
|
||||||
@@ -480,9 +536,11 @@ input:focus, textarea:focus, select:focus { border-color: var(--brand); }
|
|||||||
#msgInput { grid-area: message; min-height: 44px; max-height: 160px; padding: 11px 13px; resize: none; background: transparent; border: 0; box-shadow: none; }
|
#msgInput { grid-area: message; min-height: 44px; max-height: 160px; padding: 11px 13px; resize: none; background: transparent; border: 0; box-shadow: none; }
|
||||||
#send { grid-area: send; width: 44px; min-width: 44px; height: 44px; min-height: 44px; padding: 0; display: grid; place-items: center; border-radius: 17px; }
|
#send { grid-area: send; width: 44px; min-width: 44px; height: 44px; min-height: 44px; padding: 0; display: grid; place-items: center; border-radius: 17px; }
|
||||||
#send::after { content: "\2191"; font-size: 1.1rem; font-weight: 800; }
|
#send::after { content: "\2191"; font-size: 1.1rem; font-weight: 800; }
|
||||||
#send.loading::after { content: "■"; font-size: .85rem; }
|
#stopGeneration { grid-area: stop; width: 58px; height: 44px; min-height: 44px; padding: 0 7px; font-size: .76rem; font-weight: 800; border-radius: 15px; }
|
||||||
.generation-settings-grid { margin-top: 12px; display: grid; grid-template-columns: 1fr 1fr; gap: 9px; }
|
.generation-settings-grid { margin-top: 12px; display: grid; grid-template-columns: 1fr 1fr; gap: 9px; }
|
||||||
.generation-settings-grid .field-wide, .generation-settings-grid button { grid-column: 1 / -1; }
|
.generation-settings-grid .field-wide, .generation-settings-grid button { grid-column: 1 / -1; }
|
||||||
|
.generation-advanced { margin-top: 6px; border-top: 1px solid var(--border); }
|
||||||
|
.generation-advanced summary { padding: 10px 0; color: var(--text-soft); cursor: pointer; font-size: .8rem; }
|
||||||
|
|
||||||
/* ---------- Chat settings drawer ---------- */
|
/* ---------- Chat settings drawer ---------- */
|
||||||
.chat-drawer {
|
.chat-drawer {
|
||||||
@@ -609,7 +667,9 @@ input:focus, textarea:focus, select:focus { border-color: var(--brand); }
|
|||||||
.chat-sidebar { position: fixed; inset: 0 auto 0 0; z-index: 80; width: min(340px, 90vw); transform: translateX(-104%); transition: transform .2s ease; box-shadow: var(--shadow-lg); }
|
.chat-sidebar { position: fixed; inset: 0 auto 0 0; z-index: 80; width: min(340px, 90vw); transform: translateX(-104%); transition: transform .2s ease; box-shadow: var(--shadow-lg); }
|
||||||
body.sidebar-open .chat-sidebar { transform: translateX(0); }
|
body.sidebar-open .chat-sidebar { transform: translateX(0); }
|
||||||
.sidebar-toggle { display: inline-flex; }
|
.sidebar-toggle { display: inline-flex; }
|
||||||
.chat-header-context { display: none; }
|
.chat-header-context { display: flex; }
|
||||||
|
.chat-title-block { flex-direction: column; align-items: flex-start; justify-content: center; gap: 6px; }
|
||||||
|
.chat-context-item select { max-width: min(68vw,260px); }
|
||||||
.settings-layout { grid-template-columns: 1fr; }
|
.settings-layout { grid-template-columns: 1fr; }
|
||||||
.settings-nav { position: static; display: flex; overflow-x: auto; }
|
.settings-nav { position: static; display: flex; overflow-x: auto; }
|
||||||
.settings-nav-item { flex: 0 0 auto; }
|
.settings-nav-item { flex: 0 0 auto; }
|
||||||
@@ -641,6 +701,19 @@ input:focus, textarea:focus, select:focus { border-color: var(--brand); }
|
|||||||
.messages-empty-actions { display: grid; }
|
.messages-empty-actions { display: grid; }
|
||||||
.chat-drawer-action-list { grid-template-columns: 1fr; }
|
.chat-drawer-action-list { grid-template-columns: 1fr; }
|
||||||
.chat-drawer-action-list > * { grid-column: auto; }
|
.chat-drawer-action-list > * { grid-column: auto; }
|
||||||
|
.conversation-search:not(.is-open) { display: none; }
|
||||||
|
.conversation-search { padding: 6px 10px; flex-wrap: wrap; gap: 5px; }
|
||||||
|
.conversation-search label { width: 100%; }
|
||||||
|
.conversation-search input { flex: 1 1 110px; }
|
||||||
|
.conversation-search span { min-width: 55px; }
|
||||||
|
.input-container, .input-container:not(:has(#stopGeneration:not([hidden]))) { grid-template-columns: 40px 40px 40px minmax(0, 1fr) 44px; grid-template-areas: "attach search reasoning message send"; gap: 4px; }
|
||||||
|
.input-container:not(:has(#reasoning:not([hidden]))), .input-container:not(:has(#reasoning:not([hidden]))):not(:has(#stopGeneration:not([hidden]))) { grid-template-columns: 40px 40px minmax(0, 1fr) 44px; grid-template-areas: "attach search message send"; }
|
||||||
|
.input-container:has(#stopGeneration:not([hidden])) #send { display: none; }
|
||||||
|
.input-container:has(#stopGeneration:not([hidden])) { grid-template-areas: "attach search reasoning message stop"; }
|
||||||
|
.input-container:not(:has(#reasoning:not([hidden]))):has(#stopGeneration:not([hidden])) { grid-template-areas: "attach search message stop"; }
|
||||||
|
#attachImage, #webSearch, #reasoning { width: 40px; height: 44px; min-height: 44px; }
|
||||||
|
#stopGeneration { width: 44px; font-size: .7rem; }
|
||||||
|
.composer-tool-label { display: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (hover: none) { .msg-actions { opacity: 1; } }
|
@media (hover: none) { .msg-actions { opacity: 1; } }
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Discover personas · Elsewhere</title>
|
|
||||||
<link rel="icon" href="/img/favicon.svg">
|
|
||||||
<script src="/js/theme-bootstrap.js?v=20260909-account-controls">
|
|
||||||
</script>
|
|
||||||
<link rel="stylesheet" href="/css/style.css?v=20260909-account-controls">
|
|
||||||
<link rel="stylesheet" href="/css/pages.css?v=20260909-account-controls">
|
|
||||||
<link rel="stylesheet" href="/css/shell.css?v=20260909-account-controls">
|
|
||||||
<link
|
|
||||||
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=Manrope:wght@400;500;600;700;800&display=swap"
|
|
||||||
rel="stylesheet">
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body class="site-page discover-page">
|
|
||||||
<!-- site-header -->
|
|
||||||
<main id="main" class="site-section page-content">
|
|
||||||
<p class="eyebrow">From the community</p>
|
|
||||||
<h1>Find your next conversation.</h1>
|
|
||||||
<p class="hero-copy">Discover characters and personas made by the community. Sign in when you're ready to bring
|
|
||||||
one into your workspace.</p>
|
|
||||||
<label class="field">
|
|
||||||
<span>Search personas</span>
|
|
||||||
<input id="publicSearch" type="search" placeholder="Search by name or description">
|
|
||||||
</label>
|
|
||||||
<div id="featuredPersonas" class="feature-grid" aria-live="polite">Loading personas…</div>
|
|
||||||
</main>
|
|
||||||
<footer class="site-footer">
|
|
||||||
<a class="site-brand" href="/">elsewhere</a>
|
|
||||||
<span>A space for every conversation.</span>
|
|
||||||
<a href="/privacy.html">Privacy</a>
|
|
||||||
<a href="/tos.html">Terms</a>
|
|
||||||
</footer>
|
|
||||||
<script type="module" src="/js/pages.js?v=20260909-account-controls">
|
|
||||||
</script>
|
|
||||||
<script type="module" src="/js/workspaceNavigation.js?v=20260909-account-controls"></script>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
+39
-39
@@ -12,51 +12,51 @@
|
|||||||
<link rel="stylesheet" href="/css/pages.css?v=20260909-account-controls">
|
<link rel="stylesheet" href="/css/pages.css?v=20260909-account-controls">
|
||||||
<link rel="stylesheet" href="/css/shell.css?v=20260909-account-controls">
|
<link rel="stylesheet" href="/css/shell.css?v=20260909-account-controls">
|
||||||
<link
|
<link
|
||||||
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=Manrope:wght@400;500;600;700;800&display=swap"
|
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=Manrope:wght@400;500;600;700;800&display=swap"
|
||||||
rel="stylesheet">
|
rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="site-page home-page">
|
<body class="site-page home-page">
|
||||||
<!-- site-header -->
|
<!-- site-header -->
|
||||||
|
|
||||||
<main id="main" class="home-main page-content">
|
<main id="main" class="home-main page-content">
|
||||||
<p id="welcomeName" class="eyebrow">Welcome home</p>
|
<p id="welcomeName" class="eyebrow">Welcome home</p>
|
||||||
<h1>What would you like to do?</h1>
|
<h1>What would you like to do?</h1>
|
||||||
<p class="hero-copy">Start somewhere new, or pick up a conversation.</p>
|
<p class="hero-copy">Start somewhere new, or pick up a conversation.</p>
|
||||||
<div class="feature-grid">
|
<div class="feature-grid">
|
||||||
<a class="feature-card action-card" href="/app/chats/new">
|
<a class="feature-card action-card" href="/app/chats/new">
|
||||||
<span class="feature-number">01 / ASK</span>
|
<span class="feature-number">01 / ASK</span>
|
||||||
<h2>New conversation</h2>
|
<h2>New conversation</h2>
|
||||||
<p>Make space for a question or an idea.</p>
|
<p>Make space for a question or an idea.</p>
|
||||||
<span>Start chatting →</span>
|
<span>Start chatting →</span>
|
||||||
</a>
|
</a>
|
||||||
<a class="feature-card action-card" href="/market">
|
<a class="feature-card action-card" href="/app/personas">
|
||||||
<span class="feature-number">02 / EXPLORE</span>
|
<span class="feature-number">02 / EXPLORE</span>
|
||||||
<h2>Chat with a character</h2>
|
<h2>Choose a personality</h2>
|
||||||
<p>Find someone with a different perspective.</p>
|
<p>Pick a specialized assistant for writing, coding, learning, research, or planning.</p>
|
||||||
<span>Discover personas →</span>
|
<span>Explore personalities →</span>
|
||||||
</a>
|
</a>
|
||||||
<a class="feature-card action-card" href="/app/personas?create=assistant">
|
<a class="feature-card action-card" href="/app/chats/new?personality=creative-writer">
|
||||||
<span class="feature-number">03 / CREATE</span>
|
<span class="feature-number">03 / CREATE</span>
|
||||||
<h2>Create a persona</h2>
|
<h2>Start writing</h2>
|
||||||
<p>Give a new character a voice of their own.</p>
|
<p>Open a conversation with a focused creative-writing assistant.</p>
|
||||||
<span>Create persona →</span>
|
<span>Write something →</span>
|
||||||
</a>
|
</a>
|
||||||
|
</div>
|
||||||
|
<section class="site-section">
|
||||||
|
<div class="section-heading">
|
||||||
|
<h2>Recent conversations</h2>
|
||||||
|
<a href="/app/chats">View all →</a>
|
||||||
</div>
|
</div>
|
||||||
<section class="site-section">
|
<div id="recentChats" aria-live="polite">Loading conversations…</div>
|
||||||
<div class="section-heading">
|
</section>
|
||||||
<h2>Recent conversations</h2>
|
<p id="homeStatus" role="status">
|
||||||
<a href="/app/chats">View all →</a>
|
</p>
|
||||||
</div>
|
</main>
|
||||||
<div id="recentChats" aria-live="polite">Loading conversations…</div>
|
|
||||||
</section>
|
|
||||||
<p id="homeStatus" role="status">
|
|
||||||
</p>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<script type="module" src="/js/pages.js?v=20260909-account-controls">
|
<script type="module" src="/js/pages.js?v=20260909-account-controls">
|
||||||
</script>
|
</script>
|
||||||
<script type="module" src="/js/workspaceNavigation.js?v=20260909-account-controls"></script>
|
<script type="module" src="/js/workspaceNavigation.js?v=20260909-account-controls"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+40
-86
@@ -1,89 +1,43 @@
|
|||||||
export async function request(url, data, method = "POST") {
|
import {createSseParser, parseSseData, SseParseError} from "./sse.js";
|
||||||
try {
|
|
||||||
const res = await fetch(url, {
|
// A Response body is single-use. Cache the one read per Response so retries
|
||||||
method,
|
// and error normalization never attempt to consume the same body twice.
|
||||||
credentials: "same-origin",
|
const responseTextCache = new WeakMap();
|
||||||
headers: {"Content-Type": "application/json"},
|
|
||||||
body: data ? JSON.stringify(data) : undefined
|
export class ApiError extends Error {
|
||||||
});
|
constructor(message, {status = 0, code = "REQUEST_FAILED", requestId = null, ...details} = {}) { super(message); this.name = "ApiError"; this.status = status; this.code = code; this.requestId = requestId; this.details = details; }
|
||||||
return await res.json();
|
toJSON() { return {...this.details, error: this.message, status: this.status, code: this.code, requestId: this.requestId}; }
|
||||||
} catch {
|
|
||||||
return {error: "Network error - please try again."};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
function requestScope({signal, timeoutMs = 30000} = {}) { const controller = new AbortController(); const abort = () => controller.abort(new ApiError("Request cancelled.", {code: "ABORTED"})); if (signal?.aborted) abort(); else signal?.addEventListener("abort", abort, {once: true}); const timer = timeoutMs > 0 ? setTimeout(() => controller.abort(new ApiError("Request timed out. Please try again.", {code: "TIMEOUT"})), timeoutMs) : null; return {signal: controller.signal, close() { clearTimeout(timer); signal?.removeEventListener("abort", abort); }}; }
|
||||||
export const get = (url) => request(url, null, "GET");
|
function abortable(operation, signal) {
|
||||||
export const post = (url, data) => request(url, data, "POST");
|
if (signal.aborted) return Promise.reject(signal.reason);
|
||||||
export const put = (url, data) => request(url, data, "PUT");
|
return new Promise((resolve, reject) => {
|
||||||
export const del = (url) => request(url, null, "DELETE");
|
const abort = () => { signal.removeEventListener("abort", abort); reject(signal.reason); };
|
||||||
|
signal.addEventListener("abort", abort, {once: true});
|
||||||
/**
|
let result;
|
||||||
* @typedef {"skipped"|"failed"|"no_results"|"no_relevant_results"|"succeeded"} SearchState
|
try { result = operation(); } catch (error) { reject(error); return; }
|
||||||
* @typedef {{event: "status", data: {status: "accepted"|"searching"|"generating", sourceCount?: number, searchState?: SearchState}}
|
Promise.resolve(result).then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
|
||||||
* | {event: "chunk", data: {chunk: string, fullReply: string}}
|
|
||||||
* | {event: "thinking", data: {chunk: string, fullThinking: string}}
|
|
||||||
* | {event: "done", data: {reply: string, sources?: import("./webSearch.js").WebSource[], searchState?: SearchState}}
|
|
||||||
* | {event: "error", data: {error: string, code?: string, searchState?: SearchState}}} ChatStreamEvent
|
|
||||||
*/
|
|
||||||
export async function stream(url, data, handlers = {}, signal = undefined) {
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: "POST",
|
|
||||||
credentials: "same-origin",
|
|
||||||
headers: {"Content-Type": "application/json"},
|
|
||||||
body: JSON.stringify(data || {}),
|
|
||||||
signal
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok || !response.body) {
|
|
||||||
let error = "Streaming request failed";
|
|
||||||
try {
|
|
||||||
const payload = await response.json();
|
|
||||||
error = payload.error || error;
|
|
||||||
} catch {
|
|
||||||
// Keep the generic error if the server did not send JSON.
|
|
||||||
}
|
|
||||||
if (handlers.onError) {
|
|
||||||
handlers.onError({error});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw new Error(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = response.body.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let buffer = "";
|
|
||||||
let terminal = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
while (true) {
|
|
||||||
const {value, done} = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
buffer += decoder.decode(value, {stream: true});
|
|
||||||
const parts = buffer.split(/\r?\n\r?\n/);
|
|
||||||
buffer = parts.pop() || "";
|
|
||||||
for (const part of parts) {
|
|
||||||
const lines = part.split("\n");
|
|
||||||
const eventLine = lines.find((line) => line.startsWith("event:"));
|
|
||||||
const dataLine = lines.find((line) => line.startsWith("data:"));
|
|
||||||
if (!eventLine || !dataLine) continue;
|
|
||||||
const event = eventLine.slice(6).trim();
|
|
||||||
const payload = JSON.parse(dataLine.slice(5).trim());
|
|
||||||
if (!payload || typeof payload !== "object") throw new Error("Invalid response stream.");
|
|
||||||
const textFields = {chunk: "fullReply", thinking: "fullThinking", status: "status", done: "reply", error: "error"};
|
|
||||||
if (textFields[event] && typeof payload[textFields[event]] !== "string") throw new Error("Invalid response stream.");
|
|
||||||
if (terminal) continue;
|
|
||||||
if (event === "chunk" && handlers.onChunk) handlers.onChunk(payload);
|
|
||||||
if (event === "thinking" && handlers.onThinking) handlers.onThinking(payload);
|
|
||||||
if (event === "status" && handlers.onStatus) handlers.onStatus(payload);
|
|
||||||
if (event === "done" && handlers.onDone) handlers.onDone(payload);
|
|
||||||
if (event === "error" && handlers.onError) handlers.onError(payload);
|
|
||||||
if (event === "done" || event === "error") terminal = true;
|
|
||||||
}
|
|
||||||
if (terminal) break;
|
|
||||||
}
|
|
||||||
if (!terminal) throw new Error("Response was interrupted. Please try again.");
|
|
||||||
} finally {
|
|
||||||
await reader.cancel().catch(() => {});
|
|
||||||
reader.releaseLock();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
function responseError(response, payload = {}, fallback = "", code = "") { const details = payload && typeof payload === "object" ? payload : {}; const error = details.error; return new ApiError(typeof error === "string" ? error : error?.message || fallback || `HTTP ${response.status} ${response.statusText}`.trim(), {...details, status: response.ok && Number.isInteger(details.status) && details.status >= 400 ? details.status : response.status, code: details.code || error?.code || code || `HTTP_${response.status}`, requestId: details.requestId || response.headers.get("X-Request-ID") || null}); }
|
||||||
|
async function readResponseText(response, signal) {
|
||||||
|
let textPromise = responseTextCache.get(response);
|
||||||
|
if (!textPromise) {
|
||||||
|
textPromise = Promise.resolve().then(() => response.text());
|
||||||
|
responseTextCache.set(response, textPromise);
|
||||||
|
}
|
||||||
|
return abortable(() => textPromise, signal);
|
||||||
|
}
|
||||||
|
async function readJson(response, signal) { const text = await readResponseText(response, signal); let payload; if (text.trim()) { try { payload = JSON.parse(text); } catch { if (response.ok) throw responseError(response, {}, "Invalid JSON response.", "INVALID_RESPONSE"); } } if (!response.ok) throw responseError(response, payload); return payload ?? {}; }
|
||||||
|
function normalizeError(error, scope, response) { const failure = scope.signal.aborted ? scope.signal.reason : error; if (failure instanceof ApiError) { if (!failure.status && response) failure.status = response.status; failure.requestId ||= response?.headers.get("X-Request-ID") || null; return failure; } return new ApiError(response ? failure?.message || "Response could not be read." : "Network error - please try again.", {status: response?.status || 0, code: response ? "INVALID_RESPONSE" : "NETWORK_ERROR", requestId: response?.headers.get("X-Request-ID") || null}); }
|
||||||
|
const transient = e => e?.status === 408 || e?.status === 425 || e?.status === 429 || e?.status >= 500 || e?.code === "NETWORK_ERROR" || e?.code === "TIMEOUT";
|
||||||
|
const pause = (ms, signal) => new Promise((resolve, reject) => { const timer = setTimeout(resolve, ms); signal?.addEventListener("abort", () => { clearTimeout(timer); reject(signal.reason); }, {once: true}); });
|
||||||
|
function filenameFromDisposition(value) { const encoded = value?.match(/filename\*=UTF-8''([^;]+)/i)?.[1]; const plain = value?.match(/filename="?([^";]+)"?/i)?.[1]; try { return decodeURIComponent(encoded || plain || ""); } catch { return plain || null; } }
|
||||||
|
async function bufferedRequest(url, {method, body, headers}, options = {}, asBlob = false) { const scope = requestScope(options); const maxRetries = method === "GET" ? Math.max(0, options.retries ?? 2) : 0; try { for (let attempt = 0; ; attempt += 1) { let response; try { response = await abortable(() => fetch(url, {method, credentials: "same-origin", headers, body, signal: scope.signal}), scope.signal); if (!asBlob || !response.ok) return await readJson(response, scope.signal); const blob = await abortable(() => response.blob(), scope.signal); return {blob, filename: filenameFromDisposition(response.headers.get("Content-Disposition")), contentType: response.headers.get("Content-Type") || blob.type || "application/octet-stream", size: Number(response.headers.get("Content-Length")) || blob.size}; } catch (error) { const failure = normalizeError(error, scope, response); if (attempt < maxRetries && transient(failure) && !scope.signal.aborted) { await pause(Math.min(8000, 250 * 2 ** attempt), scope.signal); continue; } return failure.toJSON(); } } } finally { scope.close(); } }
|
||||||
|
export function request(url, data, method = "POST", options = {}) { return bufferedRequest(url, {method, headers: {"Accept": "application/json", "Content-Type": "application/json"}, body: data == null ? undefined : JSON.stringify(data)}, options); }
|
||||||
|
export async function requestResult(url, data, method = "POST", options = {}) { const value = await request(url, data, method, options); return value?.error ? {ok: false, data: null, error: new ApiError(value.error, value)} : {ok: true, data: value, error: null}; }
|
||||||
|
export async function requestOrThrow(url, data, method = "POST", options = {}) { const result = await requestResult(url, data, method, options); if (!result.ok) throw result.error; return result.data; }
|
||||||
|
export const multipart = (url, form, options = {}) => bufferedRequest(url, {method: "POST", headers: {"Accept": "application/json"}, body: form}, {timeoutMs: 120000, ...options});
|
||||||
|
export const download = (url, options = {}) => bufferedRequest(url, {method: "GET", headers: {"Accept": "*/*"}}, {timeoutMs: 120000, ...options}, true);
|
||||||
|
export const get = (url, options) => request(url, null, "GET", options); export const post = (url, data, options) => request(url, data, "POST", options); export const put = (url, data, options) => request(url, data, "PUT", options); export const del = (url, options) => request(url, null, "DELETE", options);
|
||||||
|
export async function stream(url, data, handlers = {}, options = {}) { if (options && typeof options.addEventListener === "function") options = {signal: options}; const scope = requestScope({timeoutMs: 660000, ...options}); let response; let reader; let terminal = false; try { response = await abortable(() => fetch(url, {method: "POST", credentials: "same-origin", headers: {"Accept": "text/event-stream, application/json", "Content-Type": "application/json"}, body: JSON.stringify(data || {}), signal: scope.signal}), scope.signal); if (!response.ok) await readJson(response, scope.signal); if (!response.body) throw responseError(response, {}, "Missing response stream.", "INVALID_RESPONSE"); reader = response.body.getReader(); const parser = createSseParser({onComment: handlers.onComment, onRetry: handlers.onRetry}); const decoder = new TextDecoder(); const consume = events => { for (const event of events) { let payload; try { payload = parseSseData(event); } catch (error) { throw new SseParseError(error.message, {cause: error, event}); } if (!payload || typeof payload !== "object" || Array.isArray(payload)) throw new SseParseError("Invalid response stream.", {event}); const name = event.event; const fields = {status: "status", done: "reply", error: "error", replace: "text"}; if (fields[name] && typeof payload[fields[name]] !== "string") throw new SseParseError(`Invalid ${name} event payload.`, {event}); if (name === "chunk" && typeof (payload.chunk ?? payload.fullReply) !== "string") throw new SseParseError("Invalid chunk event payload.", {event}); if (name === "thinking" && typeof (payload.chunk ?? payload.fullThinking) !== "string") throw new SseParseError("Invalid thinking event payload.", {event}); if (name === "replace" && !["reply", "thinking"].includes(payload.target)) throw new SseParseError("Invalid replacement stream.", {event}); if (name === "error") throw responseError(response, payload, "Streaming request failed.", "STREAM_ERROR"); const handler = {chunk: "onChunk", thinking: "onThinking", status: "onStatus", replace: "onReplace", done: "onDone"}[name]; if (handler) handlers[handler]?.(payload); else if (name !== "message") handlers.onUnknown?.(event, payload); if (name === "done") { terminal = true; break; } } }; while (!terminal) { const {value, done} = await abortable(() => reader.read(), scope.signal); if (done) break; consume(parser.push(decoder.decode(value, {stream: true}))); } consume(parser.finish()); if (!terminal) throw responseError(response, {}, "Response was interrupted. Please try again.", "STREAM_INTERRUPTED"); } catch (error) { const failure = normalizeError(error, scope, response); const reconnectAttempt = options._reconnectAttempt || 0; if (options.reconnect === true && reconnectAttempt < (options.maxReconnects ?? 3) && transient(failure) && !scope.signal.aborted) { await pause(Math.min(8000, options.reconnectDelayMs ?? 500 * 2 ** reconnectAttempt), scope.signal); return stream(url, data, handlers, {...options, _reconnectAttempt: reconnectAttempt + 1}); } if (handlers.onError) handlers.onError(failure.toJSON()); else throw failure; } finally { scope.close(); if (reader) { void reader.cancel().catch(() => {}); reader.releaseLock(); } } }
|
||||||
|
|||||||
+82
-15
@@ -1,3 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* @file Verwaltet Bildauswahl, Uploads und Vorschauen sowie die Anzeige gespeicherter Anhänge.
|
||||||
|
*/
|
||||||
|
import {get, del, multipart, ApiError} from "./api.js";
|
||||||
|
|
||||||
export function appendAttachments(container, attachments = []) {
|
export function appendAttachments(container, attachments = []) {
|
||||||
if (!attachments.length) return;
|
if (!attachments.length) return;
|
||||||
const gallery = document.createElement("div");
|
const gallery = document.createElement("div");
|
||||||
@@ -24,10 +29,17 @@ export function createAttachmentComposer({onChange, onError}) {
|
|||||||
const preview = document.getElementById("uploadPreviews");
|
const preview = document.getElementById("uploadPreviews");
|
||||||
const composer = document.querySelector(".input-container");
|
const composer = document.querySelector(".input-container");
|
||||||
let items = [];
|
let items = [];
|
||||||
|
let draftStore = null;
|
||||||
|
let currentDraftId = "new";
|
||||||
|
const persist = (id = currentDraftId, values = items) => {
|
||||||
|
if (id != null) draftStore?.setAttachments(id, values.filter(item => item.metadata).map(item => item.metadata));
|
||||||
|
};
|
||||||
|
const drafts = new Map();
|
||||||
let busy = false;
|
let busy = false;
|
||||||
let limits = {maxSizeMb: 10, maxFiles: 5};
|
let limits = {maxSizeMb: 10, maxFiles: 5};
|
||||||
fetch("/uploads/config").then(r => r.ok ? r.json() : limits).then(value => { limits = value; }).catch(() => {});
|
void get("/uploads/config").then(value => { if (!value.error) limits = value; });
|
||||||
const render = () => {
|
const render = () => {
|
||||||
|
persist();
|
||||||
preview.replaceChildren();
|
preview.replaceChildren();
|
||||||
preview.hidden = !items.length;
|
preview.hidden = !items.length;
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
@@ -35,11 +47,22 @@ export function createAttachmentComposer({onChange, onError}) {
|
|||||||
card.className = "upload-preview";
|
card.className = "upload-preview";
|
||||||
const image = document.createElement("img");
|
const image = document.createElement("img");
|
||||||
image.src = item.preview;
|
image.src = item.preview;
|
||||||
image.alt = item.file.name;
|
image.alt = item.file?.name || item.metadata?.originalName || "Recovered image";
|
||||||
|
image.onload = () => {
|
||||||
|
if (item.verifying) { item.verifying = false; onChange(); }
|
||||||
|
};
|
||||||
|
image.onerror = () => {
|
||||||
|
if (item.recovered && !item.error) {
|
||||||
|
item.error = "Image unavailable. Remove it and attach it again.";
|
||||||
|
item.verifying = false;
|
||||||
|
item.unavailable = true;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
};
|
||||||
const label = document.createElement("span");
|
const label = document.createElement("span");
|
||||||
label.textContent = `${item.file.name} — ${item.error || (item.metadata ? "Ready" : "Uploading…")}`;
|
label.textContent = `${item.file?.name || item.metadata?.originalName || "Recovered image"} — ${item.error || (item.metadata ? "Ready" : "Uploading…")}`;
|
||||||
card.append(image, label);
|
card.append(image, label);
|
||||||
if (item.error) {
|
if (item.error && item.file) {
|
||||||
const retry = document.createElement("button");
|
const retry = document.createElement("button");
|
||||||
retry.type = "button"; retry.textContent = "Retry"; retry.disabled = busy;
|
retry.type = "button"; retry.textContent = "Retry"; retry.disabled = busy;
|
||||||
retry.onclick = () => { void upload(item); };
|
retry.onclick = () => { void upload(item); };
|
||||||
@@ -47,11 +70,11 @@ export function createAttachmentComposer({onChange, onError}) {
|
|||||||
}
|
}
|
||||||
const remove = document.createElement("button");
|
const remove = document.createElement("button");
|
||||||
remove.type = "button"; remove.textContent = "×"; remove.disabled = busy;
|
remove.type = "button"; remove.textContent = "×"; remove.disabled = busy;
|
||||||
remove.setAttribute("aria-label", `Remove ${item.file.name}`);
|
remove.setAttribute("aria-label", `Remove ${item.file?.name || item.metadata?.originalName || "image"}`);
|
||||||
remove.onclick = () => {
|
remove.onclick = () => {
|
||||||
items = items.filter(v => v !== item);
|
items = items.filter(v => v !== item);
|
||||||
item.controller?.abort(); URL.revokeObjectURL(item.preview);
|
item.controller?.abort(); URL.revokeObjectURL(item.preview);
|
||||||
if (item.metadata) void fetch(`/attachments/${item.metadata.id}`, {method: "DELETE"});
|
if (item.metadata) void del(`/attachments/${item.metadata.id}`);
|
||||||
render();
|
render();
|
||||||
};
|
};
|
||||||
card.append(remove); preview.append(card);
|
card.append(remove); preview.append(card);
|
||||||
@@ -66,17 +89,17 @@ export function createAttachmentComposer({onChange, onError}) {
|
|||||||
render();
|
render();
|
||||||
try {
|
try {
|
||||||
const form = new FormData(); form.append("image", item.file);
|
const form = new FormData(); form.append("image", item.file);
|
||||||
const response = await fetch("/uploads", {method: "POST", body: form, signal: item.controller.signal});
|
const result = await multipart("/uploads", form, {signal: item.controller.signal});
|
||||||
const result = await response.json();
|
if (result.error) throw new ApiError(result.error, result);
|
||||||
if (!response.ok) throw new Error(result.error || "Upload failed");
|
|
||||||
item.metadata = result;
|
item.metadata = result;
|
||||||
if (!items.includes(item)) void fetch(`/attachments/${result.id}`, {method: "DELETE"});
|
if (![items, ...drafts.values()].some(draft => draft.includes(item))) void del(`/attachments/${result.id}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.name !== "AbortError") item.error = error.message || "Upload failed";
|
if (error.code !== "ABORTED") item.error = error.message || "Upload failed";
|
||||||
}
|
}
|
||||||
item.uploading = false;
|
item.uploading = false;
|
||||||
item.controller = null;
|
item.controller = null;
|
||||||
render();
|
for (const [id, draft] of drafts) if (draft.includes(item)) persist(id, draft);
|
||||||
|
if (items.includes(item)) render();
|
||||||
};
|
};
|
||||||
const add = files => {
|
const add = files => {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
@@ -102,18 +125,62 @@ export function createAttachmentComposer({onChange, onError}) {
|
|||||||
if (images.length) { if (!event.clipboardData.getData("text/plain")) event.preventDefault(); add(images); }
|
if (images.length) { if (!event.clipboardData.getData("text/plain")) event.preventDefault(); add(images); }
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
get attachments() { return items.filter(v => v.metadata).map(v => v.metadata); },
|
setDraftStore(store) { draftStore = store; },
|
||||||
get pending() { return items.some(v => !v.metadata); },
|
adoptDraft(chatId) {
|
||||||
|
if (currentDraftId !== chatId) {
|
||||||
|
if (currentDraftId === "new") draftStore?.setAttachments("new", []);
|
||||||
|
currentDraftId = chatId;
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
get attachments() { return items.filter(v => v.metadata && !v.unavailable).map(v => v.metadata); },
|
||||||
|
get pending() { return items.some(v => !v.metadata || v.verifying || v.unavailable); },
|
||||||
setBusy(value) { busy = value; button.disabled = value; picker.disabled = value; render(); },
|
setBusy(value) { busy = value; button.disabled = value; picker.disabled = value; render(); },
|
||||||
clear() { items.forEach(v => URL.revokeObjectURL(v.preview)); items = []; render(); },
|
clear() { items.forEach(v => URL.revokeObjectURL(v.preview)); items = []; render(); },
|
||||||
discard() {
|
discard() {
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
item.controller?.abort();
|
item.controller?.abort();
|
||||||
URL.revokeObjectURL(item.preview);
|
URL.revokeObjectURL(item.preview);
|
||||||
if (item.metadata) void fetch(`/attachments/${item.metadata.id}`, {method: "DELETE"});
|
if (item.metadata) void del(`/attachments/${item.metadata.id}`);
|
||||||
}
|
}
|
||||||
items = [];
|
items = [];
|
||||||
render();
|
render();
|
||||||
|
},
|
||||||
|
saveDraft(chatId) {
|
||||||
|
chatId ??= "new";
|
||||||
|
drafts.set(chatId, items);
|
||||||
|
persist(chatId);
|
||||||
|
items = [];
|
||||||
|
currentDraftId = null;
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
restoreDraft(chatId) {
|
||||||
|
currentDraftId = chatId ?? "new";
|
||||||
|
chatId ??= "new";
|
||||||
|
items = drafts.get(chatId) || (draftStore?.attachments(chatId) || []).map(metadata => ({metadata, recovered: true, verifying: true, preview: `/attachments/${metadata.id}`}));
|
||||||
|
drafts.delete(chatId);
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
clearSentDraft(chatId, ids) {
|
||||||
|
const saved = drafts.get(chatId) || [];
|
||||||
|
const sent = new Set(ids);
|
||||||
|
drafts.set(chatId, saved.filter(item => {
|
||||||
|
if (!sent.has(item.metadata?.id)) return true;
|
||||||
|
URL.revokeObjectURL(item.preview);
|
||||||
|
return false;
|
||||||
|
}));
|
||||||
|
const remaining = (draftStore?.attachments(chatId) || []).filter(item => !sent.has(item.id));
|
||||||
|
draftStore?.setAttachments(chatId, remaining);
|
||||||
|
},
|
||||||
|
discardDraft(chatId) {
|
||||||
|
const saved = drafts.get(chatId) || [];
|
||||||
|
drafts.delete(chatId);
|
||||||
|
draftStore?.setAttachments(chatId, []);
|
||||||
|
for (const item of saved) {
|
||||||
|
item.controller?.abort();
|
||||||
|
URL.revokeObjectURL(item.preview);
|
||||||
|
if (item.metadata) void del(`/attachments/${item.metadata.id}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import {get, post} from "./api.js";
|
||||||
|
import {createConversationPanel, actionButton} from "./conversationPanel.js";
|
||||||
|
|
||||||
|
export function createBranchExplorer({state, setActiveChat, setMessageContent}) {
|
||||||
|
const panel = createConversationPanel("branchExplorer", "Branches & answer comparison");
|
||||||
|
let request = 0;
|
||||||
|
return {async open() {
|
||||||
|
const chatId = state.activeChatId;
|
||||||
|
if (!chatId) return;
|
||||||
|
const current = ++request;
|
||||||
|
panel.open(); panel.notice("Loading branches..."); panel.content.replaceChildren();
|
||||||
|
const nodes = [], cache = new Map();
|
||||||
|
let next = 0, activeLeafId;
|
||||||
|
const layout = document.createElement("div"); layout.className = "branch-layout";
|
||||||
|
const sidebar = document.createElement("div"), comparison = document.createElement("div");
|
||||||
|
sidebar.className = "branch-sidebar"; comparison.className = "answer-comparison";
|
||||||
|
layout.append(sidebar, comparison); panel.content.append(layout);
|
||||||
|
const tree = document.createElement("ol"); tree.className = "branch-tree"; tree.setAttribute("aria-label", "Conversation branches");
|
||||||
|
const linked = state.chatSessions.filter(chat => chat.source_chat_id === chatId || chat.id === state.chatSessions.find(item => item.id === chatId)?.source_chat_id);
|
||||||
|
if (linked.length) {
|
||||||
|
const heading = document.createElement("h4"); heading.textContent = "Related conversations"; sidebar.append(heading);
|
||||||
|
for (const chat of linked) sidebar.append(actionButton(chat.title, async () => {
|
||||||
|
const result = await setActiveChat(chat.id);
|
||||||
|
if (!result?.error) panel.close(); else panel.notice(result.error, true);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
const hint = document.createElement("p"); hint.className = "subtitle";
|
||||||
|
hint.textContent = "Select a message to compare answers or versions. Opening this panel keeps your active branch unchanged.";
|
||||||
|
sidebar.append(hint, tree);
|
||||||
|
const panes = ["Left answer", "Right answer"].map(label => {
|
||||||
|
const section = document.createElement("section"); section.className = "comparison-pane";
|
||||||
|
const heading = document.createElement("h4"); heading.textContent = label;
|
||||||
|
const message = document.createElement("select"); message.setAttribute("aria-label", `${label} message`);
|
||||||
|
const version = document.createElement("select"); version.setAttribute("aria-label", `${label} version`);
|
||||||
|
const body = document.createElement("div"); body.className = "msg-content";
|
||||||
|
let revision = 0;
|
||||||
|
const render = async () => {
|
||||||
|
const token = ++revision, id = Number(message.value);
|
||||||
|
use.disabled = true; activate.disabled = true; version.disabled = true;
|
||||||
|
body.replaceChildren(); version.replaceChildren();
|
||||||
|
if (!id) return;
|
||||||
|
let data = cache.get(id);
|
||||||
|
if (!data) {
|
||||||
|
const loaded = await get(`/chats/${chatId}/messages/${id}/versions`);
|
||||||
|
if (token !== revision || current !== request) return;
|
||||||
|
if (loaded.error) return panel.notice(loaded.error, true);
|
||||||
|
data = loaded.message; cache.set(id, data);
|
||||||
|
}
|
||||||
|
const versions = data.responseVersions?.length ? data.responseVersions : [{content: data.content, modelName: data.modelName}];
|
||||||
|
use.hidden = data.role !== "bot" || versions.length < 2;
|
||||||
|
use.disabled = false; activate.disabled = false; version.disabled = false;
|
||||||
|
version.replaceChildren(...versions.map((item, index) => {
|
||||||
|
const option = document.createElement("option"); option.value = index;
|
||||||
|
option.textContent = `Version ${index + 1}${item.modelName ? ` · ${item.modelName}` : ""}${index === data.versionIndex ? " (selected)" : ""}`;
|
||||||
|
return option;
|
||||||
|
}));
|
||||||
|
version.value = String(label === "Left answer" && versions.length > 1 ? 0 : data.versionIndex || 0);
|
||||||
|
const show = () => setMessageContent(body, versions[Number(version.value)]?.content || "");
|
||||||
|
version.onchange = show; show();
|
||||||
|
};
|
||||||
|
message.onchange = render;
|
||||||
|
const activate = actionButton("Open this branch", async () => {
|
||||||
|
const result = await post(`/chats/${chatId}/branches/${message.value}/activate`, {includeDescendants: true});
|
||||||
|
if (result.error) return panel.notice(result.error, true);
|
||||||
|
const opened = await setActiveChat(chatId);
|
||||||
|
if (opened?.error) return panel.notice(opened.error, true);
|
||||||
|
panel.close();
|
||||||
|
});
|
||||||
|
const use = actionButton("Use this version", async () => {
|
||||||
|
const result = await post(`/chats/${chatId}/messages/${message.value}/versions/${version.value}`, {});
|
||||||
|
if (result.error) return panel.notice(result.error, true);
|
||||||
|
cache.delete(Number(message.value));
|
||||||
|
const refreshed = await setActiveChat(chatId);
|
||||||
|
if (refreshed?.error) return panel.notice(refreshed.error, true);
|
||||||
|
panel.notice("Selected answer version saved.");
|
||||||
|
});
|
||||||
|
const actions = document.createElement("div"); actions.className = "panel-actions"; actions.append(activate, use);
|
||||||
|
section.append(heading, message, version, actions, body); comparison.append(section);
|
||||||
|
return {message, render};
|
||||||
|
});
|
||||||
|
const loadMore = actionButton("Load more branches", async () => {
|
||||||
|
const result = await get(`/chats/${chatId}/branches?after=${next}`);
|
||||||
|
if (current !== request) return;
|
||||||
|
if (result.error) return panel.notice(result.error, true);
|
||||||
|
nodes.push(...result.nodes); next = result.next; activeLeafId = result.activeLeafId;
|
||||||
|
tree.replaceChildren();
|
||||||
|
const known = new Set(nodes.map(node => node.id)), children = new Map();
|
||||||
|
for (const node of nodes) {
|
||||||
|
const parent = known.has(node.parentMessageId) ? node.parentMessageId : null;
|
||||||
|
children.set(parent, [...(children.get(parent) || []), node]);
|
||||||
|
}
|
||||||
|
const stack = (children.get(null) || []).map(node => ({node, level: 0})).reverse();
|
||||||
|
while (stack.length) {
|
||||||
|
const {node, level} = stack.pop();
|
||||||
|
stack.push(...(children.get(node.id) || []).map(child => ({node: child, level: level + 1})).reverse());
|
||||||
|
const row = document.createElement("li"); row.style.setProperty("--branch-depth", Math.min(level, 8));
|
||||||
|
row.title = node.parentMessageId ? `Continues message #${node.parentMessageId}` : "Conversation root";
|
||||||
|
const button = actionButton(`${node.role === "bot" ? "Assistant" : "You"} #${node.id}${node.id === activeLeafId ? " · active tip" : ""} — ${node.preview}`, async () => {
|
||||||
|
panes[1].message.value = String(node.id); await panes[1].render();
|
||||||
|
});
|
||||||
|
if (node.id === activeLeafId) button.setAttribute("aria-current", "true");
|
||||||
|
row.append(button); tree.append(row);
|
||||||
|
}
|
||||||
|
for (const pane of panes) {
|
||||||
|
const selected = pane.message.value;
|
||||||
|
pane.message.replaceChildren(...nodes.map(node => {
|
||||||
|
const option = document.createElement("option"); option.value = node.id;
|
||||||
|
option.textContent = `${node.role === "bot" ? "Assistant" : "You"} #${node.id} — ${node.preview.slice(0, 65)}`;
|
||||||
|
return option;
|
||||||
|
}));
|
||||||
|
pane.message.value = selected || String(nodes.filter(node => node.role === "bot").at(-1)?.id || nodes.at(-1)?.id || "");
|
||||||
|
await pane.render();
|
||||||
|
}
|
||||||
|
loadMore.hidden = next == null;
|
||||||
|
panel.notice(nodes.length ? "" : "Send a message to start a conversation branch.");
|
||||||
|
});
|
||||||
|
sidebar.append(loadMore);
|
||||||
|
await loadMore.onclick();
|
||||||
|
}};
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Steuert das Chat-Seitenpanel einschließlich Tastaturbedienung und Fokusverwaltung.
|
||||||
|
*/
|
||||||
import {chatDrawer, chatDrawerButton, chatDrawerCloseBtn} from "./dom.js?v=20260909-account-controls";
|
import {chatDrawer, chatDrawerButton, chatDrawerCloseBtn} from "./dom.js?v=20260909-account-controls";
|
||||||
|
|
||||||
let lastFocusedElement = null;
|
let lastFocusedElement = null;
|
||||||
@@ -61,12 +64,14 @@ function toggleChatDrawer() {
|
|||||||
function handleDisclosureToggle(event) {
|
function handleDisclosureToggle(event) {
|
||||||
const disclosure = event.target;
|
const disclosure = event.target;
|
||||||
if (!(disclosure instanceof HTMLDetailsElement) || !chatDrawer?.contains(disclosure)) return;
|
if (!(disclosure instanceof HTMLDetailsElement) || !chatDrawer?.contains(disclosure)) return;
|
||||||
|
// Nested disclosures must not close their parent or lose their expanded state.
|
||||||
|
if (!disclosure.matches(".chat-drawer-body > details")) return;
|
||||||
if (disclosure.matches(".chat-drawer-more, .chat-drawer-danger")) {
|
if (disclosure.matches(".chat-drawer-more, .chat-drawer-danger")) {
|
||||||
const hint = disclosure.querySelector(":scope > summary .chat-drawer-row-hint");
|
const hint = disclosure.querySelector(":scope > summary .chat-drawer-row-hint");
|
||||||
if (hint) hint.textContent = disclosure.open ? "Hide" : "Show";
|
if (hint) hint.textContent = disclosure.open ? "Hide" : "Show";
|
||||||
}
|
}
|
||||||
if (disclosure.open) {
|
if (disclosure.open) {
|
||||||
chatDrawer.querySelectorAll("details[open]").forEach((other) => {
|
chatDrawer.querySelectorAll(".chat-drawer-body > details[open]").forEach((other) => {
|
||||||
if (other !== disclosure) other.open = false;
|
if (other !== disclosure) other.open = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export function titleFromFirstMessage(message) {
|
||||||
|
const firstLine = String(message || "").split(/\r?\n/).find(line => line.trim()) || "";
|
||||||
|
let subject = firstLine.replace(/^\s*(?:[-*#]+\s*)?/, "").replace(/\s+/g, " ").trim();
|
||||||
|
subject = subject.replace(/^(?:can|could|would) you (?:please )?(?:help me )?/i, "")
|
||||||
|
.replace(/^(?:please )?(?:help me (?:with |understand )?|tell me about |explain |describe |summarize |compare |write about |create |make )/i, "")
|
||||||
|
.replace(/^what (?:is|are|was|were) (?:the |a |an )?/i, "")
|
||||||
|
.replace(/^how (?:do|does|can) (?:i|we) /i, "")
|
||||||
|
.replace(/^[\s:,-]+|[\s.!?,;:]+$/g, "");
|
||||||
|
const sentence = subject.split(/(?<=[.!?])\s+/)[0].replace(/[.!?]$/, "");
|
||||||
|
if (sentence) subject = sentence;
|
||||||
|
if (subject.length > 68) {
|
||||||
|
const words = subject.slice(0, 69).split(" ");
|
||||||
|
subject = words.length > 1 ? words.slice(0, -1).join(" ") : subject.slice(0, 68);
|
||||||
|
}
|
||||||
|
subject = subject.replace(/[\s,;:.-]+$/g, "");
|
||||||
|
return subject ? subject[0].toLocaleUpperCase() + subject.slice(1) : "New chat";
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
/** Composer state, send serialization, cancellation, and streaming presentation. */
|
||||||
|
import * as defaultApi from "./api.js";
|
||||||
|
import {updateReasoning, finishReasoning} from "./reasoning.js";
|
||||||
|
import {createStreamRenderer} from "./streamRenderer.js";
|
||||||
|
|
||||||
|
export function createComposerController({
|
||||||
|
state, elements, attachmentComposer, webSearchButton, reasoningToggle, webSearchToggle,
|
||||||
|
setChatActivity, updateComposerPlaceholder, setNotice, createNewChat, getChatById,
|
||||||
|
updateWorkspaceCopy, renderChatList, addMessage, updateChatActionState, scrollMessagesToBottom,
|
||||||
|
setMessageContent, announceResponding = () => {}, announceReply = () => {}, announceResponseStatus = () => {}, setActiveChat, loadSummary, markOnboardingCompleted, maybeShowPersonaNudge,
|
||||||
|
api = defaultApi
|
||||||
|
}) {
|
||||||
|
const {msgInput, sendBtn, messagesDiv, modelSelect} = elements;
|
||||||
|
const stopBtn = elements.stopBtn || {hidden: true};
|
||||||
|
const composerHint = elements.composerHint || globalThis.document?.getElementById?.("composerHint") || {textContent: ""};
|
||||||
|
let sendController = null;
|
||||||
|
|
||||||
|
function updateSendState() {
|
||||||
|
const hasText = Boolean(msgInput.value.trim());
|
||||||
|
sendBtn.disabled = state.isProcessing || attachmentComposer.pending || (!hasText && !attachmentComposer.attachments.length);
|
||||||
|
stopBtn.hidden = !state.isProcessing;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizeComposerInput() {
|
||||||
|
msgInput.style.height = "auto";
|
||||||
|
msgInput.style.height = `${Math.min(Math.max(msgInput.scrollHeight, 46), 96)}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLoadingState(loading, overlayOptions = null) {
|
||||||
|
state.isProcessing = loading;
|
||||||
|
msgInput.disabled = loading;
|
||||||
|
webSearchButton.disabled = loading;
|
||||||
|
reasoningToggle.setBusy(loading);
|
||||||
|
attachmentComposer.setBusy(loading);
|
||||||
|
messagesDiv.querySelectorAll('[data-message-action="regenerate"]').forEach(button => {
|
||||||
|
button.disabled = loading || button.dataset.retryLimitReached === "true";
|
||||||
|
});
|
||||||
|
messagesDiv.querySelectorAll('[data-message-action="retry-failed"]').forEach(button => { button.disabled = loading; });
|
||||||
|
if (loading) {
|
||||||
|
msgInput.placeholder = "Draft your next message...";
|
||||||
|
composerHint.textContent = "Draft your next message · Enter for new line";
|
||||||
|
announceResponding();
|
||||||
|
if (overlayOptions !== false) setChatActivity(true, overlayOptions || undefined);
|
||||||
|
} else {
|
||||||
|
setChatActivity(false);
|
||||||
|
updateComposerPlaceholder();
|
||||||
|
composerHint.textContent = "Enter to send · Shift+Enter for new line";
|
||||||
|
}
|
||||||
|
updateSendState();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopGeneration() {
|
||||||
|
if (!state.isProcessing) return;
|
||||||
|
if (sendController) {
|
||||||
|
sendController.abort();
|
||||||
|
setNotice("Stopping generation...");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const chatId = state.regenerationChatId;
|
||||||
|
if (!chatId) return;
|
||||||
|
const res = await api.post(`/chats/${chatId}/stop`, {});
|
||||||
|
if (res.error) return setNotice(res.error, "error");
|
||||||
|
setNotice(res.stopped ? "Stopping generation..." : "Generation already finished.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMessage({retryMessage = null} = {}) {
|
||||||
|
if (state.isProcessing || attachmentComposer.pending) return;
|
||||||
|
const attachments = [...(retryMessage?.attachments || attachmentComposer.attachments)];
|
||||||
|
const message = retryMessage?.content || msgInput.value.trim() || (attachments.length ? "Describe the attached images." : "");
|
||||||
|
if (!message) return;
|
||||||
|
// Claim the send before any async work (including creating or renaming a chat).
|
||||||
|
state.isProcessing = true;
|
||||||
|
const model = modelSelect.value;
|
||||||
|
const webSearch = webSearchToggle.enabled, reasoning = reasoningToggle.enabled;
|
||||||
|
let chatId = state.activeChatId;
|
||||||
|
state.sendingChatId = chatId;
|
||||||
|
sendController = new AbortController();
|
||||||
|
const {signal} = sendController;
|
||||||
|
const visible = () => state.activeChatId === chatId;
|
||||||
|
let userCard, loadingMsg, rendering, completedReply = null;
|
||||||
|
let completedThinking = "", completedSources = null;
|
||||||
|
const userMessage = {role: "user", content: message, attachments,
|
||||||
|
...(retryMessage?.id ? {id: retryMessage.id, deliveryStatus: retryMessage.deliveryStatus, errorMessage: retryMessage.errorMessage} : {})};
|
||||||
|
if (!retryMessage) {
|
||||||
|
msgInput.value = "";
|
||||||
|
state.chatDrafts.set(chatId ?? "new", "");
|
||||||
|
resizeComposerInput();
|
||||||
|
}
|
||||||
|
const clearSentDraft = () => {
|
||||||
|
attachmentComposer.clearSentDraft(chatId, attachments.map(item => item.id));
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
setLoadingState(true, false);
|
||||||
|
if (!chatId) {
|
||||||
|
const created = await createNewChat({preserveComposer: true, signal});
|
||||||
|
signal.throwIfAborted();
|
||||||
|
if (!created?.id) throw new Error(created?.error || "Unable to create chat.");
|
||||||
|
chatId = created.id;
|
||||||
|
state.sendingChatId = chatId;
|
||||||
|
}
|
||||||
|
const currentChat = getChatById(chatId);
|
||||||
|
if (currentChat && !currentChat.assistant_persona_id && currentChat.title === "New chat") {
|
||||||
|
const renamed = await api.put(`/chats/${chatId}`, {title: currentChat.title}, {signal});
|
||||||
|
signal.throwIfAborted();
|
||||||
|
if (renamed.error) throw new Error(renamed.error);
|
||||||
|
}
|
||||||
|
signal.throwIfAborted();
|
||||||
|
if (visible()) {
|
||||||
|
state.currentMessages.push(userMessage);
|
||||||
|
userCard = addMessage(userMessage, true);
|
||||||
|
loadingMsg = addMessage({role: "bot", content: ""}, false, true, {isNewest: true});
|
||||||
|
}
|
||||||
|
updateChatActionState(); setNotice("Generating reply...");
|
||||||
|
const loadingBody = loadingMsg?.querySelector(".msg-content");
|
||||||
|
const loadingShell = loadingMsg?.querySelector(".loading-shell");
|
||||||
|
const loadingLabel = loadingMsg?.querySelector(".loading-copy strong");
|
||||||
|
if (loadingLabel) loadingLabel.textContent = "Preparing response...";
|
||||||
|
const render = operation => {
|
||||||
|
if (!visible() || !loadingMsg?.isConnected) return;
|
||||||
|
const follow = messagesDiv.scrollHeight - messagesDiv.scrollTop - messagesDiv.clientHeight < 100;
|
||||||
|
operation();
|
||||||
|
if (follow) scrollMessagesToBottom();
|
||||||
|
};
|
||||||
|
rendering = createStreamRenderer({
|
||||||
|
onThinking: text => { completedThinking = text; render(() => {
|
||||||
|
if (!reasoning) return;
|
||||||
|
updateReasoning(loadingMsg, text, true);
|
||||||
|
loadingShell?.classList.toggle("hidden", Boolean(text));
|
||||||
|
}); },
|
||||||
|
onReply: text => render(() => {
|
||||||
|
if (text) finishReasoning(loadingMsg);
|
||||||
|
loadingShell?.classList.toggle("hidden", Boolean(text));
|
||||||
|
if (loadingLabel) loadingLabel.textContent = "Generating response...";
|
||||||
|
loadingBody?.classList.add("has-content");
|
||||||
|
if (loadingBody) setMessageContent(loadingBody, text);
|
||||||
|
})
|
||||||
|
});
|
||||||
|
await api.stream("/chat/stream", {message, attachments: attachments.map(item => item.id), model, chatId,
|
||||||
|
...(retryMessage?.id && ["failed", "pending"].includes(retryMessage.deliveryStatus) ? {userMessageId: retryMessage.id} : {}),
|
||||||
|
webSearch, reasoning}, {
|
||||||
|
...rendering,
|
||||||
|
onStatus: ({status, sourceCount, searchState}) => {
|
||||||
|
if (status === "accepted" || !visible() || !loadingLabel) return;
|
||||||
|
loadingLabel.textContent = status === "analyzing" ? "Analyzing images..." : status === "searching" ? "Searching the web..."
|
||||||
|
: searchState === "no_relevant_results" ? "No sufficiently relevant results. Generating response..."
|
||||||
|
: sourceCount === 0 ? "No web results found. Generating response..." : "Generating response...";
|
||||||
|
},
|
||||||
|
onDone: ({reply, sources, chatTitle}) => {
|
||||||
|
completedReply = reply; completedSources = sources;
|
||||||
|
if (chatTitle && currentChat) {
|
||||||
|
currentChat.title = chatTitle;
|
||||||
|
updateWorkspaceCopy(); renderChatList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, {signal});
|
||||||
|
rendering.flush();
|
||||||
|
rendering.dispose();
|
||||||
|
const updatedChat = getChatById(chatId);
|
||||||
|
if (api === defaultApi && currentChat && !currentChat.assistant_persona_id && currentChat.title === "New chat") {
|
||||||
|
const titled = await api.post(`/chats/${chatId}/title`, {message, model}, {signal});
|
||||||
|
if (!signal.aborted && titled.title) {
|
||||||
|
currentChat.title = titled.title;
|
||||||
|
updateWorkspaceCopy(); renderChatList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (updatedChat && completedReply !== null) {
|
||||||
|
updatedChat.last_message_preview = completedReply.slice(0, 180);
|
||||||
|
updatedChat.updated_at = new Date().toISOString();
|
||||||
|
renderChatList();
|
||||||
|
}
|
||||||
|
// Keep a completed answer in both state and the DOM until history replaces it.
|
||||||
|
if (visible()) {
|
||||||
|
const reply = {role: "bot", content: completedReply, modelName: model,
|
||||||
|
...(completedThinking ? {thinking: completedThinking} : {}), ...(completedSources ? {sources: completedSources} : {})};
|
||||||
|
state.currentMessages.push(reply);
|
||||||
|
if (loadingMsg?.isConnected) loadingMsg.replaceWith(addMessage(reply, false, false, {detached: true}));
|
||||||
|
else addMessage(reply);
|
||||||
|
announceReply(completedReply);
|
||||||
|
} else loadingMsg?.remove();
|
||||||
|
reasoningToggle.reset();
|
||||||
|
if (visible()) {
|
||||||
|
const followOutput = messagesDiv.scrollHeight - messagesDiv.scrollTop - messagesDiv.clientHeight < 100;
|
||||||
|
const previousScrollTop = messagesDiv.scrollTop;
|
||||||
|
const refreshed = await setActiveChat(chatId);
|
||||||
|
if (refreshed?.error) throw new Error(refreshed.error);
|
||||||
|
if (visible()) {
|
||||||
|
if (msgInput.value) state.chatDrafts.set(chatId, msgInput.value);
|
||||||
|
else state.chatDrafts.delete(chatId);
|
||||||
|
const currentAttachments = attachmentComposer.attachments;
|
||||||
|
if (currentAttachments.length === attachments.length && currentAttachments.every((item, index) => item.id === attachments[index].id)) attachmentComposer.clear();
|
||||||
|
} else {
|
||||||
|
state.chatDrafts.delete(chatId);
|
||||||
|
clearSentDraft();
|
||||||
|
}
|
||||||
|
if (!followOutput && visible()) requestAnimationFrame(() => {
|
||||||
|
if (visible()) messagesDiv.scrollTop = previousScrollTop;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
clearSentDraft();
|
||||||
|
}
|
||||||
|
await loadSummary();
|
||||||
|
markOnboardingCompleted(); maybeShowPersonaNudge();
|
||||||
|
} catch (error) {
|
||||||
|
loadingMsg?.remove();
|
||||||
|
const messageText = signal.aborted ? "Generation stopped." : error?.message || "Unable to send message.";
|
||||||
|
if (visible() && completedReply === null) announceResponseStatus(messageText);
|
||||||
|
if (completedReply !== null) {
|
||||||
|
if (visible() && !msgInput.value.trim()) {
|
||||||
|
msgInput.value = message;
|
||||||
|
state.chatDrafts.set(chatId ?? "new", message);
|
||||||
|
resizeComposerInput();
|
||||||
|
}
|
||||||
|
setNotice(`Reply sent, but the conversation could not be refreshed: ${messageText}`, "error");
|
||||||
|
} else {
|
||||||
|
if (signal.aborted && chatId == null && !msgInput.value.trim()) {
|
||||||
|
msgInput.value = message;
|
||||||
|
state.chatDrafts.set("new", message);
|
||||||
|
resizeComposerInput();
|
||||||
|
}
|
||||||
|
if (visible()) {
|
||||||
|
if (!msgInput.value.trim()) {
|
||||||
|
msgInput.value = message;
|
||||||
|
state.chatDrafts.set(state.activeChatId ?? "new", message);
|
||||||
|
resizeComposerInput();
|
||||||
|
}
|
||||||
|
userMessage.deliveryStatus = "failed";
|
||||||
|
userMessage.errorMessage = messageText;
|
||||||
|
if (userCard?.isConnected) userCard.replaceWith(addMessage(userMessage, true, false, {detached: true}));
|
||||||
|
}
|
||||||
|
setNotice(messageText, "error");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
rendering?.dispose();
|
||||||
|
sendController = null;
|
||||||
|
state.sendingChatId = null;
|
||||||
|
setLoadingState(false); updateChatActionState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
updateSendState,
|
||||||
|
resizeComposerInput,
|
||||||
|
setLoadingState,
|
||||||
|
stopGeneration,
|
||||||
|
sendMessage
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* @file Stellt gemeinsame Theme-Daten, Einstiegsvorschläge und die angefragte Chat-ID bereit.
|
||||||
|
*/
|
||||||
export const requestedChatId = Number(window.location.pathname.match(/^\/app\/chats\/(\d+)$/)?.[1] || new URLSearchParams(window.location.search).get("chat")) || null;
|
export const requestedChatId = Number(window.location.pathname.match(/^\/app\/chats\/(\d+)$/)?.[1] || new URLSearchParams(window.location.search).get("chat")) || null;
|
||||||
|
|
||||||
export const onboardingPrompts = {
|
export const onboardingPrompts = {
|
||||||
ask: ["Explain this topic in simple terms.", "Compare these options and recommend one."],
|
ask: ["Explain this topic in simple terms.", "Compare these options and recommend one."],
|
||||||
brainstorm: ["Give me 10 practical ideas for this problem.", "Suggest 3 creative directions and tradeoffs."],
|
brainstorm: ["Give me 10 practical ideas for this problem.", "Suggest 3 creative directions and tradeoffs."],
|
||||||
roleplay: ["Start a roleplay scene with immediate tension.", "Give me a dramatic opening with clear stakes."]
|
write: ["Write an opening scene with a strong point of view.", "Help me revise this paragraph while preserving my voice."],
|
||||||
|
learn: ["Teach me this concept step by step.", "Give me a short practice question on this topic."]
|
||||||
};
|
};
|
||||||
|
|
||||||
// Stable keys preserve existing saved appearance preferences.
|
// Stable keys preserve existing saved appearance preferences.
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
import {chatSearchInput, msgInput, messagesDiv} from "./dom.js?v=20260909-account-controls";
|
||||||
|
import {get, post} from "./api.js?v=20260909-account-controls";
|
||||||
|
import {requestedChatId} from "./constants.js?v=20260909-account-controls";
|
||||||
|
|
||||||
|
export function createConversationController({
|
||||||
|
state,
|
||||||
|
renderChatList,
|
||||||
|
attachmentComposer,
|
||||||
|
resizeComposerInput,
|
||||||
|
isMobileLayout,
|
||||||
|
closeMobileSidebar,
|
||||||
|
setChatLoading,
|
||||||
|
setChatActivity,
|
||||||
|
updateWorkspaceCopy,
|
||||||
|
updateChatActionState,
|
||||||
|
setNotice,
|
||||||
|
populateGenerationSettings,
|
||||||
|
renderMessages,
|
||||||
|
updateJumpToLatest,
|
||||||
|
updateSendState,
|
||||||
|
loadSummary,
|
||||||
|
updateHistoryControls = () => {},
|
||||||
|
getPersonalityId = () => "general-assistant"
|
||||||
|
}) {
|
||||||
|
let opening = false;
|
||||||
|
function getChatById(id) { return state.chatSessions.find((chat) => chat.id === id); }
|
||||||
|
|
||||||
|
async function openCreatedChat(chat) {
|
||||||
|
if (!chat?.id) throw new Error("The server did not return the newly created chat");
|
||||||
|
if (chatSearchInput) {
|
||||||
|
chatSearchInput.value = "";
|
||||||
|
scheduleChatSearch();
|
||||||
|
}
|
||||||
|
const chatsResponse = await get("/chats");
|
||||||
|
if (chatsResponse.error) throw new Error(chatsResponse.error);
|
||||||
|
state.chatSessions = chatsResponse.chats || [];
|
||||||
|
if (!state.chatSessions.some((item) => item.id === chat.id)) {
|
||||||
|
throw new Error("The newly created chat was not persisted");
|
||||||
|
}
|
||||||
|
renderChatList();
|
||||||
|
const opened = await setActiveChat(chat.id);
|
||||||
|
if (opened?.error) throw new Error(opened.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setActiveChat(id, {preserveComposer = false, matchMessageId = null} = {}) {
|
||||||
|
const failed = response => { setNotice(response.error, "error"); return response; };
|
||||||
|
const openRequest = ++state.chatOpenRequest;
|
||||||
|
if (isMobileLayout()) closeMobileSidebar();
|
||||||
|
if (!opening) {
|
||||||
|
opening = true;
|
||||||
|
setChatLoading(true, "Opening chat...");
|
||||||
|
setChatActivity(true, {
|
||||||
|
eyebrow: "Opening chat", title: "Loading conversation",
|
||||||
|
detail: "Fetching messages and restoring the current scene."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const historyUrl = `/chats/${id}/messages${matchMessageId ? `?around=${matchMessageId}` : ""}`;
|
||||||
|
let res = await get(historyUrl, {retries: 0});
|
||||||
|
if (openRequest !== state.chatOpenRequest) return;
|
||||||
|
if (res.error) return failed(res);
|
||||||
|
if (matchMessageId && !res.messages?.some(message => message.id === matchMessageId)) {
|
||||||
|
const activated = await post(`/chats/${id}/branches/${matchMessageId}/activate`, {includeDescendants: true});
|
||||||
|
if (openRequest !== state.chatOpenRequest) return;
|
||||||
|
if (activated.error) return failed(activated);
|
||||||
|
res = await get(historyUrl, {retries: 0});
|
||||||
|
if (openRequest !== state.chatOpenRequest) return;
|
||||||
|
if (res.error) return failed(res);
|
||||||
|
}
|
||||||
|
// Commit identity, content and drafts together, only for the latest successful load.
|
||||||
|
if (!preserveComposer && id !== state.activeChatId) {
|
||||||
|
if (state.activeChatId != null) {
|
||||||
|
state.chatDrafts.set(state.activeChatId, msgInput.value);
|
||||||
|
attachmentComposer.saveDraft(state.activeChatId);
|
||||||
|
msgInput.value = state.chatDrafts.get(id) || "";
|
||||||
|
attachmentComposer.restoreDraft(id);
|
||||||
|
resizeComposerInput();
|
||||||
|
} else {
|
||||||
|
if (msgInput.value || attachmentComposer.pending || attachmentComposer.attachments.length) {
|
||||||
|
state.chatDrafts.set("new", msgInput.value);
|
||||||
|
attachmentComposer.saveDraft(null);
|
||||||
|
}
|
||||||
|
msgInput.value = state.chatDrafts.get(id) || "";
|
||||||
|
attachmentComposer.restoreDraft(id);
|
||||||
|
resizeComposerInput();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const existing = getChatById(id);
|
||||||
|
if (res.chat && existing) Object.assign(existing, res.chat);
|
||||||
|
state.activeChatId = id;
|
||||||
|
attachmentComposer.adoptDraft?.(id);
|
||||||
|
if (preserveComposer) state.chatDrafts.delete("new");
|
||||||
|
state.chatDrafts.set(id, msgInput.value);
|
||||||
|
state.currentMessages = res.messages || [];
|
||||||
|
state.historyPage = res.pagination || null;
|
||||||
|
state.historyLoading = false;
|
||||||
|
updateHistoryControls();
|
||||||
|
history.replaceState(null, "", `/app/chats/${id}`);
|
||||||
|
state.messageRetryState = new Map();
|
||||||
|
updateWorkspaceCopy(); renderChatList(); updateChatActionState();
|
||||||
|
populateGenerationSettings(res.chat || existing);
|
||||||
|
renderMessages();
|
||||||
|
if (matchMessageId) requestAnimationFrame(() => {
|
||||||
|
if (openRequest !== state.chatOpenRequest) return;
|
||||||
|
const target = [...messagesDiv.querySelectorAll("[data-message-id]")]
|
||||||
|
.find(node => Number(node.dataset.messageId) === Number(matchMessageId));
|
||||||
|
if (target) { target.scrollIntoView({block: "center"}); updateJumpToLatest(); }
|
||||||
|
});
|
||||||
|
updateSendState();
|
||||||
|
return {ok: true};
|
||||||
|
} finally {
|
||||||
|
if (openRequest === state.chatOpenRequest) {
|
||||||
|
opening = false;
|
||||||
|
setChatLoading(false);
|
||||||
|
setChatActivity(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOlderMessages() {
|
||||||
|
if (state.historyLoading || !state.historyPage?.hasMore || !state.activeChatId) return;
|
||||||
|
const chatId = state.activeChatId, requestId = state.chatOpenRequest, page = state.historyPage;
|
||||||
|
state.historyLoading = true;
|
||||||
|
updateHistoryControls();
|
||||||
|
try {
|
||||||
|
const result = await get(`/chats/${chatId}/messages?before=${page.before}&leaf=${page.leafId}`, {retries: 0});
|
||||||
|
if (chatId !== state.activeChatId || requestId !== state.chatOpenRequest) return;
|
||||||
|
if (result.error) { setNotice(result.error, "error"); return result; }
|
||||||
|
const oldHeight = messagesDiv.scrollHeight, oldTop = messagesDiv.scrollTop;
|
||||||
|
const existing = new Set(state.currentMessages.map(message => message.id));
|
||||||
|
state.currentMessages = [...result.messages.filter(message => !existing.has(message.id)), ...state.currentMessages];
|
||||||
|
state.historyPage = {...result.pagination, hasNewer: page.hasNewer};
|
||||||
|
renderMessages({preserveScroll: true});
|
||||||
|
messagesDiv.scrollTop = oldTop + messagesDiv.scrollHeight - oldHeight;
|
||||||
|
} finally {
|
||||||
|
if (state.historyPage === page || requestId === state.chatOpenRequest) {
|
||||||
|
state.historyLoading = false;
|
||||||
|
updateHistoryControls();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadChatSessions() {
|
||||||
|
setChatLoading(true, "Loading chats...");
|
||||||
|
const res = await get("/chats");
|
||||||
|
if (res.error) {
|
||||||
|
setChatLoading(false);
|
||||||
|
return setNotice(res.error, "error");
|
||||||
|
}
|
||||||
|
state.chatSessions = res.chats || []; renderChatList();
|
||||||
|
if (state.activeChatId && getChatById(state.activeChatId)) {
|
||||||
|
updateWorkspaceCopy();
|
||||||
|
setChatLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (location.pathname.endsWith("/new") && !state.activeChatId) { state.currentMessages = []; renderMessages(); updateWorkspaceCopy(); updateSendState(); setChatLoading(false); return; }
|
||||||
|
if (requestedChatId && !getChatById(requestedChatId) && !state.activeChatId) { setChatLoading(false); return setNotice("Conversation not found. Choose a chat from the sidebar or start a new one.", "error"); }
|
||||||
|
if (state.chatSessions.length) {
|
||||||
|
const preferredChatId = state.activeChatId && getChatById(state.activeChatId) ? state.activeChatId : (requestedChatId && getChatById(requestedChatId)) ? requestedChatId : state.chatSessions[0].id;
|
||||||
|
setChatLoading(false);
|
||||||
|
return setActiveChat(preferredChatId);
|
||||||
|
}
|
||||||
|
state.activeChatId = null;
|
||||||
|
state.currentMessages = [];
|
||||||
|
renderMessages();
|
||||||
|
updateWorkspaceCopy();
|
||||||
|
updateSendState();
|
||||||
|
setChatLoading(false);
|
||||||
|
return setNotice("Pick a starting action to begin.", "success");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchChatSessions(query, requestId) {
|
||||||
|
const res = await get(`/chats/search?q=${encodeURIComponent(query)}`);
|
||||||
|
if (requestId !== state.chatSearchRequest || chatSearchInput.value.trim() !== query) return;
|
||||||
|
if (res.error) {
|
||||||
|
state.chatSearchError = "Unable to search chats.";
|
||||||
|
renderChatList();
|
||||||
|
setNotice(res.error, "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.chatSearchResults = res.chats || [];
|
||||||
|
renderChatList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleChatSearch() {
|
||||||
|
clearTimeout(state.chatSearchTimer);
|
||||||
|
const requestId = ++state.chatSearchRequest;
|
||||||
|
const query = chatSearchInput.value.trim();
|
||||||
|
state.chatSearchResults = null;
|
||||||
|
state.chatSearchError = "";
|
||||||
|
if (!query) {
|
||||||
|
renderChatList();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderChatList();
|
||||||
|
state.chatSearchTimer = setTimeout(() => { void searchChatSessions(query, requestId); }, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createNewChat({preserveComposer = false, signal} = {}) {
|
||||||
|
const previousChatId = state.activeChatId;
|
||||||
|
setChatLoading(true, "Creating chat...");
|
||||||
|
setChatActivity(true, {
|
||||||
|
eyebrow: "Creating chat", title: "Opening a new conversation",
|
||||||
|
detail: "Setting up a fresh chat so you can start sending messages."
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const res = await post("/chats", {title: "New chat", personalityId: getPersonalityId()}, {signal});
|
||||||
|
if (res.error || !res.chat) {
|
||||||
|
const error = res.error || "Unable to create chat.";
|
||||||
|
setNotice(error, "error");
|
||||||
|
return {error};
|
||||||
|
}
|
||||||
|
state.chatSessions.unshift(res.chat);
|
||||||
|
await loadSummary(); renderChatList();
|
||||||
|
if (!signal?.aborted && state.activeChatId === previousChatId) {
|
||||||
|
const opened = await setActiveChat(res.chat.id, {preserveComposer});
|
||||||
|
if (opened?.error) return opened;
|
||||||
|
msgInput.focus();
|
||||||
|
}
|
||||||
|
setNotice("New chat created.", "success");
|
||||||
|
return res.chat;
|
||||||
|
} finally {
|
||||||
|
setChatLoading(false); setChatActivity(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getChatById,
|
||||||
|
openCreatedChat,
|
||||||
|
setActiveChat,
|
||||||
|
loadChatSessions,
|
||||||
|
scheduleChatSearch,
|
||||||
|
createNewChat,
|
||||||
|
loadOlderMessages
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import {openModal, closeModal} from "./modalFocus.js";
|
||||||
|
|
||||||
|
export function createConversationPanel(id, title) {
|
||||||
|
const overlay = document.createElement("div");
|
||||||
|
overlay.id = id;
|
||||||
|
overlay.className = "modal-overlay hidden";
|
||||||
|
overlay.innerHTML = `<section class="modal-card conversation-panel" role="dialog" aria-modal="true" aria-labelledby="${id}Title" tabindex="-1">
|
||||||
|
<div class="modal-header"><h3 id="${id}Title"></h3><button type="button" class="secondary-action">Close</button></div>
|
||||||
|
<p class="panel-notice status" role="status"></p><div class="panel-content"></div></section>`;
|
||||||
|
overlay.querySelector("h3").textContent = title;
|
||||||
|
document.body.append(overlay);
|
||||||
|
const close = () => closeModal(overlay);
|
||||||
|
overlay.querySelector("button").onclick = close;
|
||||||
|
overlay.onclick = event => { if (event.target === overlay) close(); };
|
||||||
|
return {element: overlay, content: overlay.querySelector(".panel-content"), close,
|
||||||
|
open() { if (overlay.classList.contains("hidden")) openModal(overlay); },
|
||||||
|
notice(text = "", error = false) {
|
||||||
|
const notice = overlay.querySelector(".panel-notice");
|
||||||
|
notice.textContent = text;
|
||||||
|
notice.classList.toggle("error", error);
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function actionButton(label, action) {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "secondary-action";
|
||||||
|
button.textContent = label;
|
||||||
|
button.onclick = async () => { button.disabled = true; try { await action(); } finally { button.disabled = false; } };
|
||||||
|
return button;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/** Shared conversation data; controllers receive an instance rather than owning globals. */
|
||||||
|
export function createConversationState() {
|
||||||
|
return {
|
||||||
|
isProcessing: false,
|
||||||
|
activeChatId: null,
|
||||||
|
editingPersonaId: null,
|
||||||
|
editingPersonaType: "assistant",
|
||||||
|
currentUsername: "",
|
||||||
|
regenerationChatId: null,
|
||||||
|
chatSessions: [],
|
||||||
|
currentMessages: [],
|
||||||
|
historyPage: null,
|
||||||
|
historyLoading: false,
|
||||||
|
assistantPersonas: [],
|
||||||
|
userPersonas: [],
|
||||||
|
activeUserPersonaId: null,
|
||||||
|
currentSummary: null,
|
||||||
|
publishedPersonaIds: new Set(),
|
||||||
|
roleplayPresetCharacterId: null,
|
||||||
|
roleplayStarterStep: 1,
|
||||||
|
messageRetryState: new Map(),
|
||||||
|
chatSearchResults: null,
|
||||||
|
chatSearchError: "",
|
||||||
|
chatSearchTimer: null,
|
||||||
|
chatSearchRequest: 0,
|
||||||
|
chatOpenRequest: 0,
|
||||||
|
chatDrafts: new Map(),
|
||||||
|
sendingChatId: null
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Bündelt Referenzen auf die HTML-Elemente der Chat-Oberfläche.
|
||||||
|
*/
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
const chatDiv = $("chat");
|
const chatDiv = $("chat");
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/** Text and uploaded attachment references only, scoped to the signed-in account. */
|
||||||
|
export function createDraftStore(username, {storage = globalThis.localStorage, onError = () => {}} = {}) {
|
||||||
|
const key = `krishd-drafts-v1:${encodeURIComponent(username)}`;
|
||||||
|
let records = {};
|
||||||
|
let warned = false;
|
||||||
|
const report = () => { if (!warned) onError("Draft recovery is unavailable in this browser. Keep this page open to retain your draft."); warned = true; };
|
||||||
|
try {
|
||||||
|
const saved = JSON.parse(storage.getItem(key) || "{}");
|
||||||
|
if (saved && typeof saved === "object" && !Array.isArray(saved)) records = saved;
|
||||||
|
} catch { report(); }
|
||||||
|
const normalizeId = id => id == null ? "new" : String(id);
|
||||||
|
const save = () => {
|
||||||
|
for (const [id, record] of Object.entries(records)) {
|
||||||
|
if (!record.text && !record.attachments?.length) delete records[id];
|
||||||
|
}
|
||||||
|
try { storage.setItem(key, JSON.stringify(records)); } catch { report(); }
|
||||||
|
};
|
||||||
|
const update = (id, changes) => {
|
||||||
|
id = normalizeId(id);
|
||||||
|
records[id] = {...records[id], ...changes};
|
||||||
|
save();
|
||||||
|
};
|
||||||
|
const textDrafts = new Map(Object.entries(records).filter(([, record]) => typeof record?.text === "string")
|
||||||
|
.map(([id, record]) => [id === "new" ? id : Number(id), record.text]));
|
||||||
|
const set = textDrafts.set.bind(textDrafts), remove = textDrafts.delete.bind(textDrafts);
|
||||||
|
textDrafts.set = (id, text) => { update(id, {text}); return set(id, text); };
|
||||||
|
textDrafts.delete = id => { update(id, {text: ""}); return remove(id); };
|
||||||
|
return {
|
||||||
|
textDrafts,
|
||||||
|
attachments(id) {
|
||||||
|
const items = records[normalizeId(id)]?.attachments;
|
||||||
|
return Array.isArray(items) ? items.filter(item => typeof item?.id === "string" && /^[a-f0-9-]{36}$/.test(item.id)) : [];
|
||||||
|
},
|
||||||
|
setAttachments(id, attachments) { update(id, {attachments}); },
|
||||||
|
clear(id) { delete records[normalizeId(id)]; remove(id); save(); }
|
||||||
|
};
|
||||||
|
}
|
||||||
+5
-688
@@ -1,688 +1,5 @@
|
|||||||
import { get, post, put } from "./api.js?v=20260909-account-controls";
|
/*
|
||||||
import { initializeAppearance } from "./themeController.js?v=20260909-account-controls";
|
* Compatibility entrypoint for deployments that still validate the retired
|
||||||
import { clearStatus, setStatus } from "./statusNotice.js?v=20260909-account-controls";
|
* marketplace asset. The marketplace UI now lives in the personas screen.
|
||||||
import { populatePersonaForm, readPersonaForm } from "./personaForm.js?v=20260909-account-controls";
|
*/
|
||||||
|
export {};
|
||||||
const $ = (id) => document.getElementById(id);
|
|
||||||
const marketPersonaList = $("marketPersonaList");
|
|
||||||
const marketUserPersonaList = $("marketUserPersonaList");
|
|
||||||
const refreshMarketBtn = $("refreshMarket");
|
|
||||||
const marketStatus = $("marketStatus");
|
|
||||||
const marketSearchInput = $("marketSearch");
|
|
||||||
const marketAssistantSort = $("marketAssistantSort");
|
|
||||||
const marketUserSort = $("marketUserSort");
|
|
||||||
const marketViewButtons = document.querySelectorAll("[data-market-view]");
|
|
||||||
const marketPanels = document.querySelectorAll("[data-market-panel]");
|
|
||||||
const marketCreateAiCharacterBtn = $("marketCreateAiCharacter");
|
|
||||||
const marketCreateUserPersonaBtn = $("marketCreateUserPersona");
|
|
||||||
const marketPreviewModal = $("marketPreviewModal");
|
|
||||||
const marketPreviewTitle = $("marketPreviewTitle");
|
|
||||||
const marketPreviewName = $("marketPreviewName");
|
|
||||||
const marketPreviewMeta = $("marketPreviewMeta");
|
|
||||||
const marketPreviewBody = $("marketPreviewBody");
|
|
||||||
const marketPreviewPronouns = $("marketPreviewPronouns");
|
|
||||||
const marketPreviewAppearance = $("marketPreviewAppearance");
|
|
||||||
const marketPreviewBackground = $("marketPreviewBackground");
|
|
||||||
const marketPreviewDetails = $("marketPreviewDetails");
|
|
||||||
const marketPreviewExampleDialogues = $("marketPreviewExampleDialogues");
|
|
||||||
const marketPreviewIntro = $("marketPreviewIntro");
|
|
||||||
const marketPreviewUserPersonaField = $("marketPreviewUserPersonaField");
|
|
||||||
const marketPreviewUserPersonaSelect = $("marketPreviewUserPersonaSelect");
|
|
||||||
const marketPreviewScenarioField = $("marketPreviewScenarioField");
|
|
||||||
const marketPreviewScenario = $("marketPreviewScenario");
|
|
||||||
const marketPreviewStepOne = $("marketPreviewStepOne");
|
|
||||||
const marketPreviewStepTwo = $("marketPreviewStepTwo");
|
|
||||||
const marketPreviewConfirm = $("marketPreviewConfirm");
|
|
||||||
const marketPreviewClose = $("marketPreviewClose");
|
|
||||||
const marketPersonaModal = $("marketPersonaModal");
|
|
||||||
const marketPersonaClose = $("marketPersonaClose");
|
|
||||||
const marketPersonaForm = $("marketPersonaForm");
|
|
||||||
const marketPersonaFormTitle = $("marketPersonaFormTitle");
|
|
||||||
const marketPersonaFormNotice = $("marketPersonaFormNotice");
|
|
||||||
const marketPersonaName = $("marketPersonaName");
|
|
||||||
const marketPersonaPronouns = $("marketPersonaPronouns");
|
|
||||||
const marketPersonaAppearance = $("marketPersonaAppearance");
|
|
||||||
const marketPersonaBackground = $("marketPersonaBackground");
|
|
||||||
const marketPersonaDetails = $("marketPersonaDetails");
|
|
||||||
const marketPersonaExampleDialogues = $("marketPersonaExampleDialogues");
|
|
||||||
const marketActivityOverlay = $("marketActivityOverlay");
|
|
||||||
const marketActivityEyebrow = $("marketActivityEyebrow");
|
|
||||||
const marketActivityTitle = $("marketActivityTitle");
|
|
||||||
const marketActivityDetail = $("marketActivityDetail");
|
|
||||||
const marketPopupModal = $("marketPopupModal");
|
|
||||||
const marketPopupClose = $("marketPopupClose");
|
|
||||||
const marketPopupCancel = $("marketPopupCancel");
|
|
||||||
const marketPopupConfirm = $("marketPopupConfirm");
|
|
||||||
const marketPopupTitle = $("marketPopupTitle");
|
|
||||||
const marketPopupEyebrow = $("marketPopupEyebrow");
|
|
||||||
const marketPopupDescription = $("marketPopupDescription");
|
|
||||||
const marketPopupField = $("marketPopupField");
|
|
||||||
const marketPopupInputLabel = $("marketPopupInputLabel");
|
|
||||||
const marketPopupInput = $("marketPopupInput");
|
|
||||||
const brandNameTargets = document.querySelectorAll("[data-brand-name]");
|
|
||||||
const marketPersonaFields = {
|
|
||||||
name: marketPersonaName,
|
|
||||||
pronouns: marketPersonaPronouns,
|
|
||||||
appearance: marketPersonaAppearance,
|
|
||||||
background: marketPersonaBackground,
|
|
||||||
details: marketPersonaDetails,
|
|
||||||
exampleDialogues: marketPersonaExampleDialogues
|
|
||||||
};
|
|
||||||
|
|
||||||
const validMarketSorts = new Set(["best", "newest", "most_favorited", "most_popular", "top_rated", "alphabetical"]);
|
|
||||||
let marketPersonasByType = { assistant: [], user: [] };
|
|
||||||
let assistantPersonas = [];
|
|
||||||
let userPersonas = [];
|
|
||||||
let pendingMarketPersona = null;
|
|
||||||
let editingPersonaId = null;
|
|
||||||
let activeUserPersonaId = null;
|
|
||||||
let activeMarketView = "ai-characters";
|
|
||||||
let marketSorts = { assistant: "best", user: "best" };
|
|
||||||
let marketActivityDepth = 0;
|
|
||||||
let marketPreviewStep = 1;
|
|
||||||
let marketPopupResolver = null;
|
|
||||||
|
|
||||||
function setMarketStatus(message, state = "") {
|
|
||||||
setStatus(marketStatus, message, state, "status persona-status");
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeMarketPopup(value = null) {
|
|
||||||
marketPopupModal.classList.add("hidden");
|
|
||||||
if (marketPopupResolver) {
|
|
||||||
const resolve = marketPopupResolver;
|
|
||||||
marketPopupResolver = null;
|
|
||||||
resolve(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function promptMarketPopup({
|
|
||||||
eyebrow = "Action",
|
|
||||||
title = "Enter a value",
|
|
||||||
description = "",
|
|
||||||
label = "Value",
|
|
||||||
value = "",
|
|
||||||
placeholder = "",
|
|
||||||
confirmLabel = "Confirm"
|
|
||||||
} = {}) {
|
|
||||||
marketPopupEyebrow.textContent = eyebrow;
|
|
||||||
marketPopupTitle.textContent = title;
|
|
||||||
marketPopupDescription.textContent = description;
|
|
||||||
marketPopupInputLabel.textContent = label;
|
|
||||||
marketPopupInput.value = value;
|
|
||||||
marketPopupInput.placeholder = placeholder;
|
|
||||||
marketPopupConfirm.textContent = confirmLabel;
|
|
||||||
marketPopupField.classList.remove("hidden");
|
|
||||||
marketPopupModal.classList.remove("hidden");
|
|
||||||
marketPopupInput.focus();
|
|
||||||
marketPopupInput.select();
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
marketPopupResolver = resolve;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function setPersonaFormNotice(message = "", state = "") {
|
|
||||||
if (!message) {
|
|
||||||
clearStatus(marketPersonaFormNotice);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setStatus(marketPersonaFormNotice, message, state);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setMarketActivity(active, {
|
|
||||||
eyebrow = "Please wait",
|
|
||||||
title = "Opening roleplay",
|
|
||||||
detail = "The AI character is preparing the first message."
|
|
||||||
} = {}) {
|
|
||||||
if (!marketActivityOverlay || !marketActivityEyebrow || !marketActivityTitle || !marketActivityDetail) return;
|
|
||||||
if (active) {
|
|
||||||
marketActivityDepth += 1;
|
|
||||||
marketActivityEyebrow.textContent = eyebrow;
|
|
||||||
marketActivityTitle.textContent = title;
|
|
||||||
marketActivityDetail.textContent = detail;
|
|
||||||
marketActivityOverlay.classList.remove("hidden");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
marketActivityDepth = Math.max(0, marketActivityDepth - 1);
|
|
||||||
if (marketActivityDepth === 0) {
|
|
||||||
marketActivityOverlay.classList.add("hidden");
|
|
||||||
marketActivityEyebrow.textContent = "Please wait";
|
|
||||||
marketActivityTitle.textContent = "Opening roleplay";
|
|
||||||
marketActivityDetail.textContent = "The AI character is preparing the first message.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setMarketView(view) {
|
|
||||||
const normalizedView = view === "user-personas" ? "user-personas" : "ai-characters";
|
|
||||||
activeMarketView = normalizedView;
|
|
||||||
marketViewButtons.forEach((item) => {
|
|
||||||
const isActive = item.dataset.marketView === normalizedView;
|
|
||||||
item.classList.toggle("active", isActive);
|
|
||||||
item.setAttribute("aria-selected", String(isActive));
|
|
||||||
});
|
|
||||||
marketPanels.forEach((panel) => {
|
|
||||||
const isActive = panel.dataset.marketPanel === normalizedView;
|
|
||||||
panel.classList.toggle("active", isActive);
|
|
||||||
panel.setAttribute("aria-hidden", String(!isActive));
|
|
||||||
});
|
|
||||||
syncMarketQuery();
|
|
||||||
applyMarketFilter();
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeMarketSort(sort) {
|
|
||||||
const normalized = String(sort || "best").trim().toLowerCase().replace(/-/g, "_");
|
|
||||||
return validMarketSorts.has(normalized) ? normalized : "best";
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncMarketQuery() {
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
params.set("view", activeMarketView);
|
|
||||||
params.set("assistantSort", marketSorts.assistant);
|
|
||||||
params.set("userSort", marketSorts.user);
|
|
||||||
history.replaceState(null, "", `${window.location.pathname}?${params.toString()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setMarketSort(personaType, sort, { reload = true } = {}) {
|
|
||||||
const normalizedType = personaType === "user" ? "user" : "assistant";
|
|
||||||
const normalizedSort = normalizeMarketSort(sort);
|
|
||||||
marketSorts[normalizedType] = normalizedSort;
|
|
||||||
if (normalizedType === "assistant" && marketAssistantSort) marketAssistantSort.value = normalizedSort;
|
|
||||||
if (normalizedType === "user" && marketUserSort) marketUserSort.value = normalizedSort;
|
|
||||||
syncMarketQuery();
|
|
||||||
if (reload) {
|
|
||||||
void loadMarketPersonas();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPersonaMeta(persona) {
|
|
||||||
const creator = persona.creator_username || "You";
|
|
||||||
const pronouns = persona.pronouns ? `Pronouns: ${persona.pronouns}` : "Pronouns: n/a";
|
|
||||||
return `${creator} | ${pronouns}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatPersonaField(value) {
|
|
||||||
return value && value.trim() ? value.trim() : "Not provided.";
|
|
||||||
}
|
|
||||||
|
|
||||||
function setCollapsiblePreviewText(element, value, maxLength = 220) {
|
|
||||||
const text = formatPersonaField(value);
|
|
||||||
element.dataset.fullText = text;
|
|
||||||
let toggle = element.nextElementSibling;
|
|
||||||
if (!toggle || !toggle.classList.contains("market-preview-toggle")) {
|
|
||||||
toggle = document.createElement("button");
|
|
||||||
toggle.type = "button";
|
|
||||||
toggle.className = "market-preview-toggle hidden";
|
|
||||||
element.insertAdjacentElement("afterend", toggle);
|
|
||||||
toggle.addEventListener("click", () => {
|
|
||||||
const isExpanded = element.dataset.expanded === "true";
|
|
||||||
element.dataset.expanded = isExpanded ? "false" : "true";
|
|
||||||
if (element.dataset.expanded === "true") {
|
|
||||||
element.textContent = element.dataset.fullText;
|
|
||||||
toggle.textContent = "Show less";
|
|
||||||
} else {
|
|
||||||
element.textContent = `${element.dataset.fullText.slice(0, maxLength).trimEnd()}...`;
|
|
||||||
toggle.textContent = "Show more";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const shouldCollapse = text !== "Not provided." && text.length > maxLength;
|
|
||||||
element.dataset.expanded = "false";
|
|
||||||
if (shouldCollapse) {
|
|
||||||
element.textContent = `${text.slice(0, maxLength).trimEnd()}...`;
|
|
||||||
toggle.textContent = "Show more";
|
|
||||||
toggle.classList.remove("hidden");
|
|
||||||
} else {
|
|
||||||
element.textContent = text;
|
|
||||||
toggle.classList.add("hidden");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeMarketPreview() {
|
|
||||||
marketPreviewModal.classList.add("hidden");
|
|
||||||
pendingMarketPersona = null;
|
|
||||||
marketPreviewStep = 1;
|
|
||||||
marketPreviewUserPersonaSelect.innerHTML = "";
|
|
||||||
marketPreviewScenario.value = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderMarketPreviewStep() {
|
|
||||||
const isScenarioStep = marketPreviewStep === 2;
|
|
||||||
const isAssistant = pendingMarketPersona?.persona_type === "assistant";
|
|
||||||
if (marketPreviewBody) marketPreviewBody.classList.toggle("hidden", isScenarioStep && isAssistant);
|
|
||||||
if (marketPreviewStepOne) marketPreviewStepOne.classList.toggle("hidden", isScenarioStep || pendingMarketPersona?.persona_type !== "assistant");
|
|
||||||
if (marketPreviewStepTwo) marketPreviewStepTwo.classList.toggle("hidden", !isScenarioStep || pendingMarketPersona?.persona_type !== "assistant");
|
|
||||||
if (marketPreviewTitle) {
|
|
||||||
marketPreviewTitle.textContent = isAssistant && isScenarioStep ? "Set the opening scene" : "Persona details";
|
|
||||||
}
|
|
||||||
if (marketPreviewIntro) {
|
|
||||||
marketPreviewIntro.textContent = pendingMarketPersona?.persona_type === "assistant"
|
|
||||||
? (isScenarioStep
|
|
||||||
? "Add an optional custom scenario. If left empty, the AI Character will invent its own opening scene."
|
|
||||||
: "Choose who you are in this roleplay before moving on to the opening scene.")
|
|
||||||
: "Review this persona and choose how you want to use it.";
|
|
||||||
}
|
|
||||||
if (marketPreviewConfirm) {
|
|
||||||
marketPreviewConfirm.textContent = pendingMarketPersona?.persona_type !== "assistant" ? "Collect persona" : "Start roleplay";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function populateUserPersonaChoices() {
|
|
||||||
marketPreviewUserPersonaSelect.innerHTML = "";
|
|
||||||
|
|
||||||
const promptOption = document.createElement("option");
|
|
||||||
promptOption.value = "";
|
|
||||||
promptOption.textContent = "Choose who you are in this roleplay";
|
|
||||||
promptOption.disabled = true;
|
|
||||||
promptOption.selected = true;
|
|
||||||
marketPreviewUserPersonaSelect.appendChild(promptOption);
|
|
||||||
|
|
||||||
const selfOption = document.createElement("option");
|
|
||||||
selfOption.value = "self";
|
|
||||||
selfOption.textContent = "Yourself (no persona)";
|
|
||||||
marketPreviewUserPersonaSelect.appendChild(selfOption);
|
|
||||||
|
|
||||||
userPersonas.forEach((persona) => {
|
|
||||||
const option = document.createElement("option");
|
|
||||||
option.value = String(persona.id);
|
|
||||||
option.textContent = persona.name;
|
|
||||||
option.selected = persona.id === activeUserPersonaId;
|
|
||||||
marketPreviewUserPersonaSelect.appendChild(option);
|
|
||||||
});
|
|
||||||
|
|
||||||
const createOption = document.createElement("option");
|
|
||||||
createOption.value = "create_new";
|
|
||||||
createOption.textContent = "Create new persona";
|
|
||||||
marketPreviewUserPersonaSelect.appendChild(createOption);
|
|
||||||
}
|
|
||||||
|
|
||||||
function openMarketPreview(persona) {
|
|
||||||
pendingMarketPersona = persona;
|
|
||||||
marketPreviewStep = 1;
|
|
||||||
marketPreviewName.textContent = persona.name;
|
|
||||||
marketPreviewMeta.textContent = buildPersonaMeta(persona);
|
|
||||||
setCollapsiblePreviewText(marketPreviewPronouns, persona.pronouns, 80);
|
|
||||||
setCollapsiblePreviewText(marketPreviewAppearance, persona.appearance);
|
|
||||||
setCollapsiblePreviewText(marketPreviewBackground, persona.background);
|
|
||||||
setCollapsiblePreviewText(marketPreviewDetails, persona.details);
|
|
||||||
setCollapsiblePreviewText(marketPreviewExampleDialogues, persona.example_dialogues);
|
|
||||||
|
|
||||||
const isAssistant = persona.persona_type === "assistant";
|
|
||||||
marketPreviewUserPersonaField.classList.toggle("hidden", !isAssistant);
|
|
||||||
if (isAssistant) {
|
|
||||||
populateUserPersonaChoices();
|
|
||||||
marketPreviewConfirm.classList.add("hidden");
|
|
||||||
} else {
|
|
||||||
marketPreviewConfirm.classList.remove("hidden");
|
|
||||||
}
|
|
||||||
renderMarketPreviewStep();
|
|
||||||
marketPreviewModal.classList.remove("hidden");
|
|
||||||
}
|
|
||||||
|
|
||||||
function openPersonaForm(persona = null) {
|
|
||||||
editingPersonaId = persona ? persona.id : null;
|
|
||||||
marketPersonaFormTitle.textContent = persona ? "Edit AI Character" : "Create AI Character";
|
|
||||||
populatePersonaForm(marketPersonaFields, persona || {});
|
|
||||||
setPersonaFormNotice("");
|
|
||||||
marketPersonaModal.classList.remove("hidden");
|
|
||||||
marketPersonaName.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
function closePersonaForm() {
|
|
||||||
marketPersonaModal.classList.add("hidden");
|
|
||||||
editingPersonaId = null;
|
|
||||||
setPersonaFormNotice("");
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderMarketList(items, listElement, type) {
|
|
||||||
listElement.innerHTML = "";
|
|
||||||
if (!items.length) {
|
|
||||||
const empty = document.createElement("p");
|
|
||||||
empty.className = "status persona-status";
|
|
||||||
empty.textContent = type === "assistant" ? "No AI Characters found." : "No User Personas found.";
|
|
||||||
listElement.appendChild(empty);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
items.forEach((persona) => {
|
|
||||||
const item = document.createElement("div");
|
|
||||||
const titleRow = document.createElement("div");
|
|
||||||
const title = document.createElement("h4");
|
|
||||||
const tag = document.createElement("span");
|
|
||||||
const meta = document.createElement("p");
|
|
||||||
const stats = document.createElement("p");
|
|
||||||
const actions = document.createElement("div");
|
|
||||||
const detailsBtn = document.createElement("button");
|
|
||||||
const favoriteBtn = document.createElement("button");
|
|
||||||
|
|
||||||
item.className = "persona-item";
|
|
||||||
titleRow.className = "persona-title-row";
|
|
||||||
actions.className = "persona-item-actions";
|
|
||||||
tag.className = `persona-role-tag ${type === "assistant" ? "ai" : "user"}`;
|
|
||||||
tag.textContent = type === "assistant" ? "AI Character" : "User";
|
|
||||||
title.textContent = persona.name;
|
|
||||||
meta.textContent = buildPersonaMeta(persona);
|
|
||||||
const tags = Array.isArray(persona.tags) && persona.tags.length ? `Tags: ${persona.tags.join(", ")}` : "Tags: none";
|
|
||||||
const rating = persona.rating_count ? `${Number(persona.rating_average || 0).toFixed(1)}★` : "Unrated";
|
|
||||||
stats.className = "subtitle";
|
|
||||||
stats.textContent = `${tags} | Favorites: ${persona.favorite_count || 0} | Uses: ${persona.usage_count || 0} | Rating: ${rating}`;
|
|
||||||
|
|
||||||
detailsBtn.type = "button";
|
|
||||||
detailsBtn.textContent = "Preview";
|
|
||||||
detailsBtn.addEventListener("click", () => openMarketPreview(persona));
|
|
||||||
actions.appendChild(detailsBtn);
|
|
||||||
|
|
||||||
favoriteBtn.type = "button";
|
|
||||||
favoriteBtn.className = persona.is_favorite ? "icon-button market-favorite-button active" : "icon-button market-favorite-button";
|
|
||||||
favoriteBtn.textContent = persona.is_favorite ? "♥" : "♡";
|
|
||||||
favoriteBtn.title = persona.is_favorite ? "Unfavorite persona" : "Favorite persona";
|
|
||||||
favoriteBtn.setAttribute("aria-label", favoriteBtn.title);
|
|
||||||
favoriteBtn.setAttribute("aria-pressed", persona.is_favorite ? "true" : "false");
|
|
||||||
favoriteBtn.addEventListener("click", () => { void toggleFavorite(persona.id); });
|
|
||||||
actions.appendChild(favoriteBtn);
|
|
||||||
|
|
||||||
const rateBtn = document.createElement("button");
|
|
||||||
rateBtn.type = "button";
|
|
||||||
rateBtn.textContent = "Rate";
|
|
||||||
rateBtn.addEventListener("click", () => { void ratePersona(persona.id); });
|
|
||||||
actions.appendChild(rateBtn);
|
|
||||||
|
|
||||||
const reportBtn = document.createElement("button");
|
|
||||||
reportBtn.type = "button";
|
|
||||||
reportBtn.textContent = "Report";
|
|
||||||
reportBtn.addEventListener("click", () => { void reportPersona(persona.id); });
|
|
||||||
actions.appendChild(reportBtn);
|
|
||||||
|
|
||||||
if (type !== "assistant") {
|
|
||||||
const quickActionBtn = document.createElement("button");
|
|
||||||
quickActionBtn.type = "button";
|
|
||||||
quickActionBtn.textContent = "Collect";
|
|
||||||
quickActionBtn.addEventListener("click", () => {
|
|
||||||
void collectMarketPersona(persona.id);
|
|
||||||
});
|
|
||||||
actions.appendChild(quickActionBtn);
|
|
||||||
}
|
|
||||||
|
|
||||||
titleRow.append(title, tag);
|
|
||||||
item.append(titleRow, meta, stats, actions);
|
|
||||||
listElement.appendChild(item);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterMarketPersonas(items) {
|
|
||||||
const query = marketSearchInput.value.trim().toLowerCase();
|
|
||||||
if (!query) return items;
|
|
||||||
return items.filter((persona) =>
|
|
||||||
[persona.name, persona.creator_username, persona.pronouns, persona.appearance, persona.background, persona.details, persona.example_dialogues]
|
|
||||||
.some((field) => field && field.toLowerCase().includes(query))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyMarketFilter() {
|
|
||||||
const assistantItems = filterMarketPersonas(marketPersonasByType.assistant);
|
|
||||||
const userItems = filterMarketPersonas(marketPersonasByType.user);
|
|
||||||
renderMarketList(assistantItems, marketPersonaList, "assistant");
|
|
||||||
renderMarketList(userItems, marketUserPersonaList, "user");
|
|
||||||
|
|
||||||
if (marketSearchInput.value.trim() && !assistantItems.length && !userItems.length) {
|
|
||||||
setMarketStatus("No personas match your search.", "error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (activeMarketView === "ai-characters") {
|
|
||||||
setMarketStatus(`${assistantItems.length} AI Character${assistantItems.length === 1 ? "" : "s"} ready for roleplay.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (activeMarketView === "user-personas") {
|
|
||||||
setMarketStatus(`${userItems.length} User Persona${userItems.length === 1 ? "" : "s"} available to collect.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setMarketStatus(`${assistantItems.length + userItems.length} persona${assistantItems.length + userItems.length === 1 ? "" : "s"} shown.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshMarket() {
|
|
||||||
const [assistantRes, userRes] = await Promise.all([
|
|
||||||
get(`/personas/market?personaType=assistant&sort=${encodeURIComponent(marketSorts.assistant)}`),
|
|
||||||
get(`/personas/market?personaType=user&sort=${encodeURIComponent(marketSorts.user)}`)
|
|
||||||
]);
|
|
||||||
if (assistantRes.error || userRes.error) {
|
|
||||||
setMarketStatus(assistantRes.error || userRes.error, "error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
marketPersonasByType = {
|
|
||||||
assistant: assistantRes.personas || [],
|
|
||||||
user: userRes.personas || []
|
|
||||||
};
|
|
||||||
applyMarketFilter();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleFavorite(marketId) {
|
|
||||||
const res = await post(`/personas/market/${marketId}/favorite`, {});
|
|
||||||
if (res.error) return setMarketStatus(res.error, "error");
|
|
||||||
await refreshMarket();
|
|
||||||
setMarketStatus(res.favorite ? "Favorited persona." : "Removed favorite.", "success");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ratePersona(marketId) {
|
|
||||||
const raw = await promptMarketPopup({
|
|
||||||
eyebrow: "Rating",
|
|
||||||
title: "Rate this persona",
|
|
||||||
description: "Enter a whole number from 1 to 5.",
|
|
||||||
label: "Rating",
|
|
||||||
value: "5",
|
|
||||||
placeholder: "5",
|
|
||||||
confirmLabel: "Save rating"
|
|
||||||
});
|
|
||||||
if (raw === null) return;
|
|
||||||
const rating = Number(raw);
|
|
||||||
if (!rating) return;
|
|
||||||
const res = await post(`/personas/market/${marketId}/rate`, { rating });
|
|
||||||
if (res.error) return setMarketStatus(res.error, "error");
|
|
||||||
await refreshMarket();
|
|
||||||
setMarketStatus("Rating saved.", "success");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function reportPersona(marketId) {
|
|
||||||
const reason = await promptMarketPopup({
|
|
||||||
eyebrow: "Report",
|
|
||||||
title: "Report persona",
|
|
||||||
description: "Describe the main issue.",
|
|
||||||
label: "Reason",
|
|
||||||
placeholder: "Abusive content",
|
|
||||||
confirmLabel: "Continue"
|
|
||||||
});
|
|
||||||
if (!reason) return;
|
|
||||||
const details = await promptMarketPopup({
|
|
||||||
eyebrow: "Report",
|
|
||||||
title: "Extra details",
|
|
||||||
description: "Add optional context for the report.",
|
|
||||||
label: "Details",
|
|
||||||
placeholder: "Optional",
|
|
||||||
confirmLabel: "Submit report"
|
|
||||||
});
|
|
||||||
if (details === null) return;
|
|
||||||
const res = await post(`/personas/market/${marketId}/report`, { reason, details });
|
|
||||||
if (res.error) return setMarketStatus(res.error, "error");
|
|
||||||
setMarketStatus("Report submitted.", "success");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadOwnedPersonas() {
|
|
||||||
const res = await get("/personas");
|
|
||||||
if (res.error) return false;
|
|
||||||
assistantPersonas = res.assistantPersonas || [];
|
|
||||||
userPersonas = res.userPersonas || [];
|
|
||||||
activeUserPersonaId = res.activeUserPersonaId || null;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadMarketPersonas() {
|
|
||||||
setMarketStatus("Loading personas...");
|
|
||||||
const ownedOk = await loadOwnedPersonas();
|
|
||||||
if (!ownedOk) {
|
|
||||||
setMarketStatus("Unable to load your personas.", "error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await refreshMarket();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function collectMarketPersona(marketId) {
|
|
||||||
setMarketStatus("Collecting User Persona...");
|
|
||||||
const res = await post(`/personas/market/${marketId}/collect`, { equip: true });
|
|
||||||
if (res.error) {
|
|
||||||
setMarketStatus(res.error, "error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await loadMarketPersonas();
|
|
||||||
setMarketStatus("User Persona collected.", "success");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startMarketPersonaChat(marketId, userPersonaSelection, scenarioPrompt = "") {
|
|
||||||
if (!userPersonaSelection) {
|
|
||||||
setMarketStatus("Choose who you are before starting roleplay.", "error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (userPersonaSelection === "create_new") {
|
|
||||||
window.location.href = "/app/personas?view=my-roleplay-companions&create=user";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setMarketStatus("Starting AI Character chat...");
|
|
||||||
setMarketActivity(true, {
|
|
||||||
eyebrow: "Starting roleplay",
|
|
||||||
title: "Opening AI Character chat",
|
|
||||||
detail: "Generating the initial message and preparing the chat window."
|
|
||||||
});
|
|
||||||
const userPersonaId = userPersonaSelection === "self" ? null : Number(userPersonaSelection);
|
|
||||||
const res = await post(`/personas/market/${marketId}/chat`, {
|
|
||||||
userPersonaId,
|
|
||||||
scenarioPrompt: scenarioPrompt.trim()
|
|
||||||
});
|
|
||||||
if (res.error || !res.chat) {
|
|
||||||
setMarketActivity(false);
|
|
||||||
setMarketStatus(res.error || "Unable to start chat.", "error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
window.location.href = `/app/chats/${res.chat.id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function savePersona() {
|
|
||||||
const payload = {
|
|
||||||
...readPersonaForm(marketPersonaFields),
|
|
||||||
personaType: "assistant",
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!payload.name) {
|
|
||||||
setPersonaFormNotice("Character name is required.", "error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = editingPersonaId
|
|
||||||
? await put(`/personas/${editingPersonaId}`, payload)
|
|
||||||
: await post("/personas", payload);
|
|
||||||
|
|
||||||
if (res.error) {
|
|
||||||
setPersonaFormNotice(res.error, "error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await loadMarketPersonas();
|
|
||||||
closePersonaForm();
|
|
||||||
setMarketView("ai-characters");
|
|
||||||
setMarketStatus(editingPersonaId ? "AI Character updated." : "AI Character created.", "success");
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshMarketBtn.addEventListener("click", () => {
|
|
||||||
void loadMarketPersonas();
|
|
||||||
});
|
|
||||||
marketPopupConfirm.addEventListener("click", () => closeMarketPopup(marketPopupInput.value));
|
|
||||||
marketPopupCancel.addEventListener("click", () => closeMarketPopup(null));
|
|
||||||
marketPopupClose.addEventListener("click", () => closeMarketPopup(null));
|
|
||||||
marketPopupModal.addEventListener("click", (event) => {
|
|
||||||
if (event.target === marketPopupModal) closeMarketPopup(null);
|
|
||||||
});
|
|
||||||
marketPopupInput.addEventListener("keydown", (event) => {
|
|
||||||
if (event.key === "Enter") {
|
|
||||||
event.preventDefault();
|
|
||||||
closeMarketPopup(marketPopupInput.value);
|
|
||||||
}
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
event.preventDefault();
|
|
||||||
closeMarketPopup(null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
marketSearchInput.addEventListener("input", applyMarketFilter);
|
|
||||||
if (marketAssistantSort) {
|
|
||||||
marketAssistantSort.addEventListener("change", () => setMarketSort("assistant", marketAssistantSort.value));
|
|
||||||
}
|
|
||||||
if (marketUserSort) {
|
|
||||||
marketUserSort.addEventListener("change", () => setMarketSort("user", marketUserSort.value));
|
|
||||||
}
|
|
||||||
marketViewButtons.forEach((button) => {
|
|
||||||
button.addEventListener("click", () => setMarketView(button.dataset.marketView));
|
|
||||||
});
|
|
||||||
if (marketCreateAiCharacterBtn) {
|
|
||||||
marketCreateAiCharacterBtn.addEventListener("click", () => openPersonaForm());
|
|
||||||
}
|
|
||||||
if (marketCreateUserPersonaBtn) {
|
|
||||||
marketCreateUserPersonaBtn.addEventListener("click", () => {
|
|
||||||
window.location.href = "/app/personas?view=my-roleplay-companions&create=user";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
marketPreviewClose.addEventListener("click", closeMarketPreview);
|
|
||||||
marketPreviewModal.addEventListener("click", (event) => {
|
|
||||||
if (event.target === marketPreviewModal) closeMarketPreview();
|
|
||||||
});
|
|
||||||
marketPreviewUserPersonaSelect.addEventListener("change", () => {
|
|
||||||
if (!pendingMarketPersona || pendingMarketPersona.persona_type !== "assistant") return;
|
|
||||||
if (marketPreviewUserPersonaSelect.value === "create_new") {
|
|
||||||
window.location.href = "/app/personas?view=my-roleplay-companions&create=user";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
marketPreviewConfirm.classList.toggle("hidden", !marketPreviewUserPersonaSelect.value);
|
|
||||||
});
|
|
||||||
marketPreviewConfirm.addEventListener("click", async () => {
|
|
||||||
if (!pendingMarketPersona) return;
|
|
||||||
const marketId = pendingMarketPersona.id;
|
|
||||||
if (pendingMarketPersona.persona_type === "assistant") {
|
|
||||||
if (marketPreviewStep === 1) {
|
|
||||||
if (!marketPreviewUserPersonaSelect.value) {
|
|
||||||
setMarketStatus("Choose who you are before continuing.", "error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
marketPreviewStep = 2;
|
|
||||||
renderMarketPreviewStep();
|
|
||||||
marketPreviewScenario.focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await startMarketPersonaChat(
|
|
||||||
marketId,
|
|
||||||
marketPreviewUserPersonaSelect.value || "self",
|
|
||||||
marketPreviewScenario.value || ""
|
|
||||||
);
|
|
||||||
closeMarketPreview();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
closeMarketPreview();
|
|
||||||
await collectMarketPersona(marketId);
|
|
||||||
});
|
|
||||||
marketPersonaClose.addEventListener("click", closePersonaForm);
|
|
||||||
marketPersonaModal.addEventListener("click", (event) => {
|
|
||||||
if (event.target === marketPersonaModal) closePersonaForm();
|
|
||||||
});
|
|
||||||
marketPersonaForm.addEventListener("submit", (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
void savePersona();
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener("load", async () => {
|
|
||||||
initializeAppearance({ title: "Elsewhere Market", nameTargets: brandNameTargets });
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
setMarketSort("assistant", params.get("assistantSort") || "best", { reload: false });
|
|
||||||
setMarketSort("user", params.get("userSort") || "best", { reload: false });
|
|
||||||
setMarketView(params.get("view") || "ai-characters");
|
|
||||||
await loadMarketPersonas();
|
|
||||||
const selectedPersona = [...marketPersonasByType.assistant, ...marketPersonasByType.user].find(persona => persona.id === Number(params.get("persona")));
|
|
||||||
if (selectedPersona) openMarketPreview(selectedPersona);
|
|
||||||
if (params.get("create") === "assistant") {
|
|
||||||
setMarketView("ai-characters");
|
|
||||||
openPersonaForm();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import {get, post, put, del} from "./api.js";
|
||||||
|
import {createConversationPanel, actionButton} from "./conversationPanel.js";
|
||||||
|
|
||||||
|
export function createMemoryPanel({state}) {
|
||||||
|
const panel = createConversationPanel("memoryPanel", "Conversation memory");
|
||||||
|
let chatId, request = 0;
|
||||||
|
function field(label, value = "", maxLength = 2000, key = "") {
|
||||||
|
const wrapper = document.createElement("label"); wrapper.className = "field";
|
||||||
|
const caption = document.createElement("span"); caption.textContent = label;
|
||||||
|
const input = document.createElement("textarea"); input.value = value; input.maxLength = maxLength; input.rows = 3;
|
||||||
|
input.dataset.draftKey = key; input.dataset.savedValue = value;
|
||||||
|
wrapper.append(caption, input);
|
||||||
|
return {wrapper, input};
|
||||||
|
}
|
||||||
|
async function load(edits = new Map()) {
|
||||||
|
const current = ++request, id = chatId;
|
||||||
|
panel.notice("Loading memory...");
|
||||||
|
const result = await get(`/chats/${id}/memories`);
|
||||||
|
if (current !== request) return;
|
||||||
|
if (result.error) { panel.notice(result.error, true); return result; }
|
||||||
|
panel.notice();
|
||||||
|
panel.content.replaceChildren();
|
||||||
|
const usage = document.createElement("p"); usage.className = "memory-context subtitle";
|
||||||
|
const context = result.context;
|
||||||
|
usage.textContent = `Estimated context: ${context.estimatedTokens.toLocaleString()} tokens${context.contextLength ? ` of ${context.contextLength.toLocaleString()}` : " (model context size not set)"}. Includes saved facts, summary, instructions and up to ${context.recentMessageLimit} recent messages. Token counts are approximate; your next message and new search results add to this.`;
|
||||||
|
panel.content.append(usage);
|
||||||
|
if (context.contextLength) {
|
||||||
|
const meter = document.createElement("meter"); meter.min = 0; meter.max = context.contextLength;
|
||||||
|
meter.value = Math.min(context.estimatedTokens, context.contextLength);
|
||||||
|
meter.setAttribute("aria-label", "Estimated context usage"); panel.content.append(meter);
|
||||||
|
}
|
||||||
|
const run = async (operation, savedKey) => {
|
||||||
|
const unsaved = new Map([...panel.content.querySelectorAll("textarea")]
|
||||||
|
.filter(input => input.value !== input.dataset.savedValue).map(input => [input.dataset.draftKey, input.value]));
|
||||||
|
const controls = [...panel.content.querySelectorAll("button, textarea")];
|
||||||
|
controls.forEach(control => { control.disabled = true; });
|
||||||
|
const response = await operation;
|
||||||
|
if (current !== request) return;
|
||||||
|
if (response.error) {
|
||||||
|
controls.forEach(control => { control.disabled = false; });
|
||||||
|
return panel.notice(response.error, true);
|
||||||
|
}
|
||||||
|
unsaved.delete(savedKey);
|
||||||
|
const refreshed = await load(unsaved);
|
||||||
|
if (refreshed?.error) {
|
||||||
|
controls.forEach(control => { control.disabled = false; });
|
||||||
|
return panel.notice(`Memory saved, but it could not be refreshed: ${refreshed.error}`, true);
|
||||||
|
}
|
||||||
|
panel.notice("Memory saved.");
|
||||||
|
};
|
||||||
|
const summary = field("Running summary", result.summary, result.summaryMaxChars, "summary");
|
||||||
|
summary.input.rows = 6;
|
||||||
|
const summaryHelp = document.createElement("p"); summaryHelp.className = "subtitle";
|
||||||
|
summaryHelp.textContent = "Correct outdated information here. Future automatic summaries build on your corrections. Clear the field and save to remove the summary.";
|
||||||
|
panel.content.append(summary.wrapper, summaryHelp,
|
||||||
|
actionButton("Save summary", () => run(put(`/chats/${id}/summary`, {summary: summary.input.value, previousSummary: result.summary}), "summary")));
|
||||||
|
if (result.summary) {
|
||||||
|
const forget = actionButton("Forget running summary", () => run(put(`/chats/${id}/summary`, {summary: "", previousSummary: result.summary}), "summary"));
|
||||||
|
forget.title = "Remove the automatic summary from conversation memory";
|
||||||
|
panel.content.append(forget);
|
||||||
|
const sourceHeading = document.createElement("h4"); sourceHeading.textContent = "Messages included in this summary";
|
||||||
|
panel.content.append(sourceHeading);
|
||||||
|
for (const source of result.summarySources || []) {
|
||||||
|
const item = document.createElement("details"); item.className = "memory-summary-source";
|
||||||
|
const label = document.createElement("summary");
|
||||||
|
const excerpt = source.content.length > 160 ? `${source.content.slice(0, 157)}...` : source.content;
|
||||||
|
label.textContent = `#${source.id} · ${source.role === "user" ? "You" : "Assistant"}: ${excerpt}`;
|
||||||
|
const content = document.createElement("p"); content.textContent = source.content;
|
||||||
|
item.append(label, content); panel.content.append(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const heading = document.createElement("h4"); heading.textContent = "Saved facts";
|
||||||
|
const hint = document.createElement("p"); hint.className = "subtitle";
|
||||||
|
hint.textContent = `The ${context.memoryFactLimit} most recently updated facts are included in context. Edit or remove facts that are no longer accurate.`;
|
||||||
|
panel.content.append(heading, hint);
|
||||||
|
for (const [index, memory] of result.memories.entries()) {
|
||||||
|
const row = document.createElement("section"); row.className = "memory-fact";
|
||||||
|
const key = `fact-${memory.id}`;
|
||||||
|
const fact = field(`Fact ${index + 1}${index >= context.memoryFactLimit ? " (outside current context)" : ""}`, memory.fact, 800, key);
|
||||||
|
const actions = document.createElement("div"); actions.className = "panel-actions";
|
||||||
|
actions.append(actionButton("Save fact", () => run(put(`/chats/${id}/memories/${memory.id}`, {fact: fact.input.value}), key)),
|
||||||
|
actionButton("Remove fact", () => run(del(`/chats/${id}/memories/${memory.id}`), key)));
|
||||||
|
row.append(fact.wrapper, actions); panel.content.append(row);
|
||||||
|
}
|
||||||
|
const fresh = field("New fact", "", 800, "new");
|
||||||
|
panel.content.append(fresh.wrapper, actionButton("Add fact", () => run(post(`/chats/${id}/memories`, {fact: fresh.input.value}), "new")));
|
||||||
|
for (const input of panel.content.querySelectorAll("textarea")) {
|
||||||
|
if (edits.has(input.dataset.draftKey)) input.value = edits.get(input.dataset.draftKey);
|
||||||
|
}
|
||||||
|
return {ok: true};
|
||||||
|
}
|
||||||
|
return {async open() {
|
||||||
|
if (!state.activeChatId) return;
|
||||||
|
chatId = state.activeChatId;
|
||||||
|
panel.content.replaceChildren(); panel.open();
|
||||||
|
await load();
|
||||||
|
}};
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import {post, put, del} from "./api.js";
|
||||||
|
import {regenerateInline} from "./regeneration.js";
|
||||||
|
|
||||||
|
export function createMessageActions({conversation, setNotice, promptPopup, confirmPopup, setActiveChat, openCreatedChat, setLoadingState, loadSummary, renderMessages, addMessage, setMessageContent, announceReply, announceResponseStatus, webSearchToggle, reasoningToggle, modelSelect, messagesDiv, msgInput}) {
|
||||||
|
function getMessageIndexById(messageId) {
|
||||||
|
return conversation.currentMessages.findIndex((message) => message.id === messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveMessageTarget(messageId, fallbackIndex = -1) {
|
||||||
|
if (messageId) {
|
||||||
|
const index = getMessageIndexById(messageId);
|
||||||
|
if (index >= 0) {
|
||||||
|
return { index, message: conversation.currentMessages[index] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (conversation.activeChatId) {
|
||||||
|
await setActiveChat(conversation.activeChatId);
|
||||||
|
}
|
||||||
|
if (messageId) {
|
||||||
|
const index = getMessageIndexById(messageId);
|
||||||
|
if (index >= 0) {
|
||||||
|
return { index, message: conversation.currentMessages[index] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fallbackIndex >= 0 && fallbackIndex < conversation.currentMessages.length) {
|
||||||
|
return { index: fallbackIndex, message: conversation.currentMessages[fallbackIndex] };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMessageEndpoint(target, suffix = "") {
|
||||||
|
if (!conversation.activeChatId || !target) return "";
|
||||||
|
if (target.message?.id) return `/chats/${conversation.activeChatId}/messages/${target.message.id}${suffix}`;
|
||||||
|
if (target.index >= 0) return `/chats/${conversation.activeChatId}/messages/by-index/${target.index}${suffix}`;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreviousUserMessageId(messageId) {
|
||||||
|
const index = getMessageIndexById(messageId);
|
||||||
|
if (index <= 0) return null;
|
||||||
|
for (let i = index - 1; i >= 0; i -= 1) {
|
||||||
|
if (conversation.currentMessages[i].role === "user" && conversation.currentMessages[i].id) {
|
||||||
|
return conversation.currentMessages[i].id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRetryStateFromMessage(message) {
|
||||||
|
const variants = Array.isArray(message?.retryVariants) && message.retryVariants.length
|
||||||
|
? [...message.retryVariants]
|
||||||
|
: [message?.content || ""];
|
||||||
|
const requestedActiveIndex = Number(message?.retryActiveIndex);
|
||||||
|
const activeIndex = Number.isInteger(requestedActiveIndex)
|
||||||
|
? Math.min(Math.max(requestedActiveIndex, 0), variants.length - 1)
|
||||||
|
: 0;
|
||||||
|
return {
|
||||||
|
variants,
|
||||||
|
activeIndex,
|
||||||
|
retriesUsed: Number(message?.retryRetriesUsed || 0),
|
||||||
|
promptMessageId: message?.retryPromptMessageId ?? getPreviousUserMessageId(message?.id)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncRetryStateFromMessage(message) {
|
||||||
|
if (!message?.id) return null;
|
||||||
|
const state = getRetryStateFromMessage(message);
|
||||||
|
conversation.messageRetryState.set(message.id, state);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editChatMessage(messageId, fallbackIndex = -1) {
|
||||||
|
const target = await resolveMessageTarget(messageId, fallbackIndex);
|
||||||
|
if (!target) return setNotice("Message is not ready yet. Try again.", "error");
|
||||||
|
const { index, message } = target;
|
||||||
|
const sourceChatId = conversation.activeChatId;
|
||||||
|
const nextContent = await promptPopup({
|
||||||
|
eyebrow: message.role === "user" ? "Your message" : "Assistant message",
|
||||||
|
title: "Edit message",
|
||||||
|
description: "Update this message.",
|
||||||
|
label: "Message",
|
||||||
|
value: message.content,
|
||||||
|
confirmLabel: "Save"
|
||||||
|
});
|
||||||
|
const content = (nextContent || "").trim();
|
||||||
|
if (!content) return;
|
||||||
|
if (message.role === "user") {
|
||||||
|
const confirmed = await confirmPopup({
|
||||||
|
eyebrow: "Create branch",
|
||||||
|
title: "Create an edited chat branch?",
|
||||||
|
description: "This creates a new chat from the messages before this one and sends your edited message there. The original chat stays unchanged.",
|
||||||
|
confirmLabel: "Create branch"
|
||||||
|
});
|
||||||
|
if (!confirmed || conversation.isProcessing) return;
|
||||||
|
conversation.regenerationChatId = sourceChatId;
|
||||||
|
setLoadingState(true, {
|
||||||
|
eyebrow: "Branching",
|
||||||
|
title: "Editing and resending",
|
||||||
|
detail: "Creating and opening a new chat branch."
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const res = await post(`/chats/${sourceChatId}/messages/${message.id}/edit-resend`, {
|
||||||
|
chatId: sourceChatId,
|
||||||
|
message: content,
|
||||||
|
model: modelSelect.value,
|
||||||
|
webSearch: webSearchToggle.enabled,
|
||||||
|
confirmed: true
|
||||||
|
}, {timeoutMs: 660000});
|
||||||
|
if (res.error) { announceResponseStatus(res.error); return setNotice(res.error, "error"); }
|
||||||
|
if (conversation.activeChatId === sourceChatId) {
|
||||||
|
await openCreatedChat(res.chat);
|
||||||
|
const reply = conversation.currentMessages.at(-1);
|
||||||
|
if (reply?.role === "bot") announceReply(reply.content);
|
||||||
|
}
|
||||||
|
return setNotice("Created the edited chat branch.", "success");
|
||||||
|
} finally {
|
||||||
|
conversation.regenerationChatId = null;
|
||||||
|
setLoadingState(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const endpoint = getMessageEndpoint(target);
|
||||||
|
if (!endpoint) return setNotice("Message is not ready yet. Try again.", "error");
|
||||||
|
const res = await put(endpoint, { content });
|
||||||
|
if (res.error || !res.message) {
|
||||||
|
return setNotice(res.error || "Unable to edit message.", "error");
|
||||||
|
}
|
||||||
|
conversation.currentMessages[index] = { ...conversation.currentMessages[index], ...res.message };
|
||||||
|
syncRetryStateFromMessage(conversation.currentMessages[index]);
|
||||||
|
renderMessages();
|
||||||
|
setNotice("Message updated.", "success");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteChatMessage(messageId, fallbackIndex = -1) {
|
||||||
|
const target = await resolveMessageTarget(messageId, fallbackIndex);
|
||||||
|
if (!target) return setNotice("Message is not ready yet. Try again.", "error");
|
||||||
|
const { message } = target;
|
||||||
|
const confirmed = await confirmPopup({
|
||||||
|
eyebrow: message.role === "user" ? "Your message" : "Assistant message",
|
||||||
|
title: "Delete message",
|
||||||
|
description: "Delete this message from the chat?",
|
||||||
|
confirmLabel: "Delete",
|
||||||
|
danger: true
|
||||||
|
});
|
||||||
|
if (!confirmed) return;
|
||||||
|
const endpoint = getMessageEndpoint(target);
|
||||||
|
if (!endpoint) return setNotice("Message is not ready yet. Try again.", "error");
|
||||||
|
const res = await del(endpoint);
|
||||||
|
if (res.error) return setNotice(res.error, "error");
|
||||||
|
conversation.currentMessages = conversation.currentMessages.filter((item) => item.id !== message.id);
|
||||||
|
if (message.id) {
|
||||||
|
conversation.messageRetryState.delete(message.id);
|
||||||
|
for (const [key, state] of conversation.messageRetryState.entries()) {
|
||||||
|
if (state.promptMessageId === message.id) {
|
||||||
|
conversation.messageRetryState.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renderMessages();
|
||||||
|
await loadSummary();
|
||||||
|
setNotice("Message deleted.", "success");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retryLatestAssistantMessage(messageId, fallbackIndex = -1) {
|
||||||
|
if (conversation.isProcessing) return;
|
||||||
|
const target = await resolveMessageTarget(messageId, fallbackIndex);
|
||||||
|
if (conversation.isProcessing) return;
|
||||||
|
if (!target) return setNotice("Message is not ready yet. Try again.", "error");
|
||||||
|
if (target.message.role !== "bot") return setNotice("Only assistant messages can be regenerated.", "error");
|
||||||
|
const retriesUsed = Math.max(0, Number(target.message.versionCount || 1) - 1);
|
||||||
|
if (retriesUsed >= 5) {
|
||||||
|
return setNotice("Retry limit reached (5).", "error");
|
||||||
|
}
|
||||||
|
const chatId = conversation.activeChatId;
|
||||||
|
const card = messagesDiv.querySelector(`[data-message-id="${target.message.id}"]`);
|
||||||
|
if (!card) return;
|
||||||
|
const payload = { model: modelSelect.value, webSearch: webSearchToggle.enabled, reasoning: reasoningToggle.enabled };
|
||||||
|
conversation.regenerationChatId = chatId;
|
||||||
|
setLoadingState(true, false);
|
||||||
|
try {
|
||||||
|
await regenerateInline({
|
||||||
|
card, scroller: messagesDiv, endpoint: `/chats/${chatId}/messages/${target.message.id}/retry/stream`, payload,
|
||||||
|
renderContent: setMessageContent,
|
||||||
|
complete: message => {
|
||||||
|
if (conversation.activeChatId !== chatId) return;
|
||||||
|
const index = getMessageIndexById(message.id);
|
||||||
|
if (index < 0) return;
|
||||||
|
conversation.currentMessages[index] = { ...conversation.currentMessages[index], ...message, thinking: message.thinking, sources: message.sources };
|
||||||
|
syncRetryStateFromMessage(conversation.currentMessages[index]);
|
||||||
|
const rendered = addMessage(conversation.currentMessages[index], false, false, { messageIndex: index, detached: true });
|
||||||
|
card.replaceChildren(...rendered.childNodes);
|
||||||
|
announceReply(message.content);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
reasoningToggle.reset();
|
||||||
|
setNotice("Reply regenerated.", "success");
|
||||||
|
} catch (error) {
|
||||||
|
announceResponseStatus(error.message || "Unable to regenerate message.");
|
||||||
|
setNotice(error.message || "Unable to regenerate message.", "error");
|
||||||
|
} finally {
|
||||||
|
conversation.regenerationChatId = null;
|
||||||
|
setLoadingState(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function switchResponseVersion(messageId, direction, fallbackIndex = -1) {
|
||||||
|
const target = await resolveMessageTarget(messageId, fallbackIndex);
|
||||||
|
if (!target) return setNotice("Message is not ready yet. Try again.", "error");
|
||||||
|
if (Number(target.message.versionCount) > 1) {
|
||||||
|
const chatId = conversation.activeChatId;
|
||||||
|
const card = messagesDiv.querySelector(`[data-message-id="${messageId}"]`);
|
||||||
|
if (!card || card.getAttribute("aria-busy") === "true") return;
|
||||||
|
const buttons = [...card.querySelectorAll("button")].map(button => [button, button.disabled]);
|
||||||
|
card.setAttribute("aria-busy", "true");
|
||||||
|
buttons.forEach(([button]) => { button.disabled = true; });
|
||||||
|
try {
|
||||||
|
const res = await post(`/chats/${chatId}/messages/${messageId}/versions/${target.message.versionIndex + direction}`, {});
|
||||||
|
if (res.error) return setNotice(res.error, "error");
|
||||||
|
if (conversation.activeChatId !== chatId) return;
|
||||||
|
const index = getMessageIndexById(messageId);
|
||||||
|
if (index < 0) return;
|
||||||
|
conversation.currentMessages[index] = { ...conversation.currentMessages[index], ...res.message, thinking: res.message.thinking, sources: res.message.sources };
|
||||||
|
syncRetryStateFromMessage(conversation.currentMessages[index]);
|
||||||
|
const rendered = addMessage(conversation.currentMessages[index], false, false, { messageIndex: index, detached: true });
|
||||||
|
card.replaceChildren(...rendered.childNodes);
|
||||||
|
} finally {
|
||||||
|
card.removeAttribute("aria-busy");
|
||||||
|
buttons.forEach(([button, disabled]) => { button.disabled = disabled; });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function switchRetryVariant(messageId, direction, fallbackIndex = -1) {
|
||||||
|
const target = await resolveMessageTarget(messageId, fallbackIndex);
|
||||||
|
if (!target) return setNotice("Message is not ready yet. Try again.", "error");
|
||||||
|
const siblingId = direction < 0 ? target.message.previousSiblingId : target.message.nextSiblingId;
|
||||||
|
if (!siblingId) return;
|
||||||
|
const res = await post(`/chats/${conversation.activeChatId}/branches/${siblingId}/activate`, { includeDescendants: true });
|
||||||
|
if (res.error) return setNotice(res.error, "error");
|
||||||
|
conversation.currentMessages = res.messages || [];
|
||||||
|
conversation.historyPage = res.pagination || null;
|
||||||
|
renderMessages();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function branchFromMessage(messageId) {
|
||||||
|
if (!conversation.activeChatId || !messageId) return;
|
||||||
|
const confirmed = await confirmPopup({
|
||||||
|
eyebrow: "Create branch",
|
||||||
|
title: "Create a new chat branch?",
|
||||||
|
description: "The new chat will contain the conversation through this message. The original chat stays unchanged.",
|
||||||
|
confirmLabel: "Create branch"
|
||||||
|
});
|
||||||
|
if (!confirmed) return;
|
||||||
|
const sourceChatId = conversation.activeChatId;
|
||||||
|
const res = await post(`/chats/${sourceChatId}/messages/${messageId}/branch`, { confirmed: true });
|
||||||
|
if (res.error) return setNotice(res.error, "error");
|
||||||
|
await openCreatedChat(res.chat);
|
||||||
|
msgInput.focus();
|
||||||
|
setNotice("Created and opened a new chat branch.", "success");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {editChatMessage, deleteChatMessage, retryLatestAssistantMessage, switchResponseVersion, switchRetryVariant, branchFromMessage};
|
||||||
|
}
|
||||||
@@ -0,0 +1,504 @@
|
|||||||
|
import {messagesDiv, msgInput} from "./dom.js?v=20260909-account-controls";
|
||||||
|
import {appendAttachments} from "./attachments.js?v=20260930-chat-usability";
|
||||||
|
import {updateReasoning} from "./reasoning.js?v=20260921-reasoning-capability";
|
||||||
|
import {appendSources} from "./webSearch.js";
|
||||||
|
import {get} from "./api.js";
|
||||||
|
|
||||||
|
export function createMessageRenderer({
|
||||||
|
getChatById,
|
||||||
|
state,
|
||||||
|
setNotice,
|
||||||
|
sendMessage,
|
||||||
|
editChatMessage,
|
||||||
|
deleteChatMessage,
|
||||||
|
branchFromMessage,
|
||||||
|
retryLatestAssistantMessage,
|
||||||
|
switchResponseVersion,
|
||||||
|
switchRetryVariant,
|
||||||
|
renderDrawerSummary,
|
||||||
|
updateChatActionState,
|
||||||
|
updateComposerPlaceholder,
|
||||||
|
createNewChat,
|
||||||
|
openMessage
|
||||||
|
}) {
|
||||||
|
const jumpToLatest = document.getElementById("jumpToLatest");
|
||||||
|
const searchInput = document.getElementById("conversationSearch");
|
||||||
|
const searchToggle = document.getElementById("conversationSearchToggle");
|
||||||
|
const searchBar = document.getElementById("conversationSearchBar");
|
||||||
|
const searchCount = document.getElementById("conversationSearchCount");
|
||||||
|
const previousMatch = document.getElementById("conversationSearchPrevious");
|
||||||
|
const nextMatch = document.getElementById("conversationSearchNext");
|
||||||
|
const replyAnnouncement = document.getElementById("replyAnnouncement");
|
||||||
|
searchToggle?.addEventListener("click", () => {
|
||||||
|
const expanded = searchToggle.getAttribute("aria-expanded") !== "true";
|
||||||
|
searchToggle.setAttribute("aria-expanded", String(expanded));
|
||||||
|
searchBar.classList.toggle("is-open", expanded);
|
||||||
|
if (expanded) searchInput.focus();
|
||||||
|
else searchToggle.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
let scrollFrame = null;
|
||||||
|
|
||||||
|
let renderedChatId = null;
|
||||||
|
|
||||||
|
let renderedMessages = new Map();
|
||||||
|
let searchMatches = [];
|
||||||
|
let conversationMatches = null;
|
||||||
|
let selectedMatch = 0;
|
||||||
|
let searchChatId = null;
|
||||||
|
let searchFrame = null;
|
||||||
|
let searchTimer = null;
|
||||||
|
let searchRequest = 0;
|
||||||
|
let searchNavigation = 0;
|
||||||
|
|
||||||
|
function clearSearchMarks() {
|
||||||
|
messagesDiv.querySelectorAll("mark.conversation-search-match").forEach(mark => {
|
||||||
|
mark.replaceWith(document.createTextNode(mark.textContent));
|
||||||
|
});
|
||||||
|
messagesDiv.normalize();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSearchCount() {
|
||||||
|
const total = conversationMatches?.length ?? searchMatches.length;
|
||||||
|
const label = total ? `${selectedMatch + 1} of ${total} matches` : "0 matches";
|
||||||
|
if (searchCount.textContent !== label) searchCount.textContent = label;
|
||||||
|
previousMatch.disabled = nextMatch.disabled = !total;
|
||||||
|
const target = conversationMatches?.[selectedMatch];
|
||||||
|
searchMatches.forEach((mark, index) => mark.classList.toggle("current", target
|
||||||
|
? String(target.messageId) === mark.dataset.messageId && Number(mark.dataset.occurrence) === target.occurrence
|
||||||
|
: index === selectedMatch));
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshConversationSearch() {
|
||||||
|
if (searchFrame !== null) { cancelAnimationFrame(searchFrame); searchFrame = null; }
|
||||||
|
clearSearchMarks();
|
||||||
|
searchMatches = [];
|
||||||
|
const query = searchInput.value.trim();
|
||||||
|
if (query) {
|
||||||
|
const needle = query.toLocaleLowerCase();
|
||||||
|
const occurrences = new Map();
|
||||||
|
messagesDiv.querySelectorAll(".msg-content").forEach(content => {
|
||||||
|
const messageId = content.closest(".msg")?.dataset.messageId || "";
|
||||||
|
const walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT);
|
||||||
|
const nodes = [];
|
||||||
|
while (walker.nextNode()) nodes.push(walker.currentNode);
|
||||||
|
nodes.forEach(node => {
|
||||||
|
const value = node.nodeValue;
|
||||||
|
const lower = value.toLocaleLowerCase();
|
||||||
|
let start = lower.indexOf(needle);
|
||||||
|
if (start < 0) return;
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
let cursor = 0;
|
||||||
|
while (start >= 0) {
|
||||||
|
fragment.append(document.createTextNode(value.slice(cursor, start)));
|
||||||
|
const mark = document.createElement("mark");
|
||||||
|
mark.className = "conversation-search-match";
|
||||||
|
mark.textContent = value.slice(start, start + query.length);
|
||||||
|
mark.dataset.messageId = messageId;
|
||||||
|
mark.dataset.occurrence = String(occurrences.get(messageId) || 0);
|
||||||
|
occurrences.set(messageId, (occurrences.get(messageId) || 0) + 1);
|
||||||
|
fragment.append(mark);
|
||||||
|
searchMatches.push(mark);
|
||||||
|
cursor = start + query.length;
|
||||||
|
start = lower.indexOf(needle, cursor);
|
||||||
|
}
|
||||||
|
fragment.append(document.createTextNode(value.slice(cursor)));
|
||||||
|
node.replaceWith(fragment);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
selectedMatch = Math.min(selectedMatch, Math.max(0, (conversationMatches?.length ?? searchMatches.length) - 1));
|
||||||
|
updateSearchCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleConversationSearch(reset = false, focusMatch = false) {
|
||||||
|
clearTimeout(searchTimer);
|
||||||
|
const request = ++searchRequest;
|
||||||
|
const chatId = state.activeChatId;
|
||||||
|
const query = searchInput.value.trim();
|
||||||
|
if (reset || !query) conversationMatches = null;
|
||||||
|
updateSearchCount();
|
||||||
|
if (!chatId || !query) return;
|
||||||
|
searchTimer = setTimeout(async () => {
|
||||||
|
const result = await get(`/chats/${chatId}/messages/search?q=${encodeURIComponent(query)}`);
|
||||||
|
if (request !== searchRequest || chatId !== state.activeChatId || query !== searchInput.value.trim()) return;
|
||||||
|
if (result.error) { searchCount.textContent = "Search unavailable"; return; }
|
||||||
|
conversationMatches = result.matches || [];
|
||||||
|
selectedMatch = Math.min(selectedMatch, Math.max(0, conversationMatches.length - 1));
|
||||||
|
updateSearchCount();
|
||||||
|
if (focusMatch && conversationMatches.length) void moveSearchMatch(0);
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSearchRefresh() {
|
||||||
|
if (!searchInput.value.trim() || searchFrame !== null) return;
|
||||||
|
searchFrame = requestAnimationFrame(refreshConversationSearch);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveSearchMatch(direction) {
|
||||||
|
const navigation = ++searchNavigation;
|
||||||
|
const hits = conversationMatches || searchMatches;
|
||||||
|
if (!hits.length) return;
|
||||||
|
selectedMatch = (selectedMatch + direction + hits.length) % hits.length;
|
||||||
|
updateSearchCount();
|
||||||
|
const hit = conversationMatches?.[selectedMatch];
|
||||||
|
if (hit && !searchMatches.some(mark => mark.dataset.messageId === String(hit.messageId))) {
|
||||||
|
await openMessage(hit.messageId);
|
||||||
|
if (navigation !== searchNavigation || state.activeChatId !== searchChatId) return;
|
||||||
|
refreshConversationSearch();
|
||||||
|
}
|
||||||
|
const mark = hit
|
||||||
|
? searchMatches.find(item => item.dataset.messageId === String(hit.messageId) && Number(item.dataset.occurrence) === hit.occurrence)
|
||||||
|
|| searchMatches.find(item => item.dataset.messageId === String(hit.messageId))
|
||||||
|
: searchMatches[selectedMatch];
|
||||||
|
mark?.scrollIntoView({block: "center", behavior: "smooth"});
|
||||||
|
}
|
||||||
|
|
||||||
|
searchInput.addEventListener("input", () => {
|
||||||
|
searchNavigation++;
|
||||||
|
selectedMatch = 0;
|
||||||
|
conversationMatches = null;
|
||||||
|
refreshConversationSearch();
|
||||||
|
searchMatches[0]?.scrollIntoView({block: "center", behavior: "smooth"});
|
||||||
|
scheduleConversationSearch(false, true);
|
||||||
|
});
|
||||||
|
searchInput.addEventListener("keydown", event => {
|
||||||
|
if (event.key === "Enter") { event.preventDefault(); moveSearchMatch(event.shiftKey ? -1 : 1); }
|
||||||
|
});
|
||||||
|
previousMatch.addEventListener("click", () => moveSearchMatch(-1));
|
||||||
|
nextMatch.addEventListener("click", () => moveSearchMatch(1));
|
||||||
|
|
||||||
|
function announceResponding() { replyAnnouncement.textContent = "Assistant is responding."; }
|
||||||
|
function announceResponseStatus(status) { replyAnnouncement.textContent = status; }
|
||||||
|
function announceReply(content) {
|
||||||
|
const plain = document.createElement("div");
|
||||||
|
renderMarkdown(plain, content || "");
|
||||||
|
replyAnnouncement.textContent = `Assistant: ${plain.textContent.trim() || "Response complete."}`;
|
||||||
|
if (searchInput.value.trim()) scheduleConversationSearch();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMarkdown(el, content) {
|
||||||
|
const markdownParser = window.marked;
|
||||||
|
const purifier = window.DOMPurify;
|
||||||
|
if (markdownParser?.parse && purifier?.sanitize) {
|
||||||
|
el.innerHTML = purifier.sanitize(markdownParser.parse(content, { breaks: true }));
|
||||||
|
} else {
|
||||||
|
el.textContent = content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMessageContent(el, content) {
|
||||||
|
renderMarkdown(el, content);
|
||||||
|
scheduleSearchRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollMessagesToBottom() {
|
||||||
|
if (scrollFrame !== null) cancelAnimationFrame(scrollFrame);
|
||||||
|
scrollFrame = requestAnimationFrame(() => {
|
||||||
|
scrollFrame = null;
|
||||||
|
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
||||||
|
updateJumpToLatest();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateJumpToLatest() {
|
||||||
|
const distance = messagesDiv.scrollHeight - messagesDiv.scrollTop - messagesDiv.clientHeight;
|
||||||
|
jumpToLatest.classList.toggle("hidden", distance < 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMessage(contentOrMessage, isUser = false, isLoading = false, options = {}) {
|
||||||
|
const msgDiv = document.createElement("div");
|
||||||
|
msgDiv.className = `msg ${isUser ? "user" : "bot"}${isLoading ? " loading" : ""}`;
|
||||||
|
msgDiv.setAttribute("role", "article");
|
||||||
|
msgDiv.tabIndex = 0;
|
||||||
|
if (isLoading) {
|
||||||
|
msgDiv.setAttribute("aria-label", "Assistant is responding");
|
||||||
|
msgDiv.innerHTML = `
|
||||||
|
<div class="msg-header">Assistant</div>
|
||||||
|
<div class="loading-shell" aria-hidden="true">
|
||||||
|
<div class="loading-bars">
|
||||||
|
<span class="loading-bar"></span>
|
||||||
|
<span class="loading-bar"></span>
|
||||||
|
<span class="loading-bar"></span>
|
||||||
|
<span class="loading-bar"></span>
|
||||||
|
</div>
|
||||||
|
<div class="loading-copy">
|
||||||
|
<strong>Thinking</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="msg-content streaming-content"></div>`;
|
||||||
|
} else {
|
||||||
|
const message = typeof contentOrMessage === "object" && contentOrMessage
|
||||||
|
? contentOrMessage
|
||||||
|
: { id: null, role: isUser ? "user" : "bot", content: String(contentOrMessage || "") };
|
||||||
|
const isMessageUser = message.role === "user";
|
||||||
|
msgDiv.dataset.messageId = message.id;
|
||||||
|
msgDiv.className = `msg ${isMessageUser ? "user" : "bot"}`;
|
||||||
|
msgDiv.setAttribute("aria-label", isMessageUser ? "Your message" : "Assistant message");
|
||||||
|
const header = document.createElement("div"), body = document.createElement("div"), actions = document.createElement("div");
|
||||||
|
header.className = "msg-header"; body.className = "msg-content";
|
||||||
|
actions.className = "msg-actions";
|
||||||
|
actions.setAttribute("aria-label", "Message actions");
|
||||||
|
const activeChat = getChatById(state.activeChatId);
|
||||||
|
header.textContent = isMessageUser ? "You" : (activeChat?.assistant_persona_name || "Assistant");
|
||||||
|
if (!isMessageUser && Array.isArray(message.sources) && message.sources.length) {
|
||||||
|
const webBadge = document.createElement("span");
|
||||||
|
webBadge.className = "msg-web-badge";
|
||||||
|
webBadge.textContent = "Used web results";
|
||||||
|
header.append(webBadge);
|
||||||
|
}
|
||||||
|
setMessageContent(body, message.content);
|
||||||
|
|
||||||
|
const copyBtn = document.createElement("button");
|
||||||
|
copyBtn.type = "button";
|
||||||
|
copyBtn.className = "msg-action-btn";
|
||||||
|
copyBtn.textContent = "Copy";
|
||||||
|
copyBtn.setAttribute("aria-label", `Copy ${isMessageUser ? "your" : "assistant"} message`);
|
||||||
|
copyBtn.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(message.content || "");
|
||||||
|
setNotice("Message copied.", "success");
|
||||||
|
} catch {
|
||||||
|
setNotice("Unable to copy message.", "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
actions.appendChild(copyBtn);
|
||||||
|
|
||||||
|
if (["failed", "pending"].includes(message.deliveryStatus) && isMessageUser) {
|
||||||
|
const retryFailedBtn = document.createElement("button");
|
||||||
|
retryFailedBtn.type = "button";
|
||||||
|
retryFailedBtn.className = "msg-action-btn";
|
||||||
|
retryFailedBtn.dataset.messageAction = "retry-failed";
|
||||||
|
retryFailedBtn.textContent = message.deliveryStatus === "pending" ? "Resume" : "Retry";
|
||||||
|
retryFailedBtn.disabled = state.isProcessing;
|
||||||
|
retryFailedBtn.addEventListener("click", () => { void sendMessage({ retryMessage: message }); });
|
||||||
|
actions.appendChild(retryFailedBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
const menu = document.createElement("details");
|
||||||
|
menu.className = "msg-action-menu";
|
||||||
|
const menuTrigger = document.createElement("summary");
|
||||||
|
menuTrigger.className = "msg-action-btn";
|
||||||
|
menuTrigger.textContent = "More";
|
||||||
|
menuTrigger.setAttribute("aria-label", "More message actions");
|
||||||
|
const menuItems = document.createElement("div");
|
||||||
|
menuItems.className = "msg-action-menu-items";
|
||||||
|
menuItems.addEventListener("click", () => { menu.open = false; });
|
||||||
|
menu.append(menuTrigger, menuItems);
|
||||||
|
if (message.id) actions.appendChild(menu);
|
||||||
|
|
||||||
|
const editBtn = document.createElement("button");
|
||||||
|
editBtn.type = "button";
|
||||||
|
editBtn.className = "msg-action-btn";
|
||||||
|
editBtn.textContent = isMessageUser ? "Edit & resend" : "Edit";
|
||||||
|
editBtn.setAttribute("aria-label", `Edit ${isMessageUser ? "your" : "assistant"} message`);
|
||||||
|
editBtn.addEventListener("click", () => {
|
||||||
|
void editChatMessage(message.id, options.messageIndex ?? -1);
|
||||||
|
});
|
||||||
|
menuItems.appendChild(editBtn);
|
||||||
|
|
||||||
|
const deleteBtn = document.createElement("button");
|
||||||
|
deleteBtn.type = "button";
|
||||||
|
deleteBtn.className = "msg-action-btn";
|
||||||
|
deleteBtn.textContent = "Delete";
|
||||||
|
deleteBtn.setAttribute("aria-label", `Delete ${isMessageUser ? "your" : "assistant"} message`);
|
||||||
|
deleteBtn.addEventListener("click", () => {
|
||||||
|
void deleteChatMessage(message.id, options.messageIndex ?? -1);
|
||||||
|
});
|
||||||
|
menuItems.appendChild(deleteBtn);
|
||||||
|
|
||||||
|
const branchBtn = document.createElement("button");
|
||||||
|
branchBtn.type = "button";
|
||||||
|
branchBtn.className = "msg-action-btn";
|
||||||
|
branchBtn.textContent = "Branch here";
|
||||||
|
branchBtn.addEventListener("click", () => { void branchFromMessage(message.id); });
|
||||||
|
menuItems.appendChild(branchBtn);
|
||||||
|
|
||||||
|
if (message.role === "bot") {
|
||||||
|
const retriesUsed = Math.max(0, Number(message.versionCount || 1) - 1);
|
||||||
|
const retryBtn = document.createElement("button");
|
||||||
|
retryBtn.type = "button";
|
||||||
|
retryBtn.className = "msg-action-btn";
|
||||||
|
retryBtn.dataset.messageAction = "regenerate";
|
||||||
|
retryBtn.dataset.retryLimitReached = String(retriesUsed >= 5);
|
||||||
|
retryBtn.textContent = `Regenerate (${retriesUsed}/5)`;
|
||||||
|
retryBtn.setAttribute("aria-label", `Regenerate assistant message, ${retriesUsed} of 5 retries used`);
|
||||||
|
retryBtn.disabled = retriesUsed >= 5 || state.isProcessing;
|
||||||
|
retryBtn.addEventListener("click", () => {
|
||||||
|
void retryLatestAssistantMessage(message.id, options.messageIndex ?? -1);
|
||||||
|
});
|
||||||
|
actions.appendChild(retryBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const versioned of [true, false]) {
|
||||||
|
if (Number((versioned ? message.versionCount : message.siblingCount) || 1) <= 1) continue;
|
||||||
|
const navigate = versioned ? switchResponseVersion : switchRetryVariant;
|
||||||
|
const prevBtn = document.createElement("button");
|
||||||
|
prevBtn.type = "button";
|
||||||
|
prevBtn.className = "msg-action-btn";
|
||||||
|
prevBtn.textContent = "Prev";
|
||||||
|
prevBtn.setAttribute("aria-label", versioned ? "Show previous response version" : "Show previous branch");
|
||||||
|
prevBtn.disabled = versioned ? message.versionIndex <= 0 : !message.previousSiblingId;
|
||||||
|
prevBtn.addEventListener("click", () => {
|
||||||
|
void navigate(message.id, -1, options.messageIndex ?? -1);
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextBtn = document.createElement("button");
|
||||||
|
nextBtn.type = "button";
|
||||||
|
nextBtn.className = "msg-action-btn";
|
||||||
|
nextBtn.textContent = "Next";
|
||||||
|
nextBtn.setAttribute("aria-label", versioned ? "Show next response version" : "Show next branch");
|
||||||
|
nextBtn.disabled = versioned ? message.versionIndex >= message.versionCount - 1 : !message.nextSiblingId;
|
||||||
|
nextBtn.addEventListener("click", () => {
|
||||||
|
void navigate(message.id, 1, options.messageIndex ?? -1);
|
||||||
|
});
|
||||||
|
|
||||||
|
const indexBadge = document.createElement("span");
|
||||||
|
indexBadge.className = "msg-variant-index";
|
||||||
|
indexBadge.textContent = versioned ? `${message.versionIndex + 1} / ${message.versionCount}` : `${Number(message.siblingIndex || 0) + 1}/${message.siblingCount}`;
|
||||||
|
actions.append(prevBtn, indexBadge, nextBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
msgDiv.append(header);
|
||||||
|
appendAttachments(msgDiv, message.attachments);
|
||||||
|
msgDiv.append(body);
|
||||||
|
if (!isMessageUser) updateReasoning(msgDiv, message.thinking);
|
||||||
|
if (!isMessageUser) appendSources(msgDiv, message.sources);
|
||||||
|
if (message.deliveryStatus === "failed" && message.errorMessage) {
|
||||||
|
const error = document.createElement("p");
|
||||||
|
error.className = "status error";
|
||||||
|
error.textContent = message.errorMessage;
|
||||||
|
msgDiv.appendChild(error);
|
||||||
|
}
|
||||||
|
if (actions.childElementCount) {
|
||||||
|
msgDiv.appendChild(actions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!options.detached) {
|
||||||
|
messagesDiv.appendChild(msgDiv);
|
||||||
|
scrollMessagesToBottom();
|
||||||
|
}
|
||||||
|
return msgDiv;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMessages({preserveScroll = false} = {}) {
|
||||||
|
if (scrollFrame !== null) { cancelAnimationFrame(scrollFrame); scrollFrame = null; }
|
||||||
|
const sameChat = renderedChatId === state.activeChatId;
|
||||||
|
const wasReadingEarlier = sameChat && messagesDiv.scrollHeight - messagesDiv.scrollTop - messagesDiv.clientHeight >= 100;
|
||||||
|
const previousScrollTop = messagesDiv.scrollTop;
|
||||||
|
const chat = getChatById(state.activeChatId);
|
||||||
|
if (!state.currentMessages.length && (!chat || chat.assistant_persona_id)) {
|
||||||
|
messagesDiv.replaceChildren();
|
||||||
|
renderMessageEmptyState();
|
||||||
|
renderedMessages = new Map();
|
||||||
|
} else {
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
const nextRendered = new Map();
|
||||||
|
state.currentMessages.forEach((msg, index) => {
|
||||||
|
const key = msg.id ? String(msg.id) : null;
|
||||||
|
const snapshot = JSON.stringify(msg);
|
||||||
|
const previous = sameChat && key ? renderedMessages.get(key) : null;
|
||||||
|
const card = previous?.snapshot === snapshot && previous.node.isConnected
|
||||||
|
? previous.node
|
||||||
|
: addMessage(msg, msg.role === "user", false, {
|
||||||
|
messageIndex: index,
|
||||||
|
isNewest: index === state.currentMessages.length - 1,
|
||||||
|
detached: true
|
||||||
|
});
|
||||||
|
fragment.append(card);
|
||||||
|
if (key) nextRendered.set(key, {snapshot, node: card});
|
||||||
|
});
|
||||||
|
messagesDiv.replaceChildren(fragment);
|
||||||
|
renderedMessages = nextRendered;
|
||||||
|
}
|
||||||
|
renderedChatId = state.activeChatId;
|
||||||
|
if (searchChatId !== state.activeChatId) {
|
||||||
|
searchNavigation++;
|
||||||
|
searchChatId = state.activeChatId;
|
||||||
|
selectedMatch = 0;
|
||||||
|
scheduleConversationSearch(true, true);
|
||||||
|
} else if (searchInput.value.trim()) scheduleConversationSearch();
|
||||||
|
refreshConversationSearch();
|
||||||
|
renderDrawerSummary();
|
||||||
|
updateChatActionState();
|
||||||
|
updateComposerPlaceholder();
|
||||||
|
if (wasReadingEarlier || preserveScroll) {
|
||||||
|
messagesDiv.scrollTop = previousScrollTop;
|
||||||
|
updateJumpToLatest();
|
||||||
|
} else if (state.currentMessages.length) {
|
||||||
|
scrollMessagesToBottom();
|
||||||
|
} else {
|
||||||
|
updateJumpToLatest();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStarterAction(label, className, handler) {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = className;
|
||||||
|
button.textContent = label;
|
||||||
|
button.addEventListener("click", handler);
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMessageStarterPrompt(label, prompt) {
|
||||||
|
return createStarterAction(label, "secondary-action", async () => {
|
||||||
|
if (!state.activeChatId) await createNewChat();
|
||||||
|
msgInput.value = prompt;
|
||||||
|
msgInput.dispatchEvent(new Event("input"));
|
||||||
|
msgInput.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMessageEmptyState() {
|
||||||
|
const empty = document.createElement("section");
|
||||||
|
const title = document.createElement("h3");
|
||||||
|
const description = document.createElement("p");
|
||||||
|
const actions = document.createElement("div");
|
||||||
|
const chat = getChatById(state.activeChatId);
|
||||||
|
|
||||||
|
empty.className = "messages-empty";
|
||||||
|
title.className = "messages-empty-title";
|
||||||
|
description.className = "messages-empty-copy";
|
||||||
|
actions.className = "messages-empty-actions";
|
||||||
|
|
||||||
|
if (!chat) {
|
||||||
|
title.textContent = "Where shall we start?";
|
||||||
|
description.textContent = "Choose a starting point.";
|
||||||
|
actions.append(
|
||||||
|
createStarterAction("New chat", "primary-action", () => { void createNewChat(); }),
|
||||||
|
createStarterAction("Browse Personalities", "secondary-action", () => { location.assign("/app/personas"); }),
|
||||||
|
createMessageStarterPrompt("Try a prompt", "Help me plan my day in 3 steps.")
|
||||||
|
);
|
||||||
|
} else if (chat.assistant_persona_id) {
|
||||||
|
title.textContent = "Roleplay is ready";
|
||||||
|
description.textContent = "Send a line or pick a prompt.";
|
||||||
|
actions.append(
|
||||||
|
createMessageStarterPrompt("Add tension", "Open with a tense line that raises the stakes immediately."),
|
||||||
|
createMessageStarterPrompt("Set the scene", "Describe what your character notices first in this scene.")
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
title.textContent = "What can I help with?";
|
||||||
|
description.textContent = "Write a message below or start with one of these prompts.";
|
||||||
|
actions.append(
|
||||||
|
createMessageStarterPrompt("Explain something", "Explain this topic in simple terms: "),
|
||||||
|
createMessageStarterPrompt("Plan a task", "Help me make a practical step-by-step plan for: "),
|
||||||
|
createMessageStarterPrompt("Brainstorm ideas", "Give me several creative ideas for: ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
empty.append(title, description, actions);
|
||||||
|
messagesDiv.appendChild(empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
setMessageContent,
|
||||||
|
announceResponding,
|
||||||
|
announceResponseStatus,
|
||||||
|
announceReply,
|
||||||
|
scrollMessagesToBottom,
|
||||||
|
updateJumpToLatest,
|
||||||
|
addMessage,
|
||||||
|
renderMessages
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
const focusableSelector = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||||
|
const stack = [];
|
||||||
|
|
||||||
|
function focusables(modal) {
|
||||||
|
return [...modal.querySelectorAll(focusableSelector)].filter(element =>
|
||||||
|
element.getClientRects().length && !element.closest('.hidden, [hidden]'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openModal(modal, preferredFocus) {
|
||||||
|
const previous = document.activeElement;
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
stack.push({modal, previous});
|
||||||
|
(preferredFocus || focusables(modal)[0] || modal.querySelector('[role="dialog"]'))?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeModal(modal) {
|
||||||
|
const index = stack.findLastIndex(entry => entry.modal === modal);
|
||||||
|
if (index < 0) return;
|
||||||
|
const [{previous}] = stack.splice(index, 1);
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
if (index === stack.length) {
|
||||||
|
const target = stack.length ? focusables(stack.at(-1).modal)[0] : previous;
|
||||||
|
if (target?.isConnected) target.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function topModal() { return stack.at(-1)?.modal || null; }
|
||||||
|
|
||||||
|
document.addEventListener('keydown', event => {
|
||||||
|
if (event.key !== 'Tab' || !stack.length) return;
|
||||||
|
const modal = topModal();
|
||||||
|
const elements = focusables(modal);
|
||||||
|
if (!elements.length) { event.preventDefault(); modal.querySelector('[role="dialog"]')?.focus(); return; }
|
||||||
|
const first = elements[0], last = elements.at(-1);
|
||||||
|
if (event.shiftKey && (document.activeElement === first || !modal.contains(document.activeElement))) {
|
||||||
|
event.preventDefault(); last.focus();
|
||||||
|
} else if (!event.shiftKey && (document.activeElement === last || !modal.contains(document.activeElement))) {
|
||||||
|
event.preventDefault(); first.focus();
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Ermittelt die Modellfamilie und prüft die Unterstützung des Reasoning-Modus.
|
||||||
|
*/
|
||||||
// Conservative allowlist for models supporting the boolean think API.
|
// Conservative allowlist for models supporting the boolean think API.
|
||||||
// Unknown models remain usable for normal chat.
|
// Unknown models remain usable for normal chat.
|
||||||
const reasoningFamilies = new Set(["qwen3", "deepseek-r1", "deepseek-v3.1"]);
|
const reasoningFamilies = new Set(["qwen3", "deepseek-r1", "deepseek-v3.1"]);
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Ordnet Modellen Profilinformationen für ihre Darstellung und Auswahl in der Oberfläche zu.
|
||||||
|
*/
|
||||||
export const defaultModelProfile = {
|
export const defaultModelProfile = {
|
||||||
badge: "General",
|
badge: "General",
|
||||||
summary: "Best for general questions, drafting, and everyday conversation. Use a specialized model when you need stronger coding, reasoning, or roleplay behavior."
|
summary: "Best for general questions, drafting, and everyday conversation. Use a specialized model when you need stronger coding, reasoning, or roleplay behavior."
|
||||||
|
|||||||
+3
-27
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Steuert öffentliche Seiten sowie Anmelde- und Registrierungsformulare mit Rückkehr in den Arbeitsbereich.
|
||||||
|
*/
|
||||||
import { get, post } from "/js/api.js?v=20260909-account-controls";
|
import { get, post } from "/js/api.js?v=20260909-account-controls";
|
||||||
|
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
@@ -106,30 +109,3 @@ if (recent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const featured = document.querySelector("#featuredPersonas");
|
|
||||||
if (featured) {
|
|
||||||
const result = await get("/api/discover");
|
|
||||||
const search = document.querySelector("#publicSearch");
|
|
||||||
function render() {
|
|
||||||
featured.replaceChildren();
|
|
||||||
if (result.error) { featured.textContent = result.error; return; }
|
|
||||||
const query = search?.value.trim().toLowerCase() || "";
|
|
||||||
let personas = (result.personas || []).filter(persona => `${persona.name} ${persona.description || ""}`.toLowerCase().includes(query));
|
|
||||||
if (!search) personas = personas.slice(0, 3);
|
|
||||||
if (!personas.length) { featured.textContent = query ? "No personas match your search." : "The community is just getting started. Create a persona and give the next story its voice."; return; }
|
|
||||||
for (const persona of personas) {
|
|
||||||
const card = document.createElement("article"); card.className = "feature-card";
|
|
||||||
const category = document.createElement("span"); category.className = "feature-number"; category.textContent = persona.persona_type === "assistant" ? "AI CHARACTER" : "USER PERSONA";
|
|
||||||
const title = document.createElement("h3"); title.textContent = persona.name;
|
|
||||||
const description = document.createElement("p"); description.textContent = persona.description || "A community persona, ready for a new conversation.";
|
|
||||||
const creator = document.createElement("p"); creator.textContent = `By ${persona.creator_username}`;
|
|
||||||
const link = document.createElement("a"); const destination = `/market?persona=${persona.id}`;
|
|
||||||
link.href = session.user ? destination : `/login?next=${encodeURIComponent(destination)}`;
|
|
||||||
link.textContent = session.user ? "Explore persona \u2192" : "Sign in to use persona \u2192";
|
|
||||||
card.append(category, title, description, creator, link); featured.append(card);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
render(); search?.addEventListener("input", render);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,437 @@
|
|||||||
|
import {
|
||||||
|
personaFormNotice,
|
||||||
|
roleplayCharacterSelect,
|
||||||
|
roleplayUserPersonaSelect,
|
||||||
|
roleplayStarterSuggestions,
|
||||||
|
roleplayScenarioInput,
|
||||||
|
roleplayStarterNotice,
|
||||||
|
roleplayStarterStepOne,
|
||||||
|
roleplayStarterStepTwo,
|
||||||
|
roleplayStarterBack,
|
||||||
|
roleplayStarterTitle,
|
||||||
|
roleplayStarterIntroCopy,
|
||||||
|
roleplayStarterCancel,
|
||||||
|
roleplayStarterConfirm,
|
||||||
|
activeUserPersonaStatus,
|
||||||
|
chatDrawerPersonaValue,
|
||||||
|
headerPersonaName,
|
||||||
|
clearUserPersonaBtn,
|
||||||
|
personaModal,
|
||||||
|
personaNameInput,
|
||||||
|
personaFormTitle,
|
||||||
|
personaTypeSelect,
|
||||||
|
personaPronounsInput,
|
||||||
|
personaAppearanceInput,
|
||||||
|
personaBackgroundInput,
|
||||||
|
personaDetailsInput,
|
||||||
|
personaExampleDialoguesInput,
|
||||||
|
personaExamplesField,
|
||||||
|
roleplayStarterModal,
|
||||||
|
userPersonaList,
|
||||||
|
modelSelect
|
||||||
|
} from "./dom.js?v=20260909-account-controls";
|
||||||
|
import {closeChatDrawer} from "./chatDrawer.js?v=20260909-account-controls";
|
||||||
|
import {openModal, closeModal} from "./modalFocus.js";
|
||||||
|
import {get, post, put, del} from "./api.js?v=20260909-account-controls";
|
||||||
|
|
||||||
|
export function createPersonaController({
|
||||||
|
state,
|
||||||
|
setNotice,
|
||||||
|
closePopup,
|
||||||
|
updateWorkspaceCopy,
|
||||||
|
loadChatSessions,
|
||||||
|
confirmPopup,
|
||||||
|
setChatLoading,
|
||||||
|
setChatActivity,
|
||||||
|
setActiveChat
|
||||||
|
}) {
|
||||||
|
function setPersonaFormNotice(message = "", noticeState = "") {
|
||||||
|
if (!personaFormNotice) return;
|
||||||
|
if (!message) {
|
||||||
|
personaFormNotice.textContent = "";
|
||||||
|
personaFormNotice.className = "status hidden";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
personaFormNotice.textContent = message;
|
||||||
|
personaFormNotice.className = noticeState ? `status ${noticeState}` : "status";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPersonaName(id, items) {
|
||||||
|
return items.find((persona) => persona.id === id)?.name || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRoleplayStarterSuggestions() {
|
||||||
|
const assistantPersonaId = Number(roleplayCharacterSelect?.value);
|
||||||
|
const userPersonaId = roleplayUserPersonaSelect?.value ? Number(roleplayUserPersonaSelect.value) : null;
|
||||||
|
const assistantName = getPersonaName(assistantPersonaId, state.assistantPersonas) || "the character";
|
||||||
|
const userName = userPersonaId ? getPersonaName(userPersonaId, state.userPersonas) : "me";
|
||||||
|
return [
|
||||||
|
`${assistantName} unexpectedly crosses paths with ${userName} in a place that matters to them both.`,
|
||||||
|
`${assistantName} already knows something important about ${userName} and confronts them with it.`,
|
||||||
|
`${assistantName} and ${userName} are forced to cooperate while something goes wrong around them.`,
|
||||||
|
`${assistantName} meets ${userName} in a quiet, intimate scene with unresolved tension.`,
|
||||||
|
`${assistantName} draws ${userName} into a high-stakes problem that starts immediately.`
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRoleplaySuggestions() {
|
||||||
|
if (!roleplayStarterSuggestions) return;
|
||||||
|
roleplayStarterSuggestions.innerHTML = "";
|
||||||
|
getRoleplayStarterSuggestions().forEach((suggestion) => {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "prompt-chip";
|
||||||
|
button.textContent = suggestion;
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
if (roleplayScenarioInput) {
|
||||||
|
roleplayScenarioInput.value = suggestion;
|
||||||
|
roleplayScenarioInput.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
roleplayStarterSuggestions.appendChild(button);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRoleplayStarterNotice(message = "", noticeState = "") {
|
||||||
|
if (!roleplayStarterNotice) return;
|
||||||
|
if (!message) {
|
||||||
|
roleplayStarterNotice.textContent = "";
|
||||||
|
roleplayStarterNotice.className = "status hidden";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
roleplayStarterNotice.textContent = message;
|
||||||
|
roleplayStarterNotice.className = noticeState ? `status ${noticeState}` : "status";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRoleplayStarterStep() {
|
||||||
|
const isSceneStep = state.roleplayStarterStep === 2;
|
||||||
|
if (roleplayStarterStepOne) roleplayStarterStepOne.classList.toggle("hidden", isSceneStep);
|
||||||
|
if (roleplayStarterStepTwo) roleplayStarterStepTwo.classList.toggle("hidden", !isSceneStep);
|
||||||
|
if (roleplayStarterBack) roleplayStarterBack.classList.toggle("hidden", !isSceneStep);
|
||||||
|
if (roleplayStarterTitle) {
|
||||||
|
roleplayStarterTitle.textContent = isSceneStep ? "Shape the opening scene" : "Choose your roleplay";
|
||||||
|
}
|
||||||
|
if (roleplayStarterIntroCopy) {
|
||||||
|
roleplayStarterIntroCopy.textContent = isSceneStep
|
||||||
|
? "Add an optional custom scenario. If you leave it empty, the AI Character will invent its own opening scene."
|
||||||
|
: "Pick a character and choose who you are before moving on to the opening scene.";
|
||||||
|
}
|
||||||
|
if (roleplayStarterCancel) {
|
||||||
|
roleplayStarterCancel.textContent = isSceneStep ? "Close" : "Cancel";
|
||||||
|
}
|
||||||
|
if (roleplayStarterConfirm) {
|
||||||
|
roleplayStarterConfirm.textContent = isSceneStep ? "Start roleplay" : "Continue";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPersonaSection() {
|
||||||
|
const activeChat = state.chatSessions.find((chat) => chat.id === state.activeChatId);
|
||||||
|
const chatPersona = state.userPersonas.find((persona) => persona.id === activeChat?.user_persona_id);
|
||||||
|
if (activeUserPersonaStatus) {
|
||||||
|
activeUserPersonaStatus.textContent = activeChat
|
||||||
|
? `Using in this chat: ${chatPersona?.name || "yourself"}. Default: ${getPersonaName(state.activeUserPersonaId, state.userPersonas) || "yourself"}.`
|
||||||
|
: `Default user persona: ${getPersonaName(state.activeUserPersonaId, state.userPersonas) || "yourself"}.`;
|
||||||
|
}
|
||||||
|
if (chatDrawerPersonaValue) {
|
||||||
|
chatDrawerPersonaValue.textContent = chatPersona?.name || "Yourself";
|
||||||
|
if (headerPersonaName) headerPersonaName.textContent = chatPersona?.name || "Yourself";
|
||||||
|
}
|
||||||
|
if (clearUserPersonaBtn) clearUserPersonaBtn.disabled = !activeChat?.user_persona_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeShowPersonaNudge() {
|
||||||
|
if (localStorage.getItem("krishd-persona-nudge-shown") === "true") return;
|
||||||
|
const assistantOnlyChats = state.chatSessions.filter((chat) => !chat.assistant_persona_id).length;
|
||||||
|
if (assistantOnlyChats < 3) return;
|
||||||
|
localStorage.setItem("krishd-persona-nudge-shown", "true");
|
||||||
|
setNotice("Want to personalize responses? Try personas in Tools.", "success");
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPersonaForm(persona = null, personaType = "assistant") {
|
||||||
|
closeChatDrawer();
|
||||||
|
closePopup(false);
|
||||||
|
openModal(personaModal, personaNameInput);
|
||||||
|
if (persona) {
|
||||||
|
state.editingPersonaId = persona.id; state.editingPersonaType = persona.persona_type || "assistant";
|
||||||
|
personaFormTitle.textContent = `Edit ${state.editingPersonaType === "user" ? "your persona" : "AI Character"}`;
|
||||||
|
personaTypeSelect.value = state.editingPersonaType; personaTypeSelect.disabled = true;
|
||||||
|
personaNameInput.value = persona.name || ""; personaPronounsInput.value = persona.pronouns || "";
|
||||||
|
personaAppearanceInput.value = persona.appearance || ""; personaBackgroundInput.value = persona.background || ""; personaDetailsInput.value = persona.details || "";
|
||||||
|
personaExampleDialoguesInput.value = persona.example_dialogues || "";
|
||||||
|
} else {
|
||||||
|
state.editingPersonaId = null; state.editingPersonaType = personaType;
|
||||||
|
personaFormTitle.textContent = `Create ${personaType === "user" ? "your persona" : "AI Character"}`;
|
||||||
|
personaTypeSelect.value = personaType; personaTypeSelect.disabled = false;
|
||||||
|
personaNameInput.value = ""; personaPronounsInput.value = ""; personaAppearanceInput.value = ""; personaBackgroundInput.value = ""; personaDetailsInput.value = "";
|
||||||
|
personaExampleDialoguesInput.value = "";
|
||||||
|
}
|
||||||
|
personaExamplesField.classList.toggle("hidden", personaTypeSelect.value !== "assistant");
|
||||||
|
setPersonaFormNotice("");
|
||||||
|
personaNameInput.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePersonaForm() { closeModal(personaModal); state.editingPersonaId = null; state.editingPersonaType = "assistant"; personaTypeSelect.disabled = false; setPersonaFormNotice(""); }
|
||||||
|
|
||||||
|
function populateRoleplayStarter() {
|
||||||
|
if (!roleplayCharacterSelect || !roleplayUserPersonaSelect) return;
|
||||||
|
roleplayCharacterSelect.innerHTML = "";
|
||||||
|
const placeholder = document.createElement("option");
|
||||||
|
placeholder.value = "";
|
||||||
|
placeholder.textContent = "Choose AI Character";
|
||||||
|
placeholder.disabled = true;
|
||||||
|
placeholder.selected = true;
|
||||||
|
roleplayCharacterSelect.appendChild(placeholder);
|
||||||
|
state.assistantPersonas.forEach((persona) => {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = String(persona.id);
|
||||||
|
option.textContent = persona.name;
|
||||||
|
option.selected = persona.id === state.roleplayPresetCharacterId;
|
||||||
|
roleplayCharacterSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
if (state.roleplayPresetCharacterId) {
|
||||||
|
roleplayCharacterSelect.value = String(state.roleplayPresetCharacterId);
|
||||||
|
}
|
||||||
|
roleplayUserPersonaSelect.innerHTML = "";
|
||||||
|
const noneOption = document.createElement("option");
|
||||||
|
noneOption.value = "";
|
||||||
|
noneOption.textContent = "Myself (no user persona)";
|
||||||
|
roleplayUserPersonaSelect.appendChild(noneOption);
|
||||||
|
state.userPersonas.forEach((persona) => {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = String(persona.id);
|
||||||
|
option.textContent = persona.name;
|
||||||
|
option.selected = persona.id === state.activeUserPersonaId;
|
||||||
|
roleplayUserPersonaSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
if (state.assistantPersonas.length === 0) {
|
||||||
|
setRoleplayStarterNotice("Create an AI Character first, then start roleplay.", "error");
|
||||||
|
roleplayStarterConfirm.disabled = true;
|
||||||
|
} else {
|
||||||
|
setRoleplayStarterNotice("");
|
||||||
|
roleplayStarterConfirm.disabled = false;
|
||||||
|
}
|
||||||
|
updateRoleplaySuggestions();
|
||||||
|
renderRoleplayStarterStep();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRoleplayStarter(characterId = null) {
|
||||||
|
closeChatDrawer();
|
||||||
|
state.roleplayPresetCharacterId = characterId;
|
||||||
|
state.roleplayStarterStep = 1;
|
||||||
|
populateRoleplayStarter();
|
||||||
|
if (roleplayScenarioInput) roleplayScenarioInput.value = "";
|
||||||
|
setRoleplayStarterNotice("");
|
||||||
|
openModal(roleplayStarterModal, roleplayCharacterSelect);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeRoleplayStarter() {
|
||||||
|
if (!roleplayStarterModal) return;
|
||||||
|
closeModal(roleplayStarterModal);
|
||||||
|
state.roleplayPresetCharacterId = null;
|
||||||
|
state.roleplayStarterStep = 1;
|
||||||
|
roleplayStarterConfirm.disabled = false;
|
||||||
|
if (roleplayScenarioInput) roleplayScenarioInput.value = "";
|
||||||
|
setRoleplayStarterNotice("");
|
||||||
|
renderRoleplayStarterStep();
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToRoleplayStarterSceneStep() {
|
||||||
|
if (!state.assistantPersonas.length) {
|
||||||
|
return setRoleplayStarterNotice("Create an AI Character first, then start roleplay.", "error");
|
||||||
|
}
|
||||||
|
if (!roleplayCharacterSelect.value) {
|
||||||
|
return setRoleplayStarterNotice("Choose a character to continue.", "error");
|
||||||
|
}
|
||||||
|
state.roleplayStarterStep = 2;
|
||||||
|
setRoleplayStarterNotice("");
|
||||||
|
renderRoleplayStarterStep();
|
||||||
|
if (roleplayScenarioInput) roleplayScenarioInput.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPersonaList(items, activeId, listElement, personaType) {
|
||||||
|
if (!listElement) {
|
||||||
|
renderPersonaSection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
listElement.innerHTML = "";
|
||||||
|
if (!items.length) {
|
||||||
|
const empty = document.createElement("p");
|
||||||
|
empty.className = "status persona-status";
|
||||||
|
empty.textContent = "No personas yet. Create one to get started.";
|
||||||
|
listElement.appendChild(empty);
|
||||||
|
renderPersonaSection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const selectedId = personaType === "user"
|
||||||
|
? state.chatSessions.find((chat) => chat.id === state.activeChatId)?.user_persona_id ?? null
|
||||||
|
: activeId;
|
||||||
|
items.forEach((persona) => {
|
||||||
|
const item = document.createElement("div"), titleRow = document.createElement("div"), title = document.createElement("h4");
|
||||||
|
const tag = document.createElement("span"), meta = document.createElement("p"), actions = document.createElement("div");
|
||||||
|
item.className = `persona-item${personaType === "user" && persona.id === selectedId ? " active" : ""}`; titleRow.className = "persona-title-row"; actions.className = "persona-item-actions";
|
||||||
|
tag.className = `persona-role-tag ${personaType === "user" ? "user" : "ai"}`; tag.textContent = personaType === "user" ? "You" : "AI";
|
||||||
|
title.textContent = persona.name; meta.textContent = persona.pronouns ? `Pronouns: ${persona.pronouns}` : "Pronouns: n/a";
|
||||||
|
[[personaType === "assistant" ? "Start roleplay" : "Use in this chat", () => personaType === "user" ? setChatUserPersona(persona.id) : equipPersona(persona.id, personaType), personaType === "user" && persona.id === selectedId],
|
||||||
|
...(personaType === "user" ? [["Set default", () => equipPersona(persona.id, personaType), persona.id === state.activeUserPersonaId]] : []),
|
||||||
|
["Edit", () => openPersonaForm(persona), false],
|
||||||
|
[state.publishedPersonaIds.has(persona.id) ? "Update listing" : "Publish", () => publishPersona(persona.id), false],
|
||||||
|
["Delete", () => deletePersona(persona.id), false]]
|
||||||
|
.forEach(([label, handler, disabled]) => {
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.type = "button"; btn.textContent = label; btn.disabled = disabled; btn.addEventListener("click", handler); actions.appendChild(btn);
|
||||||
|
});
|
||||||
|
if (state.publishedPersonaIds.has(persona.id)) {
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.type = "button"; btn.textContent = "Unpublish"; btn.addEventListener("click", () => unpublishPersona(persona.id)); actions.appendChild(btn);
|
||||||
|
}
|
||||||
|
titleRow.append(title, tag); item.append(titleRow, meta, actions); listElement.appendChild(item);
|
||||||
|
});
|
||||||
|
renderPersonaSection();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPersonas() {
|
||||||
|
const res = await get("/personas");
|
||||||
|
if (res.error) return setNotice(res.error, "error");
|
||||||
|
state.assistantPersonas = res.assistantPersonas || []; state.userPersonas = res.userPersonas || [];
|
||||||
|
state.activeUserPersonaId = res.activeUserPersonaId || null; state.publishedPersonaIds = new Set(res.publishedPersonaIds || []);
|
||||||
|
renderPersonaList(state.userPersonas, state.activeUserPersonaId, userPersonaList, "user");
|
||||||
|
populateRoleplayStarter();
|
||||||
|
renderPersonaSection();
|
||||||
|
updateWorkspaceCopy();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function equipPersona(id, type) {
|
||||||
|
if (type === "assistant") {
|
||||||
|
openRoleplayStarter(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await post(`/personas/${id}/equip-user`, {});
|
||||||
|
if (res.error) return setNotice(res.error, "error");
|
||||||
|
state.activeUserPersonaId = id;
|
||||||
|
renderPersonaList(state.userPersonas, state.activeUserPersonaId, userPersonaList, "user");
|
||||||
|
setNotice("User persona updated.", "success");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setChatUserPersona(id) {
|
||||||
|
if (!state.activeChatId) return setNotice("Open a chat before choosing a user persona.", "error");
|
||||||
|
const res = await put(`/chats/${state.activeChatId}/user-persona`, {userPersonaId: id});
|
||||||
|
if (res.error) return setNotice(res.error, "error");
|
||||||
|
const chat = state.chatSessions.find((item) => item.id === state.activeChatId);
|
||||||
|
if (chat) Object.assign(chat, res.chat);
|
||||||
|
renderPersonaList(state.userPersonas, res.chat.user_persona_id, userPersonaList, "user");
|
||||||
|
setNotice("User persona switched for this chat.", "success");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearChatUserPersona() {
|
||||||
|
if (!state.activeChatId) return;
|
||||||
|
const res = await put(`/chats/${state.activeChatId}/user-persona`, {userPersonaId: null});
|
||||||
|
if (res.error) return setNotice(res.error, "error");
|
||||||
|
const chat = state.chatSessions.find((item) => item.id === state.activeChatId);
|
||||||
|
if (chat) Object.assign(chat, res.chat);
|
||||||
|
renderPersonaList(state.userPersonas, null, userPersonaList, "user");
|
||||||
|
setNotice("This chat will use yourself.", "success");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePersona() {
|
||||||
|
const payload = {
|
||||||
|
personaType: personaTypeSelect.value,
|
||||||
|
name: personaNameInput.value.trim(),
|
||||||
|
pronouns: personaPronounsInput.value.trim(),
|
||||||
|
appearance: personaAppearanceInput.value.trim(),
|
||||||
|
background: personaBackgroundInput.value.trim(),
|
||||||
|
details: personaDetailsInput.value.trim(),
|
||||||
|
exampleDialogues: personaTypeSelect.value === "assistant" ? personaExampleDialoguesInput.value.trim() : ""
|
||||||
|
};
|
||||||
|
if (!payload.name) return setPersonaFormNotice("Persona name is required.", "error");
|
||||||
|
const res = state.editingPersonaId ? await put(`/personas/${state.editingPersonaId}`, payload) : await post("/personas", payload);
|
||||||
|
if (res.error) return setPersonaFormNotice(res.error, "error");
|
||||||
|
const wasEditing = Boolean(state.editingPersonaId);
|
||||||
|
await loadPersonas();
|
||||||
|
if (payload.personaType === "assistant") await loadChatSessions();
|
||||||
|
closePersonaForm(); setNotice(wasEditing ? "Persona updated." : "Persona created.", "success");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePersona(id) {
|
||||||
|
const persona = state.assistantPersonas.find((item) => item.id === id) || state.userPersonas.find((item) => item.id === id);
|
||||||
|
if (!persona) return;
|
||||||
|
const confirmed = await confirmPopup({
|
||||||
|
eyebrow: persona.persona_type === "user" ? "Your persona" : "AI Character",
|
||||||
|
title: "Delete persona",
|
||||||
|
description: `Delete "${persona.name}"? This removes it from your library and any active slot.`,
|
||||||
|
confirmLabel: "Delete",
|
||||||
|
danger: true
|
||||||
|
});
|
||||||
|
if (!confirmed) return;
|
||||||
|
const res = await del(`/personas/${id}`);
|
||||||
|
if (res.error) return setNotice(res.error, "error");
|
||||||
|
await loadPersonas();
|
||||||
|
if (persona.persona_type === "assistant") await loadChatSessions();
|
||||||
|
setNotice("Persona deleted.", "success");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishPersona(id) { const res = await post(`/personas/${id}/publish`, {}); if (res.error) return setNotice(res.error, "error"); await loadPersonas(); setNotice("Persona published.", "success"); }
|
||||||
|
|
||||||
|
async function unpublishPersona(id) { const res = await post(`/personas/${id}/unpublish`, {}); if (res.error) return setNotice(res.error, "error"); await loadPersonas(); setNotice("Persona unpublished.", "success"); }
|
||||||
|
|
||||||
|
async function startRoleplay() {
|
||||||
|
if (state.roleplayStarterStep === 1) {
|
||||||
|
goToRoleplayStarterSceneStep();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setChatLoading(true, "Starting roleplay...");
|
||||||
|
setChatActivity(true, {
|
||||||
|
eyebrow: "Starting roleplay",
|
||||||
|
title: "Building the opening scene",
|
||||||
|
detail: "Generating the first beat and opening the roleplay chat."
|
||||||
|
});
|
||||||
|
const assistantPersonaId = Number(roleplayCharacterSelect.value);
|
||||||
|
const userPersonaId = roleplayUserPersonaSelect.value ? Number(roleplayUserPersonaSelect.value) : null;
|
||||||
|
const scenarioPrompt = roleplayScenarioInput?.value.trim() || "";
|
||||||
|
if (!assistantPersonaId) {
|
||||||
|
setChatLoading(false);
|
||||||
|
setChatActivity(false);
|
||||||
|
return setRoleplayStarterNotice("Choose a character to start the roleplay.", "error");
|
||||||
|
}
|
||||||
|
roleplayStarterConfirm.disabled = true;
|
||||||
|
setRoleplayStarterNotice("Building the opening scene...");
|
||||||
|
const res = await post("/roleplays/start", {
|
||||||
|
assistantPersonaId,
|
||||||
|
userPersonaId,
|
||||||
|
scenarioPrompt,
|
||||||
|
model: modelSelect.value
|
||||||
|
}, {timeoutMs: 660000});
|
||||||
|
roleplayStarterConfirm.disabled = false;
|
||||||
|
if (res.error || !res.chat) {
|
||||||
|
setChatLoading(false);
|
||||||
|
setChatActivity(false);
|
||||||
|
return setRoleplayStarterNotice(res.error || "Unable to start roleplay.", "error");
|
||||||
|
}
|
||||||
|
closeRoleplayStarter();
|
||||||
|
await loadChatSessions();
|
||||||
|
await setActiveChat(res.chat.id);
|
||||||
|
setNotice(
|
||||||
|
res.degraded
|
||||||
|
? "Roleplay started, but the opening scene timed out. Send the first message to continue."
|
||||||
|
: res.generatedInitialMessage ? "Roleplay started with a fresh scene." : "Roleplay reopened.",
|
||||||
|
res.degraded ? "error" : "success"
|
||||||
|
);
|
||||||
|
setChatLoading(false);
|
||||||
|
setChatActivity(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
renderPersonaSection,
|
||||||
|
updateRoleplaySuggestions,
|
||||||
|
setRoleplayStarterNotice,
|
||||||
|
renderRoleplayStarterStep,
|
||||||
|
maybeShowPersonaNudge,
|
||||||
|
openPersonaForm,
|
||||||
|
closePersonaForm,
|
||||||
|
openRoleplayStarter,
|
||||||
|
closeRoleplayStarter,
|
||||||
|
loadPersonas,
|
||||||
|
clearChatUserPersona,
|
||||||
|
savePersona,
|
||||||
|
startRoleplay
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Überträgt Persona-Daten zwischen Formularfeldern und den verwendeten Datenobjekten.
|
||||||
|
*/
|
||||||
const personaFieldNames = ["name", "pronouns", "appearance", "background", "details", "exampleDialogues"];
|
const personaFieldNames = ["name", "pronouns", "appearance", "background", "details", "exampleDialogues"];
|
||||||
|
|
||||||
export function populatePersonaForm(fields, persona = {}) {
|
export function populatePersonaForm(fields, persona = {}) {
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import {get} from "./api.js";
|
||||||
|
|
||||||
|
const grid = document.getElementById("personalityGrid");
|
||||||
|
const iconPaths = {
|
||||||
|
spark: '<path d="M12 2.8 14.5 9.5 21.2 12l-6.7 2.5L12 21.2l-2.5-6.7L2.8 12l6.7-2.5L12 2.8Z"/><path d="m19 2 .8 2.2L22 5l-2.2.8L19 8l-.8-2.2L16 5l2.2-.8L19 2Z"/>',
|
||||||
|
quill: '<path d="M20.8 3.2c-6.3-.4-11 2.3-13.2 7.1-1 2.1-.9 4.1-.5 5.7 1.6.4 3.6.5 5.7-.5 4.8-2.2 7.5-6.9 7.1-13.2Z"/><path d="M3 21c3.5-5.1 7.8-8.8 13-11.4M7.2 16.4l-3.4-.3M10.2 13.1l-.1-3.4"/>',
|
||||||
|
code: '<path d="m8 7-5 5 5 5M16 7l5 5-5 5M14 4l-4 16"/>',
|
||||||
|
flask: '<path d="M9 3h6M10 3v6l-5.8 9.5A2.3 2.3 0 0 0 6.2 22h11.6a2.3 2.3 0 0 0 2-3.5L14 9V3M7.5 16h9"/>',
|
||||||
|
"book-open": '<path d="M12 7.2C9.8 5.4 6.5 5 3 5.8v13c3.5-.8 6.8-.4 9 1.4m0-13c2.2-1.8 5.5-2.2 9-1.4v13c-3.5-.8-6.8-.4-9 1.4m0-13v13"/>',
|
||||||
|
logic: '<circle cx="6" cy="6" r="2"/><circle cx="18" cy="6" r="2"/><circle cx="12" cy="18" r="2"/><path d="M8 7.2 10.8 16M16 7.2 13.2 16M8 6h8"/>'
|
||||||
|
};
|
||||||
|
const result = await get("/personalities");
|
||||||
|
if (result.error) {
|
||||||
|
grid.textContent = result.error;
|
||||||
|
} else {
|
||||||
|
for (const item of result.personalities || []) {
|
||||||
|
const card = document.createElement("article");
|
||||||
|
card.className = "personality-card";
|
||||||
|
card.style.setProperty("--personality-accent", item.accentColor);
|
||||||
|
const icon = document.createElement("span");
|
||||||
|
icon.className = "personality-icon";
|
||||||
|
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||||
|
svg.setAttribute("viewBox", "0 0 24 24");
|
||||||
|
svg.setAttribute("fill", "none");
|
||||||
|
svg.setAttribute("stroke", "currentColor");
|
||||||
|
svg.setAttribute("stroke-width", "1.65");
|
||||||
|
svg.setAttribute("stroke-linecap", "round");
|
||||||
|
svg.setAttribute("stroke-linejoin", "round");
|
||||||
|
svg.setAttribute("aria-hidden", "true");
|
||||||
|
svg.innerHTML = iconPaths[item.icon] || iconPaths.spark;
|
||||||
|
icon.append(svg);
|
||||||
|
icon.title = item.name;
|
||||||
|
icon.setAttribute("aria-hidden", "true");
|
||||||
|
const specialization = document.createElement("p");
|
||||||
|
specialization.className = "personality-specialization";
|
||||||
|
specialization.textContent = `${item.specialization}${item.isDefault ? " · Default" : ""}`;
|
||||||
|
const name = document.createElement("h2");
|
||||||
|
name.textContent = item.name;
|
||||||
|
const description = document.createElement("p");
|
||||||
|
description.className = "personality-description";
|
||||||
|
description.textContent = item.description;
|
||||||
|
const details = document.createElement("details");
|
||||||
|
details.className = "personality-details";
|
||||||
|
const summary = document.createElement("summary");
|
||||||
|
summary.textContent = "Learn more";
|
||||||
|
const longDescription = document.createElement("p");
|
||||||
|
longDescription.textContent = item.longDescription;
|
||||||
|
const inspiration = document.createElement("p");
|
||||||
|
inspiration.className = "personality-inspiration";
|
||||||
|
inspiration.textContent = `Thematic inspiration: ${item.historicalInspiration}`;
|
||||||
|
details.append(summary, longDescription, inspiration);
|
||||||
|
const action = document.createElement("a");
|
||||||
|
action.className = "primary-action";
|
||||||
|
action.innerHTML = '<span>Start a chat</span><span aria-hidden="true">→</span>';
|
||||||
|
action.setAttribute("aria-label", `Start a chat with ${item.name}, ${item.specialization}`);
|
||||||
|
action.href = `/app/chats/new?personality=${encodeURIComponent(item.id)}`;
|
||||||
|
const identity = document.createElement("div");
|
||||||
|
identity.className = "personality-identity";
|
||||||
|
identity.append(specialization, name);
|
||||||
|
card.append(icon, identity, description, details, action);
|
||||||
|
grid.append(card);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Steuert die Reasoning-Auswahl und die aufklappbare Anzeige gestreamter Gedankengänge.
|
||||||
|
*/
|
||||||
import {supportsReasoning} from "./modelCapabilities.js?v=20260921-reasoning-capability";
|
import {supportsReasoning} from "./modelCapabilities.js?v=20260921-reasoning-capability";
|
||||||
|
|
||||||
export function createReasoningToggle(button) {
|
export function createReasoningToggle(button) {
|
||||||
@@ -5,6 +8,7 @@ export function createReasoningToggle(button) {
|
|||||||
let supported = false;
|
let supported = false;
|
||||||
let busy = false;
|
let busy = false;
|
||||||
const render = () => {
|
const render = () => {
|
||||||
|
button.hidden = !supported;
|
||||||
button.disabled = !supported || busy;
|
button.disabled = !supported || busy;
|
||||||
button.setAttribute("aria-pressed", String(reasoningEnabled));
|
button.setAttribute("aria-pressed", String(reasoningEnabled));
|
||||||
button.title = !supported ? "This model does not support reasoning"
|
button.title = !supported ? "This model does not support reasoning"
|
||||||
@@ -43,6 +47,7 @@ export function updateReasoning(messageElement, thinking, streaming = false) {
|
|||||||
}
|
}
|
||||||
details.querySelector("summary").textContent = streaming ? "Reasoning · Thinking…" : "Reasoning";
|
details.querySelector("summary").textContent = streaming ? "Reasoning · Thinking…" : "Reasoning";
|
||||||
const content = details.querySelector(".msg-reasoning-content");
|
const content = details.querySelector(".msg-reasoning-content");
|
||||||
|
// Nur automatisch nach unten scrollen, wenn die Ansicht bereits nahe am Ende steht.
|
||||||
const follow = content.scrollHeight - content.scrollTop - content.clientHeight < 40;
|
const follow = content.scrollHeight - content.scrollTop - content.clientHeight < 40;
|
||||||
content.textContent = thinking;
|
content.textContent = thinking;
|
||||||
if (streaming && follow) content.scrollTop = content.scrollHeight;
|
if (streaming && follow) content.scrollTop = content.scrollHeight;
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* @file Ersetzt eine Antwort während der Neugenerierung in ihrer Nachrichtenkarte und stellt sie bei Fehlern wieder her.
|
||||||
|
*/
|
||||||
import {stream} from "./api.js";
|
import {stream} from "./api.js";
|
||||||
import {updateReasoning, finishReasoning} from "./reasoning.js";
|
import {updateReasoning, finishReasoning} from "./reasoning.js";
|
||||||
import {appendSources} from "./webSearch.js";
|
import {appendSources} from "./webSearch.js";
|
||||||
|
import {createStreamRenderer} from "./streamRenderer.js";
|
||||||
|
|
||||||
// Keep the original nodes (including action handlers) until a committed answer arrives.
|
// Keep the original nodes (including action handlers) until a committed answer arrives.
|
||||||
export async function regenerateInline({card, scroller, endpoint, payload, renderContent, complete}) {
|
export async function regenerateInline({card, scroller, endpoint, payload, renderContent, complete}) {
|
||||||
@@ -22,8 +26,17 @@ export async function regenerateInline({card, scroller, endpoint, payload, rende
|
|||||||
// Anchor to this response, including when a long old answer collapses.
|
// Anchor to this response, including when a long old answer collapses.
|
||||||
update(() => card.replaceChildren(original[0].cloneNode(true), status, body));
|
update(() => card.replaceChildren(original[0].cloneNode(true), status, body));
|
||||||
card.setAttribute("aria-busy", "true");
|
card.setAttribute("aria-busy", "true");
|
||||||
|
const rendering = createStreamRenderer({
|
||||||
|
onThinking: text => update(() => updateReasoning(card, text, true)),
|
||||||
|
onReply: text => update(() => {
|
||||||
|
status.hidden = Boolean(text);
|
||||||
|
if (text) finishReasoning(card);
|
||||||
|
renderContent(body, text);
|
||||||
|
})
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
await stream(endpoint, payload, {
|
await stream(endpoint, payload, {
|
||||||
|
...rendering,
|
||||||
onStatus: data => update(() => {
|
onStatus: data => update(() => {
|
||||||
status.textContent = data.status === "searching" ? "Searching the web..."
|
status.textContent = data.status === "searching" ? "Searching the web..."
|
||||||
: data.status === "analyzing" ? "Analyzing attachments..." : "Generating...";
|
: data.status === "analyzing" ? "Analyzing attachments..." : "Generating...";
|
||||||
@@ -32,20 +45,16 @@ export async function regenerateInline({card, scroller, endpoint, payload, rende
|
|||||||
appendSources(card, data.sources);
|
appendSources(card, data.sources);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
onThinking: ({fullThinking}) => update(() => updateReasoning(card, fullThinking, true)),
|
|
||||||
onChunk: ({fullReply}) => update(() => {
|
|
||||||
status.hidden = Boolean(fullReply);
|
|
||||||
if (fullReply) finishReasoning(card);
|
|
||||||
renderContent(body, fullReply);
|
|
||||||
}),
|
|
||||||
onError: ({error}) => { throw new Error(error || "Unable to regenerate message."); },
|
|
||||||
onDone: ({message}) => {
|
onDone: ({message}) => {
|
||||||
if (!message) throw new Error("Missing regenerated response.");
|
if (!message) throw new Error("Missing regenerated response.");
|
||||||
|
rendering.dispose();
|
||||||
update(() => complete(message));
|
update(() => complete(message));
|
||||||
committed = true;
|
committed = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
|
rendering.dispose();
|
||||||
|
// Bei Fehler oder Abbruch die ursprünglichen Knoten samt Ereignishandlern wieder einsetzen.
|
||||||
if (!committed) update(() => card.replaceChildren(...original));
|
if (!committed) update(() => card.replaceChildren(...original));
|
||||||
card.removeAttribute("aria-busy");
|
card.removeAttribute("aria-busy");
|
||||||
}
|
}
|
||||||
|
|||||||
+369
-1314
File diff suppressed because it is too large
Load Diff
+25
-24
@@ -1,4 +1,7 @@
|
|||||||
import { del, get, post, put } from "./api.js?v=20260909-account-controls";
|
/**
|
||||||
|
* @file Steuert die Einstellungsoberfläche für Darstellung, Konto, Personas und Diagnosen.
|
||||||
|
*/
|
||||||
|
import { del, get, post, put, request } from "./api.js?v=20260909-account-controls";
|
||||||
import {
|
import {
|
||||||
applyTheme as applySharedTheme,
|
applyTheme as applySharedTheme,
|
||||||
applyThemeMode as applySharedThemeMode,
|
applyThemeMode as applySharedThemeMode,
|
||||||
@@ -6,6 +9,7 @@ import {
|
|||||||
} from "./themeController.js?v=20260909-account-controls";
|
} from "./themeController.js?v=20260909-account-controls";
|
||||||
import { clearStatus, setStatus } from "./statusNotice.js?v=20260909-account-controls";
|
import { clearStatus, setStatus } from "./statusNotice.js?v=20260909-account-controls";
|
||||||
import { populatePersonaForm, readPersonaForm } from "./personaForm.js?v=20260909-account-controls";
|
import { populatePersonaForm, readPersonaForm } from "./personaForm.js?v=20260909-account-controls";
|
||||||
|
import { openModal, closeModal, topModal } from "./modalFocus.js";
|
||||||
|
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
const settingsNotice = $("settingsNotice");
|
const settingsNotice = $("settingsNotice");
|
||||||
@@ -62,7 +66,6 @@ const settingsPopupConfirm = $("settingsPopupConfirm");
|
|||||||
const settingsPopupTitle = $("settingsPopupTitle");
|
const settingsPopupTitle = $("settingsPopupTitle");
|
||||||
const settingsPopupEyebrow = $("settingsPopupEyebrow");
|
const settingsPopupEyebrow = $("settingsPopupEyebrow");
|
||||||
const settingsPopupDescription = $("settingsPopupDescription");
|
const settingsPopupDescription = $("settingsPopupDescription");
|
||||||
const settingsPopupField = $("settingsPopupField");
|
|
||||||
const settingsPopupInputLabel = $("settingsPopupInputLabel");
|
const settingsPopupInputLabel = $("settingsPopupInputLabel");
|
||||||
const settingsPopupInput = $("settingsPopupInput");
|
const settingsPopupInput = $("settingsPopupInput");
|
||||||
const settingsPersonaFields = {
|
const settingsPersonaFields = {
|
||||||
@@ -94,7 +97,7 @@ function setPersonaNotice(message = "", state = "") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closeSettingsPopup(value = null) {
|
function closeSettingsPopup(value = null) {
|
||||||
settingsPopupModal.classList.add("hidden");
|
closeModal(settingsPopupModal);
|
||||||
if (settingsPopupResolver) {
|
if (settingsPopupResolver) {
|
||||||
const resolve = settingsPopupResolver;
|
const resolve = settingsPopupResolver;
|
||||||
settingsPopupResolver = null;
|
settingsPopupResolver = null;
|
||||||
@@ -118,7 +121,7 @@ function promptSettingsPopup({
|
|||||||
settingsPopupInput.value = value;
|
settingsPopupInput.value = value;
|
||||||
settingsPopupInput.placeholder = placeholder;
|
settingsPopupInput.placeholder = placeholder;
|
||||||
settingsPopupConfirm.textContent = confirmLabel;
|
settingsPopupConfirm.textContent = confirmLabel;
|
||||||
settingsPopupModal.classList.remove("hidden");
|
openModal(settingsPopupModal, settingsPopupInput);
|
||||||
settingsPopupInput.focus();
|
settingsPopupInput.focus();
|
||||||
settingsPopupInput.select();
|
settingsPopupInput.select();
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
@@ -230,12 +233,11 @@ function openPersonaModal(persona = null, personaType = "assistant") {
|
|||||||
populatePersonaForm(settingsPersonaFields, persona || {});
|
populatePersonaForm(settingsPersonaFields, persona || {});
|
||||||
syncPersonaExamplesField();
|
syncPersonaExamplesField();
|
||||||
setPersonaNotice("");
|
setPersonaNotice("");
|
||||||
settingsPersonaModal.classList.remove("hidden");
|
openModal(settingsPersonaModal, settingsPersonaName);
|
||||||
settingsPersonaName.focus();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function closePersonaModal() {
|
function closePersonaModal() {
|
||||||
settingsPersonaModal.classList.add("hidden");
|
closeModal(settingsPersonaModal);
|
||||||
editingPersonaId = null;
|
editingPersonaId = null;
|
||||||
setPersonaNotice("");
|
setPersonaNotice("");
|
||||||
}
|
}
|
||||||
@@ -314,7 +316,7 @@ async function loadProfile() {
|
|||||||
if (accountName) { accountName.textContent = res.username || ""; accountName.title = res.username || ""; }
|
if (accountName) { accountName.textContent = res.username || ""; accountName.title = res.username || ""; }
|
||||||
currentUsernameInput.value = res.username || "";
|
currentUsernameInput.value = res.username || "";
|
||||||
newUsernameInput.value = res.username || "";
|
newUsernameInput.value = res.username || "";
|
||||||
isAdminUser = res.username === "admin";
|
isAdminUser = res.role === "admin";
|
||||||
diagnosticsNavItem.classList.toggle("hidden", !isAdminUser);
|
diagnosticsNavItem.classList.toggle("hidden", !isAdminUser);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -497,9 +499,8 @@ usernameForm.addEventListener("submit", async (event) => {
|
|||||||
setNotice(res.error, "error");
|
setNotice(res.error, "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
currentUsernameInput.value = res.username;
|
|
||||||
newUsernameInput.value = res.username;
|
|
||||||
usernamePasswordInput.value = "";
|
usernamePasswordInput.value = "";
|
||||||
|
if (!await loadProfile()) return;
|
||||||
setNotice("Username updated.", "success");
|
setNotice("Username updated.", "success");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -546,8 +547,8 @@ deleteAccountBtn.addEventListener("click", () => { void deleteAccount(); });
|
|||||||
refreshDiagnosticsBtn.addEventListener("click", () => { void loadDiagnostics(); });
|
refreshDiagnosticsBtn.addEventListener("click", () => { void loadDiagnostics(); });
|
||||||
|
|
||||||
settingsNavItems.forEach((item) => item.addEventListener("click", () => setSettingsView(item.dataset.settingsView)));
|
settingsNavItems.forEach((item) => item.addEventListener("click", () => setSettingsView(item.dataset.settingsView)));
|
||||||
createAiCharacterBtn.addEventListener("click", () => openPersonaModal(null, "assistant"));
|
createAiCharacterBtn?.addEventListener("click", () => openPersonaModal(null, "assistant"));
|
||||||
createUserPersonaBtn.addEventListener("click", () => openPersonaModal(null, "user"));
|
createUserPersonaBtn?.addEventListener("click", () => openPersonaModal(null, "user"));
|
||||||
settingsPersonaClose.addEventListener("click", closePersonaModal);
|
settingsPersonaClose.addEventListener("click", closePersonaModal);
|
||||||
settingsPersonaModal.addEventListener("click", (event) => {
|
settingsPersonaModal.addEventListener("click", (event) => {
|
||||||
if (event.target === settingsPersonaModal) closePersonaModal();
|
if (event.target === settingsPersonaModal) closePersonaModal();
|
||||||
@@ -570,28 +571,28 @@ settingsPopupInput.addEventListener("keydown", (event) => {
|
|||||||
}
|
}
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
closeSettingsPopup(null);
|
closeSettingsPopup(null);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
document.addEventListener("keydown", event => {
|
||||||
|
if (event.key !== "Escape") return;
|
||||||
|
const modal = topModal();
|
||||||
|
if (!modal) return;
|
||||||
|
event.preventDefault();
|
||||||
|
if (modal === settingsPopupModal) closeSettingsPopup(null);
|
||||||
|
else if (modal === settingsPersonaModal) closePersonaModal();
|
||||||
|
});
|
||||||
|
|
||||||
window.addEventListener("load", async () => {
|
window.addEventListener("load", async () => {
|
||||||
initializeAppearance({ title: "Elsewhere Settings", themeSelect: settingsThemeSelect, modeSelect: themeModeSelect });
|
initializeAppearance({ title: "Elsewhere Settings", themeSelect: settingsThemeSelect, modeSelect: themeModeSelect });
|
||||||
workspaceModeSelect.value = localStorage.getItem("krishd-workspace-mode") || "basic";
|
workspaceModeSelect.value = localStorage.getItem("krishd-workspace-mode") || "basic";
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const personaPage = location.pathname === "/app/personas";
|
const view = params.get("view") || "personal";
|
||||||
const view = personaPage ? "my-roleplay-companions" : params.get("view") || "personal";
|
|
||||||
if (personaPage) { document.querySelector(".market-header .eyebrow").textContent = "Your collection"; document.querySelector("h1").textContent = "My personas"; document.title = "My personas - Elsewhere"; document.querySelector(".settings-nav").hidden = true; document.querySelector(".settings-layout").style.gridTemplateColumns = "1fr"; }
|
|
||||||
const profileOk = await loadProfile();
|
const profileOk = await loadProfile();
|
||||||
setSettingsView(view);
|
setSettingsView(view);
|
||||||
const personasOk = await loadPersonas();
|
if (profileOk && view !== "diagnostics") setNotice("Settings loaded.");
|
||||||
if (profileOk && personasOk && view !== "diagnostics") setNotice(personaPage ? "Personas loaded." : "Settings loaded.");
|
|
||||||
|
|
||||||
const create = params.get("create");
|
const create = params.get("create");
|
||||||
if (create === "assistant") {
|
if (create === "assistant" || create === "user") location.replace("/app/personas");
|
||||||
setSettingsView("my-roleplay-companions");
|
|
||||||
openPersonaModal(null, "assistant");
|
|
||||||
} else if (create === "user") {
|
|
||||||
setSettingsView("my-roleplay-companions");
|
|
||||||
openPersonaModal(null, "user");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/** Reusable Server-Sent Events parser for browser and test environments. */
|
||||||
|
export class SseParseError extends Error {
|
||||||
|
constructor(message, details = {}) {
|
||||||
|
super(message);
|
||||||
|
this.name = "SseParseError";
|
||||||
|
Object.assign(this, details);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEvent(block, {onComment, onRetry} = {}) {
|
||||||
|
if (!block) return null;
|
||||||
|
let event = "message";
|
||||||
|
const data = [];
|
||||||
|
let id;
|
||||||
|
let retry;
|
||||||
|
for (const line of block.split(/\r?\n/)) {
|
||||||
|
if (line.startsWith(":")) { onComment?.(line.slice(1).trimStart()); continue; }
|
||||||
|
const separator = line.indexOf(":");
|
||||||
|
const field = separator < 0 ? line : line.slice(0, separator);
|
||||||
|
const value = separator < 0 ? "" : line.slice(separator + 1).replace(/^ /, "");
|
||||||
|
if (field === "event") event = value;
|
||||||
|
else if (field === "data") data.push(value);
|
||||||
|
else if (field === "id" && !value.includes("\u0000")) id = value;
|
||||||
|
else if (field === "retry" && /^\d+$/.test(value)) { retry = Number(value); onRetry?.(retry); }
|
||||||
|
}
|
||||||
|
if (!data.length) return null;
|
||||||
|
return {event, data: data.join("\n"), ...(id !== undefined ? {id} : {}), ...(retry !== undefined ? {retry} : {})};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSseParser(options = {}) {
|
||||||
|
let buffer = "";
|
||||||
|
return {
|
||||||
|
push(text) {
|
||||||
|
buffer += text;
|
||||||
|
const blocks = buffer.split(/\r?\n\r?\n/);
|
||||||
|
buffer = blocks.pop() || "";
|
||||||
|
return blocks.map(block => parseEvent(block, options)).filter(Boolean);
|
||||||
|
},
|
||||||
|
finish() {
|
||||||
|
const event = parseEvent(buffer, options);
|
||||||
|
buffer = "";
|
||||||
|
return event ? [event] : [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSseData(event) {
|
||||||
|
try { return JSON.parse(event.data); }
|
||||||
|
catch (error) { throw new SseParseError("Malformed SSE JSON payload.", {cause: error, event}); }
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Zeigt Statusmeldungen an und setzt deren Inhalt und Sichtbarkeit zurück.
|
||||||
|
*/
|
||||||
export function setStatus(element, message = "", state = "", baseClass = "status") {
|
export function setStatus(element, message = "", state = "", baseClass = "status") {
|
||||||
if (!element) return;
|
if (!element) return;
|
||||||
element.textContent = message;
|
element.textContent = message;
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/** Accumulate deltas and render at most once per animation frame. */
|
||||||
|
export function createStreamRenderer({onReply, onThinking, schedule = callback => requestAnimationFrame(callback), cancel = id => cancelAnimationFrame(id)}) {
|
||||||
|
const text = {reply: "", thinking: ""};
|
||||||
|
const dirty = new Set();
|
||||||
|
let frame = null;
|
||||||
|
const flush = () => {
|
||||||
|
if (frame !== null) cancel(frame);
|
||||||
|
frame = null;
|
||||||
|
if (dirty.has("thinking")) onThinking?.(text.thinking);
|
||||||
|
if (dirty.has("reply")) onReply?.(text.reply);
|
||||||
|
dirty.clear();
|
||||||
|
};
|
||||||
|
const update = (target, value, replace = false) => {
|
||||||
|
text[target] = replace ? value : text[target] + value;
|
||||||
|
dirty.add(target);
|
||||||
|
if (frame === null) frame = schedule(flush);
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
onChunk: ({chunk, fullReply}) => update("reply", typeof fullReply === "string" ? fullReply : chunk, typeof fullReply === "string"),
|
||||||
|
onThinking: ({chunk, fullThinking}) => update("thinking", typeof fullThinking === "string" ? fullThinking : chunk, typeof fullThinking === "string"),
|
||||||
|
onReplace: ({target, text: value}) => update(target, value, true),
|
||||||
|
flush,
|
||||||
|
dispose() {
|
||||||
|
if (frame !== null) cancel(frame);
|
||||||
|
frame = null;
|
||||||
|
dirty.clear();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Übernimmt das gespeicherte Theme frühzeitig in das Dokument, bevor die übrige Oberfläche initialisiert wird.
|
||||||
|
*/
|
||||||
(() => {
|
(() => {
|
||||||
const themes = new Set(["fakegpt", "fraud", "germini", "slopilot", "beta-ai", "confusity"]);
|
const themes = new Set(["fakegpt", "fraud", "germini", "slopilot", "beta-ai", "confusity"]);
|
||||||
const storedTheme = localStorage.getItem("krishd-theme");
|
const storedTheme = localStorage.getItem("krishd-theme");
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Normalisiert, speichert und synchronisiert Theme und Hell-Dunkel-Modus der Oberfläche.
|
||||||
|
*/
|
||||||
import {themes} from "./constants.js?v=20260909-account-controls";
|
import {themes} from "./constants.js?v=20260909-account-controls";
|
||||||
|
|
||||||
export const THEME_STORAGE_KEY = "krishd-theme";
|
export const THEME_STORAGE_KEY = "krishd-theme";
|
||||||
|
|||||||
+12
-9
@@ -1,24 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* @file Steuert die Websuche-Auswahl und rendert Quellenlinks zu Chat-Antworten.
|
||||||
|
*/
|
||||||
/** @typedef {{title: string, url: string, snippet: string}} WebSource */
|
/** @typedef {{title: string, url: string, snippet: string}} WebSource */
|
||||||
/** @typedef {{message: string, model: string, chatId: number, webSearch?: boolean, reasoning?: boolean}} ChatRequest */
|
/** @typedef {{message: string, model: string, chatId: number, webSearch?: boolean, reasoning?: boolean}} ChatRequest */
|
||||||
/** @typedef {{role: string, content: string, thinking?: string, sources?: WebSource[]}} ChatMessage */
|
/** @typedef {{role: string, content: string, thinking?: string, sources?: WebSource[]}} ChatMessage */
|
||||||
|
|
||||||
export function createWebSearchToggle(button) {
|
export function createWebSearchToggle(button, storage = globalThis.localStorage) {
|
||||||
let enabled = false;
|
const key = "krishd-web-search-enabled";
|
||||||
const reset = () => {
|
let enabled = true;
|
||||||
enabled = false;
|
try { enabled = storage?.getItem(key) !== "false"; } catch { /* Storage may be unavailable. */ }
|
||||||
render();
|
|
||||||
};
|
|
||||||
const render = () => {
|
const render = () => {
|
||||||
button.setAttribute("aria-pressed", String(enabled));
|
button.setAttribute("aria-pressed", String(enabled));
|
||||||
button.title = enabled ? "Web search enabled" : "Search the web";
|
button.title = enabled ? "Web search allowed. The assistant searches when useful. Click to turn off."
|
||||||
|
: "Web search off. Click to allow the assistant to search when useful.";
|
||||||
button.setAttribute("aria-label", button.title);
|
button.setAttribute("aria-label", button.title);
|
||||||
};
|
};
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
enabled = !enabled;
|
enabled = !enabled;
|
||||||
|
try { storage?.setItem(key, String(enabled)); } catch { /* Keep this session's choice. */ }
|
||||||
render();
|
render();
|
||||||
});
|
});
|
||||||
render();
|
render();
|
||||||
return {get enabled() { return enabled; }, reset};
|
return {get enabled() { return enabled; }};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param {HTMLElement} container @param {WebSource[] | undefined} sources */
|
/** @param {HTMLElement} container @param {WebSource[] | undefined} sources */
|
||||||
@@ -29,7 +32,7 @@ export function appendSources(container, sources) {
|
|||||||
section.setAttribute("aria-label", "Sources");
|
section.setAttribute("aria-label", "Sources");
|
||||||
const heading = document.createElement("div");
|
const heading = document.createElement("div");
|
||||||
heading.className = "msg-sources-heading";
|
heading.className = "msg-sources-heading";
|
||||||
heading.textContent = "Sources";
|
heading.textContent = "Used web results · Sources";
|
||||||
section.appendChild(heading);
|
section.appendChild(heading);
|
||||||
sources.slice(0, 5).forEach((source, index) => {
|
sources.slice(0, 5).forEach((source, index) => {
|
||||||
let url;
|
let url;
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import {post} from './api.js?v=20260909-account-controls';
|
/**
|
||||||
|
* @file Verbindet die Abmeldeknöpfe des gemeinsamen Seitenkopfs mit der Sitzungsverwaltung.
|
||||||
|
*/
|
||||||
|
import {post} from './api.js?v=20260909-account-controls';
|
||||||
|
|
||||||
// Every page gets this control from the same server-rendered header.
|
// Every page gets this control from the same server-rendered header.
|
||||||
for (const button of document.querySelectorAll('[data-sign-out]')) {
|
for (const button of document.querySelectorAll('[data-sign-out]')) {
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import {download, multipart, post} from "./api.js";
|
||||||
|
|
||||||
|
export function createWorkspaceTransfer({conversation, getChatById, setNotice, promptPopup, confirmPopup, setChatLoading, loadChatSessions}) {
|
||||||
|
async function exportCurrentChat() {
|
||||||
|
const chat = getChatById(conversation.activeChatId);
|
||||||
|
if (!chat || !conversation.currentMessages.length) return;
|
||||||
|
const format = await promptPopup({
|
||||||
|
eyebrow: "Export",
|
||||||
|
title: "Export format",
|
||||||
|
description: "Use txt, md, or json.",
|
||||||
|
label: "Format",
|
||||||
|
value: "md",
|
||||||
|
placeholder: "md",
|
||||||
|
confirmLabel: "Download"
|
||||||
|
});
|
||||||
|
if (format === null) return;
|
||||||
|
const normalized = ["txt", "md", "json"].includes(String(format || "").trim().toLowerCase())
|
||||||
|
? String(format).trim().toLowerCase()
|
||||||
|
: "md";
|
||||||
|
const response = await download(`/exports/chats/${chat.id}?format=${normalized}`);
|
||||||
|
if (response.error) return setNotice(response.error, "error");
|
||||||
|
const {blob} = response;
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = `${(chat.title.replace(/[^a-z0-9-_]+/gi, "-").replace(/^-+|-+$/g, "") || "chat")}.${normalized}`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
setNotice(`Chat exported as ${normalized}.`, "success");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function backupWorkspace() {
|
||||||
|
const response = await download("/exports/workspace/archive");
|
||||||
|
if (response.error) return setNotice(response.error, "error");
|
||||||
|
const {blob} = response;
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = `workspace-backup-${new Date().toISOString().slice(0, 10)}.tar.gz`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
setNotice("Workspace archive downloaded with images.", "success");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatImportPreview(preview, warnings = []) {
|
||||||
|
const counts = [
|
||||||
|
`${preview.personas || 0} persona${preview.personas === 1 ? "" : "s"}`,
|
||||||
|
`${preview.chats || 0} chat${preview.chats === 1 ? "" : "s"}`,
|
||||||
|
`${preview.messages || 0} message${preview.messages === 1 ? "" : "s"}`,
|
||||||
|
`${preview.templates || 0} template${preview.templates === 1 ? "" : "s"}`
|
||||||
|
].join(", ");
|
||||||
|
const warningText = warnings.length ? ` Warnings: ${warnings.slice(0, 4).join("; ")}${warnings.length > 4 ? "; ..." : ""}` : "";
|
||||||
|
return `Import preview: ${counts}.${warningText}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importWorkspaceFromFile(file) {
|
||||||
|
if (!file) return;
|
||||||
|
setChatLoading(true, "Reading workspace backup...");
|
||||||
|
const isArchive = file.name.toLowerCase().endsWith(".tar.gz");
|
||||||
|
if (isArchive) {
|
||||||
|
const submit = async (path, allowDuplicates = false) => {
|
||||||
|
const body = new FormData();
|
||||||
|
body.append("archive", file);
|
||||||
|
if (allowDuplicates) body.append("allowDuplicates", "true");
|
||||||
|
return multipart(path, body);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const preview = await submit("/imports/workspace/archive/preview");
|
||||||
|
setChatLoading(false);
|
||||||
|
if (preview.error || preview.ok === false) return setNotice((preview.errors || []).slice(0, 4).join("; ") || preview.error || "Invalid archive.", "error");
|
||||||
|
const confirmed = await confirmPopup({eyebrow: "Workspace import", title: "Review import",
|
||||||
|
description: formatImportPreview(preview.preview || {}, preview.warnings || []), confirmLabel: "Import"});
|
||||||
|
if (!confirmed) return;
|
||||||
|
setChatLoading(true, "Importing workspace and images...");
|
||||||
|
const result = await submit("/imports/workspace/archive", Boolean(preview.preview?.duplicates?.length));
|
||||||
|
if (result.error) return setNotice(result.error, "error");
|
||||||
|
await loadChatSessions();
|
||||||
|
setNotice(`Workspace imported: ${result.imported.chats} chats with their images.`, "success");
|
||||||
|
} catch { setNotice("Workspace archive could not be imported.", "error"); }
|
||||||
|
finally { setChatLoading(false); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let workspace;
|
||||||
|
try {
|
||||||
|
workspace = JSON.parse(await file.text());
|
||||||
|
} catch {
|
||||||
|
setChatLoading(false);
|
||||||
|
return setNotice("Import failed: choose a valid JSON workspace backup.", "error");
|
||||||
|
}
|
||||||
|
|
||||||
|
const preview = await post("/imports/workspace/preview", { workspace });
|
||||||
|
if (preview.error || preview.ok === false) {
|
||||||
|
setChatLoading(false);
|
||||||
|
const detail = (preview.errors || []).slice(0, 4).join("; ");
|
||||||
|
return setNotice(detail || preview.error || "Workspace import validation failed.", "error");
|
||||||
|
}
|
||||||
|
|
||||||
|
setChatLoading(false);
|
||||||
|
const confirmed = await confirmPopup({
|
||||||
|
eyebrow: "Workspace import",
|
||||||
|
title: "Review import",
|
||||||
|
description: formatImportPreview(preview.preview || {}, preview.warnings || []),
|
||||||
|
confirmLabel: "Import"
|
||||||
|
});
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
setChatLoading(true, "Importing workspace...");
|
||||||
|
const imported = await post("/imports/workspace", {
|
||||||
|
workspace,
|
||||||
|
allowDuplicates: Boolean(preview.preview?.duplicates?.length)
|
||||||
|
});
|
||||||
|
if (imported.error) {
|
||||||
|
setChatLoading(false);
|
||||||
|
const detail = (imported.errors || imported.warnings || []).slice(0, 4).join("; ");
|
||||||
|
return setNotice(detail || imported.error, "error");
|
||||||
|
}
|
||||||
|
await loadChatSessions();
|
||||||
|
setChatLoading(false);
|
||||||
|
setNotice(`Workspace imported: ${imported.imported.personas} personas, ${imported.imported.chats} chats, ${imported.imported.templates} templates.`, "success");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {exportCurrentChat, backupWorkspace, importWorkspaceFromFile};
|
||||||
|
}
|
||||||
+19
-20
@@ -11,29 +11,28 @@
|
|||||||
<link rel="stylesheet" href="/css/pages.css?v=20260909-account-controls">
|
<link rel="stylesheet" href="/css/pages.css?v=20260909-account-controls">
|
||||||
<link rel="stylesheet" href="/css/shell.css?v=20260909-account-controls">
|
<link rel="stylesheet" href="/css/shell.css?v=20260909-account-controls">
|
||||||
<link
|
<link
|
||||||
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=Manrope:wght@400;500;600;700;800&display=swap"
|
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=Manrope:wght@400;500;600;700;800&display=swap"
|
||||||
rel="stylesheet">
|
rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="site-page landing-page">
|
<body class="site-page landing-page">
|
||||||
<!-- site-header -->
|
<!-- site-header -->
|
||||||
<main id="main" class="landing-entry">
|
<main id="main" class="landing-entry">
|
||||||
<div class="entry-door" aria-hidden="true"><span></span></div>
|
<div class="entry-door" aria-hidden="true"><span></span></div>
|
||||||
<div class="entry-copy">
|
<div class="entry-copy">
|
||||||
<p class="eyebrow">A space for every conversation.</p>
|
<p class="eyebrow">A space for every conversation.</p>
|
||||||
<h1>Take a thought<br>somewhere.</h1>
|
<h1>Take a thought<br>somewhere.</h1>
|
||||||
<p>Talk it through. Make something up.<br>Meet a character and see where it goes.</p>
|
<p>Talk it through. Make something up.<br>Choose an assistant for the work ahead.</p>
|
||||||
<div class="site-actions">
|
<div class="site-actions">
|
||||||
<a data-start-link class="primary-action" href="/register">Step inside <span
|
<a data-start-link class="primary-action" href="/register">Step inside <span
|
||||||
aria-hidden="true">→</span></a>
|
aria-hidden="true">→</span></a>
|
||||||
<a href="/market">Meet the personas</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</div>
|
||||||
<footer class="site-footer"><span>elsewhere / a little room for imagination</span><a
|
</main>
|
||||||
href="/privacy.html">Privacy</a><a href="/tos.html">Terms</a></footer>
|
<footer class="site-footer"><span>elsewhere / a little room for imagination</span><a
|
||||||
<script type="module" src="/js/pages.js?v=20260909-account-controls"></script>
|
href="/privacy.html">Privacy</a><a href="/tos.html">Terms</a></footer>
|
||||||
<script type="module" src="/js/workspaceNavigation.js?v=20260909-account-controls"></script>
|
<script type="module" src="/js/pages.js?v=20260909-account-controls"></script>
|
||||||
|
<script type="module" src="/js/workspaceNavigation.js?v=20260909-account-controls"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,262 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/img/favicon.svg">
|
|
||||||
<title>Elsewhere Discover</title>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link
|
|
||||||
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=Manrope:wght@400;500;600;700;800&display=swap"
|
|
||||||
rel="stylesheet">
|
|
||||||
<script src="/js/theme-bootstrap.js?v=20260909-account-controls"></script>
|
|
||||||
<link rel="stylesheet" href="/css/style.css?v=20260909-account-controls" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<link rel="stylesheet" href="/css/shell.css?v=20260909-account-controls">
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body class="site-page market-page" data-theme="fakegpt" data-theme-mode="light">
|
|
||||||
<!-- site-header -->
|
|
||||||
<main id="main" class="market-shell page-content">
|
|
||||||
<header class="market-header market-header-simple">
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow">Shared personas</p>
|
|
||||||
<h1>Discover</h1>
|
|
||||||
</div>
|
|
||||||
<div class="market-header-actions"><button id="refreshMarket" class="secondary-action"
|
|
||||||
type="button">Refresh</button></div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="market-search">
|
|
||||||
<label for="marketSearch">Search</label>
|
|
||||||
<input id="marketSearch" type="search" placeholder="Search by name, creator, or description" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p id="marketStatus" class="status persona-status">Loading market personas...</p>
|
|
||||||
|
|
||||||
<section class="settings-layout market-settings-layout">
|
|
||||||
<aside class="settings-nav" aria-label="Market sections" role="tablist">
|
|
||||||
<button class="settings-nav-item active" type="button" role="tab" aria-selected="true"
|
|
||||||
data-market-view="ai-characters">Characters</button>
|
|
||||||
<button class="settings-nav-item" type="button" role="tab" aria-selected="false"
|
|
||||||
data-market-view="user-personas">User personas</button>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<div class="settings-content">
|
|
||||||
<section class="settings-view active" role="tabpanel" data-market-panel="ai-characters">
|
|
||||||
<div class="settings-panel">
|
|
||||||
<div class="settings-section-header">
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow">Discover</p>
|
|
||||||
<h2>AI Characters</h2>
|
|
||||||
</div>
|
|
||||||
<div class="market-section-actions">
|
|
||||||
<label class="market-sort-control" for="marketAssistantSort">
|
|
||||||
<span>Sort</span>
|
|
||||||
<select id="marketAssistantSort">
|
|
||||||
<option value="best">Best</option>
|
|
||||||
<option value="newest">Newest</option>
|
|
||||||
<option value="most_favorited">Most Favorited</option>
|
|
||||||
<option value="most_popular">Most Popular</option>
|
|
||||||
<option value="top_rated">Top Rated</option>
|
|
||||||
<option value="alphabetical">A-Z</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<button id="marketCreateAiCharacter" type="button" class="secondary-action">Create AI
|
|
||||||
Character</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="marketPersonaList" class="persona-list"></div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="settings-view" role="tabpanel" aria-hidden="true" data-market-panel="user-personas">
|
|
||||||
<div class="settings-panel">
|
|
||||||
<div class="settings-section-header">
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow">Discover</p>
|
|
||||||
<h2>User Personas</h2>
|
|
||||||
</div>
|
|
||||||
<div class="market-section-actions">
|
|
||||||
<label class="market-sort-control" for="marketUserSort">
|
|
||||||
<span>Sort</span>
|
|
||||||
<select id="marketUserSort">
|
|
||||||
<option value="best">Best</option>
|
|
||||||
<option value="newest">Newest</option>
|
|
||||||
<option value="most_favorited">Most Favorited</option>
|
|
||||||
<option value="most_popular">Most Popular</option>
|
|
||||||
<option value="top_rated">Top Rated</option>
|
|
||||||
<option value="alphabetical">A-Z</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<button id="marketCreateUserPersona" type="button" class="secondary-action">Create User
|
|
||||||
Persona</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="marketUserPersonaList" class="persona-list"></div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<div id="marketPreviewModal" class="modal-overlay hidden">
|
|
||||||
<div class="modal-card market-preview-card">
|
|
||||||
<div class="modal-header">
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow">Persona preview</p>
|
|
||||||
<h3 id="marketPreviewTitle">Persona details</h3>
|
|
||||||
<h4 id="marketPreviewName" class="market-preview-name">Persona details</h4>
|
|
||||||
<p id="marketPreviewIntro" class="subtitle">Review this persona and choose how you want to use it.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button id="marketPreviewClose" type="button" class="icon-button">Close</button>
|
|
||||||
</div>
|
|
||||||
<div id="marketPreviewBody" class="market-preview-body">
|
|
||||||
<p id="marketPreviewMeta" class="persona-guidance"></p>
|
|
||||||
<div class="market-preview-details">
|
|
||||||
<div>
|
|
||||||
<h4>Pronouns</h4>
|
|
||||||
<p id="marketPreviewPronouns"></p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4>Appearance</h4>
|
|
||||||
<p id="marketPreviewAppearance"></p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4>Background</h4>
|
|
||||||
<p id="marketPreviewBackground"></p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4>Details</h4>
|
|
||||||
<p id="marketPreviewDetails"></p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4>Example dialogues</h4>
|
|
||||||
<p id="marketPreviewExampleDialogues"></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="market-preview-actions">
|
|
||||||
<div id="marketPreviewStepOne" class="roleplay-starter-page hidden">
|
|
||||||
<label id="marketPreviewUserPersonaField" class="field market-preview-user-field hidden">
|
|
||||||
<span>You in this roleplay</span>
|
|
||||||
<select id="marketPreviewUserPersonaSelect"></select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div id="marketPreviewStepTwo" class="roleplay-starter-page hidden">
|
|
||||||
<label id="marketPreviewScenarioField" class="field market-preview-scenario-field">
|
|
||||||
<span>Scenario (optional)</span>
|
|
||||||
<textarea id="marketPreviewScenario" rows="4"
|
|
||||||
placeholder="Describe the opening scene you want. Leave it empty and the AI Character will invent its own scenario."></textarea>
|
|
||||||
</label>
|
|
||||||
<p class="subtitle">Leave this empty if you want the AI Character to invent the scenario on its own.
|
|
||||||
No character limit.</p>
|
|
||||||
</div>
|
|
||||||
<div class="market-preview-footer">
|
|
||||||
<button id="marketPreviewConfirm" type="button" class="primary-action">Take persona</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="marketPersonaModal" class="modal-overlay hidden">
|
|
||||||
<div class="modal-card persona-modal-card">
|
|
||||||
<div class="modal-header">
|
|
||||||
<div class="persona-modal-heading">
|
|
||||||
<p class="eyebrow">AI Character editor</p>
|
|
||||||
<h3 id="marketPersonaFormTitle">Create AI Character</h3>
|
|
||||||
</div>
|
|
||||||
<button id="marketPersonaClose" type="button" class="icon-button">Close</button>
|
|
||||||
</div>
|
|
||||||
<form id="marketPersonaForm" class="persona-form">
|
|
||||||
<div class="persona-form-intro">
|
|
||||||
<p class="persona-helper">Create or update your AI Characters for roleplay chats and marketplace
|
|
||||||
publishing.</p>
|
|
||||||
</div>
|
|
||||||
<p id="marketPersonaFormNotice" class="status hidden"></p>
|
|
||||||
<div class="persona-form-grid">
|
|
||||||
<label class="field">
|
|
||||||
<span>Character name</span>
|
|
||||||
<input id="marketPersonaName" placeholder="Character name" required />
|
|
||||||
</label>
|
|
||||||
<label class="field">
|
|
||||||
<span>Pronouns</span>
|
|
||||||
<input id="marketPersonaPronouns" placeholder="e.g. she/her, they/them" />
|
|
||||||
</label>
|
|
||||||
<label class="field field-wide">
|
|
||||||
<span>Appearance</span>
|
|
||||||
<textarea id="marketPersonaAppearance" rows="3" placeholder="Describe their look"></textarea>
|
|
||||||
</label>
|
|
||||||
<label class="field field-wide">
|
|
||||||
<span>Background</span>
|
|
||||||
<textarea id="marketPersonaBackground" rows="3"
|
|
||||||
placeholder="Backstory, history, role, or origins"></textarea>
|
|
||||||
</label>
|
|
||||||
<label class="field field-wide">
|
|
||||||
<span>Details</span>
|
|
||||||
<textarea id="marketPersonaDetails" rows="4"
|
|
||||||
placeholder="Personality traits, voice, goals, quirks, boundaries, style"></textarea>
|
|
||||||
</label>
|
|
||||||
<label class="field field-wide">
|
|
||||||
<span>Example dialogues</span>
|
|
||||||
<div class="dialogue-format-card" aria-hidden="true">
|
|
||||||
<p class="dialogue-format-label">Format</p>
|
|
||||||
<pre class="dialogue-format-example">{{character}}: Hello, my name is marc
|
|
||||||
{{user}}: Hi, Marc!
|
|
||||||
{{character}}: Nice to meet you</pre>
|
|
||||||
</div>
|
|
||||||
<textarea id="marketPersonaExampleDialogues" rows="5"
|
|
||||||
placeholder="Write example exchanges that define the character's tone, phrasing, pacing, and personality."></textarea>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="persona-actions">
|
|
||||||
<button id="marketPersonaSave" type="submit" class="primary-action">Save AI Character</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="marketActivityOverlay" class="chat-activity-overlay hidden" role="status" aria-live="polite"
|
|
||||||
aria-atomic="true">
|
|
||||||
<div class="chat-activity-card">
|
|
||||||
<div class="chat-activity-mark" aria-hidden="true">
|
|
||||||
<span></span>
|
|
||||||
<span></span>
|
|
||||||
<span></span>
|
|
||||||
</div>
|
|
||||||
<p id="marketActivityEyebrow" class="eyebrow">Please wait</p>
|
|
||||||
<h3 id="marketActivityTitle">Opening roleplay</h3>
|
|
||||||
<p id="marketActivityDetail" class="subtitle">The AI character is preparing the first message.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="marketPopupModal" class="modal-overlay hidden">
|
|
||||||
<div class="modal-card popup-card" role="dialog" aria-modal="true" aria-labelledby="marketPopupTitle"
|
|
||||||
aria-describedby="marketPopupDescription">
|
|
||||||
<div class="modal-header">
|
|
||||||
<div class="persona-modal-heading">
|
|
||||||
<p id="marketPopupEyebrow" class="eyebrow">Action</p>
|
|
||||||
<h3 id="marketPopupTitle">Confirm action</h3>
|
|
||||||
</div>
|
|
||||||
<button id="marketPopupClose" type="button" class="icon-button">Close</button>
|
|
||||||
</div>
|
|
||||||
<div class="popup-content">
|
|
||||||
<p id="marketPopupDescription" class="subtitle">Please confirm this action.</p>
|
|
||||||
<label id="marketPopupField" class="field">
|
|
||||||
<span id="marketPopupInputLabel">Value</span>
|
|
||||||
<input id="marketPopupInput" type="text" autocomplete="off" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="popup-actions">
|
|
||||||
<button id="marketPopupCancel" type="button" class="secondary-action">Cancel</button>
|
|
||||||
<button id="marketPopupConfirm" type="button">Confirm</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<script type="module" src="/js/market.js?v=20260923-asset-paths"></script>
|
|
||||||
<script type="module" src="/js/workspaceNavigation.js?v=20260909-account-controls"></script>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="icon" href="/img/favicon.svg">
|
||||||
|
<title>Personalities - Elsewhere</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=Manrope:wght@400;500;600;700;800&display=swap"
|
||||||
|
rel="stylesheet">
|
||||||
|
<script src="/js/theme-bootstrap.js"></script>
|
||||||
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
|
<link rel="stylesheet" href="/css/pages.css">
|
||||||
|
<link rel="stylesheet" href="/css/shell.css">
|
||||||
|
</head>
|
||||||
|
<body class="site-page personalities-page" data-theme="fakegpt" data-theme-mode="light"><!-- site-header -->
|
||||||
|
<main id="main" class="personality-shell">
|
||||||
|
<header class="personality-page-header"><p class="eyebrow">Choose your approach</p>
|
||||||
|
<h1>Personalities</h1>
|
||||||
|
<p class="subtitle">Six specialized assistants, use whichever fits you best.</p></header>
|
||||||
|
<section id="personalityGrid" class="personality-grid" aria-live="polite"></section>
|
||||||
|
</main>
|
||||||
|
<script type="module" src="/js/personalities.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+254
-191
@@ -12,203 +12,266 @@
|
|||||||
<link rel="stylesheet" href="/css/shell.css?v=20260923-legal">
|
<link rel="stylesheet" href="/css/shell.css?v=20260923-legal">
|
||||||
<link rel="stylesheet" href="/css/legal.css?v=20260923-legal">
|
<link rel="stylesheet" href="/css/legal.css?v=20260923-legal">
|
||||||
<link
|
<link
|
||||||
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=Manrope:wght@400;500;600;700;800&display=swap"
|
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=Manrope:wght@400;500;600;700;800&display=swap"
|
||||||
rel="stylesheet">
|
rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="site-page legal-page">
|
<body class="site-page legal-page">
|
||||||
<!-- site-header -->
|
<!-- site-header -->
|
||||||
<main id="main" class="legal-shell">
|
<main id="main" class="legal-shell">
|
||||||
<header class="legal-hero">
|
<header class="legal-hero">
|
||||||
<p class="eyebrow">Your data, explained</p>
|
<p class="eyebrow">Your data, explained</p>
|
||||||
<h1>Privacy Policy</h1>
|
<h1>Privacy Policy</h1>
|
||||||
<p class="legal-summary">This policy explains what personal data Elsewhere handles, why it is needed, where
|
<p class="legal-summary">This policy explains what personal data Elsewhere handles, why it is needed, where
|
||||||
it goes and the choices available to you.</p>
|
it goes and the choices available to you.</p>
|
||||||
<p class="legal-updated">Effective and last updated <time datetime="2026-09-23">23 September 2026</time></p>
|
<p class="legal-updated">Effective and last updated
|
||||||
</header>
|
<time datetime="2026-09-23">23 September 2026</time>
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
<aside class="legal-callout" aria-label="Privacy summary">
|
<aside class="legal-callout" aria-label="Privacy summary">
|
||||||
<div><strong>No advertising</strong><span>We do not sell personal data or use it for targeted ads.</span></div>
|
<div><strong>No advertising</strong><span>We do not sell personal data or use it for targeted ads.</span></div>
|
||||||
<div><strong>You stay in control</strong><span>Export your workspace or delete your account in Settings.</span></div>
|
<div><strong>You stay in control</strong><span>Export your workspace or delete your account in Settings.</span>
|
||||||
<div><strong>Features are optional</strong><span>Web search, image uploads and public publishing are choices you make.</span></div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<div class="legal-layout">
|
|
||||||
<nav class="legal-toc" aria-label="On this page">
|
|
||||||
<p>On this page</p>
|
|
||||||
<ol>
|
|
||||||
<li><a href="#controller">Who is responsible</a></li>
|
|
||||||
<li><a href="#data">Data we handle</a></li>
|
|
||||||
<li><a href="#use">How and why we use it</a></li>
|
|
||||||
<li><a href="#providers">AI, images and search</a></li>
|
|
||||||
<li><a href="#public">Public features</a></li>
|
|
||||||
<li><a href="#sharing">Sharing</a></li>
|
|
||||||
<li><a href="#cookies">Cookies</a></li>
|
|
||||||
<li><a href="#retention">Retention</a></li>
|
|
||||||
<li><a href="#transfers">International transfers</a></li>
|
|
||||||
<li><a href="#security">Security</a></li>
|
|
||||||
<li><a href="#rights">Your rights</a></li>
|
|
||||||
<li><a href="#changes">Changes and contact</a></li>
|
|
||||||
</ol>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<article class="legal-content">
|
|
||||||
<section id="controller">
|
|
||||||
<h2><span>01</span> Who is responsible</h2>
|
|
||||||
<p>Kristian Cerni is the controller responsible for Elsewhere. This policy applies to the Elsewhere
|
|
||||||
website, account area, chats, persona market and related features.</p>
|
|
||||||
<dl class="legal-details">
|
|
||||||
<div><dt>Service</dt><dd>Elsewhere</dd></div>
|
|
||||||
<div><dt>Privacy contact</dt><dd><a href="mailto:kriscerni@gmail.com">kriscerni@gmail.com</a></dd></div>
|
|
||||||
<div><dt>Project contact</dt><dd><a href="https://github.com/KrisHD1337" rel="external noopener">github.com/KrisHD1337</a></dd></div>
|
|
||||||
</dl>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="data">
|
|
||||||
<h2><span>02</span> Personal data we handle</h2>
|
|
||||||
<p>We collect data you provide and technical data generated while you use the service:</p>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Account data:</strong> username, password hash and session records. Elsewhere does not
|
|
||||||
ask for an email address during registration.</li>
|
|
||||||
<li><strong>Workspace content:</strong> chats, AI responses, conversation titles, personas, prompt
|
|
||||||
templates, settings, memories, model choices, retry history and roleplay state.</li>
|
|
||||||
<li><strong>Uploaded images:</strong> image files plus filename, type, size, dimensions, ownership,
|
|
||||||
storage key, timestamps and cached analysis associated with your question.</li>
|
|
||||||
<li><strong>Persona market activity:</strong> published personas and creator username, collections,
|
|
||||||
favourites, ratings, reports and moderation records.</li>
|
|
||||||
<li><strong>Web-search data:</strong> the search query derived from your current message, selected
|
|
||||||
result links and snippets when you turn on web search.</li>
|
|
||||||
<li><strong>Technical and security data:</strong> session identifiers, request identifiers, routes,
|
|
||||||
response status and timing, account username for signed-in requests, and IP-based rate-limit
|
|
||||||
information held temporarily in memory.</li>
|
|
||||||
</ul>
|
|
||||||
<p>Please avoid putting sensitive personal data into chats or images unless it is genuinely necessary
|
|
||||||
for your use of the service.</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="use">
|
|
||||||
<h2><span>03</span> How and why we use data</h2>
|
|
||||||
<div class="legal-table-wrap">
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>Purpose</th><th>Data used</th><th>Basis where the GDPR applies</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td>Create and secure your account</td><td>Account, session and security data</td><td>Performance of our contract; legitimate interests in security</td></tr>
|
|
||||||
<tr><td>Provide chats, personas, uploads, exports and settings</td><td>Account and workspace content</td><td>Performance of our contract</td></tr>
|
|
||||||
<tr><td>Run optional AI, image and search features</td><td>Your selected message, relevant context, images or query</td><td>Performance of our contract at your request</td></tr>
|
|
||||||
<tr><td>Operate and moderate the persona market</td><td>Published content, username and market activity</td><td>Performance of our contract; legitimate interests in a safe public space</td></tr>
|
|
||||||
<tr><td>Prevent abuse, diagnose faults and maintain reliability</td><td>Technical, account and limited content data where necessary</td><td>Legitimate interests in security and service operation</td></tr>
|
|
||||||
<tr><td>Meet legal obligations and defend claims</td><td>Relevant records</td><td>Legal obligation or legitimate interests</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<p>We do not use your data for targeted advertising and do not sell it. Elsewhere does not make
|
|
||||||
decisions about you that produce legal or similarly significant effects solely by automated means.</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="providers">
|
|
||||||
<h2><span>04</span> AI models, image analysis and web search</h2>
|
|
||||||
<p>To generate a reply, Elsewhere sends your current message and relevant conversation context to the
|
|
||||||
configured model backend. Depending on your settings, this may also include persona instructions,
|
|
||||||
conversation summaries, stored memories, selected web results and analysis derived from images.
|
|
||||||
The final chat model does not receive image files directly; a separate configured vision model
|
|
||||||
receives the image and your question to produce an analysis.</p>
|
|
||||||
<p>If you enable web search, Elsewhere sends a query based on your current message to the configured
|
|
||||||
SearXNG service. The service may pass that query to search engines enabled by its operator. It does
|
|
||||||
not receive your full conversation, account password or persona profile from Elsewhere.</p>
|
|
||||||
<p>The deployment operator chooses and hosts or configures these services. Their location and
|
|
||||||
sub-processors can therefore vary by deployment. Contact us if you need current information about
|
|
||||||
the providers used by this instance.</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="public">
|
|
||||||
<h2><span>05</span> Public persona features</h2>
|
|
||||||
<p>Your chats, uploads and private personas are not public by default. If you publish a persona, its
|
|
||||||
profile fields and your username become visible in the public market along with aggregate usage,
|
|
||||||
favourite and rating information. Reports are available to administrators for moderation.</p>
|
|
||||||
<p>You can unpublish a persona. A private copy previously collected by another user is separate and
|
|
||||||
is not automatically removed when the original is unpublished or your account is deleted.</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="sharing">
|
|
||||||
<h2><span>06</span> When data is shared</h2>
|
|
||||||
<p>We disclose personal data only as needed:</p>
|
|
||||||
<ul>
|
|
||||||
<li>to the configured model and search services when you use those features;</li>
|
|
||||||
<li>to infrastructure providers that host or support the application under appropriate safeguards;</li>
|
|
||||||
<li>to other users when you intentionally publish a persona;</li>
|
|
||||||
<li>when required by law, a valid legal process or to protect rights, safety and service security; or</li>
|
|
||||||
<li>as part of a service reorganisation, with notice and appropriate protection where required.</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="cookies">
|
|
||||||
<h2><span>07</span> Cookies and local storage</h2>
|
|
||||||
<p>Elsewhere uses an essential, HTTP-only session cookie to keep you signed in. It expires after the
|
|
||||||
configured session period or when invalidated. The browser also stores appearance and interface
|
|
||||||
preferences locally on your device. These technologies are used for service functionality, not
|
|
||||||
advertising or cross-site tracking.</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="retention">
|
|
||||||
<h2><span>08</span> How long data is kept</h2>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Account and workspace data</strong> remain until you delete individual items or your account.</li>
|
|
||||||
<li><strong>Temporary, unattached uploads</strong> are scheduled for deletion after 24 hours. Images attached to a message remain with that conversation.</li>
|
|
||||||
<li><strong>Session records</strong> expire according to the configured session lifetime and can be invalidated from Settings.</li>
|
|
||||||
<li><strong>Operational logs and in-memory rate-limit records</strong> are kept only as long as reasonably needed for security, diagnosis and operation.</li>
|
|
||||||
</ul>
|
|
||||||
<p>Deleting your account removes its workspace, attached files and published market entries from the
|
|
||||||
active service. Deletion from backups or queued file cleanup may take additional time. We may keep
|
|
||||||
limited records longer where required by law or necessary to establish, exercise or defend legal claims.</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="transfers">
|
|
||||||
<h2><span>09</span> International transfers</h2>
|
|
||||||
<p>Data may be processed outside your country when infrastructure, model or search providers are
|
|
||||||
located elsewhere. Where required, we rely on a legally recognised transfer mechanism, such as an
|
|
||||||
adequacy decision or appropriate contractual safeguards. Contact us for details relevant to the
|
|
||||||
current deployment.</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="security">
|
|
||||||
<h2><span>10</span> Security</h2>
|
|
||||||
<p>We use reasonable technical and organisational measures designed to protect personal data,
|
|
||||||
including hashed passwords, HTTP-only same-site session cookies, same-origin request protection,
|
|
||||||
access checks for chats and attachments, upload validation and rate limiting. No system is
|
|
||||||
completely secure, so please use a unique password and do not share it.</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="rights">
|
|
||||||
<h2><span>11</span> Your choices and rights</h2>
|
|
||||||
<p>Depending on the law that applies to you, you may have the right to request access, correction,
|
|
||||||
deletion, restriction, portability or an objection to certain processing. You may also withdraw
|
|
||||||
consent where processing relies on consent, without affecting earlier lawful processing.</p>
|
|
||||||
<p>You can export your workspace and delete your account directly in Settings. For other requests,
|
|
||||||
email <a href="mailto:kriscerni@gmail.com">kriscerni@gmail.com</a>. We may need to verify your
|
|
||||||
identity before completing a request.</p>
|
|
||||||
<p>You may lodge a complaint with the Swiss Federal Data Protection and Information Commissioner
|
|
||||||
(FDPIC) or, where the GDPR applies, your local data protection authority. We encourage you to
|
|
||||||
contact us first so we can try to resolve the concern.</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="changes">
|
|
||||||
<h2><span>12</span> Changes and contact</h2>
|
|
||||||
<p>We may update this policy when the service, providers or applicable requirements change. We will
|
|
||||||
post the new version here, change the date above and provide additional notice for material
|
|
||||||
changes where appropriate.</p>
|
|
||||||
<p>Questions or requests can be sent to <a
|
|
||||||
href="mailto:kriscerni@gmail.com">kriscerni@gmail.com</a>.</p>
|
|
||||||
</section>
|
|
||||||
</article>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
<div><strong>Features are optional</strong><span>Web search, image uploads and public publishing are choices you make.</span>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
<footer class="legal-footer">
|
<div class="legal-layout">
|
||||||
<span>Elsewhere</span>
|
<nav class="legal-toc" aria-label="On this page">
|
||||||
<a href="/tos.html">Terms of Service</a>
|
<p>On this page</p>
|
||||||
<a href="/">Back to Elsewhere</a>
|
<ol>
|
||||||
</footer>
|
<li><a href="#controller">Who is responsible</a></li>
|
||||||
<script type="module" src="/js/workspaceNavigation.js?v=20260923-legal"></script>
|
<li><a href="#data">Data we handle</a></li>
|
||||||
|
<li><a href="#use">How and why we use it</a></li>
|
||||||
|
<li><a href="#providers">AI, images and search</a></li>
|
||||||
|
<li><a href="#public">Public features</a></li>
|
||||||
|
<li><a href="#sharing">Sharing</a></li>
|
||||||
|
<li><a href="#cookies">Cookies</a></li>
|
||||||
|
<li><a href="#retention">Retention</a></li>
|
||||||
|
<li><a href="#transfers">International transfers</a></li>
|
||||||
|
<li><a href="#security">Security</a></li>
|
||||||
|
<li><a href="#rights">Your rights</a></li>
|
||||||
|
<li><a href="#changes">Changes and contact</a></li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<article class="legal-content">
|
||||||
|
<section id="controller">
|
||||||
|
<h2><span>01</span> Who is responsible</h2>
|
||||||
|
<p>Kristian Cerni is the controller responsible for Elsewhere. This policy applies to the Elsewhere
|
||||||
|
website, account area, chats, persona market and related features.</p>
|
||||||
|
<dl class="legal-details">
|
||||||
|
<div>
|
||||||
|
<dt>Service</dt>
|
||||||
|
<dd>Elsewhere</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Privacy contact</dt>
|
||||||
|
<dd><a href="mailto:kriscerni@gmail.com">kriscerni@gmail.com</a></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Project contact</dt>
|
||||||
|
<dd><a href="https://github.com/KrisHD1337" rel="external noopener">github.com/KrisHD1337</a>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="data">
|
||||||
|
<h2><span>02</span> Personal data we handle</h2>
|
||||||
|
<p>We collect data you provide and technical data generated while you use the service:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Account data:</strong> username, password hash and session records. Elsewhere does not
|
||||||
|
ask for an email address during registration.
|
||||||
|
</li>
|
||||||
|
<li><strong>Workspace content:</strong> chats, AI responses, conversation titles, personas, prompt
|
||||||
|
templates, settings, memories, model choices, retry history and roleplay state.
|
||||||
|
</li>
|
||||||
|
<li><strong>Uploaded images:</strong> image files plus filename, type, size, dimensions, ownership,
|
||||||
|
storage key, timestamps and cached analysis associated with your question.
|
||||||
|
</li>
|
||||||
|
<li><strong>Persona market activity:</strong> published personas and creator username, collections,
|
||||||
|
favourites, ratings, reports and moderation records.
|
||||||
|
</li>
|
||||||
|
<li><strong>Web-search data:</strong> the search query derived from your current message, selected
|
||||||
|
result links and snippets when you turn on web search.
|
||||||
|
</li>
|
||||||
|
<li><strong>Technical and security data:</strong> session identifiers, request identifiers, routes,
|
||||||
|
response status and timing, account username for signed-in requests, and IP-based rate-limit
|
||||||
|
information held temporarily in memory.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p>Please avoid putting sensitive personal data into chats or images unless it is genuinely necessary
|
||||||
|
for your use of the service.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="use">
|
||||||
|
<h2><span>03</span> How and why we use data</h2>
|
||||||
|
<div class="legal-table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Purpose</th>
|
||||||
|
<th>Data used</th>
|
||||||
|
<th>Basis where the GDPR applies</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Create and secure your account</td>
|
||||||
|
<td>Account, session and security data</td>
|
||||||
|
<td>Performance of our contract; legitimate interests in security</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Provide chats, personas, uploads, exports and settings</td>
|
||||||
|
<td>Account and workspace content</td>
|
||||||
|
<td>Performance of our contract</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Run optional AI, image and search features</td>
|
||||||
|
<td>Your selected message, relevant context, images or query</td>
|
||||||
|
<td>Performance of our contract at your request</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Operate and moderate the persona market</td>
|
||||||
|
<td>Published content, username and market activity</td>
|
||||||
|
<td>Performance of our contract; legitimate interests in a safe public space</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Prevent abuse, diagnose faults and maintain reliability</td>
|
||||||
|
<td>Technical, account and limited content data where necessary</td>
|
||||||
|
<td>Legitimate interests in security and service operation</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Meet legal obligations and defend claims</td>
|
||||||
|
<td>Relevant records</td>
|
||||||
|
<td>Legal obligation or legitimate interests</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p>We do not use your data for targeted advertising and do not sell it. Elsewhere does not make
|
||||||
|
decisions about you that produce legal or similarly significant effects solely by automated
|
||||||
|
means.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="providers">
|
||||||
|
<h2><span>04</span> AI models, image analysis and web search</h2>
|
||||||
|
<p>To generate a reply, Elsewhere sends your current message and relevant conversation context to the
|
||||||
|
configured model backend. Depending on your settings, this may also include persona instructions,
|
||||||
|
conversation summaries, stored memories, selected web results and analysis derived from images.
|
||||||
|
The final chat model does not receive image files directly; a separate configured vision model
|
||||||
|
receives the image and your question to produce an analysis.</p>
|
||||||
|
<p>If you enable web search, Elsewhere sends a query based on your current message to the configured
|
||||||
|
SearXNG service. The service may pass that query to search engines enabled by its operator. It does
|
||||||
|
not receive your full conversation, account password or persona profile from Elsewhere.</p>
|
||||||
|
<p>The deployment operator chooses and hosts or configures these services. Their location and
|
||||||
|
sub-processors can therefore vary by deployment. Contact us if you need current information about
|
||||||
|
the providers used by this instance.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="public">
|
||||||
|
<h2><span>05</span> Public persona features</h2>
|
||||||
|
<p>Your chats, uploads and private personas are not public by default. If you publish a persona, its
|
||||||
|
profile fields and your username become visible in the public market along with aggregate usage,
|
||||||
|
favourite and rating information. Reports are available to administrators for moderation.</p>
|
||||||
|
<p>You can unpublish a persona. A private copy previously collected by another user is separate and
|
||||||
|
is not automatically removed when the original is unpublished or your account is deleted.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="sharing">
|
||||||
|
<h2><span>06</span> When data is shared</h2>
|
||||||
|
<p>We disclose personal data only as needed:</p>
|
||||||
|
<ul>
|
||||||
|
<li>to the configured model and search services when you use those features;</li>
|
||||||
|
<li>to infrastructure providers that host or support the application under appropriate safeguards;
|
||||||
|
</li>
|
||||||
|
<li>to other users when you intentionally publish a persona;</li>
|
||||||
|
<li>when required by law, a valid legal process or to protect rights, safety and service security;
|
||||||
|
or
|
||||||
|
</li>
|
||||||
|
<li>as part of a service reorganisation, with notice and appropriate protection where required.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="cookies">
|
||||||
|
<h2><span>07</span> Cookies and local storage</h2>
|
||||||
|
<p>Elsewhere uses an essential, HTTP-only session cookie to keep you signed in. It expires after the
|
||||||
|
configured session period or when invalidated. The browser also stores appearance and interface
|
||||||
|
preferences locally on your device. These technologies are used for service functionality, not
|
||||||
|
advertising or cross-site tracking.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="retention">
|
||||||
|
<h2><span>08</span> How long data is kept</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Account and workspace data</strong> remain until you delete individual items or your
|
||||||
|
account.
|
||||||
|
</li>
|
||||||
|
<li><strong>Temporary, unattached uploads</strong> are scheduled for deletion after 24 hours. Images
|
||||||
|
attached to a message remain with that conversation.
|
||||||
|
</li>
|
||||||
|
<li><strong>Session records</strong> expire according to the configured session lifetime and can be
|
||||||
|
invalidated from Settings.
|
||||||
|
</li>
|
||||||
|
<li><strong>Operational logs and in-memory rate-limit records</strong> are kept only as long as
|
||||||
|
reasonably needed for security, diagnosis and operation.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p>Deleting your account removes its workspace, attached files and published market entries from the
|
||||||
|
active service. Deletion from backups or queued file cleanup may take additional time. We may keep
|
||||||
|
limited records longer where required by law or necessary to establish, exercise or defend legal
|
||||||
|
claims.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="transfers">
|
||||||
|
<h2><span>09</span> International transfers</h2>
|
||||||
|
<p>Data may be processed outside your country when infrastructure, model or search providers are
|
||||||
|
located elsewhere. Where required, we rely on a legally recognised transfer mechanism, such as an
|
||||||
|
adequacy decision or appropriate contractual safeguards. Contact us for details relevant to the
|
||||||
|
current deployment.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="security">
|
||||||
|
<h2><span>10</span> Security</h2>
|
||||||
|
<p>We use reasonable technical and organisational measures designed to protect personal data,
|
||||||
|
including hashed passwords, HTTP-only same-site session cookies, same-origin request protection,
|
||||||
|
access checks for chats and attachments, upload validation and rate limiting. No system is
|
||||||
|
completely secure, so please use a unique password and do not share it.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="rights">
|
||||||
|
<h2><span>11</span> Your choices and rights</h2>
|
||||||
|
<p>Depending on the law that applies to you, you may have the right to request access, correction,
|
||||||
|
deletion, restriction, portability or an objection to certain processing. You may also withdraw
|
||||||
|
consent where processing relies on consent, without affecting earlier lawful processing.</p>
|
||||||
|
<p>You can export your workspace and delete your account directly in Settings. For other requests,
|
||||||
|
email <a href="mailto:kriscerni@gmail.com">kriscerni@gmail.com</a>. We may need to verify your
|
||||||
|
identity before completing a request.</p>
|
||||||
|
<p>You may lodge a complaint with the Swiss Federal Data Protection and Information Commissioner
|
||||||
|
(FDPIC) or, where the GDPR applies, your local data protection authority. We encourage you to
|
||||||
|
contact us first so we can try to resolve the concern.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="changes">
|
||||||
|
<h2><span>12</span> Changes and contact</h2>
|
||||||
|
<p>We may update this policy when the service, providers or applicable requirements change. We will
|
||||||
|
post the new version here, change the date above and provide additional notice for material
|
||||||
|
changes where appropriate.</p>
|
||||||
|
<p>Questions or requests can be sent to <a
|
||||||
|
href="mailto:kriscerni@gmail.com">kriscerni@gmail.com</a>.</p>
|
||||||
|
</section>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="legal-footer">
|
||||||
|
<span>Elsewhere</span>
|
||||||
|
<a href="/tos.html">Terms of Service</a>
|
||||||
|
<a href="/">Back to Elsewhere</a>
|
||||||
|
</footer>
|
||||||
|
<script type="module" src="/js/workspaceNavigation.js?v=20260923-legal"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+290
-291
@@ -3,322 +3,321 @@
|
|||||||
|
|
||||||
<head>
|
<head>
|
||||||
<link rel="icon" type="image/svg+xml" href="/img/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/img/favicon.svg">
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8"/>
|
||||||
<title>Elsewhere Settings</title>
|
<title>Elsewhere Settings</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link
|
<link
|
||||||
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=Manrope:wght@400;500;600;700;800&display=swap"
|
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=Manrope:wght@400;500;600;700;800&display=swap"
|
||||||
rel="stylesheet">
|
rel="stylesheet">
|
||||||
<script src="/js/theme-bootstrap.js?v=20260909-account-controls"></script>
|
<script src="/js/theme-bootstrap.js?v=20260909-account-controls"></script>
|
||||||
<link rel="stylesheet" href="/css/style.css?v=20260909-account-controls" />
|
<link rel="stylesheet" href="/css/style.css?v=20260909-account-controls"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<link rel="stylesheet" href="/css/shell.css?v=20260909-account-controls">
|
<link rel="stylesheet" href="/css/shell.css?v=20260909-account-controls">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="site-page settings-page" data-theme="fakegpt" data-theme-mode="light">
|
<body class="site-page settings-page" data-theme="fakegpt" data-theme-mode="light">
|
||||||
<!-- site-header -->
|
<!-- site-header -->
|
||||||
<main id="main" class="settings-shell page-content">
|
<main id="main" class="settings-shell page-content">
|
||||||
<header class="market-header market-header-simple">
|
<header class="market-header market-header-simple">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Elsewhere settings</p>
|
<p class="eyebrow">Elsewhere settings</p>
|
||||||
<h1>Preferences</h1>
|
<h1>Preferences</h1>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<p id="settingsNotice" class="status">Loading settings...</p>
|
<p id="settingsNotice" class="status">Loading settings...</p>
|
||||||
|
|
||||||
<section class="settings-layout">
|
<section class="settings-layout">
|
||||||
<aside class="settings-nav" aria-label="Settings sections" role="tablist">
|
<aside class="settings-nav" aria-label="Settings sections" role="tablist">
|
||||||
<button class="settings-nav-item active" type="button" role="tab" aria-selected="true"
|
<button class="settings-nav-item active" type="button" role="tab" aria-selected="true"
|
||||||
data-settings-view="personal">Account</button>
|
data-settings-view="personal">Account
|
||||||
|
</button>
|
||||||
|
|
||||||
<button class="settings-nav-item" type="button" role="tab" aria-selected="false"
|
<button class="settings-nav-item" type="button" role="tab" aria-selected="false"
|
||||||
data-settings-view="design">Appearance</button>
|
data-settings-view="design">Appearance
|
||||||
<button id="diagnosticsNavItem" class="settings-nav-item hidden" type="button" role="tab"
|
</button>
|
||||||
aria-selected="false" data-settings-view="diagnostics">Diagnostics</button>
|
<button id="diagnosticsNavItem" class="settings-nav-item hidden" type="button" role="tab"
|
||||||
</aside>
|
aria-selected="false" data-settings-view="diagnostics">Diagnostics
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
|
||||||
<div class="settings-content">
|
<div class="settings-content">
|
||||||
<section class="settings-view active" role="tabpanel" data-settings-panel="personal">
|
<section class="settings-view active" role="tabpanel" data-settings-panel="personal">
|
||||||
<div class="settings-panel">
|
<div class="settings-panel">
|
||||||
<div class="settings-section-header">
|
<div class="settings-section-header">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Profile</p>
|
<p class="eyebrow">Profile</p>
|
||||||
<h2>Username</h2>
|
<h2>Username</h2>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<form id="usernameForm" class="settings-form-stack">
|
|
||||||
<label class="field">
|
|
||||||
<span>Current username</span>
|
|
||||||
<input id="currentUsername" readonly />
|
|
||||||
</label>
|
|
||||||
<label class="field">
|
|
||||||
<span>New username</span>
|
|
||||||
<input id="newUsername" autocomplete="username" />
|
|
||||||
</label>
|
|
||||||
<label class="field">
|
|
||||||
<span>Current password</span>
|
|
||||||
<input id="usernamePassword" type="password" autocomplete="current-password" />
|
|
||||||
</label>
|
|
||||||
<button id="saveUsername" type="submit">Save username</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-panel">
|
|
||||||
<div class="settings-section-header">
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow">Security</p>
|
|
||||||
<h2>Password</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<form id="passwordForm" class="settings-form-stack">
|
|
||||||
<label class="field">
|
|
||||||
<span>Current password</span>
|
|
||||||
<input id="currentPassword" type="password" autocomplete="current-password" />
|
|
||||||
</label>
|
|
||||||
<label class="field">
|
|
||||||
<span>New password</span>
|
|
||||||
<input id="newPassword" type="password" autocomplete="new-password" />
|
|
||||||
</label>
|
|
||||||
<label class="field">
|
|
||||||
<span>Repeat new password</span>
|
|
||||||
<input id="repeatPassword" type="password" autocomplete="new-password" />
|
|
||||||
</label>
|
|
||||||
<button id="savePassword" type="submit">Save password</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-panel">
|
|
||||||
<div class="settings-section-header">
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow">Account data</p>
|
|
||||||
<h2>Export and sessions</h2>
|
|
||||||
<p class="subtitle">Download your account data or sign out every active browser session.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="persona-actions">
|
|
||||||
<button id="exportAccount" type="button" class="secondary-action">Export account</button>
|
|
||||||
<button id="logoutAllSessions" type="button" class="secondary-action">Logout all
|
|
||||||
sessions</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<form id="usernameForm" class="settings-form-stack">
|
||||||
<div class="settings-panel">
|
<label class="field">
|
||||||
<div class="settings-section-header">
|
<span>Current username</span>
|
||||||
<div>
|
<input id="currentUsername" readonly/>
|
||||||
<p class="eyebrow">Danger zone</p>
|
</label>
|
||||||
<h2>Delete account</h2>
|
<label class="field">
|
||||||
<p class="subtitle">This permanently removes your account, chats, personas, templates,
|
<span>New username</span>
|
||||||
and market activity.</p>
|
<input id="newUsername" autocomplete="username"/>
|
||||||
</div>
|
</label>
|
||||||
</div>
|
<label class="field">
|
||||||
<div class="persona-actions">
|
<span>Current password</span>
|
||||||
<button id="deleteAccount" type="button" class="secondary-action danger">Delete
|
<input id="usernamePassword" type="password" autocomplete="current-password"/>
|
||||||
account</button>
|
</label>
|
||||||
</div>
|
<button id="saveUsername" type="submit">Save username</button>
|
||||||
</div>
|
</form>
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="settings-view" role="tabpanel" aria-hidden="true"
|
|
||||||
data-settings-panel="my-roleplay-companions">
|
|
||||||
<div class="settings-panel">
|
|
||||||
<div class="settings-section-header">
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow">Create & manage</p>
|
|
||||||
<h2>My personas</h2>
|
|
||||||
</div>
|
|
||||||
<div class="market-header-actions action-pair">
|
|
||||||
<button id="createAiCharacter" type="button" class="secondary-action">Create AI
|
|
||||||
Character</button>
|
|
||||||
<button id="createUserPersona" type="button" class="secondary-action">Create User
|
|
||||||
Persona</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h3>AI Characters</h3>
|
|
||||||
<div id="settingsAiCharacterList" class="persona-list"></div>
|
|
||||||
<h3>User Personas</h3>
|
|
||||||
<div id="settingsUserPersonaList" class="persona-list"></div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="settings-view" role="tabpanel" aria-hidden="true" data-settings-panel="design">
|
|
||||||
<div class="settings-panel settings-panel-compact">
|
|
||||||
<div class="settings-section-header">
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow">Appearance</p>
|
|
||||||
<h2>Make yourself at home</h2>
|
|
||||||
<p class="subtitle">Choose a style for your conversation space.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="settings-theme-row">
|
|
||||||
<label class="field">
|
|
||||||
<span>Style</span>
|
|
||||||
<select id="settingsThemeSelect">
|
|
||||||
<option value="fakegpt">Grove</option>
|
|
||||||
<option value="fraud">Parchment</option>
|
|
||||||
<option value="germini">Aurora</option>
|
|
||||||
<option value="slopilot">Current</option>
|
|
||||||
<option value="beta-ai">Iris</option>
|
|
||||||
<option value="confusity">Graphite</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label class="field">
|
|
||||||
<span>Light or dark</span>
|
|
||||||
<select id="themeModeSelect">
|
|
||||||
<option value="light">Light</option>
|
|
||||||
<option value="dark">Dark</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label class="field">
|
|
||||||
<span>Workspace mode</span>
|
|
||||||
<select id="workspaceModeSelect">
|
|
||||||
<option value="basic">Basic</option>
|
|
||||||
<option value="advanced">Advanced</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="settings-view" role="tabpanel" aria-hidden="true" data-settings-panel="diagnostics">
|
|
||||||
<div class="settings-panel">
|
|
||||||
<div class="settings-section-header">
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow">Admin</p>
|
|
||||||
<h2>Model diagnostics</h2>
|
|
||||||
<p class="subtitle">Backend status, model inventory, latency, and configuration
|
|
||||||
warnings.</p>
|
|
||||||
</div>
|
|
||||||
<button id="refreshDiagnostics" type="button" class="secondary-action">Refresh</button>
|
|
||||||
</div>
|
|
||||||
<div class="diagnostics-grid">
|
|
||||||
<div class="diagnostics-card">
|
|
||||||
<span>Status</span>
|
|
||||||
<strong id="diagnosticsStatus">Unknown</strong>
|
|
||||||
<p id="diagnosticsBackend" class="subtitle">Backend unavailable</p>
|
|
||||||
</div>
|
|
||||||
<div class="diagnostics-card">
|
|
||||||
<span>Latency</span>
|
|
||||||
<strong id="diagnosticsLatency">n/a</strong>
|
|
||||||
<p id="diagnosticsCheckedAt" class="subtitle">Not checked yet</p>
|
|
||||||
</div>
|
|
||||||
<div class="diagnostics-card">
|
|
||||||
<span>Models</span>
|
|
||||||
<strong id="diagnosticsModelCount">0</strong>
|
|
||||||
<p id="diagnosticsCacheAge" class="subtitle">Cache age n/a</p>
|
|
||||||
</div>
|
|
||||||
<div class="diagnostics-card">
|
|
||||||
<span>Active requests</span>
|
|
||||||
<strong id="diagnosticsActiveRequests">0</strong>
|
|
||||||
<p id="diagnosticsLastFailure" class="subtitle">No recorded failures</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="diagnostics-columns">
|
|
||||||
<div>
|
|
||||||
<h3>Available models</h3>
|
|
||||||
<div id="diagnosticsModels" class="diagnostics-list"></div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3>Config warnings</h3>
|
|
||||||
<div id="diagnosticsWarnings" class="diagnostics-list"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<div id="settingsPersonaModal" class="modal-overlay hidden">
|
|
||||||
<div class="modal-card persona-modal-card">
|
|
||||||
<div class="modal-header">
|
|
||||||
<div class="persona-modal-heading">
|
|
||||||
<p class="eyebrow">Persona editor</p>
|
|
||||||
<h3 id="settingsPersonaFormTitle">Create persona</h3>
|
|
||||||
</div>
|
</div>
|
||||||
<button id="settingsPersonaClose" type="button" class="icon-button">Close</button>
|
|
||||||
</div>
|
<div class="settings-panel">
|
||||||
<form id="settingsPersonaForm" class="persona-form">
|
<div class="settings-section-header">
|
||||||
<div class="persona-form-intro">
|
<div>
|
||||||
<p id="settingsPersonaHelper" class="persona-helper">Create a persona for roleplay chats.</p>
|
<p class="eyebrow">Security</p>
|
||||||
|
<h2>Password</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form id="passwordForm" class="settings-form-stack">
|
||||||
|
<label class="field">
|
||||||
|
<span>Current password</span>
|
||||||
|
<input id="currentPassword" type="password" autocomplete="current-password"/>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>New password</span>
|
||||||
|
<input id="newPassword" type="password" autocomplete="new-password"/>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Repeat new password</span>
|
||||||
|
<input id="repeatPassword" type="password" autocomplete="new-password"/>
|
||||||
|
</label>
|
||||||
|
<button id="savePassword" type="submit">Save password</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<p id="settingsPersonaFormNotice" class="status hidden"></p>
|
|
||||||
<div class="persona-form-grid">
|
<div class="settings-panel">
|
||||||
<label class="field field-wide">
|
<div class="settings-section-header">
|
||||||
<span>Persona for</span>
|
<div>
|
||||||
<select id="settingsPersonaType">
|
<p class="eyebrow">Account data</p>
|
||||||
<option value="assistant">AI Character</option>
|
<h2>Export and sessions</h2>
|
||||||
<option value="user">Your persona</option>
|
<p class="subtitle">Download your account data or sign out every active browser session.
|
||||||
</select>
|
</p>
|
||||||
</label>
|
</div>
|
||||||
<label class="field">
|
</div>
|
||||||
<span>Name</span>
|
<div class="persona-actions">
|
||||||
<input id="settingsPersonaName" placeholder="Persona name" required />
|
<button id="exportAccount" type="button" class="secondary-action">Export account</button>
|
||||||
</label>
|
<button id="logoutAllSessions" type="button" class="secondary-action">Logout all
|
||||||
<label class="field">
|
sessions
|
||||||
<span>Pronouns</span>
|
</button>
|
||||||
<input id="settingsPersonaPronouns" placeholder="e.g. she/her, they/them" />
|
</div>
|
||||||
</label>
|
</div>
|
||||||
<label class="field field-wide">
|
|
||||||
<span>Appearance</span>
|
<div class="settings-panel">
|
||||||
<textarea id="settingsPersonaAppearance" rows="3" placeholder="Describe their look"></textarea>
|
<div class="settings-section-header">
|
||||||
</label>
|
<div>
|
||||||
<label class="field field-wide">
|
<p class="eyebrow">Danger zone</p>
|
||||||
<span>Background</span>
|
<h2>Delete account</h2>
|
||||||
<textarea id="settingsPersonaBackground" rows="3"
|
<p class="subtitle">This permanently removes your account, chats, personas, templates,
|
||||||
placeholder="Backstory, history, role, or origins"></textarea>
|
and market activity.</p>
|
||||||
</label>
|
</div>
|
||||||
<label class="field field-wide">
|
</div>
|
||||||
<span>Details</span>
|
<div class="persona-actions">
|
||||||
<textarea id="settingsPersonaDetails" rows="4"
|
<button id="deleteAccount" type="button" class="secondary-action danger">Delete
|
||||||
placeholder="Personality traits, voice, goals, quirks, boundaries, style"></textarea>
|
account
|
||||||
</label>
|
</button>
|
||||||
<label id="settingsPersonaExamplesField" class="field field-wide hidden">
|
</div>
|
||||||
<span>Example dialogues</span>
|
</div>
|
||||||
<div class="dialogue-format-card" aria-hidden="true">
|
</section>
|
||||||
<p class="dialogue-format-label">Format</p>
|
|
||||||
<pre class="dialogue-format-example">{{character}}: Hello, my name is marc
|
<section class="settings-view" role="tabpanel" aria-hidden="true"
|
||||||
|
data-settings-panel="my-roleplay-companions">
|
||||||
|
<div class="settings-panel">
|
||||||
|
<div class="settings-section-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Curated assistants</p>
|
||||||
|
<h2>Personalities</h2>
|
||||||
|
<p class="subtitle">Choose a focused assistant for writing, coding, research, learning, or
|
||||||
|
planning.</p>
|
||||||
|
</div>
|
||||||
|
<a class="secondary-action" href="/app/personas">Browse Personalities</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-view" role="tabpanel" aria-hidden="true" data-settings-panel="design">
|
||||||
|
<div class="settings-panel settings-panel-compact">
|
||||||
|
<div class="settings-section-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Appearance</p>
|
||||||
|
<h2>Make yourself at home</h2>
|
||||||
|
<p class="subtitle">Choose a style for your conversation space.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-theme-row">
|
||||||
|
<label class="field">
|
||||||
|
<span>Style</span>
|
||||||
|
<select id="settingsThemeSelect">
|
||||||
|
<option value="fakegpt">Grove</option>
|
||||||
|
<option value="fraud">Parchment</option>
|
||||||
|
<option value="germini">Aurora</option>
|
||||||
|
<option value="slopilot">Current</option>
|
||||||
|
<option value="beta-ai">Iris</option>
|
||||||
|
<option value="confusity">Graphite</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Light or dark</span>
|
||||||
|
<select id="themeModeSelect">
|
||||||
|
<option value="light">Light</option>
|
||||||
|
<option value="dark">Dark</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Workspace mode</span>
|
||||||
|
<select id="workspaceModeSelect">
|
||||||
|
<option value="basic">Basic</option>
|
||||||
|
<option value="advanced">Advanced</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-view" role="tabpanel" aria-hidden="true" data-settings-panel="diagnostics">
|
||||||
|
<div class="settings-panel">
|
||||||
|
<div class="settings-section-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Admin</p>
|
||||||
|
<h2>Model diagnostics</h2>
|
||||||
|
<p class="subtitle">Backend status, model inventory, latency, and configuration
|
||||||
|
warnings.</p>
|
||||||
|
</div>
|
||||||
|
<button id="refreshDiagnostics" type="button" class="secondary-action">Refresh</button>
|
||||||
|
</div>
|
||||||
|
<div class="diagnostics-grid">
|
||||||
|
<div class="diagnostics-card">
|
||||||
|
<span>Status</span>
|
||||||
|
<strong id="diagnosticsStatus">Unknown</strong>
|
||||||
|
<p id="diagnosticsBackend" class="subtitle">Backend unavailable</p>
|
||||||
|
</div>
|
||||||
|
<div class="diagnostics-card">
|
||||||
|
<span>Latency</span>
|
||||||
|
<strong id="diagnosticsLatency">n/a</strong>
|
||||||
|
<p id="diagnosticsCheckedAt" class="subtitle">Not checked yet</p>
|
||||||
|
</div>
|
||||||
|
<div class="diagnostics-card">
|
||||||
|
<span>Models</span>
|
||||||
|
<strong id="diagnosticsModelCount">0</strong>
|
||||||
|
<p id="diagnosticsCacheAge" class="subtitle">Cache age n/a</p>
|
||||||
|
</div>
|
||||||
|
<div class="diagnostics-card">
|
||||||
|
<span>Active requests</span>
|
||||||
|
<strong id="diagnosticsActiveRequests">0</strong>
|
||||||
|
<p id="diagnosticsLastFailure" class="subtitle">No recorded failures</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="diagnostics-columns">
|
||||||
|
<div>
|
||||||
|
<h3>Available models</h3>
|
||||||
|
<div id="diagnosticsModels" class="diagnostics-list"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>Config warnings</h3>
|
||||||
|
<div id="diagnosticsWarnings" class="diagnostics-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div id="settingsPersonaModal" class="modal-overlay hidden">
|
||||||
|
<div class="modal-card persona-modal-card" role="dialog" aria-modal="true"
|
||||||
|
aria-labelledby="settingsPersonaFormTitle" tabindex="-1">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="persona-modal-heading">
|
||||||
|
<p class="eyebrow">Persona editor</p>
|
||||||
|
<h3 id="settingsPersonaFormTitle">Create persona</h3>
|
||||||
|
</div>
|
||||||
|
<button id="settingsPersonaClose" type="button" class="icon-button">Close</button>
|
||||||
|
</div>
|
||||||
|
<form id="settingsPersonaForm" class="persona-form">
|
||||||
|
<div class="persona-form-intro">
|
||||||
|
<p id="settingsPersonaHelper" class="persona-helper">Create a persona for roleplay chats.</p>
|
||||||
|
</div>
|
||||||
|
<p id="settingsPersonaFormNotice" class="status hidden"></p>
|
||||||
|
<div class="persona-form-grid">
|
||||||
|
<label class="field field-wide">
|
||||||
|
<span>Persona for</span>
|
||||||
|
<select id="settingsPersonaType">
|
||||||
|
<option value="assistant">AI Character</option>
|
||||||
|
<option value="user">Your persona</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Name</span>
|
||||||
|
<input id="settingsPersonaName" placeholder="Persona name" required/>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Pronouns</span>
|
||||||
|
<input id="settingsPersonaPronouns" placeholder="e.g. she/her, they/them"/>
|
||||||
|
</label>
|
||||||
|
<label class="field field-wide">
|
||||||
|
<span>Appearance</span>
|
||||||
|
<textarea id="settingsPersonaAppearance" rows="3" placeholder="Describe their look"></textarea>
|
||||||
|
</label>
|
||||||
|
<label class="field field-wide">
|
||||||
|
<span>Background</span>
|
||||||
|
<textarea id="settingsPersonaBackground" rows="3"
|
||||||
|
placeholder="Backstory, history, role, or origins"></textarea>
|
||||||
|
</label>
|
||||||
|
<label class="field field-wide">
|
||||||
|
<span>Details</span>
|
||||||
|
<textarea id="settingsPersonaDetails" rows="4"
|
||||||
|
placeholder="Personality traits, voice, goals, quirks, boundaries, style"></textarea>
|
||||||
|
</label>
|
||||||
|
<label id="settingsPersonaExamplesField" class="field field-wide hidden">
|
||||||
|
<span>Example dialogues</span>
|
||||||
|
<div class="dialogue-format-card" aria-hidden="true">
|
||||||
|
<p class="dialogue-format-label">Format</p>
|
||||||
|
<pre class="dialogue-format-example">{{character}}: Hello, my name is marc
|
||||||
{{user}}: Hi, Marc!
|
{{user}}: Hi, Marc!
|
||||||
{{character}}: Nice to meet you</pre>
|
{{character}}: Nice to meet you</pre>
|
||||||
</div>
|
</div>
|
||||||
<textarea id="settingsPersonaExampleDialogues" rows="5"
|
<textarea id="settingsPersonaExampleDialogues" rows="5"
|
||||||
placeholder="Write example exchanges that define the character's tone, phrasing, pacing, and personality."></textarea>
|
placeholder="Write example exchanges that define the character's tone, phrasing, pacing, and personality."></textarea>
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="persona-actions">
|
|
||||||
<button id="settingsPersonaSave" type="submit" class="primary-action">Save persona</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="settingsPopupModal" class="modal-overlay hidden">
|
|
||||||
<div class="modal-card popup-card" role="dialog" aria-modal="true" aria-labelledby="settingsPopupTitle"
|
|
||||||
aria-describedby="settingsPopupDescription">
|
|
||||||
<div class="modal-header">
|
|
||||||
<div class="persona-modal-heading">
|
|
||||||
<p id="settingsPopupEyebrow" class="eyebrow">Action</p>
|
|
||||||
<h3 id="settingsPopupTitle">Confirm action</h3>
|
|
||||||
</div>
|
|
||||||
<button id="settingsPopupClose" type="button" class="icon-button">Close</button>
|
|
||||||
</div>
|
|
||||||
<div class="popup-content">
|
|
||||||
<p id="settingsPopupDescription" class="subtitle">Please confirm this action.</p>
|
|
||||||
<label id="settingsPopupField" class="field">
|
|
||||||
<span id="settingsPopupInputLabel">Value</span>
|
|
||||||
<input id="settingsPopupInput" type="text" autocomplete="off" />
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="popup-actions">
|
<div class="persona-actions">
|
||||||
<button id="settingsPopupCancel" type="button" class="secondary-action">Cancel</button>
|
<button id="settingsPersonaSave" type="submit" class="primary-action">Save persona</button>
|
||||||
<button id="settingsPopupConfirm" type="button">Confirm</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="settingsPopupModal" class="modal-overlay hidden">
|
||||||
|
<div class="modal-card popup-card" role="dialog" aria-modal="true" aria-labelledby="settingsPopupTitle"
|
||||||
|
aria-describedby="settingsPopupDescription">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="persona-modal-heading">
|
||||||
|
<p id="settingsPopupEyebrow" class="eyebrow">Action</p>
|
||||||
|
<h3 id="settingsPopupTitle">Confirm action</h3>
|
||||||
|
</div>
|
||||||
|
<button id="settingsPopupClose" type="button" class="icon-button">Close</button>
|
||||||
|
</div>
|
||||||
|
<div class="popup-content">
|
||||||
|
<p id="settingsPopupDescription" class="subtitle">Please confirm this action.</p>
|
||||||
|
<label id="settingsPopupField" class="field">
|
||||||
|
<span id="settingsPopupInputLabel">Value</span>
|
||||||
|
<input id="settingsPopupInput" type="text" autocomplete="off"/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="popup-actions">
|
||||||
|
<button id="settingsPopupCancel" type="button" class="secondary-action">Cancel</button>
|
||||||
|
<button id="settingsPopupConfirm" type="button">Confirm</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="/js/settings.js?v=20260923-asset-paths"></script>
|
</div>
|
||||||
<script type="module" src="/js/workspaceNavigation.js?v=20260909-account-controls"></script>
|
<script type="module" src="/js/settings.js?v=20260930-account-fixes"></script>
|
||||||
|
<script type="module" src="/js/workspaceNavigation.js?v=20260909-account-controls"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+146
-141
@@ -12,163 +12,168 @@
|
|||||||
<link rel="stylesheet" href="/css/shell.css?v=20260923-legal">
|
<link rel="stylesheet" href="/css/shell.css?v=20260923-legal">
|
||||||
<link rel="stylesheet" href="/css/legal.css?v=20260923-legal">
|
<link rel="stylesheet" href="/css/legal.css?v=20260923-legal">
|
||||||
<link
|
<link
|
||||||
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=Manrope:wght@400;500;600;700;800&display=swap"
|
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=Manrope:wght@400;500;600;700;800&display=swap"
|
||||||
rel="stylesheet">
|
rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="site-page legal-page">
|
<body class="site-page legal-page">
|
||||||
<!-- site-header -->
|
<!-- site-header -->
|
||||||
<main id="main" class="legal-shell">
|
<main id="main" class="legal-shell">
|
||||||
<header class="legal-hero">
|
<header class="legal-hero">
|
||||||
<p class="eyebrow">The ground rules</p>
|
<p class="eyebrow">The ground rules</p>
|
||||||
<h1>Terms of Service</h1>
|
<h1>Terms of Service</h1>
|
||||||
<p class="legal-summary">Please read these terms before using Elsewhere. They explain what you can expect
|
<p class="legal-summary">Please read these terms before using Elsewhere. They explain what you can expect
|
||||||
from the service and what we expect from you.</p>
|
from the service and what we expect from you.</p>
|
||||||
<p class="legal-updated">Effective and last updated <time datetime="2026-09-23">23 September 2026</time></p>
|
<p class="legal-updated">Effective and last updated
|
||||||
</header>
|
<time datetime="2026-09-23">23 September 2026</time>
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div class="legal-layout">
|
<div class="legal-layout">
|
||||||
<nav class="legal-toc" aria-label="On this page">
|
<nav class="legal-toc" aria-label="On this page">
|
||||||
<p>On this page</p>
|
<p>On this page</p>
|
||||||
<ol>
|
<ol>
|
||||||
<li><a href="#acceptance">Acceptance</a></li>
|
<li><a href="#acceptance">Acceptance</a></li>
|
||||||
<li><a href="#service">The service</a></li>
|
<li><a href="#service">The service</a></li>
|
||||||
<li><a href="#accounts">Accounts</a></li>
|
<li><a href="#accounts">Accounts</a></li>
|
||||||
<li><a href="#content">Your content</a></li>
|
<li><a href="#content">Your content</a></li>
|
||||||
<li><a href="#conduct">Acceptable use</a></li>
|
<li><a href="#conduct">Acceptable use</a></li>
|
||||||
<li><a href="#ai">AI and search</a></li>
|
<li><a href="#ai">AI and search</a></li>
|
||||||
<li><a href="#availability">Availability</a></li>
|
<li><a href="#availability">Availability</a></li>
|
||||||
<li><a href="#liability">Liability</a></li>
|
<li><a href="#liability">Liability</a></li>
|
||||||
<li><a href="#termination">Termination</a></li>
|
<li><a href="#termination">Termination</a></li>
|
||||||
<li><a href="#law">Law and changes</a></li>
|
<li><a href="#law">Law and changes</a></li>
|
||||||
<li><a href="#contact">Contact</a></li>
|
<li><a href="#contact">Contact</a></li>
|
||||||
</ol>
|
</ol>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<article class="legal-content">
|
<article class="legal-content">
|
||||||
<section id="acceptance">
|
<section id="acceptance">
|
||||||
<h2><span>01</span> Acceptance of these terms</h2>
|
<h2><span>01</span> Acceptance of these terms</h2>
|
||||||
<p>These Terms form an agreement between you and Kristian Cerni, the operator of Elsewhere
|
<p>These Terms form an agreement between you and Kristian Cerni, the operator of Elsewhere
|
||||||
(“Elsewhere”, “we”, “us”, or “our”). By creating an account or using the service, you agree to
|
(“Elsewhere”, “we”, “us”, or “our”). By creating an account or using the service, you agree to
|
||||||
these Terms and acknowledge the <a href="/privacy.html">Privacy Policy</a>. If you do not agree,
|
these Terms and acknowledge the <a href="/privacy.html">Privacy Policy</a>. If you do not agree,
|
||||||
do not use the service.</p>
|
do not use the service.</p>
|
||||||
<p>You must be legally able to enter into this agreement. If you use Elsewhere for an organisation,
|
<p>You must be legally able to enter into this agreement. If you use Elsewhere for an organisation,
|
||||||
you confirm that you have authority to bind that organisation.</p>
|
you confirm that you have authority to bind that organisation.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="service">
|
<section id="service">
|
||||||
<h2><span>02</span> The service</h2>
|
<h2><span>02</span> The service</h2>
|
||||||
<p>Elsewhere is an account-based AI chat and roleplay application. It lets you create conversations,
|
<p>Elsewhere is an account-based AI chat and roleplay application. It lets you create conversations,
|
||||||
personas and prompt templates; attach supported images; optionally search the web; and publish or
|
personas and prompt templates; attach supported images; optionally search the web; and publish or
|
||||||
collect personas through the public persona market.</p>
|
collect personas through the public persona market.</p>
|
||||||
<p>The service may change as features are added, removed or improved. We may set reasonable usage,
|
<p>The service may change as features are added, removed or improved. We may set reasonable usage,
|
||||||
storage or technical limits to keep Elsewhere reliable and safe.</p>
|
storage or technical limits to keep Elsewhere reliable and safe.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="accounts">
|
<section id="accounts">
|
||||||
<h2><span>03</span> Your account</h2>
|
<h2><span>03</span> Your account</h2>
|
||||||
<p>Provide accurate registration information, keep your password confidential and promptly tell us
|
<p>Provide accurate registration information, keep your password confidential and promptly tell us
|
||||||
if you believe your account has been compromised. You are responsible for activity performed
|
if you believe your account has been compromised. You are responsible for activity performed
|
||||||
through your account unless it results from our failure to use reasonable security measures.</p>
|
through your account unless it results from our failure to use reasonable security measures.</p>
|
||||||
<p>You may export your workspace, sign out active sessions, change your credentials or delete your
|
<p>You may export your workspace, sign out active sessions, change your credentials or delete your
|
||||||
account from Settings. Account deletion is permanent and removes your private workspace and
|
account from Settings. Account deletion is permanent and removes your private workspace and
|
||||||
published market content, subject to limited legal or operational retention described in the
|
published market content, subject to limited legal or operational retention described in the
|
||||||
Privacy Policy.</p>
|
Privacy Policy.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="content">
|
<section id="content">
|
||||||
<h2><span>04</span> Your content</h2>
|
<h2><span>04</span> Your content</h2>
|
||||||
<p>You retain your rights in prompts, messages, images, personas and other material you submit. You
|
<p>You retain your rights in prompts, messages, images, personas and other material you submit. You
|
||||||
give us a limited, non-exclusive licence to host, copy, process and display that content only as
|
give us a limited, non-exclusive licence to host, copy, process and display that content only as
|
||||||
needed to operate, secure and improve the features you choose to use.</p>
|
needed to operate, secure and improve the features you choose to use.</p>
|
||||||
<p>Content stays private to your account unless you deliberately publish it. When you publish a
|
<p>Content stays private to your account unless you deliberately publish it. When you publish a
|
||||||
persona to the market, its persona details, your username and related public engagement data can
|
persona to the market, its persona details, your username and related public engagement data can
|
||||||
be viewed and used by others. You can unpublish it, but copies already collected by other users
|
be viewed and used by others. You can unpublish it, but copies already collected by other users
|
||||||
may remain in their private workspaces.</p>
|
may remain in their private workspaces.</p>
|
||||||
<p>You confirm that you have the rights and permissions needed for content you submit. You are
|
<p>You confirm that you have the rights and permissions needed for content you submit. You are
|
||||||
responsible for that content and for deciding whether AI-generated output is suitable for your
|
responsible for that content and for deciding whether AI-generated output is suitable for your
|
||||||
use.</p>
|
use.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="conduct">
|
<section id="conduct">
|
||||||
<h2><span>05</span> Acceptable use</h2>
|
<h2><span>05</span> Acceptable use</h2>
|
||||||
<p>Do not use Elsewhere to:</p>
|
<p>Do not use Elsewhere to:</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>break the law, violate another person’s rights or encourage serious harm;</li>
|
<li>break the law, violate another person’s rights or encourage serious harm;</li>
|
||||||
<li>upload malware, probe for vulnerabilities, bypass access controls or disrupt the service;</li>
|
<li>upload malware, probe for vulnerabilities, bypass access controls or disrupt the service;</li>
|
||||||
<li>access another person’s account or data without permission;</li>
|
<li>access another person’s account or data without permission;</li>
|
||||||
<li>misrepresent AI-generated material as authentic where that could deceive or harm someone;</li>
|
<li>misrepresent AI-generated material as authentic where that could deceive or harm someone;</li>
|
||||||
<li>publish content that is unlawful, abusive, exploitative or infringes intellectual-property or
|
<li>publish content that is unlawful, abusive, exploitative or infringes intellectual-property or
|
||||||
privacy rights; or</li>
|
privacy rights; or
|
||||||
<li>automate access in a way that places an unreasonable load on the service or circumvents its
|
</li>
|
||||||
limits.</li>
|
<li>automate access in a way that places an unreasonable load on the service or circumvents its
|
||||||
</ul>
|
limits.
|
||||||
<p>We may remove public content or restrict access when reasonably necessary to address abuse,
|
</li>
|
||||||
security risks, legal obligations or violations of these Terms.</p>
|
</ul>
|
||||||
</section>
|
<p>We may remove public content or restrict access when reasonably necessary to address abuse,
|
||||||
|
security risks, legal obligations or violations of these Terms.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section id="ai">
|
<section id="ai">
|
||||||
<h2><span>06</span> AI output, images and web search</h2>
|
<h2><span>06</span> AI output, images and web search</h2>
|
||||||
<p>AI output can be inaccurate, incomplete, biased or unexpected. It is not professional medical,
|
<p>AI output can be inaccurate, incomplete, biased or unexpected. It is not professional medical,
|
||||||
legal, financial or other expert advice. Check important information independently and use human
|
legal, financial or other expert advice. Check important information independently and use human
|
||||||
judgment before relying on or sharing output.</p>
|
judgment before relying on or sharing output.</p>
|
||||||
<p>When image analysis is used, the system may misread visual details or text. When web search is
|
<p>When image analysis is used, the system may misread visual details or text. When web search is
|
||||||
enabled, Elsewhere may return links and excerpts from independent websites. We do not control or
|
enabled, Elsewhere may return links and excerpts from independent websites. We do not control or
|
||||||
endorse those sites, and their content and availability remain their responsibility.</p>
|
endorse those sites, and their content and availability remain their responsibility.</p>
|
||||||
<p>Outputs may not be unique. Other users may receive similar material, and we do not promise that
|
<p>Outputs may not be unique. Other users may receive similar material, and we do not promise that
|
||||||
generated output can be owned, registered or used without third-party restrictions.</p>
|
generated output can be owned, registered or used without third-party restrictions.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="availability">
|
<section id="availability">
|
||||||
<h2><span>07</span> Availability and warranties</h2>
|
<h2><span>07</span> Availability and warranties</h2>
|
||||||
<p>Elsewhere is provided on an “as available” basis. To the extent permitted by law, we make no
|
<p>Elsewhere is provided on an “as available” basis. To the extent permitted by law, we make no
|
||||||
warranties that the service will always be available, secure or error-free, or that output will
|
warranties that the service will always be available, secure or error-free, or that output will
|
||||||
be accurate or fit for a particular purpose. Nothing in these Terms excludes warranties or
|
be accurate or fit for a particular purpose. Nothing in these Terms excludes warranties or
|
||||||
consumer rights that cannot legally be excluded.</p>
|
consumer rights that cannot legally be excluded.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="liability">
|
<section id="liability">
|
||||||
<h2><span>08</span> Limitation of liability</h2>
|
<h2><span>08</span> Limitation of liability</h2>
|
||||||
<p>To the fullest extent permitted by applicable law, we are not liable for indirect, incidental,
|
<p>To the fullest extent permitted by applicable law, we are not liable for indirect, incidental,
|
||||||
special, consequential or punitive loss, or for loss of profits, opportunities, data or goodwill,
|
special, consequential or punitive loss, or for loss of profits, opportunities, data or goodwill,
|
||||||
arising from your use of Elsewhere. We do not limit liability where doing so would be unlawful,
|
arising from your use of Elsewhere. We do not limit liability where doing so would be unlawful,
|
||||||
including liability for intentional misconduct or gross negligence.</p>
|
including liability for intentional misconduct or gross negligence.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="termination">
|
<section id="termination">
|
||||||
<h2><span>09</span> Suspension and termination</h2>
|
<h2><span>09</span> Suspension and termination</h2>
|
||||||
<p>You may stop using Elsewhere or delete your account at any time. We may suspend or terminate
|
<p>You may stop using Elsewhere or delete your account at any time. We may suspend or terminate
|
||||||
access if you materially or repeatedly violate these Terms, create a security or legal risk, or
|
access if you materially or repeatedly violate these Terms, create a security or legal risk, or
|
||||||
misuse the service. Where reasonable, we will give notice and an opportunity to address the issue.</p>
|
misuse the service. Where reasonable, we will give notice and an opportunity to address the
|
||||||
</section>
|
issue.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section id="law">
|
<section id="law">
|
||||||
<h2><span>10</span> Governing law and changes</h2>
|
<h2><span>10</span> Governing law and changes</h2>
|
||||||
<p>These Terms are governed by Swiss law, without regard to conflict-of-law principles. Mandatory
|
<p>These Terms are governed by Swiss law, without regard to conflict-of-law principles. Mandatory
|
||||||
protections and courts available to consumers in their country of residence remain unaffected.</p>
|
protections and courts available to consumers in their country of residence remain unaffected.</p>
|
||||||
<p>We may update these Terms to reflect service, legal or security changes. We will post the revised
|
<p>We may update these Terms to reflect service, legal or security changes. We will post the revised
|
||||||
version here and update the date above. For material changes, we will provide reasonable notice
|
version here and update the date above. For material changes, we will provide reasonable notice
|
||||||
where practicable. Continued use after the revised Terms take effect means you accept them.</p>
|
where practicable. Continued use after the revised Terms take effect means you accept them.</p>
|
||||||
<p>If any provision is unenforceable, the remaining provisions continue in effect. A failure to
|
<p>If any provision is unenforceable, the remaining provisions continue in effect. A failure to
|
||||||
enforce a provision is not a waiver of it.</p>
|
enforce a provision is not a waiver of it.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="contact">
|
<section id="contact">
|
||||||
<h2><span>11</span> Contact</h2>
|
<h2><span>11</span> Contact</h2>
|
||||||
<p>Questions about these Terms can be sent to <a
|
<p>Questions about these Terms can be sent to <a
|
||||||
href="mailto:kriscerni@gmail.com">kriscerni@gmail.com</a> or raised through the project’s <a
|
href="mailto:kriscerni@gmail.com">kriscerni@gmail.com</a> or raised through the project’s <a
|
||||||
href="https://github.com/KrisHD1337" rel="external noopener">GitHub profile</a>.</p>
|
href="https://github.com/KrisHD1337" rel="external noopener">GitHub profile</a>.</p>
|
||||||
</section>
|
</section>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="legal-footer">
|
<footer class="legal-footer">
|
||||||
<span>Elsewhere</span>
|
<span>Elsewhere</span>
|
||||||
<a href="/privacy.html">Privacy Policy</a>
|
<a href="/privacy.html">Privacy Policy</a>
|
||||||
<a href="/">Back to Elsewhere</a>
|
<a href="/">Back to Elsewhere</a>
|
||||||
</footer>
|
</footer>
|
||||||
<script type="module" src="/js/workspaceNavigation.js?v=20260923-legal"></script>
|
<script type="module" src="/js/workspaceNavigation.js?v=20260923-legal"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Vendored
+20
@@ -0,0 +1,20 @@
|
|||||||
|
/* Local, pinned Markdown renderer compatible with the marked.parse API used by the chat UI. */
|
||||||
|
(function () {
|
||||||
|
const escape = value => String(value).replace(/[&<>"']/g, character => ({"&":"&","<":"<",">":">","\"":""","'":"'"}[character]));
|
||||||
|
const inline = value => escape(value)
|
||||||
|
.replace(/`([^`]+)`/g, "<code>$1</code>")
|
||||||
|
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
||||||
|
.replace(/__([^_]+)__/g, "<strong>$1</strong>")
|
||||||
|
.replace(/\*([^*]+)\*/g, "<em>$1</em>")
|
||||||
|
.replace(/_([^_]+)_/g, "<em>$1</em>")
|
||||||
|
.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" rel="noreferrer noopener">$1</a>');
|
||||||
|
window.marked = {parse(markdown) {
|
||||||
|
return String(markdown || "").split(/\n{2,}/).map(block => {
|
||||||
|
const lines = block.split("\n");
|
||||||
|
if (/^### /.test(lines[0])) return `<h3>${inline(lines[0].slice(4))}</h3>`;
|
||||||
|
if (/^## /.test(lines[0])) return `<h2>${inline(lines[0].slice(3))}</h2>`;
|
||||||
|
if (/^# /.test(lines[0])) return `<h1>${inline(lines[0].slice(2))}</h1>`;
|
||||||
|
return `<p>${lines.map(inline).join("<br>")}</p>`;
|
||||||
|
}).join("");
|
||||||
|
}};
|
||||||
|
}());
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/** Local-only browser regression harness; serves no account data or workspace files. */
|
||||||
|
import express from "express";
|
||||||
|
import {fileURLToPath} from "node:url";
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use("/public/js", express.static(fileURLToPath(new URL("../public/js", import.meta.url))));
|
||||||
|
app.use("/browser-tests", express.static(fileURLToPath(new URL("../browser-tests", import.meta.url))));
|
||||||
|
const server = app.listen(0, "127.0.0.1", () => {
|
||||||
|
console.log(`Navigation tests: http://127.0.0.1:${server.address().port}/browser-tests/conversation.html`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
process.env.NODE_ENV ??= "development";
|
||||||
|
await import("../server.js");
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import {spawnSync} from "node:child_process";
|
||||||
|
|
||||||
|
const suites = {
|
||||||
|
// Persona-market/roleplay generation protection belonged to the retired feature.
|
||||||
|
// Keep its historical test file available for reference, but do not run it as
|
||||||
|
// part of the supported application contract.
|
||||||
|
backend: ["app", "attachments", "automaticWebSearch", "modelStreaming", "rateLimit", "visionAnalysis", "visionDiagnostics", "webSearch", "webSearchRegression", "workspaceImport"],
|
||||||
|
// reasoning.test.js still contains the retired persona-roleplay workflow;
|
||||||
|
// supported streaming/reasoning coverage remains in modelStreaming and chat tests.
|
||||||
|
frontend: ["api", "attachmentComposer", "chatUsability", "composer", "conversation", "draftStore", "personalities", "regeneration", "regenerationUi", "streamRenderer", "workspaceTransferUi"]
|
||||||
|
};
|
||||||
|
const suite = process.argv[2];
|
||||||
|
if (!suites[suite]) throw new Error(`Unknown test suite: ${suite}`);
|
||||||
|
for (const name of suites[suite]) {
|
||||||
|
const result = spawnSync(process.execPath, ["--test", "--test-isolation=none", `test/${name}.test.js`], {stdio: "inherit"});
|
||||||
|
if (result.error) throw result.error;
|
||||||
|
if (result.status !== 0) process.exit(result.status ?? 1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/** Operator-only role assignment; never exposed through an HTTP endpoint. */
|
||||||
|
import {createConfig} from "../src/config.js";
|
||||||
|
import {createDatabase} from "../src/db/index.js";
|
||||||
|
|
||||||
|
const [username, role] = process.argv.slice(2);
|
||||||
|
if (!username || !["user", "admin"].includes(role) || process.argv.length !== 4) {
|
||||||
|
console.error("Usage: npm run user:role -- <existing-username> <user|admin>");
|
||||||
|
process.exitCode = 1;
|
||||||
|
} else {
|
||||||
|
const db = createDatabase(createConfig());
|
||||||
|
try {
|
||||||
|
const result = db.prepare("UPDATE users SET role = ? WHERE username = ?").run(role, username);
|
||||||
|
if (!result.changes) throw new Error("Account not found. Register the account first.");
|
||||||
|
console.log(`Role for ${username} set to ${role}.`);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
process.env.NODE_ENV ??= "production";
|
||||||
|
await import("../server.js");
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Startet den HTTP-Server der Chat-Anwendung.
|
||||||
|
*/
|
||||||
import {startServer} from "./src/app.js";
|
import {startServer} from "./src/app.js";
|
||||||
|
|
||||||
startServer();
|
startServer();
|
||||||
|
|||||||
+53
-16
@@ -1,23 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* @file Verbindet Express, Sitzungen, Datenbank und Routen und verwaltet den Server-Lebenszyklus.
|
||||||
|
*/
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
|
import {createRequire} from "node:module";
|
||||||
import {randomUUID} from "crypto";
|
import {randomUUID} from "crypto";
|
||||||
import {performance} from "perf_hooks";
|
import {performance} from "perf_hooks";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import session from "express-session";
|
import session from "express-session";
|
||||||
import FileStoreFactory from "session-file-store";
|
|
||||||
import {createConfig} from "./config.js";
|
import {createConfig} from "./config.js";
|
||||||
import {createDatabase} from "./db/index.js";
|
import {createDatabase} from "./db/index.js";
|
||||||
import {createRepositories} from "./db/repositories.js";
|
import {createRepositories} from "./db/repositories.js";
|
||||||
import {createAuthRateLimiters, createChatRateLimiters} from "./middleware/rateLimiters.js";
|
import {createAuthRateLimiters, createChatRateLimiters} from "./middleware/rateLimiters.js";
|
||||||
|
import {createSqliteRateLimitStore} from "./middleware/rateLimit.js";
|
||||||
import {createModelService} from "./services/modelService.js";
|
import {createModelService} from "./services/modelService.js";
|
||||||
import {createChatService} from "./services/chatService.js";
|
import {createChatService} from "./services/chatService.js";
|
||||||
import {createAuthRouter} from "./routes/auth.js";
|
import {createAuthRouter} from "./routes/auth.js";
|
||||||
import {createChatsRouter} from "./routes/chats.js";
|
import {createChatsRouter} from "./routes/chats.js";
|
||||||
|
import {createWorkspaceTransferRouter} from "./routes/workspaceTransfer.js";
|
||||||
import {createPagesRouter} from "./routes/pages.js";
|
import {createPagesRouter} from "./routes/pages.js";
|
||||||
import {createPersonasRouter} from "./routes/personas.js";
|
|
||||||
import {createSettingsRouter} from "./routes/settings.js";
|
import {createSettingsRouter} from "./routes/settings.js";
|
||||||
import {createSystemRouter} from "./routes/system.js";
|
import {createHealthRouter, createSystemRouter} from "./routes/system.js";
|
||||||
import {createAttachmentService} from "./services/attachmentService.js";
|
import {createAttachmentService} from "./services/attachmentService.js";
|
||||||
import {createUploadsRouter} from "./routes/uploads.js";
|
import {createUploadsRouter} from "./routes/uploads.js";
|
||||||
|
import {createVisionDebugService} from "./services/visionDebugService.js";
|
||||||
|
import {createVisionDebugRouter} from "./routes/visionDebug.js";
|
||||||
|
import {createAdminMarketRouter} from "./routes/adminMarket.js";
|
||||||
|
import {createSqliteSessionStore} from "./middleware/sqliteSessionStore.js";
|
||||||
|
|
||||||
export function createCloseHandler(resources) {
|
export function createCloseHandler(resources) {
|
||||||
let closed = false;
|
let closed = false;
|
||||||
@@ -95,7 +103,7 @@ function createObservability(config) {
|
|||||||
at: new Date().toISOString(),
|
at: new Date().toISOString(),
|
||||||
requestId: req?.id || null,
|
requestId: req?.id || null,
|
||||||
method: req?.method || null,
|
method: req?.method || null,
|
||||||
path: req?.originalUrl || req?.url || null,
|
path: req?.path || null,
|
||||||
status,
|
status,
|
||||||
message: String(error?.message || error || "Unknown error").slice(0, 500)
|
message: String(error?.message || error || "Unknown error").slice(0, 500)
|
||||||
};
|
};
|
||||||
@@ -121,11 +129,10 @@ function createObservability(config) {
|
|||||||
type: "request",
|
type: "request",
|
||||||
requestId: req.id,
|
requestId: req.id,
|
||||||
method: req.method,
|
method: req.method,
|
||||||
path: req.originalUrl || req.url,
|
path: req.path,
|
||||||
status,
|
status,
|
||||||
durationMs,
|
durationMs,
|
||||||
slow: durationMs > config.slowRequestWarnMs,
|
slow: durationMs > config.slowRequestWarnMs,
|
||||||
user: req.session?.user || null
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -214,13 +221,28 @@ function sameOriginProtection(config) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function browserSecurityHeaders(config) {
|
||||||
|
return (_req, res, next) => {
|
||||||
|
res.setHeader("Content-Security-Policy", "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self'; style-src 'self'; script-src 'self'; font-src 'self' data:; worker-src 'self' blob:");
|
||||||
|
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||||
|
res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()");
|
||||||
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||||
|
if (process.env.NODE_ENV === "production" && config.cookieSecure) res.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload");
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function enforceSessionVersion(repositories) {
|
function enforceSessionVersion(repositories) {
|
||||||
return (req, res, next) => {
|
return (req, res, next) => {
|
||||||
if (!req.session.user) return next();
|
if (!req.session.user) return next();
|
||||||
const user = repositories.getUser(req.session.user);
|
const user = repositories.getUser(req.session.user);
|
||||||
const currentVersion = Number(user?.session_version || 0);
|
const currentVersion = Number(user?.session_version || 0);
|
||||||
const sessionVersion = Number(req.session.sessionVersion || 0);
|
const sessionVersion = Number(req.session.sessionVersion || 0);
|
||||||
if (user && currentVersion === sessionVersion) return next();
|
if (user && currentVersion === sessionVersion) {
|
||||||
|
// Read the role on every request so revocation also affects existing sessions.
|
||||||
|
req.account = user;
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
req.session.destroy(() => {
|
req.session.destroy(() => {
|
||||||
if (req.path === "/session") {
|
if (req.path === "/session") {
|
||||||
@@ -239,6 +261,7 @@ export function createApp(options = {}) {
|
|||||||
if (config.dbPath !== ":memory:") {
|
if (config.dbPath !== ":memory:") {
|
||||||
fs.mkdirSync(config.dataDir, {recursive: true});
|
fs.mkdirSync(config.dataDir, {recursive: true});
|
||||||
fs.mkdirSync(config.sessionsDir, {recursive: true});
|
fs.mkdirSync(config.sessionsDir, {recursive: true});
|
||||||
|
fs.mkdirSync(config.uploadTmpDir, {recursive: true});
|
||||||
}
|
}
|
||||||
|
|
||||||
const closeHandlers = createLifecycleMonitors(app, config);
|
const closeHandlers = createLifecycleMonitors(app, config);
|
||||||
@@ -247,22 +270,27 @@ export function createApp(options = {}) {
|
|||||||
const db = createDatabase(config);
|
const db = createDatabase(config);
|
||||||
const repositories = createRepositories(db, config);
|
const repositories = createRepositories(db, config);
|
||||||
const modelService = options.modelService || createModelService(config, options.modelOverrides);
|
const modelService = options.modelService || createModelService(config, options.modelOverrides);
|
||||||
const attachmentService = createAttachmentService(db, config, modelService);
|
const visionDebugService = createVisionDebugService(config);
|
||||||
const chatService = createChatService(repositories, modelService, config, undefined, attachmentService);
|
const attachmentService = createAttachmentService(db, config, modelService, visionDebugService);
|
||||||
const FileStore = FileStoreFactory(session);
|
const chatService = createChatService(repositories, modelService, config, undefined, attachmentService, visionDebugService);
|
||||||
const authRateLimiters = createAuthRateLimiters(config);
|
const sessionStore = options.sessionStore || (config.testMode ? undefined : createSqliteSessionStore(db));
|
||||||
const chatRateLimiters = createChatRateLimiters(config);
|
const rateLimitStore = createSqliteRateLimitStore(db);
|
||||||
|
const authRateLimiters = createAuthRateLimiters(config, rateLimitStore);
|
||||||
|
const chatRateLimiters = createChatRateLimiters(config, rateLimitStore);
|
||||||
closeHandlers.push(
|
closeHandlers.push(
|
||||||
...authRateLimiters.map((limiter) => limiter.close),
|
...authRateLimiters.map((limiter) => limiter.close),
|
||||||
...chatRateLimiters.map((limiter) => limiter.close),
|
...chatRateLimiters.map((limiter) => limiter.close),
|
||||||
|
...(sessionStore?.close ? [() => sessionStore.close()] : []),
|
||||||
() => attachmentService.close(),
|
() => attachmentService.close(),
|
||||||
() => db.close()
|
() => db.close()
|
||||||
);
|
);
|
||||||
|
|
||||||
app.use(observability.middleware);
|
app.use(observability.middleware);
|
||||||
|
app.use(browserSecurityHeaders(config));
|
||||||
|
app.use(createHealthRouter({modelService, config}));
|
||||||
app.use(express.json({limit: "1mb"}));
|
app.use(express.json({limit: "1mb"}));
|
||||||
app.use(session({
|
app.use(session({
|
||||||
store: options.sessionStore || (config.testMode ? undefined : new FileStore({path: config.sessionsDir, reapInterval: 3600})),
|
store: sessionStore,
|
||||||
secret: config.sessionSecret,
|
secret: config.sessionSecret,
|
||||||
resave: false,
|
resave: false,
|
||||||
saveUninitialized: false,
|
saveUninitialized: false,
|
||||||
@@ -285,10 +313,17 @@ export function createApp(options = {}) {
|
|||||||
app.locals.repositories = repositories;
|
app.locals.repositories = repositories;
|
||||||
app.locals.modelService = modelService;
|
app.locals.modelService = modelService;
|
||||||
app.locals.attachmentService = attachmentService;
|
app.locals.attachmentService = attachmentService;
|
||||||
|
app.locals.visionDebugService = visionDebugService;
|
||||||
app.locals.observability = observability;
|
app.locals.observability = observability;
|
||||||
app.locals.close = createCloseHandler(closeHandlers);
|
app.locals.close = createCloseHandler(closeHandlers);
|
||||||
|
|
||||||
app.use(createPagesRouter(config));
|
app.use(createPagesRouter(config));
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const dompurifyPath = require.resolve("dompurify").replace(/purify\.cjs\.js$/, "purify.min.js");
|
||||||
|
app.get("/vendor/dompurify.js", (_req, res) => {
|
||||||
|
res.set("Cache-Control", "no-cache");
|
||||||
|
res.sendFile(dompurifyPath);
|
||||||
|
});
|
||||||
app.use(express.static(config.publicDir, {
|
app.use(express.static(config.publicDir, {
|
||||||
maxAge: config.staticMaxAge,
|
maxAge: config.staticMaxAge,
|
||||||
etag: config.staticEtag,
|
etag: config.staticEtag,
|
||||||
@@ -300,10 +335,12 @@ export function createApp(options = {}) {
|
|||||||
}));
|
}));
|
||||||
app.use(createAuthRouter({repositories, authRateLimiters, config}));
|
app.use(createAuthRouter({repositories, authRateLimiters, config}));
|
||||||
app.use(createUploadsRouter(attachmentService, config));
|
app.use(createUploadsRouter(attachmentService, config));
|
||||||
|
if (config.visionDebug) app.use(createVisionDebugRouter({visionDebugService, repositories}));
|
||||||
app.use(createSettingsRouter({repositories}));
|
app.use(createSettingsRouter({repositories}));
|
||||||
app.use(createChatsRouter({repositories, chatService, modelService, config, chatRateLimiters}));
|
app.use(createChatsRouter({repositories, chatService, modelService, config, chatRateLimiters, attachmentService}));
|
||||||
app.use(createPersonasRouter({repositories, chatService, modelService, config}));
|
app.use(createAdminMarketRouter(repositories));
|
||||||
app.use(createSystemRouter({modelService, config}));
|
app.use(createWorkspaceTransferRouter({repositories, chatService, config, attachmentService, db}));
|
||||||
|
app.use(createSystemRouter({modelService, config, observability}));
|
||||||
app.use(notFoundHandler);
|
app.use(notFoundHandler);
|
||||||
app.use(errorHandler);
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
|||||||
+61
-27
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Erstellt die Anwendungskonfiguration aus Umgebungsvariablen, Standardwerten und expliziten Vorgaben.
|
||||||
|
*/
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
const bool = (value, fallback = false) => {
|
const bool = (value, fallback = false) => {
|
||||||
@@ -9,6 +12,8 @@ const int = (value, fallback) => {
|
|||||||
const parsed = Number.parseInt(value, 10);
|
const parsed = Number.parseInt(value, 10);
|
||||||
return Number.isInteger(parsed) ? parsed : fallback;
|
return Number.isInteger(parsed) ? parsed : fallback;
|
||||||
};
|
};
|
||||||
|
const positiveInt = (value, fallback) => Math.max(1, int(value, fallback));
|
||||||
|
const nonNegativeInt = (value, fallback) => Math.max(0, int(value, fallback));
|
||||||
|
|
||||||
const trustProxy = (value, fallback = false) => {
|
const trustProxy = (value, fallback = false) => {
|
||||||
if (value == null || value === "") return fallback;
|
if (value == null || value === "") return fallback;
|
||||||
@@ -19,17 +24,25 @@ const trustProxy = (value, fallback = false) => {
|
|||||||
return Number.isInteger(parsed) ? parsed : value;
|
return Number.isInteger(parsed) ? parsed : value;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const validHttpUrl = (value, name, {allowEmpty = false} = {}) => {
|
||||||
|
if (allowEmpty && !value) return;
|
||||||
|
let parsed;
|
||||||
|
try { parsed = new URL(value); } catch { throw new Error(`${name} must be a valid URL.`); }
|
||||||
|
if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error(`${name} must use http or https.`);
|
||||||
|
};
|
||||||
|
|
||||||
export function createConfig(overrides = {}) {
|
export function createConfig(overrides = {}) {
|
||||||
const rootDir = overrides.rootDir || process.cwd();
|
const rootDir = overrides.rootDir || process.cwd();
|
||||||
const dataDir = overrides.dataDir || path.resolve(rootDir, "data");
|
const dataDir = overrides.dataDir || path.resolve(rootDir, "data");
|
||||||
const sessionsDir = overrides.sessionsDir || path.resolve(rootDir, "sessions");
|
const sessionsDir = overrides.sessionsDir || path.resolve(rootDir, "sessions");
|
||||||
const testMode = bool(process.env.TEST_MODE, process.env.NODE_ENV === "test");
|
const testMode = overrides.testMode ?? bool(process.env.TEST_MODE, process.env.NODE_ENV === "test");
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
rootDir,
|
rootDir,
|
||||||
publicDir: path.resolve(rootDir, "public"),
|
publicDir: path.resolve(rootDir, "public"),
|
||||||
dataDir,
|
dataDir,
|
||||||
uploadDir: process.env.UPLOAD_DIR || (process.env.NODE_ENV === "production" ? "/app/data/uploads" : path.resolve(dataDir, "uploads")),
|
uploadDir: process.env.UPLOAD_DIR || (process.env.NODE_ENV === "production" ? "/app/data/uploads" : path.resolve(dataDir, "uploads")),
|
||||||
|
uploadTmpDir: process.env.UPLOAD_TMP_DIR || path.resolve(dataDir, "upload-tmp"),
|
||||||
uploadMaxSizeMb: Math.max(1, int(process.env.UPLOAD_MAX_SIZE_MB, 10)),
|
uploadMaxSizeMb: Math.max(1, int(process.env.UPLOAD_MAX_SIZE_MB, 10)),
|
||||||
uploadMaxFiles: Math.max(1, int(process.env.UPLOAD_MAX_FILES, 5)),
|
uploadMaxFiles: Math.max(1, int(process.env.UPLOAD_MAX_FILES, 5)),
|
||||||
uploadMaxPixels: Math.max(1, int(process.env.UPLOAD_MAX_PIXELS, 30000000)),
|
uploadMaxPixels: Math.max(1, int(process.env.UPLOAD_MAX_PIXELS, 30000000)),
|
||||||
@@ -41,7 +54,7 @@ export function createConfig(overrides = {}) {
|
|||||||
cookieSecure: bool(process.env.COOKIE_SECURE, false),
|
cookieSecure: bool(process.env.COOKIE_SECURE, false),
|
||||||
allowInsecureCookies: bool(process.env.ALLOW_INSECURE_COOKIES, false),
|
allowInsecureCookies: bool(process.env.ALLOW_INSECURE_COOKIES, false),
|
||||||
trustProxy: trustProxy(process.env.TRUST_PROXY, false),
|
trustProxy: trustProxy(process.env.TRUST_PROXY, false),
|
||||||
sessionMaxAgeMs: int(process.env.SESSION_MAX_AGE_MS, 7 * 24 * 60 * 60 * 1000),
|
sessionMaxAgeMs: positiveInt(process.env.SESSION_MAX_AGE_MS, 7 * 24 * 60 * 60 * 1000),
|
||||||
registrationInviteCode: process.env.REGISTRATION_INVITE_CODE || "",
|
registrationInviteCode: process.env.REGISTRATION_INVITE_CODE || "",
|
||||||
sameOriginProtectionEnabled: bool(process.env.SAME_ORIGIN_PROTECTION_ENABLED, true),
|
sameOriginProtectionEnabled: bool(process.env.SAME_ORIGIN_PROTECTION_ENABLED, true),
|
||||||
staticMaxAge: process.env.STATIC_MAX_AGE || (process.env.NODE_ENV === "production" ? "1h" : 0),
|
staticMaxAge: process.env.STATIC_MAX_AGE || (process.env.NODE_ENV === "production" ? "1h" : 0),
|
||||||
@@ -52,42 +65,63 @@ export function createConfig(overrides = {}) {
|
|||||||
slowRequestLoggingEnabled: bool(process.env.SLOW_REQUEST_LOGGING_ENABLED, !testMode),
|
slowRequestLoggingEnabled: bool(process.env.SLOW_REQUEST_LOGGING_ENABLED, !testMode),
|
||||||
slowRequestWarnMs: int(process.env.SLOW_REQUEST_WARN_MS, 50),
|
slowRequestWarnMs: int(process.env.SLOW_REQUEST_WARN_MS, 50),
|
||||||
structuredRequestLoggingEnabled: bool(process.env.STRUCTURED_REQUEST_LOGGING_ENABLED, !testMode),
|
structuredRequestLoggingEnabled: bool(process.env.STRUCTURED_REQUEST_LOGGING_ENABLED, !testMode),
|
||||||
|
visionDebug: bool(process.env.VISION_DEBUG, false),
|
||||||
visionDebugSha256Enabled: bool(process.env.VISION_DEBUG_SHA256_ENABLED, false),
|
visionDebugSha256Enabled: bool(process.env.VISION_DEBUG_SHA256_ENABLED, false),
|
||||||
chatLimit: int(process.env.CHAT_LIMIT, 10),
|
chatLimit: positiveInt(process.env.CHAT_LIMIT, 10),
|
||||||
chatHistoryLimit: int(process.env.CHAT_HISTORY_LIMIT, 20),
|
chatHistoryLimit: positiveInt(process.env.CHAT_HISTORY_LIMIT, 20),
|
||||||
chatSummaryUpdateEveryMessages: int(process.env.CHAT_SUMMARY_UPDATE_EVERY_MESSAGES, 8),
|
chatSummaryUpdateEveryMessages: process.env.CHAT_SUMMARY_UPDATE_EVERY_MESSAGES == null ? 8 : nonNegativeInt(process.env.CHAT_SUMMARY_UPDATE_EVERY_MESSAGES, 8),
|
||||||
chatSummaryMaxChars: int(process.env.CHAT_SUMMARY_MAX_CHARS, 2200),
|
chatSummaryMaxChars: positiveInt(process.env.CHAT_SUMMARY_MAX_CHARS, 2200),
|
||||||
chatMaxRetries: int(process.env.CHAT_MAX_RETRIES, 5),
|
// The only model used for persisted context compaction. Leave unset to disable it.
|
||||||
|
contextModel: process.env.CONTEXT_MODEL || null,
|
||||||
|
contextCompactionTriggerTokens: positiveInt(process.env.CONTEXT_COMPACTION_TRIGGER_TOKENS, 6000),
|
||||||
|
contextCompactionRecentTokens: positiveInt(process.env.CONTEXT_COMPACTION_RECENT_TOKENS, 2500),
|
||||||
|
contextCompactionMinMessages: positiveInt(process.env.CONTEXT_COMPACTION_MIN_MESSAGES, 4),
|
||||||
|
contextCompactionMaxChars: positiveInt(process.env.CONTEXT_COMPACTION_MAX_CHARS, 8000),
|
||||||
|
chatMaxRetries: nonNegativeInt(process.env.CHAT_MAX_RETRIES, 5),
|
||||||
|
titleModel: process.env.TITLE_MODEL || null,
|
||||||
modelApiBaseUrl: process.env.MODEL_API_BASE_URL || "https://ai.krishd.ch/api",
|
modelApiBaseUrl: process.env.MODEL_API_BASE_URL || "https://ai.krishd.ch/api",
|
||||||
modelRequestYTimeoutMs: int(process.env.MODEL_REQUEST_TIMEOUT_MS, 180000),
|
modelRequestTimeoutMs: positiveInt(process.env.MODEL_REQUEST_TIMEOUT_MS, 180000),
|
||||||
modelConnectTimeoutMs: Math.max(1, int(process.env.MODEL_CONNECT_TIMEOUT_MS, 30000)),
|
modelConnectTimeoutMs: positiveInt(process.env.MODEL_CONNECT_TIMEOUT_MS, 30000),
|
||||||
modelInactivityTimeoutMs: Math.max(1, int(process.env.MODEL_INACTIVITY_TIMEOUT_MS, int(process.env.MODEL_REQUEST_TIMEOUT_MS, 180000))),
|
modelInactivityTimeoutMs: positiveInt(process.env.MODEL_INACTIVITY_TIMEOUT_MS, int(process.env.MODEL_REQUEST_TIMEOUT_MS, 180000)),
|
||||||
modelMaxGenerationMs: Math.max(1, int(process.env.MODEL_MAX_GENERATION_MS, 600000)),
|
modelMaxGenerationMs: positiveInt(process.env.MODEL_MAX_GENERATION_MS, 600000),
|
||||||
ollamaKeepAlive: process.env.OLLAMA_KEEP_ALIVE || "30m",
|
ollamaKeepAlive: process.env.OLLAMA_KEEP_ALIVE || "30m",
|
||||||
searxngUrl: process.env.SEARXNG_URL || "",
|
searxngUrl: process.env.SEARXNG_URL || "",
|
||||||
webSearchTimeoutMs: Math.max(1, int(process.env.WEB_SEARCH_TIMEOUT_MS, 8000)),
|
webSearchTimeoutMs: positiveInt(process.env.WEB_SEARCH_TIMEOUT_MS, 8000),
|
||||||
modelListTimeoutMs: int(process.env.MODEL_LIST_TIMEOUT_MS, 4000),
|
modelListTimeoutMs: positiveInt(process.env.MODEL_LIST_TIMEOUT_MS, 4000),
|
||||||
modelUnloadAfterMs: int(process.env.MODEL_UNLOAD_AFTER_MS, 30 * 1000),
|
modelUnloadAfterMs: positiveInt(process.env.MODEL_UNLOAD_AFTER_MS, 30 * 1000),
|
||||||
modelsCacheTtlMs: int(process.env.MODELS_CACHE_TTL_MS, 5 * 60 * 1000),
|
modelsCacheTtlMs: nonNegativeInt(process.env.MODELS_CACHE_TTL_MS, 5 * 60 * 1000),
|
||||||
authRateLimitWindowMs: int(process.env.AUTH_RATE_LIMIT_WINDOW_MS, 15 * 60 * 1000),
|
authRateLimitWindowMs: positiveInt(process.env.AUTH_RATE_LIMIT_WINDOW_MS, 15 * 60 * 1000),
|
||||||
authRateLimitMaxPerIp: int(process.env.AUTH_RATE_LIMIT_MAX_PER_IP, 20),
|
authRateLimitMaxPerIp: positiveInt(process.env.AUTH_RATE_LIMIT_MAX_PER_IP, 20),
|
||||||
authRateLimitMaxPerAccount: int(process.env.AUTH_RATE_LIMIT_MAX_PER_ACCOUNT, 10),
|
authRateLimitMaxPerAccount: positiveInt(process.env.AUTH_RATE_LIMIT_MAX_PER_ACCOUNT, 10),
|
||||||
chatRateLimitWindowMs: int(process.env.CHAT_RATE_LIMIT_WINDOW_MS, 60 * 1000),
|
chatRateLimitWindowMs: positiveInt(process.env.CHAT_RATE_LIMIT_WINDOW_MS, 60 * 1000),
|
||||||
chatRateLimitMaxPerIp: int(process.env.CHAT_RATE_LIMIT_MAX_PER_IP, 60),
|
chatRateLimitMaxPerIp: positiveInt(process.env.CHAT_RATE_LIMIT_MAX_PER_IP, 60),
|
||||||
chatRateLimitMaxPerAccount: int(process.env.CHAT_RATE_LIMIT_MAX_PER_ACCOUNT, 30),
|
chatRateLimitMaxPerAccount: positiveInt(process.env.CHAT_RATE_LIMIT_MAX_PER_ACCOUNT, 30),
|
||||||
rateLimitCleanupIntervalMs: int(process.env.RATE_LIMIT_CLEANUP_INTERVAL_MS, 60 * 1000),
|
rateLimitCleanupIntervalMs: positiveInt(process.env.RATE_LIMIT_CLEANUP_INTERVAL_MS, 60 * 1000),
|
||||||
testMode,
|
testMode,
|
||||||
dropLegacyChats: bool(process.env.DROP_LEGACY_CHATS, true)
|
dropLegacyChats: bool(process.env.DROP_LEGACY_CHATS, false),
|
||||||
|
healthCacheTtlMs: Math.max(0, int(process.env.HEALTH_CACHE_TTL_MS, testMode ? 0 : 5000)),
|
||||||
|
healthTimeoutMs: Math.max(100, int(process.env.HEALTH_TIMEOUT_MS, 2000)),
|
||||||
|
healthRateLimitWindowMs: Math.max(1000, int(process.env.HEALTH_RATE_LIMIT_WINDOW_MS, 60000)),
|
||||||
|
healthRateLimitMaxPerIp: Math.max(1, int(process.env.HEALTH_RATE_LIMIT_MAX_PER_IP, testMode ? 1000 : 30))
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolved = {...config, ...overrides};
|
const resolved = {...config, ...overrides};
|
||||||
if (process.env.NODE_ENV === "production" && !resolved.testMode) {
|
// An explicitly named environment is a deployment contract. With no NODE_ENV,
|
||||||
|
// retain the local-development defaults used by the CLI and test helpers.
|
||||||
|
const isProduction = process.env.NODE_ENV === "production" || overrides.production === true;
|
||||||
|
const isNonTestDeployment = Boolean(process.env.NODE_ENV) && process.env.NODE_ENV !== "test" && !resolved.testMode;
|
||||||
|
if (isNonTestDeployment || overrides.production === true) {
|
||||||
if (resolved.sessionSecret === "change-me-session-secret") {
|
if (resolved.sessionSecret === "change-me-session-secret") {
|
||||||
throw new Error("SESSION_SECRET must be set in production.");
|
throw new Error("SESSION_SECRET must be set outside test mode.");
|
||||||
}
|
}
|
||||||
if (!resolved.cookieSecure && !resolved.allowInsecureCookies) {
|
if (isProduction && !resolved.cookieSecure && !resolved.allowInsecureCookies) {
|
||||||
throw new Error("COOKIE_SECURE must be true in production unless ALLOW_INSECURE_COOKIES=true is set.");
|
throw new Error("COOKIE_SECURE must be true outside test mode unless ALLOW_INSECURE_COOKIES=true is set.");
|
||||||
}
|
}
|
||||||
|
if (![true, false].includes(resolved.trustProxy) && !(Number.isInteger(resolved.trustProxy) && resolved.trustProxy >= 0)) {
|
||||||
|
throw new Error("TRUST_PROXY must be a boolean or non-negative hop count.");
|
||||||
|
}
|
||||||
|
validHttpUrl(resolved.modelApiBaseUrl, "MODEL_API_BASE_URL");
|
||||||
|
validHttpUrl(resolved.searxngUrl, "SEARXNG_URL", {allowEmpty: true});
|
||||||
}
|
}
|
||||||
|
|
||||||
return resolved;
|
return resolved;
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
export function createAccountStatements(db) {
|
||||||
|
return {
|
||||||
|
insertUser: db.prepare("INSERT INTO users (username, password) VALUES (?, ?)"),
|
||||||
|
getUser: db.prepare("SELECT username, password, role, session_version FROM users WHERE username = ?"),
|
||||||
|
getUserCaseInsensitive: db.prepare("SELECT username, password, role, session_version FROM users WHERE LOWER(username) = LOWER(?)"),
|
||||||
|
updateUsername: db.prepare("UPDATE users SET username = ? WHERE username = ?"),
|
||||||
|
updatePassword: db.prepare("UPDATE users SET password = ? WHERE username = ?"),
|
||||||
|
incrementSessionVersion: db.prepare("UPDATE users SET session_version = COALESCE(session_version, 0) + 1 WHERE username = ?"),
|
||||||
|
updateChatSessionsUsername: db.prepare("UPDATE chat_sessions SET username = ? WHERE username = ?"),
|
||||||
|
updatePersonasUsername: db.prepare("UPDATE personas SET username = ? WHERE username = ?"),
|
||||||
|
updatePersonasSourceCreator: db.prepare("UPDATE personas SET source_creator_username = ? WHERE source_creator_username = ?"),
|
||||||
|
updateUserSettingsUsername: db.prepare("UPDATE user_settings SET username = ? WHERE username = ?"),
|
||||||
|
updatePersonaMarketCreator: db.prepare("UPDATE persona_market SET creator_username = ? WHERE creator_username = ?"),
|
||||||
|
updatePersonaVersionsUsername: db.prepare("UPDATE persona_versions SET username = ? WHERE username = ?"),
|
||||||
|
updatePromptTemplatesUsername: db.prepare("UPDATE prompt_templates SET username = ? WHERE username = ?"),
|
||||||
|
updateChatMemoriesUsername: db.prepare("UPDATE chat_memories SET username = ? WHERE username = ?"),
|
||||||
|
updatePersonaChatStateUsername: db.prepare("UPDATE persona_chat_state SET username = ? WHERE username = ?"),
|
||||||
|
updateMarketFavoritesUsername: db.prepare("UPDATE persona_market_favorites SET username = ? WHERE username = ?"),
|
||||||
|
updateMarketRatingsUsername: db.prepare("UPDATE persona_market_ratings SET username = ? WHERE username = ?"),
|
||||||
|
updateMarketReportsUsername: db.prepare("UPDATE persona_market_reports SET reporter_username = ? WHERE reporter_username = ?"),
|
||||||
|
deleteChatMemoriesByUser: db.prepare("DELETE FROM chat_memories WHERE username = ?"),
|
||||||
|
deleteChatSessionsByUser: db.prepare("DELETE FROM chat_sessions WHERE username = ?"),
|
||||||
|
deletePersonasByUser: db.prepare("DELETE FROM personas WHERE username = ?"),
|
||||||
|
deleteUserSettingsByUser: db.prepare("DELETE FROM user_settings WHERE username = ?"),
|
||||||
|
deletePersonaVersionsByUser: db.prepare("DELETE FROM persona_versions WHERE username = ?"),
|
||||||
|
deletePromptTemplatesByUser: db.prepare("DELETE FROM prompt_templates WHERE username = ?"),
|
||||||
|
deleteMarketPersonasByCreator: db.prepare("DELETE FROM persona_market WHERE creator_username = ?"),
|
||||||
|
deleteMarketFavoritesByUser: db.prepare("DELETE FROM persona_market_favorites WHERE username = ?"),
|
||||||
|
deleteMarketRatingsByUser: db.prepare("DELETE FROM persona_market_ratings WHERE username = ?"),
|
||||||
|
deleteMarketReportsByUser: db.prepare("DELETE FROM persona_market_reports WHERE reporter_username = ?"),
|
||||||
|
deletePersonaChatStateByUser: db.prepare("DELETE FROM persona_chat_state WHERE username = ?"),
|
||||||
|
deleteUser: db.prepare("DELETE FROM users WHERE username = ?")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAccountRepository(db, statements) {
|
||||||
|
const renameUserTransaction = db.transaction((currentUsername, nextUsername) => {
|
||||||
|
// Existing username references do not all cascade on update. Check them at commit,
|
||||||
|
// after every owned row has been moved to the new username.
|
||||||
|
db.pragma("defer_foreign_keys = ON");
|
||||||
|
statements.updateUsername.run(nextUsername, currentUsername);
|
||||||
|
statements.updateChatSessionsUsername.run(nextUsername, currentUsername);
|
||||||
|
statements.updatePersonasUsername.run(nextUsername, currentUsername);
|
||||||
|
statements.updatePersonasSourceCreator.run(nextUsername, currentUsername);
|
||||||
|
statements.updateUserSettingsUsername.run(nextUsername, currentUsername);
|
||||||
|
statements.updatePersonaMarketCreator.run(nextUsername, currentUsername);
|
||||||
|
statements.updatePersonaVersionsUsername.run(nextUsername, currentUsername);
|
||||||
|
statements.updatePromptTemplatesUsername.run(nextUsername, currentUsername);
|
||||||
|
statements.updateChatMemoriesUsername.run(nextUsername, currentUsername);
|
||||||
|
statements.updatePersonaChatStateUsername.run(nextUsername, currentUsername);
|
||||||
|
statements.updateMarketFavoritesUsername.run(nextUsername, currentUsername);
|
||||||
|
statements.updateMarketRatingsUsername.run(nextUsername, currentUsername);
|
||||||
|
statements.updateMarketReportsUsername.run(nextUsername, currentUsername);
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteUserTransaction = db.transaction((username) => {
|
||||||
|
statements.deleteMarketFavoritesByUser.run(username);
|
||||||
|
statements.deleteMarketRatingsByUser.run(username);
|
||||||
|
statements.deleteMarketReportsByUser.run(username);
|
||||||
|
statements.deleteMarketPersonasByCreator.run(username);
|
||||||
|
statements.deletePromptTemplatesByUser.run(username);
|
||||||
|
statements.deletePersonaVersionsByUser.run(username);
|
||||||
|
statements.deleteChatMemoriesByUser.run(username);
|
||||||
|
statements.deletePersonaChatStateByUser.run(username);
|
||||||
|
statements.deleteChatSessionsByUser.run(username);
|
||||||
|
statements.deleteUserSettingsByUser.run(username);
|
||||||
|
statements.deletePersonasByUser.run(username);
|
||||||
|
return statements.deleteUser.run(username);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
getUser: (username) => statements.getUser.get(username),
|
||||||
|
getUserCaseInsensitive: (username) => statements.getUserCaseInsensitive.get(username),
|
||||||
|
insertUser: (username, passwordHash) => statements.insertUser.run(username, passwordHash),
|
||||||
|
updatePassword: (passwordHash, username) => statements.updatePassword.run(passwordHash, username),
|
||||||
|
incrementSessionVersion(username) {
|
||||||
|
statements.incrementSessionVersion.run(username);
|
||||||
|
return statements.getUser.get(username);
|
||||||
|
},
|
||||||
|
renameUser: (currentUsername, nextUsername) => renameUserTransaction(currentUsername, nextUsername),
|
||||||
|
deleteUser: (username) => deleteUserTransaction(username)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Definiert Tabellen für Bildanhänge und Analyse-Caches und bereitet Anhangsmetadaten auf.
|
||||||
|
*/
|
||||||
export function initializeAttachments(db) {
|
export function initializeAttachments(db) {
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS attachments (
|
CREATE TABLE IF NOT EXISTS attachments (
|
||||||
@@ -24,6 +27,17 @@ export function initializeAttachments(db) {
|
|||||||
PRIMARY KEY (attachment_id, vision_model, prompt_version, question)
|
PRIMARY KEY (attachment_id, vision_model, prompt_version, question)
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS upload_deletions (storage_key TEXT PRIMARY KEY);
|
CREATE TABLE IF NOT EXISTS upload_deletions (storage_key TEXT PRIMARY KEY);
|
||||||
|
CREATE TABLE IF NOT EXISTS upload_leases (
|
||||||
|
token TEXT PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL REFERENCES users(username) ON DELETE CASCADE,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS upload_leases_user ON upload_leases(username);
|
||||||
|
CREATE TABLE IF NOT EXISTS workspace_operation_leases (
|
||||||
|
username TEXT PRIMARY KEY REFERENCES users(username) ON DELETE CASCADE,
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
CREATE TRIGGER IF NOT EXISTS release_message_attachment AFTER DELETE ON message_attachments
|
CREATE TRIGGER IF NOT EXISTS release_message_attachment AFTER DELETE ON message_attachments
|
||||||
BEGIN
|
BEGIN
|
||||||
DELETE FROM attachments WHERE id = OLD.attachment_id AND status = 'attached'
|
DELETE FROM attachments WHERE id = OLD.attachment_id AND status = 'attached'
|
||||||
|
|||||||
@@ -0,0 +1,575 @@
|
|||||||
|
export function createChatStatements(db) {
|
||||||
|
return {
|
||||||
|
insertChatSession: db.prepare(
|
||||||
|
"INSERT INTO chat_sessions (username, title, assistant_persona_id, user_persona_id, folder_name, is_pinned, archived_at, scenario_prompt, scenario_summary) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||||
|
),
|
||||||
|
listChatSessions: db.prepare(`
|
||||||
|
SELECT cs.id,
|
||||||
|
CASE
|
||||||
|
WHEN cs.assistant_persona_id IS NOT NULL AND cs.source_chat_id IS NULL THEN COALESCE(p.name, cs.title)
|
||||||
|
ELSE cs.title
|
||||||
|
END AS title,
|
||||||
|
cs.title AS stored_title,
|
||||||
|
cs.assistant_persona_id,
|
||||||
|
cs.personality_id,
|
||||||
|
p.name AS assistant_persona_name,
|
||||||
|
cs.user_persona_id,
|
||||||
|
up.name AS user_persona_name,
|
||||||
|
cs.folder_name,
|
||||||
|
cs.is_pinned,
|
||||||
|
cs.archived_at,
|
||||||
|
cs.scenario_prompt,
|
||||||
|
cs.scenario_summary,
|
||||||
|
cs.context_summary,
|
||||||
|
cs.context_summary_message_id,
|
||||||
|
cs.active_leaf_message_id,
|
||||||
|
cs.source_chat_id,
|
||||||
|
cs.branched_from_message_id,
|
||||||
|
cs.preferred_model,
|
||||||
|
cs.temperature,
|
||||||
|
cs.context_length,
|
||||||
|
cs.response_length,
|
||||||
|
cs.system_instruction,
|
||||||
|
cs.created_at,
|
||||||
|
cs.updated_at,
|
||||||
|
(SELECT substr(m.content, 1, 180) FROM chat_messages m
|
||||||
|
WHERE m.chat_id = cs.id AND m.content <> ''
|
||||||
|
ORDER BY m.id DESC LIMIT 1) AS last_message_preview
|
||||||
|
FROM chat_sessions cs
|
||||||
|
LEFT JOIN personas p ON p.id = cs.assistant_persona_id
|
||||||
|
LEFT JOIN personas up ON up.id = cs.user_persona_id
|
||||||
|
WHERE cs.username = ?
|
||||||
|
ORDER BY cs.is_pinned DESC, datetime(cs.updated_at) DESC
|
||||||
|
`),
|
||||||
|
countChatSessions: db.prepare("SELECT COUNT(*) as count FROM chat_sessions WHERE username = ? AND archived_at IS NULL"),
|
||||||
|
getChatSession: db.prepare(`
|
||||||
|
SELECT cs.id,
|
||||||
|
CASE
|
||||||
|
WHEN cs.assistant_persona_id IS NOT NULL AND cs.source_chat_id IS NULL THEN COALESCE(p.name, cs.title)
|
||||||
|
ELSE cs.title
|
||||||
|
END AS title,
|
||||||
|
cs.title AS stored_title,
|
||||||
|
cs.assistant_persona_id,
|
||||||
|
cs.personality_id,
|
||||||
|
p.name AS assistant_persona_name,
|
||||||
|
cs.user_persona_id,
|
||||||
|
up.name AS user_persona_name,
|
||||||
|
cs.folder_name,
|
||||||
|
cs.is_pinned,
|
||||||
|
cs.archived_at,
|
||||||
|
cs.scenario_prompt,
|
||||||
|
cs.scenario_summary,
|
||||||
|
cs.context_summary,
|
||||||
|
cs.context_summary_message_id,
|
||||||
|
cs.active_leaf_message_id,
|
||||||
|
cs.source_chat_id,
|
||||||
|
cs.branched_from_message_id,
|
||||||
|
cs.preferred_model,
|
||||||
|
cs.temperature,
|
||||||
|
cs.context_length,
|
||||||
|
cs.response_length,
|
||||||
|
cs.system_instruction,
|
||||||
|
cs.created_at,
|
||||||
|
cs.updated_at
|
||||||
|
FROM chat_sessions cs
|
||||||
|
LEFT JOIN personas p ON p.id = cs.assistant_persona_id
|
||||||
|
LEFT JOIN personas up ON up.id = cs.user_persona_id
|
||||||
|
WHERE cs.id = ?
|
||||||
|
AND cs.username = ?
|
||||||
|
`),
|
||||||
|
getChatSessionByParticipants: db.prepare(`
|
||||||
|
SELECT cs.id,
|
||||||
|
CASE
|
||||||
|
WHEN cs.assistant_persona_id IS NOT NULL AND cs.source_chat_id IS NULL THEN COALESCE(p.name, cs.title)
|
||||||
|
ELSE cs.title
|
||||||
|
END AS title,
|
||||||
|
cs.title AS stored_title,
|
||||||
|
cs.assistant_persona_id,
|
||||||
|
cs.personality_id,
|
||||||
|
p.name AS assistant_persona_name,
|
||||||
|
cs.user_persona_id,
|
||||||
|
up.name AS user_persona_name,
|
||||||
|
cs.folder_name,
|
||||||
|
cs.is_pinned,
|
||||||
|
cs.archived_at,
|
||||||
|
cs.scenario_prompt,
|
||||||
|
cs.scenario_summary,
|
||||||
|
cs.context_summary,
|
||||||
|
cs.context_summary_message_id,
|
||||||
|
cs.active_leaf_message_id,
|
||||||
|
cs.source_chat_id,
|
||||||
|
cs.branched_from_message_id,
|
||||||
|
cs.preferred_model,
|
||||||
|
cs.temperature,
|
||||||
|
cs.context_length,
|
||||||
|
cs.response_length,
|
||||||
|
cs.system_instruction,
|
||||||
|
cs.created_at,
|
||||||
|
cs.updated_at
|
||||||
|
FROM chat_sessions cs
|
||||||
|
LEFT JOIN personas p ON p.id = cs.assistant_persona_id
|
||||||
|
LEFT JOIN personas up ON up.id = cs.user_persona_id
|
||||||
|
WHERE cs.username = ?
|
||||||
|
AND cs.assistant_persona_id = ?
|
||||||
|
AND ((cs.user_persona_id IS NULL AND ? IS NULL) OR cs.user_persona_id = ?)
|
||||||
|
AND cs.source_chat_id IS NULL
|
||||||
|
ORDER BY cs.is_pinned DESC, datetime(cs.updated_at) DESC
|
||||||
|
LIMIT 1
|
||||||
|
`),
|
||||||
|
updateChatSessionTitle: db.prepare("UPDATE chat_sessions SET title = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?"),
|
||||||
|
updateChatUserPersona: db.prepare("UPDATE chat_sessions SET user_persona_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?"),
|
||||||
|
updateChatPersonality: db.prepare("UPDATE chat_sessions SET personality_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?"),
|
||||||
|
updateChatSessionScene: db.prepare("UPDATE chat_sessions SET scenario_prompt = ?, scenario_summary = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?"),
|
||||||
|
updateChatContextSummary: db.prepare("UPDATE chat_sessions SET context_summary = ?, context_summary_message_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?"),
|
||||||
|
updateChatContextSummaryIfUnchanged: db.prepare("UPDATE chat_sessions SET context_summary = ?, context_summary_message_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ? AND context_summary IS ? AND context_summary_message_id = ?"),
|
||||||
|
updateChatGenerationSettings: db.prepare(`
|
||||||
|
UPDATE chat_sessions
|
||||||
|
SET preferred_model = ?, temperature = ?, context_length = ?, response_length = ?, system_instruction = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ? AND username = ?
|
||||||
|
`),
|
||||||
|
updateChatPreferredModel: db.prepare("UPDATE chat_sessions SET preferred_model = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?"),
|
||||||
|
updateChatActiveLeaf: db.prepare("UPDATE chat_sessions SET active_leaf_message_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?"),
|
||||||
|
updateChatBranchSource: db.prepare("UPDATE chat_sessions SET source_chat_id = ?, branched_from_message_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?"),
|
||||||
|
setChatActiveLeafById: db.prepare("UPDATE chat_sessions SET active_leaf_message_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"),
|
||||||
|
updateChatSessionOrganization: db.prepare(`
|
||||||
|
UPDATE chat_sessions
|
||||||
|
SET folder_name = ?,
|
||||||
|
is_pinned = ?,
|
||||||
|
archived_at = ?,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
AND username = ?
|
||||||
|
`),
|
||||||
|
touchChatSession: db.prepare("UPDATE chat_sessions SET updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?"),
|
||||||
|
deleteChatSession: db.prepare("DELETE FROM chat_sessions WHERE id = ? AND username = ?"),
|
||||||
|
insertChatMessage: db.prepare("INSERT INTO chat_messages (chat_id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id, delivery_status, error_message, parent_message_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"),
|
||||||
|
updateChatMessageDelivery: db.prepare("UPDATE chat_messages SET delivery_status = ?, error_message = ? WHERE id = ? AND chat_id = ?"),
|
||||||
|
updateChatMessageSources: db.prepare("UPDATE chat_messages SET sources = ? WHERE id = ? AND chat_id = ?"),
|
||||||
|
updateChatMessageThinking: db.prepare("UPDATE chat_messages SET thinking = ? WHERE id = ? AND chat_id = ?"),
|
||||||
|
getChatMessages: db.prepare(`
|
||||||
|
SELECT thinking, sources, id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id, delivery_status, error_message, parent_message_id, created_at
|
||||||
|
FROM chat_messages
|
||||||
|
WHERE chat_id = ?
|
||||||
|
ORDER BY id
|
||||||
|
`),
|
||||||
|
getRecentChatMessages: db.prepare(`
|
||||||
|
WITH RECURSIVE branch AS (
|
||||||
|
SELECT cm.* FROM chat_messages cm
|
||||||
|
JOIN chat_sessions cs ON cs.id = cm.chat_id
|
||||||
|
WHERE cm.chat_id = ?
|
||||||
|
AND cm.id = COALESCE(cs.active_leaf_message_id, (SELECT MAX(id) FROM chat_messages WHERE chat_id = cm.chat_id))
|
||||||
|
UNION ALL
|
||||||
|
SELECT parent.* FROM chat_messages parent
|
||||||
|
JOIN branch child ON child.parent_message_id = parent.id
|
||||||
|
)
|
||||||
|
SELECT id, role, content FROM branch
|
||||||
|
WHERE delivery_status != 'failed'
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT ?
|
||||||
|
`),
|
||||||
|
getRecentChatMessagesUpToId: db.prepare(`
|
||||||
|
WITH RECURSIVE branch AS (
|
||||||
|
SELECT * FROM chat_messages WHERE chat_id = ? AND id = ?
|
||||||
|
UNION ALL
|
||||||
|
SELECT parent.* FROM chat_messages parent
|
||||||
|
JOIN branch child ON child.parent_message_id = parent.id
|
||||||
|
)
|
||||||
|
SELECT id, role, content FROM branch
|
||||||
|
WHERE delivery_status != 'failed'
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT ?
|
||||||
|
`),
|
||||||
|
getChatBranchThroughMessage: db.prepare(`
|
||||||
|
WITH RECURSIVE branch AS (
|
||||||
|
SELECT * FROM chat_messages WHERE chat_id = ? AND id = ?
|
||||||
|
UNION ALL
|
||||||
|
SELECT parent.* FROM chat_messages parent
|
||||||
|
JOIN branch child ON child.parent_message_id = parent.id
|
||||||
|
)
|
||||||
|
SELECT id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used,
|
||||||
|
retry_prompt_message_id, delivery_status, error_message, parent_message_id, created_at, sources, thinking
|
||||||
|
FROM branch
|
||||||
|
WHERE delivery_status = 'complete'
|
||||||
|
ORDER BY id
|
||||||
|
`),
|
||||||
|
getActiveBranchMessages: db.prepare(`
|
||||||
|
WITH RECURSIVE branch AS (
|
||||||
|
SELECT cm.* FROM chat_messages cm
|
||||||
|
JOIN chat_sessions cs ON cs.id = cm.chat_id
|
||||||
|
WHERE cm.chat_id = ?
|
||||||
|
AND cm.id = COALESCE(cs.active_leaf_message_id, (SELECT MAX(id) FROM chat_messages WHERE chat_id = cm.chat_id))
|
||||||
|
UNION ALL
|
||||||
|
SELECT parent.* FROM chat_messages parent
|
||||||
|
JOIN branch child ON child.parent_message_id = parent.id
|
||||||
|
)
|
||||||
|
SELECT id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used,
|
||||||
|
retry_prompt_message_id, delivery_status, error_message, parent_message_id, created_at, sources, thinking
|
||||||
|
FROM branch ORDER BY id
|
||||||
|
`),
|
||||||
|
getSiblingChatMessages: db.prepare(`
|
||||||
|
SELECT id, role, content, parent_message_id
|
||||||
|
FROM chat_messages
|
||||||
|
WHERE chat_id = ? AND role = ? AND parent_message_id IS ?
|
||||||
|
ORDER BY id
|
||||||
|
`),
|
||||||
|
getLatestDescendantMessage: db.prepare(`
|
||||||
|
WITH RECURSIVE descendants AS (
|
||||||
|
SELECT id FROM chat_messages WHERE chat_id = ? AND id = ?
|
||||||
|
UNION ALL
|
||||||
|
SELECT child.id FROM chat_messages child JOIN descendants parent ON child.parent_message_id = parent.id
|
||||||
|
WHERE child.chat_id = ? AND child.delivery_status != 'failed'
|
||||||
|
)
|
||||||
|
SELECT cm.* FROM chat_messages cm JOIN descendants d ON d.id = cm.id ORDER BY cm.id DESC LIMIT 1
|
||||||
|
`),
|
||||||
|
getChatMessageById: db.prepare(`
|
||||||
|
SELECT thinking, sources, id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id, delivery_status, error_message, parent_message_id, created_at
|
||||||
|
FROM chat_messages
|
||||||
|
WHERE id = ?
|
||||||
|
AND chat_id = ?
|
||||||
|
`),
|
||||||
|
getChatMessageByIndex: db.prepare(`
|
||||||
|
SELECT thinking, sources, id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id, delivery_status, error_message, parent_message_id, created_at
|
||||||
|
FROM chat_messages
|
||||||
|
WHERE chat_id = ?
|
||||||
|
ORDER BY id
|
||||||
|
LIMIT 1 OFFSET ?
|
||||||
|
`),
|
||||||
|
getLatestChatMessage: db.prepare(`
|
||||||
|
SELECT id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id, delivery_status, error_message, parent_message_id, created_at
|
||||||
|
FROM chat_messages
|
||||||
|
WHERE chat_id = ?
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 1
|
||||||
|
`),
|
||||||
|
getPreviousUserMessage: db.prepare(`
|
||||||
|
SELECT id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id, delivery_status, error_message, parent_message_id, created_at
|
||||||
|
FROM chat_messages
|
||||||
|
WHERE chat_id = ?
|
||||||
|
AND role = 'user'
|
||||||
|
AND id < ?
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 1
|
||||||
|
`),
|
||||||
|
listChatMemories: db.prepare(`
|
||||||
|
SELECT id, username, chat_id, assistant_persona_id, user_persona_id, fact, created_at, updated_at
|
||||||
|
FROM chat_memories
|
||||||
|
WHERE chat_id = ?
|
||||||
|
AND username = ?
|
||||||
|
ORDER BY datetime(updated_at) DESC, id DESC
|
||||||
|
`),
|
||||||
|
getChatMemory: db.prepare(`
|
||||||
|
SELECT id, username, chat_id, assistant_persona_id, user_persona_id, fact, created_at, updated_at
|
||||||
|
FROM chat_memories
|
||||||
|
WHERE id = ?
|
||||||
|
AND chat_id = ?
|
||||||
|
AND username = ?
|
||||||
|
`),
|
||||||
|
insertChatMemory: db.prepare(`
|
||||||
|
INSERT INTO chat_memories (username, chat_id, assistant_persona_id, user_persona_id, fact)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`),
|
||||||
|
updateChatMemory: db.prepare(`
|
||||||
|
UPDATE chat_memories
|
||||||
|
SET fact = ?,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
AND chat_id = ?
|
||||||
|
AND username = ?
|
||||||
|
`),
|
||||||
|
deleteChatMemory: db.prepare("DELETE FROM chat_memories WHERE id = ? AND chat_id = ? AND username = ?"),
|
||||||
|
getPersonaChatState: db.prepare(`
|
||||||
|
SELECT id,
|
||||||
|
username,
|
||||||
|
chat_id,
|
||||||
|
assistant_persona_id,
|
||||||
|
user_persona_id,
|
||||||
|
relationship_notes,
|
||||||
|
tone,
|
||||||
|
current_location,
|
||||||
|
goals,
|
||||||
|
unresolved_threads,
|
||||||
|
boundaries,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM persona_chat_state
|
||||||
|
WHERE chat_id = ?
|
||||||
|
AND username = ?
|
||||||
|
`),
|
||||||
|
upsertPersonaChatState: db.prepare(`
|
||||||
|
INSERT INTO persona_chat_state (
|
||||||
|
username,
|
||||||
|
chat_id,
|
||||||
|
assistant_persona_id,
|
||||||
|
user_persona_id,
|
||||||
|
relationship_notes,
|
||||||
|
tone,
|
||||||
|
current_location,
|
||||||
|
goals,
|
||||||
|
unresolved_threads,
|
||||||
|
boundaries
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(chat_id) DO UPDATE SET
|
||||||
|
username = excluded.username,
|
||||||
|
assistant_persona_id = excluded.assistant_persona_id,
|
||||||
|
user_persona_id = excluded.user_persona_id,
|
||||||
|
relationship_notes = excluded.relationship_notes,
|
||||||
|
tone = excluded.tone,
|
||||||
|
current_location = excluded.current_location,
|
||||||
|
goals = excluded.goals,
|
||||||
|
unresolved_threads = excluded.unresolved_threads,
|
||||||
|
boundaries = excluded.boundaries,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
`),
|
||||||
|
updateChatMessage: db.prepare("UPDATE chat_messages SET content = ? WHERE id = ? AND chat_id = ?"),
|
||||||
|
updateChatMessageWithRetryState: db.prepare("UPDATE chat_messages SET content = ?, retry_variants = ?, retry_active_index = ?, retry_retries_used = ?, retry_prompt_message_id = ? WHERE id = ? AND chat_id = ?"),
|
||||||
|
deleteChatMessage: db.prepare("DELETE FROM chat_messages WHERE id = ? AND chat_id = ?"),
|
||||||
|
deleteChatMessages: db.prepare("DELETE FROM chat_messages WHERE chat_id = ?"),
|
||||||
|
searchChats: db.prepare(`
|
||||||
|
SELECT cs.id,
|
||||||
|
CASE
|
||||||
|
WHEN cs.assistant_persona_id IS NOT NULL AND cs.source_chat_id IS NULL THEN COALESCE(p.name, cs.title)
|
||||||
|
ELSE cs.title
|
||||||
|
END AS title,
|
||||||
|
cs.title AS stored_title,
|
||||||
|
cs.assistant_persona_id,
|
||||||
|
cs.personality_id,
|
||||||
|
p.name AS assistant_persona_name,
|
||||||
|
cs.user_persona_id,
|
||||||
|
up.name AS user_persona_name,
|
||||||
|
cs.folder_name,
|
||||||
|
cs.is_pinned,
|
||||||
|
cs.archived_at,
|
||||||
|
cs.scenario_prompt,
|
||||||
|
cs.scenario_summary,
|
||||||
|
cs.context_summary,
|
||||||
|
cs.context_summary_message_id,
|
||||||
|
cs.active_leaf_message_id,
|
||||||
|
cs.source_chat_id,
|
||||||
|
cs.branched_from_message_id,
|
||||||
|
cs.preferred_model,
|
||||||
|
cs.temperature,
|
||||||
|
cs.context_length,
|
||||||
|
cs.response_length,
|
||||||
|
cs.system_instruction,
|
||||||
|
cs.created_at,
|
||||||
|
cs.updated_at,
|
||||||
|
(SELECT substr(m.content, 1, 180) FROM chat_messages m
|
||||||
|
WHERE m.chat_id = cs.id AND m.content <> ''
|
||||||
|
ORDER BY m.id DESC LIMIT 1) AS last_message_preview,
|
||||||
|
cm.id AS matched_message_id,
|
||||||
|
cm.content AS matched_content
|
||||||
|
FROM chat_sessions cs
|
||||||
|
LEFT JOIN personas p ON p.id = cs.assistant_persona_id
|
||||||
|
LEFT JOIN personas up ON up.id = cs.user_persona_id
|
||||||
|
LEFT JOIN chat_messages cm ON cm.id = (
|
||||||
|
SELECT m.id FROM chat_messages m
|
||||||
|
WHERE m.chat_id = cs.id AND LOWER(m.content) LIKE ?
|
||||||
|
ORDER BY m.id DESC LIMIT 1
|
||||||
|
)
|
||||||
|
WHERE cs.username = ?
|
||||||
|
AND (
|
||||||
|
LOWER(CASE
|
||||||
|
WHEN cs.assistant_persona_id IS NOT NULL AND cs.source_chat_id IS NULL THEN COALESCE(p.name, cs.title)
|
||||||
|
ELSE cs.title
|
||||||
|
END) LIKE ?
|
||||||
|
OR cm.id IS NOT NULL
|
||||||
|
)
|
||||||
|
ORDER BY cs.is_pinned DESC, datetime(cs.updated_at) DESC
|
||||||
|
`)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createChatRepository(db, config, statements) {
|
||||||
|
const completeChatTurnTransaction = db.transaction((chatId, userMessageId, assistantContent, retryState, modelName, sources, thinking) => {
|
||||||
|
const assistantResult = statements.insertChatMessage.run(
|
||||||
|
chatId,
|
||||||
|
"bot",
|
||||||
|
String(assistantContent || ""),
|
||||||
|
modelName || null,
|
||||||
|
JSON.stringify(retryState.retryVariants),
|
||||||
|
retryState.retryActiveIndex,
|
||||||
|
retryState.retryRetriesUsed,
|
||||||
|
retryState.retryPromptMessageId ?? null,
|
||||||
|
"complete",
|
||||||
|
null,
|
||||||
|
userMessageId
|
||||||
|
);
|
||||||
|
statements.updateChatMessageDelivery.run("complete", null, userMessageId, chatId);
|
||||||
|
if (sources) statements.updateChatMessageSources.run(JSON.stringify(sources), assistantResult.lastInsertRowid, chatId);
|
||||||
|
if (thinking) statements.updateChatMessageThinking.run(thinking, assistantResult.lastInsertRowid, chatId);
|
||||||
|
statements.setChatActiveLeafById.run(assistantResult.lastInsertRowid, chatId);
|
||||||
|
return assistantResult;
|
||||||
|
});
|
||||||
|
|
||||||
|
const insertBranchChatMessageTransaction = db.transaction((chatId, role, content, retryState, modelName, deliveryStatus, errorMessage, parentMessageId) => {
|
||||||
|
const result = statements.insertChatMessage.run(
|
||||||
|
chatId, role, String(content || ""), modelName || null,
|
||||||
|
retryState ? JSON.stringify(retryState.retryVariants) : null,
|
||||||
|
retryState?.retryActiveIndex ?? 0,
|
||||||
|
retryState?.retryRetriesUsed ?? 0,
|
||||||
|
retryState?.retryPromptMessageId ?? null,
|
||||||
|
deliveryStatus,
|
||||||
|
errorMessage,
|
||||||
|
parentMessageId ?? null
|
||||||
|
);
|
||||||
|
statements.setChatActiveLeafById.run(result.lastInsertRowid, chatId);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
const failChatTurnTransaction = db.transaction((chatId, userMessageId, errorMessage, parentMessageId) => {
|
||||||
|
statements.updateChatMessageDelivery.run("failed", errorMessage, userMessageId, chatId);
|
||||||
|
statements.setChatActiveLeafById.run(parentMessageId ?? null, chatId);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
listChats: (username) => statements.listChatSessions.all(username),
|
||||||
|
searchChats(username, query) {
|
||||||
|
const like = `%${String(query || "").trim().toLowerCase()}%`;
|
||||||
|
return statements.searchChats.all(like, username, like);
|
||||||
|
},
|
||||||
|
countChats: (username) => statements.countChatSessions.get(username).count,
|
||||||
|
getChat: (chatId, username) => statements.getChatSession.get(chatId, username),
|
||||||
|
getChatByParticipants: (username, assistantPersonaId, userPersonaId) => statements.getChatSessionByParticipants.get(username, assistantPersonaId, userPersonaId, userPersonaId),
|
||||||
|
createChat(username, title, assistantPersonaId = null, userPersonaId = null, scenarioPrompt = null, scenarioSummary = null, organization = {}) {
|
||||||
|
const chatId = statements.insertChatSession.run(
|
||||||
|
username,
|
||||||
|
title,
|
||||||
|
assistantPersonaId,
|
||||||
|
userPersonaId,
|
||||||
|
organization.folderName ?? null,
|
||||||
|
organization.isPinned ? 1 : 0,
|
||||||
|
organization.archivedAt ?? null,
|
||||||
|
scenarioPrompt,
|
||||||
|
scenarioSummary
|
||||||
|
).lastInsertRowid;
|
||||||
|
return statements.getChatSession.get(chatId, username);
|
||||||
|
},
|
||||||
|
updateChatTitle: (chatId, username, title) => statements.updateChatSessionTitle.run(title, chatId, username),
|
||||||
|
updateChatUserPersona: (chatId, username, personaId) => statements.updateChatUserPersona.run(personaId ?? null, chatId, username),
|
||||||
|
updateChatPersonality: (chatId, username, personalityId) => statements.updateChatPersonality.run(personalityId, chatId, username),
|
||||||
|
updateChatScene: (chatId, username, scenarioPrompt, scenarioSummary) => statements.updateChatSessionScene.run(scenarioPrompt, scenarioSummary, chatId, username),
|
||||||
|
updateChatContextSummary: (chatId, username, summary, messageId) => statements.updateChatContextSummary.run(summary, Number(messageId || 0), chatId, username),
|
||||||
|
updateChatContextSummaryIfUnchanged: (chatId, username, summary, messageId, previousSummary, previousMessageId) => statements.updateChatContextSummaryIfUnchanged.run(summary, Number(messageId || 0), chatId, username, previousSummary, Number(previousMessageId || 0)),
|
||||||
|
getPersonaChatState: (chatId, username) => statements.getPersonaChatState.get(chatId, username) || null,
|
||||||
|
updatePersonaChatState(username, chat, state) {
|
||||||
|
statements.upsertPersonaChatState.run(
|
||||||
|
username,
|
||||||
|
chat.id,
|
||||||
|
chat.assistant_persona_id ?? null,
|
||||||
|
chat.user_persona_id ?? null,
|
||||||
|
state.relationship_notes ?? null,
|
||||||
|
state.tone ?? null,
|
||||||
|
state.current_location ?? null,
|
||||||
|
state.goals ?? null,
|
||||||
|
state.unresolved_threads ?? null,
|
||||||
|
state.boundaries ?? null
|
||||||
|
);
|
||||||
|
return statements.getPersonaChatState.get(chat.id, username);
|
||||||
|
},
|
||||||
|
updateChatOrganization(chatId, username, {folderName = null, isPinned = false, archivedAt = null}) {
|
||||||
|
return statements.updateChatSessionOrganization.run(folderName, isPinned ? 1 : 0, archivedAt, chatId, username);
|
||||||
|
},
|
||||||
|
touchChat: (chatId, username) => statements.touchChatSession.run(chatId, username),
|
||||||
|
deleteChat: (chatId, username) => statements.deleteChatSession.run(chatId, username),
|
||||||
|
listChatMessages: (chatId) => statements.getChatMessages.all(chatId),
|
||||||
|
listActiveBranchMessages: (chatId) => statements.getActiveBranchMessages.all(chatId),
|
||||||
|
listChatBranchThroughMessage: (chatId, messageId) => statements.getChatBranchThroughMessage.all(chatId, messageId),
|
||||||
|
listSiblingChatMessages: (chatId, role, parentMessageId) => statements.getSiblingChatMessages.all(chatId, role, parentMessageId ?? null),
|
||||||
|
getLatestDescendantMessage: (chatId, messageId) => statements.getLatestDescendantMessage.get(chatId, messageId, chatId),
|
||||||
|
getRecentChatMessages: (chatId) => statements.getRecentChatMessages.all(chatId, config.chatHistoryLimit),
|
||||||
|
getRecentChatMessagesUpToId: (chatId, messageId) => statements.getRecentChatMessagesUpToId.all(chatId, messageId, config.chatHistoryLimit),
|
||||||
|
getChatMessage: (chatId, messageId) => statements.getChatMessageById.get(messageId, chatId),
|
||||||
|
getChatMessageByIndex: (chatId, index) => statements.getChatMessageByIndex.get(chatId, index),
|
||||||
|
getLatestChatMessage: (chatId) => statements.getLatestChatMessage.get(chatId),
|
||||||
|
getPreviousUserMessage: (chatId, messageId) => statements.getPreviousUserMessage.get(chatId, messageId),
|
||||||
|
listChatMemories: (chatId, username) => statements.listChatMemories.all(chatId, username),
|
||||||
|
getChatMemory: (chatId, memoryId, username) => statements.getChatMemory.get(memoryId, chatId, username),
|
||||||
|
createChatMemory(username, chat, fact) {
|
||||||
|
const result = statements.insertChatMemory.run(
|
||||||
|
username,
|
||||||
|
chat.id,
|
||||||
|
chat.assistant_persona_id ?? null,
|
||||||
|
chat.user_persona_id ?? null,
|
||||||
|
String(fact || "").trim()
|
||||||
|
);
|
||||||
|
return statements.getChatMemory.get(result.lastInsertRowid, chat.id, username);
|
||||||
|
},
|
||||||
|
updateChatMemory: (chatId, memoryId, username, fact) => statements.updateChatMemory.run(String(fact || "").trim(), memoryId, chatId, username),
|
||||||
|
deleteChatMemory: (chatId, memoryId, username) => statements.deleteChatMemory.run(memoryId, chatId, username),
|
||||||
|
insertChatMessage(chatId, role, content, retryState = null, modelName = null, deliveryStatus = "complete", errorMessage = null, parentMessageId = null) {
|
||||||
|
return statements.insertChatMessage.run(
|
||||||
|
chatId,
|
||||||
|
role,
|
||||||
|
String(content || ""),
|
||||||
|
modelName || null,
|
||||||
|
retryState ? JSON.stringify(retryState.retryVariants) : null,
|
||||||
|
retryState?.retryActiveIndex ?? 0,
|
||||||
|
retryState?.retryRetriesUsed ?? 0,
|
||||||
|
retryState?.retryPromptMessageId ?? null,
|
||||||
|
deliveryStatus,
|
||||||
|
errorMessage,
|
||||||
|
parentMessageId
|
||||||
|
);
|
||||||
|
},
|
||||||
|
insertBranchChatMessage: (chatId, role, content, retryState = null, modelName = null, deliveryStatus = "complete", errorMessage = null, parentMessageId = null) =>
|
||||||
|
insertBranchChatMessageTransaction(chatId, role, content, retryState, modelName, deliveryStatus, errorMessage, parentMessageId),
|
||||||
|
updateChatMessageDelivery: (chatId, messageId, deliveryStatus, errorMessage = null) =>
|
||||||
|
statements.updateChatMessageDelivery.run(deliveryStatus, errorMessage, messageId, chatId),
|
||||||
|
completeChatTurn: (chatId, userMessageId, assistantContent, retryState, modelName = null, sources = null, thinking = null) =>
|
||||||
|
completeChatTurnTransaction(chatId, userMessageId, assistantContent, retryState, modelName, sources, thinking),
|
||||||
|
updateChatMessageThinking: (chatId, messageId, thinking) =>
|
||||||
|
statements.updateChatMessageThinking.run(thinking, messageId, chatId),
|
||||||
|
updateChatMessageSources: (chatId, messageId, sources) =>
|
||||||
|
statements.updateChatMessageSources.run(JSON.stringify(sources), messageId, chatId),
|
||||||
|
failChatTurn: (chatId, userMessageId, errorMessage, parentMessageId = null) =>
|
||||||
|
failChatTurnTransaction(chatId, userMessageId, errorMessage, parentMessageId),
|
||||||
|
updateChatActiveLeaf: (chatId, username, messageId) => statements.updateChatActiveLeaf.run(messageId, chatId, username),
|
||||||
|
updateChatBranchSource: (chatId, username, sourceChatId, messageId) =>
|
||||||
|
statements.updateChatBranchSource.run(sourceChatId, messageId ?? null, chatId, username),
|
||||||
|
updateChatGenerationSettings: (chatId, username, settings) => statements.updateChatGenerationSettings.run(
|
||||||
|
settings.preferredModel ?? null,
|
||||||
|
settings.temperature ?? null,
|
||||||
|
settings.contextLength ?? null,
|
||||||
|
settings.responseLength ?? null,
|
||||||
|
settings.systemInstruction ?? null,
|
||||||
|
chatId,
|
||||||
|
username
|
||||||
|
),
|
||||||
|
updateChatPreferredModel: (chatId, username, model) => statements.updateChatPreferredModel.run(model || null, chatId, username),
|
||||||
|
listMessageVersions: (messageId) => db.prepare("SELECT * FROM chat_message_versions WHERE message_id = ? ORDER BY id").all(messageId),
|
||||||
|
saveMessageVersion: (chatId, messageId, version = null, selectedIndex = null) => db.transaction(() => {
|
||||||
|
const row = statements.getChatMessageById.get(messageId, chatId);
|
||||||
|
if (!row) throw new Error("Message not found");
|
||||||
|
let versions = db.prepare("SELECT * FROM chat_message_versions WHERE message_id = ? ORDER BY id").all(messageId);
|
||||||
|
const insert = db.prepare("INSERT INTO chat_message_versions (message_id, content, thinking, sources, model_name) VALUES (?, ?, ?, ?, ?)");
|
||||||
|
if (!versions.length) {
|
||||||
|
db.prepare("INSERT INTO chat_message_versions (message_id, content, thinking, sources, model_name, created_at) VALUES (?, ?, ?, ?, ?, ?)")
|
||||||
|
.run(messageId, row.content, row.thinking, row.sources, row.model_name, row.created_at);
|
||||||
|
} else {
|
||||||
|
// Edits keep their existing semantics: update the selected answer.
|
||||||
|
db.prepare("UPDATE chat_message_versions SET content = ?, thinking = ?, sources = ?, model_name = ? WHERE id = ?")
|
||||||
|
.run(row.content, row.thinking, row.sources, row.model_name, versions[row.retry_active_index]?.id ?? versions[0].id);
|
||||||
|
}
|
||||||
|
if (version) insert.run(messageId, version.content, version.thinking || null, JSON.stringify(version.sources ?? null), version.modelName);
|
||||||
|
versions = db.prepare("SELECT * FROM chat_message_versions WHERE message_id = ? ORDER BY id").all(messageId);
|
||||||
|
const index = version ? versions.length - 1 : selectedIndex;
|
||||||
|
if (!Number.isInteger(index) || !versions[index]) throw new Error("Version is out of range");
|
||||||
|
const active = versions[index];
|
||||||
|
db.prepare(`UPDATE chat_messages SET content = ?, thinking = ?, sources = ?, model_name = ?,
|
||||||
|
retry_variants = ?, retry_active_index = ?, retry_retries_used = ? WHERE id = ? AND chat_id = ?`)
|
||||||
|
.run(active.content, active.thinking, active.sources, active.model_name, JSON.stringify(versions.map(item => item.content)), index, versions.length - 1, messageId, chatId);
|
||||||
|
})(),
|
||||||
|
updateChatMessage: (chatId, messageId, content) => statements.updateChatMessage.run(content, messageId, chatId),
|
||||||
|
updateChatMessageRetryState: (chatId, messageId, payload) => statements.updateChatMessageWithRetryState.run(
|
||||||
|
payload.content,
|
||||||
|
JSON.stringify(payload.retryVariants),
|
||||||
|
payload.retryActiveIndex,
|
||||||
|
payload.retryRetriesUsed,
|
||||||
|
payload.retryPromptMessageId ?? null,
|
||||||
|
messageId,
|
||||||
|
chatId
|
||||||
|
),
|
||||||
|
deleteChatMessage: (chatId, messageId) => statements.deleteChatMessage.run(messageId, chatId),
|
||||||
|
clearChatMessages: (chatId) => statements.deleteChatMessages.run(chatId)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import {attachmentMetadata} from "./attachments.js";
|
||||||
|
|
||||||
|
/** Bounded history reads and batched metadata for a single page. */
|
||||||
|
export function createHistoryRepository(db) {
|
||||||
|
const activeLeaf = db.prepare(`SELECT COALESCE(active_leaf_message_id,
|
||||||
|
(SELECT MAX(id) FROM chat_messages WHERE chat_id = chat_sessions.id)) AS id
|
||||||
|
FROM chat_sessions WHERE id = ?`);
|
||||||
|
const ancestors = `WITH RECURSIVE branch(id, parent_message_id) AS (
|
||||||
|
SELECT id, parent_message_id FROM chat_messages WHERE chat_id = ? AND id = ?
|
||||||
|
UNION SELECT p.id, p.parent_message_id FROM chat_messages p JOIN branch c ON p.id = c.parent_message_id WHERE p.chat_id = ?
|
||||||
|
)`;
|
||||||
|
const onBranch = db.prepare(`${ancestors} SELECT * FROM branch WHERE id = ?`);
|
||||||
|
const page = db.prepare(`WITH RECURSIVE branch AS (
|
||||||
|
SELECT *, 0 AS depth FROM chat_messages WHERE chat_id = ? AND id = ?
|
||||||
|
UNION ALL SELECT p.*, c.depth + 1 FROM chat_messages p JOIN branch c ON p.id = c.parent_message_id
|
||||||
|
WHERE p.chat_id = ? AND c.depth < ?
|
||||||
|
) SELECT * FROM branch ORDER BY depth DESC`);
|
||||||
|
const versions = db.prepare("SELECT * FROM chat_message_versions WHERE message_id IN (SELECT value FROM json_each(?)) ORDER BY id");
|
||||||
|
const attachments = db.prepare(`SELECT a.*, m.message_id FROM attachments a JOIN message_attachments m ON a.id = m.attachment_id
|
||||||
|
WHERE m.message_id IN (SELECT value FROM json_each(?)) ORDER BY m.position`);
|
||||||
|
const siblings = db.prepare(`WITH families AS (SELECT DISTINCT role, parent_message_id FROM chat_messages
|
||||||
|
WHERE id IN (SELECT value FROM json_each(?)) AND chat_id = ?)
|
||||||
|
SELECT cm.id, ROW_NUMBER() OVER family - 1 AS siblingIndex, COUNT(*) OVER family AS siblingCount,
|
||||||
|
LAG(cm.id) OVER family AS previousSiblingId, LEAD(cm.id) OVER family AS nextSiblingId
|
||||||
|
FROM chat_messages cm JOIN families f ON cm.role = f.role AND cm.parent_message_id IS f.parent_message_id
|
||||||
|
WHERE cm.chat_id = ? WINDOW family AS (PARTITION BY cm.role, cm.parent_message_id ORDER BY cm.id
|
||||||
|
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)`);
|
||||||
|
const group = (rows, format = row => row) => {
|
||||||
|
const result = new Map();
|
||||||
|
for (const row of rows) result.set(row.message_id, [...(result.get(row.message_id) || []), format(row)]);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
getActiveBranchPage(chatId, {limit = 50, before = null, leaf = null, around = null} = {}) {
|
||||||
|
const leafId = activeLeaf.get(chatId)?.id ?? null;
|
||||||
|
if (leaf != null && leaf !== leafId) return {error: "The conversation branch changed. Reload the latest messages.", status: 409};
|
||||||
|
let startId = leafId;
|
||||||
|
let matchOnBranch = null;
|
||||||
|
if (around != null) {
|
||||||
|
matchOnBranch = Boolean(onBranch.get(chatId, leafId, chatId, around));
|
||||||
|
if (matchOnBranch) startId = around;
|
||||||
|
}
|
||||||
|
if (before != null) {
|
||||||
|
const cursor = onBranch.get(chatId, leafId, chatId, before);
|
||||||
|
if (!cursor) return {error: "History cursor is not on this branch.", status: 400};
|
||||||
|
startId = cursor.parent_message_id;
|
||||||
|
}
|
||||||
|
const rows = startId == null ? [] : page.all(chatId, startId, chatId, limit);
|
||||||
|
const hasMore = rows.length > limit;
|
||||||
|
if (hasMore) rows.shift();
|
||||||
|
return {rows, matchOnBranch, pagination: {leafId, hasMore, before: hasMore ? rows[0].id : null,
|
||||||
|
hasNewer: around != null && matchOnBranch && around !== leafId}};
|
||||||
|
},
|
||||||
|
getHistoryMetadata(chatId, ids) {
|
||||||
|
const wanted = new Set(ids);
|
||||||
|
const encoded = JSON.stringify(ids);
|
||||||
|
return {versions: group(versions.all(encoded)), attachments: group(attachments.all(encoded), attachmentMetadata),
|
||||||
|
siblings: new Map(siblings.all(encoded, chatId, chatId).filter(row => wanted.has(row.id)).map(row => [row.id, row]))};
|
||||||
|
},
|
||||||
|
listBranchNodes(chatId, {after = 0, limit = 200} = {}) {
|
||||||
|
return db.prepare(`SELECT id, role, parent_message_id AS parentMessageId, substr(content, 1, 180) AS preview,
|
||||||
|
retry_retries_used + 1 AS versionCount FROM chat_messages WHERE chat_id = ? AND id > ? ORDER BY id LIMIT ?`).all(chatId, after, limit + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
+4
-1
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Öffnet die SQLite-Datenbank und initialisiert Tabellen sowie Migrationen.
|
||||||
|
*/
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import Database from "better-sqlite3";
|
import Database from "better-sqlite3";
|
||||||
import {initializeSchema, runMigrations} from "./schema.js";
|
import {initializeSchema, runMigrations} from "./schema.js";
|
||||||
@@ -10,7 +13,7 @@ export function createDatabase(config) {
|
|||||||
|
|
||||||
const db = new Database(config.dbPath);
|
const db = new Database(config.dbPath);
|
||||||
initializeSchema(db);
|
initializeSchema(db);
|
||||||
runMigrations(db, config);
|
|
||||||
initializeAttachments(db);
|
initializeAttachments(db);
|
||||||
|
runMigrations(db, config);
|
||||||
return db;
|
return db;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import {buildPersonaSnapshot} from "./personaSnapshot.js";
|
||||||
|
|
||||||
|
export function createPersonaStatements(db) {
|
||||||
|
return {
|
||||||
|
clearAssistantPersonaFromChats: db.prepare(`
|
||||||
|
UPDATE chat_sessions
|
||||||
|
SET assistant_persona_id = NULL,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE username = ?
|
||||||
|
AND assistant_persona_id = ?
|
||||||
|
`),
|
||||||
|
clearUserPersonaFromChats: db.prepare(`
|
||||||
|
UPDATE chat_sessions
|
||||||
|
SET user_persona_id = NULL,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE username = ?
|
||||||
|
AND user_persona_id = ?
|
||||||
|
`),
|
||||||
|
listPersonasByType: db.prepare(`
|
||||||
|
SELECT id, name, pronouns, appearance, background, details, example_dialogues, persona_type,
|
||||||
|
source_market_id, source_creator_username, created_at, updated_at
|
||||||
|
FROM personas
|
||||||
|
WHERE username = ?
|
||||||
|
AND persona_type = ?
|
||||||
|
ORDER BY datetime(updated_at) DESC
|
||||||
|
`),
|
||||||
|
listAllPersonas: db.prepare(`
|
||||||
|
SELECT id, username, name, pronouns, appearance, background, details, example_dialogues, persona_type,
|
||||||
|
source_market_id, source_creator_username, created_at, updated_at
|
||||||
|
FROM personas
|
||||||
|
WHERE username = ?
|
||||||
|
ORDER BY datetime(updated_at) DESC
|
||||||
|
`),
|
||||||
|
getPersona: db.prepare(`
|
||||||
|
SELECT id, name, pronouns, appearance, background, details, example_dialogues, persona_type,
|
||||||
|
source_market_id, source_creator_username, created_at, updated_at
|
||||||
|
FROM personas
|
||||||
|
WHERE id = ?
|
||||||
|
AND username = ?
|
||||||
|
`),
|
||||||
|
insertPersona: db.prepare("INSERT INTO personas (username, name, pronouns, appearance, background, details, example_dialogues, persona_type) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"),
|
||||||
|
updatePersona: db.prepare(`
|
||||||
|
UPDATE personas
|
||||||
|
SET name = ?,
|
||||||
|
pronouns = ?,
|
||||||
|
appearance = ?,
|
||||||
|
background = ?,
|
||||||
|
details = ?,
|
||||||
|
example_dialogues = ?,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
AND username = ?
|
||||||
|
`),
|
||||||
|
deletePersona: db.prepare("DELETE FROM personas WHERE id = ? AND username = ?"),
|
||||||
|
getNextPersonaVersionNumber: db.prepare("SELECT COALESCE(MAX(version_number), 0) + 1 AS nextVersion FROM persona_versions WHERE persona_id = ?"),
|
||||||
|
insertPersonaVersion: db.prepare("INSERT INTO persona_versions (persona_id, username, version_number, snapshot_json) VALUES (?, ?, ?, ?)"),
|
||||||
|
listPersonaVersions: db.prepare(`
|
||||||
|
SELECT id, persona_id, username, version_number, snapshot_json, created_at
|
||||||
|
FROM persona_versions
|
||||||
|
WHERE persona_id = ?
|
||||||
|
AND username = ?
|
||||||
|
ORDER BY version_number DESC
|
||||||
|
`),
|
||||||
|
getPersonaVersion: db.prepare(`
|
||||||
|
SELECT id, persona_id, username, version_number, snapshot_json, created_at
|
||||||
|
FROM persona_versions
|
||||||
|
WHERE id = ?
|
||||||
|
AND persona_id = ?
|
||||||
|
AND username = ?
|
||||||
|
`),
|
||||||
|
getActivePersonaId: db.prepare("SELECT active_persona_id, active_user_persona_id FROM user_settings WHERE username = ?"),
|
||||||
|
setActiveUserPersona: db.prepare(`
|
||||||
|
INSERT INTO user_settings (username, active_user_persona_id)
|
||||||
|
VALUES (?, ?)
|
||||||
|
ON CONFLICT(username) DO UPDATE SET active_user_persona_id = excluded.active_user_persona_id
|
||||||
|
`),
|
||||||
|
getActiveUserPersona: db.prepare(`
|
||||||
|
SELECT p.id, p.name, p.pronouns, p.appearance, p.background, p.details, p.example_dialogues
|
||||||
|
FROM user_settings us
|
||||||
|
JOIN personas p ON p.id = us.active_user_persona_id
|
||||||
|
WHERE us.username = ?
|
||||||
|
`),
|
||||||
|
getAssistantPersonaForChat: db.prepare(`
|
||||||
|
SELECT p.id, p.name, p.pronouns, p.appearance, p.background, p.details, p.example_dialogues
|
||||||
|
FROM chat_sessions cs
|
||||||
|
JOIN personas p ON p.id = cs.assistant_persona_id
|
||||||
|
WHERE cs.id = ?
|
||||||
|
AND cs.username = ?
|
||||||
|
`),
|
||||||
|
getUserPersonaForChat: db.prepare(`
|
||||||
|
SELECT p.id, p.name, p.pronouns, p.appearance, p.background, p.details, p.example_dialogues
|
||||||
|
FROM chat_sessions cs
|
||||||
|
JOIN personas p ON p.id = cs.user_persona_id
|
||||||
|
WHERE cs.id = ?
|
||||||
|
AND cs.username = ?
|
||||||
|
`),
|
||||||
|
deleteMarketPersonaByPersonaId: db.prepare("DELETE FROM persona_market WHERE persona_id = ? AND creator_username = ?")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPersonaRepository(db, statements) {
|
||||||
|
const savePersonaVersion = db.transaction((personaId, username) => {
|
||||||
|
const persona = statements.getPersona.get(personaId, username);
|
||||||
|
if (!persona) return null;
|
||||||
|
const versionNumber = statements.getNextPersonaVersionNumber.get(personaId).nextVersion;
|
||||||
|
statements.insertPersonaVersion.run(personaId, username, versionNumber, buildPersonaSnapshot(persona));
|
||||||
|
return versionNumber;
|
||||||
|
});
|
||||||
|
|
||||||
|
const clonePersonaTransaction = db.transaction((personaId, username) => {
|
||||||
|
const source = statements.getPersona.get(personaId, username);
|
||||||
|
if (!source) return null;
|
||||||
|
const cloneId = statements.insertPersona.run(
|
||||||
|
username,
|
||||||
|
`${source.name} Copy`,
|
||||||
|
source.pronouns,
|
||||||
|
source.appearance,
|
||||||
|
source.background,
|
||||||
|
source.details,
|
||||||
|
source.example_dialogues,
|
||||||
|
source.persona_type
|
||||||
|
).lastInsertRowid;
|
||||||
|
const clone = statements.getPersona.get(cloneId, username);
|
||||||
|
statements.insertPersonaVersion.run(cloneId, username, 1, buildPersonaSnapshot(clone));
|
||||||
|
return clone;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
listPersonasByType: (username, personaType) => statements.listPersonasByType.all(username, personaType),
|
||||||
|
listAllPersonas: (username) => statements.listAllPersonas.all(username),
|
||||||
|
getPersona: (personaId, username) => statements.getPersona.get(personaId, username),
|
||||||
|
createPersona(username, payload, personaType) {
|
||||||
|
const personaId = statements.insertPersona.run(
|
||||||
|
username,
|
||||||
|
payload.name,
|
||||||
|
payload.pronouns,
|
||||||
|
payload.appearance,
|
||||||
|
payload.background,
|
||||||
|
payload.details,
|
||||||
|
payload.exampleDialogues,
|
||||||
|
personaType
|
||||||
|
).lastInsertRowid;
|
||||||
|
const persona = statements.getPersona.get(personaId, username);
|
||||||
|
statements.insertPersonaVersion.run(personaId, username, 1, buildPersonaSnapshot(persona));
|
||||||
|
return persona;
|
||||||
|
},
|
||||||
|
updatePersona(personaId, username, payload) {
|
||||||
|
savePersonaVersion(personaId, username);
|
||||||
|
return statements.updatePersona.run(
|
||||||
|
payload.name,
|
||||||
|
payload.pronouns,
|
||||||
|
payload.appearance,
|
||||||
|
payload.background,
|
||||||
|
payload.details,
|
||||||
|
payload.exampleDialogues,
|
||||||
|
personaId,
|
||||||
|
username
|
||||||
|
);
|
||||||
|
},
|
||||||
|
restorePersonaVersion(personaId, versionId, username) {
|
||||||
|
const version = statements.getPersonaVersion.get(versionId, personaId, username);
|
||||||
|
if (!version) return null;
|
||||||
|
savePersonaVersion(personaId, username);
|
||||||
|
const snapshot = JSON.parse(version.snapshot_json);
|
||||||
|
statements.updatePersona.run(
|
||||||
|
snapshot.name,
|
||||||
|
snapshot.pronouns ?? null,
|
||||||
|
snapshot.appearance ?? null,
|
||||||
|
snapshot.background ?? null,
|
||||||
|
snapshot.details ?? null,
|
||||||
|
snapshot.example_dialogues ?? null,
|
||||||
|
personaId,
|
||||||
|
username
|
||||||
|
);
|
||||||
|
return statements.getPersona.get(personaId, username);
|
||||||
|
},
|
||||||
|
listPersonaVersions(personaId, username) {
|
||||||
|
return statements.listPersonaVersions.all(personaId, username).map((version) => ({
|
||||||
|
...version,
|
||||||
|
snapshot: JSON.parse(version.snapshot_json)
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
clonePersona: (personaId, username) => clonePersonaTransaction(personaId, username),
|
||||||
|
deletePersona: (personaId, username) => statements.deletePersona.run(personaId, username),
|
||||||
|
clearPersonaReferences(username, personaId) {
|
||||||
|
statements.clearAssistantPersonaFromChats.run(username, personaId);
|
||||||
|
statements.clearUserPersonaFromChats.run(username, personaId);
|
||||||
|
statements.deleteMarketPersonaByPersonaId.run(personaId, username);
|
||||||
|
},
|
||||||
|
getActivePersonaIds: (username) => statements.getActivePersonaId.get(username) || {},
|
||||||
|
setActiveUserPersona: (username, personaId) => statements.setActiveUserPersona.run(username, personaId),
|
||||||
|
getActiveUserPersona: (username) => statements.getActiveUserPersona.get(username),
|
||||||
|
getAssistantPersonaForChat: (chatId, username) => statements.getAssistantPersonaForChat.get(chatId, username),
|
||||||
|
getUserPersonaForChat: (chatId, username) => statements.getUserPersonaForChat.get(chatId, username)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export const buildPersonaSnapshot = (persona) => JSON.stringify({
|
||||||
|
name: persona.name,
|
||||||
|
pronouns: persona.pronouns,
|
||||||
|
appearance: persona.appearance,
|
||||||
|
background: persona.background,
|
||||||
|
details: persona.details,
|
||||||
|
example_dialogues: persona.example_dialogues,
|
||||||
|
persona_type: persona.persona_type,
|
||||||
|
source_market_id: persona.source_market_id ?? null,
|
||||||
|
source_creator_username: persona.source_creator_username ?? null
|
||||||
|
});
|
||||||
+53
-825
@@ -1,3 +1,11 @@
|
|||||||
|
import {buildPersonaSnapshot} from "./personaSnapshot.js";
|
||||||
|
import {createHistoryRepository} from "./historyRepository.js";
|
||||||
|
import {createAccountStatements, createAccountRepository} from "./accountRepository.js";
|
||||||
|
import {createChatStatements, createChatRepository} from "./chatRepository.js";
|
||||||
|
import {createPersonaStatements, createPersonaRepository} from "./personaRepository.js";
|
||||||
|
/**
|
||||||
|
* @file Bündelt Datenbankzugriffe auf Konten, Chats, Nachrichten, Personas und den Marktplatz.
|
||||||
|
*/
|
||||||
import {attachmentMetadata} from "./attachments.js";
|
import {attachmentMetadata} from "./attachments.js";
|
||||||
|
|
||||||
const parseJsonArray = (value) => {
|
const parseJsonArray = (value) => {
|
||||||
@@ -10,18 +18,6 @@ const parseJsonArray = (value) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildPersonaSnapshot = (persona) => JSON.stringify({
|
|
||||||
name: persona.name,
|
|
||||||
pronouns: persona.pronouns,
|
|
||||||
appearance: persona.appearance,
|
|
||||||
background: persona.background,
|
|
||||||
details: persona.details,
|
|
||||||
example_dialogues: persona.example_dialogues,
|
|
||||||
persona_type: persona.persona_type,
|
|
||||||
source_market_id: persona.source_market_id ?? null,
|
|
||||||
source_creator_username: persona.source_creator_username ?? null
|
|
||||||
});
|
|
||||||
|
|
||||||
const MARKET_SORT_ORDERS = {
|
const MARKET_SORT_ORDERS = {
|
||||||
best: "pm.favorite_count DESC, rating_average DESC, pm.usage_count DESC, datetime(pm.created_at) DESC, pm.id DESC",
|
best: "pm.favorite_count DESC, rating_average DESC, pm.usage_count DESC, datetime(pm.created_at) DESC, pm.id DESC",
|
||||||
newest: "datetime(pm.created_at) DESC, pm.id DESC",
|
newest: "datetime(pm.created_at) DESC, pm.id DESC",
|
||||||
@@ -42,407 +38,10 @@ function normalizeMarketPersonaType(personaType) {
|
|||||||
|
|
||||||
export function createRepositories(db, config) {
|
export function createRepositories(db, config) {
|
||||||
const statements = {
|
const statements = {
|
||||||
insertUser: db.prepare("INSERT INTO users (username, password) VALUES (?, ?)"),
|
...createAccountStatements(db),
|
||||||
getUser: db.prepare("SELECT username, password, session_version FROM users WHERE username = ?"),
|
...createChatStatements(db),
|
||||||
updateUsername: db.prepare("UPDATE users SET username = ? WHERE username = ?"),
|
...createPersonaStatements(db),
|
||||||
updatePassword: db.prepare("UPDATE users SET password = ? WHERE username = ?"),
|
updateChatMessageLinks: db.prepare("UPDATE chat_messages SET parent_message_id = ?, retry_prompt_message_id = ? WHERE id = ? AND chat_id = ?"),
|
||||||
incrementSessionVersion: db.prepare("UPDATE users SET session_version = COALESCE(session_version, 0) + 1 WHERE username = ?"),
|
|
||||||
insertChatSession: db.prepare(
|
|
||||||
"INSERT INTO chat_sessions (username, title, assistant_persona_id, user_persona_id, folder_name, is_pinned, archived_at, scenario_prompt, scenario_summary) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
|
||||||
),
|
|
||||||
listChatSessions: db.prepare(`
|
|
||||||
SELECT cs.id,
|
|
||||||
CASE
|
|
||||||
WHEN cs.assistant_persona_id IS NOT NULL AND cs.source_chat_id IS NULL THEN COALESCE(p.name, cs.title)
|
|
||||||
ELSE cs.title
|
|
||||||
END AS title,
|
|
||||||
cs.title AS stored_title,
|
|
||||||
cs.assistant_persona_id,
|
|
||||||
p.name AS assistant_persona_name,
|
|
||||||
cs.user_persona_id,
|
|
||||||
up.name AS user_persona_name,
|
|
||||||
cs.folder_name,
|
|
||||||
cs.is_pinned,
|
|
||||||
cs.archived_at,
|
|
||||||
cs.scenario_prompt,
|
|
||||||
cs.scenario_summary,
|
|
||||||
cs.context_summary,
|
|
||||||
cs.context_summary_message_id,
|
|
||||||
cs.active_leaf_message_id,
|
|
||||||
cs.source_chat_id,
|
|
||||||
cs.branched_from_message_id,
|
|
||||||
cs.preferred_model,
|
|
||||||
cs.temperature,
|
|
||||||
cs.context_length,
|
|
||||||
cs.response_length,
|
|
||||||
cs.system_instruction,
|
|
||||||
cs.created_at,
|
|
||||||
cs.updated_at
|
|
||||||
FROM chat_sessions cs
|
|
||||||
LEFT JOIN personas p ON p.id = cs.assistant_persona_id
|
|
||||||
LEFT JOIN personas up ON up.id = cs.user_persona_id
|
|
||||||
WHERE cs.username = ?
|
|
||||||
ORDER BY cs.is_pinned DESC, datetime(cs.updated_at) DESC
|
|
||||||
`),
|
|
||||||
countChatSessions: db.prepare("SELECT COUNT(*) as count FROM chat_sessions WHERE username = ?"),
|
|
||||||
getChatSession: db.prepare(`
|
|
||||||
SELECT cs.id,
|
|
||||||
CASE
|
|
||||||
WHEN cs.assistant_persona_id IS NOT NULL AND cs.source_chat_id IS NULL THEN COALESCE(p.name, cs.title)
|
|
||||||
ELSE cs.title
|
|
||||||
END AS title,
|
|
||||||
cs.title AS stored_title,
|
|
||||||
cs.assistant_persona_id,
|
|
||||||
p.name AS assistant_persona_name,
|
|
||||||
cs.user_persona_id,
|
|
||||||
up.name AS user_persona_name,
|
|
||||||
cs.folder_name,
|
|
||||||
cs.is_pinned,
|
|
||||||
cs.archived_at,
|
|
||||||
cs.scenario_prompt,
|
|
||||||
cs.scenario_summary,
|
|
||||||
cs.context_summary,
|
|
||||||
cs.context_summary_message_id,
|
|
||||||
cs.active_leaf_message_id,
|
|
||||||
cs.source_chat_id,
|
|
||||||
cs.branched_from_message_id,
|
|
||||||
cs.preferred_model,
|
|
||||||
cs.temperature,
|
|
||||||
cs.context_length,
|
|
||||||
cs.response_length,
|
|
||||||
cs.system_instruction,
|
|
||||||
cs.created_at,
|
|
||||||
cs.updated_at
|
|
||||||
FROM chat_sessions cs
|
|
||||||
LEFT JOIN personas p ON p.id = cs.assistant_persona_id
|
|
||||||
LEFT JOIN personas up ON up.id = cs.user_persona_id
|
|
||||||
WHERE cs.id = ?
|
|
||||||
AND cs.username = ?
|
|
||||||
`),
|
|
||||||
getChatSessionByParticipants: db.prepare(`
|
|
||||||
SELECT cs.id,
|
|
||||||
CASE
|
|
||||||
WHEN cs.assistant_persona_id IS NOT NULL AND cs.source_chat_id IS NULL THEN COALESCE(p.name, cs.title)
|
|
||||||
ELSE cs.title
|
|
||||||
END AS title,
|
|
||||||
cs.title AS stored_title,
|
|
||||||
cs.assistant_persona_id,
|
|
||||||
p.name AS assistant_persona_name,
|
|
||||||
cs.user_persona_id,
|
|
||||||
up.name AS user_persona_name,
|
|
||||||
cs.folder_name,
|
|
||||||
cs.is_pinned,
|
|
||||||
cs.archived_at,
|
|
||||||
cs.scenario_prompt,
|
|
||||||
cs.scenario_summary,
|
|
||||||
cs.context_summary,
|
|
||||||
cs.context_summary_message_id,
|
|
||||||
cs.active_leaf_message_id,
|
|
||||||
cs.source_chat_id,
|
|
||||||
cs.branched_from_message_id,
|
|
||||||
cs.preferred_model,
|
|
||||||
cs.temperature,
|
|
||||||
cs.context_length,
|
|
||||||
cs.response_length,
|
|
||||||
cs.system_instruction,
|
|
||||||
cs.created_at,
|
|
||||||
cs.updated_at
|
|
||||||
FROM chat_sessions cs
|
|
||||||
LEFT JOIN personas p ON p.id = cs.assistant_persona_id
|
|
||||||
LEFT JOIN personas up ON up.id = cs.user_persona_id
|
|
||||||
WHERE cs.username = ?
|
|
||||||
AND cs.assistant_persona_id = ?
|
|
||||||
AND ((cs.user_persona_id IS NULL AND ? IS NULL) OR cs.user_persona_id = ?)
|
|
||||||
AND cs.source_chat_id IS NULL
|
|
||||||
ORDER BY cs.is_pinned DESC, datetime(cs.updated_at) DESC
|
|
||||||
LIMIT 1
|
|
||||||
`),
|
|
||||||
updateChatSessionTitle: db.prepare("UPDATE chat_sessions SET title = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?"),
|
|
||||||
updateChatSessionScene: db.prepare("UPDATE chat_sessions SET scenario_prompt = ?, scenario_summary = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?"),
|
|
||||||
updateChatContextSummary: db.prepare("UPDATE chat_sessions SET context_summary = ?, context_summary_message_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?"),
|
|
||||||
updateChatGenerationSettings: db.prepare(`
|
|
||||||
UPDATE chat_sessions
|
|
||||||
SET preferred_model = ?, temperature = ?, context_length = ?, response_length = ?, system_instruction = ?, updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = ? AND username = ?
|
|
||||||
`),
|
|
||||||
updateChatPreferredModel: db.prepare("UPDATE chat_sessions SET preferred_model = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?"),
|
|
||||||
updateChatActiveLeaf: db.prepare("UPDATE chat_sessions SET active_leaf_message_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?"),
|
|
||||||
updateChatBranchSource: db.prepare("UPDATE chat_sessions SET source_chat_id = ?, branched_from_message_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?"),
|
|
||||||
setChatActiveLeafById: db.prepare("UPDATE chat_sessions SET active_leaf_message_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"),
|
|
||||||
updateChatSessionOrganization: db.prepare(`
|
|
||||||
UPDATE chat_sessions
|
|
||||||
SET folder_name = ?,
|
|
||||||
is_pinned = ?,
|
|
||||||
archived_at = ?,
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = ?
|
|
||||||
AND username = ?
|
|
||||||
`),
|
|
||||||
touchChatSession: db.prepare("UPDATE chat_sessions SET updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?"),
|
|
||||||
deleteChatSession: db.prepare("DELETE FROM chat_sessions WHERE id = ? AND username = ?"),
|
|
||||||
clearAssistantPersonaFromChats: db.prepare(`
|
|
||||||
UPDATE chat_sessions
|
|
||||||
SET assistant_persona_id = NULL,
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE username = ?
|
|
||||||
AND assistant_persona_id = ?
|
|
||||||
`),
|
|
||||||
clearUserPersonaFromChats: db.prepare(`
|
|
||||||
UPDATE chat_sessions
|
|
||||||
SET user_persona_id = NULL,
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE username = ?
|
|
||||||
AND user_persona_id = ?
|
|
||||||
`),
|
|
||||||
insertChatMessage: db.prepare("INSERT INTO chat_messages (chat_id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id, delivery_status, error_message, parent_message_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"),
|
|
||||||
updateChatMessageDelivery: db.prepare("UPDATE chat_messages SET delivery_status = ?, error_message = ? WHERE id = ? AND chat_id = ?"),
|
|
||||||
updateChatMessageSources: db.prepare("UPDATE chat_messages SET sources = ? WHERE id = ? AND chat_id = ?"),
|
|
||||||
updateChatMessageThinking: db.prepare("UPDATE chat_messages SET thinking = ? WHERE id = ? AND chat_id = ?"),
|
|
||||||
getChatMessages: db.prepare(`
|
|
||||||
SELECT thinking, sources, id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id, delivery_status, error_message, parent_message_id, created_at
|
|
||||||
FROM chat_messages
|
|
||||||
WHERE chat_id = ?
|
|
||||||
ORDER BY id
|
|
||||||
`),
|
|
||||||
getRecentChatMessages: db.prepare(`
|
|
||||||
WITH RECURSIVE branch AS (
|
|
||||||
SELECT cm.* FROM chat_messages cm
|
|
||||||
JOIN chat_sessions cs ON cs.id = cm.chat_id
|
|
||||||
WHERE cm.chat_id = ?
|
|
||||||
AND cm.id = COALESCE(cs.active_leaf_message_id, (SELECT MAX(id) FROM chat_messages WHERE chat_id = cm.chat_id))
|
|
||||||
UNION ALL
|
|
||||||
SELECT parent.* FROM chat_messages parent
|
|
||||||
JOIN branch child ON child.parent_message_id = parent.id
|
|
||||||
)
|
|
||||||
SELECT id, role, content FROM branch
|
|
||||||
WHERE delivery_status != 'failed'
|
|
||||||
ORDER BY id DESC
|
|
||||||
LIMIT ?
|
|
||||||
`),
|
|
||||||
getRecentChatMessagesUpToId: db.prepare(`
|
|
||||||
WITH RECURSIVE branch AS (
|
|
||||||
SELECT * FROM chat_messages WHERE chat_id = ? AND id = ?
|
|
||||||
UNION ALL
|
|
||||||
SELECT parent.* FROM chat_messages parent
|
|
||||||
JOIN branch child ON child.parent_message_id = parent.id
|
|
||||||
)
|
|
||||||
SELECT id, role, content FROM branch
|
|
||||||
WHERE delivery_status != 'failed'
|
|
||||||
ORDER BY id DESC
|
|
||||||
LIMIT ?
|
|
||||||
`),
|
|
||||||
getChatBranchThroughMessage: db.prepare(`
|
|
||||||
WITH RECURSIVE branch AS (
|
|
||||||
SELECT * FROM chat_messages WHERE chat_id = ? AND id = ?
|
|
||||||
UNION ALL
|
|
||||||
SELECT parent.* FROM chat_messages parent
|
|
||||||
JOIN branch child ON child.parent_message_id = parent.id
|
|
||||||
)
|
|
||||||
SELECT id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used,
|
|
||||||
retry_prompt_message_id, delivery_status, error_message, parent_message_id, created_at, sources, thinking
|
|
||||||
FROM branch
|
|
||||||
WHERE delivery_status = 'complete'
|
|
||||||
ORDER BY id
|
|
||||||
`),
|
|
||||||
getActiveBranchMessages: db.prepare(`
|
|
||||||
WITH RECURSIVE branch AS (
|
|
||||||
SELECT cm.* FROM chat_messages cm
|
|
||||||
JOIN chat_sessions cs ON cs.id = cm.chat_id
|
|
||||||
WHERE cm.chat_id = ?
|
|
||||||
AND cm.id = COALESCE(cs.active_leaf_message_id, (SELECT MAX(id) FROM chat_messages WHERE chat_id = cm.chat_id))
|
|
||||||
UNION ALL
|
|
||||||
SELECT parent.* FROM chat_messages parent
|
|
||||||
JOIN branch child ON child.parent_message_id = parent.id
|
|
||||||
)
|
|
||||||
SELECT id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used,
|
|
||||||
retry_prompt_message_id, delivery_status, error_message, parent_message_id, created_at, sources, thinking
|
|
||||||
FROM branch ORDER BY id
|
|
||||||
`),
|
|
||||||
getSiblingChatMessages: db.prepare(`
|
|
||||||
SELECT id, role, content, parent_message_id
|
|
||||||
FROM chat_messages
|
|
||||||
WHERE chat_id = ? AND role = ? AND parent_message_id IS ?
|
|
||||||
ORDER BY id
|
|
||||||
`),
|
|
||||||
getLatestDescendantMessage: db.prepare(`
|
|
||||||
WITH RECURSIVE descendants AS (
|
|
||||||
SELECT id FROM chat_messages WHERE chat_id = ? AND id = ?
|
|
||||||
UNION ALL
|
|
||||||
SELECT child.id FROM chat_messages child JOIN descendants parent ON child.parent_message_id = parent.id
|
|
||||||
WHERE child.chat_id = ? AND child.delivery_status != 'failed'
|
|
||||||
)
|
|
||||||
SELECT cm.* FROM chat_messages cm JOIN descendants d ON d.id = cm.id ORDER BY cm.id DESC LIMIT 1
|
|
||||||
`),
|
|
||||||
getChatMessageById: db.prepare(`
|
|
||||||
SELECT thinking, sources, id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id, delivery_status, error_message, parent_message_id, created_at
|
|
||||||
FROM chat_messages
|
|
||||||
WHERE id = ?
|
|
||||||
AND chat_id = ?
|
|
||||||
`),
|
|
||||||
getChatMessageByIndex: db.prepare(`
|
|
||||||
SELECT thinking, sources, id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id, delivery_status, error_message, parent_message_id, created_at
|
|
||||||
FROM chat_messages
|
|
||||||
WHERE chat_id = ?
|
|
||||||
ORDER BY id
|
|
||||||
LIMIT 1 OFFSET ?
|
|
||||||
`),
|
|
||||||
getLatestChatMessage: db.prepare(`
|
|
||||||
SELECT id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id, delivery_status, error_message, parent_message_id, created_at
|
|
||||||
FROM chat_messages
|
|
||||||
WHERE chat_id = ?
|
|
||||||
ORDER BY id DESC
|
|
||||||
LIMIT 1
|
|
||||||
`),
|
|
||||||
getPreviousUserMessage: db.prepare(`
|
|
||||||
SELECT id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id, delivery_status, error_message, parent_message_id, created_at
|
|
||||||
FROM chat_messages
|
|
||||||
WHERE chat_id = ?
|
|
||||||
AND role = 'user'
|
|
||||||
AND id < ?
|
|
||||||
ORDER BY id DESC
|
|
||||||
LIMIT 1
|
|
||||||
`),
|
|
||||||
listChatMemories: db.prepare(`
|
|
||||||
SELECT id, username, chat_id, assistant_persona_id, user_persona_id, fact, created_at, updated_at
|
|
||||||
FROM chat_memories
|
|
||||||
WHERE chat_id = ?
|
|
||||||
AND username = ?
|
|
||||||
ORDER BY datetime(updated_at) DESC, id DESC
|
|
||||||
`),
|
|
||||||
getChatMemory: db.prepare(`
|
|
||||||
SELECT id, username, chat_id, assistant_persona_id, user_persona_id, fact, created_at, updated_at
|
|
||||||
FROM chat_memories
|
|
||||||
WHERE id = ?
|
|
||||||
AND chat_id = ?
|
|
||||||
AND username = ?
|
|
||||||
`),
|
|
||||||
insertChatMemory: db.prepare(`
|
|
||||||
INSERT INTO chat_memories (username, chat_id, assistant_persona_id, user_persona_id, fact)
|
|
||||||
VALUES (?, ?, ?, ?, ?)
|
|
||||||
`),
|
|
||||||
updateChatMemory: db.prepare(`
|
|
||||||
UPDATE chat_memories
|
|
||||||
SET fact = ?,
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = ?
|
|
||||||
AND chat_id = ?
|
|
||||||
AND username = ?
|
|
||||||
`),
|
|
||||||
deleteChatMemory: db.prepare("DELETE FROM chat_memories WHERE id = ? AND chat_id = ? AND username = ?"),
|
|
||||||
getPersonaChatState: db.prepare(`
|
|
||||||
SELECT id,
|
|
||||||
username,
|
|
||||||
chat_id,
|
|
||||||
assistant_persona_id,
|
|
||||||
user_persona_id,
|
|
||||||
relationship_notes,
|
|
||||||
tone,
|
|
||||||
current_location,
|
|
||||||
goals,
|
|
||||||
unresolved_threads,
|
|
||||||
boundaries,
|
|
||||||
created_at,
|
|
||||||
updated_at
|
|
||||||
FROM persona_chat_state
|
|
||||||
WHERE chat_id = ?
|
|
||||||
AND username = ?
|
|
||||||
`),
|
|
||||||
upsertPersonaChatState: db.prepare(`
|
|
||||||
INSERT INTO persona_chat_state (
|
|
||||||
username,
|
|
||||||
chat_id,
|
|
||||||
assistant_persona_id,
|
|
||||||
user_persona_id,
|
|
||||||
relationship_notes,
|
|
||||||
tone,
|
|
||||||
current_location,
|
|
||||||
goals,
|
|
||||||
unresolved_threads,
|
|
||||||
boundaries
|
|
||||||
)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(chat_id) DO UPDATE SET
|
|
||||||
username = excluded.username,
|
|
||||||
assistant_persona_id = excluded.assistant_persona_id,
|
|
||||||
user_persona_id = excluded.user_persona_id,
|
|
||||||
relationship_notes = excluded.relationship_notes,
|
|
||||||
tone = excluded.tone,
|
|
||||||
current_location = excluded.current_location,
|
|
||||||
goals = excluded.goals,
|
|
||||||
unresolved_threads = excluded.unresolved_threads,
|
|
||||||
boundaries = excluded.boundaries,
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
|
||||||
`),
|
|
||||||
updateChatMessage: db.prepare("UPDATE chat_messages SET content = ? WHERE id = ? AND chat_id = ?"),
|
|
||||||
updateChatMessageWithRetryState: db.prepare("UPDATE chat_messages SET content = ?, retry_variants = ?, retry_active_index = ?, retry_retries_used = ?, retry_prompt_message_id = ? WHERE id = ? AND chat_id = ?"),
|
|
||||||
deleteChatMessage: db.prepare("DELETE FROM chat_messages WHERE id = ? AND chat_id = ?"),
|
|
||||||
deleteChatMessages: db.prepare("DELETE FROM chat_messages WHERE chat_id = ?"),
|
|
||||||
searchChats: db.prepare(`
|
|
||||||
SELECT DISTINCT cs.id,
|
|
||||||
CASE
|
|
||||||
WHEN cs.assistant_persona_id IS NOT NULL AND cs.source_chat_id IS NULL THEN COALESCE(p.name, cs.title)
|
|
||||||
ELSE cs.title
|
|
||||||
END AS title,
|
|
||||||
cs.title AS stored_title,
|
|
||||||
cs.assistant_persona_id,
|
|
||||||
p.name AS assistant_persona_name,
|
|
||||||
cs.user_persona_id,
|
|
||||||
up.name AS user_persona_name,
|
|
||||||
cs.folder_name,
|
|
||||||
cs.is_pinned,
|
|
||||||
cs.archived_at,
|
|
||||||
cs.scenario_prompt,
|
|
||||||
cs.scenario_summary,
|
|
||||||
cs.context_summary,
|
|
||||||
cs.context_summary_message_id,
|
|
||||||
cs.active_leaf_message_id,
|
|
||||||
cs.source_chat_id,
|
|
||||||
cs.branched_from_message_id,
|
|
||||||
cs.preferred_model,
|
|
||||||
cs.temperature,
|
|
||||||
cs.context_length,
|
|
||||||
cs.response_length,
|
|
||||||
cs.system_instruction,
|
|
||||||
cs.created_at,
|
|
||||||
cs.updated_at,
|
|
||||||
cm.content AS matched_content
|
|
||||||
FROM chat_sessions cs
|
|
||||||
LEFT JOIN personas p ON p.id = cs.assistant_persona_id
|
|
||||||
LEFT JOIN personas up ON up.id = cs.user_persona_id
|
|
||||||
LEFT JOIN chat_messages cm ON cm.chat_id = cs.id
|
|
||||||
WHERE cs.username = ?
|
|
||||||
AND (
|
|
||||||
LOWER(CASE
|
|
||||||
WHEN cs.assistant_persona_id IS NOT NULL AND cs.source_chat_id IS NULL THEN COALESCE(p.name, cs.title)
|
|
||||||
ELSE cs.title
|
|
||||||
END) LIKE ?
|
|
||||||
OR LOWER(COALESCE(cm.content, '')) LIKE ?
|
|
||||||
)
|
|
||||||
ORDER BY cs.is_pinned DESC, datetime(cs.updated_at) DESC
|
|
||||||
`),
|
|
||||||
listPersonasByType: db.prepare(`
|
|
||||||
SELECT id, name, pronouns, appearance, background, details, example_dialogues, persona_type,
|
|
||||||
source_market_id, source_creator_username, created_at, updated_at
|
|
||||||
FROM personas
|
|
||||||
WHERE username = ?
|
|
||||||
AND persona_type = ?
|
|
||||||
ORDER BY datetime(updated_at) DESC
|
|
||||||
`),
|
|
||||||
listAllPersonas: db.prepare(`
|
|
||||||
SELECT id, username, name, pronouns, appearance, background, details, example_dialogues, persona_type,
|
|
||||||
source_market_id, source_creator_username, created_at, updated_at
|
|
||||||
FROM personas
|
|
||||||
WHERE username = ?
|
|
||||||
ORDER BY datetime(updated_at) DESC
|
|
||||||
`),
|
|
||||||
getPersona: db.prepare(`
|
|
||||||
SELECT id, name, pronouns, appearance, background, details, example_dialogues, persona_type,
|
|
||||||
source_market_id, source_creator_username, created_at, updated_at
|
|
||||||
FROM personas
|
|
||||||
WHERE id = ?
|
|
||||||
AND username = ?
|
|
||||||
`),
|
|
||||||
insertPersona: db.prepare("INSERT INTO personas (username, name, pronouns, appearance, background, details, example_dialogues, persona_type) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"),
|
|
||||||
insertMarketPersona: db.prepare("INSERT INTO personas (username, name, pronouns, appearance, background, details, example_dialogues, persona_type, source_market_id, source_creator_username) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"),
|
insertMarketPersona: db.prepare("INSERT INTO personas (username, name, pronouns, appearance, background, details, example_dialogues, persona_type, source_market_id, source_creator_username) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"),
|
||||||
getPersonaBySourceMarket: db.prepare(`
|
getPersonaBySourceMarket: db.prepare(`
|
||||||
SELECT id
|
SELECT id
|
||||||
@@ -450,61 +49,6 @@ export function createRepositories(db, config) {
|
|||||||
WHERE username = ?
|
WHERE username = ?
|
||||||
AND source_market_id = ?
|
AND source_market_id = ?
|
||||||
`),
|
`),
|
||||||
updatePersona: db.prepare(`
|
|
||||||
UPDATE personas
|
|
||||||
SET name = ?,
|
|
||||||
pronouns = ?,
|
|
||||||
appearance = ?,
|
|
||||||
background = ?,
|
|
||||||
details = ?,
|
|
||||||
example_dialogues = ?,
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = ?
|
|
||||||
AND username = ?
|
|
||||||
`),
|
|
||||||
deletePersona: db.prepare("DELETE FROM personas WHERE id = ? AND username = ?"),
|
|
||||||
getNextPersonaVersionNumber: db.prepare("SELECT COALESCE(MAX(version_number), 0) + 1 AS nextVersion FROM persona_versions WHERE persona_id = ?"),
|
|
||||||
insertPersonaVersion: db.prepare("INSERT INTO persona_versions (persona_id, username, version_number, snapshot_json) VALUES (?, ?, ?, ?)"),
|
|
||||||
listPersonaVersions: db.prepare(`
|
|
||||||
SELECT id, persona_id, username, version_number, snapshot_json, created_at
|
|
||||||
FROM persona_versions
|
|
||||||
WHERE persona_id = ?
|
|
||||||
AND username = ?
|
|
||||||
ORDER BY version_number DESC
|
|
||||||
`),
|
|
||||||
getPersonaVersion: db.prepare(`
|
|
||||||
SELECT id, persona_id, username, version_number, snapshot_json, created_at
|
|
||||||
FROM persona_versions
|
|
||||||
WHERE id = ?
|
|
||||||
AND persona_id = ?
|
|
||||||
AND username = ?
|
|
||||||
`),
|
|
||||||
getActivePersonaId: db.prepare("SELECT active_persona_id, active_user_persona_id FROM user_settings WHERE username = ?"),
|
|
||||||
setActiveUserPersona: db.prepare(`
|
|
||||||
INSERT INTO user_settings (username, active_user_persona_id)
|
|
||||||
VALUES (?, ?)
|
|
||||||
ON CONFLICT(username) DO UPDATE SET active_user_persona_id = excluded.active_user_persona_id
|
|
||||||
`),
|
|
||||||
getActiveUserPersona: db.prepare(`
|
|
||||||
SELECT p.id, p.name, p.pronouns, p.appearance, p.background, p.details, p.example_dialogues
|
|
||||||
FROM user_settings us
|
|
||||||
JOIN personas p ON p.id = us.active_user_persona_id
|
|
||||||
WHERE us.username = ?
|
|
||||||
`),
|
|
||||||
getAssistantPersonaForChat: db.prepare(`
|
|
||||||
SELECT p.id, p.name, p.pronouns, p.appearance, p.background, p.details, p.example_dialogues
|
|
||||||
FROM chat_sessions cs
|
|
||||||
JOIN personas p ON p.id = cs.assistant_persona_id
|
|
||||||
WHERE cs.id = ?
|
|
||||||
AND cs.username = ?
|
|
||||||
`),
|
|
||||||
getUserPersonaForChat: db.prepare(`
|
|
||||||
SELECT p.id, p.name, p.pronouns, p.appearance, p.background, p.details, p.example_dialogues
|
|
||||||
FROM chat_sessions cs
|
|
||||||
JOIN personas p ON p.id = cs.user_persona_id
|
|
||||||
WHERE cs.id = ?
|
|
||||||
AND cs.username = ?
|
|
||||||
`),
|
|
||||||
listMarketPersonas: db.prepare(`
|
listMarketPersonas: db.prepare(`
|
||||||
SELECT pm.id, pm.persona_id, pm.creator_username, pm.name, pm.pronouns, pm.appearance, pm.background, pm.details,
|
SELECT pm.id, pm.persona_id, pm.creator_username, pm.name, pm.pronouns, pm.appearance, pm.background, pm.details,
|
||||||
pm.example_dialogues, pm.persona_type, pm.tags, pm.usage_count, pm.favorite_count, pm.rating_total, pm.rating_count,
|
pm.example_dialogues, pm.persona_type, pm.tags, pm.usage_count, pm.favorite_count, pm.rating_total, pm.rating_count,
|
||||||
@@ -682,134 +226,13 @@ export function createRepositories(db, config) {
|
|||||||
WHERE creator_username = ?
|
WHERE creator_username = ?
|
||||||
AND soft_deleted_at IS NULL
|
AND soft_deleted_at IS NULL
|
||||||
`),
|
`),
|
||||||
updateChatSessionsUsername: db.prepare("UPDATE chat_sessions SET username = ? WHERE username = ?"),
|
|
||||||
updatePersonasUsername: db.prepare("UPDATE personas SET username = ? WHERE username = ?"),
|
|
||||||
updatePersonasSourceCreator: db.prepare("UPDATE personas SET source_creator_username = ? WHERE source_creator_username = ?"),
|
|
||||||
updateUserSettingsUsername: db.prepare("UPDATE user_settings SET username = ? WHERE username = ?"),
|
|
||||||
updatePersonaMarketCreator: db.prepare("UPDATE persona_market SET creator_username = ? WHERE creator_username = ?"),
|
|
||||||
updatePersonaVersionsUsername: db.prepare("UPDATE persona_versions SET username = ? WHERE username = ?"),
|
|
||||||
updatePromptTemplatesUsername: db.prepare("UPDATE prompt_templates SET username = ? WHERE username = ?"),
|
|
||||||
updateChatMemoriesUsername: db.prepare("UPDATE chat_memories SET username = ? WHERE username = ?"),
|
|
||||||
updatePersonaChatStateUsername: db.prepare("UPDATE persona_chat_state SET username = ? WHERE username = ?"),
|
|
||||||
updateMarketFavoritesUsername: db.prepare("UPDATE persona_market_favorites SET username = ? WHERE username = ?"),
|
|
||||||
updateMarketRatingsUsername: db.prepare("UPDATE persona_market_ratings SET username = ? WHERE username = ?"),
|
|
||||||
updateMarketReportsUsername: db.prepare("UPDATE persona_market_reports SET reporter_username = ? WHERE reporter_username = ?"),
|
|
||||||
deleteChatMemoriesByUser: db.prepare("DELETE FROM chat_memories WHERE username = ?"),
|
|
||||||
deleteChatSessionsByUser: db.prepare("DELETE FROM chat_sessions WHERE username = ?"),
|
|
||||||
deletePersonasByUser: db.prepare("DELETE FROM personas WHERE username = ?"),
|
|
||||||
deleteUserSettingsByUser: db.prepare("DELETE FROM user_settings WHERE username = ?"),
|
|
||||||
deletePersonaVersionsByUser: db.prepare("DELETE FROM persona_versions WHERE username = ?"),
|
|
||||||
deletePromptTemplatesByUser: db.prepare("DELETE FROM prompt_templates WHERE username = ?"),
|
|
||||||
deleteMarketPersonasByCreator: db.prepare("DELETE FROM persona_market WHERE creator_username = ?"),
|
|
||||||
deleteMarketFavoritesByUser: db.prepare("DELETE FROM persona_market_favorites WHERE username = ?"),
|
|
||||||
deleteMarketRatingsByUser: db.prepare("DELETE FROM persona_market_ratings WHERE username = ?"),
|
|
||||||
deleteMarketReportsByUser: db.prepare("DELETE FROM persona_market_reports WHERE reporter_username = ?"),
|
|
||||||
deletePersonaChatStateByUser: db.prepare("DELETE FROM persona_chat_state WHERE username = ?"),
|
|
||||||
deleteUser: db.prepare("DELETE FROM users WHERE username = ?"),
|
|
||||||
deleteMarketPersonaByPersonaId: db.prepare("DELETE FROM persona_market WHERE persona_id = ? AND creator_username = ?"),
|
|
||||||
incrementMarketUsageCount: db.prepare("UPDATE persona_market SET usage_count = usage_count + 1 WHERE id = ?")
|
incrementMarketUsageCount: db.prepare("UPDATE persona_market SET usage_count = usage_count + 1 WHERE id = ?")
|
||||||
};
|
};
|
||||||
|
|
||||||
const renameUserTransaction = db.transaction((currentUsername, nextUsername) => {
|
|
||||||
statements.updateUsername.run(nextUsername, currentUsername);
|
|
||||||
statements.updateChatSessionsUsername.run(nextUsername, currentUsername);
|
|
||||||
statements.updatePersonasUsername.run(nextUsername, currentUsername);
|
|
||||||
statements.updatePersonasSourceCreator.run(nextUsername, currentUsername);
|
|
||||||
statements.updateUserSettingsUsername.run(nextUsername, currentUsername);
|
|
||||||
statements.updatePersonaMarketCreator.run(nextUsername, currentUsername);
|
|
||||||
statements.updatePersonaVersionsUsername.run(nextUsername, currentUsername);
|
|
||||||
statements.updatePromptTemplatesUsername.run(nextUsername, currentUsername);
|
|
||||||
statements.updateChatMemoriesUsername.run(nextUsername, currentUsername);
|
|
||||||
statements.updatePersonaChatStateUsername.run(nextUsername, currentUsername);
|
|
||||||
statements.updateMarketFavoritesUsername.run(nextUsername, currentUsername);
|
|
||||||
statements.updateMarketRatingsUsername.run(nextUsername, currentUsername);
|
|
||||||
statements.updateMarketReportsUsername.run(nextUsername, currentUsername);
|
|
||||||
});
|
|
||||||
|
|
||||||
const deleteUserTransaction = db.transaction((username) => {
|
|
||||||
statements.deleteMarketFavoritesByUser.run(username);
|
|
||||||
statements.deleteMarketRatingsByUser.run(username);
|
|
||||||
statements.deleteMarketReportsByUser.run(username);
|
|
||||||
statements.deleteMarketPersonasByCreator.run(username);
|
|
||||||
statements.deletePromptTemplatesByUser.run(username);
|
|
||||||
statements.deletePersonaVersionsByUser.run(username);
|
|
||||||
statements.deleteChatMemoriesByUser.run(username);
|
|
||||||
statements.deletePersonaChatStateByUser.run(username);
|
|
||||||
statements.deleteChatSessionsByUser.run(username);
|
|
||||||
statements.deleteUserSettingsByUser.run(username);
|
|
||||||
statements.deletePersonasByUser.run(username);
|
|
||||||
return statements.deleteUser.run(username);
|
|
||||||
});
|
|
||||||
|
|
||||||
const completeChatTurnTransaction = db.transaction((chatId, userMessageId, assistantContent, retryState, modelName, sources, thinking) => {
|
|
||||||
const assistantResult = statements.insertChatMessage.run(
|
|
||||||
chatId,
|
|
||||||
"bot",
|
|
||||||
String(assistantContent || ""),
|
|
||||||
modelName || null,
|
|
||||||
JSON.stringify(retryState.retryVariants),
|
|
||||||
retryState.retryActiveIndex,
|
|
||||||
retryState.retryRetriesUsed,
|
|
||||||
retryState.retryPromptMessageId ?? null,
|
|
||||||
"complete",
|
|
||||||
null,
|
|
||||||
userMessageId
|
|
||||||
);
|
|
||||||
statements.updateChatMessageDelivery.run("complete", null, userMessageId, chatId);
|
|
||||||
if (sources) statements.updateChatMessageSources.run(JSON.stringify(sources), assistantResult.lastInsertRowid, chatId);
|
|
||||||
if (thinking) statements.updateChatMessageThinking.run(thinking, assistantResult.lastInsertRowid, chatId);
|
|
||||||
statements.setChatActiveLeafById.run(assistantResult.lastInsertRowid, chatId);
|
|
||||||
return assistantResult;
|
|
||||||
});
|
|
||||||
|
|
||||||
const insertBranchChatMessageTransaction = db.transaction((chatId, role, content, retryState, modelName, deliveryStatus, errorMessage, parentMessageId) => {
|
|
||||||
const result = statements.insertChatMessage.run(
|
|
||||||
chatId, role, String(content || ""), modelName || null,
|
|
||||||
retryState ? JSON.stringify(retryState.retryVariants) : null,
|
|
||||||
retryState?.retryActiveIndex ?? 0,
|
|
||||||
retryState?.retryRetriesUsed ?? 0,
|
|
||||||
retryState?.retryPromptMessageId ?? null,
|
|
||||||
deliveryStatus,
|
|
||||||
errorMessage,
|
|
||||||
parentMessageId ?? null
|
|
||||||
);
|
|
||||||
statements.setChatActiveLeafById.run(result.lastInsertRowid, chatId);
|
|
||||||
return result;
|
|
||||||
});
|
|
||||||
|
|
||||||
const failChatTurnTransaction = db.transaction((chatId, userMessageId, errorMessage, parentMessageId) => {
|
|
||||||
statements.updateChatMessageDelivery.run("failed", errorMessage, userMessageId, chatId);
|
|
||||||
statements.setChatActiveLeafById.run(parentMessageId ?? null, chatId);
|
|
||||||
});
|
|
||||||
|
|
||||||
const savePersonaVersion = db.transaction((personaId, username) => {
|
|
||||||
const persona = statements.getPersona.get(personaId, username);
|
|
||||||
if (!persona) return null;
|
|
||||||
const versionNumber = statements.getNextPersonaVersionNumber.get(personaId).nextVersion;
|
|
||||||
statements.insertPersonaVersion.run(personaId, username, versionNumber, buildPersonaSnapshot(persona));
|
|
||||||
return versionNumber;
|
|
||||||
});
|
|
||||||
|
|
||||||
const clonePersonaTransaction = db.transaction((personaId, username) => {
|
|
||||||
const source = statements.getPersona.get(personaId, username);
|
|
||||||
if (!source) return null;
|
|
||||||
const cloneId = statements.insertPersona.run(
|
|
||||||
username,
|
|
||||||
`${source.name} Copy`,
|
|
||||||
source.pronouns,
|
|
||||||
source.appearance,
|
|
||||||
source.background,
|
|
||||||
source.details,
|
|
||||||
source.example_dialogues,
|
|
||||||
source.persona_type
|
|
||||||
).lastInsertRowid;
|
|
||||||
const clone = statements.getPersona.get(cloneId, username);
|
|
||||||
statements.insertPersonaVersion.run(cloneId, username, 1, buildPersonaSnapshot(clone));
|
|
||||||
return clone;
|
|
||||||
});
|
|
||||||
|
|
||||||
const importWorkspaceTransaction = db.transaction((username, workspace) => {
|
const importWorkspaceTransaction = db.transaction((username, workspace) => {
|
||||||
const imported = {personas: 0, chats: 0, templates: 0};
|
const imported = {personas: 0, chats: 0, templates: 0};
|
||||||
|
const personaIds = new Map();
|
||||||
|
const importedChats = [];
|
||||||
for (const persona of workspace.personas || []) {
|
for (const persona of workspace.personas || []) {
|
||||||
const personaId = statements.insertPersona.run(
|
const personaId = statements.insertPersona.run(
|
||||||
username,
|
username,
|
||||||
@@ -821,14 +244,15 @@ export function createRepositories(db, config) {
|
|||||||
persona.example_dialogues ?? null,
|
persona.example_dialogues ?? null,
|
||||||
persona.persona_type || "assistant"
|
persona.persona_type || "assistant"
|
||||||
).lastInsertRowid;
|
).lastInsertRowid;
|
||||||
statements.insertPersonaVersion.run(personaId, username, 1, JSON.stringify(persona));
|
if (persona.sourceId != null) personaIds.set(persona.sourceId, Number(personaId));
|
||||||
|
statements.insertPersonaVersion.run(personaId, username, 1, buildPersonaSnapshot(statements.getPersona.get(personaId, username)));
|
||||||
imported.personas += 1;
|
imported.personas += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const template of workspace.templates || []) {
|
for (const template of workspace.templates || []) {
|
||||||
statements.insertPromptTemplate.run(
|
statements.insertPromptTemplate.run(
|
||||||
username,
|
username,
|
||||||
null,
|
personaIds.get(template.persona_id) ?? null,
|
||||||
template.name,
|
template.name,
|
||||||
template.description ?? null,
|
template.description ?? null,
|
||||||
template.category ?? null,
|
template.category ?? null,
|
||||||
@@ -840,21 +264,23 @@ export function createRepositories(db, config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const chat of workspace.chats || []) {
|
for (const chat of workspace.chats || []) {
|
||||||
|
const assistantPersonaId = personaIds.get(chat.assistant_persona_id) ?? null;
|
||||||
|
const userPersonaId = personaIds.get(chat.user_persona_id) ?? null;
|
||||||
const chatId = statements.insertChatSession.run(
|
const chatId = statements.insertChatSession.run(
|
||||||
username,
|
username,
|
||||||
chat.stored_title || chat.title || "Imported chat",
|
chat.stored_title || chat.title || "Imported chat",
|
||||||
null,
|
assistantPersonaId,
|
||||||
null,
|
userPersonaId,
|
||||||
chat.folder_name ?? null,
|
chat.folder_name ?? null,
|
||||||
chat.is_pinned ? 1 : 0,
|
chat.is_pinned ? 1 : 0,
|
||||||
chat.archived_at ?? null,
|
chat.archived_at ?? null,
|
||||||
chat.scenario_prompt ?? null,
|
chat.scenario_prompt ?? null,
|
||||||
chat.scenario_summary ?? null
|
chat.scenario_summary ?? null
|
||||||
).lastInsertRowid;
|
).lastInsertRowid;
|
||||||
|
db.prepare("UPDATE chat_sessions SET personality_id = ? WHERE id = ?").run(chat.personality_id || "general-assistant", chatId);
|
||||||
const importedMessageIds = new Map();
|
const importedMessageIds = new Map();
|
||||||
|
importedChats.push({chat, chatId, messageIds: importedMessageIds});
|
||||||
for (const message of chat.messages || []) {
|
for (const message of chat.messages || []) {
|
||||||
const mappedRetryPromptId = importedMessageIds.get(message.retryPromptMessageId) ?? null;
|
|
||||||
const mappedParentId = importedMessageIds.get(message.parentMessageId) ?? null;
|
|
||||||
const result = statements.insertChatMessage.run(
|
const result = statements.insertChatMessage.run(
|
||||||
chatId,
|
chatId,
|
||||||
message.role,
|
message.role,
|
||||||
@@ -863,10 +289,10 @@ export function createRepositories(db, config) {
|
|||||||
JSON.stringify(message.retryVariants || [message.content || ""]),
|
JSON.stringify(message.retryVariants || [message.content || ""]),
|
||||||
Number(message.retryActiveIndex || 0),
|
Number(message.retryActiveIndex || 0),
|
||||||
Number(message.retryRetriesUsed || 0),
|
Number(message.retryRetriesUsed || 0),
|
||||||
mappedRetryPromptId,
|
null,
|
||||||
message.deliveryStatus || "complete",
|
message.deliveryStatus || "complete",
|
||||||
message.errorMessage || null,
|
message.errorMessage || null,
|
||||||
mappedParentId
|
null
|
||||||
);
|
);
|
||||||
if (message.sourceId != null) importedMessageIds.set(message.sourceId, Number(result.lastInsertRowid));
|
if (message.sourceId != null) importedMessageIds.set(message.sourceId, Number(result.lastInsertRowid));
|
||||||
if (message.sources) statements.updateChatMessageSources.run(JSON.stringify(message.sources), result.lastInsertRowid, chatId);
|
if (message.sources) statements.updateChatMessageSources.run(JSON.stringify(message.sources), result.lastInsertRowid, chatId);
|
||||||
@@ -876,11 +302,21 @@ export function createRepositories(db, config) {
|
|||||||
.run(result.lastInsertRowid, version.content, version.thinking, JSON.stringify(version.sources), version.modelName, version.createdAt);
|
.run(result.lastInsertRowid, version.content, version.thinking, JSON.stringify(version.sources), version.modelName, version.createdAt);
|
||||||
}
|
}
|
||||||
for (const [position, attachment] of (message.attachments || []).slice(0, config.uploadMaxFiles || 5).entries()) {
|
for (const [position, attachment] of (message.attachments || []).slice(0, config.uploadMaxFiles || 5).entries()) {
|
||||||
// JSON backups contain references, not files. Only restore existing, owned images.
|
// JSON imports reconnect owned images; archive imports also reconnect newly restored images.
|
||||||
db.prepare("INSERT OR IGNORE INTO message_attachments(message_id,attachment_id,position) SELECT ?,id,? FROM attachments WHERE id=? AND username=? AND status='attached'")
|
db.prepare("INSERT OR IGNORE INTO message_attachments(message_id,attachment_id,position) SELECT ?,id,? FROM attachments WHERE id=? AND username=? AND status IN ('temporary','attached')")
|
||||||
.run(result.lastInsertRowid, position, attachment.id, username);
|
.run(result.lastInsertRowid, position, attachment.id, username);
|
||||||
|
db.prepare("UPDATE attachments SET status='attached' WHERE id=? AND username=?").run(attachment.id, username);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Resolve links after every message has an ID, including forward references.
|
||||||
|
for (const message of chat.messages || []) {
|
||||||
|
statements.updateChatMessageLinks.run(
|
||||||
|
importedMessageIds.get(message.parentMessageId) ?? null,
|
||||||
|
importedMessageIds.get(message.retryPromptMessageId) ?? null,
|
||||||
|
importedMessageIds.get(message.sourceId),
|
||||||
|
chatId
|
||||||
|
);
|
||||||
|
}
|
||||||
statements.updateChatGenerationSettings.run(
|
statements.updateChatGenerationSettings.run(
|
||||||
chat.preferred_model ?? null,
|
chat.preferred_model ?? null,
|
||||||
chat.temperature ?? null,
|
chat.temperature ?? null,
|
||||||
@@ -906,15 +342,17 @@ export function createRepositories(db, config) {
|
|||||||
for (const memory of chat.memories || []) {
|
for (const memory of chat.memories || []) {
|
||||||
const fact = String(memory?.fact || "").trim();
|
const fact = String(memory?.fact || "").trim();
|
||||||
if (!fact) continue;
|
if (!fact) continue;
|
||||||
statements.insertChatMemory.run(username, chatId, null, null, fact);
|
statements.insertChatMemory.run(username, chatId,
|
||||||
|
memory.assistant_persona_id === undefined ? assistantPersonaId : personaIds.get(memory.assistant_persona_id) ?? null,
|
||||||
|
memory.user_persona_id === undefined ? userPersonaId : personaIds.get(memory.user_persona_id) ?? null, fact);
|
||||||
}
|
}
|
||||||
const personaState = chat.persona_state || chat.personaChatState;
|
const personaState = chat.persona_state || chat.personaChatState;
|
||||||
if (personaState) {
|
if (personaState) {
|
||||||
statements.upsertPersonaChatState.run(
|
statements.upsertPersonaChatState.run(
|
||||||
username,
|
username,
|
||||||
chatId,
|
chatId,
|
||||||
null,
|
personaIds.get(personaState.assistant_persona_id) ?? null,
|
||||||
null,
|
personaIds.get(personaState.user_persona_id) ?? null,
|
||||||
personaState.relationship_notes ?? personaState.relationshipNotes ?? null,
|
personaState.relationship_notes ?? personaState.relationshipNotes ?? null,
|
||||||
personaState.tone ?? null,
|
personaState.tone ?? null,
|
||||||
personaState.current_location ?? personaState.currentLocation ?? null,
|
personaState.current_location ?? personaState.currentLocation ?? null,
|
||||||
@@ -926,231 +364,21 @@ export function createRepositories(db, config) {
|
|||||||
imported.chats += 1;
|
imported.chats += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const chatsBySourceId = new Map(importedChats.filter(({chat}) => chat.sourceId != null).map(entry => [entry.chat.sourceId, entry]));
|
||||||
|
for (const {chat, chatId} of importedChats) {
|
||||||
|
const sourceChat = chatsBySourceId.get(chat.source_chat_id);
|
||||||
|
statements.updateChatBranchSource.run(sourceChat?.chatId ?? null,
|
||||||
|
sourceChat?.messageIds.get(chat.branched_from_message_id) ?? null, chatId, username);
|
||||||
|
}
|
||||||
|
|
||||||
return imported;
|
return imported;
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getUser: (username) => statements.getUser.get(username),
|
...createHistoryRepository(db),
|
||||||
insertUser: (username, passwordHash) => statements.insertUser.run(username, passwordHash),
|
...createAccountRepository(db, statements),
|
||||||
updatePassword: (passwordHash, username) => statements.updatePassword.run(passwordHash, username),
|
...createChatRepository(db, config, statements),
|
||||||
incrementSessionVersion(username) {
|
...createPersonaRepository(db, statements),
|
||||||
statements.incrementSessionVersion.run(username);
|
|
||||||
return statements.getUser.get(username);
|
|
||||||
},
|
|
||||||
renameUser: (currentUsername, nextUsername) => renameUserTransaction(currentUsername, nextUsername),
|
|
||||||
deleteUser: (username) => deleteUserTransaction(username),
|
|
||||||
listChats: (username) => statements.listChatSessions.all(username),
|
|
||||||
searchChats(username, query) {
|
|
||||||
const like = `%${String(query || "").trim().toLowerCase()}%`;
|
|
||||||
return statements.searchChats.all(username, like, like);
|
|
||||||
},
|
|
||||||
countChats: (username) => statements.countChatSessions.get(username).count,
|
|
||||||
getChat: (chatId, username) => statements.getChatSession.get(chatId, username),
|
|
||||||
getChatByParticipants: (username, assistantPersonaId, userPersonaId) => statements.getChatSessionByParticipants.get(username, assistantPersonaId, userPersonaId, userPersonaId),
|
|
||||||
createChat(username, title, assistantPersonaId = null, userPersonaId = null, scenarioPrompt = null, scenarioSummary = null, organization = {}) {
|
|
||||||
const chatId = statements.insertChatSession.run(
|
|
||||||
username,
|
|
||||||
title,
|
|
||||||
assistantPersonaId,
|
|
||||||
userPersonaId,
|
|
||||||
organization.folderName ?? null,
|
|
||||||
organization.isPinned ? 1 : 0,
|
|
||||||
organization.archivedAt ?? null,
|
|
||||||
scenarioPrompt,
|
|
||||||
scenarioSummary
|
|
||||||
).lastInsertRowid;
|
|
||||||
return statements.getChatSession.get(chatId, username);
|
|
||||||
},
|
|
||||||
updateChatTitle: (chatId, username, title) => statements.updateChatSessionTitle.run(title, chatId, username),
|
|
||||||
updateChatScene: (chatId, username, scenarioPrompt, scenarioSummary) => statements.updateChatSessionScene.run(scenarioPrompt, scenarioSummary, chatId, username),
|
|
||||||
updateChatContextSummary: (chatId, username, summary, messageId) => statements.updateChatContextSummary.run(summary, Number(messageId || 0), chatId, username),
|
|
||||||
getPersonaChatState: (chatId, username) => statements.getPersonaChatState.get(chatId, username) || null,
|
|
||||||
updatePersonaChatState(username, chat, state) {
|
|
||||||
statements.upsertPersonaChatState.run(
|
|
||||||
username,
|
|
||||||
chat.id,
|
|
||||||
chat.assistant_persona_id ?? null,
|
|
||||||
chat.user_persona_id ?? null,
|
|
||||||
state.relationship_notes ?? null,
|
|
||||||
state.tone ?? null,
|
|
||||||
state.current_location ?? null,
|
|
||||||
state.goals ?? null,
|
|
||||||
state.unresolved_threads ?? null,
|
|
||||||
state.boundaries ?? null
|
|
||||||
);
|
|
||||||
return statements.getPersonaChatState.get(chat.id, username);
|
|
||||||
},
|
|
||||||
updateChatOrganization(chatId, username, {folderName = null, isPinned = false, archivedAt = null}) {
|
|
||||||
return statements.updateChatSessionOrganization.run(folderName, isPinned ? 1 : 0, archivedAt, chatId, username);
|
|
||||||
},
|
|
||||||
touchChat: (chatId, username) => statements.touchChatSession.run(chatId, username),
|
|
||||||
deleteChat: (chatId, username) => statements.deleteChatSession.run(chatId, username),
|
|
||||||
listChatMessages: (chatId) => statements.getChatMessages.all(chatId),
|
|
||||||
listActiveBranchMessages: (chatId) => statements.getActiveBranchMessages.all(chatId),
|
|
||||||
listChatBranchThroughMessage: (chatId, messageId) => statements.getChatBranchThroughMessage.all(chatId, messageId),
|
|
||||||
listSiblingChatMessages: (chatId, role, parentMessageId) => statements.getSiblingChatMessages.all(chatId, role, parentMessageId ?? null),
|
|
||||||
getLatestDescendantMessage: (chatId, messageId) => statements.getLatestDescendantMessage.get(chatId, messageId, chatId),
|
|
||||||
getRecentChatMessages: (chatId) => statements.getRecentChatMessages.all(chatId, config.chatHistoryLimit),
|
|
||||||
getRecentChatMessagesUpToId: (chatId, messageId) => statements.getRecentChatMessagesUpToId.all(chatId, messageId, config.chatHistoryLimit),
|
|
||||||
getChatMessage: (chatId, messageId) => statements.getChatMessageById.get(messageId, chatId),
|
|
||||||
getChatMessageByIndex: (chatId, index) => statements.getChatMessageByIndex.get(chatId, index),
|
|
||||||
getLatestChatMessage: (chatId) => statements.getLatestChatMessage.get(chatId),
|
|
||||||
getPreviousUserMessage: (chatId, messageId) => statements.getPreviousUserMessage.get(chatId, messageId),
|
|
||||||
listChatMemories: (chatId, username) => statements.listChatMemories.all(chatId, username),
|
|
||||||
getChatMemory: (chatId, memoryId, username) => statements.getChatMemory.get(memoryId, chatId, username),
|
|
||||||
createChatMemory(username, chat, fact) {
|
|
||||||
const result = statements.insertChatMemory.run(
|
|
||||||
username,
|
|
||||||
chat.id,
|
|
||||||
chat.assistant_persona_id ?? null,
|
|
||||||
chat.user_persona_id ?? null,
|
|
||||||
String(fact || "").trim()
|
|
||||||
);
|
|
||||||
return statements.getChatMemory.get(result.lastInsertRowid, chat.id, username);
|
|
||||||
},
|
|
||||||
updateChatMemory: (chatId, memoryId, username, fact) => statements.updateChatMemory.run(String(fact || "").trim(), memoryId, chatId, username),
|
|
||||||
deleteChatMemory: (chatId, memoryId, username) => statements.deleteChatMemory.run(memoryId, chatId, username),
|
|
||||||
insertChatMessage(chatId, role, content, retryState = null, modelName = null, deliveryStatus = "complete", errorMessage = null, parentMessageId = null) {
|
|
||||||
return statements.insertChatMessage.run(
|
|
||||||
chatId,
|
|
||||||
role,
|
|
||||||
String(content || ""),
|
|
||||||
modelName || null,
|
|
||||||
retryState ? JSON.stringify(retryState.retryVariants) : null,
|
|
||||||
retryState?.retryActiveIndex ?? 0,
|
|
||||||
retryState?.retryRetriesUsed ?? 0,
|
|
||||||
retryState?.retryPromptMessageId ?? null,
|
|
||||||
deliveryStatus,
|
|
||||||
errorMessage,
|
|
||||||
parentMessageId
|
|
||||||
);
|
|
||||||
},
|
|
||||||
insertBranchChatMessage: (chatId, role, content, retryState = null, modelName = null, deliveryStatus = "complete", errorMessage = null, parentMessageId = null) =>
|
|
||||||
insertBranchChatMessageTransaction(chatId, role, content, retryState, modelName, deliveryStatus, errorMessage, parentMessageId),
|
|
||||||
updateChatMessageDelivery: (chatId, messageId, deliveryStatus, errorMessage = null) =>
|
|
||||||
statements.updateChatMessageDelivery.run(deliveryStatus, errorMessage, messageId, chatId),
|
|
||||||
completeChatTurn: (chatId, userMessageId, assistantContent, retryState, modelName = null, sources = null, thinking = null) =>
|
|
||||||
completeChatTurnTransaction(chatId, userMessageId, assistantContent, retryState, modelName, sources, thinking),
|
|
||||||
updateChatMessageThinking: (chatId, messageId, thinking) =>
|
|
||||||
statements.updateChatMessageThinking.run(thinking, messageId, chatId),
|
|
||||||
updateChatMessageSources: (chatId, messageId, sources) =>
|
|
||||||
statements.updateChatMessageSources.run(JSON.stringify(sources), messageId, chatId),
|
|
||||||
failChatTurn: (chatId, userMessageId, errorMessage, parentMessageId = null) =>
|
|
||||||
failChatTurnTransaction(chatId, userMessageId, errorMessage, parentMessageId),
|
|
||||||
updateChatActiveLeaf: (chatId, username, messageId) => statements.updateChatActiveLeaf.run(messageId, chatId, username),
|
|
||||||
updateChatBranchSource: (chatId, username, sourceChatId, messageId) =>
|
|
||||||
statements.updateChatBranchSource.run(sourceChatId, messageId ?? null, chatId, username),
|
|
||||||
updateChatGenerationSettings: (chatId, username, settings) => statements.updateChatGenerationSettings.run(
|
|
||||||
settings.preferredModel ?? null,
|
|
||||||
settings.temperature ?? null,
|
|
||||||
settings.contextLength ?? null,
|
|
||||||
settings.responseLength ?? null,
|
|
||||||
settings.systemInstruction ?? null,
|
|
||||||
chatId,
|
|
||||||
username
|
|
||||||
),
|
|
||||||
updateChatPreferredModel: (chatId, username, model) => statements.updateChatPreferredModel.run(model || null, chatId, username),
|
|
||||||
listMessageVersions: (messageId) => db.prepare("SELECT * FROM chat_message_versions WHERE message_id = ? ORDER BY id").all(messageId),
|
|
||||||
saveMessageVersion: (chatId, messageId, version = null, selectedIndex = null) => db.transaction(() => {
|
|
||||||
const row = statements.getChatMessageById.get(messageId, chatId);
|
|
||||||
if (!row) throw new Error("Message not found");
|
|
||||||
let versions = db.prepare("SELECT * FROM chat_message_versions WHERE message_id = ? ORDER BY id").all(messageId);
|
|
||||||
const insert = db.prepare("INSERT INTO chat_message_versions (message_id, content, thinking, sources, model_name) VALUES (?, ?, ?, ?, ?)");
|
|
||||||
if (!versions.length) {
|
|
||||||
db.prepare("INSERT INTO chat_message_versions (message_id, content, thinking, sources, model_name, created_at) VALUES (?, ?, ?, ?, ?, ?)")
|
|
||||||
.run(messageId, row.content, row.thinking, row.sources, row.model_name, row.created_at);
|
|
||||||
} else {
|
|
||||||
// Edits keep their existing semantics: update the selected answer.
|
|
||||||
db.prepare("UPDATE chat_message_versions SET content = ?, thinking = ?, sources = ?, model_name = ? WHERE id = ?")
|
|
||||||
.run(row.content, row.thinking, row.sources, row.model_name, versions[row.retry_active_index]?.id ?? versions[0].id);
|
|
||||||
}
|
|
||||||
if (version) insert.run(messageId, version.content, version.thinking || null, JSON.stringify(version.sources ?? null), version.modelName);
|
|
||||||
versions = db.prepare("SELECT * FROM chat_message_versions WHERE message_id = ? ORDER BY id").all(messageId);
|
|
||||||
const index = version ? versions.length - 1 : selectedIndex;
|
|
||||||
if (!Number.isInteger(index) || !versions[index]) throw new Error("Version is out of range");
|
|
||||||
const active = versions[index];
|
|
||||||
db.prepare(`UPDATE chat_messages SET content = ?, thinking = ?, sources = ?, model_name = ?,
|
|
||||||
retry_variants = ?, retry_active_index = ?, retry_retries_used = ? WHERE id = ? AND chat_id = ?`)
|
|
||||||
.run(active.content, active.thinking, active.sources, active.model_name, JSON.stringify(versions.map(item => item.content)), index, versions.length - 1, messageId, chatId);
|
|
||||||
})(),
|
|
||||||
updateChatMessage: (chatId, messageId, content) => statements.updateChatMessage.run(content, messageId, chatId),
|
|
||||||
updateChatMessageRetryState: (chatId, messageId, payload) => statements.updateChatMessageWithRetryState.run(
|
|
||||||
payload.content,
|
|
||||||
JSON.stringify(payload.retryVariants),
|
|
||||||
payload.retryActiveIndex,
|
|
||||||
payload.retryRetriesUsed,
|
|
||||||
payload.retryPromptMessageId ?? null,
|
|
||||||
messageId,
|
|
||||||
chatId
|
|
||||||
),
|
|
||||||
deleteChatMessage: (chatId, messageId) => statements.deleteChatMessage.run(messageId, chatId),
|
|
||||||
clearChatMessages: (chatId) => statements.deleteChatMessages.run(chatId),
|
|
||||||
listPersonasByType: (username, personaType) => statements.listPersonasByType.all(username, personaType),
|
|
||||||
listAllPersonas: (username) => statements.listAllPersonas.all(username),
|
|
||||||
getPersona: (personaId, username) => statements.getPersona.get(personaId, username),
|
|
||||||
createPersona(username, payload, personaType) {
|
|
||||||
const personaId = statements.insertPersona.run(
|
|
||||||
username,
|
|
||||||
payload.name,
|
|
||||||
payload.pronouns,
|
|
||||||
payload.appearance,
|
|
||||||
payload.background,
|
|
||||||
payload.details,
|
|
||||||
payload.exampleDialogues,
|
|
||||||
personaType
|
|
||||||
).lastInsertRowid;
|
|
||||||
const persona = statements.getPersona.get(personaId, username);
|
|
||||||
statements.insertPersonaVersion.run(personaId, username, 1, buildPersonaSnapshot(persona));
|
|
||||||
return persona;
|
|
||||||
},
|
|
||||||
updatePersona(personaId, username, payload) {
|
|
||||||
savePersonaVersion(personaId, username);
|
|
||||||
return statements.updatePersona.run(
|
|
||||||
payload.name,
|
|
||||||
payload.pronouns,
|
|
||||||
payload.appearance,
|
|
||||||
payload.background,
|
|
||||||
payload.details,
|
|
||||||
payload.exampleDialogues,
|
|
||||||
personaId,
|
|
||||||
username
|
|
||||||
);
|
|
||||||
},
|
|
||||||
restorePersonaVersion(personaId, versionId, username) {
|
|
||||||
const version = statements.getPersonaVersion.get(versionId, personaId, username);
|
|
||||||
if (!version) return null;
|
|
||||||
savePersonaVersion(personaId, username);
|
|
||||||
const snapshot = JSON.parse(version.snapshot_json);
|
|
||||||
statements.updatePersona.run(
|
|
||||||
snapshot.name,
|
|
||||||
snapshot.pronouns ?? null,
|
|
||||||
snapshot.appearance ?? null,
|
|
||||||
snapshot.background ?? null,
|
|
||||||
snapshot.details ?? null,
|
|
||||||
snapshot.example_dialogues ?? null,
|
|
||||||
personaId,
|
|
||||||
username
|
|
||||||
);
|
|
||||||
return statements.getPersona.get(personaId, username);
|
|
||||||
},
|
|
||||||
listPersonaVersions(personaId, username) {
|
|
||||||
return statements.listPersonaVersions.all(personaId, username).map((version) => ({
|
|
||||||
...version,
|
|
||||||
snapshot: JSON.parse(version.snapshot_json)
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
clonePersona: (personaId, username) => clonePersonaTransaction(personaId, username),
|
|
||||||
deletePersona: (personaId, username) => statements.deletePersona.run(personaId, username),
|
|
||||||
clearPersonaReferences(username, personaId) {
|
|
||||||
statements.clearAssistantPersonaFromChats.run(username, personaId);
|
|
||||||
statements.clearUserPersonaFromChats.run(username, personaId);
|
|
||||||
statements.deleteMarketPersonaByPersonaId.run(personaId, username);
|
|
||||||
},
|
|
||||||
getActivePersonaIds: (username) => statements.getActivePersonaId.get(username) || {},
|
|
||||||
setActiveUserPersona: (username, personaId) => statements.setActiveUserPersona.run(username, personaId),
|
|
||||||
getActiveUserPersona: (username) => statements.getActiveUserPersona.get(username),
|
|
||||||
getAssistantPersonaForChat: (chatId, username) => statements.getAssistantPersonaForChat.get(chatId, username),
|
|
||||||
getUserPersonaForChat: (chatId, username) => statements.getUserPersonaForChat.get(chatId, username),
|
|
||||||
listMarketPersonas({admin = false, username = null, personaType = null, sort = "best"} = {}) {
|
listMarketPersonas({admin = false, username = null, personaType = null, sort = "best"} = {}) {
|
||||||
const normalizedPersonaType = normalizeMarketPersonaType(personaType);
|
const normalizedPersonaType = normalizeMarketPersonaType(personaType);
|
||||||
const normalizedSort = normalizeMarketSort(sort);
|
const normalizedSort = normalizeMarketSort(sort);
|
||||||
|
|||||||
+80
-1
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Definiert das Datenbankschema und migriert bestehende Daten auf die benötigte Struktur.
|
||||||
|
*/
|
||||||
export function initializeSchema(db) {
|
export function initializeSchema(db) {
|
||||||
db.pragma("journal_mode = WAL");
|
db.pragma("journal_mode = WAL");
|
||||||
db.pragma("foreign_keys = ON");
|
db.pragma("foreign_keys = ON");
|
||||||
@@ -7,6 +10,7 @@ export function initializeSchema(db) {
|
|||||||
(
|
(
|
||||||
username TEXT PRIMARY KEY,
|
username TEXT PRIMARY KEY,
|
||||||
password TEXT NOT NULL,
|
password TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('user', 'admin')),
|
||||||
session_version INTEGER DEFAULT 0
|
session_version INTEGER DEFAULT 0
|
||||||
)
|
)
|
||||||
`).run();
|
`).run();
|
||||||
@@ -34,6 +38,7 @@ export function initializeSchema(db) {
|
|||||||
context_length INTEGER,
|
context_length INTEGER,
|
||||||
response_length INTEGER,
|
response_length INTEGER,
|
||||||
system_instruction TEXT,
|
system_instruction TEXT,
|
||||||
|
personality_id TEXT NOT NULL DEFAULT 'general-assistant',
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (assistant_persona_id) REFERENCES personas (id) ON DELETE SET NULL,
|
FOREIGN KEY (assistant_persona_id) REFERENCES personas (id) ON DELETE SET NULL,
|
||||||
@@ -235,7 +240,11 @@ export function initializeSchema(db) {
|
|||||||
`).run();
|
`).run();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runMigrations(db, {dropLegacyChats = true} = {}) {
|
export function runMigrations(db, {dropLegacyChats = false} = {}) {
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
version INTEGER PRIMARY KEY,
|
||||||
|
applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)`);
|
||||||
db.exec(`CREATE TABLE IF NOT EXISTS chat_message_versions (
|
db.exec(`CREATE TABLE IF NOT EXISTS chat_message_versions (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
message_id INTEGER NOT NULL REFERENCES chat_messages(id) ON DELETE CASCADE,
|
message_id INTEGER NOT NULL REFERENCES chat_messages(id) ON DELETE CASCADE,
|
||||||
@@ -253,9 +262,64 @@ export function runMigrations(db, {dropLegacyChats = true} = {}) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const userColumns = db.prepare("PRAGMA table_info(users)").all();
|
const userColumns = db.prepare("PRAGMA table_info(users)").all();
|
||||||
|
ensureColumn("users", userColumns, "role", "ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('user', 'admin'))");
|
||||||
ensureColumn("users", userColumns, "session_version", "ALTER TABLE users ADD COLUMN session_version INTEGER DEFAULT 0");
|
ensureColumn("users", userColumns, "session_version", "ALTER TABLE users ADD COLUMN session_version INTEGER DEFAULT 0");
|
||||||
db.prepare("UPDATE users SET session_version = 0 WHERE session_version IS NULL").run();
|
db.prepare("UPDATE users SET session_version = 0 WHERE session_version IS NULL").run();
|
||||||
|
|
||||||
|
// Older databases allowed usernames that differed only by case. Repair those
|
||||||
|
// rows before adding the case-insensitive uniqueness guarantee. Keep the first
|
||||||
|
// account unchanged and give later accounts a stable, recoverable username;
|
||||||
|
// moving their owned rows prevents orphaned workspace data.
|
||||||
|
const conflictingUsers = db.prepare(`
|
||||||
|
SELECT LOWER(username) AS username_lower
|
||||||
|
FROM users
|
||||||
|
GROUP BY LOWER(username)
|
||||||
|
HAVING COUNT(*) > 1
|
||||||
|
`).all();
|
||||||
|
if (conflictingUsers.length > 0) {
|
||||||
|
const ownershipColumns = [
|
||||||
|
["chat_sessions", "username"],
|
||||||
|
["chat_memories", "username"],
|
||||||
|
["persona_chat_state", "username"],
|
||||||
|
["personas", "username"],
|
||||||
|
["personas", "source_creator_username"],
|
||||||
|
["user_settings", "username"],
|
||||||
|
["persona_market", "creator_username"],
|
||||||
|
["persona_versions", "username"],
|
||||||
|
["prompt_templates", "username"],
|
||||||
|
["persona_market_favorites", "username"],
|
||||||
|
["persona_market_ratings", "username"],
|
||||||
|
["persona_market_reports", "reporter_username"],
|
||||||
|
["attachments", "username"],
|
||||||
|
["upload_leases", "username"],
|
||||||
|
["workspace_operation_leases", "username"]
|
||||||
|
];
|
||||||
|
const existingTables = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map(({name}) => name));
|
||||||
|
const updateStatements = ownershipColumns
|
||||||
|
.filter(([table]) => existingTables.has(table))
|
||||||
|
.map(([table, column]) => [
|
||||||
|
table,
|
||||||
|
column,
|
||||||
|
db.prepare(`UPDATE "${table}" SET "${column}" = ? WHERE "${column}" = ?`)
|
||||||
|
]);
|
||||||
|
const repairConflicts = db.transaction(() => {
|
||||||
|
db.pragma("defer_foreign_keys = ON");
|
||||||
|
for (const {username_lower: usernameLower} of conflictingUsers) {
|
||||||
|
const rows = db.prepare("SELECT rowid, username FROM users WHERE LOWER(username) = ? ORDER BY rowid").all(usernameLower);
|
||||||
|
for (const row of rows.slice(1)) {
|
||||||
|
const suffix = `-legacy-${row.rowid}`;
|
||||||
|
const nextUsername = `${row.username.slice(0, Math.max(1, 64 - suffix.length))}${suffix}`;
|
||||||
|
for (const [, , statement] of updateStatements) {
|
||||||
|
statement.run(nextUsername, row.username);
|
||||||
|
}
|
||||||
|
db.prepare("UPDATE users SET username = ? WHERE username = ?").run(nextUsername, row.username);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
repairConflicts();
|
||||||
|
}
|
||||||
|
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower ON users (LOWER(username))");
|
||||||
|
|
||||||
const personaColumns = db.prepare("PRAGMA table_info(personas)").all();
|
const personaColumns = db.prepare("PRAGMA table_info(personas)").all();
|
||||||
ensureColumn("personas", personaColumns, "custom_fields", "ALTER TABLE personas ADD COLUMN custom_fields TEXT");
|
ensureColumn("personas", personaColumns, "custom_fields", "ALTER TABLE personas ADD COLUMN custom_fields TEXT");
|
||||||
ensureColumn("personas", personaColumns, "persona_type", "ALTER TABLE personas ADD COLUMN persona_type TEXT DEFAULT 'assistant'");
|
ensureColumn("personas", personaColumns, "persona_type", "ALTER TABLE personas ADD COLUMN persona_type TEXT DEFAULT 'assistant'");
|
||||||
@@ -294,11 +358,15 @@ export function runMigrations(db, {dropLegacyChats = true} = {}) {
|
|||||||
ensureColumn("chat_messages", chatMessageColumns, "delivery_status", "ALTER TABLE chat_messages ADD COLUMN delivery_status TEXT DEFAULT 'complete'");
|
ensureColumn("chat_messages", chatMessageColumns, "delivery_status", "ALTER TABLE chat_messages ADD COLUMN delivery_status TEXT DEFAULT 'complete'");
|
||||||
ensureColumn("chat_messages", chatMessageColumns, "error_message", "ALTER TABLE chat_messages ADD COLUMN error_message TEXT");
|
ensureColumn("chat_messages", chatMessageColumns, "error_message", "ALTER TABLE chat_messages ADD COLUMN error_message TEXT");
|
||||||
ensureColumn("chat_messages", chatMessageColumns, "parent_message_id", "ALTER TABLE chat_messages ADD COLUMN parent_message_id INTEGER");
|
ensureColumn("chat_messages", chatMessageColumns, "parent_message_id", "ALTER TABLE chat_messages ADD COLUMN parent_message_id INTEGER");
|
||||||
|
db.exec(`CREATE INDEX IF NOT EXISTS chat_messages_family ON chat_messages(chat_id, parent_message_id, role, id);
|
||||||
|
CREATE INDEX IF NOT EXISTS chat_messages_chat_order ON chat_messages(chat_id, id);`);
|
||||||
db.prepare("UPDATE chat_messages SET retry_active_index = 0 WHERE retry_active_index IS NULL").run();
|
db.prepare("UPDATE chat_messages SET retry_active_index = 0 WHERE retry_active_index IS NULL").run();
|
||||||
db.prepare("UPDATE chat_messages SET retry_retries_used = 0 WHERE retry_retries_used IS NULL").run();
|
db.prepare("UPDATE chat_messages SET retry_retries_used = 0 WHERE retry_retries_used IS NULL").run();
|
||||||
db.prepare("UPDATE chat_messages SET delivery_status = 'complete' WHERE delivery_status IS NULL").run();
|
db.prepare("UPDATE chat_messages SET delivery_status = 'complete' WHERE delivery_status IS NULL").run();
|
||||||
|
|
||||||
const chatSessionColumns = db.prepare("PRAGMA table_info(chat_sessions)").all();
|
const chatSessionColumns = db.prepare("PRAGMA table_info(chat_sessions)").all();
|
||||||
|
ensureColumn("chat_sessions", chatSessionColumns, "personality_id", "ALTER TABLE chat_sessions ADD COLUMN personality_id TEXT NOT NULL DEFAULT 'general-assistant'");
|
||||||
|
db.prepare("UPDATE chat_sessions SET personality_id = 'general-assistant' WHERE personality_id IS NULL OR personality_id = ''").run();
|
||||||
ensureColumn("chat_sessions", chatSessionColumns, "assistant_persona_id", "ALTER TABLE chat_sessions ADD COLUMN assistant_persona_id INTEGER");
|
ensureColumn("chat_sessions", chatSessionColumns, "assistant_persona_id", "ALTER TABLE chat_sessions ADD COLUMN assistant_persona_id INTEGER");
|
||||||
ensureColumn("chat_sessions", chatSessionColumns, "user_persona_id", "ALTER TABLE chat_sessions ADD COLUMN user_persona_id INTEGER");
|
ensureColumn("chat_sessions", chatSessionColumns, "user_persona_id", "ALTER TABLE chat_sessions ADD COLUMN user_persona_id INTEGER");
|
||||||
ensureColumn("chat_sessions", chatSessionColumns, "folder_name", "ALTER TABLE chat_sessions ADD COLUMN folder_name TEXT");
|
ensureColumn("chat_sessions", chatSessionColumns, "folder_name", "ALTER TABLE chat_sessions ADD COLUMN folder_name TEXT");
|
||||||
@@ -318,6 +386,12 @@ export function runMigrations(db, {dropLegacyChats = true} = {}) {
|
|||||||
ensureColumn("chat_sessions", chatSessionColumns, "system_instruction", "ALTER TABLE chat_sessions ADD COLUMN system_instruction TEXT");
|
ensureColumn("chat_sessions", chatSessionColumns, "system_instruction", "ALTER TABLE chat_sessions ADD COLUMN system_instruction TEXT");
|
||||||
db.prepare("UPDATE chat_sessions SET is_pinned = 0 WHERE is_pinned IS NULL").run();
|
db.prepare("UPDATE chat_sessions SET is_pinned = 0 WHERE is_pinned IS NULL").run();
|
||||||
db.prepare("UPDATE chat_sessions SET context_summary_message_id = 0 WHERE context_summary_message_id IS NULL").run();
|
db.prepare("UPDATE chat_sessions SET context_summary_message_id = 0 WHERE context_summary_message_id IS NULL").run();
|
||||||
|
db.exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS chat_sessions_owner_updated ON chat_sessions(username, updated_at DESC, id DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS chat_sessions_owner_archived_pinned ON chat_sessions(username, archived_at, is_pinned, updated_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS chat_messages_chat_created ON chat_messages(chat_id, created_at, id);
|
||||||
|
CREATE INDEX IF NOT EXISTS chat_memories_owner_chat ON chat_memories(username, chat_id, updated_at DESC);
|
||||||
|
`);
|
||||||
if (!hadParentMessageId) {
|
if (!hadParentMessageId) {
|
||||||
db.exec(`
|
db.exec(`
|
||||||
WITH ordered AS (
|
WITH ordered AS (
|
||||||
@@ -448,6 +522,7 @@ export function runMigrations(db, {dropLegacyChats = true} = {}) {
|
|||||||
|
|
||||||
const legacyChatsExist = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'chats'").get();
|
const legacyChatsExist = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'chats'").get();
|
||||||
if (!legacyChatsExist) {
|
if (!legacyChatsExist) {
|
||||||
|
db.prepare("INSERT OR IGNORE INTO schema_migrations(version) VALUES (?)").run(1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -479,5 +554,9 @@ export function runMigrations(db, {dropLegacyChats = true} = {}) {
|
|||||||
|
|
||||||
if (dropLegacyChats) {
|
if (dropLegacyChats) {
|
||||||
db.prepare("DROP TABLE chats").run();
|
db.prepare("DROP TABLE chats").run();
|
||||||
|
} else {
|
||||||
|
const backupName = `chats_legacy_backup_${Date.now()}`;
|
||||||
|
db.prepare(`ALTER TABLE chats RENAME TO "${backupName}"`).run();
|
||||||
}
|
}
|
||||||
|
db.prepare("INSERT OR IGNORE INTO schema_migrations(version) VALUES (?)").run(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
/** Forward rejected route promises to Express 4's error middleware. */
|
||||||
|
export const asyncHandler = (handler) => (req, res, next) =>
|
||||||
|
Promise.resolve().then(() => handler(req, res, next)).catch(next);
|
||||||
@@ -1,6 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* @file Schützt Routen vor Zugriffen ohne angemeldete Sitzung.
|
||||||
|
*/
|
||||||
export function requireLogin(req, res, next) {
|
export function requireLogin(req, res, next) {
|
||||||
if (!req.session.user) {
|
if (!req.session.user) {
|
||||||
return res.status(403).json({error: "Not logged in"});
|
return res.status(403).json({error: "Not logged in"});
|
||||||
}
|
}
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function requireAdmin(req, res, next) {
|
||||||
|
if (req.account?.role !== "admin") {
|
||||||
|
return res.status(403).json({error: "Admin only"});
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/** Cancel generation when its HTTP client disconnects, for JSON and streamed replies. */
|
||||||
|
export function generationCancellation(req, res, next) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const onClose = () => {
|
||||||
|
if (!res.writableEnded) controller.abort();
|
||||||
|
res.removeListener("finish", onFinish);
|
||||||
|
};
|
||||||
|
const onFinish = () => res.removeListener("close", onClose);
|
||||||
|
res.once("close", onClose);
|
||||||
|
res.once("finish", onFinish);
|
||||||
|
req.generationSignal = controller.signal;
|
||||||
|
next();
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Begrenzt Anfragen je Schlüssel in einem gleitenden Zeitfenster und entfernt abgelaufene Einträge.
|
||||||
|
*/
|
||||||
function pruneBucket(bucket, windowMs, now) {
|
function pruneBucket(bucket, windowMs, now) {
|
||||||
const cutoff = now - windowMs;
|
const cutoff = now - windowMs;
|
||||||
while (bucket.length && bucket[0] <= cutoff) {
|
while (bucket.length && bucket[0] <= cutoff) {
|
||||||
@@ -5,7 +8,7 @@ function pruneBucket(bucket, windowMs, now) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createRateLimiter({windowMs, max, keyGenerator, message, cleanupIntervalMs = 60 * 1000}) {
|
export function createRateLimiter({windowMs, max, keyGenerator, message, cleanupIntervalMs = 60 * 1000, store = null}) {
|
||||||
const hits = new Map();
|
const hits = new Map();
|
||||||
let cleanupTimer = null;
|
let cleanupTimer = null;
|
||||||
|
|
||||||
@@ -19,8 +22,9 @@ export function createRateLimiter({windowMs, max, keyGenerator, message, cleanup
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (cleanupIntervalMs > 0) {
|
if (!store && cleanupIntervalMs > 0) {
|
||||||
cleanupTimer = setInterval(cleanup, cleanupIntervalMs);
|
cleanupTimer = setInterval(cleanup, cleanupIntervalMs);
|
||||||
|
// Der Aufräumtimer allein soll den Node-Prozess beim Herunterfahren nicht offen halten.
|
||||||
cleanupTimer.unref();
|
cleanupTimer.unref();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,6 +35,10 @@ export function createRateLimiter({windowMs, max, keyGenerator, message, cleanup
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
if (store) {
|
||||||
|
if (!store.consume(key, now, windowMs, max)) return res.status(429).json({error: message});
|
||||||
|
return next();
|
||||||
|
}
|
||||||
const bucket = hits.get(key) || [];
|
const bucket = hits.get(key) || [];
|
||||||
pruneBucket(bucket, windowMs, now);
|
pruneBucket(bucket, windowMs, now);
|
||||||
if (!bucket.length) {
|
if (!bucket.length) {
|
||||||
@@ -55,3 +63,26 @@ export function createRateLimiter({windowMs, max, keyGenerator, message, cleanup
|
|||||||
|
|
||||||
return limiter;
|
return limiter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SQLite is already shared by all application instances and serializes this
|
||||||
|
// short transaction, so the check-and-increment remains atomic across workers.
|
||||||
|
export function createSqliteRateLimitStore(db) {
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS rate_limit_hits (
|
||||||
|
bucket_key TEXT NOT NULL,
|
||||||
|
hit_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS rate_limit_hits_expiry ON rate_limit_hits(hit_at);`);
|
||||||
|
db.pragma("busy_timeout = 5000");
|
||||||
|
// Prune globally on every write. Pruning by bucket only leaves expired rows
|
||||||
|
// for inactive keys in the database forever.
|
||||||
|
const prune = db.prepare("DELETE FROM rate_limit_hits WHERE hit_at <= ?");
|
||||||
|
const count = db.prepare("SELECT COUNT(*) AS count FROM rate_limit_hits WHERE bucket_key = ?");
|
||||||
|
const insert = db.prepare("INSERT INTO rate_limit_hits (bucket_key, hit_at) VALUES (?, ?)");
|
||||||
|
const consume = db.transaction((key, now, windowMs, max) => {
|
||||||
|
prune.run(now - windowMs);
|
||||||
|
if (count.get(key).count >= max) return false;
|
||||||
|
insert.run(key, now);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return {consume};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* @file Erstellt die konfigurierten Anfragebegrenzungen für Anmeldung und Chat-Nutzung.
|
||||||
|
*/
|
||||||
import {createRateLimiter} from "./rateLimit.js";
|
import {createRateLimiter} from "./rateLimit.js";
|
||||||
|
|
||||||
export function createAuthRateLimiters(config) {
|
export function createAuthRateLimiters(config, store = null) {
|
||||||
return [
|
return [
|
||||||
createRateLimiter({
|
createRateLimiter({
|
||||||
windowMs: config.authRateLimitWindowMs,
|
windowMs: config.authRateLimitWindowMs,
|
||||||
max: config.authRateLimitMaxPerIp,
|
max: config.authRateLimitMaxPerIp,
|
||||||
cleanupIntervalMs: config.rateLimitCleanupIntervalMs,
|
cleanupIntervalMs: config.rateLimitCleanupIntervalMs,
|
||||||
|
store,
|
||||||
keyGenerator: (req) => `auth:ip:${req.ip}`,
|
keyGenerator: (req) => `auth:ip:${req.ip}`,
|
||||||
message: "Too many authentication attempts from this IP. Please try again later."
|
message: "Too many authentication attempts from this IP. Please try again later."
|
||||||
}),
|
}),
|
||||||
@@ -13,6 +17,7 @@ export function createAuthRateLimiters(config) {
|
|||||||
windowMs: config.authRateLimitWindowMs,
|
windowMs: config.authRateLimitWindowMs,
|
||||||
max: config.authRateLimitMaxPerAccount,
|
max: config.authRateLimitMaxPerAccount,
|
||||||
cleanupIntervalMs: config.rateLimitCleanupIntervalMs,
|
cleanupIntervalMs: config.rateLimitCleanupIntervalMs,
|
||||||
|
store,
|
||||||
keyGenerator: (req) => {
|
keyGenerator: (req) => {
|
||||||
const username = String(req.body?.username || "").trim().toLowerCase();
|
const username = String(req.body?.username || "").trim().toLowerCase();
|
||||||
return username ? `auth:user:${username}` : null;
|
return username ? `auth:user:${username}` : null;
|
||||||
@@ -22,12 +27,13 @@ export function createAuthRateLimiters(config) {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createChatRateLimiters(config) {
|
export function createChatRateLimiters(config, store = null) {
|
||||||
return [
|
return [
|
||||||
createRateLimiter({
|
createRateLimiter({
|
||||||
windowMs: config.chatRateLimitWindowMs,
|
windowMs: config.chatRateLimitWindowMs,
|
||||||
max: config.chatRateLimitMaxPerIp,
|
max: config.chatRateLimitMaxPerIp,
|
||||||
cleanupIntervalMs: config.rateLimitCleanupIntervalMs,
|
cleanupIntervalMs: config.rateLimitCleanupIntervalMs,
|
||||||
|
store,
|
||||||
keyGenerator: (req) => `chat:ip:${req.ip}`,
|
keyGenerator: (req) => `chat:ip:${req.ip}`,
|
||||||
message: "Too many chat requests from this IP. Please slow down."
|
message: "Too many chat requests from this IP. Please slow down."
|
||||||
}),
|
}),
|
||||||
@@ -35,6 +41,7 @@ export function createChatRateLimiters(config) {
|
|||||||
windowMs: config.chatRateLimitWindowMs,
|
windowMs: config.chatRateLimitWindowMs,
|
||||||
max: config.chatRateLimitMaxPerAccount,
|
max: config.chatRateLimitMaxPerAccount,
|
||||||
cleanupIntervalMs: config.rateLimitCleanupIntervalMs,
|
cleanupIntervalMs: config.rateLimitCleanupIntervalMs,
|
||||||
|
store,
|
||||||
keyGenerator: (req) => req.session.user ? `chat:user:${req.session.user}` : null,
|
keyGenerator: (req) => req.session.user ? `chat:user:${req.session.user}` : null,
|
||||||
message: "Too many chat requests for this account. Please slow down."
|
message: "Too many chat requests for this account. Please slow down."
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import session from "express-session";
|
||||||
|
|
||||||
|
// Small shared session store backed by the same SQLite database as the app.
|
||||||
|
// Expired rows are pruned on access/write, so no process-local timer is needed.
|
||||||
|
export function createSqliteSessionStore(db) {
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
sid TEXT PRIMARY KEY,
|
||||||
|
sess TEXT NOT NULL,
|
||||||
|
expires_at INTEGER
|
||||||
|
); CREATE INDEX IF NOT EXISTS sessions_expiry ON sessions(expires_at);`);
|
||||||
|
const prune = db.prepare("DELETE FROM sessions WHERE expires_at IS NOT NULL AND expires_at <= ?");
|
||||||
|
const get = db.prepare("SELECT sess, expires_at FROM sessions WHERE sid = ?");
|
||||||
|
const put = db.prepare("INSERT INTO sessions(sid, sess, expires_at) VALUES(?, ?, ?) ON CONFLICT(sid) DO UPDATE SET sess=excluded.sess, expires_at=excluded.expires_at");
|
||||||
|
const remove = db.prepare("DELETE FROM sessions WHERE sid = ?");
|
||||||
|
const store = new session.Store();
|
||||||
|
store.get = (sid, callback) => {
|
||||||
|
try {
|
||||||
|
const now = Date.now();
|
||||||
|
prune.run(now);
|
||||||
|
const row = get.get(sid);
|
||||||
|
if (!row) return callback(null, null);
|
||||||
|
if (row.expires_at != null && row.expires_at <= now) {
|
||||||
|
remove.run(sid);
|
||||||
|
return callback(null, null);
|
||||||
|
}
|
||||||
|
return callback(null, JSON.parse(row.sess));
|
||||||
|
} catch (error) { return callback(error); }
|
||||||
|
};
|
||||||
|
store.set = (sid, sess, callback) => {
|
||||||
|
try {
|
||||||
|
const expiresAt = sess.cookie?.expires ? new Date(sess.cookie.expires).getTime() : null;
|
||||||
|
prune.run(Date.now());
|
||||||
|
put.run(sid, JSON.stringify(sess), Number.isFinite(expiresAt) ? expiresAt : null);
|
||||||
|
return callback?.(null);
|
||||||
|
} catch (error) { return callback?.(error); }
|
||||||
|
};
|
||||||
|
store.destroy = (sid, callback) => {
|
||||||
|
try { remove.run(sid); return callback?.(null); } catch (error) { return callback?.(error); }
|
||||||
|
};
|
||||||
|
store.touch = (sid, sess, callback) => {
|
||||||
|
try {
|
||||||
|
const expiresAt = sess.cookie?.expires ? new Date(sess.cookie.expires).getTime() : null;
|
||||||
|
put.run(sid, JSON.stringify(sess), Number.isFinite(expiresAt) ? expiresAt : null);
|
||||||
|
return callback?.(null);
|
||||||
|
} catch (error) { return callback?.(error); }
|
||||||
|
};
|
||||||
|
return store;
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Prüft Anfrageinhalte mit einem übergebenen Validator und stellt die validierten Daten für Routen bereit.
|
||||||
|
*/
|
||||||
export function validateBody(validator) {
|
export function validateBody(validator) {
|
||||||
return (req, res, next) => {
|
return (req, res, next) => {
|
||||||
const result = validator(req.body || {}, req);
|
const result = validator(req.body || {}, req);
|
||||||
|
|||||||
+5
-2
@@ -1,11 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* @file Erzeugt den gemeinsamen HTML-Seitenkopf mit Navigation und Kontosteuerung.
|
||||||
|
*/
|
||||||
const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (character) => ({
|
const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (character) => ({
|
||||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
||||||
})[character]);
|
})[character]);
|
||||||
|
|
||||||
export function renderSiteHeader({user, pathname}) {
|
export function renderSiteHeader({user, pathname}) {
|
||||||
const destinations = user
|
const destinations = user
|
||||||
? [["/app", "Home"], ["/app/chats", "Chats"], ["/app/personas", "My personas"], ["/market", "Discover"], ["/app/settings", "Settings"]]
|
? [["/app", "Home"], ["/app/chats", "Chats"], ["/app/personas", "Personalities"], ["/app/settings", "Settings"]]
|
||||||
: [["/market", "Discover"]];
|
: [];
|
||||||
const navigation = destinations.map(([href, label]) => {
|
const navigation = destinations.map(([href, label]) => {
|
||||||
const active = pathname === href || (href === "/app/chats" && pathname.startsWith("/app/chats/"));
|
const active = pathname === href || (href === "/app/chats" && pathname.startsWith("/app/chats/"));
|
||||||
return `<a href="${href}"${active ? ' aria-current="page"' : ""}>${label}</a>`;
|
return `<a href="${href}"${active ? ' aria-current="page"' : ""}>${label}</a>`;
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import express from "express";
|
||||||
|
import {requireLogin, requireAdmin} from "../middleware/auth.js";
|
||||||
|
|
||||||
|
export function createAdminMarketRouter(repositories) {
|
||||||
|
const router = express.Router();
|
||||||
|
router.get("/admin/reports", requireLogin, requireAdmin, (_req, res) => res.json({
|
||||||
|
reports: repositories.listMarketReports(),
|
||||||
|
personas: repositories.listMarketPersonas({admin: true, username: _req.session.user})
|
||||||
|
}));
|
||||||
|
router.post("/admin/reports/:id", requireLogin, requireAdmin, (req, res) => {
|
||||||
|
repositories.updateMarketReportStatus(Number(req.params.id), String(req.body?.status || "reviewed").trim() || "reviewed");
|
||||||
|
return res.json({message: "Report updated"});
|
||||||
|
});
|
||||||
|
router.post("/admin/personas/market/:id/moderate", requireLogin, requireAdmin, (req, res) => {
|
||||||
|
const status = String(req.body?.status || "approved").trim() || "approved";
|
||||||
|
repositories.updateMarketModeration(Number(req.params.id), {
|
||||||
|
status,
|
||||||
|
notes: req.body?.notes == null ? null : String(req.body.notes).trim() || null,
|
||||||
|
softDelete: Boolean(req.body?.softDelete)
|
||||||
|
});
|
||||||
|
return res.json({persona: repositories.getMarketPersona(Number(req.params.id))});
|
||||||
|
});
|
||||||
|
return router;
|
||||||
|
}
|
||||||
+46
-20
@@ -1,50 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* @file Verarbeitet Registrierung, Anmeldung, Abmeldung und die Abfrage der aktuellen Sitzung.
|
||||||
|
*/
|
||||||
import express from "express";
|
import express from "express";
|
||||||
|
import {asyncHandler} from "../middleware/asyncHandler.js";
|
||||||
import bcrypt from "bcrypt";
|
import bcrypt from "bcrypt";
|
||||||
import {validateBody} from "../middleware/validate.js";
|
import {validateBody} from "../middleware/validate.js";
|
||||||
|
import {newPasswordError} from "../services/passwordPolicy.js";
|
||||||
|
import {normalizeUsername, usernameError} from "../services/usernamePolicy.js";
|
||||||
|
|
||||||
const credentialsValidator = (body) => {
|
const credentialsValidator = (body) => {
|
||||||
const username = String(body?.username || "").trim();
|
const username = normalizeUsername(body?.username);
|
||||||
const password = String(body?.password || "");
|
const password = String(body?.password || "");
|
||||||
const inviteCode = String(body?.inviteCode || "").trim();
|
const inviteCode = String(body?.inviteCode || "").trim();
|
||||||
if (!username) return {error: "Username is required"};
|
const usernameValidationError = usernameError(username);
|
||||||
if (username.length < 3) return {error: "Username must be at least 3 characters"};
|
if (usernameValidationError) return {error: usernameValidationError};
|
||||||
if (!password) return {error: "Password is required"};
|
if (!password) return {error: "Password is required"};
|
||||||
if (password.length < 6) return {error: "Password must be at least 6 characters"};
|
if (password.length < 6) return {error: "Password must be at least 6 characters"};
|
||||||
return {value: {username, password, inviteCode}};
|
return {value: {username, password, inviteCode}};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DUMMY_PASSWORD_HASH = "$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy";
|
||||||
|
|
||||||
|
const regenerateSession = (req, fields) => new Promise((resolve, reject) => {
|
||||||
|
req.session.regenerate(error => {
|
||||||
|
if (error) return reject(error);
|
||||||
|
Object.assign(req.session, fields);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
export function createAuthRouter({repositories, authRateLimiters, config}) {
|
export function createAuthRouter({repositories, authRateLimiters, config}) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.post("/register", ...authRateLimiters, validateBody(credentialsValidator), async (req, res) => {
|
router.post("/register", ...authRateLimiters, validateBody(credentialsValidator), asyncHandler(async (req, res) => {
|
||||||
const {username, password, inviteCode} = req.validatedBody;
|
const {username, password, inviteCode} = req.validatedBody;
|
||||||
|
const passwordError = newPasswordError(password);
|
||||||
|
if (passwordError) return res.status(400).json({error: passwordError, code: "PASSWORD_TOO_LONG"});
|
||||||
if (config.registrationInviteCode && inviteCode !== config.registrationInviteCode) {
|
if (config.registrationInviteCode && inviteCode !== config.registrationInviteCode) {
|
||||||
return res.status(403).json({error: "Valid invite code is required"});
|
return res.status(403).json({error: "Valid invite code is required"});
|
||||||
}
|
}
|
||||||
if (repositories.getUser(username)) {
|
if (repositories.getUserCaseInsensitive(username)) {
|
||||||
return res.status(400).json({error: "User already exists"});
|
return res.status(409).json({error: "User already exists", code: "USERNAME_TAKEN"});
|
||||||
}
|
}
|
||||||
|
|
||||||
const hashed = await bcrypt.hash(password, 10);
|
const hashed = await bcrypt.hash(password, 10);
|
||||||
repositories.insertUser(username, hashed);
|
try {
|
||||||
req.session.user = username;
|
repositories.insertUser(username, hashed);
|
||||||
req.session.sessionVersion = 0;
|
} catch (error) {
|
||||||
|
// The precheck can race while bcrypt yields; the database is authoritative.
|
||||||
|
if (["SQLITE_CONSTRAINT_PRIMARYKEY", "SQLITE_CONSTRAINT_UNIQUE"].includes(error.code)) {
|
||||||
|
return res.status(409).json({error: "User already exists", code: "USERNAME_TAKEN"});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
await regenerateSession(req, {user: username, sessionVersion: 0});
|
||||||
return res.json({message: "Registration successful"});
|
return res.json({message: "Registration successful"});
|
||||||
});
|
}));
|
||||||
|
|
||||||
router.post("/login", ...authRateLimiters, validateBody(credentialsValidator), async (req, res) => {
|
router.post("/login", ...authRateLimiters, validateBody(credentialsValidator), asyncHandler(async (req, res) => {
|
||||||
const {username, password} = req.validatedBody;
|
const {username, password} = req.validatedBody;
|
||||||
const user = repositories.getUser(username);
|
const user = repositories.getUserCaseInsensitive(username);
|
||||||
if (!user) return res.status(400).json({error: "User not found"});
|
const valid = await bcrypt.compare(password, user?.password || DUMMY_PASSWORD_HASH);
|
||||||
const valid = await bcrypt.compare(password, user.password);
|
if (!user || !valid) {
|
||||||
if (!valid) return res.status(403).json({error: "Invalid password"});
|
// Keep the message generic and still perform bcrypt work for unknown users.
|
||||||
req.session.user = username;
|
return res.status(user ? 403 : 400).json({error: "Invalid username or password"});
|
||||||
req.session.sessionVersion = Number(user.session_version || 0);
|
}
|
||||||
|
await regenerateSession(req, {user: user.username, sessionVersion: Number(user.session_version || 0)});
|
||||||
return res.json({message: "Login successful"});
|
return res.json({message: "Login successful"});
|
||||||
});
|
}));
|
||||||
|
|
||||||
router.post("/logout", (req, res) => {
|
router.post("/logout", (req, res, next) => {
|
||||||
req.session.destroy(() => res.json({message: "Logged out"}));
|
req.session.destroy(error => error ? next(error) : res.json({message: "Logged out"}));
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get("/session", (req, res) => {
|
router.get("/session", (req, res) => {
|
||||||
|
|||||||
+215
-122
@@ -1,14 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* @file Stellt Chat-Routen für Nachrichten, Streaming, Antwortvarianten, Verzweigungen und Einstellungen bereit.
|
||||||
|
*/
|
||||||
import express from "express";
|
import express from "express";
|
||||||
|
import {createTextStreamEvents} from "../services/streamEvents.js";
|
||||||
|
import {asyncHandler} from "../middleware/asyncHandler.js";
|
||||||
|
import {generationCancellation} from "../middleware/generationCancellation.js";
|
||||||
import {requireLogin} from "../middleware/auth.js";
|
import {requireLogin} from "../middleware/auth.js";
|
||||||
import {validateBody} from "../middleware/validate.js";
|
import {validateBody} from "../middleware/validate.js";
|
||||||
import {validateWorkspaceImport} from "../services/workspaceImportService.js";
|
import {getPersonality, getPersonalityMetadata, listPersonalities, DEFAULT_PERSONALITY_ID} from "../services/personalityRegistry.js";
|
||||||
|
|
||||||
const createChatValidator = (body) => ({
|
const createChatValidator = (body) => {
|
||||||
value: {
|
const rawPersonalityId = body?.personalityId;
|
||||||
title: String(body?.title || "New chat").trim() || "New chat",
|
if (rawPersonalityId != null && (typeof rawPersonalityId !== "string" || !rawPersonalityId.trim())) {
|
||||||
assistantPersonaId: body?.assistantPersonaId ? Number(body.assistantPersonaId) : null
|
return {error: "personalityId must be a non-empty string"};
|
||||||
}
|
}
|
||||||
});
|
return {
|
||||||
|
value: {
|
||||||
|
title: String(body?.title || "New chat").trim() || "New chat",
|
||||||
|
personalityId: rawPersonalityId == null ? DEFAULT_PERSONALITY_ID : rawPersonalityId.trim()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const messageValidator = (body) => {
|
const messageValidator = (body) => {
|
||||||
const content = String(body?.content || "").trim();
|
const content = String(body?.content || "").trim();
|
||||||
@@ -16,16 +28,24 @@ const messageValidator = (body) => {
|
|||||||
return {value: {content, retryActiveIndex: body?.retryActiveIndex}};
|
return {value: {content, retryActiveIndex: body?.retryActiveIndex}};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const searchPreferencesValidator = (body) => {
|
||||||
|
if (body?.webSearch !== undefined && typeof body.webSearch !== "boolean") return {error: "webSearch must be a boolean"};
|
||||||
|
if (body?.reasoning !== undefined && typeof body.reasoning !== "boolean") return {error: "reasoning must be a boolean"};
|
||||||
|
return {value: {webSearch: body?.webSearch !== false, reasoning: body?.reasoning === true}};
|
||||||
|
};
|
||||||
|
|
||||||
const sendMessageValidator = (body) => {
|
const sendMessageValidator = (body) => {
|
||||||
const attachments = body?.attachments ?? [];
|
const attachments = body?.attachments ?? [];
|
||||||
if (!Array.isArray(attachments) || attachments.some(id => typeof id !== "string" || !/^[a-f0-9-]{36}$/.test(id))) return {error: "Invalid attachment IDs"};
|
if (!Array.isArray(attachments) || attachments.some(id => typeof id !== "string" || !/^[a-f0-9-]{36}$/.test(id))) return {error: "Invalid attachment IDs"};
|
||||||
const message = String(body?.message || "").trim() || (attachments.length ? "Describe the attached images." : "");
|
const message = String(body?.message || "").trim() || (attachments.length ? "Describe the attached images." : "");
|
||||||
const chatId = Number(body?.chatId);
|
const chatId = Number(body?.chatId);
|
||||||
|
const userMessageId = body?.userMessageId == null ? null : Number(body.userMessageId);
|
||||||
|
if (userMessageId != null && (!Number.isInteger(userMessageId) || userMessageId < 1)) return {error: "Invalid message ID"};
|
||||||
if (!chatId) return {error: "chatId is required"};
|
if (!chatId) return {error: "chatId is required"};
|
||||||
if (!message) return {error: "message is required"};
|
if (!message) return {error: "message is required"};
|
||||||
if (body.webSearch !== undefined && typeof body.webSearch !== "boolean") return {error: "webSearch must be a boolean"};
|
const preferences = searchPreferencesValidator(body);
|
||||||
if (body.reasoning !== undefined && typeof body.reasoning !== "boolean") return {error: "reasoning must be a boolean"};
|
if (preferences.error) return preferences;
|
||||||
return {value: {message, chatId, attachments, model: String(body?.model || "qwen3:14b"), webSearch: body.webSearch === true, reasoning: body.reasoning === true}};
|
return {value: {message, chatId, attachments, userMessageId, model: String(body?.model || "qwen3:14b"), ...preferences.value}};
|
||||||
};
|
};
|
||||||
|
|
||||||
const organizationValidator = (body) => ({
|
const organizationValidator = (body) => ({
|
||||||
@@ -102,6 +122,13 @@ const preferredModelValidator = (body) => {
|
|||||||
return {value: {model}};
|
return {value: {model}};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const userPersonaValidator = (body) => {
|
||||||
|
if (body?.userPersonaId == null || body.userPersonaId === "") return {value: {userPersonaId: null}};
|
||||||
|
const userPersonaId = Number(body.userPersonaId);
|
||||||
|
if (!Number.isInteger(userPersonaId) || userPersonaId < 1) return {error: "Invalid user persona"};
|
||||||
|
return {value: {userPersonaId}};
|
||||||
|
};
|
||||||
|
|
||||||
export function createChatsRouter({repositories, chatService, modelService, config, chatRateLimiters}) {
|
export function createChatsRouter({repositories, chatService, modelService, config, chatRateLimiters}) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
router.use(requireLogin);
|
router.use(requireLogin);
|
||||||
@@ -114,44 +141,49 @@ export function createChatsRouter({repositories, chatService, modelService, conf
|
|||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get("/models", async (req, res) => {
|
router.get("/models", asyncHandler(async (req, res) => {
|
||||||
try {
|
try {
|
||||||
return res.json(await modelService.listModels());
|
return res.json(await modelService.listModels());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const mapped = modelService.mapError(error);
|
const mapped = modelService.mapError(error);
|
||||||
return res.status(mapped.status).json(mapped.body);
|
return res.status(mapped.status).json(mapped.body);
|
||||||
}
|
}
|
||||||
});
|
}));
|
||||||
|
|
||||||
router.get("/chats", (req, res) => res.json({chats: repositories.listChats(req.session.user)}));
|
router.get("/chats", (req, res) => res.json({chats: repositories.listChats(req.session.user)}));
|
||||||
|
router.get("/personalities", (_req, res) => res.json({personalities: listPersonalities(), defaultPersonalityId: DEFAULT_PERSONALITY_ID}));
|
||||||
|
router.get("/personalities/:id", (req, res) => {
|
||||||
|
const personality = getPersonalityMetadata(req.params.id);
|
||||||
|
return personality ? res.json({personality}) : res.status(404).json({error: "Personality not found"});
|
||||||
|
});
|
||||||
router.get("/chats/search", (req, res) => res.json({chats: repositories.searchChats(req.session.user, req.query.q || "")}));
|
router.get("/chats/search", (req, res) => res.json({chats: repositories.searchChats(req.session.user, req.query.q || "")}));
|
||||||
|
|
||||||
router.post("/chats", validateBody(createChatValidator), (req, res) => {
|
router.post("/chats", validateBody(createChatValidator), (req, res) => {
|
||||||
const user = req.session.user;
|
const user = req.session.user;
|
||||||
const {assistantPersonaId, title} = req.validatedBody;
|
const {personalityId, title} = req.validatedBody;
|
||||||
|
if (!getPersonality(personalityId)) return res.status(400).json({error: "Unknown personality"});
|
||||||
if (assistantPersonaId) {
|
|
||||||
const persona = repositories.getPersona(assistantPersonaId, user);
|
|
||||||
if (!persona || persona.persona_type !== "assistant") {
|
|
||||||
return res.status(404).json({error: "AI Character not found"});
|
|
||||||
}
|
|
||||||
const existingChat = repositories.getChatByParticipants(user, assistantPersonaId, null);
|
|
||||||
try {
|
|
||||||
const chat = chatService.getOrCreatePersonaChat(user, persona, null);
|
|
||||||
return res.json({chat, existing: Boolean(existingChat)});
|
|
||||||
} catch (error) {
|
|
||||||
if (error.message === "CHAT_LIMIT_REACHED") {
|
|
||||||
return res.status(400).json({error: `Maximum of ${config.chatLimit} chats reached. Please delete an old chat to create a new one.`});
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (repositories.countChats(user) >= config.chatLimit) {
|
if (repositories.countChats(user) >= config.chatLimit) {
|
||||||
return res.status(400).json({error: `Maximum of ${config.chatLimit} chats reached. Please delete an old chat to create a new one.`});
|
return res.status(400).json({error: `Maximum of ${config.chatLimit} active chats reached. Archive or delete an active chat to create a new one.`});
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json({chat: repositories.createChat(user, title), existing: false});
|
const chat = repositories.createChat(user, title);
|
||||||
|
repositories.updateChatPersonality(chat.id, user, personalityId);
|
||||||
|
return res.json({chat: repositories.getChat(chat.id, user), existing: false});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put("/chats/:id/personality", (req, res) => {
|
||||||
|
const chatId = Number(req.params.id);
|
||||||
|
const chat = repositories.getChat(chatId, req.session.user);
|
||||||
|
if (!chat) return res.status(404).json({error: "Chat not found"});
|
||||||
|
const personalityId = req.body?.personalityId;
|
||||||
|
if (typeof personalityId !== "string" || !personalityId.trim()) {
|
||||||
|
return res.status(400).json({error: "personalityId must be a non-empty string"});
|
||||||
|
}
|
||||||
|
const normalizedPersonalityId = personalityId.trim();
|
||||||
|
if (!getPersonality(normalizedPersonalityId)) return res.status(400).json({error: "Unknown personality"});
|
||||||
|
repositories.updateChatPersonality(chatId, req.session.user, normalizedPersonalityId);
|
||||||
|
return res.json({chat: repositories.getChat(chatId, req.session.user), personality: getPersonalityMetadata(normalizedPersonalityId)});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put("/chats/:id", validateBody((body) => {
|
router.put("/chats/:id", validateBody((body) => {
|
||||||
@@ -168,6 +200,9 @@ export function createChatsRouter({repositories, chatService, modelService, conf
|
|||||||
const chatId = Number(req.params.id);
|
const chatId = Number(req.params.id);
|
||||||
const chat = repositories.getChat(chatId, req.session.user);
|
const chat = repositories.getChat(chatId, req.session.user);
|
||||||
if (!chat) return res.status(404).json({error: "Chat not found"});
|
if (!chat) return res.status(404).json({error: "Chat not found"});
|
||||||
|
if (chat.archived_at && !req.validatedBody.archived && repositories.countChats(req.session.user) >= config.chatLimit) {
|
||||||
|
return res.status(400).json({error: `Maximum of ${config.chatLimit} active chats reached. Archive another chat before restoring this one.`});
|
||||||
|
}
|
||||||
repositories.updateChatOrganization(chatId, req.session.user, {
|
repositories.updateChatOrganization(chatId, req.session.user, {
|
||||||
folderName: req.validatedBody.folderName,
|
folderName: req.validatedBody.folderName,
|
||||||
isPinned: req.validatedBody.isPinned,
|
isPinned: req.validatedBody.isPinned,
|
||||||
@@ -182,14 +217,61 @@ export function createChatsRouter({repositories, chatService, modelService, conf
|
|||||||
return res.json({message: "Chat deleted"});
|
return res.json({message: "Chat deleted"});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get("/chats/:id/messages/search", (req, res) => {
|
||||||
|
const chatId = Number(req.params.id);
|
||||||
|
if (!repositories.getChat(chatId, req.session.user)) return res.status(404).json({error: "Chat not found"});
|
||||||
|
const query = String(req.query.q || "").trim().slice(0, 200).toLocaleLowerCase();
|
||||||
|
if (!query) return res.json({matches: []});
|
||||||
|
const matches = [];
|
||||||
|
for (const message of repositories.listActiveBranchMessages(chatId)) {
|
||||||
|
const content = String(message.content || "").toLocaleLowerCase();
|
||||||
|
let at = content.indexOf(query);
|
||||||
|
let occurrence = 0;
|
||||||
|
while (at >= 0) {
|
||||||
|
matches.push({messageId: message.id, occurrence: occurrence++});
|
||||||
|
at = content.indexOf(query, at + query.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res.json({matches});
|
||||||
|
});
|
||||||
|
|
||||||
router.get("/chats/:id/messages", (req, res) => {
|
router.get("/chats/:id/messages", (req, res) => {
|
||||||
const chatId = Number(req.params.id);
|
const chatId = Number(req.params.id);
|
||||||
const chat = repositories.getChat(chatId, req.session.user);
|
const chat = repositories.getChat(chatId, req.session.user);
|
||||||
if (!chat) return res.status(404).json({error: "Chat not found"});
|
if (!chat) return res.status(404).json({error: "Chat not found"});
|
||||||
return res.json({
|
const options = {};
|
||||||
messages: chatService.listActiveBranchMessages(chatId),
|
for (const key of ["limit", "before", "leaf", "around"]) {
|
||||||
chat
|
if (req.query[key] === undefined) continue;
|
||||||
});
|
const value = Number(req.query[key]);
|
||||||
|
if (!Number.isSafeInteger(value) || value < 1 || (key === "limit" && value > 100)) {
|
||||||
|
return res.status(400).json({error: `Invalid history ${key}`});
|
||||||
|
}
|
||||||
|
options[key] = value;
|
||||||
|
}
|
||||||
|
const result = chatService.getHistoryPage(chatId, options);
|
||||||
|
if (result.error) return res.status(result.status).json(result);
|
||||||
|
return res.json({...result, chat});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/chats/:id/branches", (req, res) => {
|
||||||
|
const chatId = Number(req.params.id);
|
||||||
|
const chat = repositories.getChat(chatId, req.session.user);
|
||||||
|
if (!chat) return res.status(404).json({error: "Chat not found"});
|
||||||
|
const after = Number(req.query.after || 0);
|
||||||
|
if (!Number.isSafeInteger(after) || after < 0) return res.status(400).json({error: "Invalid branch cursor"});
|
||||||
|
const nodes = repositories.listBranchNodes(chatId, {after});
|
||||||
|
const hasMore = nodes.length > 200;
|
||||||
|
if (hasMore) nodes.pop();
|
||||||
|
return res.json({nodes, activeLeafId: chat.active_leaf_message_id || nodes.at(-1)?.id || null,
|
||||||
|
next: hasMore ? nodes.at(-1).id : null});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/chats/:id/messages/:messageId/versions", (req, res) => {
|
||||||
|
const chatId = Number(req.params.id);
|
||||||
|
if (!repositories.getChat(chatId, req.session.user)) return res.status(404).json({error: "Chat not found"});
|
||||||
|
const message = repositories.getChatMessage(chatId, Number(req.params.messageId));
|
||||||
|
if (!message) return res.status(404).json({error: "Message not found"});
|
||||||
|
return res.json({message: chatService.formatChatMessage(message)});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put("/chats/:id/generation-settings", validateBody(generationSettingsValidator), (req, res) => {
|
router.put("/chats/:id/generation-settings", validateBody(generationSettingsValidator), (req, res) => {
|
||||||
@@ -206,6 +288,32 @@ export function createChatsRouter({repositories, chatService, modelService, conf
|
|||||||
return res.json({chat: repositories.getChat(chatId, req.session.user)});
|
return res.json({chat: repositories.getChat(chatId, req.session.user)});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.put("/chats/:id/user-persona", validateBody(userPersonaValidator), (req, res) => {
|
||||||
|
const chatId = Number(req.params.id);
|
||||||
|
const user = req.session.user;
|
||||||
|
const chat = repositories.getChat(chatId, user);
|
||||||
|
if (!chat) return res.status(404).json({error: "Chat not found"});
|
||||||
|
const {userPersonaId} = req.validatedBody;
|
||||||
|
if (userPersonaId) {
|
||||||
|
const persona = repositories.getPersona(userPersonaId, user);
|
||||||
|
if (!persona || persona.persona_type !== "user") return res.status(404).json({error: "User persona not found"});
|
||||||
|
}
|
||||||
|
repositories.updateChatUserPersona(chatId, user, userPersonaId);
|
||||||
|
if (chat.assistant_persona_id) {
|
||||||
|
const assistantPersona = repositories.getPersona(chat.assistant_persona_id, user);
|
||||||
|
const userPersona = userPersonaId ? repositories.getPersona(userPersonaId, user) : null;
|
||||||
|
if (assistantPersona) {
|
||||||
|
repositories.updateChatScene(
|
||||||
|
chatId,
|
||||||
|
user,
|
||||||
|
chat.scenario_prompt,
|
||||||
|
chatService.buildRoleplaySceneSummary(assistantPersona, userPersona, chat.scenario_prompt)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res.json({chat: repositories.getChat(chatId, user)});
|
||||||
|
});
|
||||||
|
|
||||||
router.post("/chats/:id/branches/:messageId/activate", (req, res) => {
|
router.post("/chats/:id/branches/:messageId/activate", (req, res) => {
|
||||||
const result = chatService.activateBranch({
|
const result = chatService.activateBranch({
|
||||||
user: req.session.user,
|
user: req.session.user,
|
||||||
@@ -234,7 +342,26 @@ export function createChatsRouter({repositories, chatService, modelService, conf
|
|||||||
return res.json(result);
|
return res.json(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/chats/:id/messages/:messageId/edit-resend", validateBody(sendMessageValidator), async (req, res) => {
|
router.post("/chats/:id/title", asyncHandler(async (req, res) => {
|
||||||
|
const message = String(req.body?.message || "").trim();
|
||||||
|
if (!message) return res.status(400).json({error: "Message is required"});
|
||||||
|
try {
|
||||||
|
const result = await chatService.generateInitialChatTitle({
|
||||||
|
user: req.session.user,
|
||||||
|
chatId: Number(req.params.id),
|
||||||
|
message,
|
||||||
|
model: String(req.body?.model || "qwen3:14b"),
|
||||||
|
signal: req.generationSignal
|
||||||
|
});
|
||||||
|
if (result.error) return res.status(result.status).json({error: result.error});
|
||||||
|
return res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
const mapped = modelService.mapError(error);
|
||||||
|
return res.status(mapped.status).json(mapped.body);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post("/chats/:id/messages/:messageId/edit-resend", ...chatRateLimiters, generationCancellation, validateBody(sendMessageValidator), asyncHandler(async (req, res) => {
|
||||||
if (req.body?.confirmed !== true) return res.status(400).json({error: "Confirmation is required before creating an edited branch"});
|
if (req.body?.confirmed !== true) return res.status(400).json({error: "Confirmation is required before creating an edited branch"});
|
||||||
const chatId = Number(req.params.id);
|
const chatId = Number(req.params.id);
|
||||||
const targetMessage = repositories.getChatMessage(chatId, Number(req.params.messageId));
|
const targetMessage = repositories.getChatMessage(chatId, Number(req.params.messageId));
|
||||||
@@ -242,6 +369,7 @@ export function createChatsRouter({repositories, chatService, modelService, conf
|
|||||||
if (!targetMessage) return res.status(404).json({error: "Message not found"});
|
if (!targetMessage) return res.status(404).json({error: "Message not found"});
|
||||||
try {
|
try {
|
||||||
const result = await chatService.editAndResendMessage({
|
const result = await chatService.editAndResendMessage({
|
||||||
|
signal: req.generationSignal,
|
||||||
user: req.session.user,
|
user: req.session.user,
|
||||||
requestId: req.id,
|
requestId: req.id,
|
||||||
chatId,
|
chatId,
|
||||||
@@ -257,7 +385,7 @@ export function createChatsRouter({repositories, chatService, modelService, conf
|
|||||||
const mapped = modelService.mapError(error);
|
const mapped = modelService.mapError(error);
|
||||||
return res.status(mapped.status).json(mapped.body);
|
return res.status(mapped.status).json(mapped.body);
|
||||||
}
|
}
|
||||||
});
|
}));
|
||||||
|
|
||||||
router.put("/chats/:id/messages/:messageId", validateBody(messageValidator), (req, res) => {
|
router.put("/chats/:id/messages/:messageId", validateBody(messageValidator), (req, res) => {
|
||||||
const chatId = Number(req.params.id);
|
const chatId = Number(req.params.id);
|
||||||
@@ -374,7 +502,30 @@ export function createChatsRouter({repositories, chatService, modelService, conf
|
|||||||
const chatId = Number(req.params.id);
|
const chatId = Number(req.params.id);
|
||||||
const chat = repositories.getChat(chatId, req.session.user);
|
const chat = repositories.getChat(chatId, req.session.user);
|
||||||
if (!chat) return res.status(404).json({error: "Chat not found"});
|
if (!chat) return res.status(404).json({error: "Chat not found"});
|
||||||
return res.json({memories: repositories.listChatMemories(chatId, req.session.user)});
|
const payload = chatService.buildMessagesPayload({user: req.session.user, chatId, session: chat});
|
||||||
|
const characters = payload.messagesPayload.reduce((total, message) => total + String(message.content || "").length, 0);
|
||||||
|
const summaryMessageId = Number(chat.context_summary_message_id || 0);
|
||||||
|
const summarySources = summaryMessageId ? chatService.listActiveBranchMessages(chatId)
|
||||||
|
.filter(message => message.id <= summaryMessageId && message.deliveryStatus === "complete")
|
||||||
|
.map(({id, role, content}) => ({id, role, content})) : [];
|
||||||
|
return res.json({memories: repositories.listChatMemories(chatId, req.session.user), summary: chat.context_summary || "",
|
||||||
|
summaryMessageId, summarySources, summaryMaxChars: config.chatSummaryMaxChars,
|
||||||
|
context: {estimatedTokens: Math.ceil(characters / 4), contextLength: chat.context_length || null,
|
||||||
|
recentMessageLimit: config.chatHistoryLimit, memoryFactLimit: 30}});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put("/chats/:id/summary", (req, res) => {
|
||||||
|
const chatId = Number(req.params.id);
|
||||||
|
const chat = repositories.getChat(chatId, req.session.user);
|
||||||
|
if (!chat) return res.status(404).json({error: "Chat not found"});
|
||||||
|
if (chatService.isGenerating(chatId)) return res.status(409).json({error: "Wait for the response to finish before editing its summary."});
|
||||||
|
const summary = req.body?.summary;
|
||||||
|
if (typeof summary !== "string" || summary.length > config.chatSummaryMaxChars) {
|
||||||
|
return res.status(400).json({error: `Summary must be text of at most ${config.chatSummaryMaxChars} characters.`});
|
||||||
|
}
|
||||||
|
if (req.body.previousSummary !== (chat.context_summary || "")) return res.status(409).json({error: "The summary changed. Reopen memory to review it before saving."});
|
||||||
|
repositories.updateChatContextSummary(chatId, req.session.user, summary.trim() || null, chat.context_summary_message_id);
|
||||||
|
return res.json({summary: summary.trim()});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/chats/:id/memories", validateBody(memoryValidator), (req, res) => {
|
router.post("/chats/:id/memories", validateBody(memoryValidator), (req, res) => {
|
||||||
@@ -418,32 +569,29 @@ export function createChatsRouter({repositories, chatService, modelService, conf
|
|||||||
return res.json({state});
|
return res.json({state});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/chat", ...chatRateLimiters, validateBody(sendMessageValidator), async (req, res) => {
|
router.post("/chat", ...chatRateLimiters, generationCancellation, validateBody(sendMessageValidator), asyncHandler(async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const result = await chatService.sendChatMessage({
|
const result = await chatService.sendChatMessage({
|
||||||
|
signal: req.generationSignal,
|
||||||
user: req.session.user,
|
user: req.session.user,
|
||||||
requestId: req.id,
|
requestId: req.id,
|
||||||
chatId: req.validatedBody.chatId,
|
chatId: req.validatedBody.chatId,
|
||||||
message: req.validatedBody.message,
|
message: req.validatedBody.message,
|
||||||
attachments: req.validatedBody.attachments,
|
attachments: req.validatedBody.attachments,
|
||||||
|
userMessageId: req.validatedBody.userMessageId,
|
||||||
model: req.validatedBody.model,
|
model: req.validatedBody.model,
|
||||||
webSearch: req.validatedBody.webSearch,
|
webSearch: req.validatedBody.webSearch,
|
||||||
reasoning: req.validatedBody.reasoning
|
reasoning: req.validatedBody.reasoning
|
||||||
});
|
});
|
||||||
if (result.error) return res.status(result.status).json({error: result.error, ...(result.searchState ? {searchState: result.searchState} : {})});
|
if (result.error) return res.status(result.status).json({error: result.error, ...(result.searchState ? {searchState: result.searchState} : {})});
|
||||||
return res.json({reply: result.reply, searchState: result.searchState, ...(result.sources ? {sources: result.sources} : {})});
|
return res.json({reply: result.reply, searchState: result.searchState, ...(result.sources ? {sources: result.sources} : {}), ...(result.chatTitle ? {chatTitle: result.chatTitle} : {})});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const mapped = modelService.mapError(error);
|
const mapped = modelService.mapError(error);
|
||||||
return res.status(mapped.status).json(mapped.body);
|
return res.status(mapped.status).json(mapped.body);
|
||||||
}
|
}
|
||||||
});
|
}));
|
||||||
|
|
||||||
router.post("/chat/stream", ...chatRateLimiters, validateBody(sendMessageValidator), async (req, res) => {
|
router.post("/chat/stream", ...chatRateLimiters, generationCancellation, validateBody(sendMessageValidator), asyncHandler(async (req, res) => {
|
||||||
const generationController = new AbortController();
|
|
||||||
let generationFinished = false;
|
|
||||||
res.once("close", () => {
|
|
||||||
if (!generationFinished) generationController.abort();
|
|
||||||
});
|
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
"Content-Type": "text/event-stream",
|
"Content-Type": "text/event-stream",
|
||||||
"Cache-Control": "no-cache, no-transform",
|
"Cache-Control": "no-cache, no-transform",
|
||||||
@@ -464,27 +612,26 @@ export function createChatsRouter({repositories, chatService, modelService, conf
|
|||||||
chatId: req.validatedBody.chatId,
|
chatId: req.validatedBody.chatId,
|
||||||
message: req.validatedBody.message,
|
message: req.validatedBody.message,
|
||||||
attachments: req.validatedBody.attachments,
|
attachments: req.validatedBody.attachments,
|
||||||
|
userMessageId: req.validatedBody.userMessageId,
|
||||||
model: req.validatedBody.model,
|
model: req.validatedBody.model,
|
||||||
onChunk: (chunk, fullReply) => sendEvent("chunk", {chunk, fullReply}),
|
...createTextStreamEvents(sendEvent),
|
||||||
onThinking: (chunk, fullThinking) => sendEvent("thinking", {chunk, fullThinking}),
|
|
||||||
webSearch: req.validatedBody.webSearch,
|
webSearch: req.validatedBody.webSearch,
|
||||||
reasoning: req.validatedBody.reasoning,
|
reasoning: req.validatedBody.reasoning,
|
||||||
onStatus: (status) => sendEvent("status", status),
|
onStatus: (status) => sendEvent("status", status),
|
||||||
signal: generationController.signal
|
signal: req.generationSignal
|
||||||
});
|
});
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
sendEvent("error", {error: result.error, ...(result.searchState ? {searchState: result.searchState} : {})});
|
sendEvent("error", {error: result.error, status: result.status, requestId: req.id, ...(result.searchState ? {searchState: result.searchState} : {})});
|
||||||
} else {
|
} else {
|
||||||
sendEvent("done", {reply: result.reply, searchState: result.searchState, ...(result.sources ? {sources: result.sources} : {})});
|
sendEvent("done", {reply: result.reply, searchState: result.searchState, ...(result.sources ? {sources: result.sources} : {}), ...(result.chatTitle ? {chatTitle: result.chatTitle} : {})});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const mapped = modelService.mapError(error);
|
const mapped = modelService.mapError(error);
|
||||||
sendEvent("error", mapped.body);
|
sendEvent("error", {...mapped.body, status: mapped.status, requestId: req.id});
|
||||||
} finally {
|
} finally {
|
||||||
generationFinished = true;
|
|
||||||
res.end();
|
res.end();
|
||||||
}
|
}
|
||||||
});
|
}));
|
||||||
|
|
||||||
router.post("/chats/:id/messages/:messageId/versions/:index", (req, res) => {
|
router.post("/chats/:id/messages/:messageId/versions/:index", (req, res) => {
|
||||||
const chatId = Number(req.params.id), messageId = Number(req.params.messageId);
|
const chatId = Number(req.params.id), messageId = Number(req.params.messageId);
|
||||||
@@ -500,7 +647,7 @@ export function createChatsRouter({repositories, chatService, modelService, conf
|
|||||||
res.json({message: chatService.formatChatMessage(repositories.getChatMessage(chatId, messageId))});
|
res.json({message: chatService.formatChatMessage(repositories.getChatMessage(chatId, messageId))});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post(["/chats/:id/messages/:messageId/retry", "/chats/:id/messages/:messageId/retry/stream"], ...chatRateLimiters, async (req, res) => {
|
router.post(["/chats/:id/messages/:messageId/retry", "/chats/:id/messages/:messageId/retry/stream"], ...chatRateLimiters, generationCancellation, validateBody(searchPreferencesValidator), asyncHandler(async (req, res) => {
|
||||||
const chatId = Number(req.params.id);
|
const chatId = Number(req.params.id);
|
||||||
const messageId = Number(req.params.messageId);
|
const messageId = Number(req.params.messageId);
|
||||||
const chat = repositories.getChat(chatId, req.session.user);
|
const chat = repositories.getChat(chatId, req.session.user);
|
||||||
@@ -510,13 +657,10 @@ export function createChatsRouter({repositories, chatService, modelService, conf
|
|||||||
if (targetMessage.role !== "bot") return res.status(400).json({error: "Only assistant messages can be regenerated"});
|
if (targetMessage.role !== "bot") return res.status(400).json({error: "Only assistant messages can be regenerated"});
|
||||||
|
|
||||||
const streaming = req.path.endsWith("/stream");
|
const streaming = req.path.endsWith("/stream");
|
||||||
const controller = new AbortController();
|
|
||||||
let finished = false;
|
|
||||||
const sendEvent = (event, data) => {
|
const sendEvent = (event, data) => {
|
||||||
if (!res.destroyed) res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
if (!res.destroyed) res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||||
};
|
};
|
||||||
if (streaming) {
|
if (streaming) {
|
||||||
res.once("close", () => { if (!finished) controller.abort(); });
|
|
||||||
res.writeHead(200, {"Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform", Connection: "keep-alive", "X-Accel-Buffering": "no"});
|
res.writeHead(200, {"Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform", Connection: "keep-alive", "X-Accel-Buffering": "no"});
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -527,32 +671,30 @@ export function createChatsRouter({repositories, chatService, modelService, conf
|
|||||||
selectedModel: String(req.body?.model || "qwen3:14b"),
|
selectedModel: String(req.body?.model || "qwen3:14b"),
|
||||||
targetMessage,
|
targetMessage,
|
||||||
retryStyle: String(req.body?.style || "").trim() || null,
|
retryStyle: String(req.body?.style || "").trim() || null,
|
||||||
webSearch: req.body?.webSearch === true,
|
webSearch: req.validatedBody.webSearch,
|
||||||
reasoning: req.body?.reasoning === true,
|
reasoning: req.validatedBody.reasoning,
|
||||||
signal: controller.signal,
|
signal: req.generationSignal,
|
||||||
...(streaming ? {
|
...(streaming ? {
|
||||||
onChunk: (chunk, fullReply) => sendEvent("chunk", {chunk, fullReply}),
|
...createTextStreamEvents(sendEvent),
|
||||||
onThinking: (chunk, fullThinking) => sendEvent("thinking", {chunk, fullThinking}),
|
|
||||||
onStatus: status => sendEvent("status", status)
|
onStatus: status => sendEvent("status", status)
|
||||||
} : {})
|
} : {})
|
||||||
});
|
});
|
||||||
if (streaming) {
|
if (streaming) {
|
||||||
sendEvent(result.error ? "error" : "done", result.error ? {error: result.error, searchState: result.searchState} : {...result, reply: result.message.content});
|
sendEvent(result.error ? "error" : "done", result.error ? {error: result.error, status: result.status, requestId: req.id, searchState: result.searchState} : {...result, reply: result.message.content});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (result.error) return res.status(result.status).json({error: result.error});
|
if (result.error) return res.status(result.status).json({error: result.error});
|
||||||
return res.json(result);
|
return res.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const mapped = modelService.mapError(error);
|
const mapped = modelService.mapError(error);
|
||||||
if (streaming) { sendEvent("error", mapped.body); return; }
|
if (streaming) { sendEvent("error", {...mapped.body, status: mapped.status, requestId: req.id}); return; }
|
||||||
return res.status(mapped.status).json(mapped.body);
|
return res.status(mapped.status).json(mapped.body);
|
||||||
} finally {
|
} finally {
|
||||||
finished = true;
|
|
||||||
if (streaming) res.end();
|
if (streaming) res.end();
|
||||||
}
|
}
|
||||||
});
|
}));
|
||||||
|
|
||||||
router.post("/chats/:id/messages/by-index/:index/retry", ...chatRateLimiters, async (req, res) => {
|
router.post("/chats/:id/messages/by-index/:index/retry", ...chatRateLimiters, generationCancellation, validateBody(searchPreferencesValidator), asyncHandler(async (req, res) => {
|
||||||
const chatId = Number(req.params.id);
|
const chatId = Number(req.params.id);
|
||||||
const chat = repositories.getChat(chatId, req.session.user);
|
const chat = repositories.getChat(chatId, req.session.user);
|
||||||
if (!chat) return res.status(404).json({error: "Chat not found"});
|
if (!chat) return res.status(404).json({error: "Chat not found"});
|
||||||
@@ -562,14 +704,15 @@ export function createChatsRouter({repositories, chatService, modelService, conf
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await chatService.retryMessage({
|
const result = await chatService.retryMessage({
|
||||||
|
signal: req.generationSignal,
|
||||||
user: req.session.user,
|
user: req.session.user,
|
||||||
requestId: req.id,
|
requestId: req.id,
|
||||||
chatId,
|
chatId,
|
||||||
selectedModel: String(req.body?.model || "qwen3:14b"),
|
selectedModel: String(req.body?.model || "qwen3:14b"),
|
||||||
targetMessage,
|
targetMessage,
|
||||||
retryStyle: String(req.body?.style || "").trim() || null,
|
retryStyle: String(req.body?.style || "").trim() || null,
|
||||||
webSearch: req.body?.webSearch === true,
|
webSearch: req.validatedBody.webSearch,
|
||||||
reasoning: req.body?.reasoning === true
|
reasoning: req.validatedBody.reasoning
|
||||||
});
|
});
|
||||||
if (result.error) return res.status(result.status).json({error: result.error});
|
if (result.error) return res.status(result.status).json({error: result.error});
|
||||||
return res.json(result);
|
return res.json(result);
|
||||||
@@ -577,57 +720,7 @@ export function createChatsRouter({repositories, chatService, modelService, conf
|
|||||||
const mapped = modelService.mapError(error);
|
const mapped = modelService.mapError(error);
|
||||||
return res.status(mapped.status).json(mapped.body);
|
return res.status(mapped.status).json(mapped.body);
|
||||||
}
|
}
|
||||||
});
|
}));
|
||||||
|
|
||||||
router.get("/exports/chats/:id", (req, res) => {
|
|
||||||
const chatId = Number(req.params.id);
|
|
||||||
const format = String(req.query.format || "txt").toLowerCase();
|
|
||||||
const chat = repositories.getChat(chatId, req.session.user);
|
|
||||||
if (!chat) return res.status(404).json({error: "Chat not found"});
|
|
||||||
const messages = repositories.listChatMessages(chatId).map(chatService.formatChatMessage);
|
|
||||||
if (format === "json") {
|
|
||||||
return res.json({chat, messages});
|
|
||||||
}
|
|
||||||
if (format === "md") {
|
|
||||||
const body = [`# ${chat.title}`, "", ...messages.flatMap((message) => [`## ${message.role === "user" ? "You" : "Assistant"}`, "", message.content, ""])].join("\n");
|
|
||||||
res.type("text/markdown");
|
|
||||||
return res.send(body);
|
|
||||||
}
|
|
||||||
const body = [`Chat: ${chat.title}`, "", ...messages.flatMap((message) => [`${message.role === "user" ? "You" : "Assistant"}:`, message.content, ""])].join("\n");
|
|
||||||
res.type("text/plain");
|
|
||||||
return res.send(body);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get("/exports/workspace", (req, res) => res.json({workspace: repositories.exportWorkspace(req.session.user)}));
|
|
||||||
router.post("/imports/workspace/preview", (req, res) => {
|
|
||||||
const validation = validateWorkspaceImport(req.body?.workspace, repositories.exportWorkspace(req.session.user));
|
|
||||||
return res.status(validation.ok ? 200 : 400).json({
|
|
||||||
ok: validation.ok,
|
|
||||||
errors: validation.errors,
|
|
||||||
warnings: validation.warnings,
|
|
||||||
preview: validation.preview
|
|
||||||
});
|
|
||||||
});
|
|
||||||
router.post("/imports/workspace", (req, res) => {
|
|
||||||
const validation = validateWorkspaceImport(req.body?.workspace, repositories.exportWorkspace(req.session.user));
|
|
||||||
if (!validation.ok) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: "Workspace import validation failed",
|
|
||||||
errors: validation.errors,
|
|
||||||
warnings: validation.warnings,
|
|
||||||
preview: validation.preview
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (validation.preview.duplicates.length && !req.body?.allowDuplicates) {
|
|
||||||
return res.status(409).json({
|
|
||||||
error: "Workspace import contains duplicate records",
|
|
||||||
errors: [],
|
|
||||||
warnings: validation.warnings,
|
|
||||||
preview: validation.preview
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return res.json({imported: repositories.importWorkspace(req.session.user, validation.workspace), preview: validation.preview});
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get("/chat/history", (req, res) => res.json({history: repositories.listChats(req.session.user)}));
|
router.get("/chat/history", (req, res) => res.json({history: repositories.listChats(req.session.user)}));
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Liefert öffentliche Seiten und geschützte Ansichten des Arbeitsbereichs aus.
|
||||||
|
*/
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import {readFile} from "node:fs/promises";
|
import {readFile} from "node:fs/promises";
|
||||||
import {renderSiteHeader} from "../pageLayout.js";
|
import {renderSiteHeader} from "../pageLayout.js";
|
||||||
@@ -20,18 +23,15 @@ export function createPagesRouter(config) {
|
|||||||
if (req.session.user) return res.redirect("/app");
|
if (req.session.user) return res.redirect("/app");
|
||||||
return page("account")(req, res, next);
|
return page("account")(req, res, next);
|
||||||
});
|
});
|
||||||
router.get("/market", (req, res, next) => page(req.session.user ? "market" : "discover")(req, res, next));
|
router.get("/market", (req, res) => res.redirect(req.session.user ? "/app/personas" : "/login"));
|
||||||
router.get("/privacy.html", page("privacy"));
|
router.get("/privacy.html", page("privacy"));
|
||||||
router.get("/tos.html", page("tos"));
|
router.get("/tos.html", page("tos"));
|
||||||
router.get("/api/discover", (req, res) => {
|
|
||||||
const personas = req.app.locals.repositories.listMarketPersonas().map(({id, name, background, creator_username, persona_type}) => ({id, name, description: String(background || "").slice(0, 240), creator_username, persona_type}));
|
|
||||||
res.json({personas});
|
|
||||||
});
|
|
||||||
router.get(["/home.html", "/account.html", "/discover.html", "/landing.html"], (req, res) => res.redirect(({"/home.html": "/app", "/account.html": "/login", "/discover.html": "/market", "/landing.html": "/"})[req.path]));
|
router.get(["/home.html", "/account.html", "/discover.html", "/landing.html"], (req, res) => res.redirect(({"/home.html": "/app", "/account.html": "/login", "/discover.html": "/market", "/landing.html": "/"})[req.path]));
|
||||||
router.get("/settings", (req, res) => res.redirect(`/app/settings${req.url.slice(req.path.length)}`));
|
router.get("/settings", (req, res) => res.redirect(`/app/settings${req.url.slice(req.path.length)}`));
|
||||||
router.get("/app", signedIn, page("home"));
|
router.get("/app", signedIn, page("home"));
|
||||||
router.get(["/app/chats", "/app/chats/:id"], signedIn, page("chat"));
|
router.get(["/app/chats", "/app/chats/:id"], signedIn, page("chat"));
|
||||||
router.get(["/app/settings", "/app/personas"], signedIn, page("settings"));
|
router.get("/app/settings", signedIn, page("settings"));
|
||||||
|
router.get("/app/personas", signedIn, page("personalities"));
|
||||||
router.get("/chat.html", signedIn, (req, res) => res.redirect("/app/chats"));
|
router.get("/chat.html", signedIn, (req, res) => res.redirect("/app/chats"));
|
||||||
router.get("/settings.html", signedIn, (req, res) => res.redirect("/app/settings"));
|
router.get("/settings.html", signedIn, (req, res) => res.redirect("/app/settings"));
|
||||||
router.get("/market.html", (req, res) => res.redirect("/market"));
|
router.get("/market.html", (req, res) => res.redirect("/market"));
|
||||||
|
|||||||
+22
-52
@@ -1,4 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* @file Verwaltet eigene Personas sowie Veröffentlichung, Suche und Interaktionen auf dem Persona-Marktplatz.
|
||||||
|
*/
|
||||||
import express from "express";
|
import express from "express";
|
||||||
|
import {asyncHandler} from "../middleware/asyncHandler.js";
|
||||||
|
import {generationCancellation} from "../middleware/generationCancellation.js";
|
||||||
import {requireLogin} from "../middleware/auth.js";
|
import {requireLogin} from "../middleware/auth.js";
|
||||||
import {validateBody} from "../middleware/validate.js";
|
import {validateBody} from "../middleware/validate.js";
|
||||||
import {normalizeOptionalText, validatePersonaPayload} from "../services/personaService.js";
|
import {normalizeOptionalText, validatePersonaPayload} from "../services/personaService.js";
|
||||||
@@ -43,10 +48,10 @@ const templateValidator = (body) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createPersonasRouter({repositories, chatService, modelService, config}) {
|
export function createPersonasRouter({repositories, chatService, modelService, config, chatRateLimiters}) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
router.use(requireLogin);
|
router.use(requireLogin);
|
||||||
const isAdmin = (req) => req.session.user === "admin";
|
const isAdmin = (req) => req.account?.role === "admin";
|
||||||
const parseMarketPersonaType = (value) => ["assistant", "user"].includes(value) ? value : null;
|
const parseMarketPersonaType = (value) => ["assistant", "user"].includes(value) ? value : null;
|
||||||
|
|
||||||
router.get("/personas", (req, res) => {
|
router.get("/personas", (req, res) => {
|
||||||
@@ -118,7 +123,7 @@ export function createPersonasRouter({repositories, chatService, modelService, c
|
|||||||
return res.json({chat: chatService.getOrCreatePersonaChat(user, persona, userPersonaId, null, null)});
|
return res.json({chat: chatService.getOrCreatePersonaChat(user, persona, userPersonaId, null, null)});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.message === "CHAT_LIMIT_REACHED") {
|
if (error.message === "CHAT_LIMIT_REACHED") {
|
||||||
return res.status(400).json({error: `Maximum of ${config.chatLimit} chats reached. Please delete an old chat to create a new one.`});
|
return res.status(400).json({error: `Maximum of ${config.chatLimit} active chats reached. Archive or delete an active chat to create a new one.`});
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -211,7 +216,7 @@ export function createPersonasRouter({repositories, chatService, modelService, c
|
|||||||
return res.json({message: "Report submitted"});
|
return res.json({message: "Report submitted"});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/personas/market/:id/chat", async (req, res) => {
|
router.post("/personas/market/:id/chat", ...chatRateLimiters, generationCancellation, asyncHandler(async (req, res) => {
|
||||||
const user = req.session.user;
|
const user = req.session.user;
|
||||||
const marketPersona = repositories.getMarketPersona(Number(req.params.id));
|
const marketPersona = repositories.getMarketPersona(Number(req.params.id));
|
||||||
const userPersonaId = req.body?.userPersonaId ? Number(req.body.userPersonaId) : null;
|
const userPersonaId = req.body?.userPersonaId ? Number(req.body.userPersonaId) : null;
|
||||||
@@ -242,45 +247,28 @@ export function createPersonasRouter({repositories, chatService, modelService, c
|
|||||||
chat = chatService.getOrCreatePersonaChat(user, persona, userPersonaId, scenarioPrompt, sceneSummary);
|
chat = chatService.getOrCreatePersonaChat(user, persona, userPersonaId, scenarioPrompt, sceneSummary);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.message === "CHAT_LIMIT_REACHED") {
|
if (error.message === "CHAT_LIMIT_REACHED") {
|
||||||
return res.status(400).json({error: `Maximum of ${config.chatLimit} chats reached. Please delete an old chat to create a new one.`});
|
return res.status(400).json({error: `Maximum of ${config.chatLimit} active chats reached. Archive or delete an active chat to create a new one.`});
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (repositories.listChatMessages(chat.id).length > 0) {
|
|
||||||
return res.json({persona, chat, existing: true, generatedInitialMessage: false});
|
|
||||||
}
|
|
||||||
|
|
||||||
repositories.updateChatScene(chat.id, user, scenarioPrompt, sceneSummary);
|
|
||||||
chat = repositories.getChat(chat.id, user);
|
|
||||||
try {
|
try {
|
||||||
let thinking = "";
|
const result = await chatService.initializeRoleplay({
|
||||||
const opener = await chatService.generateRoleplayOpener({
|
user, chatId: chat.id, signal: req.generationSignal,
|
||||||
onThinking: (_chunk, fullThinking) => { thinking = fullThinking; },
|
|
||||||
selectedModel: "qwen3:14b",
|
selectedModel: "qwen3:14b",
|
||||||
assistantPersona: persona,
|
assistantPersona: persona,
|
||||||
userPersona,
|
userPersona,
|
||||||
scenarioPrompt,
|
scenarioPrompt,
|
||||||
sceneSummary
|
sceneSummary
|
||||||
});
|
});
|
||||||
if (!opener || chatService.containsUserVoiceInRoleplayOpener(opener, userPersona)) {
|
if (result.error) return res.status(result.status).json({error: result.error});
|
||||||
return res.json({persona, chat, existing: false, generatedInitialMessage: false});
|
return res.json({persona, ...result});
|
||||||
}
|
|
||||||
const inserted = chatService.insertChatMessage(chat.id, "bot", opener, {
|
|
||||||
retryVariants: [opener],
|
|
||||||
retryActiveIndex: 0,
|
|
||||||
retryRetriesUsed: 0,
|
|
||||||
retryPromptMessageId: null
|
|
||||||
});
|
|
||||||
if (thinking) repositories.updateChatMessageThinking(chat.id, Number(inserted.lastInsertRowid), thinking);
|
|
||||||
repositories.touchChat(chat.id, user);
|
|
||||||
return res.json({persona, chat: repositories.getChat(chat.id, user), existing: false, generatedInitialMessage: true, opener});
|
|
||||||
} catch {
|
} catch {
|
||||||
return res.json({persona, chat, existing: false, generatedInitialMessage: false, degraded: true});
|
return res.json({persona, chat, existing: false, generatedInitialMessage: false, degraded: true});
|
||||||
}
|
}
|
||||||
});
|
}));
|
||||||
|
|
||||||
router.post("/roleplays/start", validateBody(roleplayStartValidator), async (req, res) => {
|
router.post("/roleplays/start", ...chatRateLimiters, generationCancellation, validateBody(roleplayStartValidator), asyncHandler(async (req, res) => {
|
||||||
const user = req.session.user;
|
const user = req.session.user;
|
||||||
const {assistantPersonaId, userPersonaId, scenarioPrompt, model} = req.validatedBody;
|
const {assistantPersonaId, userPersonaId, scenarioPrompt, model} = req.validatedBody;
|
||||||
const assistantPersona = repositories.getPersona(assistantPersonaId, user);
|
const assistantPersona = repositories.getPersona(assistantPersonaId, user);
|
||||||
@@ -297,40 +285,22 @@ export function createPersonasRouter({repositories, chatService, modelService, c
|
|||||||
chat = chatService.getOrCreatePersonaChat(user, assistantPersona, userPersonaId, scenarioPrompt, sceneSummary);
|
chat = chatService.getOrCreatePersonaChat(user, assistantPersona, userPersonaId, scenarioPrompt, sceneSummary);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.message === "CHAT_LIMIT_REACHED") {
|
if (error.message === "CHAT_LIMIT_REACHED") {
|
||||||
return res.status(400).json({error: `Maximum of ${config.chatLimit} chats reached. Please delete an old chat to create a new one.`});
|
return res.status(400).json({error: `Maximum of ${config.chatLimit} active chats reached. Archive or delete an active chat to create a new one.`});
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (repositories.listChatMessages(chat.id).length > 0) {
|
|
||||||
return res.json({chat, existing: true, generatedInitialMessage: false});
|
|
||||||
}
|
|
||||||
|
|
||||||
repositories.updateChatScene(chat.id, user, scenarioPrompt, sceneSummary);
|
|
||||||
chat = repositories.getChat(chat.id, user);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let thinking = "";
|
const result = await chatService.initializeRoleplay({
|
||||||
const opener = await chatService.generateRoleplayOpener({
|
user, chatId: chat.id, signal: req.generationSignal,
|
||||||
onThinking: (_chunk, fullThinking) => { thinking = fullThinking; },
|
|
||||||
selectedModel: model,
|
selectedModel: model,
|
||||||
assistantPersona,
|
assistantPersona,
|
||||||
userPersona,
|
userPersona,
|
||||||
scenarioPrompt,
|
scenarioPrompt,
|
||||||
sceneSummary
|
sceneSummary
|
||||||
});
|
});
|
||||||
if (!opener || chatService.containsUserVoiceInRoleplayOpener(opener, userPersona)) {
|
if (result.error) return res.status(result.status).json({error: result.error});
|
||||||
return res.json({chat, existing: false, generatedInitialMessage: false, degraded: true});
|
return res.json(result);
|
||||||
}
|
|
||||||
const inserted = chatService.insertChatMessage(chat.id, "bot", opener, {
|
|
||||||
retryVariants: [opener],
|
|
||||||
retryActiveIndex: 0,
|
|
||||||
retryRetriesUsed: 0,
|
|
||||||
retryPromptMessageId: null
|
|
||||||
});
|
|
||||||
if (thinking) repositories.updateChatMessageThinking(chat.id, Number(inserted.lastInsertRowid), thinking);
|
|
||||||
repositories.touchChat(chat.id, user);
|
|
||||||
return res.json({chat: repositories.getChat(chat.id, user), existing: false, generatedInitialMessage: true, opener});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const mapped = modelService.mapError(error);
|
const mapped = modelService.mapError(error);
|
||||||
return res.json({
|
return res.json({
|
||||||
@@ -342,7 +312,7 @@ export function createPersonasRouter({repositories, chatService, modelService, c
|
|||||||
modelErrorCode: mapped.body.code
|
modelErrorCode: mapped.body.code
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
}));
|
||||||
|
|
||||||
router.get("/templates", (req, res) => {
|
router.get("/templates", (req, res) => {
|
||||||
const personaId = req.query.personaId ? Number(req.query.personaId) : null;
|
const personaId = req.query.personaId ? Number(req.query.personaId) : null;
|
||||||
|
|||||||
+37
-17
@@ -1,13 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* @file Verarbeitet Benutzer- und Kontoeinstellungen sowie Export und Import des Arbeitsbereichs.
|
||||||
|
*/
|
||||||
import express from "express";
|
import express from "express";
|
||||||
|
import {asyncHandler} from "../middleware/asyncHandler.js";
|
||||||
import bcrypt from "bcrypt";
|
import bcrypt from "bcrypt";
|
||||||
import {requireLogin} from "../middleware/auth.js";
|
import {requireLogin} from "../middleware/auth.js";
|
||||||
import {validateBody} from "../middleware/validate.js";
|
import {validateBody} from "../middleware/validate.js";
|
||||||
|
import {newPasswordError} from "../services/passwordPolicy.js";
|
||||||
|
import {normalizeUsername, usernameError} from "../services/usernamePolicy.js";
|
||||||
|
|
||||||
const usernameValidator = (body) => {
|
const usernameValidator = (body) => {
|
||||||
const username = String(body?.username || "").trim();
|
const username = normalizeUsername(body?.username);
|
||||||
const password = String(body?.password || "");
|
const password = String(body?.password || "");
|
||||||
if (!username) return {error: "New username is required"};
|
const usernameValidationError = usernameError(username, "New username");
|
||||||
if (username.length < 3) return {error: "Username must be at least 3 characters"};
|
if (usernameValidationError) return {error: usernameValidationError};
|
||||||
if (!password) return {error: "Current password is required"};
|
if (!password) return {error: "Current password is required"};
|
||||||
return {value: {username, password}};
|
return {value: {username, password}};
|
||||||
};
|
};
|
||||||
@@ -26,7 +32,7 @@ const currentPasswordValidator = (body) => {
|
|||||||
return {value: {currentPassword}};
|
return {value: {currentPassword}};
|
||||||
};
|
};
|
||||||
|
|
||||||
const destroySession = (req) => new Promise((resolve) => req.session.destroy(() => resolve()));
|
const destroySession = (req) => new Promise((resolve, reject) => req.session.destroy(error => error ? reject(error) : resolve()));
|
||||||
|
|
||||||
export function createSettingsRouter({repositories}) {
|
export function createSettingsRouter({repositories}) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -35,10 +41,10 @@ export function createSettingsRouter({repositories}) {
|
|||||||
router.get("/settings/profile", (req, res) => {
|
router.get("/settings/profile", (req, res) => {
|
||||||
const user = repositories.getUser(req.session.user);
|
const user = repositories.getUser(req.session.user);
|
||||||
if (!user) return res.status(404).json({error: "User not found"});
|
if (!user) return res.status(404).json({error: "User not found"});
|
||||||
return res.json({username: user.username});
|
return res.json({username: user.username, role: user.role});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put("/settings/username", validateBody(usernameValidator), async (req, res) => {
|
router.put("/settings/username", validateBody(usernameValidator), asyncHandler(async (req, res) => {
|
||||||
const currentUsername = req.session.user;
|
const currentUsername = req.session.user;
|
||||||
const {username: nextUsername, password} = req.validatedBody;
|
const {username: nextUsername, password} = req.validatedBody;
|
||||||
if (nextUsername === currentUsername) {
|
if (nextUsername === currentUsername) {
|
||||||
@@ -49,18 +55,32 @@ export function createSettingsRouter({repositories}) {
|
|||||||
if (!currentUser) return res.status(404).json({error: "User not found"});
|
if (!currentUser) return res.status(404).json({error: "User not found"});
|
||||||
const valid = await bcrypt.compare(password, currentUser.password);
|
const valid = await bcrypt.compare(password, currentUser.password);
|
||||||
if (!valid) return res.status(403).json({error: "Current password is incorrect"});
|
if (!valid) return res.status(403).json({error: "Current password is incorrect"});
|
||||||
if (repositories.getUser(nextUsername)) {
|
if (repositories.getUserCaseInsensitive(nextUsername)) {
|
||||||
return res.status(400).json({error: "Username already exists"});
|
return res.status(409).json({error: "Username already exists", code: "USERNAME_TAKEN"});
|
||||||
}
|
}
|
||||||
|
|
||||||
repositories.renameUser(currentUsername, nextUsername);
|
try {
|
||||||
req.session.user = nextUsername;
|
repositories.renameUser(currentUsername, nextUsername);
|
||||||
|
} catch (error) {
|
||||||
|
if (["SQLITE_CONSTRAINT_PRIMARYKEY", "SQLITE_CONSTRAINT_UNIQUE"].includes(error.code)) {
|
||||||
|
return res.status(409).json({error: "Username already exists", code: "USERNAME_TAKEN"});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
await new Promise((resolve, reject) => req.session.regenerate(error => {
|
||||||
|
if (error) return reject(error);
|
||||||
|
req.session.user = nextUsername;
|
||||||
|
req.session.sessionVersion = 0;
|
||||||
|
resolve();
|
||||||
|
}));
|
||||||
return res.json({username: nextUsername});
|
return res.json({username: nextUsername});
|
||||||
});
|
}));
|
||||||
|
|
||||||
router.put("/settings/password", validateBody(passwordValidator), async (req, res) => {
|
router.put("/settings/password", validateBody(passwordValidator), asyncHandler(async (req, res) => {
|
||||||
const username = req.session.user;
|
const username = req.session.user;
|
||||||
const {currentPassword, newPassword} = req.validatedBody;
|
const {currentPassword, newPassword} = req.validatedBody;
|
||||||
|
const passwordError = newPasswordError(newPassword);
|
||||||
|
if (passwordError) return res.status(400).json({error: passwordError, code: "PASSWORD_TOO_LONG"});
|
||||||
const user = repositories.getUser(username);
|
const user = repositories.getUser(username);
|
||||||
if (!user) return res.status(404).json({error: "User not found"});
|
if (!user) return res.status(404).json({error: "User not found"});
|
||||||
const valid = await bcrypt.compare(currentPassword, user.password);
|
const valid = await bcrypt.compare(currentPassword, user.password);
|
||||||
@@ -70,11 +90,11 @@ export function createSettingsRouter({repositories}) {
|
|||||||
const updatedUser = repositories.incrementSessionVersion(username);
|
const updatedUser = repositories.incrementSessionVersion(username);
|
||||||
req.session.sessionVersion = Number(updatedUser.session_version || 0);
|
req.session.sessionVersion = Number(updatedUser.session_version || 0);
|
||||||
return res.json({message: "Password updated"});
|
return res.json({message: "Password updated"});
|
||||||
});
|
}));
|
||||||
|
|
||||||
router.post("/settings/logout-all-sessions", (req, res) => {
|
router.post("/settings/logout-all-sessions", (req, res, next) => {
|
||||||
repositories.incrementSessionVersion(req.session.user);
|
repositories.incrementSessionVersion(req.session.user);
|
||||||
req.session.destroy(() => res.json({message: "All sessions logged out"}));
|
req.session.destroy(error => error ? next(error) : res.json({message: "All sessions logged out"}));
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get("/settings/export", (req, res) => {
|
router.get("/settings/export", (req, res) => {
|
||||||
@@ -89,7 +109,7 @@ export function createSettingsRouter({repositories}) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete("/settings/account", validateBody(currentPasswordValidator), async (req, res) => {
|
router.delete("/settings/account", validateBody(currentPasswordValidator), asyncHandler(async (req, res) => {
|
||||||
const username = req.session.user;
|
const username = req.session.user;
|
||||||
const user = repositories.getUser(username);
|
const user = repositories.getUser(username);
|
||||||
if (!user) return res.status(404).json({error: "User not found"});
|
if (!user) return res.status(404).json({error: "User not found"});
|
||||||
@@ -98,7 +118,7 @@ export function createSettingsRouter({repositories}) {
|
|||||||
repositories.deleteUser(username);
|
repositories.deleteUser(username);
|
||||||
await destroySession(req);
|
await destroySession(req);
|
||||||
return res.json({message: "Account deleted"});
|
return res.json({message: "Account deleted"});
|
||||||
});
|
}));
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
+55
-15
@@ -1,12 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* @file Stellt Modellinformationen und administrative Diagnosedaten bereit.
|
||||||
|
*/
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import {requireLogin} from "../middleware/auth.js";
|
import {asyncHandler} from "../middleware/asyncHandler.js";
|
||||||
|
import {requireLogin, requireAdmin} from "../middleware/auth.js";
|
||||||
function requireAdmin(req, res, next) {
|
import {createRateLimiter} from "../middleware/rateLimit.js";
|
||||||
if (req.session.user !== "admin") {
|
|
||||||
return res.status(403).json({error: "Admin only"});
|
|
||||||
}
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildConfigWarnings(config) {
|
function buildConfigWarnings(config) {
|
||||||
const warnings = [];
|
const warnings = [];
|
||||||
@@ -28,20 +26,62 @@ function buildConfigWarnings(config) {
|
|||||||
return warnings;
|
return warnings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSystemRouter({modelService, config}) {
|
export function createHealthRouter({modelService, config}) {
|
||||||
|
const router = express.Router();
|
||||||
|
const limiter = createRateLimiter({
|
||||||
|
windowMs: config.healthRateLimitWindowMs,
|
||||||
|
max: config.healthRateLimitMaxPerIp,
|
||||||
|
keyGenerator: req => `health:${req.ip}`,
|
||||||
|
message: "Too many health checks. Please try again later."
|
||||||
|
});
|
||||||
|
let cached = null;
|
||||||
|
let checkedAt = 0;
|
||||||
|
let inFlight = null;
|
||||||
|
|
||||||
|
const runHealthCheck = () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
let timer;
|
||||||
|
const timeout = new Promise(resolve => {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
controller.abort(new Error("Health check timed out"));
|
||||||
|
resolve({ok: false, timedOut: true});
|
||||||
|
}, config.healthTimeoutMs);
|
||||||
|
});
|
||||||
|
const check = Promise.resolve(modelService.checkHealth(controller.signal));
|
||||||
|
return Promise.race([check, timeout]).finally(() => clearTimeout(timer));
|
||||||
|
};
|
||||||
|
|
||||||
|
router.get("/health", limiter, asyncHandler(async (req, res) => {
|
||||||
|
if (cached && Date.now() - checkedAt < config.healthCacheTtlMs) {
|
||||||
|
return res.status(cached.ok ? 200 : 503).json({ok: Boolean(cached.ok), cached: true});
|
||||||
|
}
|
||||||
|
if (!inFlight) {
|
||||||
|
inFlight = runHealthCheck().then(model => {
|
||||||
|
cached = model;
|
||||||
|
checkedAt = Date.now();
|
||||||
|
return model;
|
||||||
|
}).finally(() => { inFlight = null; });
|
||||||
|
}
|
||||||
|
const model = await inFlight;
|
||||||
|
return res.status(model.ok ? 200 : 503).json({ok: Boolean(model.ok)});
|
||||||
|
}));
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSystemRouter({modelService, config, observability}) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.get("/health", async (req, res) => {
|
router.get("/admin/diagnostics", requireLogin, requireAdmin, asyncHandler(async (req, res) => {
|
||||||
const model = await modelService.checkHealth();
|
|
||||||
return res.status(model.ok ? 200 : 503).json({ok: model.ok, model});
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get("/admin/diagnostics", requireLogin, requireAdmin, async (req, res) => {
|
|
||||||
const diagnostics = await modelService.getDiagnostics();
|
const diagnostics = await modelService.getDiagnostics();
|
||||||
return res.status(diagnostics.model.ok ? 200 : 503).json({
|
return res.status(diagnostics.model.ok ? 200 : 503).json({
|
||||||
model: diagnostics.model,
|
model: diagnostics.model,
|
||||||
configWarnings: buildConfigWarnings(config)
|
configWarnings: buildConfigWarnings(config)
|
||||||
});
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.get("/admin/observability", requireLogin, requireAdmin, (req, res) => {
|
||||||
|
return res.json(observability.getSnapshot());
|
||||||
});
|
});
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
|
|||||||
+20
-14
@@ -1,12 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* @file Verarbeitet das Hochladen, Abrufen und Entfernen von Bildanhängen mit Zugriffskontrolle.
|
||||||
|
*/
|
||||||
import express from "express";
|
import express from "express";
|
||||||
|
import {mkdirSync} from "node:fs";
|
||||||
|
import {unlink} from "node:fs/promises";
|
||||||
|
import {asyncHandler} from "../middleware/asyncHandler.js";
|
||||||
import multer from "multer";
|
import multer from "multer";
|
||||||
import {requireLogin} from "../middleware/auth.js";
|
import {requireLogin} from "../middleware/auth.js";
|
||||||
import {AttachmentError} from "../services/attachmentService.js";
|
import {AttachmentError} from "../services/attachmentService.js";
|
||||||
|
|
||||||
export function createUploadsRouter(service, config) {
|
export function createUploadsRouter(service, config) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const activeUploads = new Map();
|
mkdirSync(config.uploadTmpDir, {recursive: true});
|
||||||
const upload = multer({storage: multer.memoryStorage(), limits: {
|
const upload = multer({storage: multer.diskStorage({destination: config.uploadTmpDir}), limits: {
|
||||||
fileSize: config.uploadMaxSizeMb * 1024 * 1024, files: 1, fields: 0, parts: 1
|
fileSize: config.uploadMaxSizeMb * 1024 * 1024, files: 1, fields: 0, parts: 1
|
||||||
}}).single("image");
|
}}).single("image");
|
||||||
const fail = (res, error) => res.status(error instanceof AttachmentError ? error.status : 500).json({error:
|
const fail = (res, error) => res.status(error instanceof AttachmentError ? error.status : 500).json({error:
|
||||||
@@ -14,31 +20,31 @@ export function createUploadsRouter(service, config) {
|
|||||||
router.get("/uploads/config", requireLogin, (_req, res) => res.json({maxSizeMb: config.uploadMaxSizeMb, maxFiles: config.uploadMaxFiles}));
|
router.get("/uploads/config", requireLogin, (_req, res) => res.json({maxSizeMb: config.uploadMaxSizeMb, maxFiles: config.uploadMaxFiles}));
|
||||||
router.post("/uploads", requireLogin, (req, res) => {
|
router.post("/uploads", requireLogin, (req, res) => {
|
||||||
const user = req.session.user;
|
const user = req.session.user;
|
||||||
const active = activeUploads.get(user) || 0;
|
const lease = service.acquireUploadLease(user);
|
||||||
if (active >= config.uploadMaxFiles) return res.status(429).json({error: "Too many simultaneous uploads. Please wait."});
|
if (!lease) return res.status(429).json({error: "Too many simultaneous uploads. Please wait."});
|
||||||
activeUploads.set(user, active + 1);
|
let released = false;
|
||||||
res.once("close", () => {
|
const release = () => { if (!released) { released = true; service.releaseUploadLease(lease); } };
|
||||||
const remaining = (activeUploads.get(user) || 1) - 1;
|
res.once("close", release);
|
||||||
if (remaining) activeUploads.set(user, remaining); else activeUploads.delete(user);
|
|
||||||
});
|
|
||||||
upload(req, res, async error => {
|
upload(req, res, async error => {
|
||||||
if (error) return res.status(error.code === "LIMIT_FILE_SIZE" ? 413 : 400).json({error:
|
if (error) { release(); if (req.file?.path) await unlink(req.file.path).catch(() => {}); return res.status(error.code === "LIMIT_FILE_SIZE" ? 413 : 400).json({error:
|
||||||
error.code === "LIMIT_FILE_SIZE" ? `Image exceeds ${config.uploadMaxSizeMb} MB` : "Upload one PNG, JPEG or WebP image per request"});
|
error.code === "LIMIT_FILE_SIZE" ? `Image exceeds ${config.uploadMaxSizeMb} MB` : "Upload one PNG, JPEG or WebP image per request"});
|
||||||
|
}
|
||||||
try { res.status(201).json(await service.upload(req.file, req.session.user)); }
|
try { res.status(201).json(await service.upload(req.file, req.session.user)); }
|
||||||
catch (failure) { fail(res, failure); }
|
catch (failure) { fail(res, failure); }
|
||||||
|
finally { release(); }
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
router.get("/attachments/:id", requireLogin, async (req, res) => {
|
router.get("/attachments/:id", requireLogin, asyncHandler(async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const row = service.owned(req.params.id, req.session.user);
|
const row = service.owned(req.params.id, req.session.user);
|
||||||
const buffer = await service.storage.read(row.storage_key);
|
const buffer = await service.storage.read(row.storage_key);
|
||||||
res.set({"Content-Type": row.mime_type, "Content-Length": buffer.length, "Cache-Control": "private, no-store",
|
res.set({"Content-Type": row.mime_type, "Content-Length": buffer.length, "Cache-Control": "private, no-store",
|
||||||
"X-Content-Type-Options": "nosniff", "Content-Disposition": "inline"}).send(buffer);
|
"X-Content-Type-Options": "nosniff", "Content-Disposition": "inline"}).send(buffer);
|
||||||
} catch (error) { fail(res, error); }
|
} catch (error) { fail(res, error); }
|
||||||
});
|
}));
|
||||||
router.delete("/attachments/:id", requireLogin, async (req, res) => {
|
router.delete("/attachments/:id", requireLogin, asyncHandler(async (req, res) => {
|
||||||
try { await service.remove(req.params.id, req.session.user); res.json({success: true}); }
|
try { await service.remove(req.params.id, req.session.user); res.json({success: true}); }
|
||||||
catch (error) { fail(res, error); }
|
catch (error) { fail(res, error); }
|
||||||
});
|
}));
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* @file Liefert angemeldeten Benutzern die für sie zugänglichen Diagnosen der Bildverarbeitung.
|
||||||
|
*/
|
||||||
|
import express from "express";
|
||||||
|
import {requireLogin} from "../middleware/auth.js";
|
||||||
|
|
||||||
|
export function createVisionDebugRouter({visionDebugService, repositories}) {
|
||||||
|
const router = express.Router();
|
||||||
|
router.use(requireLogin);
|
||||||
|
router.get("/debug/vision/latest", (req, res) => {
|
||||||
|
const diagnostic = visionDebugService.latest(req.session.user, repositories);
|
||||||
|
return diagnostic ? res.json({diagnostic}) : res.status(404).json({error: "Vision diagnostic not found"});
|
||||||
|
});
|
||||||
|
router.get("/debug/vision/:requestId", (req, res) => {
|
||||||
|
const diagnostic = visionDebugService.get(req.params.requestId, req.session.user, repositories);
|
||||||
|
return diagnostic ? res.json({diagnostic}) : res.status(404).json({error: "Vision diagnostic not found"});
|
||||||
|
});
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import express from "express";
|
||||||
|
import multer from "multer";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import {randomUUID} from "node:crypto";
|
||||||
|
import {requireLogin} from "../middleware/auth.js";
|
||||||
|
import {asyncHandler} from "../middleware/asyncHandler.js";
|
||||||
|
import {validateWorkspaceImport} from "../services/workspaceImportService.js";
|
||||||
|
import {createWorkspaceArchiveAsync, parseWorkspaceArchiveFile, MAX_ARCHIVE_BYTES} from "../services/workspaceArchive.js";
|
||||||
|
|
||||||
|
export function createWorkspaceTransferRouter({repositories, chatService, attachmentService, config, db}) {
|
||||||
|
const router = express.Router();
|
||||||
|
router.use(requireLogin);
|
||||||
|
const receiveArchive = multer({storage: multer.diskStorage({destination: config.uploadTmpDir, filename: (_req, _file, cb) => cb(null, `workspace-${Date.now()}-${Math.random().toString(16).slice(2)}.tar.gz`)}), limits: {fileSize: MAX_ARCHIVE_BYTES, files: 1, parts: 2}}).single("archive");
|
||||||
|
const readArchive = (req, res, next) => receiveArchive(req, res, async error => {
|
||||||
|
if (error) return res.status(error.code === "LIMIT_FILE_SIZE" ? 413 : 400).json({error: "Invalid or oversized workspace archive"});
|
||||||
|
try {
|
||||||
|
if (!req.file) throw new Error("Choose a workspace archive");
|
||||||
|
req.archive = await parseWorkspaceArchiveFile(req.file.path);
|
||||||
|
next();
|
||||||
|
} catch (failure) { res.status(400).json({error: failure.message}); }
|
||||||
|
finally { if (req.file?.path) await fs.rm(req.file.path, {force: true}).catch(() => {}); }
|
||||||
|
});
|
||||||
|
const acquire = (req, res) => {
|
||||||
|
const key = req.session.user;
|
||||||
|
const token = randomUUID();
|
||||||
|
let acquired = false;
|
||||||
|
try {
|
||||||
|
acquired = db.transaction(() => {
|
||||||
|
db.prepare("DELETE FROM workspace_operation_leases WHERE created_at < datetime('now', '-30 minutes')").run();
|
||||||
|
db.prepare("INSERT INTO workspace_operation_leases(username, token) VALUES(?, ?)").run(key, token);
|
||||||
|
return true;
|
||||||
|
})();
|
||||||
|
} catch (error) {
|
||||||
|
if (!String(error?.code || "").startsWith("SQLITE_CONSTRAINT")) throw error;
|
||||||
|
}
|
||||||
|
if (!acquired) {
|
||||||
|
res.status(429).json({error: "Another workspace archive operation is already running"});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let released = false;
|
||||||
|
const release = () => {
|
||||||
|
if (released) return;
|
||||||
|
released = true;
|
||||||
|
db.prepare("DELETE FROM workspace_operation_leases WHERE username = ? AND token = ?").run(key, token);
|
||||||
|
};
|
||||||
|
res.once("close", release);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
const archiveImages = (workspace, files) => {
|
||||||
|
const images = new Map();
|
||||||
|
for (const chat of workspace.chats) for (const message of chat.messages) for (const attachment of message.attachments) {
|
||||||
|
if (images.has(attachment.id)) continue;
|
||||||
|
const matches = [...files.entries()].filter(([name]) => name.startsWith(`images/${attachment.id}.`));
|
||||||
|
if (matches.length !== 1) throw new Error(`Image ${attachment.id} is missing from archive`);
|
||||||
|
const [name, buffer] = matches[0];
|
||||||
|
images.set(attachment.id, {buffer, size: buffer.length, originalname: name.split("/").at(-1)});
|
||||||
|
}
|
||||||
|
return images;
|
||||||
|
};
|
||||||
|
|
||||||
|
router.get("/exports/chats/:id", (req, res) => {
|
||||||
|
const chatId = Number(req.params.id), format = String(req.query.format || "txt").toLowerCase();
|
||||||
|
const chat = repositories.getChat(chatId, req.session.user);
|
||||||
|
if (!chat) return res.status(404).json({error: "Chat not found"});
|
||||||
|
const messages = repositories.listChatMessages(chatId).map(chatService.formatChatMessage);
|
||||||
|
if (format === "json") return res.json({chat, messages});
|
||||||
|
const markdown = format === "md";
|
||||||
|
const body = markdown
|
||||||
|
? [`# ${chat.title}`, "", ...messages.flatMap(message => [`## ${message.role === "user" ? "You" : "Assistant"}`, "", message.content, ""])].join("\n")
|
||||||
|
: [`Chat: ${chat.title}`, "", ...messages.flatMap(message => [`${message.role === "user" ? "You" : "Assistant"}:`, message.content, ""])].join("\n");
|
||||||
|
res.type(markdown ? "text/markdown" : "text/plain");
|
||||||
|
return res.send(body);
|
||||||
|
});
|
||||||
|
router.get("/exports/workspace", (req, res) => res.json({workspace: repositories.exportWorkspace(req.session.user)}));
|
||||||
|
router.get("/exports/workspace/archive", asyncHandler(async (req, res) => {
|
||||||
|
if (!acquire(req, res)) return;
|
||||||
|
try {
|
||||||
|
const workspace = repositories.exportWorkspace(req.session.user), images = new Map();
|
||||||
|
for (const chat of workspace.chats) for (const message of chat.messages) for (const attachment of message.attachments) {
|
||||||
|
if (images.has(attachment.id)) continue;
|
||||||
|
const row = attachmentService.owned(attachment.id, req.session.user);
|
||||||
|
images.set(attachment.id, [`images/${row.id}.${row.mime_type.split("/")[1]}`, await attachmentService.storage.read(row.storage_key)]);
|
||||||
|
}
|
||||||
|
const archive = await createWorkspaceArchiveAsync(workspace, [...images.values()]);
|
||||||
|
res.set({"Content-Type": "application/gzip", "Content-Disposition": 'attachment; filename="workspace-backup.tar.gz"', "Cache-Control": "private, no-store"});
|
||||||
|
return res.send(archive);
|
||||||
|
} catch (error) { return res.status(error.message.includes("250 MB") ? 413 : 500).json({error: "Workspace archive could not be created"}); }
|
||||||
|
}));
|
||||||
|
router.post("/imports/workspace/archive/preview", readArchive, (req, res) => {
|
||||||
|
const validation = validateWorkspaceImport(req.archive.workspace, repositories.exportWorkspace(req.session.user));
|
||||||
|
if (validation.ok) try { archiveImages(validation.workspace, req.archive.images); } catch (error) { validation.ok = false; validation.errors.push(error.message); }
|
||||||
|
return res.status(validation.ok ? 200 : 400).json({ok: validation.ok, errors: validation.errors, warnings: validation.warnings, preview: validation.preview});
|
||||||
|
});
|
||||||
|
router.post("/imports/workspace/archive", readArchive, asyncHandler(async (req, res) => {
|
||||||
|
if (!acquire(req, res)) return;
|
||||||
|
const validation = validateWorkspaceImport(req.archive.workspace, repositories.exportWorkspace(req.session.user));
|
||||||
|
if (!validation.ok) return res.status(400).json({error: "Workspace import validation failed", errors: validation.errors});
|
||||||
|
if (validation.preview.duplicates.length && req.body?.allowDuplicates !== "true") return res.status(409).json({error: "Workspace import contains duplicate records", warnings: validation.warnings, preview: validation.preview});
|
||||||
|
let images;
|
||||||
|
try { images = archiveImages(validation.workspace, req.archive.images); } catch (error) { return res.status(400).json({error: error.message}); }
|
||||||
|
const restored = [];
|
||||||
|
try {
|
||||||
|
const ids = new Map();
|
||||||
|
for (const [oldId, file] of images) { const uploaded = await attachmentService.upload(file, req.session.user, {forImport: true}); restored.push(uploaded.id); ids.set(oldId, uploaded.id); }
|
||||||
|
for (const chat of validation.workspace.chats) for (const message of chat.messages) message.attachments = message.attachments.map(attachment => ({id: ids.get(attachment.id)}));
|
||||||
|
return res.json({imported: repositories.importWorkspace(req.session.user, validation.workspace), preview: validation.preview});
|
||||||
|
} catch (error) {
|
||||||
|
for (const id of restored) await attachmentService.remove(id, req.session.user).catch(() => {});
|
||||||
|
return res.status(error.status || 400).json({error: error.message || "Workspace archive import failed"});
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
router.post("/imports/workspace/preview", (req, res) => {
|
||||||
|
const validation = validateWorkspaceImport(req.body?.workspace, repositories.exportWorkspace(req.session.user));
|
||||||
|
return res.status(validation.ok ? 200 : 400).json({ok: validation.ok, errors: validation.errors, warnings: validation.warnings, preview: validation.preview});
|
||||||
|
});
|
||||||
|
router.post("/imports/workspace", (req, res) => {
|
||||||
|
const validation = validateWorkspaceImport(req.body?.workspace, repositories.exportWorkspace(req.session.user));
|
||||||
|
if (!validation.ok) return res.status(400).json({error: "Workspace import validation failed", errors: validation.errors, warnings: validation.warnings, preview: validation.preview});
|
||||||
|
if (validation.preview.duplicates.length && !req.body?.allowDuplicates) return res.status(409).json({error: "Workspace import contains duplicate records", errors: [], warnings: validation.warnings, preview: validation.preview});
|
||||||
|
return res.json({imported: repositories.importWorkspace(req.session.user, validation.workspace), preview: validation.preview});
|
||||||
|
});
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* @file Validiert und speichert Bildanhänge und bereitet zwischengespeicherte Bildanalysen für den Chat vor.
|
||||||
|
*/
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
|
import {readFile, unlink} from "node:fs/promises";
|
||||||
import {createHash, randomUUID} from "node:crypto";
|
import {createHash, randomUUID} from "node:crypto";
|
||||||
import {attachmentMetadata} from "../db/attachments.js";
|
import {attachmentMetadata} from "../db/attachments.js";
|
||||||
import {LocalFileStorage} from "./localFileStorage.js";
|
import {LocalFileStorage} from "./localFileStorage.js";
|
||||||
@@ -16,15 +20,24 @@ export function parseVisionAnalysis(text) {
|
|||||||
const json = raw.startsWith("```json\n") && raw.endsWith("```") ? raw.slice(8, -3).trim()
|
const json = raw.startsWith("```json\n") && raw.endsWith("```") ? raw.slice(8, -3).trim()
|
||||||
: raw.startsWith("```\n") && raw.endsWith("```") ? raw.slice(4, -3).trim() : raw;
|
: raw.startsWith("```\n") && raw.endsWith("```") ? raw.slice(4, -3).trim() : raw;
|
||||||
const value = JSON.parse(json);
|
const value = JSON.parse(json);
|
||||||
if (!value || typeof value.summary !== "string") throw new Error("Invalid analysis");
|
if (value && typeof value === "object" && !Array.isArray(value)) return normalizeVisionAnalysis(value);
|
||||||
return normalizeVisionAnalysis(value);
|
throw new Error("Invalid analysis");
|
||||||
} catch {
|
} catch {
|
||||||
return {...normalizeVisionAnalysis({summary: raw}), rawAnalysis: raw.slice(0, 6000)};
|
return {...normalizeVisionAnalysis({scene: raw}), rawAnalysis: raw.slice(0, 6000)};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createAttachmentService(db, config, modelService) {
|
export function createAttachmentService(db, config, modelService, visionDebugService = null) {
|
||||||
const storage = new LocalFileStorage(config.uploadDir);
|
const storage = new LocalFileStorage(config.uploadDir);
|
||||||
|
const acquireUploadLease = (username) => db.transaction(() => {
|
||||||
|
db.prepare("DELETE FROM upload_leases WHERE created_at < datetime('now', '-15 minutes')").run();
|
||||||
|
const active = db.prepare("SELECT COUNT(*) AS count FROM upload_leases WHERE username=?").get(username).count;
|
||||||
|
if (active >= config.uploadMaxFiles) return null;
|
||||||
|
const token = randomUUID();
|
||||||
|
db.prepare("INSERT INTO upload_leases(token, username) VALUES(?, ?)").run(token, username);
|
||||||
|
return token;
|
||||||
|
})();
|
||||||
|
const releaseUploadLease = token => { if (token) db.prepare("DELETE FROM upload_leases WHERE token=?").run(token); };
|
||||||
const log = (event, fields) => { if (config.structuredRequestLoggingEnabled) console.log(JSON.stringify({event, ...fields})); };
|
const log = (event, fields) => { if (config.structuredRequestLoggingEnabled) console.log(JSON.stringify({event, ...fields})); };
|
||||||
const get = id => db.prepare("SELECT * FROM attachments WHERE id = ?").get(id);
|
const get = id => db.prepare("SELECT * FROM attachments WHERE id = ?").get(id);
|
||||||
const rows = messageId => db.prepare("SELECT a.*, m.position FROM attachments a JOIN message_attachments m ON m.attachment_id=a.id WHERE m.message_id=? ORDER BY m.position").all(messageId);
|
const rows = messageId => db.prepare("SELECT a.*, m.position FROM attachments a JOIN message_attachments m ON m.attachment_id=a.id WHERE m.message_id=? ORDER BY m.position").all(messageId);
|
||||||
@@ -55,30 +68,56 @@ export function createAttachmentService(db, config, modelService) {
|
|||||||
const entry = db.prepare("SELECT analysis FROM attachment_vision_analyses WHERE attachment_id=? AND vision_model=? AND prompt_version=? AND question=?")
|
const entry = db.prepare("SELECT analysis FROM attachment_vision_analyses WHERE attachment_id=? AND vision_model=? AND prompt_version=? AND question=?")
|
||||||
.get(row.id, config.visionModel, VISION_PROMPT_VERSION, normalizeVisionQuestion(question));
|
.get(row.id, config.visionModel, VISION_PROMPT_VERSION, normalizeVisionQuestion(question));
|
||||||
if (!entry) return null;
|
if (!entry) return null;
|
||||||
try { const analysis = JSON.parse(entry.analysis); return typeof analysis?.summary === "string" ? analysis : null; }
|
try {
|
||||||
|
const analysis = JSON.parse(entry.analysis);
|
||||||
|
return typeof analysis?.scene === "string"
|
||||||
|
? {...normalizeVisionAnalysis(analysis), ...(typeof analysis.rawAnalysis === "string" ? {rawAnalysis: analysis.rawAnalysis.slice(0, 6000)} : {})}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
catch { return null; }
|
catch { return null; }
|
||||||
};
|
};
|
||||||
const analyzeRows = async (images, question, signal, diagnostics = {}) => {
|
const analyzeRows = async (images, question, signal, diagnostics = {}) => {
|
||||||
for (const row of images) {
|
for (const row of images) {
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
|
const normalizedQuestion = normalizeVisionQuestion(question);
|
||||||
const fields = {requestId: diagnostics.requestId ?? null, chatId: diagnostics.chatId ?? null,
|
const fields = {requestId: diagnostics.requestId ?? null, chatId: diagnostics.chatId ?? null,
|
||||||
triggeringMessageId: diagnostics.triggeringMessageId ?? row.messageId, attachmentId: row.id,
|
triggeringMessageId: diagnostics.triggeringMessageId ?? row.messageId, attachmentId: row.id,
|
||||||
sourceMessageId: row.messageId, attachmentPosition: row.position, scope: row.scope || "current",
|
sourceMessageId: row.messageId, attachmentPosition: row.position, scope: row.scope || "current",
|
||||||
storageKey: row.storage_key, originalFilename: row.original_name, visionModel: config.visionModel,
|
storageKey: row.storage_key, originalFilename: row.original_name, visionModel: config.visionModel,
|
||||||
visionPromptVersion: VISION_PROMPT_VERSION, question: normalizeVisionQuestion(question).slice(0, 500)};
|
visionPromptVersion: VISION_PROMPT_VERSION, question: normalizedQuestion.slice(0, 500)};
|
||||||
|
const cacheIdentity = {attachmentId: row.id, visionModel: config.visionModel,
|
||||||
|
visionPromptVersion: VISION_PROMPT_VERSION, normalizedQuestion};
|
||||||
|
visionDebugService?.recordImage(diagnostics.requestId, {
|
||||||
|
attachmentId: row.id, sourceMessageId: row.messageId, position: row.position,
|
||||||
|
scope: row.scope || "current", label: row.label, originalFilename: row.original_name,
|
||||||
|
mimeType: row.mime_type, width: row.width, height: row.height, sizeBytes: row.size_bytes,
|
||||||
|
visionModel: config.visionModel, visionPromptVersion: VISION_PROMPT_VERSION, normalizedQuestion
|
||||||
|
});
|
||||||
const hit = cached(row, question);
|
const hit = cached(row, question);
|
||||||
if (hit) { log("visionAnalysisCacheHit", {...fields, cacheHit: true, resultSummary: hit.summary.slice(0, 160)}); continue; }
|
if (hit) {
|
||||||
|
visionDebugService?.recordCache(diagnostics.requestId, row.id,
|
||||||
|
{cacheHit: true, cacheIdentity, normalizedVisionAnalysis: hit});
|
||||||
|
log("visionAnalysisCacheHit", {...fields, cacheHit: true, resultSummary: hit.scene.slice(0, 160)});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
visionDebugService?.recordCache(diagnostics.requestId, row.id, {cacheHit: false, cacheIdentity});
|
||||||
try {
|
try {
|
||||||
log("visionAnalysisStart", {...fields, cacheHit: false});
|
log("visionAnalysisStart", {...fields, cacheHit: false});
|
||||||
const buffer = await storage.read(row.storage_key);
|
const buffer = await storage.read(row.storage_key);
|
||||||
|
const imageSha256 = visionDebugService?.enabled
|
||||||
|
? createHash("sha256").update(buffer).digest("hex") : null;
|
||||||
if (config.visionDebugSha256Enabled && config.structuredRequestLoggingEnabled) {
|
if (config.visionDebugSha256Enabled && config.structuredRequestLoggingEnabled) {
|
||||||
log("visionImageRead", {...fields, sha256: createHash("sha256").update(buffer).digest("hex")});
|
log("visionImageRead", {...fields, sha256: createHash("sha256").update(buffer).digest("hex")});
|
||||||
}
|
}
|
||||||
|
let rawVisionResponse = null;
|
||||||
const result = await modelService.generateReply(config.visionModel, [
|
const result = await modelService.generateReply(config.visionModel, [
|
||||||
{role: "system", content: VISION_ANALYSIS_PROMPT},
|
{role: "system", content: VISION_ANALYSIS_PROMPT},
|
||||||
{role: "user", content: `User question: ${JSON.stringify(String(question ?? ""))}\nAnalyze this image specifically to help answer that question.`, images: [buffer.toString("base64")]}
|
{role: "user", content: `User question (attention guidance only): ${JSON.stringify(String(question ?? ""))}\nReport the visible evidence relevant to this question without answering it.`, images: [buffer.toString("base64")]}
|
||||||
], {signal, reasoning: false, format: VISION_RESPONSE_SCHEMA, generation: {temperature: 0.1, responseLength: 1600}});
|
], {signal, reasoning: false, format: VISION_RESPONSE_SCHEMA, generation: {temperature: 0.1, responseLength: 2400},
|
||||||
|
...(visionDebugService?.enabled ? {onRawReply: value => { rawVisionResponse = value; }} : {})});
|
||||||
const parsed = parseVisionAnalysis(result);
|
const parsed = parseVisionAnalysis(result);
|
||||||
|
visionDebugService?.recordVisionRun(diagnostics.requestId, row.id,
|
||||||
|
{imageSha256, rawVisionResponse: rawVisionResponse ?? result, normalizedVisionAnalysis: parsed});
|
||||||
const analysis = JSON.stringify(parsed);
|
const analysis = JSON.stringify(parsed);
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
@@ -91,7 +130,7 @@ export function createAttachmentService(db, config, modelService) {
|
|||||||
db.prepare("UPDATE attachments SET vision_model=?,vision_analysis=?,vision_analyzed_at=CURRENT_TIMESTAMP WHERE id=?")
|
db.prepare("UPDATE attachments SET vision_model=?,vision_analysis=?,vision_analyzed_at=CURRENT_TIMESTAMP WHERE id=?")
|
||||||
.run(config.visionModel, analysis, row.id);
|
.run(config.visionModel, analysis, row.id);
|
||||||
})();
|
})();
|
||||||
log("visionAnalysisComplete", {...fields, cacheHit: false, resultSummary: parsed.summary.slice(0, 160)});
|
log("visionAnalysisComplete", {...fields, cacheHit: false, resultSummary: parsed.scene.slice(0, 160)});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (signal?.aborted) throw error;
|
if (signal?.aborted) throw error;
|
||||||
throw new AttachmentError("Image analysis failed. Check that the vision model is available, then retry.", 502);
|
throw new AttachmentError("Image analysis failed. Check that the vision model is available, then retry.", 502);
|
||||||
@@ -118,7 +157,7 @@ export function createAttachmentService(db, config, modelService) {
|
|||||||
timer.unref();
|
timer.unref();
|
||||||
void cleanup();
|
void cleanup();
|
||||||
return {
|
return {
|
||||||
storage, cleanup,
|
storage, cleanup, acquireUploadLease, releaseUploadLease,
|
||||||
async close() { clearInterval(timer); await cleanup(); closed = true; },
|
async close() { clearInterval(timer); await cleanup(); closed = true; },
|
||||||
list(messageId) { return rows(messageId).map(attachmentMetadata); },
|
list(messageId) { return rows(messageId).map(attachmentMetadata); },
|
||||||
owned(id, user) {
|
owned(id, user) {
|
||||||
@@ -133,28 +172,31 @@ export function createAttachmentService(db, config, modelService) {
|
|||||||
db.prepare("DELETE FROM attachments WHERE id=?").run(id);
|
db.prepare("DELETE FROM attachments WHERE id=?").run(id);
|
||||||
return cleanup();
|
return cleanup();
|
||||||
},
|
},
|
||||||
async upload(file, user) {
|
async upload(file, user, {forImport = false} = {}) {
|
||||||
await cleanup();
|
await cleanup();
|
||||||
if (!file?.buffer?.length) throw new AttachmentError("Choose a non-empty PNG, JPEG or WebP image");
|
let buffer;
|
||||||
if (file.size > config.uploadMaxSizeMb * 1024 * 1024) throw new AttachmentError("Image is too large", 413);
|
try { buffer = file?.buffer || (file?.path ? await readFile(file.path) : null); }
|
||||||
|
finally { if (file?.path) await unlink(file.path).catch(() => {}); }
|
||||||
|
if (!buffer?.length) throw new AttachmentError("Choose a non-empty PNG, JPEG or WebP image");
|
||||||
|
if (buffer.length > config.uploadMaxSizeMb * 1024 * 1024) throw new AttachmentError("Image is too large", 413);
|
||||||
let info;
|
let info;
|
||||||
try {
|
try {
|
||||||
const image = sharp(file.buffer, {limitInputPixels: config.uploadMaxPixels, failOn: "warning"});
|
const image = sharp(buffer, {limitInputPixels: config.uploadMaxPixels, failOn: "warning"});
|
||||||
info = await image.metadata();
|
info = await image.metadata();
|
||||||
if (!["png", "jpeg", "webp"].includes(info.format) || (info.pages || 1) > 1) throw new Error("Unsupported");
|
if (!["png", "jpeg", "webp"].includes(info.format) || (info.pages || 1) > 1) throw new Error("Unsupported");
|
||||||
await image.stats(); // Decode pixels, not just headers: reject truncated/malformed images.
|
await image.stats(); // Decode pixels, not just headers: reject truncated/malformed images.
|
||||||
} catch { throw new AttachmentError("Invalid image. Use a non-animated PNG, JPEG or WebP within the pixel limit."); }
|
} catch { throw new AttachmentError("Invalid image. Use a non-animated PNG, JPEG or WebP within the pixel limit."); }
|
||||||
const {storageKey} = await storage.save({buffer: file.buffer, extension: info.format}).catch(() => { throw new AttachmentError("Image could not be saved. Please retry.", 503); });
|
const {storageKey} = await storage.save({buffer, extension: info.format}).catch(() => { throw new AttachmentError("Image could not be saved. Please retry.", 503); });
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
try {
|
try {
|
||||||
// Bound abandoned uploads per account as well as per submitted message.
|
// Bound abandoned uploads per account as well as per submitted message.
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
if (db.prepare("SELECT COUNT(*) AS n FROM attachments WHERE username=? AND status='temporary'").get(user).n >= config.uploadMaxFiles * 4) throw new AttachmentError("Too many pending images. Remove unused uploads first.");
|
if (!forImport && db.prepare("SELECT COUNT(*) AS n FROM attachments WHERE username=? AND status='temporary'").get(user).n >= config.uploadMaxFiles * 4) throw new AttachmentError("Too many pending images. Remove unused uploads first.");
|
||||||
db.prepare("INSERT INTO attachments(id,username,original_name,storage_key,mime_type,size_bytes,width,height) VALUES(?,?,?,?,?,?,?,?)").run(id, user,
|
db.prepare("INSERT INTO attachments(id,username,original_name,storage_key,mime_type,size_bytes,width,height) VALUES(?,?,?,?,?,?,?,?)").run(id, user,
|
||||||
String(file.originalname || "image").replace(/[\\/\x00-\x1f]/g, "_").slice(0, 255), storageKey, `image/${info.format}`, file.size, info.width, info.height);
|
[...String(file.originalname || "image").replace(/[\\/]/g, "_")].map(character => character.charCodeAt(0) < 32 ? "_" : character).join("").slice(0, 255), storageKey, `image/${info.format}`, buffer.length, info.width, info.height);
|
||||||
})();
|
})();
|
||||||
} catch (error) { await storage.delete(storageKey); throw error; }
|
} catch (error) { await storage.delete(storageKey); throw error; }
|
||||||
log("attachmentUploadSuccess", {id, size: file.size, mime: `image/${info.format}`});
|
log("attachmentUploadSuccess", {id, size: buffer.length, mime: `image/${info.format}`});
|
||||||
return attachmentMetadata(get(id));
|
return attachmentMetadata(get(id));
|
||||||
},
|
},
|
||||||
createMessage(ids, user, chatId, insert, reuseChatId = null) {
|
createMessage(ids, user, chatId, insert, reuseChatId = null) {
|
||||||
@@ -179,8 +221,10 @@ export function createAttachmentService(db, config, modelService) {
|
|||||||
return searchImages.map(row => {
|
return searchImages.map(row => {
|
||||||
const analysis = cached(row, question);
|
const analysis = cached(row, question);
|
||||||
if (!analysis) return "";
|
if (!analysis) return "";
|
||||||
return [analysis.answer, ...analysis.visibleText, analysis.summary].filter(Boolean).join(" ").slice(0, 300);
|
// Search from observed features; recognized identities can be premature.
|
||||||
}).join(" ").slice(0, 400);
|
return [...analysis.notableFeatures, ...analysis.visualDetails, ...analysis.visibleText, analysis.scene]
|
||||||
|
.filter(Boolean).join("; ").slice(0, 350);
|
||||||
|
}).filter(Boolean).join("; ").slice(0, 600);
|
||||||
},
|
},
|
||||||
hasImages: selection => selectedRows(selection).length > 0,
|
hasImages: selection => selectedRows(selection).length > 0,
|
||||||
analyze: (messageId, question, signal) => analyzeRows(rows(messageId).map((row, index) => ({...row, messageId, scope: "current", label: `Current image ${index + 1}`})), question, signal),
|
analyze: (messageId, question, signal) => analyzeRows(rows(messageId).map((row, index) => ({...row, messageId, scope: "current", label: `Current image ${index + 1}`})), question, signal),
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Normalisiert Nachrichten und erstellt Kontexttexte für Erinnerungen, Zusammenfassungen und Personas.
|
||||||
|
*/
|
||||||
import {normalizeSources} from "./webSearchService.js";
|
import {normalizeSources} from "./webSearchService.js";
|
||||||
|
|
||||||
export function parseSources(value) {
|
export function parseSources(value) {
|
||||||
|
|||||||
+319
-143
@@ -1,31 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* @file Koordiniert Chat-Antworten, Modellkontext, Websuche, Anhänge, Antwortvarianten und Gesprächsverzweigungen.
|
||||||
|
*/
|
||||||
import {
|
import {
|
||||||
buildPersonaPrompt,
|
buildPersonaPrompt,
|
||||||
buildRoleplayDirectionPrompt,
|
buildRoleplayDirectionPrompt,
|
||||||
buildRoleplayOpenerPrompt,
|
buildRoleplayOpenerPrompt,
|
||||||
buildRoleplayReplyGuardPrompt,
|
|
||||||
buildRoleplaySceneSummary,
|
buildRoleplaySceneSummary,
|
||||||
buildUserPersonaPrompt,
|
buildUserPersonaPrompt,
|
||||||
containsUserVoiceInRoleplayOpener
|
containsUserVoiceInRoleplayOpener
|
||||||
} from "./personaService.js";
|
} from "./personaService.js";
|
||||||
import {
|
import {
|
||||||
buildContextSummaryPrompt,
|
|
||||||
buildMemoryPrompt,
|
|
||||||
buildPersonaStatePrompt,
|
|
||||||
clampRetryActiveIndex,
|
clampRetryActiveIndex,
|
||||||
formatChatMessage as formatBaseChatMessage,
|
formatChatMessage as formatBaseChatMessage,
|
||||||
formatMessagesForSummary,
|
formatMessagesForSummary,
|
||||||
parseSources,
|
parseSources,
|
||||||
retryStylePrompts
|
retryStylePrompts
|
||||||
} from "./chatMessageHelpers.js";
|
} from "./chatMessageHelpers.js";
|
||||||
|
import {createHash} from "node:crypto";
|
||||||
|
|
||||||
import { SearXNGSearchProvider, WebSearchError, buildWebSearchContext, retrieveWebSearch } from "./webSearchService.js";
|
import { SearXNGSearchProvider, WebSearchError, decideWebSearch, retrieveWebSearch } from "./webSearchService.js";
|
||||||
import { supportsReasoning } from "../../public/js/modelCapabilities.js";
|
import { supportsReasoning } from "../../public/js/modelCapabilities.js";
|
||||||
|
import {titleFromFirstMessage} from "../../public/js/chatTitle.js";
|
||||||
|
import {VISION_EVIDENCE_GUIDANCE} from "./visionAnalysis.js";
|
||||||
|
import {createConversationContextBuilder} from "./conversationContext.js";
|
||||||
|
import {COMPACTION_SCHEMA, COMPACTION_SYSTEM_PROMPT, buildCompactionRequest, estimateTokens, isCompactionEligibleMessage, normalizeCompaction} from "./contextCompaction.js";
|
||||||
|
|
||||||
export function createChatService(repositories, modelService, config, webSearchProvider = new SearXNGSearchProvider(config), attachmentService = null) {
|
export function createChatService(repositories, modelService, config, webSearchProvider = new SearXNGSearchProvider(config), attachmentService = null, visionDebugService = null) {
|
||||||
const formatChatMessage = row => {
|
const formatChatMessage = (row, related = null) => {
|
||||||
const message = formatBaseChatMessage(row);
|
const message = formatBaseChatMessage(row);
|
||||||
if (message?.role === "bot") {
|
if (message?.role === "bot") {
|
||||||
const versions = repositories.listMessageVersions(row.id);
|
const versions = related?.versions ? related.versions.get(row.id) || [] : repositories.listMessageVersions(row.id);
|
||||||
message.versionCount = versions.length || 1;
|
message.versionCount = versions.length || 1;
|
||||||
message.versionIndex = versions.length ? Number(row.retry_active_index || 0) : 0;
|
message.versionIndex = versions.length ? Number(row.retry_active_index || 0) : 0;
|
||||||
if (versions.length) message.responseVersions = versions.map(version => ({
|
if (versions.length) message.responseVersions = versions.map(version => ({
|
||||||
@@ -33,15 +37,44 @@ export function createChatService(repositories, modelService, config, webSearchP
|
|||||||
sources: parseSources(version.sources), modelName: version.model_name, createdAt: version.created_at
|
sources: parseSources(version.sources), modelName: version.model_name, createdAt: version.created_at
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
const attachments = row && attachmentService?.list(row.id);
|
const attachments = row && (related?.attachments ? related.attachments.get(row.id) : attachmentService?.list(row.id));
|
||||||
return message && attachments?.length ? { ...message, attachments } : message;
|
return message && attachments?.length ? { ...message, attachments } : message;
|
||||||
};
|
};
|
||||||
const chatGenerationQueues = new Map();
|
const chatGenerationQueues = new Map();
|
||||||
const summaryQueues = new Map();
|
|
||||||
const activeGenerations = new Map();
|
const activeGenerations = new Map();
|
||||||
const regeneratingMessages = new Map();
|
const regeneratingMessages = new Map();
|
||||||
const maxRetries = Number(config.chatMaxRetries ?? 5);
|
const maxRetries = Number(config.chatMaxRetries ?? 5);
|
||||||
const now = typeof config.now === "function" ? config.now : () => new Date();
|
const now = typeof config.now === "function" ? config.now : () => new Date();
|
||||||
|
const visionGuidanceSha256 = createHash("sha256").update(VISION_EVIDENCE_GUIDANCE).digest("hex");
|
||||||
|
|
||||||
|
const beginVisionDiagnostics = ({requestId, user, chatId, triggeringMessageId, userQuestion, selectedModel, reasoningRequested, webSearchRequested, visionSelection}) => {
|
||||||
|
if (!visionDebugService?.enabled || !visionSelection || !attachmentService.hasImages(visionSelection)) return;
|
||||||
|
visionDebugService.begin({requestId, user, chatId, triggeringMessageId, userQuestion, selectedModel, reasoningRequested, webSearchRequested});
|
||||||
|
const decision = decideWebSearch(userQuestion, webSearchRequested);
|
||||||
|
visionDebugService.recordSearch(requestId, {
|
||||||
|
requested: webSearchRequested,
|
||||||
|
needed: decision.needsSearch,
|
||||||
|
explicitSearchIntent: decision.explicitSearchIntent,
|
||||||
|
state: webSearchRequested ? (decision.shouldSearch ? "pending" : "skipped") : "disabled"
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const recordQwenDiagnostics = ({requestId, messagesPayload, visionSelection, model, generation, reasoning}) => {
|
||||||
|
if (!visionDebugService?.enabled || !requestId) return;
|
||||||
|
const evidenceMessage = messagesPayload.find(message => message.role === "user" && message.content.startsWith("<image_evidence>"));
|
||||||
|
const metadata = typeof modelService.getRequestMetadata === "function"
|
||||||
|
? modelService.getRequestMetadata(model, generation, reasoning)
|
||||||
|
: {model, think: reasoning === true && supportsReasoning(model), stream: true,
|
||||||
|
temperature: generation.temperature ?? null, numCtx: generation.contextLength ?? null,
|
||||||
|
numPredict: generation.responseLength ?? null, keepAlive: config.ollamaKeepAlive ?? "30m"};
|
||||||
|
visionDebugService.recordQwen(requestId, {
|
||||||
|
imageEvidence: evidenceMessage?.content || null,
|
||||||
|
visionGuidanceSha256,
|
||||||
|
visionEvidenceIncluded: Boolean(evidenceMessage),
|
||||||
|
imageCount: (visionSelection?.currentMessageImages.length || 0) + (visionSelection?.historicalImages.length || 0),
|
||||||
|
currentImageLabels: visionSelection?.currentMessageImages.map(image => image.label) || [],
|
||||||
|
...metadata
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const insertChatMessage = (chatId, role, content, retryState = null, modelName = null, deliveryStatus = "complete", errorMessage = null) => {
|
const insertChatMessage = (chatId, role, content, retryState = null, modelName = null, deliveryStatus = "complete", errorMessage = null) => {
|
||||||
const normalizedContent = String(content || "");
|
const normalizedContent = String(content || "");
|
||||||
@@ -63,21 +96,27 @@ export function createChatService(repositories, modelService, config, webSearchP
|
|||||||
|
|
||||||
const withChatGenerationLock = (chatId, operation) => {
|
const withChatGenerationLock = (chatId, operation) => {
|
||||||
const previous = chatGenerationQueues.get(chatId) || Promise.resolve();
|
const previous = chatGenerationQueues.get(chatId) || Promise.resolve();
|
||||||
|
// Ein fehlgeschlagener Vorgänger darf die Warteschlange dieses Chats nicht blockieren.
|
||||||
const current = previous.catch(() => undefined).then(operation);
|
const current = previous.catch(() => undefined).then(operation);
|
||||||
chatGenerationQueues.set(chatId, current);
|
chatGenerationQueues.set(chatId, current);
|
||||||
return current.finally(() => {
|
return current.finally(() => {
|
||||||
|
// Nur den eigenen Eintrag entfernen: Inzwischen könnte bereits ein Folgeauftrag warten.
|
||||||
if (chatGenerationQueues.get(chatId) === current) chatGenerationQueues.delete(chatId);
|
if (chatGenerationQueues.get(chatId) === current) chatGenerationQueues.delete(chatId);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const withActiveGeneration = async (chatId, externalSignal, operation) => {
|
const withActiveGeneration = async (chatId, externalSignal, operation) => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
// Verbindungsabbruch und manuelles Stoppen wirken auf denselben Generierungsauftrag.
|
||||||
const abortFromCaller = () => controller.abort(externalSignal?.reason);
|
const abortFromCaller = () => controller.abort(externalSignal?.reason);
|
||||||
if (externalSignal?.aborted) abortFromCaller();
|
if (externalSignal?.aborted) abortFromCaller();
|
||||||
else externalSignal?.addEventListener("abort", abortFromCaller, { once: true });
|
else externalSignal?.addEventListener("abort", abortFromCaller, { once: true });
|
||||||
activeGenerations.set(chatId, controller);
|
activeGenerations.set(chatId, controller);
|
||||||
try {
|
try {
|
||||||
return await operation(controller.signal);
|
controller.signal.throwIfAborted();
|
||||||
|
const result = await operation(controller.signal);
|
||||||
|
controller.signal.throwIfAborted();
|
||||||
|
return result;
|
||||||
} finally {
|
} finally {
|
||||||
if (activeGenerations.get(chatId) === controller) activeGenerations.delete(chatId);
|
if (activeGenerations.get(chatId) === controller) activeGenerations.delete(chatId);
|
||||||
externalSignal?.removeEventListener("abort", abortFromCaller);
|
externalSignal?.removeEventListener("abort", abortFromCaller);
|
||||||
@@ -93,6 +132,33 @@ export function createChatService(repositories, modelService, config, webSearchP
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const generateChatTitle = async (message, model, signal) => {
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
try {
|
||||||
|
const response = await modelService.generateReply(model, [
|
||||||
|
{role: "system", content: [
|
||||||
|
"Create a short, useful title for a chat from the user's first message.",
|
||||||
|
"Capture the main subject or goal, not the wording of the request.",
|
||||||
|
"Use the same language as the user. Do not answer the message.",
|
||||||
|
"Return only JSON in the form {\"title\":\"...\"}.",
|
||||||
|
"The title must be 2 to 8 words and no more than 70 characters. Do not use quotes, markdown, emojis, or a trailing period."
|
||||||
|
].join("\n")},
|
||||||
|
{role: "user", content: String(message).slice(0, 6000)}
|
||||||
|
], {
|
||||||
|
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(10000)]) : AbortSignal.timeout(10000),
|
||||||
|
reasoning: false,
|
||||||
|
generation: {temperature: 0.2, responseLength: 80},
|
||||||
|
format: {type: "object", properties: {title: {type: "string"}}, required: ["title"], additionalProperties: false}
|
||||||
|
});
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
const title = String(JSON.parse(response)?.title || "").replace(/[\r\n]+/g, " ").replace(/["“”']/g, "").replace(/\s+/g, " ").trim();
|
||||||
|
if (title && title.length <= 70 && title.split(/\s+/).length <= 8) return title;
|
||||||
|
} catch {
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
}
|
||||||
|
return titleFromFirstMessage(message);
|
||||||
|
};
|
||||||
|
|
||||||
const persistChatMessageRetryState = ({ chatId, messageId, content, retryVariants, retryActiveIndex, retryRetriesUsed, retryPromptMessageId }) => {
|
const persistChatMessageRetryState = ({ chatId, messageId, content, retryVariants, retryActiveIndex, retryRetriesUsed, retryPromptMessageId }) => {
|
||||||
const versioned = repositories.listMessageVersions(messageId).length > 0;
|
const versioned = repositories.listMessageVersions(messageId).length > 0;
|
||||||
if (versioned) repositories.saveMessageVersion(chatId, messageId, null, retryActiveIndex);
|
if (versioned) repositories.saveMessageVersion(chatId, messageId, null, retryActiveIndex);
|
||||||
@@ -118,13 +184,14 @@ export function createChatService(repositories, modelService, config, webSearchP
|
|||||||
return `${normalized.slice(0, Math.max(0, config.chatSummaryMaxChars - 3)).trimEnd()}...`;
|
return `${normalized.slice(0, Math.max(0, config.chatSummaryMaxChars - 3)).trimEnd()}...`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const maybeUpdateContextSummary = async ({ user, chatId, selectedModel }) => {
|
const maybeUpdateContextSummary = async ({ user, chatId }) => {
|
||||||
if (config.chatSummaryUpdateEveryMessages <= 0) return;
|
if (config.chatSummaryUpdateEveryMessages <= 0) return;
|
||||||
const session = repositories.getChat(chatId, user);
|
const session = repositories.getChat(chatId, user);
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
const allMessages = repositories.listActiveBranchMessages(chatId)
|
const allMessages = repositories.listActiveBranchMessages(chatId)
|
||||||
.filter((message) => !message.delivery_status || message.delivery_status === "complete");
|
.filter((message) => !message.delivery_status || message.delivery_status === "complete");
|
||||||
const keepRecent = Math.max(0, config.chatHistoryLimit);
|
const keepRecent = Math.max(0, config.chatHistoryLimit);
|
||||||
|
// Die neuesten Nachrichten bleiben vollständig im Kontext; nur ältere werden zusammengefasst.
|
||||||
const cutoffIndex = allMessages.length - keepRecent - 1;
|
const cutoffIndex = allMessages.length - keepRecent - 1;
|
||||||
if (cutoffIndex < 0) return;
|
if (cutoffIndex < 0) return;
|
||||||
const cutoffMessage = allMessages[cutoffIndex];
|
const cutoffMessage = allMessages[cutoffIndex];
|
||||||
@@ -133,7 +200,8 @@ export function createChatService(repositories, modelService, config, webSearchP
|
|||||||
if (summarizable.length < config.chatSummaryUpdateEveryMessages) return;
|
if (summarizable.length < config.chatSummaryUpdateEveryMessages) return;
|
||||||
|
|
||||||
const previousSummary = String(session.context_summary || "").trim();
|
const previousSummary = String(session.context_summary || "").trim();
|
||||||
const summary = await modelService.generateReply(selectedModel, [
|
if (!config.contextModel) return;
|
||||||
|
const summary = await modelService.generateReply(config.contextModel, [
|
||||||
{
|
{
|
||||||
role: "system",
|
role: "system",
|
||||||
content: [
|
content: [
|
||||||
@@ -156,6 +224,9 @@ export function createChatService(repositories, modelService, config, webSearchP
|
|||||||
|
|
||||||
const latestSummarized = summarizable[summarizable.length - 1];
|
const latestSummarized = summarizable[summarizable.length - 1];
|
||||||
if (!latestSummarized || !summary) return;
|
if (!latestSummarized || !summary) return;
|
||||||
|
const current = repositories.getChat(chatId, user);
|
||||||
|
// A manual correction or branch switch during generation wins over the stale summary.
|
||||||
|
if (!current || current.context_summary !== session.context_summary || current.context_summary_message_id !== session.context_summary_message_id) return;
|
||||||
repositories.updateChatContextSummary(chatId, user, clampSummary(summary), latestSummarized.id);
|
repositories.updateChatContextSummary(chatId, user, clampSummary(summary), latestSummarized.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -168,14 +239,62 @@ export function createChatService(repositories, modelService, config, webSearchP
|
|||||||
};
|
};
|
||||||
|
|
||||||
const scheduleContextSummaryUpdate = ({ user, chatId, selectedModel }) => {
|
const scheduleContextSummaryUpdate = ({ user, chatId, selectedModel }) => {
|
||||||
const previous = summaryQueues.get(chatId) || Promise.resolve();
|
// Compaction is performed synchronously before the next main-model request.
|
||||||
const current = previous
|
void user; void chatId; void selectedModel; void updateContextSummaryWithoutBreakingReply;
|
||||||
.catch(() => undefined)
|
};
|
||||||
.then(() => updateContextSummaryWithoutBreakingReply({ user, chatId, selectedModel }));
|
|
||||||
summaryQueues.set(chatId, current);
|
const ensureContextCompaction = async ({user, chatId, currentMessage, signal = null}) => {
|
||||||
void current.finally(() => {
|
if (!config.contextModel) return null;
|
||||||
if (summaryQueues.get(chatId) === current) summaryQueues.delete(chatId);
|
const session = repositories.getChat(chatId, user);
|
||||||
});
|
if (!session) return null;
|
||||||
|
const allMessages = repositories.listActiveBranchMessages(chatId)
|
||||||
|
.filter(isCompactionEligibleMessage);
|
||||||
|
const previousSummary = String(session.context_summary || "").trim();
|
||||||
|
const compactedThrough = Number(session.context_summary_message_id || 0);
|
||||||
|
const activeMessages = allMessages.filter(message => Number(message.id) > compactedThrough);
|
||||||
|
const contextTokens = estimateTokens(previousSummary) + activeMessages.reduce((sum, item) => sum + estimateTokens(item.content), 0) + estimateTokens(currentMessage);
|
||||||
|
if (contextTokens < Number(config.contextCompactionTriggerTokens || 6000)) return null;
|
||||||
|
let recentTokens = 0;
|
||||||
|
let splitIndex = activeMessages.length;
|
||||||
|
for (let index = activeMessages.length - 1; index >= 0; index -= 1) {
|
||||||
|
recentTokens += estimateTokens(activeMessages[index].content);
|
||||||
|
if (recentTokens > Number(config.contextCompactionRecentTokens || 2500) && activeMessages.length - index >= 2) {
|
||||||
|
splitIndex = index;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const summarizable = activeMessages.slice(0, splitIndex);
|
||||||
|
if (summarizable.length < Number(config.contextCompactionMinMessages || 4)) return null;
|
||||||
|
const latestSummarized = summarizable.at(-1);
|
||||||
|
const startedAt = Date.now();
|
||||||
|
console.info("CONTEXT COMPACTION", JSON.stringify({conversationId: chatId, triggered: true, contextTokens,
|
||||||
|
compactedTokens: summarizable.reduce((sum, item) => sum + estimateTokens(item.content), 0), messages: summarizable.length}));
|
||||||
|
let rawResult;
|
||||||
|
try {
|
||||||
|
rawResult = await modelService.generateReply(config.contextModel, [
|
||||||
|
{role: "system", content: COMPACTION_SYSTEM_PROMPT},
|
||||||
|
{role: "user", content: buildCompactionRequest(previousSummary, summarizable)}
|
||||||
|
], {signal, reasoning: false, generation: {temperature: 0, responseLength: 1600}, format: COMPACTION_SCHEMA});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("CONTEXT COMPACTION FAILED", JSON.stringify({conversationId: chatId, error: String(error?.message || error)}));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const compacted = normalizeCompaction(rawResult, Number(config.contextCompactionMaxChars || 8000));
|
||||||
|
if (!compacted) {
|
||||||
|
console.warn("CONTEXT COMPACTION INVALID", JSON.stringify({conversationId: chatId, durationMs: Date.now() - startedAt,
|
||||||
|
resultChars: typeof rawResult === "string" ? rawResult.length : null}));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const serialized = JSON.stringify(compacted);
|
||||||
|
const update = repositories.updateChatContextSummaryIfUnchanged?.(chatId, user, serialized, latestSummarized.id,
|
||||||
|
session.context_summary, session.context_summary_message_id);
|
||||||
|
if (repositories.updateChatContextSummaryIfUnchanged && !update?.changes) {
|
||||||
|
console.warn("CONTEXT COMPACTION STALE", JSON.stringify({conversationId: chatId, durationMs: Date.now() - startedAt}));
|
||||||
|
return repositories.getChat(chatId, user);
|
||||||
|
}
|
||||||
|
console.info("CONTEXT COMPACTION COMPLETE", JSON.stringify({conversationId: chatId, durationMs: Date.now() - startedAt,
|
||||||
|
compactedMessages: summarizable.length, resultChars: serialized.length}));
|
||||||
|
return {...session, context_summary: serialized, context_summary_message_id: latestSummarized.id};
|
||||||
};
|
};
|
||||||
|
|
||||||
const getOrCreatePersonaChat = (user, persona, userPersonaId = null, scenarioPrompt = null, scenarioSummary = null) => {
|
const getOrCreatePersonaChat = (user, persona, userPersonaId = null, scenarioPrompt = null, scenarioSummary = null) => {
|
||||||
@@ -199,6 +318,7 @@ export function createChatService(repositories, modelService, config, webSearchP
|
|||||||
? modelService.streamReply(selectedModel, messages, onChunk, { onThinking, signal, generation, reasoning })
|
? modelService.streamReply(selectedModel, messages, onChunk, { onThinking, signal, generation, reasoning })
|
||||||
: modelService.generateReply(selectedModel, messages, { onThinking, signal, generation, reasoning });
|
: modelService.generateReply(selectedModel, messages, { onThinking, signal, generation, reasoning });
|
||||||
let opener = await generate(baseMessages);
|
let opener = await generate(baseMessages);
|
||||||
|
signal?.throwIfAborted();
|
||||||
if (!containsUserVoiceInRoleplayOpener(opener, userPersona)) {
|
if (!containsUserVoiceInRoleplayOpener(opener, userPersona)) {
|
||||||
return opener;
|
return opener;
|
||||||
}
|
}
|
||||||
@@ -252,60 +372,9 @@ export function createChatService(repositories, modelService, config, webSearchP
|
|||||||
return repositories.getActiveUserPersona(user);
|
return repositories.getActiveUserPersona(user);
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildConversationContext = ({ user, chatId, session = null, message, upToMessageId = null, sources = null, historyOverride = null, visionSelection = null, contextSummaryOverride = undefined }) => {
|
const buildConversationContext = createConversationContextBuilder({repositories, attachmentService, now});
|
||||||
const activeSession = session || repositories.getChat(chatId, user);
|
|
||||||
if (!activeSession) return null;
|
|
||||||
const activePersona = repositories.getAssistantPersonaForChat(chatId, user);
|
|
||||||
const activeUserPersona = getUserPersonaForSession(chatId, user, activeSession, activePersona);
|
|
||||||
const history = historyOverride || (upToMessageId == null
|
|
||||||
? repositories.getRecentChatMessages(chatId).reverse()
|
|
||||||
: repositories.getRecentChatMessagesUpToId(chatId, upToMessageId).reverse());
|
|
||||||
const conversation = message == null ? history : [...history, { role: "user", content: message }];
|
|
||||||
const messagesPayload = [];
|
|
||||||
// Result material remains a separate untrusted user-context message. The
|
|
||||||
// grounding rule is added after mutable persona/chat prompts so it remains
|
|
||||||
// the final trusted instruction governing the supplied references.
|
|
||||||
const webSearchContext = sources === null ? null : buildWebSearchContext(sources, now());
|
|
||||||
if (activeSession.system_instruction) {
|
|
||||||
messagesPayload.push({ role: "system", content: `CHAT-SPECIFIC INSTRUCTIONS:\n${activeSession.system_instruction}` });
|
|
||||||
}
|
|
||||||
if (activePersona) messagesPayload.push({ role: "system", content: buildPersonaPrompt(activePersona) });
|
|
||||||
if (activeUserPersona) messagesPayload.push({ role: "system", content: buildUserPersonaPrompt(activeUserPersona) });
|
|
||||||
if (activePersona && activeSession.scenario_summary) {
|
|
||||||
messagesPayload.push({
|
|
||||||
role: "system",
|
|
||||||
content: buildRoleplayDirectionPrompt({
|
|
||||||
assistantPersona: activePersona,
|
|
||||||
userPersona: activeUserPersona,
|
|
||||||
scenarioPrompt: activeSession.scenario_prompt,
|
|
||||||
sceneSummary: activeSession.scenario_summary
|
|
||||||
})
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const contextSummaryPrompt = buildContextSummaryPrompt(contextSummaryOverride === undefined
|
|
||||||
? (upToMessageId == null ? activeSession.context_summary : null) : contextSummaryOverride);
|
|
||||||
if (contextSummaryPrompt) messagesPayload.push({ role: "system", content: contextSummaryPrompt });
|
|
||||||
const memoryPrompt = buildMemoryPrompt(repositories.listChatMemories(chatId, user));
|
|
||||||
if (memoryPrompt) messagesPayload.push({ role: "system", content: memoryPrompt });
|
|
||||||
const personaStatePrompt = buildPersonaStatePrompt(repositories.getPersonaChatState(chatId, user));
|
|
||||||
if (personaStatePrompt) messagesPayload.push({ role: "system", content: personaStatePrompt });
|
|
||||||
if (activePersona) messagesPayload.push({ role: "system", content: buildRoleplayReplyGuardPrompt() });
|
|
||||||
const conversationMessages = conversation.map((entry) => ({
|
|
||||||
role: entry.role === "bot" ? "assistant" : "user",
|
|
||||||
content: entry.content
|
|
||||||
}));
|
|
||||||
messagesPayload.push(...conversationMessages.slice(0, -1));
|
|
||||||
const currentQuestion = conversation.at(-1)?.content || "";
|
|
||||||
const visionContext = attachmentService?.context(visionSelection || attachmentService.selectImages(
|
|
||||||
message == null ? history : [...history, {role: "user", content: message}], currentQuestion), currentQuestion);
|
|
||||||
if (visionContext) messagesPayload.push(...visionContext);
|
|
||||||
if (webSearchContext) messagesPayload.push(webSearchContext[0]);
|
|
||||||
if (webSearchContext?.[1]) messagesPayload.push(webSearchContext[1]);
|
|
||||||
if (conversationMessages.length) messagesPayload.push(conversationMessages.at(-1));
|
|
||||||
return { session: activeSession, activePersona, activeUserPersona, messagesPayload };
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildRetryPayload = async ({ chatId, user, session, selectedModel, targetMessage, retryStyle = null, signal = null, generation = {}, webSearch = false, reasoning = false, onChunk = null, onThinking = null, onStatus = null, requestId = null }) => {
|
const buildRetryPayload = async ({ chatId, user, session, selectedModel, targetMessage, retryStyle = null, signal = null, generation = {}, webSearch = true, reasoning = false, onChunk = null, onThinking = null, onStatus = null, requestId = null }) => {
|
||||||
let thinking = "";
|
let thinking = "";
|
||||||
const captureThinking = (chunk, fullThinking) => {
|
const captureThinking = (chunk, fullThinking) => {
|
||||||
if (!reasoning) return;
|
if (!reasoning) return;
|
||||||
@@ -342,19 +411,36 @@ export function createChatService(repositories, modelService, config, webSearchP
|
|||||||
|
|
||||||
const imageHistory = repositories.getRecentChatMessagesUpToId(chatId, promptMessage.id).reverse();
|
const imageHistory = repositories.getRecentChatMessagesUpToId(chatId, promptMessage.id).reverse();
|
||||||
const visionSelection = attachmentService?.selectImages(imageHistory, promptMessage.content);
|
const visionSelection = attachmentService?.selectImages(imageHistory, promptMessage.content);
|
||||||
|
beginVisionDiagnostics({requestId, user, chatId, triggeringMessageId: promptMessage.id,
|
||||||
|
userQuestion: promptMessage.content, selectedModel, reasoningRequested: reasoning,
|
||||||
|
webSearchRequested: webSearch, visionSelection});
|
||||||
if (visionSelection && attachmentService.hasImages(visionSelection)) onStatus?.({ status: "analyzing" });
|
if (visionSelection && attachmentService.hasImages(visionSelection)) onStatus?.({ status: "analyzing" });
|
||||||
if (visionSelection) await attachmentService.analyzeContext(visionSelection, promptMessage.content, signal,
|
if (visionSelection) await attachmentService.analyzeContext(visionSelection, promptMessage.content, signal,
|
||||||
{requestId, chatId, triggeringMessageId: promptMessage.id});
|
{requestId, chatId, triggeringMessageId: promptMessage.id});
|
||||||
let sources = null;
|
let sources = null;
|
||||||
let searchState = "skipped";
|
let searchState = "skipped";
|
||||||
if (webSearch) {
|
if (webSearch) {
|
||||||
const search = await retrieveWebSearch(promptMessage.content, webSearchProvider, {
|
const visualReference = visionSelection ? attachmentService.searchContext(visionSelection, promptMessage.content) : "";
|
||||||
signal,
|
try {
|
||||||
referenceText: visionSelection ? attachmentService.searchContext(visionSelection, promptMessage.content) : "",
|
const search = await retrieveWebSearch(promptMessage.content, webSearchProvider, {
|
||||||
onSearching: () => onStatus?.({ status: "searching" })
|
signal, referenceText: visualReference,
|
||||||
});
|
modelService, model: selectedModel, history: imageHistory.slice(0, -1),
|
||||||
sources = search.sources;
|
onSearching: () => {
|
||||||
searchState = search.state;
|
visionDebugService?.recordSearch(requestId, {requested: true, state: "searching", visualReference});
|
||||||
|
onStatus?.({ status: "searching" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
sources = search.sources;
|
||||||
|
searchState = search.state;
|
||||||
|
visionDebugService?.recordSearch(requestId, {requested: true, needed: search.decision.needsSearch,
|
||||||
|
explicitSearchIntent: search.decision.explicitSearchIntent, state: search.state, visualReference,
|
||||||
|
queries: search.queries, candidateCount: search.candidates.length,
|
||||||
|
selectedCount: search.sources?.length || 0, selectedSources: search.sources || []});
|
||||||
|
} catch (error) {
|
||||||
|
visionDebugService?.recordSearch(requestId, {requested: true, state: "failed", visualReference});
|
||||||
|
visionDebugService?.fail(requestId, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
onStatus?.({ status: "generating", searchState, sources });
|
onStatus?.({ status: "generating", searchState, sources });
|
||||||
const { messagesPayload } = buildConversationContext({
|
const { messagesPayload } = buildConversationContext({
|
||||||
@@ -369,6 +455,8 @@ export function createChatService(repositories, modelService, config, webSearchP
|
|||||||
});
|
});
|
||||||
|
|
||||||
const styledPayload = appendRetryStyleInstruction(messagesPayload, retryStyle);
|
const styledPayload = appendRetryStyleInstruction(messagesPayload, retryStyle);
|
||||||
|
recordQwenDiagnostics({requestId, messagesPayload: styledPayload, visionSelection,
|
||||||
|
model: selectedModel, generation, reasoning});
|
||||||
const fullReply = activePersona
|
const fullReply = activePersona
|
||||||
? await generateValidatedRoleplayReply({ selectedModel, messagesPayload: styledPayload, userPersona: activeUserPersona, signal, generation, onThinking: captureThinking, onChunk, reasoning })
|
? await generateValidatedRoleplayReply({ selectedModel, messagesPayload: styledPayload, userPersona: activeUserPersona, signal, generation, onThinking: captureThinking, onChunk, reasoning })
|
||||||
: onChunk
|
: onChunk
|
||||||
@@ -376,12 +464,12 @@ export function createChatService(repositories, modelService, config, webSearchP
|
|||||||
: await modelService.generateReply(selectedModel, styledPayload, { signal, generation, onThinking: captureThinking, reasoning });
|
: await modelService.generateReply(selectedModel, styledPayload, { signal, generation, onThinking: captureThinking, reasoning });
|
||||||
|
|
||||||
if (!fullReply) return { error: "Failed to regenerate message" };
|
if (!fullReply) return { error: "Failed to regenerate message" };
|
||||||
|
visionDebugService?.complete(requestId, {finalAnswer: fullReply, visibleReasoning: reasoning ? thinking : null});
|
||||||
return { fullReply, thinking, sources, searchState, promptMessageId: promptMessage.id };
|
return { fullReply, thinking, sources, searchState, promptMessageId: promptMessage.id };
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateChatTurn = ({ user, chatId, message, model, attachments = [], reuseChatId = null, webSearch = false, reasoning = false, onStatus = null, onChunk = null, onThinking = null, signal = null, parentMessageId = undefined, requestId = null }) =>
|
const generateChatTurn = ({ user, chatId, message, model, attachments = [], reuseChatId = null, userMessageId: resumeMessageId = null, webSearch = true, reasoning = false, onStatus = null, onChunk = null, onThinking = null, signal = null, parentMessageId = undefined, requestId = null }) =>
|
||||||
withChatGenerationLock(chatId, async () => {
|
withChatGenerationLock(chatId, async () => {
|
||||||
await (summaryQueues.get(chatId) || Promise.resolve());
|
|
||||||
const session = repositories.getChat(chatId, user);
|
const session = repositories.getChat(chatId, user);
|
||||||
if (!session) return { error: "Chat not found", status: 404 };
|
if (!session) return { error: "Chat not found", status: 404 };
|
||||||
if (parentMessageId !== undefined) {
|
if (parentMessageId !== undefined) {
|
||||||
@@ -389,7 +477,18 @@ export function createChatService(repositories, modelService, config, webSearchP
|
|||||||
session.context_summary = null;
|
session.context_summary = null;
|
||||||
session.context_summary_message_id = 0;
|
session.context_summary_message_id = 0;
|
||||||
}
|
}
|
||||||
const effectiveParentId = parentMessageId === undefined
|
const resumedMessage = resumeMessageId == null ? null : repositories.getChatMessage(chatId, resumeMessageId);
|
||||||
|
if (resumeMessageId != null && (!resumedMessage || resumedMessage.role !== "user" || !["pending", "failed"].includes(resumedMessage.delivery_status))) {
|
||||||
|
return {error: "This message cannot be resumed", status: 409};
|
||||||
|
}
|
||||||
|
if (resumedMessage?.delivery_status === "pending") {
|
||||||
|
const createdAt = Date.parse(`${String(resumedMessage.created_at).replace(" ", "T")}Z`);
|
||||||
|
const staleAfterMs = Number(config.modelMaxGenerationMs || 600000) + 60000;
|
||||||
|
if (Number.isFinite(createdAt) && new Date(now()).getTime() - createdAt < staleAfterMs) {
|
||||||
|
return {error: "This generation may still be running. Wait a little longer before resuming it.", status: 409};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const effectiveParentId = resumedMessage ? resumedMessage.parent_message_id : parentMessageId === undefined
|
||||||
? session.active_leaf_message_id ?? repositories.getLatestChatMessage(chatId)?.id ?? null
|
? session.active_leaf_message_id ?? repositories.getLatestChatMessage(chatId)?.id ?? null
|
||||||
: parentMessageId;
|
: parentMessageId;
|
||||||
if (effectiveParentId != null && !repositories.getChatMessage(chatId, effectiveParentId)) {
|
if (effectiveParentId != null && !repositories.getChatMessage(chatId, effectiveParentId)) {
|
||||||
@@ -398,9 +497,16 @@ export function createChatService(repositories, modelService, config, webSearchP
|
|||||||
const { model: effectiveModel, generation } = getGenerationSettings(session, model);
|
const { model: effectiveModel, generation } = getGenerationSettings(session, model);
|
||||||
const useReasoning = reasoning === true && supportsReasoning(effectiveModel);
|
const useReasoning = reasoning === true && supportsReasoning(effectiveModel);
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
const insert = () => repositories.insertBranchChatMessage(chatId, "user", message, null, null, "pending", null, effectiveParentId);
|
let userMessageId;
|
||||||
const inserted = attachmentService ? attachmentService.createMessage(attachments, user, chatId, insert, reuseChatId) : insert();
|
if (resumedMessage) {
|
||||||
const userMessageId = Number(inserted?.lastInsertRowid);
|
userMessageId = Number(resumedMessage.id);
|
||||||
|
repositories.updateChatMessageDelivery(chatId, userMessageId, "pending", null);
|
||||||
|
repositories.updateChatActiveLeaf(chatId, user, userMessageId);
|
||||||
|
} else {
|
||||||
|
const insert = () => repositories.insertBranchChatMessage(chatId, "user", message, null, null, "pending", null, effectiveParentId);
|
||||||
|
const inserted = attachmentService ? attachmentService.createMessage(attachments, user, chatId, insert, reuseChatId) : insert();
|
||||||
|
userMessageId = Number(inserted?.lastInsertRowid);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
let sources = null;
|
let sources = null;
|
||||||
let searchState = "skipped";
|
let searchState = "skipped";
|
||||||
@@ -416,22 +522,42 @@ export function createChatService(repositories, modelService, config, webSearchP
|
|||||||
onStatus?.({ status: "accepted" });
|
onStatus?.({ status: "accepted" });
|
||||||
const imageHistory = repositories.getRecentChatMessagesUpToId(chatId, userMessageId).reverse();
|
const imageHistory = repositories.getRecentChatMessagesUpToId(chatId, userMessageId).reverse();
|
||||||
const visionSelection = attachmentService?.selectImages(imageHistory, message);
|
const visionSelection = attachmentService?.selectImages(imageHistory, message);
|
||||||
|
beginVisionDiagnostics({requestId, user, chatId, triggeringMessageId: userMessageId,
|
||||||
|
userQuestion: message, selectedModel: effectiveModel, reasoningRequested: useReasoning,
|
||||||
|
webSearchRequested: webSearch, visionSelection});
|
||||||
if (visionSelection && attachmentService.hasImages(visionSelection)) {
|
if (visionSelection && attachmentService.hasImages(visionSelection)) {
|
||||||
onStatus?.({ status: "analyzing" });
|
onStatus?.({ status: "analyzing" });
|
||||||
await attachmentService.analyzeContext(visionSelection, message, activeSignal,
|
await attachmentService.analyzeContext(visionSelection, message, activeSignal,
|
||||||
{requestId, chatId, triggeringMessageId: userMessageId});
|
{requestId, chatId, triggeringMessageId: userMessageId});
|
||||||
}
|
}
|
||||||
if (webSearch) {
|
if (webSearch) {
|
||||||
const search = await retrieveWebSearch(message, webSearchProvider, {
|
const visualReference = visionSelection ? attachmentService.searchContext(visionSelection, message) : "";
|
||||||
referenceText: visionSelection ? attachmentService.searchContext(visionSelection, message) : "",
|
let search;
|
||||||
signal: activeSignal,
|
try {
|
||||||
onSearching: () => onStatus?.({ status: "searching" })
|
search = await retrieveWebSearch(message, webSearchProvider, {
|
||||||
});
|
referenceText: visualReference,
|
||||||
|
modelService, model: effectiveModel, history: imageHistory.slice(0, -1),
|
||||||
|
signal: activeSignal,
|
||||||
|
onSearching: () => {
|
||||||
|
visionDebugService?.recordSearch(requestId, {requested: true, state: "searching", visualReference});
|
||||||
|
onStatus?.({ status: "searching" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
visionDebugService?.recordSearch(requestId, {requested: true, state: "failed", visualReference});
|
||||||
|
visionDebugService?.fail(requestId, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
sources = search.sources;
|
sources = search.sources;
|
||||||
searchState = search.state;
|
searchState = search.state;
|
||||||
|
visionDebugService?.recordSearch(requestId, {requested: true, needed: search.decision.needsSearch,
|
||||||
|
explicitSearchIntent: search.decision.explicitSearchIntent, state: search.state, visualReference,
|
||||||
|
queries: search.queries, candidateCount: search.candidates.length,
|
||||||
|
selectedCount: search.sources?.length || 0, selectedSources: search.sources || []});
|
||||||
if (config.structuredRequestLoggingEnabled) {
|
if (config.structuredRequestLoggingEnabled) {
|
||||||
console.log(JSON.stringify({
|
console.log(JSON.stringify({
|
||||||
event: "web_search",
|
event: "web_search",
|
||||||
|
requestId,
|
||||||
webSearchRequested: true,
|
webSearchRequested: true,
|
||||||
explicitSearchRequested: search.decision.explicitSearchIntent,
|
explicitSearchRequested: search.decision.explicitSearchIntent,
|
||||||
automaticSearchDecision: search.decision.automaticSearchDecision,
|
automaticSearchDecision: search.decision.automaticSearchDecision,
|
||||||
@@ -445,9 +571,16 @@ export function createChatService(repositories, modelService, config, webSearchP
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const compactedSession = await ensureContextCompaction({user, chatId, currentMessage: message, signal: activeSignal});
|
||||||
|
if (compactedSession) {
|
||||||
|
session.context_summary = compactedSession.context_summary;
|
||||||
|
session.context_summary_message_id = compactedSession.context_summary_message_id;
|
||||||
|
}
|
||||||
onStatus?.({ status: "generating", searchState, ...(sources !== null ? { sourceCount: sources.length } : {}) });
|
onStatus?.({ status: "generating", searchState, ...(sources !== null ? { sourceCount: sources.length } : {}) });
|
||||||
payload = buildConversationContext({ user, chatId, session, message: null, upToMessageId: userMessageId,
|
payload = buildConversationContext({ user, chatId, session, message: null, upToMessageId: userMessageId,
|
||||||
historyOverride: imageHistory, visionSelection, contextSummaryOverride: session.context_summary, sources });
|
historyOverride: imageHistory, visionSelection, contextSummaryOverride: session.context_summary, sources });
|
||||||
|
recordQwenDiagnostics({requestId, messagesPayload: payload.messagesPayload, visionSelection,
|
||||||
|
model: effectiveModel, generation, reasoning: useReasoning});
|
||||||
return payload.activePersona
|
return payload.activePersona
|
||||||
? generateValidatedRoleplayReply({
|
? generateValidatedRoleplayReply({
|
||||||
selectedModel: effectiveModel,
|
selectedModel: effectiveModel,
|
||||||
@@ -459,9 +592,9 @@ export function createChatService(repositories, modelService, config, webSearchP
|
|||||||
onChunk,
|
onChunk,
|
||||||
reasoning: useReasoning
|
reasoning: useReasoning
|
||||||
})
|
})
|
||||||
: onChunk
|
: onChunk
|
||||||
? modelService.streamReply(effectiveModel, payload.messagesPayload, onChunk, { signal: activeSignal, generation, onThinking: captureThinking, reasoning: useReasoning })
|
? modelService.streamReply(effectiveModel, payload.messagesPayload, onChunk, { signal: activeSignal, generation, onThinking: captureThinking, reasoning: useReasoning })
|
||||||
: modelService.generateReply(effectiveModel, payload.messagesPayload, { signal: activeSignal, generation, onThinking: captureThinking, reasoning: useReasoning });
|
: modelService.generateReply(effectiveModel, payload.messagesPayload, { signal: activeSignal, generation, onThinking: captureThinking, reasoning: useReasoning });
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!fullReply) {
|
if (!fullReply) {
|
||||||
@@ -469,6 +602,8 @@ export function createChatService(repositories, modelService, config, webSearchP
|
|||||||
return { error: "Failed to generate a valid character reply", status: 500 };
|
return { error: "Failed to generate a valid character reply", status: 500 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
visionDebugService?.complete(requestId, {finalAnswer: fullReply, visibleReasoning: useReasoning ? thinking : null});
|
||||||
|
|
||||||
const retryState = {
|
const retryState = {
|
||||||
retryVariants: [fullReply],
|
retryVariants: [fullReply],
|
||||||
retryActiveIndex: 0,
|
retryActiveIndex: 0,
|
||||||
@@ -480,6 +615,7 @@ export function createChatService(repositories, modelService, config, webSearchP
|
|||||||
scheduleContextSummaryUpdate({ user, chatId, selectedModel: effectiveModel });
|
scheduleContextSummaryUpdate({ user, chatId, selectedModel: effectiveModel });
|
||||||
return { reply: fullReply, userMessageId, searchState, ...(sources ? { sources } : {}) };
|
return { reply: fullReply, userMessageId, searchState, ...(sources ? { sources } : {}) };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
visionDebugService?.fail(requestId, error);
|
||||||
repositories.failChatTurn(chatId, userMessageId, String(error?.message || "Model request failed").slice(0, 500), webSearch ? userMessageId : effectiveParentId);
|
repositories.failChatTurn(chatId, userMessageId, String(error?.message || "Model request failed").slice(0, 500), webSearch ? userMessageId : effectiveParentId);
|
||||||
repositories.touchChat(chatId, user);
|
repositories.touchChat(chatId, user);
|
||||||
if (error instanceof WebSearchError) return { error: error.message, status: error.status, searchState: "failed" };
|
if (error instanceof WebSearchError) return { error: error.message, status: error.status, searchState: "failed" };
|
||||||
@@ -487,22 +623,21 @@ export function createChatService(repositories, modelService, config, webSearchP
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const listActiveBranchMessages = (chatId) => repositories.listActiveBranchMessages(chatId).map((row) => {
|
const formatHistory = (chatId, rows) => {
|
||||||
const message = formatChatMessage(row);
|
const metadata = repositories.getHistoryMetadata(chatId, rows.map(row => row.id));
|
||||||
const siblings = repositories.listSiblingChatMessages(chatId, row.role, row.parent_message_id);
|
return rows.map(row => ({...formatChatMessage(row, metadata), ...metadata.siblings.get(row.id)}));
|
||||||
const siblingIndex = siblings.findIndex((sibling) => sibling.id === row.id);
|
};
|
||||||
return {
|
const listActiveBranchMessages = chatId => formatHistory(chatId, repositories.listActiveBranchMessages(chatId));
|
||||||
...message,
|
const getHistoryPage = (chatId, options) => {
|
||||||
siblingIndex,
|
const result = repositories.getActiveBranchPage(chatId, options);
|
||||||
siblingCount: siblings.length,
|
if (result.error) return result;
|
||||||
previousSiblingId: siblingIndex > 0 ? siblings[siblingIndex - 1].id : null,
|
const {rows, ...details} = result;
|
||||||
nextSiblingId: siblingIndex >= 0 && siblingIndex < siblings.length - 1 ? siblings[siblingIndex + 1].id : null
|
return {...details, messages: formatHistory(chatId, rows)};
|
||||||
};
|
};
|
||||||
});
|
|
||||||
|
|
||||||
const cloneChatThroughMessage = ({ user, sourceChat, messageId = null }) => {
|
const cloneChatThroughMessage = ({ user, sourceChat, messageId = null }) => {
|
||||||
if (repositories.countChats(user) >= config.chatLimit) {
|
if (repositories.countChats(user) >= config.chatLimit) {
|
||||||
return { error: `Maximum of ${config.chatLimit} chats reached. Please delete an old chat to create a new one.`, status: 400 };
|
return { error: `Maximum of ${config.chatLimit} active chats reached. Archive or delete an active chat to create a new one.`, status: 400 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceMessages = messageId == null
|
const sourceMessages = messageId == null
|
||||||
@@ -522,6 +657,7 @@ export function createChatService(repositories, modelService, config, webSearchP
|
|||||||
sourceChat.scenario_summary ?? null,
|
sourceChat.scenario_summary ?? null,
|
||||||
{ folderName: sourceChat.folder_name ?? null }
|
{ folderName: sourceChat.folder_name ?? null }
|
||||||
);
|
);
|
||||||
|
repositories.updateChatPersonality(clonedChat.id, user, sourceChat.personality_id || "general-assistant");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
repositories.updateChatBranchSource(clonedChat.id, user, sourceChat.id, messageId);
|
repositories.updateChatBranchSource(clonedChat.id, user, sourceChat.id, messageId);
|
||||||
@@ -576,21 +712,58 @@ export function createChatService(repositories, modelService, config, webSearchP
|
|||||||
generateRoleplayOpener,
|
generateRoleplayOpener,
|
||||||
insertChatMessage,
|
insertChatMessage,
|
||||||
containsUserVoiceInRoleplayOpener,
|
containsUserVoiceInRoleplayOpener,
|
||||||
|
async initializeRoleplay({user, chatId, assistantPersona, userPersona, scenarioPrompt, sceneSummary, selectedModel, signal = null}) {
|
||||||
|
if (chatGenerationQueues.has(chatId)) return {error: "A response is already being generated", status: 409};
|
||||||
|
return withChatGenerationLock(chatId, () => withActiveGeneration(chatId, signal, async activeSignal => {
|
||||||
|
let chat = repositories.getChat(chatId, user);
|
||||||
|
if (!chat) return {error: "Chat not found", status: 404};
|
||||||
|
// Check inside the same lock that protects generation and persistence.
|
||||||
|
if (repositories.listChatMessages(chatId).length) return {chat, existing: true, generatedInitialMessage: false};
|
||||||
|
repositories.updateChatScene(chatId, user, scenarioPrompt, sceneSummary);
|
||||||
|
chat = repositories.getChat(chatId, user);
|
||||||
|
const {model, generation} = getGenerationSettings(chat, selectedModel);
|
||||||
|
const opener = await generateRoleplayOpener({selectedModel: model, generation,
|
||||||
|
assistantPersona, userPersona, scenarioPrompt, sceneSummary, signal: activeSignal});
|
||||||
|
activeSignal.throwIfAborted();
|
||||||
|
if (!opener || containsUserVoiceInRoleplayOpener(opener, userPersona)) {
|
||||||
|
return {chat, existing: false, generatedInitialMessage: false, degraded: true};
|
||||||
|
}
|
||||||
|
insertChatMessage(chatId, "bot", opener, null, model);
|
||||||
|
repositories.touchChat(chatId, user);
|
||||||
|
return {chat: repositories.getChat(chatId, user), existing: false, generatedInitialMessage: true, opener};
|
||||||
|
}));
|
||||||
|
},
|
||||||
persistChatMessageRetryState,
|
persistChatMessageRetryState,
|
||||||
buildMessagesPayload: buildConversationContext,
|
buildMessagesPayload: buildConversationContext,
|
||||||
async sendChatMessage({ user, chatId, message, model, attachments = [], webSearch = false, reasoning = false, requestId = null }) {
|
async generateInitialChatTitle({user, chatId, message, model, signal = null}) {
|
||||||
return generateChatTurn({ user, chatId, message, model, attachments, webSearch, reasoning, requestId });
|
const session = repositories.getChat(chatId, user);
|
||||||
|
if (!session) return {error: "Chat not found", status: 404};
|
||||||
|
if (session.assistant_persona_id || session.title !== "New chat") {
|
||||||
|
return {title: session.title};
|
||||||
|
}
|
||||||
|
const firstMessage = repositories.getChatMessageByIndex(chatId, 0);
|
||||||
|
if (firstMessage && (firstMessage.role !== "user" || firstMessage.content.trim() !== String(message).trim())) {
|
||||||
|
return {title: session.title};
|
||||||
|
}
|
||||||
|
const title = await generateChatTitle(message, config.titleModel || model || session.preferred_model || "qwen3:14b", signal);
|
||||||
|
const currentSession = repositories.getChat(chatId, user);
|
||||||
|
if (!currentSession) return {error: "Chat not found", status: 404};
|
||||||
|
if (currentSession.title !== "New chat") return {title: currentSession.title};
|
||||||
|
if (title !== "New chat") repositories.updateChatTitle(chatId, user, title);
|
||||||
|
return {title};
|
||||||
},
|
},
|
||||||
async streamChatMessage({ user, chatId, message, model, attachments = [], webSearch = false, reasoning = false, onStatus, onChunk, onThinking, signal = null, requestId = null }) {
|
async sendChatMessage({ user, chatId, message, model, attachments = [], userMessageId = null, webSearch = true, reasoning = false, signal = null, requestId = null }) {
|
||||||
return generateChatTurn({ user, chatId, message, model, attachments, webSearch, reasoning, onStatus, onChunk, onThinking, signal, requestId });
|
return generateChatTurn({ user, chatId, message, model, attachments, userMessageId, webSearch, reasoning, signal, requestId });
|
||||||
|
},
|
||||||
|
async streamChatMessage({ user, chatId, message, model, attachments = [], userMessageId = null, webSearch = true, reasoning = false, onStatus, onChunk, onThinking, signal = null, requestId = null }) {
|
||||||
|
return generateChatTurn({ user, chatId, message, model, attachments, userMessageId, webSearch, reasoning, onStatus, onChunk, onThinking, signal, requestId });
|
||||||
},
|
},
|
||||||
isGenerating: (chatId) => chatGenerationQueues.has(chatId),
|
isGenerating: (chatId) => chatGenerationQueues.has(chatId),
|
||||||
isRegeneratingMessage: (chatId, messageId) => regeneratingMessages.get(chatId) === messageId,
|
isRegeneratingMessage: (chatId, messageId) => regeneratingMessages.get(chatId) === messageId,
|
||||||
async retryMessage({ user, chatId, selectedModel, targetMessage, retryStyle = null, webSearch = false, reasoning = false, onChunk = null, onThinking = null, onStatus = null, signal = null, requestId = null }) {
|
async retryMessage({ user, chatId, selectedModel, targetMessage, retryStyle = null, webSearch = true, reasoning = false, onChunk = null, onThinking = null, onStatus = null, signal = null, requestId = null }) {
|
||||||
if (chatGenerationQueues.has(chatId)) return { error: "A response is already being generated", status: 409 };
|
if (chatGenerationQueues.has(chatId)) return { error: "A response is already being generated", status: 409 };
|
||||||
regeneratingMessages.set(chatId, targetMessage.id);
|
regeneratingMessages.set(chatId, targetMessage.id);
|
||||||
return withChatGenerationLock(chatId, async () => {
|
return withChatGenerationLock(chatId, async () => {
|
||||||
await (summaryQueues.get(chatId) || Promise.resolve());
|
|
||||||
const session = repositories.getChat(chatId, user);
|
const session = repositories.getChat(chatId, user);
|
||||||
if (!session) return { error: "Chat not found", status: 404 };
|
if (!session) return { error: "Chat not found", status: 404 };
|
||||||
|
|
||||||
@@ -624,37 +797,39 @@ export function createChatService(repositories, modelService, config, webSearchP
|
|||||||
throw error;
|
throw error;
|
||||||
}).finally(() => regeneratingMessages.delete(chatId));
|
}).finally(() => regeneratingMessages.delete(chatId));
|
||||||
},
|
},
|
||||||
async editAndResendMessage({ user, chatId, targetMessage, content, model, webSearch = false, onChunk = null, signal = null, requestId = null }) {
|
async editAndResendMessage({ user, chatId, targetMessage, content, model, webSearch = true, reasoning = false, onChunk = null, signal = null, requestId = null }) {
|
||||||
if (!targetMessage || targetMessage.role !== "user") return { error: "Only user messages can be edited and resent", status: 400 };
|
if (!targetMessage || targetMessage.role !== "user") return { error: "Only user messages can be edited and resent", status: 400 };
|
||||||
const sourceChat = repositories.getChat(chatId, user);
|
if (chatGenerationQueues.has(chatId)) return { error: "A response is already being generated", status: 409 };
|
||||||
if (!sourceChat) return { error: "Chat not found", status: 404 };
|
return withChatGenerationLock(chatId, () => withActiveGeneration(chatId, signal, async activeSignal => {
|
||||||
const cloned = cloneChatThroughMessage({
|
const sourceChat = repositories.getChat(chatId, user);
|
||||||
user,
|
if (!sourceChat) return { error: "Chat not found", status: 404 };
|
||||||
sourceChat,
|
const freshTarget = repositories.getChatMessage(chatId, targetMessage.id);
|
||||||
messageId: targetMessage.parent_message_id ?? null
|
if (!freshTarget) return { error: "Message not found", status: 404 };
|
||||||
});
|
const cloned = cloneChatThroughMessage({user, sourceChat, messageId: freshTarget.parent_message_id ?? null});
|
||||||
if (cloned.error) return cloned;
|
if (cloned.error) return cloned;
|
||||||
|
|
||||||
const generated = await generateChatTurn({
|
const generated = await generateChatTurn({
|
||||||
user,
|
user,
|
||||||
chatId: cloned.chat.id,
|
chatId: cloned.chat.id,
|
||||||
attachments: attachmentService?.list(targetMessage.id).map(item => item.id) || [],
|
attachments: attachmentService?.list(freshTarget.id).map(item => item.id) || [],
|
||||||
reuseChatId: chatId,
|
reuseChatId: chatId,
|
||||||
message: content,
|
message: content,
|
||||||
model,
|
model,
|
||||||
webSearch,
|
webSearch,
|
||||||
onChunk,
|
reasoning,
|
||||||
signal,
|
onChunk,
|
||||||
requestId
|
signal: activeSignal,
|
||||||
});
|
requestId
|
||||||
if (generated.error) return { ...generated, chat: cloned.chat };
|
});
|
||||||
return {
|
if (generated.error) return { ...generated, chat: cloned.chat };
|
||||||
...generated,
|
return {
|
||||||
chat: repositories.getChat(cloned.chat.id, user),
|
...generated,
|
||||||
messages: listActiveBranchMessages(cloned.chat.id),
|
chat: repositories.getChat(cloned.chat.id, user),
|
||||||
sourceChatId: chatId,
|
messages: listActiveBranchMessages(cloned.chat.id),
|
||||||
branchedFromMessageId: targetMessage.id
|
sourceChatId: chatId,
|
||||||
};
|
branchedFromMessageId: freshTarget.id
|
||||||
|
};
|
||||||
|
}));
|
||||||
},
|
},
|
||||||
createBranchedChat({ user, chatId, messageId }) {
|
createBranchedChat({ user, chatId, messageId }) {
|
||||||
const sourceChat = repositories.getChat(chatId, user);
|
const sourceChat = repositories.getChat(chatId, user);
|
||||||
@@ -670,9 +845,10 @@ export function createChatService(repositories, modelService, config, webSearchP
|
|||||||
const leaf = includeDescendants ? repositories.getLatestDescendantMessage(chatId, messageId) : message;
|
const leaf = includeDescendants ? repositories.getLatestDescendantMessage(chatId, messageId) : message;
|
||||||
repositories.updateChatActiveLeaf(chatId, user, leaf?.id || messageId);
|
repositories.updateChatActiveLeaf(chatId, user, leaf?.id || messageId);
|
||||||
repositories.updateChatContextSummary(chatId, user, null, 0);
|
repositories.updateChatContextSummary(chatId, user, null, 0);
|
||||||
return { messages: listActiveBranchMessages(chatId) };
|
return getHistoryPage(chatId);
|
||||||
},
|
},
|
||||||
listActiveBranchMessages,
|
listActiveBranchMessages,
|
||||||
|
getHistoryPage,
|
||||||
stopGeneration({ user, chatId }) {
|
stopGeneration({ user, chatId }) {
|
||||||
if (!repositories.getChat(chatId, user)) return { error: "Chat not found", status: 404 };
|
if (!repositories.getChat(chatId, user)) return { error: "Chat not found", status: 404 };
|
||||||
const controller = activeGenerations.get(chatId);
|
const controller = activeGenerations.get(chatId);
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
export const COMPACTION_SYSTEM_PROMPT = [
|
||||||
|
"You are a context-compaction model.",
|
||||||
|
"Your output will be given to another AI model as memory of an earlier conversation.",
|
||||||
|
"Compress aggressively, but never discard information that may affect future answers.",
|
||||||
|
"Preserve current goals, user requirements, explicit constraints, decisions already made, unfinished work, project state, technical configuration, model names, versions, filenames, paths, URLs when relevant, commands, ports, identifiers, numeric values, API/interface decisions, errors, causes, attempted and successful fixes, user preferences, and relationships between important entities.",
|
||||||
|
"Remove greetings, acknowledgements, filler, repeated explanations, redundant wording, and obsolete intermediate discussion that no longer affects the current state.",
|
||||||
|
"Never invent information or silently change exact technical values. Do not replace precise facts with vague wording.",
|
||||||
|
"When newer information supersedes older information, preserve the newer state and mention the superseded state only if still relevant.",
|
||||||
|
"Distinguish facts from unresolved assumptions. Keep unresolved questions and tasks. Prefer concise factual statements.",
|
||||||
|
"Output only the requested structured JSON format."
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
export const COMPACTION_SCHEMA = {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
summary: {type: "string"},
|
||||||
|
current_goals: {type: "array", items: {type: "string"}},
|
||||||
|
important_facts: {type: "array", items: {type: "string"}},
|
||||||
|
decisions: {type: "array", items: {type: "string"}},
|
||||||
|
constraints: {type: "array", items: {type: "string"}},
|
||||||
|
technical_state: {type: "array", items: {type: "string"}},
|
||||||
|
open_tasks: {type: "array", items: {type: "string"}},
|
||||||
|
problems_and_solutions: {type: "array", items: {type: "string"}},
|
||||||
|
user_preferences: {type: "array", items: {type: "string"}},
|
||||||
|
important_exact_values: {type: "array", items: {type: "string"}}
|
||||||
|
},
|
||||||
|
required: ["summary", "current_goals", "important_facts", "decisions", "constraints", "technical_state", "open_tasks", "problems_and_solutions", "user_preferences", "important_exact_values"]
|
||||||
|
};
|
||||||
|
|
||||||
|
const FIELDS = COMPACTION_SCHEMA.required;
|
||||||
|
|
||||||
|
export function estimateTokens(value) {
|
||||||
|
return Math.ceil(String(value || "").length / 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeCompaction(value, maxChars = 10000) {
|
||||||
|
let parsed = value;
|
||||||
|
if (typeof value === "string") {
|
||||||
|
try { parsed = JSON.parse(value.replace(/^```(?:json)?\s*|\s*```$/gi, "").trim()); }
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
||||||
|
const normalized = {};
|
||||||
|
for (const field of FIELDS) {
|
||||||
|
if (field === "summary") normalized[field] = typeof parsed[field] === "string" ? parsed[field].trim() : "";
|
||||||
|
else if (!Array.isArray(parsed[field]) || parsed[field].some(item => typeof item !== "string")) return null;
|
||||||
|
else normalized[field] = parsed[field].map(item => item.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
const serialized = JSON.stringify(normalized);
|
||||||
|
const hasContent = normalized.summary || FIELDS.some(field => field !== "summary" && normalized[field].length);
|
||||||
|
if (!hasContent || serialized.length > maxChars) return null;
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactMessage(message) {
|
||||||
|
const lines = [`${message.role === "bot" ? "Assistant" : message.role}: ${String(message.content || "").trim()}`];
|
||||||
|
if (message.thinking) lines.push(`Assistant reasoning outcome: ${String(message.thinking).slice(0, 1200)}`);
|
||||||
|
if (message.error_message) lines.push(`Error: ${String(message.error_message).slice(0, 1000)}`);
|
||||||
|
if (message.delivery_status && message.delivery_status !== "complete") lines.push(`Delivery status: ${message.delivery_status}`);
|
||||||
|
if (message.sources) {
|
||||||
|
try {
|
||||||
|
const sources = typeof message.sources === "string" ? JSON.parse(message.sources) : message.sources;
|
||||||
|
if (Array.isArray(sources) && sources.length) lines.push(`Sources: ${sources.slice(0, 5).map(source => `${source.title || ""} ${source.url || ""} ${String(source.snippet || "").slice(0, 500)}`).join(" | ")}`);
|
||||||
|
} catch { /* malformed optional metadata must not block compaction */ }
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCompactionEligibleMessage(message) {
|
||||||
|
return Boolean(message && String(message.content || message.error_message || "").trim())
|
||||||
|
&& (!message.delivery_status || message.delivery_status === "complete" || message.delivery_status === "failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMessagesForCompaction(messages) {
|
||||||
|
return messages.map(compactMessage).join("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCompactionRequest(previousSummary, messages) {
|
||||||
|
return [
|
||||||
|
previousSummary ? `Existing compacted state (update it; do not repeat it blindly):\n${previousSummary}` : "Existing compacted state: none",
|
||||||
|
"",
|
||||||
|
"Newly expired conversation records:",
|
||||||
|
formatMessagesForCompaction(messages),
|
||||||
|
"",
|
||||||
|
"Return exactly one JSON object matching the supplied schema."
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import {
|
||||||
|
buildContextSummaryPrompt,
|
||||||
|
buildMemoryPrompt,
|
||||||
|
buildPersonaStatePrompt
|
||||||
|
} from "./chatMessageHelpers.js";
|
||||||
|
import {buildWebSearchContext} from "./webSearchService.js";
|
||||||
|
import {getPersonalitySystemPrompt} from "./personalityRegistry.js";
|
||||||
|
|
||||||
|
export function createConversationContextBuilder({repositories, attachmentService, now}) {
|
||||||
|
const getUserPersonaForSession = (chatId, user, activePersona) => {
|
||||||
|
const chatPersona = repositories.getUserPersonaForChat(chatId, user);
|
||||||
|
if (chatPersona || activePersona) return chatPersona || null;
|
||||||
|
return repositories.getActiveUserPersona(user);
|
||||||
|
};
|
||||||
|
|
||||||
|
return ({
|
||||||
|
user,
|
||||||
|
chatId,
|
||||||
|
session = null,
|
||||||
|
message,
|
||||||
|
upToMessageId = null,
|
||||||
|
sources = null,
|
||||||
|
historyOverride = null,
|
||||||
|
visionSelection = null,
|
||||||
|
contextSummaryOverride = undefined
|
||||||
|
}) => {
|
||||||
|
const activeSession = session || repositories.getChat(chatId, user);
|
||||||
|
if (!activeSession) return null;
|
||||||
|
const activePersona = repositories.getAssistantPersonaForChat(chatId, user);
|
||||||
|
const activeUserPersona = getUserPersonaForSession(chatId, user, activePersona);
|
||||||
|
let history = historyOverride || (upToMessageId == null
|
||||||
|
? repositories.getRecentChatMessages(chatId).reverse()
|
||||||
|
: repositories.getRecentChatMessagesUpToId(chatId, upToMessageId).reverse());
|
||||||
|
const compactedThrough = Number(activeSession.context_summary_message_id || 0);
|
||||||
|
if (compactedThrough) history = history.filter(entry => Number(entry.id || 0) > compactedThrough);
|
||||||
|
const conversation = message == null ? history : [...history, {role: "user", content: message}];
|
||||||
|
const messagesPayload = [];
|
||||||
|
// Search results stay untrusted user context, followed by their grounding rule.
|
||||||
|
const webSearchContext = sources === null ? null : buildWebSearchContext(sources, now());
|
||||||
|
if (activeSession.system_instruction) messagesPayload.push({
|
||||||
|
role: "system",
|
||||||
|
content: `CHAT-SPECIFIC INSTRUCTIONS:\n${activeSession.system_instruction}`
|
||||||
|
});
|
||||||
|
messagesPayload.push({role: "system", content: getPersonalitySystemPrompt(activeSession.personality_id)});
|
||||||
|
const summary = buildContextSummaryPrompt(contextSummaryOverride === undefined
|
||||||
|
? (upToMessageId == null ? activeSession.context_summary : null) : contextSummaryOverride);
|
||||||
|
if (summary) messagesPayload.push({role: "system", content: summary});
|
||||||
|
const memory = buildMemoryPrompt(repositories.listChatMemories(chatId, user));
|
||||||
|
if (memory) messagesPayload.push({role: "system", content: memory});
|
||||||
|
const personaState = buildPersonaStatePrompt(repositories.getPersonaChatState(chatId, user));
|
||||||
|
if (personaState) messagesPayload.push({
|
||||||
|
role: "system",
|
||||||
|
content: `Conversation continuity notes (context only):\n${personaState}`
|
||||||
|
});
|
||||||
|
const conversationMessages = conversation.map(entry => ({
|
||||||
|
role: entry.role === "bot" ? "assistant" : "user",
|
||||||
|
content: entry.content
|
||||||
|
}));
|
||||||
|
messagesPayload.push(...conversationMessages.slice(0, -1));
|
||||||
|
const currentQuestion = conversation.at(-1)?.content || "";
|
||||||
|
const selectedImages = visionSelection || attachmentService?.selectImages(
|
||||||
|
message == null ? history : [...history, {role: "user", content: message}], currentQuestion);
|
||||||
|
const visionContext = attachmentService?.context(selectedImages, currentQuestion);
|
||||||
|
if (visionContext) messagesPayload.push(...visionContext);
|
||||||
|
if (webSearchContext) messagesPayload.push(webSearchContext[0]);
|
||||||
|
if (webSearchContext?.[1]) messagesPayload.push(webSearchContext[1]);
|
||||||
|
if (conversationMessages.length) messagesPayload.push(conversationMessages.at(-1));
|
||||||
|
return {session: activeSession, activePersona, activeUserPersona, messagesPayload};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Speichert, liest und löscht hochgeladene Dateien in einem lokalen Speicherverzeichnis.
|
||||||
|
*/
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import {randomUUID} from "node:crypto";
|
import {randomUUID} from "node:crypto";
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user