From 9020356e62faaf4c2edc06490d1de3dbc7a8b151 Mon Sep 17 00:00:00 2001 From: fretuks Date: Fri, 25 Sep 2026 17:02:37 +0200 Subject: [PATCH 1/6] add vision analysis service to dev branch? --- src/routes/visionDebug.js | 16 +++ src/services/visionDebugService.js | 180 +++++++++++++++++++++++++ test/visionDiagnostics.test.js | 206 +++++++++++++++++++++++++++++ 3 files changed, 402 insertions(+) create mode 100644 src/routes/visionDebug.js create mode 100644 src/services/visionDebugService.js create mode 100644 test/visionDiagnostics.test.js diff --git a/src/routes/visionDebug.js b/src/routes/visionDebug.js new file mode 100644 index 0000000..4135e16 --- /dev/null +++ b/src/routes/visionDebug.js @@ -0,0 +1,16 @@ +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; +} diff --git a/src/services/visionDebugService.js b/src/services/visionDebugService.js new file mode 100644 index 0000000..2a702d3 --- /dev/null +++ b/src/services/visionDebugService.js @@ -0,0 +1,180 @@ +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; + } + }; +} diff --git a/test/visionDiagnostics.test.js b/test/visionDiagnostics.test.js new file mode 100644 index 0000000..91574e3 --- /dev/null +++ b/test/visionDiagnostics.test.js @@ -0,0 +1,206 @@ +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("")).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/); +}); -- 2.52.0 From 0a24035b27f5fb82c9a9c2d463ae6802bb7240f3 Mon Sep 17 00:00:00 2001 From: fretuks Date: Fri, 25 Sep 2026 17:14:56 +0200 Subject: [PATCH 2/6] add JSDoc comments because yes --- eslint.config.js | 3 +++ public/js/api.js | 5 +++++ public/js/attachments.js | 3 +++ public/js/chatDrawer.js | 3 +++ public/js/constants.js | 3 +++ public/js/dom.js | 3 +++ public/js/market.js | 3 +++ public/js/modelCapabilities.js | 3 +++ public/js/modelProfiles.js | 3 +++ public/js/pages.js | 3 +++ public/js/personaForm.js | 3 +++ public/js/reasoning.js | 4 ++++ public/js/regeneration.js | 4 ++++ public/js/script.js | 3 +++ public/js/settings.js | 3 +++ public/js/statusNotice.js | 3 +++ public/js/theme-bootstrap.js | 3 +++ public/js/themeController.js | 3 +++ public/js/webSearch.js | 3 +++ public/js/workspaceNavigation.js | 3 +++ server.js | 3 +++ src/app.js | 3 +++ src/config.js | 3 +++ src/db/attachments.js | 3 +++ src/db/index.js | 3 +++ src/db/repositories.js | 3 +++ src/db/schema.js | 3 +++ src/middleware/auth.js | 3 +++ src/middleware/rateLimit.js | 4 ++++ src/middleware/rateLimiters.js | 3 +++ src/middleware/validate.js | 3 +++ src/pageLayout.js | 3 +++ src/routes/auth.js | 3 +++ src/routes/chats.js | 3 +++ src/routes/pages.js | 3 +++ src/routes/personas.js | 3 +++ src/routes/settings.js | 3 +++ src/routes/system.js | 3 +++ src/routes/uploads.js | 3 +++ src/routes/visionDebug.js | 3 +++ src/services/attachmentService.js | 3 +++ src/services/chatMessageHelpers.js | 3 +++ src/services/chatService.js | 7 +++++++ src/services/localFileStorage.js | 3 +++ src/services/modelService.js | 5 +++++ src/services/personaService.js | 3 +++ src/services/visionAnalysis.js | 3 +++ src/services/visionDebugService.js | 3 +++ src/services/webSearchService.js | 3 +++ src/services/workspaceImportService.js | 3 +++ test/app.test.js | 3 +++ test/attachmentComposer.test.js | 3 +++ test/attachments.test.js | 3 +++ test/config.test.js | 3 +++ test/modelStreaming.test.js | 3 +++ test/reasoning.test.js | 3 +++ test/regeneration.test.js | 3 +++ test/regenerationUi.test.js | 3 +++ test/visionAnalysis.test.js | 3 +++ test/visionDiagnostics.test.js | 3 +++ test/webSearch.test.js | 3 +++ test/webSearchRegression.test.js | 3 +++ 62 files changed, 197 insertions(+) diff --git a/eslint.config.js b/eslint.config.js index 2690113..963962d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,3 +1,6 @@ +/** + * @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"; diff --git a/public/js/api.js b/public/js/api.js index 33f7d04..a8ec20b 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -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(() => {}); diff --git a/public/js/attachments.js b/public/js/attachments.js index 3b65b96..d46d0b9 100644 --- a/public/js/attachments.js +++ b/public/js/attachments.js @@ -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"); diff --git a/public/js/chatDrawer.js b/public/js/chatDrawer.js index b6c845e..3dc8987 100644 --- a/public/js/chatDrawer.js +++ b/public/js/chatDrawer.js @@ -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; diff --git a/public/js/constants.js b/public/js/constants.js index 53e03e1..b94fb9a 100644 --- a/public/js/constants.js +++ b/public/js/constants.js @@ -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 = { diff --git a/public/js/dom.js b/public/js/dom.js index 9669391..6c3b885 100644 --- a/public/js/dom.js +++ b/public/js/dom.js @@ -1,3 +1,6 @@ +/** + * @file Bündelt Referenzen auf die HTML-Elemente der Chat-Oberfläche. + */ const $ = (id) => document.getElementById(id); const chatDiv = $("chat"); diff --git a/public/js/market.js b/public/js/market.js index 41334fd..98d131d 100644 --- a/public/js/market.js +++ b/public/js/market.js @@ -1,3 +1,6 @@ +/** + * @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"; diff --git a/public/js/modelCapabilities.js b/public/js/modelCapabilities.js index fc09ba3..c108dce 100644 --- a/public/js/modelCapabilities.js +++ b/public/js/modelCapabilities.js @@ -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"]); diff --git a/public/js/modelProfiles.js b/public/js/modelProfiles.js index 360d3e6..48a8bca 100644 --- a/public/js/modelProfiles.js +++ b/public/js/modelProfiles.js @@ -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." diff --git a/public/js/pages.js b/public/js/pages.js index 015c57e..34cefd5 100644 --- a/public/js/pages.js +++ b/public/js/pages.js @@ -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); diff --git a/public/js/personaForm.js b/public/js/personaForm.js index 0e07577..18dfbf1 100644 --- a/public/js/personaForm.js +++ b/public/js/personaForm.js @@ -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 = {}) { diff --git a/public/js/reasoning.js b/public/js/reasoning.js index c7f4473..b948f08 100644 --- a/public/js/reasoning.js +++ b/public/js/reasoning.js @@ -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; diff --git a/public/js/regeneration.js b/public/js/regeneration.js index 0cd15c1..75f4f99 100644 --- a/public/js/regeneration.js +++ b/public/js/regeneration.js @@ -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"); } diff --git a/public/js/script.js b/public/js/script.js index 7f9278c..21fb949 100644 --- a/public/js/script.js +++ b/public/js/script.js @@ -1,3 +1,6 @@ +/** + * @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"; diff --git a/public/js/settings.js b/public/js/settings.js index 279fed9..331ff73 100644 --- a/public/js/settings.js +++ b/public/js/settings.js @@ -1,3 +1,6 @@ +/** + * @file Steuert die Einstellungsoberfläche für Darstellung, Konto, Personas und Diagnosen. + */ import { del, get, post, put } from "./api.js?v=20260909-account-controls"; import { applyTheme as applySharedTheme, diff --git a/public/js/statusNotice.js b/public/js/statusNotice.js index f25bb42..7fbcfb1 100644 --- a/public/js/statusNotice.js +++ b/public/js/statusNotice.js @@ -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; diff --git a/public/js/theme-bootstrap.js b/public/js/theme-bootstrap.js index 105c081..ddf5518 100644 --- a/public/js/theme-bootstrap.js +++ b/public/js/theme-bootstrap.js @@ -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"); diff --git a/public/js/themeController.js b/public/js/themeController.js index bb05c71..99c292e 100644 --- a/public/js/themeController.js +++ b/public/js/themeController.js @@ -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"; diff --git a/public/js/webSearch.js b/public/js/webSearch.js index 6b1355a..8fef8b0 100644 --- a/public/js/webSearch.js +++ b/public/js/webSearch.js @@ -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 */ diff --git a/public/js/workspaceNavigation.js b/public/js/workspaceNavigation.js index c19b859..ada02c9 100644 --- a/public/js/workspaceNavigation.js +++ b/public/js/workspaceNavigation.js @@ -1,3 +1,6 @@ +/** + * @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. diff --git a/server.js b/server.js index d91b2f7..4beb6e7 100644 --- a/server.js +++ b/server.js @@ -1,3 +1,6 @@ +/** + * @file Startet den HTTP-Server der Chat-Anwendung. + */ import {startServer} from "./src/app.js"; startServer(); diff --git a/src/app.js b/src/app.js index 972112b..871f31b 100644 --- a/src/app.js +++ b/src/app.js @@ -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"; diff --git a/src/config.js b/src/config.js index 2fb9829..4a8ae77 100644 --- a/src/config.js +++ b/src/config.js @@ -1,3 +1,6 @@ +/** + * @file Erstellt die Anwendungskonfiguration aus Umgebungsvariablen, Standardwerten und expliziten Vorgaben. + */ import path from "path"; const bool = (value, fallback = false) => { diff --git a/src/db/attachments.js b/src/db/attachments.js index 8075bdb..44ecc59 100644 --- a/src/db/attachments.js +++ b/src/db/attachments.js @@ -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 ( diff --git a/src/db/index.js b/src/db/index.js index 8d6f91a..d0ee081 100644 --- a/src/db/index.js +++ b/src/db/index.js @@ -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"; diff --git a/src/db/repositories.js b/src/db/repositories.js index 98d0987..ffd9546 100644 --- a/src/db/repositories.js +++ b/src/db/repositories.js @@ -1,3 +1,6 @@ +/** + * @file Bündelt Datenbankzugriffe auf Konten, Chats, Nachrichten, Personas und den Marktplatz. + */ import {attachmentMetadata} from "./attachments.js"; const parseJsonArray = (value) => { diff --git a/src/db/schema.js b/src/db/schema.js index bef91af..b9724c4 100644 --- a/src/db/schema.js +++ b/src/db/schema.js @@ -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"); diff --git a/src/middleware/auth.js b/src/middleware/auth.js index eadd3be..bd16d6f 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -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"}); diff --git a/src/middleware/rateLimit.js b/src/middleware/rateLimit.js index cf915f1..ef5cf81 100644 --- a/src/middleware/rateLimit.js +++ b/src/middleware/rateLimit.js @@ -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(); } diff --git a/src/middleware/rateLimiters.js b/src/middleware/rateLimiters.js index 41f0d19..d4a85f8 100644 --- a/src/middleware/rateLimiters.js +++ b/src/middleware/rateLimiters.js @@ -1,3 +1,6 @@ +/** + * @file Erstellt die konfigurierten Anfragebegrenzungen für Anmeldung und Chat-Nutzung. + */ import {createRateLimiter} from "./rateLimit.js"; export function createAuthRateLimiters(config) { diff --git a/src/middleware/validate.js b/src/middleware/validate.js index 03d2603..c6ae704 100644 --- a/src/middleware/validate.js +++ b/src/middleware/validate.js @@ -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); diff --git a/src/pageLayout.js b/src/pageLayout.js index 4f01de8..4ca9143 100644 --- a/src/pageLayout.js +++ b/src/pageLayout.js @@ -1,3 +1,6 @@ +/** + * @file Erzeugt den gemeinsamen HTML-Seitenkopf mit Navigation und Kontosteuerung. + */ const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (character) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character]); diff --git a/src/routes/auth.js b/src/routes/auth.js index b79e37f..911a137 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -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"; diff --git a/src/routes/chats.js b/src/routes/chats.js index 8abfbdf..369f17a 100644 --- a/src/routes/chats.js +++ b/src/routes/chats.js @@ -1,3 +1,6 @@ +/** + * @file Stellt Chat-Routen für Nachrichten, Streaming, Antwortvarianten, Verzweigungen und Einstellungen bereit. + */ import express from "express"; import {requireLogin} from "../middleware/auth.js"; import {validateBody} from "../middleware/validate.js"; diff --git a/src/routes/pages.js b/src/routes/pages.js index 519a6ad..7b2d19c 100644 --- a/src/routes/pages.js +++ b/src/routes/pages.js @@ -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"; diff --git a/src/routes/personas.js b/src/routes/personas.js index f5681be..891a26b 100644 --- a/src/routes/personas.js +++ b/src/routes/personas.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"; diff --git a/src/routes/settings.js b/src/routes/settings.js index 3174ce1..81c2125 100644 --- a/src/routes/settings.js +++ b/src/routes/settings.js @@ -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"; diff --git a/src/routes/system.js b/src/routes/system.js index 95148a8..bc16784 100644 --- a/src/routes/system.js +++ b/src/routes/system.js @@ -1,3 +1,6 @@ +/** + * @file Stellt Modellinformationen und administrative Diagnosedaten bereit. + */ import express from "express"; import {requireLogin} from "../middleware/auth.js"; diff --git a/src/routes/uploads.js b/src/routes/uploads.js index c4a95c5..1dc3f62 100644 --- a/src/routes/uploads.js +++ b/src/routes/uploads.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"; diff --git a/src/routes/visionDebug.js b/src/routes/visionDebug.js index 4135e16..aa38ad3 100644 --- a/src/routes/visionDebug.js +++ b/src/routes/visionDebug.js @@ -1,3 +1,6 @@ +/** + * @file Liefert angemeldeten Benutzern die für sie zugänglichen Diagnosen der Bildverarbeitung. + */ import express from "express"; import {requireLogin} from "../middleware/auth.js"; diff --git a/src/services/attachmentService.js b/src/services/attachmentService.js index 97a7f3b..428e521 100644 --- a/src/services/attachmentService.js +++ b/src/services/attachmentService.js @@ -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"; diff --git a/src/services/chatMessageHelpers.js b/src/services/chatMessageHelpers.js index fbecaa3..7c1feba 100644 --- a/src/services/chatMessageHelpers.js +++ b/src/services/chatMessageHelpers.js @@ -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) { diff --git a/src/services/chatService.js b/src/services/chatService.js index 93f5ecb..151e0a5 100644 --- a/src/services/chatService.js +++ b/src/services/chatService.js @@ -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]; diff --git a/src/services/localFileStorage.js b/src/services/localFileStorage.js index 93ddaad..0a4df58 100644 --- a/src/services/localFileStorage.js +++ b/src/services/localFileStorage.js @@ -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"; diff --git a/src/services/modelService.js b/src/services/modelService.js index a401aba..d170927 100644 --- a/src/services/modelService.js +++ b/src/services/modelService.js @@ -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(); diff --git a/src/services/personaService.js b/src/services/personaService.js index 9236af4..a274e1c 100644 --- a/src/services/personaService.js +++ b/src/services/personaService.js @@ -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, diff --git a/src/services/visionAnalysis.js b/src/services/visionAnalysis.js index 14a449b..4bfe5a0 100644 --- a/src/services/visionAnalysis.js +++ b/src/services/visionAnalysis.js @@ -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; diff --git a/src/services/visionDebugService.js b/src/services/visionDebugService.js index 2a702d3..47df419 100644 --- a/src/services/visionDebugService.js +++ b/src/services/visionDebugService.js @@ -1,3 +1,6 @@ +/** + * @file Sammelt begrenzte Diagnosedaten zur Bildverarbeitung und kontrolliert deren benutzerbezogenen Zugriff. + */ const RAW_VISION_LIMIT = 12000; const EVIDENCE_LIMIT = 100000; const ANSWER_LIMIT = 30000; diff --git a/src/services/webSearchService.js b/src/services/webSearchService.js index a715c6a..48d3061 100644 --- a/src/services/webSearchService.js +++ b/src/services/webSearchService.js @@ -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 */ diff --git a/src/services/workspaceImportService.js b/src/services/workspaceImportService.js index 442f6b8..7ad9171 100644 --- a/src/services/workspaceImportService.js +++ b/src/services/workspaceImportService.js @@ -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"]); diff --git a/test/app.test.js b/test/app.test.js index 62241f4..8236c87 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -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"; diff --git a/test/attachmentComposer.test.js b/test/attachmentComposer.test.js index aec79de..1b19a2d 100644 --- a/test/attachmentComposer.test.js +++ b/test/attachmentComposer.test.js @@ -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"; diff --git a/test/attachments.test.js b/test/attachments.test.js index 0675cb2..0c24de3 100644 --- a/test/attachments.test.js +++ b/test/attachments.test.js @@ -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"; diff --git a/test/config.test.js b/test/config.test.js index 27def09..a06da3b 100644 --- a/test/config.test.js +++ b/test/config.test.js @@ -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"; diff --git a/test/modelStreaming.test.js b/test/modelStreaming.test.js index 7ec807c..dd7ae20 100644 --- a/test/modelStreaming.test.js +++ b/test/modelStreaming.test.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"; diff --git a/test/reasoning.test.js b/test/reasoning.test.js index eb531d6..3664ba0 100644 --- a/test/reasoning.test.js +++ b/test/reasoning.test.js @@ -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"; diff --git a/test/regeneration.test.js b/test/regeneration.test.js index 30a17e8..bec52c1 100644 --- a/test/regeneration.test.js +++ b/test/regeneration.test.js @@ -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"; diff --git a/test/regenerationUi.test.js b/test/regenerationUi.test.js index 3895d57..bfeb3df 100644 --- a/test/regenerationUi.test.js +++ b/test/regenerationUi.test.js @@ -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"; diff --git a/test/visionAnalysis.test.js b/test/visionAnalysis.test.js index 2c5da16..404b7e7 100644 --- a/test/visionAnalysis.test.js +++ b/test/visionAnalysis.test.js @@ -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"; diff --git a/test/visionDiagnostics.test.js b/test/visionDiagnostics.test.js index 91574e3..bdbf5c0 100644 --- a/test/visionDiagnostics.test.js +++ b/test/visionDiagnostics.test.js @@ -1,3 +1,6 @@ +/** + * @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"; diff --git a/test/webSearch.test.js b/test/webSearch.test.js index b37f880..f6c9964 100644 --- a/test/webSearch.test.js +++ b/test/webSearch.test.js @@ -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"; diff --git a/test/webSearchRegression.test.js b/test/webSearchRegression.test.js index 6717c7b..f696a66 100644 --- a/test/webSearchRegression.test.js +++ b/test/webSearchRegression.test.js @@ -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"; -- 2.52.0 From 7a1b152f207d266b8aeb41cd196ba1197aba1a45 Mon Sep 17 00:00:00 2001 From: Frederik Spirgi Date: Wed, 30 Sep 2026 09:35:06 +0200 Subject: [PATCH 3/6] make some overall QoL improvements --- public/chat.html | 2 + public/css/shell.css | 2 + public/css/style.css | 8 ++ public/js/script.js | 221 +++++++++++++++++++++++++++++++++++-------- 4 files changed, 193 insertions(+), 40 deletions(-) diff --git a/public/chat.html b/public/chat.html index fdf43c5..10d2a50 100644 --- a/public/chat.html +++ b/public/chat.html @@ -41,6 +41,8 @@ +
diff --git a/public/css/shell.css b/public/css/shell.css index 712da4d..76ebe60 100644 --- a/public/css/shell.css +++ b/public/css/shell.css @@ -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; } diff --git a/public/css/style.css b/public/css/style.css index 5bd75d0..2312130 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -419,6 +419,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; } diff --git a/public/js/script.js b/public/js/script.js index 21fb949..0ca4395 100644 --- a/public/js/script.js +++ b/public/js/script.js @@ -127,6 +127,10 @@ 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; function setNotice(message = "", state = "") { @@ -193,15 +197,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() { @@ -477,7 +499,10 @@ function ensureRetryState(message) { 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 || []; @@ -705,6 +730,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"; @@ -713,7 +776,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"; @@ -723,14 +786,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); @@ -784,7 +847,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); } @@ -1049,14 +1112,17 @@ 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; } @@ -1105,6 +1171,7 @@ 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); @@ -1408,6 +1475,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) { @@ -1460,13 +1530,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) { @@ -1483,6 +1557,33 @@ 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..."); @@ -1852,12 +1953,16 @@ async function checkSession() { msgInput.focus(); 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}); @@ -1868,25 +1973,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..."; @@ -1909,7 +2020,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."; } @@ -1918,14 +2032,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(); @@ -1934,12 +2050,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(); } @@ -1996,7 +2116,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(); @@ -2019,7 +2139,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(); @@ -2072,9 +2192,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(); @@ -2105,14 +2227,33 @@ 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 openMenu = document.querySelector(".msg-action-menu[open]"); + if (openMenu) { + openMenu.open = false; + openMenu.querySelector("summary")?.focus(); + } closeMobileSidebar(); closeRoleplayStarter(); closePersonaForm(); -- 2.52.0 From d15f7867d5e1b95cc50c2826e97b939b509725df Mon Sep 17 00:00:00 2001 From: Frederik Spirgi Date: Wed, 30 Sep 2026 10:06:29 +0200 Subject: [PATCH 4/6] make some overall QoL improvements --- public/chat.html | 14 +++- public/css/style.css | 16 +++- public/js/attachments.js | 24 +++++- public/js/script.js | 126 +++++++++++++++++++++++++------- public/js/settings.js | 6 +- public/settings.html | 2 +- src/db/repositories.js | 16 +++- test/app.test.js | 51 +++++++++++++ test/attachmentComposer.test.js | 29 ++++++++ 9 files changed, 239 insertions(+), 45 deletions(-) diff --git a/public/chat.html b/public/chat.html index 10d2a50..c83b763 100644 --- a/public/chat.html +++ b/public/chat.html @@ -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"> - + @@ -129,6 +129,8 @@

Pick a model to see what it is best suited for.

+
+ Advanced generation settings
+
-
+
+
+ +
@@ -462,7 +468,7 @@ - + diff --git a/public/css/style.css b/public/css/style.css index 2312130..46d9bf2 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -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); @@ -475,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; } @@ -491,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 { diff --git a/public/js/attachments.js b/public/js/attachments.js index d46d0b9..3fd88e3 100644 --- a/public/js/attachments.js +++ b/public/js/attachments.js @@ -27,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(() => {}); @@ -73,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; @@ -117,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"}); + } } }; } diff --git a/public/js/script.js b/public/js/script.js index 0ca4395..54d7c8f 100644 --- a/public/js/script.js +++ b/public/js/script.js @@ -4,7 +4,7 @@ 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 { onboardingPrompts, requestedChatId } from "./constants.js?v=20260909-account-controls"; @@ -131,6 +131,12 @@ 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 = "") { @@ -412,11 +418,22 @@ function resizeComposerInput() { } function scrollMessagesToBottom() { - requestAnimationFrame(() => { + if (scrollFrame !== null) cancelAnimationFrame(scrollFrame); + scrollFrame = requestAnimationFrame(() => { + scrollFrame = null; messagesDiv.scrollTop = messagesDiv.scrollHeight; + updateJumpToLatest(); }); } +function updateJumpToLatest() { + const distance = messagesDiv.scrollHeight - messagesDiv.scrollTop - messagesDiv.clientHeight; + jumpToLatest.classList.toggle("hidden", distance < 180); +} + +messagesDiv.addEventListener("scroll", updateJumpToLatest, {passive: true}); +jumpToLatest.addEventListener("click", scrollMessagesToBottom); + function getNewestMessage() { return currentMessages.length ? currentMessages[currentMessages.length - 1] : null; } @@ -863,21 +880,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(); } } @@ -1127,7 +1169,7 @@ function renderChatList() { 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) || []; @@ -1152,13 +1194,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 @@ -1175,16 +1217,19 @@ function renderChatList() { 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); }); @@ -1500,8 +1545,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, { @@ -1513,16 +1571,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); @@ -1585,7 +1654,6 @@ function scheduleChatSearch() { } async function createNewChat({preserveComposer = false} = {}) { - if (!preserveComposer) attachmentComposer.discard(); setChatLoading(true, "Creating chat..."); setChatActivity(true, { eyebrow: "Creating chat", @@ -1905,7 +1973,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(); diff --git a/public/js/settings.js b/public/js/settings.js index 331ff73..bee207c 100644 --- a/public/js/settings.js +++ b/public/js/settings.js @@ -1,7 +1,7 @@ /** * @file Steuert die Einstellungsoberfläche für Darstellung, Konto, Personas und Diagnosen. */ -import { del, get, post, put } from "./api.js?v=20260909-account-controls"; +import { del, get, post, put, request } from "./api.js?v=20260909-account-controls"; import { applyTheme as applySharedTheme, applyThemeMode as applySharedThemeMode, @@ -65,7 +65,6 @@ const settingsPopupConfirm = $("settingsPopupConfirm"); const settingsPopupTitle = $("settingsPopupTitle"); const settingsPopupEyebrow = $("settingsPopupEyebrow"); const settingsPopupDescription = $("settingsPopupDescription"); -const settingsPopupField = $("settingsPopupField"); const settingsPopupInputLabel = $("settingsPopupInputLabel"); const settingsPopupInput = $("settingsPopupInput"); const settingsPersonaFields = { @@ -500,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"); }); diff --git a/public/settings.html b/public/settings.html index 9bded4f..32d750c 100644 --- a/public/settings.html +++ b/public/settings.html @@ -317,7 +317,7 @@
- + diff --git a/src/db/repositories.js b/src/db/repositories.js index ffd9546..2652424 100644 --- a/src/db/repositories.js +++ b/src/db/repositories.js @@ -381,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 @@ -408,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 `), @@ -714,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); @@ -945,7 +953,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), diff --git a/test/app.test.js b/test/app.test.js index 8236c87..193999f 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -252,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: { @@ -772,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", diff --git a/test/attachmentComposer.test.js b/test/attachmentComposer.test.js index 1b19a2d..eadf598 100644 --- a/test/attachmentComposer.test.js +++ b/test/attachmentComposer.test.js @@ -97,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(); +}); -- 2.52.0 From c69070a6e6eb9ecbaff3a9034cf893f178097905 Mon Sep 17 00:00:00 2001 From: Frederik Spirgi Date: Wed, 30 Sep 2026 11:05:13 +0200 Subject: [PATCH 5/6] make some overall QoL improvements --- README.md | 11 +++- eslint.config.js | 2 +- package.json | 2 +- public/chat.html | 14 +++-- public/js/market.js | 27 +++++---- public/js/script.js | 91 +++++++++++++++++++------------ public/js/settings.js | 19 +++++-- public/js/workspaceNavigation.js | 2 +- public/market.html | 4 +- public/settings.html | 2 +- src/app.js | 2 +- src/db/repositories.js | 7 ++- src/routes/chats.js | 82 +++++++++++++++++++++++++++- src/routes/personas.js | 6 +- src/services/attachmentService.js | 6 +- src/services/chatService.js | 2 +- test/app.test.js | 24 ++++++++ test/attachments.test.js | 31 +++++++++++ 18 files changed, 256 insertions(+), 78 deletions(-) diff --git a/README.md b/README.md index f77eaf5..514737d 100644 --- a/README.md +++ b/README.md @@ -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/.` 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: diff --git a/eslint.config.js b/eslint.config.js index 963962d..9851b1e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -6,5 +6,5 @@ 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} } }, ]); diff --git a/package.json b/package.json index 0851eaf..9ce6d02 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/public/chat.html b/public/chat.html index c83b763..19ecd0a 100644 --- a/public/chat.html +++ b/public/chat.html @@ -221,12 +221,12 @@ data-chat-action="personaState">Roleplay state + type="button">Download workspace archive + accept="application/json,.json,.tar.gz,application/gzip" />
@@ -316,7 +316,7 @@