Merge pull request 'Dev' (#14) from dev into main

Reviewed-on: #14
This commit was merged in pull request #14.
This commit is contained in:
2026-09-30 19:56:46 +02:00
71 changed files with 1386 additions and 161 deletions
+9 -2
View File
@@ -67,8 +67,15 @@ 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.
`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:
+4 -1
View File
@@ -1,7 +1,10 @@
/**
* @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} } },
]); ]);
+1 -1
View File
@@ -9,7 +9,7 @@
"scripts": { "scripts": {
"start": "node server.js", "start": "node server.js",
"dev": "node --watch server.js", "dev": "node --watch server.js",
"lint": "node scripts/lint.mjs", "lint": "eslint .",
"test": "node --test" "test": "node --test"
}, },
"dependencies": { "dependencies": {
+20 -10
View File
@@ -11,7 +11,7 @@
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=20260930-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">
@@ -41,6 +41,8 @@
</div> </div>
</div> </div>
</aside> </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">
@@ -127,6 +129,8 @@
<p id="modelHelpSummary" class="model-help-summary">Pick a <p id="modelHelpSummary" class="model-help-summary">Pick a
model to see what it is best suited for.</p> model to see what it is best suited for.</p>
</div> </div>
<details class="generation-advanced">
<summary>Advanced generation settings</summary>
<div class="generation-settings-grid"> <div class="generation-settings-grid">
<label class="field"><span>Temperature</span><input <label class="field"><span>Temperature</span><input
id="chatTemperature" type="number" min="0" max="2" id="chatTemperature" type="number" min="0" max="2"
@@ -144,6 +148,7 @@
<button id="saveGenerationSettings" type="button">Save <button id="saveGenerationSettings" type="button">Save
generation settings</button> generation settings</button>
</div> </div>
</details>
</div> </div>
</details> </details>
<details <details
@@ -216,12 +221,12 @@
data-chat-action="personaState">Roleplay state</button> data-chat-action="personaState">Roleplay state</button>
<button id="backupWorkspace" <button id="backupWorkspace"
class="secondary-action chat-drawer-action" class="secondary-action chat-drawer-action"
type="button">Backup workspace</button> type="button">Download workspace archive</button>
<button id="importWorkspace" <button id="importWorkspace"
class="secondary-action chat-drawer-action" class="secondary-action chat-drawer-action"
type="button">Import workspace</button> type="button">Import workspace</button>
<input id="importWorkspaceFile" class="hidden" type="file" <input id="importWorkspaceFile" class="hidden" type="file"
accept="application/json,.json" /> accept="application/json,.json,.tar.gz,application/gzip" />
</div> </div>
</details> </details>
<details class="chat-drawer-section chat-drawer-danger"> <details class="chat-drawer-section chat-drawer-danger">
@@ -259,8 +264,11 @@
</div> </div>
</div> </div>
<div id="messages" class="messages-panel messages-panel-focus" role="log" aria-live="polite" <div class="messages-wrap">
aria-relevant="additions text"></div> <div id="messages" class="messages-panel messages-panel-focus" role="log" aria-live="polite"
aria-relevant="additions text"></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-shell input-shell-focus">
<div class="input-meta"> <div class="input-meta">
@@ -308,7 +316,7 @@
</div> </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" aria-labelledby="personaFormTitle" tabindex="-1">
<div class="modal-header"> <div class="modal-header">
<div class="persona-modal-heading"> <div class="persona-modal-heading">
<p class="eyebrow">Persona editor</p> <p class="eyebrow">Persona editor</p>
@@ -372,7 +380,7 @@
</div> </div>
<div id="roleplayStarterModal" class="modal-overlay side-panel-overlay hidden"> <div id="roleplayStarterModal" class="modal-overlay side-panel-overlay hidden">
<div class="modal-card roleplay-starter-card side-panel-card"> <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="modal-header">
<div class="persona-modal-heading"> <div class="persona-modal-heading">
<p class="eyebrow">Start roleplay</p> <p class="eyebrow">Start roleplay</p>
@@ -441,7 +449,7 @@
</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" aria-describedby="onboardingSubtitle" tabindex="-1">
<div class="modal-header"> <div class="modal-header">
<div class="persona-modal-heading"> <div class="persona-modal-heading">
<p class="eyebrow">Get started</p> <p class="eyebrow">Get started</p>
@@ -458,9 +466,11 @@
</div> </div>
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js" defer></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/marked/15.0.12/marked.min.js"
integrity="sha512-rCQgmUulW6f6QegOvTntKKb5IAoxTpGVCdWqYjkXEpzAns6XUFs8NKVqWe+KQpctp/EoRSFSuykVputqknLYMg=="
crossorigin="anonymous" defer></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.6/dist/purify.min.js" defer></script> <script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.6/dist/purify.min.js" defer></script>
<script type="module" src="/js/script.js?v=20260923-vision-provenance"></script> <script type="module" src="/js/script.js?v=20260930-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>
+2
View File
@@ -33,6 +33,7 @@ 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; }
@@ -44,6 +45,7 @@ body.site-page { width:100%; max-width:none; margin:0; padding:0; background:var
.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; }
+22 -2
View File
@@ -363,7 +363,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; }
@@ -394,11 +398,14 @@ input:focus, textarea:focus, select:focus { border-color: var(--brand); }
.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; }
.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);
@@ -419,6 +426,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; }
@@ -467,7 +482,10 @@ 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; } } @media (max-width: 480px) {
.input-container { grid-template-columns: repeat(3, 36px) minmax(0, 1fr) 44px; grid-template-areas: "message message message message send" "attach search reasoning . ."; gap: 4px; }
#attachImage, #webSearch, #reasoning { width: 36px; height: 36px; min-height: 36px; border-radius: 12px; }
}
#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; }
@@ -483,6 +501,8 @@ input:focus, textarea:focus, select:focus { border-color: var(--brand); }
#send.loading::after { content: "■"; font-size: .85rem; } #send.loading::after { content: "■"; font-size: .85rem; }
.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 {
+5
View File
@@ -1,3 +1,6 @@
/**
* @file Bündelt JSON-Anfragen und verarbeitet Ereignisse gestreamter Chat-Antworten im Browser.
*/
export async function request(url, data, method = "POST") { export async function request(url, data, method = "POST") {
try { try {
const res = await fetch(url, { const res = await fetch(url, {
@@ -60,6 +63,7 @@ export async function stream(url, data, handlers = {}, signal = undefined) {
if (done) break; if (done) break;
buffer += decoder.decode(value, {stream: true}); buffer += decoder.decode(value, {stream: true});
const parts = buffer.split(/\r?\n\r?\n/); const parts = buffer.split(/\r?\n\r?\n/);
// Das letzte SSE-Ereignis kann unvollständig sein und wird mit dem nächsten Netzwerkblock ergänzt.
buffer = parts.pop() || ""; buffer = parts.pop() || "";
for (const part of parts) { for (const part of parts) {
const lines = part.split("\n"); const lines = part.split("\n");
@@ -81,6 +85,7 @@ export async function stream(url, data, handlers = {}, signal = undefined) {
} }
if (terminal) break; if (terminal) break;
} }
// Ohne explizites Abschluss- oder Fehlerereignis gilt die Verbindung als vorzeitig abgebrochen.
if (!terminal) throw new Error("Response was interrupted. Please try again."); if (!terminal) throw new Error("Response was interrupted. Please try again.");
} finally { } finally {
await reader.cancel().catch(() => {}); await reader.cancel().catch(() => {});
+25 -2
View File
@@ -1,3 +1,6 @@
/**
* @file Verwaltet Bildauswahl, Uploads und Vorschauen sowie die Anzeige gespeicherter Anhänge.
*/
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,6 +27,7 @@ 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 = [];
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(() => {}); fetch("/uploads/config").then(r => r.ok ? r.json() : limits).then(value => { limits = value; }).catch(() => {});
@@ -70,13 +74,13 @@ export function createAttachmentComposer({onChange, onError}) {
const result = await response.json(); const result = await response.json();
if (!response.ok) throw new Error(result.error || "Upload failed"); 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 fetch(`/attachments/${result.id}`, {method: "DELETE"});
} catch (error) { } catch (error) {
if (error.name !== "AbortError") item.error = error.message || "Upload failed"; if (error.name !== "AbortError") item.error = error.message || "Upload failed";
} }
item.uploading = false; item.uploading = false;
item.controller = null; item.controller = null;
render(); if (items.includes(item)) render();
}; };
const add = files => { const add = files => {
if (busy) return; if (busy) return;
@@ -114,6 +118,25 @@ export function createAttachmentComposer({onChange, onError}) {
} }
items = []; items = [];
render(); render();
},
saveDraft(chatId) {
if (chatId != null) drafts.set(chatId, items);
items = [];
render();
},
restoreDraft(chatId) {
items = drafts.get(chatId) || [];
drafts.delete(chatId);
render();
},
discardDraft(chatId) {
const saved = drafts.get(chatId) || [];
drafts.delete(chatId);
for (const item of saved) {
item.controller?.abort();
URL.revokeObjectURL(item.preview);
if (item.metadata) void fetch(`/attachments/${item.metadata.id}`, {method: "DELETE"});
}
} }
}; };
} }
+3
View File
@@ -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;
+3
View File
@@ -1,3 +1,6 @@
/**
* @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 = {
+3
View File
@@ -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");
+20 -10
View File
@@ -1,7 +1,11 @@
/**
* @file Steuert die Persona-Marktplatzoberfläche mit Suche, Vorschauen und Persona-Verwaltung.
*/
import { get, post, put } from "./api.js?v=20260909-account-controls"; import { get, post, put } from "./api.js?v=20260909-account-controls";
import { initializeAppearance } from "./themeController.js?v=20260909-account-controls"; import { initializeAppearance } 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 marketPersonaList = $("marketPersonaList"); const marketPersonaList = $("marketPersonaList");
@@ -28,7 +32,6 @@ const marketPreviewExampleDialogues = $("marketPreviewExampleDialogues");
const marketPreviewIntro = $("marketPreviewIntro"); const marketPreviewIntro = $("marketPreviewIntro");
const marketPreviewUserPersonaField = $("marketPreviewUserPersonaField"); const marketPreviewUserPersonaField = $("marketPreviewUserPersonaField");
const marketPreviewUserPersonaSelect = $("marketPreviewUserPersonaSelect"); const marketPreviewUserPersonaSelect = $("marketPreviewUserPersonaSelect");
const marketPreviewScenarioField = $("marketPreviewScenarioField");
const marketPreviewScenario = $("marketPreviewScenario"); const marketPreviewScenario = $("marketPreviewScenario");
const marketPreviewStepOne = $("marketPreviewStepOne"); const marketPreviewStepOne = $("marketPreviewStepOne");
const marketPreviewStepTwo = $("marketPreviewStepTwo"); const marketPreviewStepTwo = $("marketPreviewStepTwo");
@@ -71,7 +74,6 @@ const marketPersonaFields = {
const validMarketSorts = new Set(["best", "newest", "most_favorited", "most_popular", "top_rated", "alphabetical"]); const validMarketSorts = new Set(["best", "newest", "most_favorited", "most_popular", "top_rated", "alphabetical"]);
let marketPersonasByType = { assistant: [], user: [] }; let marketPersonasByType = { assistant: [], user: [] };
let assistantPersonas = [];
let userPersonas = []; let userPersonas = [];
let pendingMarketPersona = null; let pendingMarketPersona = null;
let editingPersonaId = null; let editingPersonaId = null;
@@ -87,7 +89,7 @@ function setMarketStatus(message, state = "") {
} }
function closeMarketPopup(value = null) { function closeMarketPopup(value = null) {
marketPopupModal.classList.add("hidden"); closeModal(marketPopupModal);
if (marketPopupResolver) { if (marketPopupResolver) {
const resolve = marketPopupResolver; const resolve = marketPopupResolver;
marketPopupResolver = null; marketPopupResolver = null;
@@ -112,7 +114,7 @@ function promptMarketPopup({
marketPopupInput.placeholder = placeholder; marketPopupInput.placeholder = placeholder;
marketPopupConfirm.textContent = confirmLabel; marketPopupConfirm.textContent = confirmLabel;
marketPopupField.classList.remove("hidden"); marketPopupField.classList.remove("hidden");
marketPopupModal.classList.remove("hidden"); openModal(marketPopupModal, marketPopupInput);
marketPopupInput.focus(); marketPopupInput.focus();
marketPopupInput.select(); marketPopupInput.select();
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -238,7 +240,7 @@ function setCollapsiblePreviewText(element, value, maxLength = 220) {
} }
function closeMarketPreview() { function closeMarketPreview() {
marketPreviewModal.classList.add("hidden"); closeModal(marketPreviewModal);
pendingMarketPersona = null; pendingMarketPersona = null;
marketPreviewStep = 1; marketPreviewStep = 1;
marketPreviewUserPersonaSelect.innerHTML = ""; marketPreviewUserPersonaSelect.innerHTML = "";
@@ -315,7 +317,7 @@ function openMarketPreview(persona) {
marketPreviewConfirm.classList.remove("hidden"); marketPreviewConfirm.classList.remove("hidden");
} }
renderMarketPreviewStep(); renderMarketPreviewStep();
marketPreviewModal.classList.remove("hidden"); openModal(marketPreviewModal, isAssistant ? marketPreviewUserPersonaSelect : marketPreviewConfirm);
} }
function openPersonaForm(persona = null) { function openPersonaForm(persona = null) {
@@ -323,12 +325,11 @@ function openPersonaForm(persona = null) {
marketPersonaFormTitle.textContent = persona ? "Edit AI Character" : "Create AI Character"; marketPersonaFormTitle.textContent = persona ? "Edit AI Character" : "Create AI Character";
populatePersonaForm(marketPersonaFields, persona || {}); populatePersonaForm(marketPersonaFields, persona || {});
setPersonaFormNotice(""); setPersonaFormNotice("");
marketPersonaModal.classList.remove("hidden"); openModal(marketPersonaModal, marketPersonaName);
marketPersonaName.focus();
} }
function closePersonaForm() { function closePersonaForm() {
marketPersonaModal.classList.add("hidden"); closeModal(marketPersonaModal);
editingPersonaId = null; editingPersonaId = null;
setPersonaFormNotice(""); setPersonaFormNotice("");
} }
@@ -507,7 +508,6 @@ async function reportPersona(marketId) {
async function loadOwnedPersonas() { async function loadOwnedPersonas() {
const res = await get("/personas"); const res = await get("/personas");
if (res.error) return false; if (res.error) return false;
assistantPersonas = res.assistantPersonas || [];
userPersonas = res.userPersonas || []; userPersonas = res.userPersonas || [];
activeUserPersonaId = res.activeUserPersonaId || null; activeUserPersonaId = res.activeUserPersonaId || null;
return true; return true;
@@ -605,9 +605,19 @@ marketPopupInput.addEventListener("keydown", (event) => {
} }
if (event.key === "Escape") { if (event.key === "Escape") {
event.preventDefault(); event.preventDefault();
event.stopPropagation();
closeMarketPopup(null); closeMarketPopup(null);
} }
}); });
document.addEventListener("keydown", event => {
if (event.key !== "Escape") return;
const modal = topModal();
if (!modal) return;
event.preventDefault();
if (modal === marketPopupModal) closeMarketPopup(null);
else if (modal === marketPersonaModal) closePersonaForm();
else if (modal === marketPreviewModal) closeMarketPreview();
});
marketSearchInput.addEventListener("input", applyMarketFilter); marketSearchInput.addEventListener("input", applyMarketFilter);
if (marketAssistantSort) { if (marketAssistantSort) {
marketAssistantSort.addEventListener("change", () => setMarketSort("assistant", marketAssistantSort.value)); marketAssistantSort.addEventListener("change", () => setMarketSort("assistant", marketAssistantSort.value));
+40
View File
@@ -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);
+3
View File
@@ -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"]);
+3
View File
@@ -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
View File
@@ -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);
+3
View File
@@ -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 = {}) {
+4
View File
@@ -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) {
@@ -43,6 +46,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;
+4
View File
@@ -1,3 +1,6 @@
/**
* @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";
@@ -46,6 +49,7 @@ export async function regenerateInline({card, scroller, endpoint, payload, rende
} }
}); });
} finally { } finally {
// 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");
} }
+336 -101
View File
@@ -1,9 +1,13 @@
/**
* @file Steuert die Chat-Oberfläche mit Gesprächsauswahl, Nachrichteneingabe, Streaming und Persona-Verwaltung.
*/
import { del, get, post, put, stream } from "./api.js?v=20260909-account-controls"; import { del, get, post, put, stream } from "./api.js?v=20260909-account-controls";
import { regenerateInline } from "./regeneration.js"; import { regenerateInline } from "./regeneration.js";
import { appendSources, createWebSearchToggle } from "./webSearch.js"; import { appendSources, createWebSearchToggle } from "./webSearch.js";
import { appendAttachments, createAttachmentComposer } from "./attachments.js?v=20260923-vision-provenance"; import { appendAttachments, createAttachmentComposer } from "./attachments.js?v=20260930-chat-usability";
import { createReasoningToggle, finishReasoning, updateReasoning } from "./reasoning.js?v=20260921-reasoning-capability"; import { createReasoningToggle, finishReasoning, updateReasoning } from "./reasoning.js?v=20260921-reasoning-capability";
import { closeChatDrawer, openChatDrawer } from "./chatDrawer.js?v=20260909-account-controls"; import { closeChatDrawer, openChatDrawer } from "./chatDrawer.js?v=20260909-account-controls";
import { openModal, closeModal, topModal } from "./modalFocus.js";
import { onboardingPrompts, requestedChatId } from "./constants.js?v=20260909-account-controls"; import { onboardingPrompts, requestedChatId } from "./constants.js?v=20260909-account-controls";
import { getModelProfile } from "./modelProfiles.js?v=20260909-account-controls"; import { getModelProfile } from "./modelProfiles.js?v=20260909-account-controls";
import { applyTheme as applySharedTheme, applyThemeMode as applySharedThemeMode, readStoredAppearance } from "./themeController.js?v=20260909-account-controls"; import { applyTheme as applySharedTheme, applyThemeMode as applySharedThemeMode, readStoredAppearance } from "./themeController.js?v=20260909-account-controls";
@@ -124,6 +128,16 @@ let chatLoadingDepth = 0;
let chatActivityDepth = 0; let chatActivityDepth = 0;
let messageRetryState = new Map(); let messageRetryState = new Map();
let availableModelsById = new Map(); let availableModelsById = new Map();
let chatSearchResults = null;
let chatSearchError = "";
let chatSearchTimer = null;
let chatSearchRequest = 0;
let chatOpenRequest = 0;
const chatDrafts = new Map();
const jumpToLatest = document.getElementById("jumpToLatest");
let scrollFrame = null;
let renderedChatId = null;
let renderedMessages = new Map();
function setNotice(message = "", state = "") { function setNotice(message = "", state = "") {
@@ -190,15 +204,33 @@ function isMobileLayout() {
} }
function closeMobileSidebar() { function closeMobileSidebar() {
if (!chatSidebar) return; if (!chatSidebar || !document.body.classList.contains("sidebar-open")) return;
document.body.classList.remove("sidebar-open"); document.body.classList.remove("sidebar-open");
if (chatSidebarToggle) chatSidebarToggle.setAttribute("aria-expanded", "false"); if (chatSidebarToggle) {
chatSidebarToggle.setAttribute("aria-expanded", "false");
chatSidebarToggle.setAttribute("aria-label", "Open conversation history");
}
chatSidebar.inert = isMobileLayout();
chatSidebar.removeAttribute("aria-modal");
chatSidebar.removeAttribute("role");
const backdrop = document.getElementById("chatSidebarBackdrop");
if (backdrop) backdrop.hidden = true;
chatSidebarToggle?.focus();
} }
function openMobileSidebar() { function openMobileSidebar() {
if (!chatSidebar) return; if (!chatSidebar || !isMobileLayout()) return;
chatSidebar.inert = false;
chatSidebar.setAttribute("role", "dialog");
chatSidebar.setAttribute("aria-modal", "true");
document.body.classList.add("sidebar-open"); document.body.classList.add("sidebar-open");
if (chatSidebarToggle) chatSidebarToggle.setAttribute("aria-expanded", "true"); if (chatSidebarToggle) {
chatSidebarToggle.setAttribute("aria-expanded", "true");
chatSidebarToggle.setAttribute("aria-label", "Close conversation history");
}
const backdrop = document.getElementById("chatSidebarBackdrop");
if (backdrop) backdrop.hidden = false;
chatSearchInput?.focus();
} }
function hasCompletedOnboarding() { function hasCompletedOnboarding() {
@@ -212,7 +244,7 @@ function markOnboardingCompleted() {
function closeOnboarding() { function closeOnboarding() {
if (!onboardingModal) return; if (!onboardingModal) return;
onboardingModal.classList.add("hidden"); closeModal(onboardingModal);
} }
function renderOnboardingStep() { function renderOnboardingStep() {
@@ -231,6 +263,7 @@ function renderOnboardingStep() {
button.addEventListener("click", () => { button.addEventListener("click", () => {
onboardingIntent = key; onboardingIntent = key;
renderOnboardingStep(); renderOnboardingStep();
onboardingChoices.querySelector(".active")?.focus();
}); });
onboardingChoices.appendChild(button); onboardingChoices.appendChild(button);
}); });
@@ -268,7 +301,7 @@ function maybeShowOnboarding() {
onboardingStep = 1; onboardingStep = 1;
onboardingIntent = "ask"; onboardingIntent = "ask";
renderOnboardingStep(); renderOnboardingStep();
onboardingModal.classList.remove("hidden"); openModal(onboardingModal, onboardingChoices.querySelector("button"));
} }
function setMessageContent(el, content) { function setMessageContent(el, content) {
const markdownParser = window.marked; const markdownParser = window.marked;
@@ -280,11 +313,6 @@ function setMessageContent(el, content) {
} }
} }
function getChatById(id) { return chatSessions.find((chat) => chat.id === id); } function getChatById(id) { return chatSessions.find((chat) => chat.id === id); }
function summarizeText(value, limit = 96) {
const normalized = String(value || "").replace(/\s+/g, " ").trim();
if (!normalized) return "";
return normalized.length > limit ? `${normalized.slice(0, limit - 3).trimEnd()}...` : normalized;
}
function getPersonaName(id, items) { function getPersonaName(id, items) {
return items.find((persona) => persona.id === id)?.name || ""; return items.find((persona) => persona.id === id)?.name || "";
} }
@@ -387,15 +415,22 @@ function resizeComposerInput() {
} }
function scrollMessagesToBottom() { function scrollMessagesToBottom() {
requestAnimationFrame(() => { if (scrollFrame !== null) cancelAnimationFrame(scrollFrame);
scrollFrame = requestAnimationFrame(() => {
scrollFrame = null;
messagesDiv.scrollTop = messagesDiv.scrollHeight; messagesDiv.scrollTop = messagesDiv.scrollHeight;
updateJumpToLatest();
}); });
} }
function getNewestMessage() { function updateJumpToLatest() {
return currentMessages.length ? currentMessages[currentMessages.length - 1] : null; const distance = messagesDiv.scrollHeight - messagesDiv.scrollTop - messagesDiv.clientHeight;
jumpToLatest.classList.toggle("hidden", distance < 180);
} }
messagesDiv.addEventListener("scroll", updateJumpToLatest, {passive: true});
jumpToLatest.addEventListener("click", scrollMessagesToBottom);
function getMessageIndexById(messageId) { function getMessageIndexById(messageId) {
return currentMessages.findIndex((message) => message.id === messageId); return currentMessages.findIndex((message) => message.id === messageId);
} }
@@ -463,18 +498,12 @@ function syncRetryStateFromMessage(message) {
return state; return state;
} }
function ensureRetryState(message) {
if (!message?.id) return null;
let state = messageRetryState.get(message.id);
if (!state) {
state = syncRetryStateFromMessage(message);
}
return state;
}
async function openCreatedChat(chat) { async function openCreatedChat(chat) {
if (!chat?.id) throw new Error("The server did not return the newly created chat"); if (!chat?.id) throw new Error("The server did not return the newly created chat");
if (chatSearchInput) chatSearchInput.value = ""; if (chatSearchInput) {
chatSearchInput.value = "";
scheduleChatSearch();
}
const chatsResponse = await get("/chats"); const chatsResponse = await get("/chats");
if (chatsResponse.error) throw new Error(chatsResponse.error); if (chatsResponse.error) throw new Error(chatsResponse.error);
chatSessions = chatsResponse.chats || []; chatSessions = chatsResponse.chats || [];
@@ -542,7 +571,7 @@ async function editChatMessage(messageId, fallbackIndex = -1) {
async function deleteChatMessage(messageId, fallbackIndex = -1) { async function deleteChatMessage(messageId, fallbackIndex = -1) {
const target = await resolveMessageTarget(messageId, fallbackIndex); const target = await resolveMessageTarget(messageId, fallbackIndex);
if (!target) return setNotice("Message is not ready yet. Try again.", "error"); if (!target) return setNotice("Message is not ready yet. Try again.", "error");
const { index, message } = target; const { message } = target;
const confirmed = await confirmPopup({ const confirmed = await confirmPopup({
eyebrow: message.role === "user" ? "Your message" : "Assistant message", eyebrow: message.role === "user" ? "Your message" : "Assistant message",
title: "Delete message", title: "Delete message",
@@ -702,6 +731,44 @@ function addMessage(contentOrMessage, isUser = false, isLoading = false, options
header.textContent = isMessageUser ? "You" : (activeChat?.assistant_persona_name || "Assistant"); header.textContent = isMessageUser ? "You" : (activeChat?.assistant_persona_name || "Assistant");
setMessageContent(body, message.content); 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 (message.deliveryStatus === "failed" && isMessageUser) {
const retryFailedBtn = document.createElement("button");
retryFailedBtn.type = "button";
retryFailedBtn.className = "msg-action-btn";
retryFailedBtn.dataset.messageAction = "retry-failed";
retryFailedBtn.textContent = "Retry";
retryFailedBtn.disabled = 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"); const editBtn = document.createElement("button");
editBtn.type = "button"; editBtn.type = "button";
editBtn.className = "msg-action-btn"; editBtn.className = "msg-action-btn";
@@ -710,7 +777,7 @@ function addMessage(contentOrMessage, isUser = false, isLoading = false, options
editBtn.addEventListener("click", () => { editBtn.addEventListener("click", () => {
void editChatMessage(message.id, options.messageIndex ?? -1); void editChatMessage(message.id, options.messageIndex ?? -1);
}); });
actions.appendChild(editBtn); menuItems.appendChild(editBtn);
const deleteBtn = document.createElement("button"); const deleteBtn = document.createElement("button");
deleteBtn.type = "button"; deleteBtn.type = "button";
@@ -720,14 +787,14 @@ function addMessage(contentOrMessage, isUser = false, isLoading = false, options
deleteBtn.addEventListener("click", () => { deleteBtn.addEventListener("click", () => {
void deleteChatMessage(message.id, options.messageIndex ?? -1); void deleteChatMessage(message.id, options.messageIndex ?? -1);
}); });
actions.appendChild(deleteBtn); menuItems.appendChild(deleteBtn);
const branchBtn = document.createElement("button"); const branchBtn = document.createElement("button");
branchBtn.type = "button"; branchBtn.type = "button";
branchBtn.className = "msg-action-btn"; branchBtn.className = "msg-action-btn";
branchBtn.textContent = "Branch here"; branchBtn.textContent = "Branch here";
branchBtn.addEventListener("click", () => { void branchFromMessage(message.id); }); branchBtn.addEventListener("click", () => { void branchFromMessage(message.id); });
actions.appendChild(branchBtn); menuItems.appendChild(branchBtn);
if (message.role === "bot") { if (message.role === "bot") {
const retriesUsed = Math.max(0, Number(message.versionCount || 1) - 1); const retriesUsed = Math.max(0, Number(message.versionCount || 1) - 1);
@@ -781,7 +848,7 @@ function addMessage(contentOrMessage, isUser = false, isLoading = false, options
if (!isMessageUser) appendSources(msgDiv, message.sources); if (!isMessageUser) appendSources(msgDiv, message.sources);
if (message.deliveryStatus === "failed" && message.errorMessage) { if (message.deliveryStatus === "failed" && message.errorMessage) {
const error = document.createElement("p"); const error = document.createElement("p");
error.className = "status"; error.className = "status error";
error.textContent = message.errorMessage; error.textContent = message.errorMessage;
msgDiv.appendChild(error); msgDiv.appendChild(error);
} }
@@ -797,21 +864,46 @@ function addMessage(contentOrMessage, isUser = false, isLoading = false, options
} }
function renderMessages() { function renderMessages() {
messagesDiv.innerHTML = ""; if (scrollFrame !== null) { cancelAnimationFrame(scrollFrame); scrollFrame = null; }
const sameChat = renderedChatId === activeChatId;
const wasReadingEarlier = sameChat && messagesDiv.scrollHeight - messagesDiv.scrollTop - messagesDiv.clientHeight >= 100;
const previousScrollTop = messagesDiv.scrollTop;
const chat = getChatById(activeChatId); const chat = getChatById(activeChatId);
if (!currentMessages.length && (!chat || chat.assistant_persona_id)) { if (!currentMessages.length && (!chat || chat.assistant_persona_id)) {
messagesDiv.replaceChildren();
renderMessageEmptyState(); renderMessageEmptyState();
renderedMessages = new Map();
} else { } else {
currentMessages.forEach((msg, index) => addMessage(msg, msg.role === "user", false, { const fragment = document.createDocumentFragment();
messageIndex: index, const nextRendered = new Map();
isNewest: index === currentMessages.length - 1 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 === currentMessages.length - 1,
detached: true
});
fragment.append(card);
if (key) nextRendered.set(key, {snapshot, node: card});
});
messagesDiv.replaceChildren(fragment);
renderedMessages = nextRendered;
} }
renderedChatId = activeChatId;
renderDrawerSummary(); renderDrawerSummary();
updateChatActionState(); updateChatActionState();
updateComposerPlaceholder(); updateComposerPlaceholder();
if (currentMessages.length) { if (wasReadingEarlier) {
messagesDiv.scrollTop = previousScrollTop;
updateJumpToLatest();
} else if (currentMessages.length) {
scrollMessagesToBottom(); scrollMessagesToBottom();
} else {
updateJumpToLatest();
} }
} }
@@ -1046,19 +1138,22 @@ function maybeShowPersonaNudge() {
function renderChatList() { function renderChatList() {
const query = chatSearchInput.value.trim().toLowerCase(); const query = chatSearchInput.value.trim().toLowerCase();
const filtered = chatSessions.filter((chat) => !query const source = query ? (chatSearchResults ?? []) : chatSessions;
const filtered = source.filter((chat) => !query
|| chat.title.toLowerCase().includes(query) || chat.title.toLowerCase().includes(query)
|| String(chat.matched_content || "").toLowerCase().includes(query)); || String(chat.matched_content || "").toLowerCase().includes(query));
chatList.innerHTML = ""; chatList.innerHTML = "";
if (!filtered.length) { if (!filtered.length) {
const empty = document.createElement("p"); const empty = document.createElement("p");
empty.className = "status"; empty.className = "status";
empty.textContent = query ? "No chats match this search." : "No chats yet. Start a new one."; empty.textContent = query && chatSearchError ? chatSearchError
: query && chatSearchResults === null ? "Searching chats..."
: query ? "No chats match this search." : "No chats yet. Start a new one.";
chatList.appendChild(empty); chatList.appendChild(empty);
return; return;
} }
const folderGroups = filtered const folderGroups = filtered
.filter((chat) => chat.folder_name && !chat.archived_at) .filter((chat) => chat.folder_name && !chat.is_pinned && !chat.archived_at)
.reduce((map, chat) => { .reduce((map, chat) => {
const key = String(chat.folder_name).trim(); const key = String(chat.folder_name).trim();
const items = map.get(key) || []; const items = map.get(key) || [];
@@ -1083,13 +1178,13 @@ function renderChatList() {
list.setAttribute("role", "list"); list.setAttribute("role", "list");
section.append(heading, list); section.append(heading, list);
group.items.forEach((chat) => { group.items.forEach((chat) => {
const item = document.createElement("div"), content = document.createElement("div"), title = document.createElement("span"), meta = document.createElement("small"), remove = document.createElement("button"); const item = document.createElement("div"), content = document.createElement("button"), title = document.createElement("span"), meta = document.createElement("small"), remove = document.createElement("button");
item.className = `chat-item${chat.id === activeChatId ? " active" : ""}`; item.className = `chat-item${chat.id === activeChatId ? " active" : ""}`;
item.setAttribute("role", "button"); item.setAttribute("role", "listitem");
item.tabIndex = 0; content.type = "button";
item.setAttribute("aria-label", `Open chat ${chat.title}`); content.className = "chat-item-copy secondary-action";
if (chat.id === activeChatId) item.setAttribute("aria-current", "page"); content.setAttribute("aria-label", `Open chat ${chat.title}`);
content.className = "chat-item-copy"; if (chat.id === activeChatId) content.setAttribute("aria-current", "page");
title.textContent = chat.title; title.textContent = chat.title;
meta.className = "chat-item-meta"; meta.className = "chat-item-meta";
const chatType = chat.assistant_persona_id const chatType = chat.assistant_persona_id
@@ -1102,19 +1197,23 @@ function renderChatList() {
remove.setAttribute("aria-label", `Delete chat ${chat.title}`); remove.setAttribute("aria-label", `Delete chat ${chat.title}`);
remove.addEventListener("click", async (event) => { remove.addEventListener("click", async (event) => {
event.stopPropagation(); event.stopPropagation();
closeMobileSidebar();
await deleteChat(chat.id); await deleteChat(chat.id);
}); });
content.append(title, meta); content.append(title, meta);
if (query && chat.matched_content) {
const excerpt = document.createElement("small");
const normalized = chat.matched_content.replace(/\s+/g, " ");
const matchAt = normalized.toLowerCase().indexOf(query);
const start = Math.max(0, matchAt - 35);
excerpt.className = "chat-item-excerpt";
excerpt.textContent = `${start ? "…" : ""}${normalized.slice(start, start + 110)}${normalized.length > start + 110 ? "…" : ""}`;
content.append(excerpt);
}
item.append(content, remove); item.append(content, remove);
item.addEventListener("click", () => { content.addEventListener("click", () => {
closeMobileSidebar(); closeMobileSidebar();
setActiveChat(chat.id); void setActiveChat(chat.id, {matchMessageId: query ? chat.matched_message_id : null});
});
item.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
closeMobileSidebar();
void setActiveChat(chat.id);
}); });
list.appendChild(item); list.appendChild(item);
}); });
@@ -1131,7 +1230,7 @@ function updateActivePersonaStatus(items, activeId, statusEl, emptyText, prefix)
function openPersonaForm(persona = null, personaType = "assistant") { function openPersonaForm(persona = null, personaType = "assistant") {
closeChatDrawer(); closeChatDrawer();
closePopup(false); closePopup(false);
personaModal.classList.remove("hidden"); openModal(personaModal, personaNameInput);
if (persona) { if (persona) {
editingPersonaId = persona.id; editingPersonaType = persona.persona_type || "assistant"; editingPersonaId = persona.id; editingPersonaType = persona.persona_type || "assistant";
personaFormTitle.textContent = `Edit ${editingPersonaType === "user" ? "your persona" : "AI Character"}`; personaFormTitle.textContent = `Edit ${editingPersonaType === "user" ? "your persona" : "AI Character"}`;
@@ -1151,7 +1250,7 @@ function openPersonaForm(persona = null, personaType = "assistant") {
personaNameInput.focus(); personaNameInput.focus();
} }
function closePersonaForm() { personaModal.classList.add("hidden"); editingPersonaId = null; editingPersonaType = "assistant"; personaTypeSelect.disabled = false; setPersonaFormNotice(""); } function closePersonaForm() { closeModal(personaModal); editingPersonaId = null; editingPersonaType = "assistant"; personaTypeSelect.disabled = false; setPersonaFormNotice(""); }
function populateRoleplayStarter() { function populateRoleplayStarter() {
if (!roleplayCharacterSelect || !roleplayUserPersonaSelect) return; if (!roleplayCharacterSelect || !roleplayUserPersonaSelect) return;
roleplayCharacterSelect.innerHTML = ""; roleplayCharacterSelect.innerHTML = "";
@@ -1200,12 +1299,11 @@ function openRoleplayStarter(characterId = null) {
populateRoleplayStarter(); populateRoleplayStarter();
if (roleplayScenarioInput) roleplayScenarioInput.value = ""; if (roleplayScenarioInput) roleplayScenarioInput.value = "";
setRoleplayStarterNotice(""); setRoleplayStarterNotice("");
roleplayStarterModal.classList.remove("hidden"); openModal(roleplayStarterModal, roleplayCharacterSelect);
if (roleplayCharacterSelect.options.length) roleplayCharacterSelect.focus();
} }
function closeRoleplayStarter() { function closeRoleplayStarter() {
if (!roleplayStarterModal) return; if (!roleplayStarterModal) return;
roleplayStarterModal.classList.add("hidden"); closeModal(roleplayStarterModal);
roleplayPresetCharacterId = null; roleplayPresetCharacterId = null;
roleplayStarterStep = 1; roleplayStarterStep = 1;
roleplayStarterConfirm.disabled = false; roleplayStarterConfirm.disabled = false;
@@ -1234,7 +1332,7 @@ function finishPopup(result) {
} }
function closePopup(resolveValue = null) { function closePopup(resolveValue = null) {
if (!popupModal || popupModal.classList.contains("hidden")) return; if (!popupModal || popupModal.classList.contains("hidden")) return;
popupModal.classList.add("hidden"); closeModal(popupModal);
popupField.classList.add("hidden"); popupField.classList.add("hidden");
popupInput.value = ""; popupInput.value = "";
popupConfirmBtn.disabled = false; popupConfirmBtn.disabled = false;
@@ -1262,7 +1360,7 @@ function openPopup(options) {
} else { } else {
popupInput.value = ""; popupInput.value = "";
} }
popupModal.classList.remove("hidden"); openModal(popupModal, options.mode === "prompt" ? popupInput : popupConfirmBtn);
return new Promise((resolve) => { return new Promise((resolve) => {
popupResolver = resolve; popupResolver = resolve;
requestAnimationFrame(() => { requestAnimationFrame(() => {
@@ -1405,6 +1503,9 @@ function setLoadingState(loading, overlayOptions = null) {
messagesDiv.querySelectorAll('[data-message-action="regenerate"]').forEach((button) => { messagesDiv.querySelectorAll('[data-message-action="regenerate"]').forEach((button) => {
button.disabled = loading || button.dataset.retryLimitReached === "true"; button.disabled = loading || button.dataset.retryLimitReached === "true";
}); });
messagesDiv.querySelectorAll('[data-message-action="retry-failed"]').forEach((button) => {
button.disabled = loading;
});
if (loading) { if (loading) {
msgInput.placeholder = "Processing response..."; msgInput.placeholder = "Processing response...";
if (overlayOptions === false) { if (overlayOptions === false) {
@@ -1427,8 +1528,21 @@ async function stopGeneration() {
setNotice(res.stopped ? "Stopping generation..." : "Generation already finished."); setNotice(res.stopped ? "Stopping generation..." : "Generation already finished.");
} }
async function setActiveChat(id, {preserveComposer = false} = {}) { async function setActiveChat(id, {preserveComposer = false, matchMessageId = null} = {}) {
if (!preserveComposer && id !== activeChatId) attachmentComposer.discard(); const openRequest = ++chatOpenRequest;
if (!preserveComposer && id !== activeChatId) {
if (activeChatId != null) {
chatDrafts.set(activeChatId, msgInput.value);
attachmentComposer.saveDraft(activeChatId);
msgInput.value = chatDrafts.get(id) || "";
attachmentComposer.restoreDraft(id);
resizeComposerInput();
} else if (!msgInput.value && !attachmentComposer.pending && !attachmentComposer.attachments.length) {
msgInput.value = chatDrafts.get(id) || "";
attachmentComposer.restoreDraft(id);
resizeComposerInput();
}
}
if (isMobileLayout()) closeMobileSidebar(); if (isMobileLayout()) closeMobileSidebar();
setChatLoading(true, "Opening chat..."); setChatLoading(true, "Opening chat...");
setChatActivity(true, { setChatActivity(true, {
@@ -1440,16 +1554,27 @@ async function setActiveChat(id, {preserveComposer = false} = {}) {
history.replaceState(null, "", `/app/chats/${id}`); history.replaceState(null, "", `/app/chats/${id}`);
messageRetryState = new Map(); messageRetryState = new Map();
updateWorkspaceCopy(); renderChatList(); updateChatActionState(); updateWorkspaceCopy(); renderChatList(); updateChatActionState();
const res = await get(`/chats/${id}/messages`); let res = await get(`/chats/${id}/messages`);
if (openRequest !== chatOpenRequest) { setChatLoading(false); setChatActivity(false); return; }
if (res.error) { if (res.error) {
setChatLoading(false); setChatLoading(false);
setChatActivity(false); setChatActivity(false);
return setNotice(res.error, "error"); return setNotice(res.error, "error");
} }
if (matchMessageId && !res.messages?.some(message => message.id === matchMessageId)) {
const activated = await post(`/chats/${id}/branches/${matchMessageId}/activate`, {includeDescendants: true});
if (!activated.error) res = await get(`/chats/${id}/messages`);
if (openRequest !== chatOpenRequest) { setChatLoading(false); setChatActivity(false); return; }
}
const existing = getChatById(id); const existing = getChatById(id);
if (res.chat && existing) Object.assign(existing, res.chat); if (res.chat && existing) Object.assign(existing, res.chat);
populateGenerationSettings(res.chat || existing); populateGenerationSettings(res.chat || existing);
currentMessages = res.messages || []; renderMessages(); currentMessages = res.messages || []; renderMessages();
if (matchMessageId) requestAnimationFrame(() => {
const target = [...messagesDiv.querySelectorAll("[data-message-id]")]
.find(node => Number(node.dataset.messageId) === Number(matchMessageId));
if (target) { target.scrollIntoView({block: "center"}); updateJumpToLatest(); }
});
updateSendState(); updateSendState();
setChatLoading(false); setChatLoading(false);
setChatActivity(false); setChatActivity(false);
@@ -1457,13 +1582,17 @@ async function setActiveChat(id, {preserveComposer = false} = {}) {
async function loadChatSessions() { async function loadChatSessions() {
setChatLoading(true, "Loading chats..."); setChatLoading(true, "Loading chats...");
const query = chatSearchInput?.value.trim(); const res = await get("/chats");
const res = query ? await get(`/chats/search?q=${encodeURIComponent(query)}`) : await get("/chats");
if (res.error) { if (res.error) {
setChatLoading(false); setChatLoading(false);
return setNotice(res.error, "error"); return setNotice(res.error, "error");
} }
chatSessions = res.chats || []; renderChatList(); chatSessions = res.chats || []; renderChatList();
if (activeChatId && getChatById(activeChatId)) {
updateWorkspaceCopy();
setChatLoading(false);
return;
}
if (location.pathname.endsWith("/new") && !activeChatId) { currentMessages = []; renderMessages(); updateWorkspaceCopy(); updateSendState(); setChatLoading(false); return; } if (location.pathname.endsWith("/new") && !activeChatId) { currentMessages = []; renderMessages(); updateWorkspaceCopy(); updateSendState(); setChatLoading(false); return; }
if (requestedChatId && !getChatById(requestedChatId) && !activeChatId) { setChatLoading(false); return setNotice("Conversation not found. Choose a chat from the sidebar or start a new one.", "error"); } if (requestedChatId && !getChatById(requestedChatId) && !activeChatId) { setChatLoading(false); return setNotice("Conversation not found. Choose a chat from the sidebar or start a new one.", "error"); }
if (chatSessions.length) { if (chatSessions.length) {
@@ -1480,8 +1609,34 @@ async function loadChatSessions() {
return setNotice("Pick a starting action to begin.", "success"); 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 !== chatSearchRequest || chatSearchInput.value.trim() !== query) return;
if (res.error) {
chatSearchError = "Unable to search chats.";
renderChatList();
setNotice(res.error, "error");
return;
}
chatSearchResults = res.chats || [];
renderChatList();
}
function scheduleChatSearch() {
clearTimeout(chatSearchTimer);
const requestId = ++chatSearchRequest;
const query = chatSearchInput.value.trim();
chatSearchResults = null;
chatSearchError = "";
if (!query) {
renderChatList();
return;
}
renderChatList();
chatSearchTimer = setTimeout(() => { void searchChatSessions(query, requestId); }, 300);
}
async function createNewChat({preserveComposer = false} = {}) { async function createNewChat({preserveComposer = false} = {}) {
if (!preserveComposer) attachmentComposer.discard();
setChatLoading(true, "Creating chat..."); setChatLoading(true, "Creating chat...");
setChatActivity(true, { setChatActivity(true, {
eyebrow: "Creating chat", eyebrow: "Creating chat",
@@ -1629,18 +1784,18 @@ function exportCurrentChat() {
} }
async function backupWorkspace() { async function backupWorkspace() {
const response = await fetch("/exports/workspace"); const response = await fetch("/exports/workspace/archive");
const data = await response.json(); if (!response.ok) return setNotice("Workspace archive could not be created.", "error");
const blob = new Blob([JSON.stringify(data.workspace, null, 2)], { type: "application/json;charset=utf-8" }); const blob = await response.blob();
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const link = document.createElement("a"); const link = document.createElement("a");
link.href = url; link.href = url;
link.download = `workspace-backup-${new Date().toISOString().slice(0, 10)}.json`; link.download = `workspace-backup-${new Date().toISOString().slice(0, 10)}.tar.gz`;
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
link.remove(); link.remove();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
setNotice("Workspace backup downloaded.", "success"); setNotice("Workspace archive downloaded with images.", "success");
} }
function formatImportPreview(preview, warnings = []) { function formatImportPreview(preview, warnings = []) {
@@ -1657,6 +1812,31 @@ function formatImportPreview(preview, warnings = []) {
async function importWorkspaceFromFile(file) { async function importWorkspaceFromFile(file) {
if (!file) return; if (!file) return;
setChatLoading(true, "Reading workspace backup..."); 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");
const response = await fetch(path, {method: "POST", body});
return response.json();
};
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 Promise.all([loadPersonas(), 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; let workspace;
try { try {
workspace = JSON.parse(await file.text()); workspace = JSON.parse(await file.text());
@@ -1801,7 +1981,9 @@ async function deleteChat(id) {
return setNotice(res.error, "error"); return setNotice(res.error, "error");
} }
chatSessions = chatSessions.filter((item) => item.id !== id); chatSessions = chatSessions.filter((item) => item.id !== id);
if (activeChatId === id) { activeChatId = null; currentMessages = []; renderMessages(); } chatDrafts.delete(id);
attachmentComposer.discardDraft(id);
if (activeChatId === id) { attachmentComposer.discard(); msgInput.value = ""; activeChatId = null; currentMessages = []; renderMessages(); }
messageRetryState = new Map(); messageRetryState = new Map();
await loadSummary(); renderChatList(); await loadSummary(); renderChatList();
if (chatSessions.length) await setActiveChat(chatSessions[0].id); else await createNewChat(); if (chatSessions.length) await setActiveChat(chatSessions[0].id); else await createNewChat();
@@ -1846,15 +2028,21 @@ async function checkSession() {
// Starters are offered on workspace home. // Starters are offered on workspace home.
const starter = new URLSearchParams(location.search).get("prompt"); const starter = new URLSearchParams(location.search).get("prompt");
if (starter) { msgInput.value = starter; resizeComposerInput(); updateSendState(); } if (starter) { msgInput.value = starter; resizeComposerInput(); updateSendState(); }
msgInput.focus(); return true; msgInput.focus();
if (!starter && !activeChatId) maybeShowOnboarding();
return true;
} }
async function sendMessage() { async function sendMessage({ retryMessage = null } = {}) {
if (isProcessing) return; if (isProcessing) return;
if (attachmentComposer.pending) return; if (attachmentComposer.pending) return;
const attachments = attachmentComposer.attachments; const attachments = retryMessage?.attachments || attachmentComposer.attachments;
const message = msgInput.value.trim() || (attachments.length ? "Describe the attached images." : ""); const message = retryMessage?.content || msgInput.value.trim() || (attachments.length ? "Describe the attached images." : "");
if (!message) return; if (!message) return;
if (retryMessage && !msgInput.value.trim()) {
msgInput.value = message;
resizeComposerInput();
}
const webSearch = webSearchToggle.enabled; const webSearch = webSearchToggle.enabled;
const reasoning = reasoningToggle.enabled; const reasoning = reasoningToggle.enabled;
if (!activeChatId) await createNewChat({preserveComposer: true}); if (!activeChatId) await createNewChat({preserveComposer: true});
@@ -1865,25 +2053,31 @@ async function sendMessage() {
currentChat.title = autoTitle; updateWorkspaceCopy(); renderChatList(); await put(`/chats/${activeChatId}`, { title: autoTitle }); currentChat.title = autoTitle; updateWorkspaceCopy(); renderChatList(); await put(`/chats/${activeChatId}`, { title: autoTitle });
} }
const userMessage = { role: "user", content: message, attachments }; const userMessage = { role: "user", content: message, attachments };
currentMessages.push(userMessage); addMessage(userMessage, true); updateChatActionState(); currentMessages.push(userMessage);
msgInput.value = ""; const userCard = addMessage(userMessage, true);
resizeComposerInput(); updateChatActionState();
setLoadingState(true, false); setNotice("Generating reply..."); setLoadingState(true, false); setNotice("Generating reply...");
const loadingMsg = addMessage({ role: "bot", content: "" }, false, true, { isNewest: true }); const loadingMsg = addMessage({ role: "bot", content: "" }, false, true, { isNewest: true });
const loadingBody = loadingMsg.querySelector(".msg-content"); const loadingBody = loadingMsg.querySelector(".msg-content");
const loadingShell = loadingMsg.querySelector(".loading-shell"); const loadingShell = loadingMsg.querySelector(".loading-shell");
const loadingLabel = loadingMsg.querySelector(".loading-copy strong"); const loadingLabel = loadingMsg.querySelector(".loading-copy strong");
loadingLabel.textContent = "Preparing response..."; loadingLabel.textContent = "Preparing response...";
const showSendFailure = (errorMessage) => {
if (!msgInput.value.trim()) {
msgInput.value = message;
resizeComposerInput();
}
userMessage.deliveryStatus = "failed";
userMessage.errorMessage = errorMessage;
userCard.replaceWith(addMessage(userMessage, true, false, { detached: true }));
setNotice(errorMessage, "error");
};
let completedReply = null;
try { try {
let streamError = null; let streamError = null;
await stream("/chat/stream", { message, attachments: attachments.map(item => item.id), model: modelSelect.value, chatId: activeChatId, webSearch, reasoning }, { await stream("/chat/stream", { message, attachments: attachments.map(item => item.id), model: modelSelect.value, chatId: activeChatId, webSearch, reasoning }, {
onStatus: ({ status, sourceCount, searchState }) => { onStatus: ({ status, sourceCount, searchState }) => {
if (status === "accepted") { if (status === "accepted") return;
attachmentComposer.clear();
webSearchToggle.reset();
reasoningToggle.reset();
return;
}
loadingLabel.textContent = status === "analyzing" ? "Analyzing images..." : status === "searching" ? "Searching the web..." loadingLabel.textContent = status === "analyzing" ? "Analyzing images..." : status === "searching" ? "Searching the web..."
: searchState === "no_relevant_results" ? "No sufficiently relevant results. Generating response..." : searchState === "no_relevant_results" ? "No sufficiently relevant results. Generating response..."
: sourceCount === 0 ? "No web results found. Generating response..." : "Generating response..."; : sourceCount === 0 ? "No web results found. Generating response..." : "Generating response...";
@@ -1906,7 +2100,10 @@ async function sendMessage() {
} }
if (follow) scrollMessagesToBottom(); if (follow) scrollMessagesToBottom();
}, },
onDone: () => { webSearchToggle.reset(); reasoningToggle.reset(); finishReasoning(loadingMsg); }, onDone: ({ reply }) => {
completedReply = reply;
webSearchToggle.reset(); reasoningToggle.reset(); finishReasoning(loadingMsg);
},
onError: ({ error }) => { onError: ({ error }) => {
streamError = error || "Failed to generate reply."; streamError = error || "Failed to generate reply.";
} }
@@ -1915,14 +2112,16 @@ async function sendMessage() {
const previousScrollTop = messagesDiv.scrollTop; const previousScrollTop = messagesDiv.scrollTop;
if (messagesDiv.contains(loadingMsg)) messagesDiv.removeChild(loadingMsg); if (messagesDiv.contains(loadingMsg)) messagesDiv.removeChild(loadingMsg);
if (streamError) { if (streamError) {
if (webSearch) { showSendFailure(streamError);
msgInput.value = message;
resizeComposerInput();
}
addMessage(`[Error: ${streamError}]`);
setNotice(streamError, "error");
return; return;
} }
if (msgInput.value.trim() === message) {
msgInput.value = "";
resizeComposerInput();
}
const composerAttachments = attachmentComposer.attachments;
if (composerAttachments.length === attachments.length
&& composerAttachments.every((item, index) => item.id === attachments[index].id)) attachmentComposer.clear();
await setActiveChat(activeChatId); await setActiveChat(activeChatId);
await loadSummary(); await loadSummary();
markOnboardingCompleted(); markOnboardingCompleted();
@@ -1931,12 +2130,16 @@ async function sendMessage() {
setNotice("Reply received.", "success"); setNotice("Reply received.", "success");
} catch (error) { } catch (error) {
if (messagesDiv.contains(loadingMsg)) messagesDiv.removeChild(loadingMsg); if (messagesDiv.contains(loadingMsg)) messagesDiv.removeChild(loadingMsg);
if (webSearch) {
msgInput.value = message;
resizeComposerInput();
}
const errorMessage = error?.message || "Network error while sending message."; const errorMessage = error?.message || "Network error while sending message.";
addMessage(`[Error: ${errorMessage}]`); setNotice(errorMessage, "error"); if (completedReply !== null) {
if (userCard.isConnected) {
currentMessages.push({ role: "bot", content: completedReply });
addMessage(completedReply);
}
setNotice(`Reply sent, but the conversation could not be refreshed: ${errorMessage}`, "error");
} else {
showSendFailure(errorMessage);
}
} finally { } finally {
setLoadingState(false); msgInput.focus(); updateChatActionState(); setLoadingState(false); msgInput.focus(); updateChatActionState();
} }
@@ -1993,7 +2196,7 @@ function bindChatDrawerActions() {
} }
sendBtn.addEventListener("click", () => { void (isProcessing ? stopGeneration() : sendMessage()); }); sendBtn.addEventListener("click", () => { void (isProcessing ? stopGeneration() : sendMessage()); });
msgInput.addEventListener("keydown", (event) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); void sendMessage(); } }); msgInput.addEventListener("keydown", (event) => { if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { event.preventDefault(); void sendMessage(); } });
msgInput.addEventListener("input", function () { msgInput.addEventListener("input", function () {
resizeComposerInput(); resizeComposerInput();
updateSendState(); updateSendState();
@@ -2016,7 +2219,7 @@ if (importWorkspaceBtn) importWorkspaceBtn.addEventListener("click", () => {
if (importWorkspaceFileInput) importWorkspaceFileInput.addEventListener("change", () => { if (importWorkspaceFileInput) importWorkspaceFileInput.addEventListener("change", () => {
void importWorkspaceFromFile(importWorkspaceFileInput.files?.[0]); void importWorkspaceFromFile(importWorkspaceFileInput.files?.[0]);
}); });
chatSearchInput.addEventListener("input", () => { void loadChatSessions(); }); chatSearchInput.addEventListener("input", scheduleChatSearch);
modelSelect.addEventListener("change", () => { modelSelect.addEventListener("change", () => {
renderModelSection(); renderModelSection();
void saveSelectedModel(); void saveSelectedModel();
@@ -2026,7 +2229,7 @@ if (headerModelButton) headerModelButton.addEventListener("click", (event) => {
openModelSettings(); openModelSettings();
}); });
saveGenerationSettingsBtn.addEventListener("click", () => { void saveGenerationSettings(); }); saveGenerationSettingsBtn.addEventListener("click", () => { void saveGenerationSettings(); });
roleplayNewPersonaBtn.addEventListener("click", () => openPersonaForm(null, "assistant")); roleplayNewPersonaBtn.addEventListener("click", () => { closeRoleplayStarter(); openPersonaForm(null, "assistant"); });
clearUserPersonaBtn.addEventListener("click", () => { clearUserPersonaBtn.addEventListener("click", () => {
closeChatDrawer(); closeChatDrawer();
void clearPersona("user"); void clearPersona("user");
@@ -2069,9 +2272,11 @@ popupInput.addEventListener("keydown", (event) => {
} }
}); });
bindChatDrawerActions(); bindChatDrawerActions();
if (chatSidebar) chatSidebar.inert = isMobileLayout();
if (chatSidebar) { if (chatSidebar) {
chatSidebar.addEventListener("click", (event) => event.stopPropagation()); chatSidebar.addEventListener("click", (event) => event.stopPropagation());
} }
document.getElementById("chatSidebarBackdrop")?.addEventListener("click", closeMobileSidebar);
if (chatSidebarToggle) { if (chatSidebarToggle) {
chatSidebarToggle.addEventListener("click", (event) => { chatSidebarToggle.addEventListener("click", (event) => {
event.stopPropagation(); event.stopPropagation();
@@ -2086,12 +2291,14 @@ if (onboardingContinue) {
onboardingContinue.addEventListener("click", () => { onboardingContinue.addEventListener("click", () => {
onboardingStep = 2; onboardingStep = 2;
renderOnboardingStep(); renderOnboardingStep();
onboardingChoices.querySelector("button")?.focus();
}); });
} }
if (onboardingBack) { if (onboardingBack) {
onboardingBack.addEventListener("click", () => { onboardingBack.addEventListener("click", () => {
onboardingStep = 1; onboardingStep = 1;
renderOnboardingStep(); renderOnboardingStep();
(onboardingChoices.querySelector(".active") || onboardingChoices.querySelector("button"))?.focus();
}); });
} }
if (onboardingSkip) { if (onboardingSkip) {
@@ -2102,14 +2309,42 @@ if (onboardingModal) {
if (event.target === onboardingModal) markOnboardingCompleted(); if (event.target === onboardingModal) markOnboardingCompleted();
}); });
} }
document.addEventListener("click", () => { document.addEventListener("click", (event) => {
if (!event.target.closest(".msg-action-menu")) {
document.querySelectorAll(".msg-action-menu[open]").forEach((menu) => { menu.open = false; });
}
closeMobileSidebar(); closeMobileSidebar();
}); });
window.addEventListener("resize", () => { window.addEventListener("resize", () => {
if (!isMobileLayout()) closeMobileSidebar(); if (!isMobileLayout()) closeMobileSidebar();
if (chatSidebar) chatSidebar.inert = isMobileLayout() && !document.body.classList.contains("sidebar-open");
}); });
document.addEventListener("keydown", (event) => { document.addEventListener("keydown", (event) => {
if (event.key === "Tab" && isMobileLayout() && document.body.classList.contains("sidebar-open")) {
const focusables = [...chatSidebar.querySelectorAll('button:not([disabled]), input:not([disabled]), [tabindex="0"]')]
.filter((element) => !element.hidden && !element.closest("[hidden]"));
const first = focusables[0], last = focusables.at(-1);
if (event.shiftKey && (document.activeElement === first || !chatSidebar.contains(document.activeElement))) {
event.preventDefault(); last?.focus();
} else if (!event.shiftKey && (document.activeElement === last || !chatSidebar.contains(document.activeElement))) {
event.preventDefault(); first?.focus();
}
}
if (event.key === "Escape") { if (event.key === "Escape") {
const activeModal = topModal();
if (activeModal) {
event.preventDefault();
if (activeModal === popupModal) closePopup(false);
else if (activeModal === personaModal) closePersonaForm();
else if (activeModal === roleplayStarterModal) closeRoleplayStarter();
else if (activeModal === onboardingModal) closeOnboarding();
return;
}
const openMenu = document.querySelector(".msg-action-menu[open]");
if (openMenu) {
openMenu.open = false;
openMenu.querySelector("summary")?.focus();
}
closeMobileSidebar(); closeMobileSidebar();
closeRoleplayStarter(); closeRoleplayStarter();
closePersonaForm(); closePersonaForm();
+19 -9
View File
@@ -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("");
} }
@@ -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");
}); });
@@ -570,9 +571,18 @@ 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 });
+3
View File
@@ -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;
+3
View File
@@ -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");
+3
View File
@@ -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";
+3
View File
@@ -1,3 +1,6 @@
/**
* @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 */
+4 -1
View File
@@ -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]')) {
+2 -2
View File
@@ -102,7 +102,7 @@
</main> </main>
<div id="marketPreviewModal" class="modal-overlay hidden"> <div id="marketPreviewModal" class="modal-overlay hidden">
<div class="modal-card market-preview-card"> <div class="modal-card market-preview-card" role="dialog" aria-modal="true" aria-labelledby="marketPreviewTitle" tabindex="-1">
<div class="modal-header"> <div class="modal-header">
<div> <div>
<p class="eyebrow">Persona preview</p> <p class="eyebrow">Persona preview</p>
@@ -162,7 +162,7 @@
</div> </div>
<div id="marketPersonaModal" class="modal-overlay hidden"> <div id="marketPersonaModal" class="modal-overlay hidden">
<div class="modal-card persona-modal-card"> <div class="modal-card persona-modal-card" role="dialog" aria-modal="true" aria-labelledby="marketPersonaFormTitle" tabindex="-1">
<div class="modal-header"> <div class="modal-header">
<div class="persona-modal-heading"> <div class="persona-modal-heading">
<p class="eyebrow">AI Character editor</p> <p class="eyebrow">AI Character editor</p>
+2 -2
View File
@@ -232,7 +232,7 @@
</main> </main>
<div id="settingsPersonaModal" class="modal-overlay hidden"> <div id="settingsPersonaModal" class="modal-overlay hidden">
<div class="modal-card persona-modal-card"> <div class="modal-card persona-modal-card" role="dialog" aria-modal="true" aria-labelledby="settingsPersonaFormTitle" tabindex="-1">
<div class="modal-header"> <div class="modal-header">
<div class="persona-modal-heading"> <div class="persona-modal-heading">
<p class="eyebrow">Persona editor</p> <p class="eyebrow">Persona editor</p>
@@ -317,7 +317,7 @@
</div> </div>
</div> </div>
</div> </div>
<script type="module" src="/js/settings.js?v=20260923-asset-paths"></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> <script type="module" src="/js/workspaceNavigation.js?v=20260909-account-controls"></script>
</body> </body>
+3
View File
@@ -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();
+4 -1
View File
@@ -1,3 +1,6 @@
/**
* @file Verbindet Express, Sitzungen, Datenbank und Routen und verwaltet den Server-Lebenszyklus.
*/
import fs from "fs"; import fs from "fs";
import {randomUUID} from "crypto"; import {randomUUID} from "crypto";
import {performance} from "perf_hooks"; import {performance} from "perf_hooks";
@@ -306,7 +309,7 @@ export function createApp(options = {}) {
app.use(createUploadsRouter(attachmentService, config)); app.use(createUploadsRouter(attachmentService, config));
if (config.visionDebug) app.use(createVisionDebugRouter({visionDebugService, repositories})); 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(createPersonasRouter({repositories, chatService, modelService, config}));
app.use(createSystemRouter({modelService, config})); app.use(createSystemRouter({modelService, config}));
app.use(notFoundHandler); app.use(notFoundHandler);
+3
View File
@@ -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) => {
+3
View File
@@ -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 (
+3
View File
@@ -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";
+19 -7
View File
@@ -1,3 +1,6 @@
/**
* @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) => {
@@ -84,7 +87,7 @@ export function createRepositories(db, config) {
WHERE cs.username = ? WHERE cs.username = ?
ORDER BY cs.is_pinned DESC, datetime(cs.updated_at) DESC ORDER BY cs.is_pinned DESC, datetime(cs.updated_at) DESC
`), `),
countChatSessions: db.prepare("SELECT COUNT(*) as count FROM chat_sessions WHERE username = ?"), countChatSessions: db.prepare("SELECT COUNT(*) as count FROM chat_sessions WHERE username = ? AND archived_at IS NULL"),
getChatSession: db.prepare(` getChatSession: db.prepare(`
SELECT cs.id, SELECT cs.id,
CASE CASE
@@ -378,7 +381,7 @@ export function createRepositories(db, config) {
deleteChatMessage: db.prepare("DELETE FROM chat_messages 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 = ?"), deleteChatMessages: db.prepare("DELETE FROM chat_messages WHERE chat_id = ?"),
searchChats: db.prepare(` searchChats: db.prepare(`
SELECT DISTINCT cs.id, SELECT cs.id,
CASE CASE
WHEN cs.assistant_persona_id IS NOT NULL AND cs.source_chat_id IS NULL THEN COALESCE(p.name, cs.title) WHEN cs.assistant_persona_id IS NOT NULL AND cs.source_chat_id IS NULL THEN COALESCE(p.name, cs.title)
ELSE cs.title ELSE cs.title
@@ -405,18 +408,23 @@ export function createRepositories(db, config) {
cs.system_instruction, cs.system_instruction,
cs.created_at, cs.created_at,
cs.updated_at, cs.updated_at,
cm.id AS matched_message_id,
cm.content AS matched_content cm.content AS matched_content
FROM chat_sessions cs FROM chat_sessions cs
LEFT JOIN personas p ON p.id = cs.assistant_persona_id LEFT JOIN personas p ON p.id = cs.assistant_persona_id
LEFT JOIN personas up ON up.id = cs.user_persona_id LEFT JOIN personas up ON up.id = cs.user_persona_id
LEFT JOIN chat_messages cm ON cm.chat_id = cs.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 = ? WHERE cs.username = ?
AND ( AND (
LOWER(CASE LOWER(CASE
WHEN cs.assistant_persona_id IS NOT NULL AND cs.source_chat_id IS NULL THEN COALESCE(p.name, cs.title) WHEN cs.assistant_persona_id IS NOT NULL AND cs.source_chat_id IS NULL THEN COALESCE(p.name, cs.title)
ELSE cs.title ELSE cs.title
END) LIKE ? END) LIKE ?
OR LOWER(COALESCE(cm.content, '')) LIKE ? OR cm.id IS NOT NULL
) )
ORDER BY cs.is_pinned DESC, datetime(cs.updated_at) DESC ORDER BY cs.is_pinned DESC, datetime(cs.updated_at) DESC
`), `),
@@ -711,6 +719,9 @@ export function createRepositories(db, config) {
}; };
const renameUserTransaction = db.transaction((currentUsername, nextUsername) => { 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.updateUsername.run(nextUsername, currentUsername);
statements.updateChatSessionsUsername.run(nextUsername, currentUsername); statements.updateChatSessionsUsername.run(nextUsername, currentUsername);
statements.updatePersonasUsername.run(nextUsername, currentUsername); statements.updatePersonasUsername.run(nextUsername, currentUsername);
@@ -876,9 +887,10 @@ 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);
} }
} }
statements.updateChatGenerationSettings.run( statements.updateChatGenerationSettings.run(
@@ -942,7 +954,7 @@ export function createRepositories(db, config) {
listChats: (username) => statements.listChatSessions.all(username), listChats: (username) => statements.listChatSessions.all(username),
searchChats(username, query) { searchChats(username, query) {
const like = `%${String(query || "").trim().toLowerCase()}%`; const like = `%${String(query || "").trim().toLowerCase()}%`;
return statements.searchChats.all(username, like, like); return statements.searchChats.all(like, username, like);
}, },
countChats: (username) => statements.countChatSessions.get(username).count, countChats: (username) => statements.countChatSessions.get(username).count,
getChat: (chatId, username) => statements.getChatSession.get(chatId, username), getChat: (chatId, username) => statements.getChatSession.get(chatId, username),
+3
View File
@@ -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");
+3
View File
@@ -1,3 +1,6 @@
/**
* @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"});
+4
View File
@@ -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) {
@@ -21,6 +24,7 @@ export function createRateLimiter({windowMs, max, keyGenerator, message, cleanup
if (cleanupIntervalMs > 0) { if (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();
} }
+3
View File
@@ -1,3 +1,6 @@
/**
* @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) {
+3
View File
@@ -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);
+3
View File
@@ -1,3 +1,6 @@
/**
* @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) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
})[character]); })[character]);
+3
View File
@@ -1,3 +1,6 @@
/**
* @file Verarbeitet Registrierung, Anmeldung, Abmeldung und die Abfrage der aktuellen Sitzung.
*/
import express from "express"; import express from "express";
import bcrypt from "bcrypt"; import bcrypt from "bcrypt";
import {validateBody} from "../middleware/validate.js"; import {validateBody} from "../middleware/validate.js";
+82 -3
View File
@@ -1,7 +1,12 @@
/**
* @file Stellt Chat-Routen für Nachrichten, Streaming, Antwortvarianten, Verzweigungen und Einstellungen bereit.
*/
import express from "express"; import express from "express";
import multer from "multer";
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 {validateWorkspaceImport} from "../services/workspaceImportService.js";
import {createWorkspaceArchive, parseWorkspaceArchive, MAX_ARCHIVE_BYTES} from "../services/workspaceArchive.js";
const createChatValidator = (body) => ({ const createChatValidator = (body) => ({
value: { value: {
@@ -102,9 +107,29 @@ const preferredModelValidator = (body) => {
return {value: {model}}; return {value: {model}};
}; };
export function createChatsRouter({repositories, chatService, modelService, config, chatRateLimiters}) { export function createChatsRouter({repositories, chatService, modelService, config, chatRateLimiters, attachmentService}) {
const router = express.Router(); const router = express.Router();
router.use(requireLogin); router.use(requireLogin);
const receiveArchive = multer({storage: multer.memoryStorage(), limits: {fileSize: MAX_ARCHIVE_BYTES, files: 1, parts: 2}}).single("archive");
const readArchive = (req, res, next) => receiveArchive(req, res, 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 = parseWorkspaceArchive(req.file.buffer);
next();
} catch (failure) { res.status(400).json({error: failure.message}); }
});
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.use(["/chats/:id/messages/by-index/:index", "/chats/:id/messages/:messageId"], (req, res, next) => { router.use(["/chats/:id/messages/by-index/:index", "/chats/:id/messages/:messageId"], (req, res, next) => {
const chatId = Number(req.params.id); const chatId = Number(req.params.id);
const messageId = req.params.messageId ? Number(req.params.messageId) : chatService.getChatMessageByIndex(chatId, req.params.index)?.id; const messageId = req.params.messageId ? Number(req.params.messageId) : chatService.getChatMessageByIndex(chatId, req.params.index)?.id;
@@ -141,14 +166,14 @@ export function createChatsRouter({repositories, chatService, modelService, conf
return res.json({chat, existing: Boolean(existingChat)}); return res.json({chat, existing: Boolean(existingChat)});
} 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.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}); return res.json({chat: repositories.createChat(user, title), existing: false});
@@ -168,6 +193,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,
@@ -599,6 +627,57 @@ export function createChatsRouter({repositories, chatService, modelService, conf
}); });
router.get("/exports/workspace", (req, res) => res.json({workspace: repositories.exportWorkspace(req.session.user)})); router.get("/exports/workspace", (req, res) => res.json({workspace: repositories.exportWorkspace(req.session.user)}));
router.get("/exports/workspace/archive", async (req, res) => {
try {
const workspace = repositories.exportWorkspace(req.session.user);
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 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 = createWorkspaceArchive(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, async (req, res) => {
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)}));
}
const imported = repositories.importWorkspace(req.session.user, validation.workspace);
return res.json({imported, 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) => { router.post("/imports/workspace/preview", (req, res) => {
const validation = validateWorkspaceImport(req.body?.workspace, repositories.exportWorkspace(req.session.user)); const validation = validateWorkspaceImport(req.body?.workspace, repositories.exportWorkspace(req.session.user));
return res.status(validation.ok ? 200 : 400).json({ return res.status(validation.ok ? 200 : 400).json({
+3
View File
@@ -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";
+6 -3
View File
@@ -1,3 +1,6 @@
/**
* @file Verwaltet eigene Personas sowie Veröffentlichung, Suche und Interaktionen auf dem Persona-Marktplatz.
*/
import express from "express"; import express from "express";
import {requireLogin} from "../middleware/auth.js"; import {requireLogin} from "../middleware/auth.js";
import {validateBody} from "../middleware/validate.js"; import {validateBody} from "../middleware/validate.js";
@@ -118,7 +121,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;
} }
@@ -242,7 +245,7 @@ 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;
} }
@@ -297,7 +300,7 @@ 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;
} }
+3
View File
@@ -1,3 +1,6 @@
/**
* @file Verarbeitet Benutzer- und Kontoeinstellungen sowie Export und Import des Arbeitsbereichs.
*/
import express from "express"; import express from "express";
import bcrypt from "bcrypt"; import bcrypt from "bcrypt";
import {requireLogin} from "../middleware/auth.js"; import {requireLogin} from "../middleware/auth.js";
+3
View File
@@ -1,3 +1,6 @@
/**
* @file Stellt Modellinformationen und administrative Diagnosedaten bereit.
*/
import express from "express"; import express from "express";
import {requireLogin} from "../middleware/auth.js"; import {requireLogin} from "../middleware/auth.js";
+3
View File
@@ -1,3 +1,6 @@
/**
* @file Verarbeitet das Hochladen, Abrufen und Entfernen von Bildanhängen mit Zugriffskontrolle.
*/
import express from "express"; import express from "express";
import multer from "multer"; import multer from "multer";
import {requireLogin} from "../middleware/auth.js"; import {requireLogin} from "../middleware/auth.js";
+19
View File
@@ -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;
}
+6 -3
View File
@@ -1,3 +1,6 @@
/**
* @file Validiert und speichert Bildanhänge und bereitet zwischengespeicherte Bildanalysen für den Chat vor.
*/
import sharp from "sharp"; import sharp from "sharp";
import {createHash, randomUUID} from "node:crypto"; import {createHash, randomUUID} from "node:crypto";
import {attachmentMetadata} from "../db/attachments.js"; import {attachmentMetadata} from "../db/attachments.js";
@@ -159,7 +162,7 @@ export function createAttachmentService(db, config, modelService, visionDebugSer
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"); if (!file?.buffer?.length) throw new AttachmentError("Choose a non-empty PNG, JPEG or WebP image");
if (file.size > config.uploadMaxSizeMb * 1024 * 1024) throw new AttachmentError("Image is too large", 413); if (file.size > config.uploadMaxSizeMb * 1024 * 1024) throw new AttachmentError("Image is too large", 413);
@@ -175,9 +178,9 @@ export function createAttachmentService(db, config, modelService, visionDebugSer
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}`, file.size, 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: file.size, mime: `image/${info.format}`});
+3
View File
@@ -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) {
+8 -1
View File
@@ -1,3 +1,6 @@
/**
* @file Koordiniert Chat-Antworten, Modellkontext, Websuche, Anhänge, Antwortvarianten und Gesprächsverzweigungen.
*/
import { import {
buildPersonaPrompt, buildPersonaPrompt,
buildRoleplayDirectionPrompt, buildRoleplayDirectionPrompt,
@@ -95,15 +98,18 @@ 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 });
@@ -157,6 +163,7 @@ export function createChatService(repositories, modelService, config, webSearchP
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];
@@ -577,7 +584,7 @@ export function createChatService(repositories, modelService, config, webSearchP
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
+3
View File
@@ -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";
+5
View File
@@ -1,3 +1,6 @@
/**
* @file Kommuniziert mit Ollama und verwaltet Antwortstreams, Zeitlimits, Modellstatus und Modell-Caches.
*/
import { supportsReasoning } from "../../public/js/modelCapabilities.js"; import { supportsReasoning } from "../../public/js/modelCapabilities.js";
import { AttachmentError } from "./attachmentService.js"; import { AttachmentError } from "./attachmentService.js";
@@ -147,6 +150,7 @@ export function createModelService(config, overrides = {}) {
touch(); touch();
buffer += decoder.decode(value, { stream: true }); buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n"); const lines = buffer.split("\n");
// Netzwerkblöcke können mitten in einer JSON-Zeile enden; den Rest für den nächsten Block behalten.
buffer = lines.pop() || ""; buffer = lines.pop() || "";
for (const line of lines) { for (const line of lines) {
const trimmed = line.trim(); const trimmed = line.trim();
@@ -160,6 +164,7 @@ export function createModelService(config, overrides = {}) {
if (!completed && buffer.trim()) { if (!completed && buffer.trim()) {
consume(buffer.trim()); consume(buffer.trim());
} }
// Erst das Abschlussereignis bestätigt eine vollständige Antwort; ein Stream-Ende allein genügt nicht.
if (!completed) throw new ModelServiceError("MODEL_INTERRUPTED", "Model response was interrupted.", 502); if (!completed) throw new ModelServiceError("MODEL_INTERRUPTED", "Model response was interrupted.", 502);
onRawReply?.(fullReply); onRawReply?.(fullReply);
return fullReply.trim(); return fullReply.trim();
+3
View File
@@ -1,3 +1,6 @@
/**
* @file Validiert Persona-Daten und erstellt Rollenspielanweisungen sowie Prüfungen für die Rollenverteilung.
*/
const PERSONA_FIELD_LIMITS = { const PERSONA_FIELD_LIMITS = {
name: 200, name: 200,
pronouns: 40, pronouns: 40,
+3
View File
@@ -1,3 +1,6 @@
/**
* @file Definiert Anweisungen, Antwortschema und Normalisierung für Bildanalysen und deren Nutzung im Chat.
*/
// Bump whenever the perception prompt, schema, or interpretation contract changes. // Bump whenever the perception prompt, schema, or interpretation contract changes.
export const VISION_PROMPT_VERSION = 4; export const VISION_PROMPT_VERSION = 4;
+183
View File
@@ -0,0 +1,183 @@
/**
* @file Sammelt begrenzte Diagnosedaten zur Bildverarbeitung und kontrolliert deren benutzerbezogenen Zugriff.
*/
const RAW_VISION_LIMIT = 12000;
const EVIDENCE_LIMIT = 100000;
const ANSWER_LIMIT = 30000;
const REASONING_LIMIT = 30000;
const MAX_RECORDS = 100;
const MAX_AGE_MS = 60 * 60 * 1000;
const bounded = (value, limit) => typeof value === "string" ? value.slice(0, limit) : value;
const clone = value => value == null ? value : structuredClone(value);
const exposed = record => {
const value = clone(record);
delete value.user;
return value;
};
export function createVisionDebugService(config) {
const enabled = config.visionDebug === true;
const records = new Map();
const prune = () => {
const cutoff = Date.now() - MAX_AGE_MS;
for (const [id, record] of records) {
if (Date.parse(record.updatedAt) < cutoff) records.delete(id);
}
while (records.size > MAX_RECORDS) records.delete(records.keys().next().value);
};
const update = (requestId, operation) => {
if (!enabled || !requestId) return null;
const record = records.get(requestId);
if (!record) return null;
operation(record);
record.updatedAt = new Date().toISOString();
return record;
};
const image = (record, attachmentId) => record.images.find(item => item.attachmentId === attachmentId);
return {
enabled,
begin({requestId, user, chatId, triggeringMessageId, userQuestion, selectedModel, reasoningRequested, webSearchRequested}) {
if (!enabled || !requestId) return;
prune();
const timestamp = new Date().toISOString();
records.delete(requestId);
records.set(requestId, {
requestId, user, chatId, triggeringMessageId,
createdAt: timestamp, updatedAt: timestamp,
request: {userQuestion, selectedModel, reasoningRequested: reasoningRequested === true, webSearchRequested: webSearchRequested === true},
images: [],
qwen: null,
search: {
requested: webSearchRequested === true,
needed: null,
explicitSearchIntent: null,
state: webSearchRequested === true ? "pending" : "disabled",
visualReference: "",
queries: [],
candidateCount: 0,
selectedCount: 0,
selectedSources: []
},
visibleReasoning: null,
finalAnswer: null,
error: null,
experimentNote: "For controlled Web Search/Reasoning comparisons, reuse this attachment and exact question. Re-uploading identical bytes creates a new attachment ID and vision cache entry."
});
prune();
},
recordImage(requestId, value) {
update(requestId, record => {
const existing = image(record, value.attachmentId);
const next = {
requestId,
chatId: record.chatId,
triggeringMessageId: record.triggeringMessageId,
attachmentId: value.attachmentId,
sourceMessageId: value.sourceMessageId,
position: value.position,
scope: value.scope,
label: value.label,
originalFilename: value.originalFilename,
mimeType: value.mimeType,
width: value.width,
height: value.height,
sizeBytes: value.sizeBytes,
imageSha256: null,
visionModel: value.visionModel,
visionPromptVersion: value.visionPromptVersion,
normalizedQuestion: value.normalizedQuestion,
cacheHit: null,
cacheIdentity: null,
rawVisionResponse: null,
normalizedVisionAnalysis: null
};
if (existing) Object.assign(existing, next); else record.images.push(next);
});
},
recordCache(requestId, attachmentId, {cacheHit, cacheIdentity, normalizedVisionAnalysis}) {
update(requestId, record => {
const entry = image(record, attachmentId);
if (!entry) return;
entry.cacheHit = cacheHit === true;
entry.cacheIdentity = clone(cacheIdentity);
entry.rawVisionResponse = null;
if (normalizedVisionAnalysis !== undefined) entry.normalizedVisionAnalysis = clone(normalizedVisionAnalysis);
});
},
recordVisionRun(requestId, attachmentId, {imageSha256, rawVisionResponse, normalizedVisionAnalysis}) {
update(requestId, record => {
const entry = image(record, attachmentId);
if (!entry) return;
entry.cacheHit = false;
entry.imageSha256 = imageSha256 || null;
entry.rawVisionResponse = bounded(String(rawVisionResponse ?? ""), RAW_VISION_LIMIT);
entry.normalizedVisionAnalysis = clone(normalizedVisionAnalysis);
});
},
recordQwen(requestId, value) {
update(requestId, record => {
record.qwen = {
imageEvidence: bounded(value.imageEvidence, EVIDENCE_LIMIT),
visionGuidanceSha256: value.visionGuidanceSha256,
visionEvidenceIncluded: value.visionEvidenceIncluded === true,
imageCount: value.imageCount,
currentImageLabels: clone(value.currentImageLabels),
model: value.model,
think: value.think === true,
stream: value.stream === true,
temperature: value.temperature ?? null,
numCtx: value.numCtx ?? null,
numPredict: value.numPredict ?? null,
keepAlive: value.keepAlive
};
});
},
recordSearch(requestId, value) {
update(requestId, record => {
record.search = {
...record.search,
requested: value.requested === true,
needed: value.needed ?? record.search.needed,
explicitSearchIntent: value.explicitSearchIntent ?? record.search.explicitSearchIntent,
state: value.state ?? record.search.state,
visualReference: bounded(value.visualReference ?? record.search.visualReference, 1200),
queries: Array.isArray(value.queries) ? value.queries.map(item => bounded(String(item), 500)) : record.search.queries,
candidateCount: value.candidateCount ?? record.search.candidateCount,
selectedCount: value.selectedCount ?? record.search.selectedCount,
selectedSources: Array.isArray(value.selectedSources) ? value.selectedSources.slice(0, 5).map((source, index) => ({
index: index + 1,
title: bounded(String(source.title || ""), 300),
url: bounded(String(source.url || ""), 2048),
snippet: bounded(String(source.snippet || ""), 500)
})) : record.search.selectedSources
};
});
},
complete(requestId, {finalAnswer, visibleReasoning}) {
update(requestId, record => {
record.finalAnswer = bounded(String(finalAnswer ?? ""), ANSWER_LIMIT);
record.visibleReasoning = visibleReasoning ? bounded(String(visibleReasoning), REASONING_LIMIT) : null;
});
},
fail(requestId, error) {
update(requestId, record => { record.error = bounded(String(error?.message || error || "Request failed"), 1000); });
},
get(requestId, user, repositories) {
if (!enabled) return null;
prune();
const record = records.get(requestId);
if (!record || record.user !== user || !repositories.getChat(record.chatId, user)) return null;
return exposed(record);
},
latest(user, repositories) {
if (!enabled) return null;
prune();
const values = [...records.values()].reverse();
const record = values.find(item => item.user === user && repositories.getChat(item.chatId, user));
return record ? exposed(record) : null;
}
};
}
+3
View File
@@ -1,3 +1,6 @@
/**
* @file Entscheidet über Websuchen, fragt SearXNG ab und bereitet relevante Quellen als Modellkontext auf.
*/
/** @typedef {{title: string, url: string, snippet: string, score?: number, engine?: string, category?: string, publishedDate?: string}} RawSearchResult */ /** @typedef {{title: string, url: string, snippet: string, score?: number, engine?: string, category?: string, publishedDate?: string}} RawSearchResult */
/** @typedef {{title: string, url: string, snippet: string}} SearchResult */ /** @typedef {{title: string, url: string, snippet: string}} SearchResult */
/** @typedef {{signal?: AbortSignal, limit?: number, category?: string}} SearchOptions */ /** @typedef {{signal?: AbortSignal, limit?: number, category?: string}} SearchOptions */
+61
View File
@@ -0,0 +1,61 @@
/** Portable workspace archive: a gzip-compressed ustar file with JSON and images. */
import {gzipSync, gunzipSync} from "node:zlib";
export const MAX_ARCHIVE_BYTES = 250 * 1024 * 1024;
function header(name, size) {
const block = Buffer.alloc(512);
block.write(name, 0, 100, "ascii");
block.write("0000644\0", 100, "ascii");
block.write("0000000\0", 108, "ascii");
block.write("0000000\0", 116, "ascii");
block.write(size.toString(8).padStart(11, "0") + "\0", 124, "ascii");
block.write(Math.floor(Date.now() / 1000).toString(8).padStart(11, "0") + "\0", 136, "ascii");
block.fill(32, 148, 156);
block.write("0", 156, "ascii");
block.write("ustar\0", 257, "ascii");
block.write("00", 263, "ascii");
const checksum = block.reduce((sum, byte) => sum + byte, 0);
block.write(checksum.toString(8).padStart(6, "0") + "\0 ", 148, "ascii");
return block;
}
export function createWorkspaceArchive(workspace, images) {
const files = [["workspace.json", Buffer.from(JSON.stringify(workspace, null, 2))], ...images];
const chunks = [];
let total = 1024;
for (const [name, body] of files) {
if (!/^(workspace\.json|images\/[a-f0-9-]{36}\.(png|jpeg|webp))$/.test(name)) throw new Error("Invalid archive entry");
total += 512 + Math.ceil(body.length / 512) * 512;
if (total > MAX_ARCHIVE_BYTES) throw new Error("Workspace archive exceeds 250 MB");
chunks.push(header(name, body.length), body, Buffer.alloc((512 - body.length % 512) % 512));
}
chunks.push(Buffer.alloc(1024));
return gzipSync(Buffer.concat(chunks, total), {level: 1});
}
export function parseWorkspaceArchive(buffer) {
if (!Buffer.isBuffer(buffer) || buffer.length > MAX_ARCHIVE_BYTES) throw new Error("Archive exceeds 250 MB");
const tar = gunzipSync(buffer, {maxOutputLength: MAX_ARCHIVE_BYTES});
const files = new Map();
for (let offset = 0; offset + 512 <= tar.length;) {
const block = tar.subarray(offset, offset + 512);
if (block.every(byte => byte === 0)) break;
const name = block.toString("ascii", 0, 100).split("\0")[0];
if (!/^(workspace\.json|images\/[a-f0-9-]{36}\.(png|jpeg|webp))$/.test(name) || files.has(name) || block.toString("ascii", 156, 157) !== "0") {
throw new Error("Invalid archive entry");
}
const recorded = Number.parseInt(block.toString("ascii", 148, 156).trim(), 8);
const copy = Buffer.from(block); copy.fill(32, 148, 156);
if (recorded !== copy.reduce((sum, byte) => sum + byte, 0)) throw new Error("Invalid archive checksum");
const size = Number.parseInt(block.toString("ascii", 124, 136).replaceAll("\0", "").trim(), 8);
if (!Number.isSafeInteger(size) || size < 0 || offset + 512 + size > tar.length) throw new Error("Invalid archive size");
files.set(name, tar.subarray(offset + 512, offset + 512 + size));
offset += 512 + Math.ceil(size / 512) * 512;
}
if (!files.has("workspace.json")) throw new Error("Archive has no workspace.json");
let workspace;
try { workspace = JSON.parse(files.get("workspace.json").toString("utf8")); }
catch { throw new Error("Archive has invalid workspace.json"); }
return {workspace, images: files};
}
+3
View File
@@ -1,3 +1,6 @@
/**
* @file Prüft und normalisiert importierte Arbeitsbereiche mit Personas, Chats und Nachrichtenbeziehungen.
*/
import {parseSources} from "./chatMessageHelpers.js"; import {parseSources} from "./chatMessageHelpers.js";
const VALID_PERSONA_TYPES = new Set(["assistant", "user"]); const VALID_PERSONA_TYPES = new Set(["assistant", "user"]);
+78
View File
@@ -1,3 +1,6 @@
/**
* @file Prüft Anmeldung, Kontoverwaltung, Chats, Personas und den Server-Lebenszyklus der Anwendung.
*/
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import fs from "fs"; import fs from "fs";
@@ -249,6 +252,48 @@ test("account export, logout-all, and deletion controls work", async () => {
await server.close(); await server.close();
}); });
test("username and password changes keep account data and sessions consistent", async t => {
const server = await startTestServer();
t.after(() => server.close());
const jar = new CookieJar();
const otherSession = new CookieJar();
await request(server.baseUrl, "/register", {method: "POST", body: {username: "accountowner", password: "old-password"}});
await request(server.baseUrl, "/register", {method: "POST", body: {username: "alreadytaken", password: "password123"}});
assert.equal((await request(server.baseUrl, "/login", {method: "POST", body: {username: "accountowner", password: "old-password"}, jar})).status, 200);
assert.equal((await request(server.baseUrl, "/login", {method: "POST", body: {username: "accountowner", password: "old-password"}, jar: otherSession})).status, 200);
const chat = await request(server.baseUrl, "/chats", {method: "POST", body: {title: "Keep this chat"}, jar});
assert.equal(chat.status, 200);
const persona = await request(server.baseUrl, "/personas", {method: "POST", body: {name: "Keeper", personaType: "user"}, jar});
assert.equal(persona.status, 200);
const db = server.app.locals.db;
const attachmentId = "12345678-1234-1234-1234-123456789abc";
db.prepare("INSERT INTO attachments (id, username, original_name, storage_key, mime_type, size_bytes, width, height) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
.run(attachmentId, "accountowner", "picture.png", "rename-test-image", "image/png", 1, 1, 1);
assert.equal((await request(server.baseUrl, "/settings/username", {method: "PUT", body: {username: "newowner", password: "wrong"}, jar})).status, 403);
assert.equal((await request(server.baseUrl, "/settings/username", {method: "PUT", body: {username: "alreadytaken", password: "old-password"}, jar})).status, 400);
const renamed = await request(server.baseUrl, "/settings/username", {method: "PUT", body: {username: "newowner", password: "old-password"}, jar});
assert.equal(renamed.status, 200);
assert.equal(renamed.json.username, "newowner");
assert.equal((await request(server.baseUrl, "/settings/profile", {jar})).json.username, "newowner");
assert.equal((await request(server.baseUrl, "/session", {jar})).json.user, "newowner");
assert.equal((await request(server.baseUrl, "/session", {jar: otherSession})).json.user, null);
assert.equal((await request(server.baseUrl, "/chats", {jar})).json.chats[0].id, chat.json.chat.id);
assert.equal((await request(server.baseUrl, "/personas", {jar})).json.userPersonas[0].id, persona.json.persona.id);
assert.equal(db.prepare("SELECT username FROM attachments WHERE id = ?").get(attachmentId).username, "newowner");
assert.deepEqual(db.pragma("foreign_key_check"), []);
assert.equal((await request(server.baseUrl, "/login", {method: "POST", body: {username: "newowner", password: "old-password"}, jar: otherSession})).status, 200);
assert.equal((await request(server.baseUrl, "/settings/password", {method: "PUT", body: {currentPassword: "wrong", newPassword: "new-password"}, jar})).status, 403);
assert.equal((await request(server.baseUrl, "/settings/password", {method: "PUT", body: {currentPassword: "old-password", newPassword: "new-password"}, jar})).status, 200);
assert.equal((await request(server.baseUrl, "/settings/profile", {jar})).status, 200);
assert.equal((await request(server.baseUrl, "/session", {jar: otherSession})).json.user, null);
assert.equal((await request(server.baseUrl, "/login", {method: "POST", body: {username: "newowner", password: "old-password"}})).status, 403);
assert.equal((await request(server.baseUrl, "/login", {method: "POST", body: {username: "newowner", password: "new-password"}})).status, 200);
assert.equal((await request(server.baseUrl, "/login", {method: "POST", body: {username: "accountowner", password: "new-password"}})).status, 400);
assert.equal((await request(server.baseUrl, "/settings/export", {jar})).json.account.username, "newowner");
});
test("admin diagnostics require admin and return model status", async () => { test("admin diagnostics require admin and return model status", async () => {
const server = await startTestServer({ const server = await startTestServer({
config: { config: {
@@ -769,9 +814,18 @@ test("chat search, organization, and workspace export work", async () => {
jar jar
}); });
await request(server.baseUrl, "/chat", {
method: "POST",
body: {chatId, message: "another keyword passage", model: "mistral:latest"},
jar
});
const search = await request(server.baseUrl, "/chats/search?q=keyword", {jar}); const search = await request(server.baseUrl, "/chats/search?q=keyword", {jar});
assert.equal(search.status, 200); assert.equal(search.status, 200);
assert.equal(search.json.chats.length, 1); assert.equal(search.json.chats.length, 1);
assert.equal(search.json.chats[0].matched_content, "another keyword passage");
const messages = await request(server.baseUrl, `/chats/${chatId}/messages`, {jar});
assert.equal(search.json.chats[0].matched_message_id, messages.json.messages.find(message => message.content === "another keyword passage").id);
const organize = await request(server.baseUrl, `/chats/${chatId}/organization`, { const organize = await request(server.baseUrl, `/chats/${chatId}/organization`, {
method: "PUT", method: "PUT",
@@ -833,6 +887,30 @@ test("chat search, organization, and workspace export work", async () => {
await server.close(); await server.close();
}); });
test("archiving frees an active chat slot and restoring respects the limit", async () => {
const server = await startTestServer({config: {chatLimit: 1}});
try {
const jar = new CookieJar();
await request(server.baseUrl, "/register", {method: "POST", body: {username: "archiver", password: "password123"}});
await request(server.baseUrl, "/login", {method: "POST", body: {username: "archiver", password: "password123"}, jar});
const first = await request(server.baseUrl, "/chats", {method: "POST", body: {title: "First"}, jar});
assert.equal(first.status, 200);
assert.equal((await request(server.baseUrl, "/chats", {method: "POST", body: {title: "Blocked"}, jar})).status, 400);
assert.equal((await request(server.baseUrl, `/chats/${first.json.chat.id}/organization`, {
method: "PUT", body: {archived: true}, jar
})).status, 200);
const second = await request(server.baseUrl, "/chats", {method: "POST", body: {title: "Second"}, jar});
assert.equal(second.status, 200);
assert.equal((await request(server.baseUrl, `/chats/${first.json.chat.id}/organization`, {
method: "PUT", body: {archived: false}, jar
})).status, 400);
await request(server.baseUrl, `/chats/${second.json.chat.id}/organization`, {method: "PUT", body: {archived: true}, jar});
assert.equal((await request(server.baseUrl, `/chats/${first.json.chat.id}/organization`, {
method: "PUT", body: {archived: false}, jar
})).status, 200);
} finally { await server.close(); }
});
test("persona clone, version restore, and market feedback endpoints work", async () => { test("persona clone, version restore, and market feedback endpoints work", async () => {
const server = await startTestServer(); const server = await startTestServer();
const jar = new CookieJar(); const jar = new CookieJar();
+32
View File
@@ -1,3 +1,6 @@
/**
* @file Prüft Anhangsvorschauen, Upload-Zustände, Wiederholungen und das Verwerfen von Entwürfen.
*/
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { appendAttachments, createAttachmentComposer } from "../public/js/attachments.js"; import { appendAttachments, createAttachmentComposer } from "../public/js/attachments.js";
@@ -94,3 +97,32 @@ test("discarding a chat draft aborts upload, deletes temporary files, and serial
composer.clear(); composer.clear();
assert.deepEqual(deletes, [`/attachments/${temporaryId}`], "Sent attachments are retained"); assert.deepEqual(deletes, [`/attachments/${temporaryId}`], "Sent attachments are retained");
}); });
test("attachments stay with their chat while uploads finish after switching", async t => {
const originalDocument = globalThis.document, originalFetch = globalThis.fetch;
t.after(() => { globalThis.document = originalDocument; globalThis.fetch = originalFetch; });
const elements = Object.fromEntries(["attachImage", "imagePicker", "uploadPreviews", "composer"].map(id => [id, new Element()]));
globalThis.document = {getElementById: id => elements[id], querySelector: () => elements.composer, createElement: () => new Element()};
let completeUpload;
const deletes = [];
globalThis.fetch = (url) => {
if (url === "/uploads/config") return Promise.resolve(Response.json({maxFiles: 5, maxSizeMb: 10}));
if (url === "/uploads") return new Promise(resolve => { completeUpload = resolve; });
deletes.push(url);
return Promise.resolve(Response.json({success: true}));
};
const composer = createAttachmentComposer({onChange() {}, onError: message => assert.fail(message)});
elements.imagePicker.files = [new File(["fixture"], "draft.png", {type: "image/png"})];
elements.imagePicker.onchange();
composer.saveDraft(1);
composer.restoreDraft(2);
assert.equal(composer.attachments.length, 0);
const metadata = {id: "12345678-1234-1234-1234-123456789abc"};
completeUpload(Response.json(metadata));
await new Promise(resolve => setTimeout(resolve, 0));
assert.deepEqual(deletes, []);
composer.saveDraft(2);
composer.restoreDraft(1);
assert.deepEqual(composer.attachments, [metadata]);
composer.discard();
});
+34
View File
@@ -1,3 +1,6 @@
/**
* @file Prüft Uploads, Zugriffsrechte und den Lebenszyklus von Bildanhängen sowie ihre Nutzung im Modellkontext.
*/
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
@@ -129,6 +132,37 @@ test("account rename retains ownership and account deletion removes pending file
await assert.rejects(fs.access(path.join(s.directory, key))); await assert.rejects(fs.access(path.join(s.directory, key)));
}); });
test("workspace archive restores image bytes for another account", async t => {
const s = await setup(t);
const bytes = await s.image("png");
const image = await (await s.upload(bytes)).json();
const chatId = await s.chat();
assert.equal((await s.api("/chat", {chatId, message: "What is in this image?", attachments: [image.id]})).status, 200);
const exported = await s.api("/exports/workspace/archive");
assert.equal(exported.status, 200);
assert.match(exported.headers.get("content-disposition"), /workspace-backup\.tar\.gz/);
const archive = await exported.blob();
const destination = await s.register("destination");
const form = () => {
const data = new FormData();
data.append("archive", archive, "workspace.tar.gz");
return data;
};
const base = new URL("/", exported.url).origin;
const preview = await fetch(base + "/imports/workspace/archive/preview", {method: "POST", headers: {Cookie: destination}, body: form()});
assert.equal(preview.status, 200);
assert.equal((await preview.json()).preview.chats, 1);
const imported = await fetch(base + "/imports/workspace/archive", {method: "POST", headers: {Cookie: destination}, body: form()});
assert.equal(imported.status, 200);
const workspace = await (await s.api("/exports/workspace", null, "GET", destination)).json();
const restored = workspace.workspace.chats[0].messages[0].attachments[0];
assert.notEqual(restored.id, image.id);
const download = await s.api(`/attachments/${restored.id}`, null, "GET", destination);
assert.equal(download.status, 200);
assert.deepEqual(Buffer.from(await download.arrayBuffer()), bytes);
assert.equal((await s.api(`/attachments/${restored.id}`)).status, 404);
});
test("images feed dedicated vision, cache, streamed reasoning, reload and shared branch lifecycle", async t => { test("images feed dedicated vision, cache, streamed reasoning, reload and shared branch lifecycle", async t => {
const s = await setup(t); const s = await setup(t);
const uploads = []; const uploads = [];
+3
View File
@@ -1,3 +1,6 @@
/**
* @file Prüft Konfigurationsvorgaben und Sicherheitsanforderungen für Test- und Produktionsbetrieb.
*/
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import {createConfig} from "../src/config.js"; import {createConfig} from "../src/config.js";
+3
View File
@@ -1,3 +1,6 @@
/**
* @file Prüft Antwortstreaming, Zeitlimits, Protokollfehler und Abbrüche bei getrennten Verbindungen.
*/
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { createServer } from "node:http"; import { createServer } from "node:http";
+3
View File
@@ -1,3 +1,6 @@
/**
* @file Prüft Reasoning-Auswahl, getrennte Gedankenstreams und deren Speicherung sowie Darstellung.
*/
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { createServer } from "node:http"; import { createServer } from "node:http";
+3
View File
@@ -1,3 +1,6 @@
/**
* @file Prüft Antwortvarianten und den Erhalt bestehender Antworten bei fehlgeschlagener Neugenerierung.
*/
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import {createServer} from "node:http"; import {createServer} from "node:http";
+3
View File
@@ -1,3 +1,6 @@
/**
* @file Prüft die Neugenerierung innerhalb einer Nachrichtenkarte mit Scrollverhalten und Wiederherstellung.
*/
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import {readFile} from "node:fs/promises"; import {readFile} from "node:fs/promises";
+3
View File
@@ -1,3 +1,6 @@
/**
* @file Prüft Schema, Anweisungen, Normalisierung und Fehlerbehandlung der Bildanalyse.
*/
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import {parseVisionAnalysis} from "../src/services/attachmentService.js"; import {parseVisionAnalysis} from "../src/services/attachmentService.js";
+209
View File
@@ -0,0 +1,209 @@
/**
* @file Prüft Zugriffsschutz und Diagnoseaufzeichnungen für Bildanalysen, Caches und Modellanfragen.
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {createServer} from "node:http";
import sharp from "sharp";
import {createApp} from "../src/app.js";
import {VISION_PROMPT_VERSION} from "../src/services/visionAnalysis.js";
async function listen(t, handler) {
const server = createServer(handler);
await new Promise(resolve => server.listen(0, "127.0.0.1", resolve));
t.after(() => new Promise(resolve => { server.closeAllConnections(); server.close(resolve); }));
return {server, url: `http://127.0.0.1:${server.address().port}`};
}
async function setup(t, visionDebug = true) {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "elsewhere-vision-debug-"));
const requests = [];
const searchQueries = [];
const rawVisionResponse = ` \n${JSON.stringify({
scene: "A penguin stands on dark rocks.",
subjects: [{type: "bird", description: "Black crown and white throat", location: "center"}],
visualDetails: ["A thin black line runs directly beneath the chin."],
visibleText: [], spatialRelationships: [], colorsAndAppearance: ["black and white plumage"],
recognizedElements: ["penguin"], notableFeatures: ["thin black line beneath chin"], uncertainties: []
})}\n `;
const search = await listen(t, (req, res) => {
searchQueries.push(new URL(req.url, "http://localhost").searchParams.get("q"));
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({results: [{title: "Penguin identification guide", url: "https://example.com/penguins",
content: "A penguin identification guide compares crown, throat, bill, and chin markings."}]}));
});
const app = createApp({config: {
dbPath: ":memory:", uploadDir: directory, testMode: true, sessionSecret: "vision-debug-tests",
visionDebug, visionModel: "qwen2.5vl:7b", chatSummaryUpdateEveryMessages: 0,
searxngUrl: search.url, eventLoopLagMonitorEnabled: false,
slowRequestLoggingEnabled: false, structuredRequestLoggingEnabled: false
}, modelOverrides: {fetch: async (_url, options) => {
const body = JSON.parse(options.body);
requests.push(body);
const chunks = body.model === "qwen2.5vl:7b"
? [{message: {content: rawVisionResponse}, done: true}]
: [...(body.think ? [{message: {thinking: "Compare the supplied visible evidence."}}] : []),
{message: {content: "Final penguin answer."}, done: true}];
return new Response(chunks.map(chunk => JSON.stringify(chunk)).join("\n"));
}}});
const backend = await listen(t, app);
t.after(async () => { await app.locals.close(); await fs.rm(directory, {recursive: true, force: true}); });
const register = async username => {
const response = await fetch(backend.url + "/register", {method: "POST", headers: {"Content-Type": "application/json"},
body: JSON.stringify({username, password: "test-password"})});
assert.equal(response.status, 200);
return response.headers.get("set-cookie").split(";")[0];
};
const request = (url, {cookie = "", method = "GET", body, headers = {}} = {}) => fetch(backend.url + url, {
method, headers: {...headers, ...(cookie ? {Cookie: cookie} : {}), ...(body ? {"Content-Type": "application/json"} : {})},
body: body ? JSON.stringify(body) : undefined
});
const upload = async (cookie, bytes, filename = "penguin.png") => {
const form = new FormData();
form.append("image", new Blob([bytes], {type: "image/png"}), filename);
const response = await fetch(backend.url + "/uploads", {method: "POST", headers: {Cookie: cookie}, body: form});
assert.equal(response.status, 201);
return response.json();
};
const createChat = async cookie => {
const response = await request("/chats", {cookie, method: "POST", body: {title: "Vision debug"}});
return (await response.json()).chat.id;
};
return {app, backend, register, request, upload, createChat, requests, searchQueries, rawVisionResponse};
}
const getDiagnostic = async (s, cookie, requestId) => {
const response = await s.request(`/debug/vision/${requestId}`, {cookie});
assert.equal(response.status, 200);
return (await response.json()).diagnostic;
};
test("vision diagnostics routes are absent when disabled and protected when enabled", async t => {
const disabled = await setup(t, false);
assert.equal(disabled.app.locals.config.visionDebug, false);
const disabledUser = await disabled.register("debug-disabled");
assert.equal((await disabled.request("/debug/vision/latest", {cookie: disabledUser})).status, 404);
const enabled = await setup(t, true);
assert.equal((await enabled.request("/debug/vision/latest")).status, 403);
});
test("vision diagnostics capture misses, hits, Qwen evidence, search, reasoning and ownership safely", async t => {
const s = await setup(t, true);
const owner = await s.register("debug-owner");
const other = await s.register("debug-other");
const chatId = await s.createChat(owner);
const bytes = await sharp({create: {width: 24, height: 12, channels: 3, background: "black"}}).png().toBuffer();
const attachment = await s.upload(owner, bytes);
const question = "Search the web to identify this penguin.";
const first = await s.request("/chat", {cookie: owner, method: "POST", body: {
chatId, message: question, attachments: [attachment.id], model: "qwen3:14b", webSearch: false, reasoning: false
}});
assert.equal(first.status, 200);
const firstRequestId = first.headers.get("x-request-id");
const miss = await getDiagnostic(s, owner, firstRequestId);
assert.equal((await s.request(`/debug/vision/${firstRequestId}`, {cookie: other})).status, 404);
assert.equal(miss.requestId, firstRequestId);
assert.equal(miss.chatId, chatId);
assert.equal(miss.user, undefined);
assert.equal(miss.images.length, 1);
const image = miss.images[0];
assert.equal(image.attachmentId, attachment.id);
assert.equal(image.position, 0);
assert.equal(image.scope, "current");
assert.equal(image.label, "Current image 1");
assert.equal(image.originalFilename, "penguin.png");
assert.equal(image.mimeType, "image/png");
assert.equal(image.width, 24); assert.equal(image.height, 12); assert.equal(image.sizeBytes, bytes.length);
assert.equal(image.visionModel, "qwen2.5vl:7b");
assert.equal(image.visionPromptVersion, VISION_PROMPT_VERSION);
assert.equal(image.normalizedQuestion, question);
assert.equal(image.cacheHit, false);
assert.match(image.imageSha256, /^[a-f0-9]{64}$/);
assert.equal(image.rawVisionResponse, s.rawVisionResponse);
assert.equal(image.normalizedVisionAnalysis.scene, "A penguin stands on dark rocks.");
assert.equal(miss.qwen.visionEvidenceIncluded, true);
assert.equal(miss.qwen.imageCount, 1);
assert.deepEqual(miss.qwen.currentImageLabels, ["Current image 1"]);
assert.equal(miss.qwen.model, "qwen3:14b");
assert.equal(miss.qwen.think, false);
assert.equal(miss.qwen.stream, true);
assert.equal(miss.qwen.keepAlive, "30m");
const finalRequest = s.requests.findLast(request => request.model === "qwen3:14b");
assert.equal(miss.qwen.imageEvidence, finalRequest.messages.find(message => message.content.startsWith("<image_evidence>")).content);
assert.match(miss.qwen.visionGuidanceSha256, /^[a-f0-9]{64}$/);
assert.equal(miss.search.state, "disabled");
assert.equal(miss.visibleReasoning, null);
assert.equal(miss.finalAnswer, "Final penguin answer.");
const uploadedBase64 = s.requests.find(request => request.model === "qwen2.5vl:7b").messages.at(-1).images[0];
const diagnosticText = JSON.stringify(miss);
assert.equal(diagnosticText.includes(uploadedBase64), false);
assert.equal(diagnosticText.includes(bytes.toString("latin1")), false);
const messagesResponse = await s.request(`/chats/${chatId}/messages`, {cookie: owner});
const bot = (await messagesResponse.json()).messages.at(-1);
const retry = await s.request(`/chats/${chatId}/messages/${bot.id}/retry`, {cookie: owner, method: "POST", body: {
model: "qwen3:14b", webSearch: true, reasoning: true
}});
assert.equal(retry.status, 200);
const hit = await getDiagnostic(s, owner, retry.headers.get("x-request-id"));
assert.equal(hit.images[0].cacheHit, true);
assert.deepEqual(hit.images[0].cacheIdentity, {attachmentId: attachment.id, visionModel: "qwen2.5vl:7b",
visionPromptVersion: VISION_PROMPT_VERSION, normalizedQuestion: question});
assert.equal(hit.images[0].rawVisionResponse, null);
assert.equal(hit.images[0].imageSha256, null);
assert.deepEqual(hit.images[0].normalizedVisionAnalysis, image.normalizedVisionAnalysis);
assert.equal(hit.qwen.imageEvidence, miss.qwen.imageEvidence);
assert.equal(hit.qwen.think, true);
assert.equal(hit.visibleReasoning, "Compare the supplied visible evidence.");
assert.equal(hit.search.requested, true);
assert.equal(hit.search.needed, true);
assert.equal(hit.search.explicitSearchIntent, true);
assert.equal(hit.search.state, "succeeded");
assert.match(hit.search.visualReference, /thin black line beneath chin/i);
assert.deepEqual(hit.search.queries, s.searchQueries);
assert.equal(hit.search.candidateCount, 1);
assert.equal(hit.search.selectedCount, 1);
assert.deepEqual(hit.search.selectedSources[0], {index: 1, title: "Penguin identification guide",
url: "https://example.com/penguins", snippet: "A penguin identification guide compares crown, throat, bill, and chin markings."});
assert.equal(hit.finalAnswer, "Final penguin answer.");
const latest = await s.request("/debug/vision/latest", {cookie: owner});
assert.equal((await latest.json()).diagnostic.requestId, hit.requestId);
});
test("diagnostics distinguish skipped search and preserve multiple and historical image provenance", async t => {
const s = await setup(t, true);
const cookie = await s.register("debug-provenance");
const chatId = await s.createChat(cookie);
const red = await sharp({create: {width: 20, height: 10, channels: 3, background: "red"}}).png().toBuffer();
const blue = await sharp({create: {width: 30, height: 15, channels: 3, background: "blue"}}).png().toBuffer();
const first = await s.upload(cookie, red, "first.png");
const second = await s.upload(cookie, blue, "second.png");
const comparison = await s.request("/chat", {cookie, method: "POST", body: {
chatId, message: "What species are these?", attachments: [first.id, second.id], webSearch: true
}});
const current = await getDiagnostic(s, cookie, comparison.headers.get("x-request-id"));
assert.deepEqual(current.images.map(image => [image.attachmentId, image.position, image.scope, image.label]), [
[first.id, 0, "current", "Current image 1"], [second.id, 1, "current", "Current image 2"]
]);
assert.notEqual(current.images[0].imageSha256, current.images[1].imageSha256);
assert.equal(current.search.requested, true);
assert.equal(current.search.needed, false);
assert.equal(current.search.state, "skipped");
assert.deepEqual(current.search.queries, []);
assert.equal(current.search.candidateCount, 0);
const historicalResponse = await s.request("/chat", {cookie, method: "POST", body: {
chatId, message: "What did the previous image show?"
}});
const historical = await getDiagnostic(s, cookie, historicalResponse.headers.get("x-request-id"));
assert.equal(historical.images.length, 1);
assert.equal(historical.images[0].attachmentId, second.id);
assert.equal(historical.images[0].position, 1);
assert.equal(historical.images[0].scope, "historical");
assert.match(historical.images[0].label, /Earlier image from message/);
});
+3
View File
@@ -1,3 +1,6 @@
/**
* @file Prüft Suchentscheidungen, SearXNG, Quellenauswahl und die Einbindung der Websuche in Chat und Oberfläche.
*/
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { createServer } from "node:http"; import { createServer } from "node:http";
+3
View File
@@ -1,3 +1,6 @@
/**
* @file Sichert ab, dass explizite und datumsbezogene Websuchen vor der Modellgenerierung passende Quellen liefern.
*/
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import {createServer} from "node:http"; import {createServer} from "node:http";