Compare commits
9 Commits
80a30e2ce0
...
e8cd13a0af
| Author | SHA1 | Date | |
|---|---|---|---|
| e8cd13a0af | |||
| 20ad835205 | |||
| c69070a6e6 | |||
| d15f7867d5 | |||
| 7a1b152f20 | |||
| 0a24035b27 | |||
| 9020356e62 | |||
| 85e76b5d4c | |||
| 53160b3ea4 |
@@ -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
|
||||
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
|
||||
and uploads directory**. JSON workspace exports contain attachment references only;
|
||||
imports reconnect existing images owned by the same account, not portable image bytes.
|
||||
and uploads directory** for server recovery. The workspace's **Download workspace archive**
|
||||
button saves a `.tar.gz` containing `workspace.json` and referenced images; import that
|
||||
archive on another installation to restore chats with attachments. The importer also
|
||||
accepts older JSON exports, which contain attachment references only and reconnect
|
||||
images only when those IDs already belong to the importing account. Archives are
|
||||
limited to 250 MB uncompressed, and each image must meet the destination's upload limit.
|
||||
|
||||
`CHAT_LIMIT` defaults to 10 active chats per account. Archived chats remain available
|
||||
without using an active slot; restoring one requires a free slot.
|
||||
|
||||
Authenticated API endpoints:
|
||||
|
||||
|
||||
+4
-1
@@ -1,7 +1,10 @@
|
||||
/**
|
||||
* @file Konfiguriert ESLint-Regeln und Browser-Globals für JavaScript-Dateien.
|
||||
*/
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import { defineConfig } from "eslint/config";
|
||||
|
||||
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
@@ -9,7 +9,7 @@
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "node --watch server.js",
|
||||
"lint": "node scripts/lint.mjs",
|
||||
"lint": "eslint .",
|
||||
"test": "node --test"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
+20
-10
@@ -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"
|
||||
rel="stylesheet">
|
||||
<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">
|
||||
<link rel="stylesheet" href="/css/pages.css?v=20260909-account-controls">
|
||||
<link rel="stylesheet" href="/css/shell.css?v=20260909-account-controls">
|
||||
@@ -41,6 +41,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<button id="chatSidebarBackdrop" class="chat-sidebar-backdrop" type="button"
|
||||
aria-label="Close conversation history" hidden></button>
|
||||
|
||||
<section id="main" class="chat-main chat-main-focus" aria-label="Active conversation">
|
||||
<div class="chat-heading">
|
||||
@@ -127,6 +129,8 @@
|
||||
<p id="modelHelpSummary" class="model-help-summary">Pick a
|
||||
model to see what it is best suited for.</p>
|
||||
</div>
|
||||
<details class="generation-advanced">
|
||||
<summary>Advanced generation settings</summary>
|
||||
<div class="generation-settings-grid">
|
||||
<label class="field"><span>Temperature</span><input
|
||||
id="chatTemperature" type="number" min="0" max="2"
|
||||
@@ -144,6 +148,7 @@
|
||||
<button id="saveGenerationSettings" type="button">Save
|
||||
generation settings</button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</details>
|
||||
<details
|
||||
@@ -216,12 +221,12 @@
|
||||
data-chat-action="personaState">Roleplay state</button>
|
||||
<button id="backupWorkspace"
|
||||
class="secondary-action chat-drawer-action"
|
||||
type="button">Backup workspace</button>
|
||||
type="button">Download workspace archive</button>
|
||||
<button id="importWorkspace"
|
||||
class="secondary-action chat-drawer-action"
|
||||
type="button">Import workspace</button>
|
||||
<input id="importWorkspaceFile" class="hidden" type="file"
|
||||
accept="application/json,.json" />
|
||||
accept="application/json,.json,.tar.gz,application/gzip" />
|
||||
</div>
|
||||
</details>
|
||||
<details class="chat-drawer-section chat-drawer-danger">
|
||||
@@ -259,8 +264,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="messages" class="messages-panel messages-panel-focus" role="log" aria-live="polite"
|
||||
aria-relevant="additions text"></div>
|
||||
<div class="messages-wrap">
|
||||
<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-meta">
|
||||
@@ -308,7 +316,7 @@
|
||||
</div>
|
||||
|
||||
<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="persona-modal-heading">
|
||||
<p class="eyebrow">Persona editor</p>
|
||||
@@ -372,7 +380,7 @@
|
||||
</div>
|
||||
|
||||
<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="persona-modal-heading">
|
||||
<p class="eyebrow">Start roleplay</p>
|
||||
@@ -441,7 +449,7 @@
|
||||
</div>
|
||||
|
||||
<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="persona-modal-heading">
|
||||
<p class="eyebrow">Get started</p>
|
||||
@@ -458,9 +466,11 @@
|
||||
</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 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>
|
||||
</body>
|
||||
|
||||
|
||||
@@ -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-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-backdrop { display:none; }
|
||||
.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 .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-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 .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; }
|
||||
.site-page .settings-layout { grid-template-columns:1fr; }
|
||||
.site-page .settings-nav { display:flex; flex-wrap:wrap; }
|
||||
|
||||
+22
-2
@@ -363,7 +363,11 @@ input:focus, textarea:focus, select:focus { border-color: var(--brand); }
|
||||
}
|
||||
.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-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-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; }
|
||||
@@ -394,11 +398,14 @@ input:focus, textarea:focus, select:focus { border-color: var(--brand); }
|
||||
.chat-header-actions { display: flex; align-items: center; }
|
||||
.overflow-trigger { min-height: 38px; padding: 8px 12px; border-radius: 999px; font-size: .8rem; }
|
||||
|
||||
.messages-wrap { min-height: 0; position: relative; }
|
||||
.messages-panel {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 28px max(16px, calc((100% - 940px) / 2));
|
||||
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 {
|
||||
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);
|
||||
@@ -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: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-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; }
|
||||
.typing { display: flex; gap: 5px; }
|
||||
.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 a { max-width: 100%; }
|
||||
.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 { grid-area: search; }
|
||||
#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; }
|
||||
.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-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-drawer {
|
||||
|
||||
@@ -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") {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
@@ -60,6 +63,7 @@ export async function stream(url, data, handlers = {}, signal = undefined) {
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, {stream: true});
|
||||
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() || "";
|
||||
for (const part of parts) {
|
||||
const lines = part.split("\n");
|
||||
@@ -81,6 +85,7 @@ export async function stream(url, data, handlers = {}, signal = undefined) {
|
||||
}
|
||||
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.");
|
||||
} finally {
|
||||
await reader.cancel().catch(() => {});
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* @file Verwaltet Bildauswahl, Uploads und Vorschauen sowie die Anzeige gespeicherter Anhänge.
|
||||
*/
|
||||
export function appendAttachments(container, attachments = []) {
|
||||
if (!attachments.length) return;
|
||||
const gallery = document.createElement("div");
|
||||
@@ -24,6 +27,7 @@ export function createAttachmentComposer({onChange, onError}) {
|
||||
const preview = document.getElementById("uploadPreviews");
|
||||
const composer = document.querySelector(".input-container");
|
||||
let items = [];
|
||||
const drafts = new Map();
|
||||
let busy = false;
|
||||
let limits = {maxSizeMb: 10, maxFiles: 5};
|
||||
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();
|
||||
if (!response.ok) throw new Error(result.error || "Upload failed");
|
||||
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) {
|
||||
if (error.name !== "AbortError") item.error = error.message || "Upload failed";
|
||||
}
|
||||
item.uploading = false;
|
||||
item.controller = null;
|
||||
render();
|
||||
if (items.includes(item)) render();
|
||||
};
|
||||
const add = files => {
|
||||
if (busy) return;
|
||||
@@ -114,6 +118,25 @@ export function createAttachmentComposer({onChange, onError}) {
|
||||
}
|
||||
items = [];
|
||||
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"});
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
let lastFocusedElement = null;
|
||||
|
||||
@@ -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 onboardingPrompts = {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* @file Bündelt Referenzen auf die HTML-Elemente der Chat-Oberfläche.
|
||||
*/
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
const chatDiv = $("chat");
|
||||
|
||||
+20
-10
@@ -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 { initializeAppearance } from "./themeController.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 { openModal, closeModal, topModal } from "./modalFocus.js";
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const marketPersonaList = $("marketPersonaList");
|
||||
@@ -28,7 +32,6 @@ const marketPreviewExampleDialogues = $("marketPreviewExampleDialogues");
|
||||
const marketPreviewIntro = $("marketPreviewIntro");
|
||||
const marketPreviewUserPersonaField = $("marketPreviewUserPersonaField");
|
||||
const marketPreviewUserPersonaSelect = $("marketPreviewUserPersonaSelect");
|
||||
const marketPreviewScenarioField = $("marketPreviewScenarioField");
|
||||
const marketPreviewScenario = $("marketPreviewScenario");
|
||||
const marketPreviewStepOne = $("marketPreviewStepOne");
|
||||
const marketPreviewStepTwo = $("marketPreviewStepTwo");
|
||||
@@ -71,7 +74,6 @@ const marketPersonaFields = {
|
||||
|
||||
const validMarketSorts = new Set(["best", "newest", "most_favorited", "most_popular", "top_rated", "alphabetical"]);
|
||||
let marketPersonasByType = { assistant: [], user: [] };
|
||||
let assistantPersonas = [];
|
||||
let userPersonas = [];
|
||||
let pendingMarketPersona = null;
|
||||
let editingPersonaId = null;
|
||||
@@ -87,7 +89,7 @@ function setMarketStatus(message, state = "") {
|
||||
}
|
||||
|
||||
function closeMarketPopup(value = null) {
|
||||
marketPopupModal.classList.add("hidden");
|
||||
closeModal(marketPopupModal);
|
||||
if (marketPopupResolver) {
|
||||
const resolve = marketPopupResolver;
|
||||
marketPopupResolver = null;
|
||||
@@ -112,7 +114,7 @@ function promptMarketPopup({
|
||||
marketPopupInput.placeholder = placeholder;
|
||||
marketPopupConfirm.textContent = confirmLabel;
|
||||
marketPopupField.classList.remove("hidden");
|
||||
marketPopupModal.classList.remove("hidden");
|
||||
openModal(marketPopupModal, marketPopupInput);
|
||||
marketPopupInput.focus();
|
||||
marketPopupInput.select();
|
||||
return new Promise((resolve) => {
|
||||
@@ -238,7 +240,7 @@ function setCollapsiblePreviewText(element, value, maxLength = 220) {
|
||||
}
|
||||
|
||||
function closeMarketPreview() {
|
||||
marketPreviewModal.classList.add("hidden");
|
||||
closeModal(marketPreviewModal);
|
||||
pendingMarketPersona = null;
|
||||
marketPreviewStep = 1;
|
||||
marketPreviewUserPersonaSelect.innerHTML = "";
|
||||
@@ -315,7 +317,7 @@ function openMarketPreview(persona) {
|
||||
marketPreviewConfirm.classList.remove("hidden");
|
||||
}
|
||||
renderMarketPreviewStep();
|
||||
marketPreviewModal.classList.remove("hidden");
|
||||
openModal(marketPreviewModal, isAssistant ? marketPreviewUserPersonaSelect : marketPreviewConfirm);
|
||||
}
|
||||
|
||||
function openPersonaForm(persona = null) {
|
||||
@@ -323,12 +325,11 @@ function openPersonaForm(persona = null) {
|
||||
marketPersonaFormTitle.textContent = persona ? "Edit AI Character" : "Create AI Character";
|
||||
populatePersonaForm(marketPersonaFields, persona || {});
|
||||
setPersonaFormNotice("");
|
||||
marketPersonaModal.classList.remove("hidden");
|
||||
marketPersonaName.focus();
|
||||
openModal(marketPersonaModal, marketPersonaName);
|
||||
}
|
||||
|
||||
function closePersonaForm() {
|
||||
marketPersonaModal.classList.add("hidden");
|
||||
closeModal(marketPersonaModal);
|
||||
editingPersonaId = null;
|
||||
setPersonaFormNotice("");
|
||||
}
|
||||
@@ -507,7 +508,6 @@ async function reportPersona(marketId) {
|
||||
async function loadOwnedPersonas() {
|
||||
const res = await get("/personas");
|
||||
if (res.error) return false;
|
||||
assistantPersonas = res.assistantPersonas || [];
|
||||
userPersonas = res.userPersonas || [];
|
||||
activeUserPersonaId = res.activeUserPersonaId || null;
|
||||
return true;
|
||||
@@ -605,9 +605,19 @@ marketPopupInput.addEventListener("keydown", (event) => {
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
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);
|
||||
if (marketAssistantSort) {
|
||||
marketAssistantSort.addEventListener("change", () => setMarketSort("assistant", marketAssistantSort.value));
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
const focusableSelector = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
const stack = [];
|
||||
|
||||
function focusables(modal) {
|
||||
return [...modal.querySelectorAll(focusableSelector)].filter(element =>
|
||||
element.getClientRects().length && !element.closest('.hidden, [hidden]'));
|
||||
}
|
||||
|
||||
export function openModal(modal, preferredFocus) {
|
||||
const previous = document.activeElement;
|
||||
modal.classList.remove('hidden');
|
||||
stack.push({modal, previous});
|
||||
(preferredFocus || focusables(modal)[0] || modal.querySelector('[role="dialog"]'))?.focus();
|
||||
}
|
||||
|
||||
export function closeModal(modal) {
|
||||
const index = stack.findLastIndex(entry => entry.modal === modal);
|
||||
if (index < 0) return;
|
||||
const [{previous}] = stack.splice(index, 1);
|
||||
modal.classList.add('hidden');
|
||||
if (index === stack.length) {
|
||||
const target = stack.length ? focusables(stack.at(-1).modal)[0] : previous;
|
||||
if (target?.isConnected) target.focus();
|
||||
}
|
||||
}
|
||||
|
||||
export function topModal() { return stack.at(-1)?.modal || null; }
|
||||
|
||||
document.addEventListener('keydown', event => {
|
||||
if (event.key !== 'Tab' || !stack.length) return;
|
||||
const modal = topModal();
|
||||
const elements = focusables(modal);
|
||||
if (!elements.length) { event.preventDefault(); modal.querySelector('[role="dialog"]')?.focus(); return; }
|
||||
const first = elements[0], last = elements.at(-1);
|
||||
if (event.shiftKey && (document.activeElement === first || !modal.contains(document.activeElement))) {
|
||||
event.preventDefault(); last.focus();
|
||||
} else if (!event.shiftKey && (document.activeElement === last || !modal.contains(document.activeElement))) {
|
||||
event.preventDefault(); first.focus();
|
||||
}
|
||||
}, true);
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* @file Ermittelt die Modellfamilie und prüft die Unterstützung des Reasoning-Modus.
|
||||
*/
|
||||
// Conservative allowlist for models supporting the boolean think API.
|
||||
// Unknown models remain usable for normal chat.
|
||||
const reasoningFamilies = new Set(["qwen3", "deepseek-r1", "deepseek-v3.1"]);
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* @file Ordnet Modellen Profilinformationen für ihre Darstellung und Auswahl in der Oberfläche zu.
|
||||
*/
|
||||
export const defaultModelProfile = {
|
||||
badge: "General",
|
||||
summary: "Best for general questions, drafting, and everyday conversation. Use a specialized model when you need stronger coding, reasoning, or roleplay behavior."
|
||||
|
||||
@@ -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";
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* @file Überträgt Persona-Daten zwischen Formularfeldern und den verwendeten Datenobjekten.
|
||||
*/
|
||||
const personaFieldNames = ["name", "pronouns", "appearance", "background", "details", "exampleDialogues"];
|
||||
|
||||
export function populatePersonaForm(fields, persona = {}) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
export function createReasoningToggle(button) {
|
||||
@@ -43,6 +46,7 @@ export function updateReasoning(messageElement, thinking, streaming = false) {
|
||||
}
|
||||
details.querySelector("summary").textContent = streaming ? "Reasoning · Thinking…" : "Reasoning";
|
||||
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;
|
||||
content.textContent = thinking;
|
||||
if (streaming && follow) content.scrollTop = content.scrollHeight;
|
||||
|
||||
@@ -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 {updateReasoning, finishReasoning} from "./reasoning.js";
|
||||
import {appendSources} from "./webSearch.js";
|
||||
@@ -46,6 +49,7 @@ export async function regenerateInline({card, scroller, endpoint, payload, rende
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
// Bei Fehler oder Abbruch die ursprünglichen Knoten samt Ereignishandlern wieder einsetzen.
|
||||
if (!committed) update(() => card.replaceChildren(...original));
|
||||
card.removeAttribute("aria-busy");
|
||||
}
|
||||
|
||||
+336
-101
@@ -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 { regenerateInline } from "./regeneration.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 { 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 { getModelProfile } from "./modelProfiles.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 messageRetryState = 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 = "") {
|
||||
@@ -190,15 +204,33 @@ function isMobileLayout() {
|
||||
}
|
||||
|
||||
function closeMobileSidebar() {
|
||||
if (!chatSidebar) return;
|
||||
if (!chatSidebar || !document.body.classList.contains("sidebar-open")) return;
|
||||
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() {
|
||||
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");
|
||||
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() {
|
||||
@@ -212,7 +244,7 @@ function markOnboardingCompleted() {
|
||||
|
||||
function closeOnboarding() {
|
||||
if (!onboardingModal) return;
|
||||
onboardingModal.classList.add("hidden");
|
||||
closeModal(onboardingModal);
|
||||
}
|
||||
|
||||
function renderOnboardingStep() {
|
||||
@@ -231,6 +263,7 @@ function renderOnboardingStep() {
|
||||
button.addEventListener("click", () => {
|
||||
onboardingIntent = key;
|
||||
renderOnboardingStep();
|
||||
onboardingChoices.querySelector(".active")?.focus();
|
||||
});
|
||||
onboardingChoices.appendChild(button);
|
||||
});
|
||||
@@ -268,7 +301,7 @@ function maybeShowOnboarding() {
|
||||
onboardingStep = 1;
|
||||
onboardingIntent = "ask";
|
||||
renderOnboardingStep();
|
||||
onboardingModal.classList.remove("hidden");
|
||||
openModal(onboardingModal, onboardingChoices.querySelector("button"));
|
||||
}
|
||||
function setMessageContent(el, content) {
|
||||
const markdownParser = window.marked;
|
||||
@@ -280,11 +313,6 @@ function setMessageContent(el, content) {
|
||||
}
|
||||
}
|
||||
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) {
|
||||
return items.find((persona) => persona.id === id)?.name || "";
|
||||
}
|
||||
@@ -387,15 +415,22 @@ function resizeComposerInput() {
|
||||
}
|
||||
|
||||
function scrollMessagesToBottom() {
|
||||
requestAnimationFrame(() => {
|
||||
if (scrollFrame !== null) cancelAnimationFrame(scrollFrame);
|
||||
scrollFrame = requestAnimationFrame(() => {
|
||||
scrollFrame = null;
|
||||
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
||||
updateJumpToLatest();
|
||||
});
|
||||
}
|
||||
|
||||
function getNewestMessage() {
|
||||
return currentMessages.length ? currentMessages[currentMessages.length - 1] : null;
|
||||
function updateJumpToLatest() {
|
||||
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) {
|
||||
return currentMessages.findIndex((message) => message.id === messageId);
|
||||
}
|
||||
@@ -463,18 +498,12 @@ function syncRetryStateFromMessage(message) {
|
||||
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) {
|
||||
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");
|
||||
if (chatsResponse.error) throw new Error(chatsResponse.error);
|
||||
chatSessions = chatsResponse.chats || [];
|
||||
@@ -542,7 +571,7 @@ async function editChatMessage(messageId, fallbackIndex = -1) {
|
||||
async function deleteChatMessage(messageId, fallbackIndex = -1) {
|
||||
const target = await resolveMessageTarget(messageId, fallbackIndex);
|
||||
if (!target) return setNotice("Message is not ready yet. Try again.", "error");
|
||||
const { index, message } = target;
|
||||
const { message } = target;
|
||||
const confirmed = await confirmPopup({
|
||||
eyebrow: message.role === "user" ? "Your message" : "Assistant 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");
|
||||
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");
|
||||
editBtn.type = "button";
|
||||
editBtn.className = "msg-action-btn";
|
||||
@@ -710,7 +777,7 @@ function addMessage(contentOrMessage, isUser = false, isLoading = false, options
|
||||
editBtn.addEventListener("click", () => {
|
||||
void editChatMessage(message.id, options.messageIndex ?? -1);
|
||||
});
|
||||
actions.appendChild(editBtn);
|
||||
menuItems.appendChild(editBtn);
|
||||
|
||||
const deleteBtn = document.createElement("button");
|
||||
deleteBtn.type = "button";
|
||||
@@ -720,14 +787,14 @@ function addMessage(contentOrMessage, isUser = false, isLoading = false, options
|
||||
deleteBtn.addEventListener("click", () => {
|
||||
void deleteChatMessage(message.id, options.messageIndex ?? -1);
|
||||
});
|
||||
actions.appendChild(deleteBtn);
|
||||
menuItems.appendChild(deleteBtn);
|
||||
|
||||
const branchBtn = document.createElement("button");
|
||||
branchBtn.type = "button";
|
||||
branchBtn.className = "msg-action-btn";
|
||||
branchBtn.textContent = "Branch here";
|
||||
branchBtn.addEventListener("click", () => { void branchFromMessage(message.id); });
|
||||
actions.appendChild(branchBtn);
|
||||
menuItems.appendChild(branchBtn);
|
||||
|
||||
if (message.role === "bot") {
|
||||
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 (message.deliveryStatus === "failed" && message.errorMessage) {
|
||||
const error = document.createElement("p");
|
||||
error.className = "status";
|
||||
error.className = "status error";
|
||||
error.textContent = message.errorMessage;
|
||||
msgDiv.appendChild(error);
|
||||
}
|
||||
@@ -797,21 +864,46 @@ function addMessage(contentOrMessage, isUser = false, isLoading = false, options
|
||||
}
|
||||
|
||||
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);
|
||||
if (!currentMessages.length && (!chat || chat.assistant_persona_id)) {
|
||||
messagesDiv.replaceChildren();
|
||||
renderMessageEmptyState();
|
||||
renderedMessages = new Map();
|
||||
} else {
|
||||
currentMessages.forEach((msg, index) => addMessage(msg, msg.role === "user", false, {
|
||||
messageIndex: index,
|
||||
isNewest: index === currentMessages.length - 1
|
||||
}));
|
||||
const fragment = document.createDocumentFragment();
|
||||
const nextRendered = new Map();
|
||||
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();
|
||||
updateChatActionState();
|
||||
updateComposerPlaceholder();
|
||||
if (currentMessages.length) {
|
||||
if (wasReadingEarlier) {
|
||||
messagesDiv.scrollTop = previousScrollTop;
|
||||
updateJumpToLatest();
|
||||
} else if (currentMessages.length) {
|
||||
scrollMessagesToBottom();
|
||||
} else {
|
||||
updateJumpToLatest();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1046,19 +1138,22 @@ function maybeShowPersonaNudge() {
|
||||
|
||||
function renderChatList() {
|
||||
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)
|
||||
|| String(chat.matched_content || "").toLowerCase().includes(query));
|
||||
chatList.innerHTML = "";
|
||||
if (!filtered.length) {
|
||||
const empty = document.createElement("p");
|
||||
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);
|
||||
return;
|
||||
}
|
||||
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) => {
|
||||
const key = String(chat.folder_name).trim();
|
||||
const items = map.get(key) || [];
|
||||
@@ -1083,13 +1178,13 @@ function renderChatList() {
|
||||
list.setAttribute("role", "list");
|
||||
section.append(heading, list);
|
||||
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.setAttribute("role", "button");
|
||||
item.tabIndex = 0;
|
||||
item.setAttribute("aria-label", `Open chat ${chat.title}`);
|
||||
if (chat.id === activeChatId) item.setAttribute("aria-current", "page");
|
||||
content.className = "chat-item-copy";
|
||||
item.setAttribute("role", "listitem");
|
||||
content.type = "button";
|
||||
content.className = "chat-item-copy secondary-action";
|
||||
content.setAttribute("aria-label", `Open chat ${chat.title}`);
|
||||
if (chat.id === activeChatId) content.setAttribute("aria-current", "page");
|
||||
title.textContent = chat.title;
|
||||
meta.className = "chat-item-meta";
|
||||
const chatType = chat.assistant_persona_id
|
||||
@@ -1102,19 +1197,23 @@ function renderChatList() {
|
||||
remove.setAttribute("aria-label", `Delete chat ${chat.title}`);
|
||||
remove.addEventListener("click", async (event) => {
|
||||
event.stopPropagation();
|
||||
closeMobileSidebar();
|
||||
await deleteChat(chat.id);
|
||||
});
|
||||
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.addEventListener("click", () => {
|
||||
content.addEventListener("click", () => {
|
||||
closeMobileSidebar();
|
||||
setActiveChat(chat.id);
|
||||
});
|
||||
item.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
event.preventDefault();
|
||||
closeMobileSidebar();
|
||||
void setActiveChat(chat.id);
|
||||
void setActiveChat(chat.id, {matchMessageId: query ? chat.matched_message_id : null});
|
||||
});
|
||||
list.appendChild(item);
|
||||
});
|
||||
@@ -1131,7 +1230,7 @@ function updateActivePersonaStatus(items, activeId, statusEl, emptyText, prefix)
|
||||
function openPersonaForm(persona = null, personaType = "assistant") {
|
||||
closeChatDrawer();
|
||||
closePopup(false);
|
||||
personaModal.classList.remove("hidden");
|
||||
openModal(personaModal, personaNameInput);
|
||||
if (persona) {
|
||||
editingPersonaId = persona.id; editingPersonaType = persona.persona_type || "assistant";
|
||||
personaFormTitle.textContent = `Edit ${editingPersonaType === "user" ? "your persona" : "AI Character"}`;
|
||||
@@ -1151,7 +1250,7 @@ function openPersonaForm(persona = null, personaType = "assistant") {
|
||||
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() {
|
||||
if (!roleplayCharacterSelect || !roleplayUserPersonaSelect) return;
|
||||
roleplayCharacterSelect.innerHTML = "";
|
||||
@@ -1200,12 +1299,11 @@ function openRoleplayStarter(characterId = null) {
|
||||
populateRoleplayStarter();
|
||||
if (roleplayScenarioInput) roleplayScenarioInput.value = "";
|
||||
setRoleplayStarterNotice("");
|
||||
roleplayStarterModal.classList.remove("hidden");
|
||||
if (roleplayCharacterSelect.options.length) roleplayCharacterSelect.focus();
|
||||
openModal(roleplayStarterModal, roleplayCharacterSelect);
|
||||
}
|
||||
function closeRoleplayStarter() {
|
||||
if (!roleplayStarterModal) return;
|
||||
roleplayStarterModal.classList.add("hidden");
|
||||
closeModal(roleplayStarterModal);
|
||||
roleplayPresetCharacterId = null;
|
||||
roleplayStarterStep = 1;
|
||||
roleplayStarterConfirm.disabled = false;
|
||||
@@ -1234,7 +1332,7 @@ function finishPopup(result) {
|
||||
}
|
||||
function closePopup(resolveValue = null) {
|
||||
if (!popupModal || popupModal.classList.contains("hidden")) return;
|
||||
popupModal.classList.add("hidden");
|
||||
closeModal(popupModal);
|
||||
popupField.classList.add("hidden");
|
||||
popupInput.value = "";
|
||||
popupConfirmBtn.disabled = false;
|
||||
@@ -1262,7 +1360,7 @@ function openPopup(options) {
|
||||
} else {
|
||||
popupInput.value = "";
|
||||
}
|
||||
popupModal.classList.remove("hidden");
|
||||
openModal(popupModal, options.mode === "prompt" ? popupInput : popupConfirmBtn);
|
||||
return new Promise((resolve) => {
|
||||
popupResolver = resolve;
|
||||
requestAnimationFrame(() => {
|
||||
@@ -1405,6 +1503,9 @@ function setLoadingState(loading, overlayOptions = null) {
|
||||
messagesDiv.querySelectorAll('[data-message-action="regenerate"]').forEach((button) => {
|
||||
button.disabled = loading || button.dataset.retryLimitReached === "true";
|
||||
});
|
||||
messagesDiv.querySelectorAll('[data-message-action="retry-failed"]').forEach((button) => {
|
||||
button.disabled = loading;
|
||||
});
|
||||
if (loading) {
|
||||
msgInput.placeholder = "Processing response...";
|
||||
if (overlayOptions === false) {
|
||||
@@ -1427,8 +1528,21 @@ async function stopGeneration() {
|
||||
setNotice(res.stopped ? "Stopping generation..." : "Generation already finished.");
|
||||
}
|
||||
|
||||
async function setActiveChat(id, {preserveComposer = false} = {}) {
|
||||
if (!preserveComposer && id !== activeChatId) attachmentComposer.discard();
|
||||
async function setActiveChat(id, {preserveComposer = false, matchMessageId = null} = {}) {
|
||||
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();
|
||||
setChatLoading(true, "Opening chat...");
|
||||
setChatActivity(true, {
|
||||
@@ -1440,16 +1554,27 @@ async function setActiveChat(id, {preserveComposer = false} = {}) {
|
||||
history.replaceState(null, "", `/app/chats/${id}`);
|
||||
messageRetryState = new Map();
|
||||
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) {
|
||||
setChatLoading(false);
|
||||
setChatActivity(false);
|
||||
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);
|
||||
if (res.chat && existing) Object.assign(existing, res.chat);
|
||||
populateGenerationSettings(res.chat || existing);
|
||||
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();
|
||||
setChatLoading(false);
|
||||
setChatActivity(false);
|
||||
@@ -1457,13 +1582,17 @@ async function setActiveChat(id, {preserveComposer = false} = {}) {
|
||||
|
||||
async function loadChatSessions() {
|
||||
setChatLoading(true, "Loading chats...");
|
||||
const query = chatSearchInput?.value.trim();
|
||||
const res = query ? await get(`/chats/search?q=${encodeURIComponent(query)}`) : await get("/chats");
|
||||
const res = await get("/chats");
|
||||
if (res.error) {
|
||||
setChatLoading(false);
|
||||
return setNotice(res.error, "error");
|
||||
}
|
||||
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 (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) {
|
||||
@@ -1480,8 +1609,34 @@ async function loadChatSessions() {
|
||||
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} = {}) {
|
||||
if (!preserveComposer) attachmentComposer.discard();
|
||||
setChatLoading(true, "Creating chat...");
|
||||
setChatActivity(true, {
|
||||
eyebrow: "Creating chat",
|
||||
@@ -1629,18 +1784,18 @@ function exportCurrentChat() {
|
||||
}
|
||||
|
||||
async function backupWorkspace() {
|
||||
const response = await fetch("/exports/workspace");
|
||||
const data = await response.json();
|
||||
const blob = new Blob([JSON.stringify(data.workspace, null, 2)], { type: "application/json;charset=utf-8" });
|
||||
const response = await fetch("/exports/workspace/archive");
|
||||
if (!response.ok) return setNotice("Workspace archive could not be created.", "error");
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
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);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
setNotice("Workspace backup downloaded.", "success");
|
||||
setNotice("Workspace archive downloaded with images.", "success");
|
||||
}
|
||||
|
||||
function formatImportPreview(preview, warnings = []) {
|
||||
@@ -1657,6 +1812,31 @@ function formatImportPreview(preview, warnings = []) {
|
||||
async function importWorkspaceFromFile(file) {
|
||||
if (!file) return;
|
||||
setChatLoading(true, "Reading workspace backup...");
|
||||
const isArchive = file.name.toLowerCase().endsWith(".tar.gz");
|
||||
if (isArchive) {
|
||||
const submit = async (path, allowDuplicates = false) => {
|
||||
const body = new FormData();
|
||||
body.append("archive", file);
|
||||
if (allowDuplicates) body.append("allowDuplicates", "true");
|
||||
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;
|
||||
try {
|
||||
workspace = JSON.parse(await file.text());
|
||||
@@ -1801,7 +1981,9 @@ async function deleteChat(id) {
|
||||
return setNotice(res.error, "error");
|
||||
}
|
||||
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();
|
||||
await loadSummary(); renderChatList();
|
||||
if (chatSessions.length) await setActiveChat(chatSessions[0].id); else await createNewChat();
|
||||
@@ -1846,15 +2028,21 @@ async function checkSession() {
|
||||
// Starters are offered on workspace home.
|
||||
const starter = new URLSearchParams(location.search).get("prompt");
|
||||
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 (attachmentComposer.pending) return;
|
||||
const attachments = attachmentComposer.attachments;
|
||||
const message = msgInput.value.trim() || (attachments.length ? "Describe the attached images." : "");
|
||||
const attachments = retryMessage?.attachments || attachmentComposer.attachments;
|
||||
const message = retryMessage?.content || msgInput.value.trim() || (attachments.length ? "Describe the attached images." : "");
|
||||
if (!message) return;
|
||||
if (retryMessage && !msgInput.value.trim()) {
|
||||
msgInput.value = message;
|
||||
resizeComposerInput();
|
||||
}
|
||||
const webSearch = webSearchToggle.enabled;
|
||||
const reasoning = reasoningToggle.enabled;
|
||||
if (!activeChatId) await createNewChat({preserveComposer: true});
|
||||
@@ -1865,25 +2053,31 @@ async function sendMessage() {
|
||||
currentChat.title = autoTitle; updateWorkspaceCopy(); renderChatList(); await put(`/chats/${activeChatId}`, { title: autoTitle });
|
||||
}
|
||||
const userMessage = { role: "user", content: message, attachments };
|
||||
currentMessages.push(userMessage); addMessage(userMessage, true); updateChatActionState();
|
||||
msgInput.value = "";
|
||||
resizeComposerInput();
|
||||
currentMessages.push(userMessage);
|
||||
const userCard = addMessage(userMessage, true);
|
||||
updateChatActionState();
|
||||
setLoadingState(true, false); setNotice("Generating reply...");
|
||||
const loadingMsg = addMessage({ role: "bot", content: "" }, false, true, { isNewest: true });
|
||||
const loadingBody = loadingMsg.querySelector(".msg-content");
|
||||
const loadingShell = loadingMsg.querySelector(".loading-shell");
|
||||
const loadingLabel = loadingMsg.querySelector(".loading-copy strong");
|
||||
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 {
|
||||
let streamError = null;
|
||||
await stream("/chat/stream", { message, attachments: attachments.map(item => item.id), model: modelSelect.value, chatId: activeChatId, webSearch, reasoning }, {
|
||||
onStatus: ({ status, sourceCount, searchState }) => {
|
||||
if (status === "accepted") {
|
||||
attachmentComposer.clear();
|
||||
webSearchToggle.reset();
|
||||
reasoningToggle.reset();
|
||||
return;
|
||||
}
|
||||
if (status === "accepted") return;
|
||||
loadingLabel.textContent = status === "analyzing" ? "Analyzing images..." : status === "searching" ? "Searching the web..."
|
||||
: searchState === "no_relevant_results" ? "No sufficiently relevant results. Generating response..."
|
||||
: sourceCount === 0 ? "No web results found. Generating response..." : "Generating response...";
|
||||
@@ -1906,7 +2100,10 @@ async function sendMessage() {
|
||||
}
|
||||
if (follow) scrollMessagesToBottom();
|
||||
},
|
||||
onDone: () => { webSearchToggle.reset(); reasoningToggle.reset(); finishReasoning(loadingMsg); },
|
||||
onDone: ({ reply }) => {
|
||||
completedReply = reply;
|
||||
webSearchToggle.reset(); reasoningToggle.reset(); finishReasoning(loadingMsg);
|
||||
},
|
||||
onError: ({ error }) => {
|
||||
streamError = error || "Failed to generate reply.";
|
||||
}
|
||||
@@ -1915,14 +2112,16 @@ async function sendMessage() {
|
||||
const previousScrollTop = messagesDiv.scrollTop;
|
||||
if (messagesDiv.contains(loadingMsg)) messagesDiv.removeChild(loadingMsg);
|
||||
if (streamError) {
|
||||
if (webSearch) {
|
||||
msgInput.value = message;
|
||||
resizeComposerInput();
|
||||
}
|
||||
addMessage(`[Error: ${streamError}]`);
|
||||
setNotice(streamError, "error");
|
||||
showSendFailure(streamError);
|
||||
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 loadSummary();
|
||||
markOnboardingCompleted();
|
||||
@@ -1931,12 +2130,16 @@ async function sendMessage() {
|
||||
setNotice("Reply received.", "success");
|
||||
} catch (error) {
|
||||
if (messagesDiv.contains(loadingMsg)) messagesDiv.removeChild(loadingMsg);
|
||||
if (webSearch) {
|
||||
msgInput.value = message;
|
||||
resizeComposerInput();
|
||||
}
|
||||
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 {
|
||||
setLoadingState(false); msgInput.focus(); updateChatActionState();
|
||||
}
|
||||
@@ -1993,7 +2196,7 @@ function bindChatDrawerActions() {
|
||||
}
|
||||
|
||||
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 () {
|
||||
resizeComposerInput();
|
||||
updateSendState();
|
||||
@@ -2016,7 +2219,7 @@ if (importWorkspaceBtn) importWorkspaceBtn.addEventListener("click", () => {
|
||||
if (importWorkspaceFileInput) importWorkspaceFileInput.addEventListener("change", () => {
|
||||
void importWorkspaceFromFile(importWorkspaceFileInput.files?.[0]);
|
||||
});
|
||||
chatSearchInput.addEventListener("input", () => { void loadChatSessions(); });
|
||||
chatSearchInput.addEventListener("input", scheduleChatSearch);
|
||||
modelSelect.addEventListener("change", () => {
|
||||
renderModelSection();
|
||||
void saveSelectedModel();
|
||||
@@ -2026,7 +2229,7 @@ if (headerModelButton) headerModelButton.addEventListener("click", (event) => {
|
||||
openModelSettings();
|
||||
});
|
||||
saveGenerationSettingsBtn.addEventListener("click", () => { void saveGenerationSettings(); });
|
||||
roleplayNewPersonaBtn.addEventListener("click", () => openPersonaForm(null, "assistant"));
|
||||
roleplayNewPersonaBtn.addEventListener("click", () => { closeRoleplayStarter(); openPersonaForm(null, "assistant"); });
|
||||
clearUserPersonaBtn.addEventListener("click", () => {
|
||||
closeChatDrawer();
|
||||
void clearPersona("user");
|
||||
@@ -2069,9 +2272,11 @@ popupInput.addEventListener("keydown", (event) => {
|
||||
}
|
||||
});
|
||||
bindChatDrawerActions();
|
||||
if (chatSidebar) chatSidebar.inert = isMobileLayout();
|
||||
if (chatSidebar) {
|
||||
chatSidebar.addEventListener("click", (event) => event.stopPropagation());
|
||||
}
|
||||
document.getElementById("chatSidebarBackdrop")?.addEventListener("click", closeMobileSidebar);
|
||||
if (chatSidebarToggle) {
|
||||
chatSidebarToggle.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
@@ -2086,12 +2291,14 @@ if (onboardingContinue) {
|
||||
onboardingContinue.addEventListener("click", () => {
|
||||
onboardingStep = 2;
|
||||
renderOnboardingStep();
|
||||
onboardingChoices.querySelector("button")?.focus();
|
||||
});
|
||||
}
|
||||
if (onboardingBack) {
|
||||
onboardingBack.addEventListener("click", () => {
|
||||
onboardingStep = 1;
|
||||
renderOnboardingStep();
|
||||
(onboardingChoices.querySelector(".active") || onboardingChoices.querySelector("button"))?.focus();
|
||||
});
|
||||
}
|
||||
if (onboardingSkip) {
|
||||
@@ -2102,14 +2309,42 @@ if (onboardingModal) {
|
||||
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();
|
||||
});
|
||||
window.addEventListener("resize", () => {
|
||||
if (!isMobileLayout()) closeMobileSidebar();
|
||||
if (chatSidebar) chatSidebar.inert = isMobileLayout() && !document.body.classList.contains("sidebar-open");
|
||||
});
|
||||
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") {
|
||||
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();
|
||||
closeRoleplayStarter();
|
||||
closePersonaForm();
|
||||
|
||||
+19
-9
@@ -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 {
|
||||
applyTheme as applySharedTheme,
|
||||
applyThemeMode as applySharedThemeMode,
|
||||
@@ -6,6 +9,7 @@ import {
|
||||
} from "./themeController.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 { openModal, closeModal, topModal } from "./modalFocus.js";
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const settingsNotice = $("settingsNotice");
|
||||
@@ -62,7 +66,6 @@ const settingsPopupConfirm = $("settingsPopupConfirm");
|
||||
const settingsPopupTitle = $("settingsPopupTitle");
|
||||
const settingsPopupEyebrow = $("settingsPopupEyebrow");
|
||||
const settingsPopupDescription = $("settingsPopupDescription");
|
||||
const settingsPopupField = $("settingsPopupField");
|
||||
const settingsPopupInputLabel = $("settingsPopupInputLabel");
|
||||
const settingsPopupInput = $("settingsPopupInput");
|
||||
const settingsPersonaFields = {
|
||||
@@ -94,7 +97,7 @@ function setPersonaNotice(message = "", state = "") {
|
||||
}
|
||||
|
||||
function closeSettingsPopup(value = null) {
|
||||
settingsPopupModal.classList.add("hidden");
|
||||
closeModal(settingsPopupModal);
|
||||
if (settingsPopupResolver) {
|
||||
const resolve = settingsPopupResolver;
|
||||
settingsPopupResolver = null;
|
||||
@@ -118,7 +121,7 @@ function promptSettingsPopup({
|
||||
settingsPopupInput.value = value;
|
||||
settingsPopupInput.placeholder = placeholder;
|
||||
settingsPopupConfirm.textContent = confirmLabel;
|
||||
settingsPopupModal.classList.remove("hidden");
|
||||
openModal(settingsPopupModal, settingsPopupInput);
|
||||
settingsPopupInput.focus();
|
||||
settingsPopupInput.select();
|
||||
return new Promise((resolve) => {
|
||||
@@ -230,12 +233,11 @@ function openPersonaModal(persona = null, personaType = "assistant") {
|
||||
populatePersonaForm(settingsPersonaFields, persona || {});
|
||||
syncPersonaExamplesField();
|
||||
setPersonaNotice("");
|
||||
settingsPersonaModal.classList.remove("hidden");
|
||||
settingsPersonaName.focus();
|
||||
openModal(settingsPersonaModal, settingsPersonaName);
|
||||
}
|
||||
|
||||
function closePersonaModal() {
|
||||
settingsPersonaModal.classList.add("hidden");
|
||||
closeModal(settingsPersonaModal);
|
||||
editingPersonaId = null;
|
||||
setPersonaNotice("");
|
||||
}
|
||||
@@ -497,9 +499,8 @@ usernameForm.addEventListener("submit", async (event) => {
|
||||
setNotice(res.error, "error");
|
||||
return;
|
||||
}
|
||||
currentUsernameInput.value = res.username;
|
||||
newUsernameInput.value = res.username;
|
||||
usernamePasswordInput.value = "";
|
||||
if (!await loadProfile()) return;
|
||||
setNotice("Username updated.", "success");
|
||||
});
|
||||
|
||||
@@ -570,9 +571,18 @@ settingsPopupInput.addEventListener("keydown", (event) => {
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
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 () => {
|
||||
initializeAppearance({ title: "Elsewhere Settings", themeSelect: settingsThemeSelect, modeSelect: themeModeSelect });
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* @file Zeigt Statusmeldungen an und setzt deren Inhalt und Sichtbarkeit zurück.
|
||||
*/
|
||||
export function setStatus(element, message = "", state = "", baseClass = "status") {
|
||||
if (!element) return;
|
||||
element.textContent = message;
|
||||
|
||||
@@ -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 storedTheme = localStorage.getItem("krishd-theme");
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* @file Normalisiert, speichert und synchronisiert Theme und Hell-Dunkel-Modus der Oberfläche.
|
||||
*/
|
||||
import {themes} from "./constants.js?v=20260909-account-controls";
|
||||
|
||||
export const THEME_STORAGE_KEY = "krishd-theme";
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* @file Steuert die Websuche-Auswahl und rendert Quellenlinks zu Chat-Antworten.
|
||||
*/
|
||||
/** @typedef {{title: string, url: string, snippet: string}} WebSource */
|
||||
/** @typedef {{message: string, model: string, chatId: number, webSearch?: boolean, reasoning?: boolean}} ChatRequest */
|
||||
/** @typedef {{role: string, content: string, thinking?: string, sources?: WebSource[]}} ChatMessage */
|
||||
|
||||
@@ -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.
|
||||
for (const button of document.querySelectorAll('[data-sign-out]')) {
|
||||
|
||||
+2
-2
@@ -102,7 +102,7 @@
|
||||
</main>
|
||||
|
||||
<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>
|
||||
<p class="eyebrow">Persona preview</p>
|
||||
@@ -162,7 +162,7 @@
|
||||
</div>
|
||||
|
||||
<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="persona-modal-heading">
|
||||
<p class="eyebrow">AI Character editor</p>
|
||||
|
||||
@@ -232,7 +232,7 @@
|
||||
</main>
|
||||
|
||||
<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="persona-modal-heading">
|
||||
<p class="eyebrow">Persona editor</p>
|
||||
@@ -317,7 +317,7 @@
|
||||
</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>
|
||||
</body>
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* @file Startet den HTTP-Server der Chat-Anwendung.
|
||||
*/
|
||||
import {startServer} from "./src/app.js";
|
||||
|
||||
startServer();
|
||||
|
||||
+4
-1
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* @file Verbindet Express, Sitzungen, Datenbank und Routen und verwaltet den Server-Lebenszyklus.
|
||||
*/
|
||||
import fs from "fs";
|
||||
import {randomUUID} from "crypto";
|
||||
import {performance} from "perf_hooks";
|
||||
@@ -306,7 +309,7 @@ export function createApp(options = {}) {
|
||||
app.use(createUploadsRouter(attachmentService, config));
|
||||
if (config.visionDebug) app.use(createVisionDebugRouter({visionDebugService, 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(createSystemRouter({modelService, config}));
|
||||
app.use(notFoundHandler);
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* @file Erstellt die Anwendungskonfiguration aus Umgebungsvariablen, Standardwerten und expliziten Vorgaben.
|
||||
*/
|
||||
import path from "path";
|
||||
|
||||
const bool = (value, fallback = false) => {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* @file Definiert Tabellen für Bildanhänge und Analyse-Caches und bereitet Anhangsmetadaten auf.
|
||||
*/
|
||||
export function initializeAttachments(db) {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS attachments (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* @file Öffnet die SQLite-Datenbank und initialisiert Tabellen sowie Migrationen.
|
||||
*/
|
||||
import fs from "fs";
|
||||
import Database from "better-sqlite3";
|
||||
import {initializeSchema, runMigrations} from "./schema.js";
|
||||
|
||||
+19
-7
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* @file Bündelt Datenbankzugriffe auf Konten, Chats, Nachrichten, Personas und den Marktplatz.
|
||||
*/
|
||||
import {attachmentMetadata} from "./attachments.js";
|
||||
|
||||
const parseJsonArray = (value) => {
|
||||
@@ -84,7 +87,7 @@ export function createRepositories(db, config) {
|
||||
WHERE cs.username = ?
|
||||
ORDER BY cs.is_pinned DESC, datetime(cs.updated_at) DESC
|
||||
`),
|
||||
countChatSessions: db.prepare("SELECT COUNT(*) as count FROM chat_sessions WHERE username = ?"),
|
||||
countChatSessions: db.prepare("SELECT COUNT(*) as count FROM chat_sessions WHERE username = ? AND archived_at IS NULL"),
|
||||
getChatSession: db.prepare(`
|
||||
SELECT cs.id,
|
||||
CASE
|
||||
@@ -378,7 +381,7 @@ export function createRepositories(db, config) {
|
||||
deleteChatMessage: db.prepare("DELETE FROM chat_messages WHERE id = ? AND chat_id = ?"),
|
||||
deleteChatMessages: db.prepare("DELETE FROM chat_messages WHERE chat_id = ?"),
|
||||
searchChats: db.prepare(`
|
||||
SELECT DISTINCT cs.id,
|
||||
SELECT cs.id,
|
||||
CASE
|
||||
WHEN cs.assistant_persona_id IS NOT NULL AND cs.source_chat_id IS NULL THEN COALESCE(p.name, cs.title)
|
||||
ELSE cs.title
|
||||
@@ -405,18 +408,23 @@ export function createRepositories(db, config) {
|
||||
cs.system_instruction,
|
||||
cs.created_at,
|
||||
cs.updated_at,
|
||||
cm.id AS matched_message_id,
|
||||
cm.content AS matched_content
|
||||
FROM chat_sessions cs
|
||||
LEFT JOIN personas p ON p.id = cs.assistant_persona_id
|
||||
LEFT JOIN personas up ON up.id = cs.user_persona_id
|
||||
LEFT JOIN chat_messages cm ON cm.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 = ?
|
||||
AND (
|
||||
LOWER(CASE
|
||||
WHEN cs.assistant_persona_id IS NOT NULL AND cs.source_chat_id IS NULL THEN COALESCE(p.name, cs.title)
|
||||
ELSE cs.title
|
||||
END) LIKE ?
|
||||
OR LOWER(COALESCE(cm.content, '')) LIKE ?
|
||||
OR cm.id IS NOT NULL
|
||||
)
|
||||
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) => {
|
||||
// Existing username references do not all cascade on update. Check them at commit,
|
||||
// after every owned row has been moved to the new username.
|
||||
db.pragma("defer_foreign_keys = ON");
|
||||
statements.updateUsername.run(nextUsername, currentUsername);
|
||||
statements.updateChatSessionsUsername.run(nextUsername, currentUsername);
|
||||
statements.updatePersonasUsername.run(nextUsername, currentUsername);
|
||||
@@ -876,9 +887,10 @@ export function createRepositories(db, config) {
|
||||
.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()) {
|
||||
// JSON backups contain references, not files. Only restore existing, owned 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'")
|
||||
// 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 IN ('temporary','attached')")
|
||||
.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(
|
||||
@@ -942,7 +954,7 @@ export function createRepositories(db, config) {
|
||||
listChats: (username) => statements.listChatSessions.all(username),
|
||||
searchChats(username, query) {
|
||||
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,
|
||||
getChat: (chatId, username) => statements.getChatSession.get(chatId, username),
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* @file Definiert das Datenbankschema und migriert bestehende Daten auf die benötigte Struktur.
|
||||
*/
|
||||
export function initializeSchema(db) {
|
||||
db.pragma("journal_mode = WAL");
|
||||
db.pragma("foreign_keys = ON");
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* @file Schützt Routen vor Zugriffen ohne angemeldete Sitzung.
|
||||
*/
|
||||
export function requireLogin(req, res, next) {
|
||||
if (!req.session.user) {
|
||||
return res.status(403).json({error: "Not logged in"});
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* @file Begrenzt Anfragen je Schlüssel in einem gleitenden Zeitfenster und entfernt abgelaufene Einträge.
|
||||
*/
|
||||
function pruneBucket(bucket, windowMs, now) {
|
||||
const cutoff = now - windowMs;
|
||||
while (bucket.length && bucket[0] <= cutoff) {
|
||||
@@ -21,6 +24,7 @@ export function createRateLimiter({windowMs, max, keyGenerator, message, cleanup
|
||||
|
||||
if (cleanupIntervalMs > 0) {
|
||||
cleanupTimer = setInterval(cleanup, cleanupIntervalMs);
|
||||
// Der Aufräumtimer allein soll den Node-Prozess beim Herunterfahren nicht offen halten.
|
||||
cleanupTimer.unref();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* @file Erstellt die konfigurierten Anfragebegrenzungen für Anmeldung und Chat-Nutzung.
|
||||
*/
|
||||
import {createRateLimiter} from "./rateLimit.js";
|
||||
|
||||
export function createAuthRateLimiters(config) {
|
||||
|
||||
@@ -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) {
|
||||
return (req, res, next) => {
|
||||
const result = validator(req.body || {}, req);
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* @file Erzeugt den gemeinsamen HTML-Seitenkopf mit Navigation und Kontosteuerung.
|
||||
*/
|
||||
const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (character) => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
||||
})[character]);
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* @file Verarbeitet Registrierung, Anmeldung, Abmeldung und die Abfrage der aktuellen Sitzung.
|
||||
*/
|
||||
import express from "express";
|
||||
import bcrypt from "bcrypt";
|
||||
import {validateBody} from "../middleware/validate.js";
|
||||
|
||||
+82
-3
@@ -1,7 +1,12 @@
|
||||
/**
|
||||
* @file Stellt Chat-Routen für Nachrichten, Streaming, Antwortvarianten, Verzweigungen und Einstellungen bereit.
|
||||
*/
|
||||
import express from "express";
|
||||
import multer from "multer";
|
||||
import {requireLogin} from "../middleware/auth.js";
|
||||
import {validateBody} from "../middleware/validate.js";
|
||||
import {validateWorkspaceImport} from "../services/workspaceImportService.js";
|
||||
import {createWorkspaceArchive, parseWorkspaceArchive, MAX_ARCHIVE_BYTES} from "../services/workspaceArchive.js";
|
||||
|
||||
const createChatValidator = (body) => ({
|
||||
value: {
|
||||
@@ -102,9 +107,29 @@ const preferredModelValidator = (body) => {
|
||||
return {value: {model}};
|
||||
};
|
||||
|
||||
export function createChatsRouter({repositories, chatService, modelService, config, chatRateLimiters}) {
|
||||
export function createChatsRouter({repositories, chatService, modelService, config, chatRateLimiters, attachmentService}) {
|
||||
const router = express.Router();
|
||||
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) => {
|
||||
const chatId = Number(req.params.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)});
|
||||
} catch (error) {
|
||||
if (error.message === "CHAT_LIMIT_REACHED") {
|
||||
return res.status(400).json({error: `Maximum of ${config.chatLimit} chats reached. Please delete an old chat to create a new one.`});
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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});
|
||||
@@ -168,6 +193,9 @@ export function createChatsRouter({repositories, chatService, modelService, conf
|
||||
const chatId = Number(req.params.id);
|
||||
const chat = repositories.getChat(chatId, req.session.user);
|
||||
if (!chat) return res.status(404).json({error: "Chat not found"});
|
||||
if (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, {
|
||||
folderName: req.validatedBody.folderName,
|
||||
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/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) => {
|
||||
const validation = validateWorkspaceImport(req.body?.workspace, repositories.exportWorkspace(req.session.user));
|
||||
return res.status(validation.ok ? 200 : 400).json({
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* @file Liefert öffentliche Seiten und geschützte Ansichten des Arbeitsbereichs aus.
|
||||
*/
|
||||
import express from "express";
|
||||
import {readFile} from "node:fs/promises";
|
||||
import {renderSiteHeader} from "../pageLayout.js";
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* @file Verwaltet eigene Personas sowie Veröffentlichung, Suche und Interaktionen auf dem Persona-Marktplatz.
|
||||
*/
|
||||
import express from "express";
|
||||
import {requireLogin} from "../middleware/auth.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)});
|
||||
} catch (error) {
|
||||
if (error.message === "CHAT_LIMIT_REACHED") {
|
||||
return res.status(400).json({error: `Maximum of ${config.chatLimit} chats reached. Please delete an old chat to create a new one.`});
|
||||
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;
|
||||
}
|
||||
@@ -242,7 +245,7 @@ export function createPersonasRouter({repositories, chatService, modelService, c
|
||||
chat = chatService.getOrCreatePersonaChat(user, persona, userPersonaId, scenarioPrompt, sceneSummary);
|
||||
} catch (error) {
|
||||
if (error.message === "CHAT_LIMIT_REACHED") {
|
||||
return res.status(400).json({error: `Maximum of ${config.chatLimit} chats reached. Please delete an old chat to create a new one.`});
|
||||
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;
|
||||
}
|
||||
@@ -297,7 +300,7 @@ export function createPersonasRouter({repositories, chatService, modelService, c
|
||||
chat = chatService.getOrCreatePersonaChat(user, assistantPersona, userPersonaId, scenarioPrompt, sceneSummary);
|
||||
} catch (error) {
|
||||
if (error.message === "CHAT_LIMIT_REACHED") {
|
||||
return res.status(400).json({error: `Maximum of ${config.chatLimit} chats reached. Please delete an old chat to create a new one.`});
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* @file Verarbeitet Benutzer- und Kontoeinstellungen sowie Export und Import des Arbeitsbereichs.
|
||||
*/
|
||||
import express from "express";
|
||||
import bcrypt from "bcrypt";
|
||||
import {requireLogin} from "../middleware/auth.js";
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* @file Stellt Modellinformationen und administrative Diagnosedaten bereit.
|
||||
*/
|
||||
import express from "express";
|
||||
import {requireLogin} from "../middleware/auth.js";
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* @file Verarbeitet das Hochladen, Abrufen und Entfernen von Bildanhängen mit Zugriffskontrolle.
|
||||
*/
|
||||
import express from "express";
|
||||
import multer from "multer";
|
||||
import {requireLogin} from "../middleware/auth.js";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* @file Validiert und speichert Bildanhänge und bereitet zwischengespeicherte Bildanalysen für den Chat vor.
|
||||
*/
|
||||
import sharp from "sharp";
|
||||
import {createHash, randomUUID} from "node:crypto";
|
||||
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);
|
||||
return cleanup();
|
||||
},
|
||||
async upload(file, user) {
|
||||
async upload(file, user, {forImport = false} = {}) {
|
||||
await cleanup();
|
||||
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);
|
||||
@@ -175,9 +178,9 @@ export function createAttachmentService(db, config, modelService, visionDebugSer
|
||||
try {
|
||||
// Bound abandoned uploads per account as well as per submitted message.
|
||||
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,
|
||||
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; }
|
||||
log("attachmentUploadSuccess", {id, size: file.size, mime: `image/${info.format}`});
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* @file Normalisiert Nachrichten und erstellt Kontexttexte für Erinnerungen, Zusammenfassungen und Personas.
|
||||
*/
|
||||
import {normalizeSources} from "./webSearchService.js";
|
||||
|
||||
export function parseSources(value) {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* @file Koordiniert Chat-Antworten, Modellkontext, Websuche, Anhänge, Antwortvarianten und Gesprächsverzweigungen.
|
||||
*/
|
||||
import {
|
||||
buildPersonaPrompt,
|
||||
buildRoleplayDirectionPrompt,
|
||||
@@ -95,15 +98,18 @@ export function createChatService(repositories, modelService, config, webSearchP
|
||||
|
||||
const withChatGenerationLock = (chatId, operation) => {
|
||||
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);
|
||||
chatGenerationQueues.set(chatId, current);
|
||||
return current.finally(() => {
|
||||
// Nur den eigenen Eintrag entfernen: Inzwischen könnte bereits ein Folgeauftrag warten.
|
||||
if (chatGenerationQueues.get(chatId) === current) chatGenerationQueues.delete(chatId);
|
||||
});
|
||||
};
|
||||
|
||||
const withActiveGeneration = async (chatId, externalSignal, operation) => {
|
||||
const controller = new AbortController();
|
||||
// Verbindungsabbruch und manuelles Stoppen wirken auf denselben Generierungsauftrag.
|
||||
const abortFromCaller = () => controller.abort(externalSignal?.reason);
|
||||
if (externalSignal?.aborted) abortFromCaller();
|
||||
else externalSignal?.addEventListener("abort", abortFromCaller, { once: true });
|
||||
@@ -157,6 +163,7 @@ export function createChatService(repositories, modelService, config, webSearchP
|
||||
const allMessages = repositories.listActiveBranchMessages(chatId)
|
||||
.filter((message) => !message.delivery_status || message.delivery_status === "complete");
|
||||
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;
|
||||
if (cutoffIndex < 0) return;
|
||||
const cutoffMessage = allMessages[cutoffIndex];
|
||||
@@ -577,7 +584,7 @@ export function createChatService(repositories, modelService, config, webSearchP
|
||||
|
||||
const cloneChatThroughMessage = ({ user, sourceChat, messageId = null }) => {
|
||||
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
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* @file Speichert, liest und löscht hochgeladene Dateien in einem lokalen Speicherverzeichnis.
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {randomUUID} from "node:crypto";
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* @file Kommuniziert mit Ollama und verwaltet Antwortstreams, Zeitlimits, Modellstatus und Modell-Caches.
|
||||
*/
|
||||
import { supportsReasoning } from "../../public/js/modelCapabilities.js";
|
||||
import { AttachmentError } from "./attachmentService.js";
|
||||
|
||||
@@ -147,6 +150,7 @@ export function createModelService(config, overrides = {}) {
|
||||
touch();
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
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() || "";
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
@@ -160,6 +164,7 @@ export function createModelService(config, overrides = {}) {
|
||||
if (!completed && 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);
|
||||
onRawReply?.(fullReply);
|
||||
return fullReply.trim();
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* @file Validiert Persona-Daten und erstellt Rollenspielanweisungen sowie Prüfungen für die Rollenverteilung.
|
||||
*/
|
||||
const PERSONA_FIELD_LIMITS = {
|
||||
name: 200,
|
||||
pronouns: 40,
|
||||
|
||||
@@ -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.
|
||||
export const VISION_PROMPT_VERSION = 4;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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}} SearchResult */
|
||||
/** @typedef {{signal?: AbortSignal, limit?: number, category?: string}} SearchOptions */
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* @file Prüft und normalisiert importierte Arbeitsbereiche mit Personas, Chats und Nachrichtenbeziehungen.
|
||||
*/
|
||||
import {parseSources} from "./chatMessageHelpers.js";
|
||||
|
||||
const VALID_PERSONA_TYPES = new Set(["assistant", "user"]);
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* @file Prüft Anmeldung, Kontoverwaltung, Chats, Personas und den Server-Lebenszyklus der Anwendung.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "fs";
|
||||
@@ -249,6 +252,48 @@ test("account export, logout-all, and deletion controls work", async () => {
|
||||
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 () => {
|
||||
const server = await startTestServer({
|
||||
config: {
|
||||
@@ -769,9 +814,18 @@ test("chat search, organization, and workspace export work", async () => {
|
||||
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});
|
||||
assert.equal(search.status, 200);
|
||||
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`, {
|
||||
method: "PUT",
|
||||
@@ -833,6 +887,30 @@ test("chat search, organization, and workspace export work", async () => {
|
||||
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 () => {
|
||||
const server = await startTestServer();
|
||||
const jar = new CookieJar();
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* @file Prüft Anhangsvorschauen, Upload-Zustände, Wiederholungen und das Verwerfen von Entwürfen.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
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();
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -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 assert from "node:assert/strict";
|
||||
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)));
|
||||
});
|
||||
|
||||
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 => {
|
||||
const s = await setup(t);
|
||||
const uploads = [];
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* @file Prüft Konfigurationsvorgaben und Sicherheitsanforderungen für Test- und Produktionsbetrieb.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {createConfig} from "../src/config.js";
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* @file Prüft Antwortstreaming, Zeitlimits, Protokollfehler und Abbrüche bei getrennten Verbindungen.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createServer } from "node:http";
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* @file Prüft Reasoning-Auswahl, getrennte Gedankenstreams und deren Speicherung sowie Darstellung.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createServer } from "node:http";
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* @file Prüft Antwortvarianten und den Erhalt bestehender Antworten bei fehlgeschlagener Neugenerierung.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {createServer} from "node:http";
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* @file Prüft die Neugenerierung innerhalb einer Nachrichtenkarte mit Scrollverhalten und Wiederherstellung.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {readFile} from "node:fs/promises";
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* @file Prüft Schema, Anweisungen, Normalisierung und Fehlerbehandlung der Bildanalyse.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {parseVisionAnalysis} from "../src/services/attachmentService.js";
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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 assert from "node:assert/strict";
|
||||
import { createServer } from "node:http";
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* @file Sichert ab, dass explizite und datumsbezogene Websuchen vor der Modellgenerierung passende Quellen liefern.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {createServer} from "node:http";
|
||||
|
||||
Reference in New Issue
Block a user