Add support for message history pagination, preserve scroll in rendering, and improve draft/message workflows.
CI / check (22) (push) Successful in 9m34s
CI / check (24) (push) Successful in 9m34s

This commit is contained in:
2026-10-06 00:32:18 +02:00
parent 1c0f06716e
commit 387c0ac2e7
40 changed files with 2374 additions and 1314 deletions
+14 -2
View File
@@ -4,6 +4,16 @@ Elsewhere is an Express + SQLite app for account-based chat, persona management,
A space for every conversation. Six appearance styles (Grove, Parchment, Aurora, Current, Iris, and Graphite) each support light and dark modes. The Elsewhere identity stays consistent across styles. Internal theme and storage keys remain stable to preserve saved preferences. A space for every conversation. Six appearance styles (Grove, Parchment, Aurora, Current, Iris, and Graphite) each support light and dark modes. The Elsewhere identity stays consistent across styles. Internal theme and storage keys remain stable to preserve saved preferences.
## Conversation workspace
Conversation drafts are saved in this browser per account and conversation, including references to completed image uploads. Uploads still in progress cannot be recovered after a reload. If a recovered image has expired or is unavailable, remove it and attach it again. Drafts stay local to the browser and are cleared after a successful send and history refresh.
Chat settings include **Branches & compare** for browsing the conversation tree, opening related chats, and comparing response versions side by side. **Conversation memory** provides editable facts, a running summary, and an approximate context-token count. Summary edits detect conflicting updates; subsequent automatic summaries build on saved corrections.
History opens with the latest 50 messages. **Load older messages** fetches earlier pages without losing the reading position. `GET /chats/:id/messages` accepts `limit` (1–100), `before`, and `leaf` cursors; reuse the response's `pagination.before` and `pagination.leafId`. A changed branch returns 409. `around` opens a page ending at a search match, with **Back to latest messages** available in the UI.
`GET /health` is public and returns `{ok: true}` (200) for a healthy model backend or `{ok: false}` (503) when it is unavailable. Detailed diagnostics remain protected. Registration and password changes reject new passwords over 72 UTF-8 bytes, matching [bcrypt's documented limit](https://github.com/kelektiv/node.bcrypt.js#security-issues-and-concerns). Existing passwords remain usable for login and current-password checks.
## Setup ## Setup
Node.js 22.13 or newer is required. Node.js 22.13 or newer is required.
@@ -340,7 +350,7 @@ Search emits `{status: "searching"}` only when retrieval actually runs, then `{s
Sources appear below assistant messages as numbered links. They persist in a nullable SQLite `chat_messages.sources` JSON column, added automatically on startup using the existing additive schema mechanism; no manual migration is needed. Old messages remain valid. Sources are preserved in JSON exports/imports and branches. Regenerating an answer reuses its stored results without performing another search; a fresh search requires sending another message with the toggle enabled. Sources appear below assistant messages as numbered links. They persist in a nullable SQLite `chat_messages.sources` JSON column, added automatically on startup using the existing additive schema mechanism; no manual migration is needed. Old messages remain valid. Sources are preserved in JSON exports/imports and branches. Regenerating an answer reuses its stored results without performing another search; a fresh search requires sending another message with the toggle enabled.
Run `node --test --test-isolation=none` and `npm run lint` to check the feature and existing flows. `test/webSearch.test.js` uses local HTTP protocol fixtures and an isolated in-memory database; no production search results are mocked. Live SearXNG/Ollama verification still requires configured, reachable services. There is no TypeScript or build command in this framework-free project. Run `npm test` and `npm run lint` to check the feature and existing flows. `test/webSearch.test.js` uses local HTTP protocol fixtures and an isolated in-memory database; no production search results are mocked. Live SearXNG/Ollama verification still requires configured, reachable services. There is no TypeScript or build command in this framework-free project.
## Legal ## Legal
@@ -362,4 +372,6 @@ Protected pages redirect to sign-in with a local return destination. Public disc
All page routes render the same navigation from `src/pageLayout.js` into the `<!-- site-header -->` placeholder. `public/css/shell.css` owns the shared header, page spacing, and mobile navigation; `public/js/workspaceNavigation.js` owns sign-out. Page-specific headers contain only local titles and actions. The chat sidebar is reserved for conversation history. Public and home content share `public/css/pages.css` and `public/js/pages.js`. Page routes run before static assets and authenticated API routers; rendered pages use private, non-cacheable responses. All page routes render the same navigation from `src/pageLayout.js` into the `<!-- site-header -->` placeholder. `public/css/shell.css` owns the shared header, page spacing, and mobile navigation; `public/js/workspaceNavigation.js` owns sign-out. Page-specific headers contain only local titles and actions. The chat sidebar is reserved for conversation history. Public and home content share `public/css/pages.css` and `public/js/pages.js`. Page routes run before static assets and authenticated API routers; rendered pages use private, non-cacheable responses.
For environments that block child-process test workers, run `node --test --test-isolation=none`. Run the full suite with `npm test` and `npm run lint`. The browser navigation harness is tracked in `browser-tests/`; `npm run test:browser` prints its local URL. It shares its cases with `test/conversation.test.js`.
For environments that block child-process test workers, run one test file at a time with `node --test --test-isolation=none test/<name>.test.js`; combining all files without isolation can leak their browser globals and mocks between suites.
+27
View File
@@ -0,0 +1,27 @@
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<title>Conversation navigation regression tests</title>
<h1>Conversation navigation regression tests</h1>
<div id="testTitle"></div>
<input id="testModel" aria-label="Model">
<input id="chatSearchInput" aria-label="Search">
<textarea id="msgInput" aria-label="Draft"></textarea>
<div id="messages"></div>
<pre id="results">Running...</pre>
<script type="module">
import {registerConversationTests} from "/browser-tests/conversationCases.js";
const cases = [], results = [];
const assert = {
equal(actual, expected) { if (!Object.is(actual, expected)) throw new Error(`Expected ${expected}, received ${actual}`); },
deepEqual(actual, expected) { this.equal(JSON.stringify(actual), JSON.stringify(expected)); }
};
registerConversationTests((name, run) => cases.push({name, run}), assert);
for (const {name, run} of cases) {
try { await run(); results.push(`PASS ${name}`); }
catch (error) { results.push(`FAIL ${name}: ${error.stack}`); }
}
document.getElementById("results").textContent = results.join("\n");
document.title = `${results.filter(result => result.startsWith("PASS")).length}/${cases.length} navigation tests passed`;
</script>
</html>
+197
View File
@@ -0,0 +1,197 @@
/** The same navigation regressions run in a real browser and Node's lightweight DOM fixture. */
import {createConversationController} from "../public/js/conversationController.js";
import {createConversationState} from "../public/js/conversationState.js";
const noop = () => {};
function fixture() {
const originalFetch = globalThis.fetch;
const pending = [];
globalThis.fetch = (url, options) => new Promise(resolve => pending.push({url, options, resolve}));
const state = createConversationState();
state.chatSessions = [1, 2, 3].map(id => ({id, title: `Chat ${id}`}));
state.activeChatId = 1;
state.currentMessages = [{id: 11, content: "Chat A's answer"}];
state.messageRetryState.set(11, {retryActiveIndex: 1});
state.chatDrafts.set(2, "Draft B");
state.chatDrafts.set(3, "Draft C");
history.replaceState(null, "", "/app/chats/1");
const input = document.getElementById("msgInput");
const messages = document.getElementById("messages");
const title = document.getElementById("testTitle");
const model = document.getElementById("testModel");
input.value = "Draft A";
messages.textContent = "Chat A's answer";
title.textContent = "Chat 1";
model.value = "Model A";
const attachmentDrafts = new Map([[2, ["Image B"]], [3, ["Image C"]]]);
const attachments = {pending: false, attachments: ["Image A"],
saveDraft(id) { attachmentDrafts.set(id, this.attachments); },
restoreDraft(id) { this.attachments = attachmentDrafts.get(id) || []; }};
const ui = {loading: false, activity: false, notices: [], renders: []};
const controller = createConversationController({state, attachmentComposer: attachments,
renderChatList: noop, resizeComposerInput: noop, isMobileLayout: () => false, closeMobileSidebar: noop,
setChatLoading: value => { ui.loading = value; }, setChatActivity: value => { ui.activity = value; },
updateWorkspaceCopy: () => { title.textContent = `Chat ${state.activeChatId}`; }, updateChatActionState: noop,
setNotice: message => ui.notices.push(message),
populateGenerationSettings: chat => { model.value = chat.preferred_model; },
renderMessages: () => { messages.textContent = state.currentMessages.map(message => message.content).join("\n"); ui.renders.push(state.activeChatId); },
updateJumpToLatest: noop, updateSendState: noop, loadSummary: noop});
const reply = async (index, body, status = 200) => {
const response = new Response(JSON.stringify(body), {status, headers: {"Content-Type": "application/json"}});
// Avoid browser Blob-reading tasks: this fixture controls navigation order,
// while api.test.js covers actual response-body reads and their deadlines.
response.text = async () => JSON.stringify(body);
pending[index].resolve(response);
// Fetch, response body parsing and the controller each await a promise.
await new Promise(resolve => setTimeout(resolve, 0));
};
const result = id => ({chat: {id, preferred_model: `Model ${id}`}, messages: [{id: id * 10 + 1, content: `Answer ${id}`}]});
return {state, ui, input, messages, title, model, attachments, attachmentDrafts, pending, reply, result, ...controller,
cleanup: () => { globalThis.fetch = originalFetch; }};
}
export function registerConversationTests(test, assert) {
const unchanged = s => {
assert.equal(s.state.activeChatId, 1);
assert.equal(location.pathname, "/app/chats/1");
assert.equal(s.messages.textContent, "Chat A's answer");
assert.equal(s.title.textContent, "Chat 1");
assert.equal(s.model.value, "Model A");
assert.equal(s.input.value, "Draft A");
assert.deepEqual(s.attachments.attachments, ["Image A"]);
assert.equal(s.state.messageRetryState.get(11).retryActiveIndex, 1);
};
test("failed navigation preserves chat identity, URL, messages, settings and both drafts", async () => {
const s = fixture();
try {
const opening = s.setActiveChat(2);
unchanged(s);
await s.reply(0, {error: "Chat B failed to load"}, 503);
const result = await opening;
assert.equal(result.error, "Chat B failed to load");
assert.equal(result.status, 503);
unchanged(s);
assert.equal(s.state.chatDrafts.get(2), "Draft B");
assert.deepEqual(s.attachmentDrafts.get(2), ["Image B"]);
assert.deepEqual(s.ui.notices, ["Chat B failed to load"]);
assert.equal(s.ui.loading, false);
assert.equal(s.ui.activity, false);
} finally { s.cleanup(); }
});
for (const order of ["older-first", "newer-first"]) {
for (const staleFails of [false, true]) {
test(`overlapping navigation (${order}, stale ${staleFails ? "failure" : "success"}) commits only the latest chat`, async () => {
const s = fixture();
try {
const older = s.setActiveChat(2), newer = s.setActiveChat(3);
unchanged(s);
const replyOlder = () => s.reply(0, staleFails ? {error: "Stale error"} : s.result(2), staleFails ? 500 : 200);
if (order === "older-first") {
await replyOlder(); await older;
unchanged(s);
assert.equal(s.ui.loading, true);
assert.equal(s.ui.activity, true);
// Edits made while loading belong to the still-visible chat A.
s.input.value = "Draft A updated";
await s.reply(1, s.result(3)); await newer;
} else {
await s.reply(1, s.result(3)); await newer;
await replyOlder(); await older;
}
assert.equal(s.state.activeChatId, 3);
assert.equal(location.pathname, "/app/chats/3");
assert.equal(s.messages.textContent, "Answer 3");
assert.equal(s.title.textContent, "Chat 3");
assert.equal(s.model.value, "Model 3");
assert.equal(s.input.value, "Draft C");
assert.equal(s.state.chatDrafts.get(1), order === "older-first" ? "Draft A updated" : "Draft A");
assert.equal(s.state.chatDrafts.get(2), "Draft B");
assert.deepEqual(s.attachments.attachments, ["Image C"]);
assert.deepEqual(s.attachmentDrafts.get(1), ["Image A"]);
assert.deepEqual(s.ui.renders, [3]);
assert.deepEqual(s.ui.notices, []);
assert.equal(s.state.messageRetryState.size, 0);
assert.equal(s.ui.loading, false);
} finally { s.cleanup(); }
});
}
}
test("a late success cannot replace A after the newer navigation fails", async () => {
const s = fixture();
try {
const older = s.setActiveChat(2), newer = s.setActiveChat(3);
await s.reply(1, {error: "Chat C failed"}, 500); await newer;
await s.reply(0, s.result(2)); await older;
unchanged(s);
assert.deepEqual(s.ui.notices, ["Chat C failed"]);
assert.equal(s.ui.loading, false);
} finally { s.cleanup(); }
});
for (const failure of ["activation", "reload"]) {
test(`search-result branch ${failure} failure preserves the displayed chat`, async () => {
const s = fixture();
try {
const opening = s.setActiveChat(2, {matchMessageId: 25});
await s.reply(0, s.result(2));
assert.equal(s.pending[1].url, "/chats/2/branches/25/activate");
await s.reply(1, failure === "activation" ? {error: "Branch failed"} : {}, failure === "activation" ? 500 : 200);
if (failure === "reload") await s.reply(2, {error: "Reload failed"}, 500);
await opening;
unchanged(s);
assert.equal(s.ui.notices.length, 1);
assert.equal(s.ui.loading, false);
} finally { s.cleanup(); }
});
}
test("a stale branch activation cannot reload or clear a newer navigation's activity", async () => {
const s = fixture();
try {
const older = s.setActiveChat(2, {matchMessageId: 25});
await s.reply(0, s.result(2));
const newer = s.setActiveChat(3);
await s.reply(1, {}); await older;
assert.equal(s.pending.length, 3);
assert.equal(s.ui.loading, true);
unchanged(s);
await s.reply(2, s.result(3)); await newer;
assert.equal(s.state.activeChatId, 3);
} finally { s.cleanup(); }
});
test("older messages prepend once and a failed page keeps the current history and draft", async () => {
const s = fixture();
try {
s.state.historyPage = {hasMore: true, before: 11, leafId: 11};
const older = s.loadOlderMessages();
await s.loadOlderMessages();
assert.equal(s.pending.length, 1);
await s.reply(0, {messages: [{id: 9, content: "Older answer"}], pagination: {hasMore: true, before: 9, leafId: 11}});
await older;
assert.deepEqual(s.state.currentMessages.map(message => message.id), [9, 11]);
assert.equal(s.input.value, "Draft A");
const failed = s.loadOlderMessages();
await s.reply(1, {error: "Page failed"}, 503); await failed;
assert.deepEqual(s.state.currentMessages.map(message => message.id), [9, 11]);
assert.equal(s.state.historyLoading, false);
assert.equal(s.ui.notices.at(-1), "Page failed");
} finally { s.cleanup(); }
});
test("an older page arriving after navigation cannot contaminate the new conversation", async () => {
const s = fixture();
try {
s.state.historyPage = {hasMore: true, before: 11, leafId: 11};
const older = s.loadOlderMessages(), opening = s.setActiveChat(2);
await s.reply(1, s.result(2)); await opening;
await s.reply(0, {messages: [{id: 9, content: "Stale answer"}], pagination: {hasMore: false}}); await older;
assert.deepEqual(s.state.currentMessages.map(message => message.id), [21]);
assert.equal(s.state.historyLoading, false);
assert.equal(s.state.activeChatId, 2);
} finally { s.cleanup(); }
});
}
+1 -1
View File
@@ -8,7 +8,7 @@ import { defineConfig } from "eslint/config";
export default defineConfig([ export default defineConfig([
{ files: ["**/*.{js,mjs,cjs}"], plugins: { js }, extends: ["js/recommended"], languageOptions: { globals: {...globals.browser, ...globals.node} } }, { files: ["**/*.{js,mjs,cjs}"], plugins: { js }, extends: ["js/recommended"], languageOptions: { globals: {...globals.browser, ...globals.node} } },
{ {
files: ["public/js/script.js", "public/js/composerController.js", "public/js/conversationController.js", "public/js/messageRenderer.js", "public/js/personaController.js"], files: ["public/js/script.js", "public/js/composerController.js", "public/js/conversationController.js", "public/js/messageRenderer.js", "public/js/personaController.js", "public/js/messageActions.js", "public/js/workspaceTransfer.js"],
rules: {"no-shadow": "error"} rules: {"no-shadow": "error"}
}, },
]); ]);
+6 -1
View File
@@ -215,7 +215,8 @@
data-chat-action="folder">Move to folder</button> data-chat-action="folder">Move to folder</button>
<button id="manageChatMemory" <button id="manageChatMemory"
class="secondary-action chat-drawer-action" type="button" class="secondary-action chat-drawer-action" type="button"
data-chat-action="memory">Memory facts</button> data-chat-action="memory">Conversation memory</button>
<button id="exploreBranches" type="button" class="secondary-action chat-drawer-action" data-chat-action="branches">Branches &amp; compare</button>
<button id="managePersonaState" <button id="managePersonaState"
class="secondary-action chat-drawer-action" type="button" class="secondary-action chat-drawer-action" type="button"
data-chat-action="personaState">Roleplay state</button> data-chat-action="personaState">Roleplay state</button>
@@ -265,6 +266,10 @@
</div> </div>
<div class="messages-wrap"> <div class="messages-wrap">
<div class="history-controls">
<button id="loadOlderMessages" class="secondary-action" type="button" hidden>Load older messages</button>
<button id="loadLatestMessages" class="secondary-action" type="button" hidden>Back to latest messages</button>
</div>
<div id="messages" class="messages-panel messages-panel-focus" role="log" aria-live="polite" <div id="messages" class="messages-panel messages-panel-focus" role="log" aria-live="polite"
aria-relevant="additions text"></div> aria-relevant="additions text"></div>
<button id="jumpToLatest" class="jump-to-latest secondary-action hidden" type="button">Jump to latest</button> <button id="jumpToLatest" class="jump-to-latest secondary-action hidden" type="button">Jump to latest</button>
+26 -1
View File
@@ -1,5 +1,30 @@
/* Elsewhere design system. */ /* Elsewhere design system. */
.history-controls { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; }
.history-controls:has(button:not([hidden])) { padding: 8px; }
.history-controls [hidden] { display: none; }
.modal-card.conversation-panel { width: min(1180px, 100%); min-width: 0; max-height: 90dvh; overflow-y: auto; }
.conversation-panel .panel-content { display: grid; gap: 16px; min-width: 0; }
.conversation-panel .panel-notice:empty { display: none; }
.conversation-panel textarea { width: 100%; resize: vertical; min-height: 80px; }
.conversation-panel meter { width: 100%; height: 16px; accent-color: var(--brand); }
.memory-fact { padding: 16px; border: 1px solid var(--border); border-radius: var(--radius-sm); }
.panel-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
.branch-layout { display: grid; grid-template-columns: minmax(200px, 1fr) minmax(0, 2fr); gap: 20px; }
.branch-sidebar { min-width: 0; max-height: 65dvh; overflow: auto; }
.branch-tree { padding: 0; list-style: none; }
.branch-tree li { margin: 5px 0 5px calc(var(--branch-depth) * 8px); padding-left: 6px; border-left: 2px solid var(--border); }
.branch-tree button { text-align: left; width: 100%; white-space: normal; overflow-wrap: anywhere; }
.branch-tree [aria-current="true"] { border-color: var(--brand); background: var(--brand-soft); }
.answer-comparison { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; align-items: start; }
.comparison-pane { min-width: 0; display: grid; gap: 10px; padding: 14px; background: var(--surface-soft); border: 1px solid var(--border); border-radius: var(--radius-sm); }
.comparison-pane select { width: 100%; min-width: 0; }
.comparison-pane .msg-content { overflow-wrap: anywhere; }
@media (max-width: 760px) {
.branch-layout, .answer-comparison { grid-template-columns: 1fr; }
.branch-sidebar { max-height: 240px; }
}
.msg-reasoning { .msg-reasoning {
margin: 0 0 12px; margin: 0 0 12px;
padding: 10px 14px; padding: 10px 14px;
@@ -398,7 +423,7 @@ input:focus, textarea:focus, select:focus { border-color: var(--brand); }
.chat-header-actions { display: flex; align-items: center; } .chat-header-actions { display: flex; align-items: center; }
.overflow-trigger { min-height: 38px; padding: 8px 12px; border-radius: 999px; font-size: .8rem; } .overflow-trigger { min-height: 38px; padding: 8px 12px; border-radius: 999px; font-size: .8rem; }
.messages-wrap { min-height: 0; position: relative; } .messages-wrap { min-height: 0; position: relative; display: grid; grid-template-rows: auto minmax(0, 1fr); }
.messages-panel { .messages-panel {
height: 100%; height: 100%;
min-height: 0; min-height: 0;
+16 -4
View File
@@ -82,18 +82,19 @@ function normalizeError(error, scope, response) {
}); });
} }
export async function request(url, data, method = "POST", options = {}) { async function bufferedRequest(url, {method, body, headers}, options = {}, asBlob = false) {
const scope = requestScope(options); const scope = requestScope(options);
let response; let response;
try { try {
response = await abortable(() => fetch(url, { response = await abortable(() => fetch(url, {
method, method,
credentials: "same-origin", credentials: "same-origin",
headers: {"Accept": "application/json", "Content-Type": "application/json"}, headers,
body: data == null ? undefined : JSON.stringify(data), body,
signal: scope.signal signal: scope.signal
}), scope.signal); }), scope.signal);
return await readJson(response, scope.signal); if (!asBlob || !response.ok) return await readJson(response, scope.signal);
return {blob: await abortable(() => response.blob(), scope.signal)};
} catch (error) { } catch (error) {
return normalizeError(error, scope, response).toJSON(); return normalizeError(error, scope, response).toJSON();
} finally { } finally {
@@ -101,6 +102,17 @@ export async function request(url, data, method = "POST", options = {}) {
} }
} }
export function request(url, data, method = "POST", options = {}) {
return bufferedRequest(url, {method, headers: {"Accept": "application/json", "Content-Type": "application/json"},
body: data == null ? undefined : JSON.stringify(data)}, options);
}
// Let fetch set the multipart boundary; share deadlines, cancellation and errors.
export const multipart = (url, form, options = {}) => bufferedRequest(url,
{method: "POST", headers: {"Accept": "application/json"}, body: form}, {timeoutMs: 120000, ...options});
export const download = (url, options = {}) => bufferedRequest(url,
{method: "GET", headers: {"Accept": "*/*"}}, {timeoutMs: 120000, ...options}, true);
export const get = (url, options) => request(url, null, "GET", options); export const get = (url, options) => request(url, null, "GET", options);
export const post = (url, data, options) => request(url, data, "POST", options); export const post = (url, data, options) => request(url, data, "POST", options);
export const put = (url, data, options) => request(url, data, "PUT", options); export const put = (url, data, options) => request(url, data, "PUT", options);
+52 -17
View File
@@ -1,6 +1,8 @@
/** /**
* @file Verwaltet Bildauswahl, Uploads und Vorschauen sowie die Anzeige gespeicherter Anhänge. * @file Verwaltet Bildauswahl, Uploads und Vorschauen sowie die Anzeige gespeicherter Anhänge.
*/ */
import {get, del, multipart, ApiError} from "./api.js";
export function appendAttachments(container, attachments = []) { export function appendAttachments(container, attachments = []) {
if (!attachments.length) return; if (!attachments.length) return;
const gallery = document.createElement("div"); const gallery = document.createElement("div");
@@ -27,11 +29,17 @@ export function createAttachmentComposer({onChange, onError}) {
const preview = document.getElementById("uploadPreviews"); const preview = document.getElementById("uploadPreviews");
const composer = document.querySelector(".input-container"); const composer = document.querySelector(".input-container");
let items = []; let items = [];
let draftStore = null;
let currentDraftId = "new";
const persist = (id = currentDraftId, values = items) => {
if (id != null) draftStore?.setAttachments(id, values.filter(item => item.metadata).map(item => item.metadata));
};
const drafts = new Map(); const drafts = new Map();
let busy = false; let busy = false;
let limits = {maxSizeMb: 10, maxFiles: 5}; let limits = {maxSizeMb: 10, maxFiles: 5};
fetch("/uploads/config").then(r => r.ok ? r.json() : limits).then(value => { limits = value; }).catch(() => {}); void get("/uploads/config").then(value => { if (!value.error) limits = value; });
const render = () => { const render = () => {
persist();
preview.replaceChildren(); preview.replaceChildren();
preview.hidden = !items.length; preview.hidden = !items.length;
for (const item of items) { for (const item of items) {
@@ -39,11 +47,22 @@ export function createAttachmentComposer({onChange, onError}) {
card.className = "upload-preview"; card.className = "upload-preview";
const image = document.createElement("img"); const image = document.createElement("img");
image.src = item.preview; image.src = item.preview;
image.alt = item.file.name; image.alt = item.file?.name || item.metadata?.originalName || "Recovered image";
image.onload = () => {
if (item.verifying) { item.verifying = false; onChange(); }
};
image.onerror = () => {
if (item.recovered && !item.error) {
item.error = "Image unavailable. Remove it and attach it again.";
item.verifying = false;
item.unavailable = true;
render();
}
};
const label = document.createElement("span"); const label = document.createElement("span");
label.textContent = `${item.file.name} — ${item.error || (item.metadata ? "Ready" : "Uploading…")}`; label.textContent = `${item.file?.name || item.metadata?.originalName || "Recovered image"} — ${item.error || (item.metadata ? "Ready" : "Uploading…")}`;
card.append(image, label); card.append(image, label);
if (item.error) { if (item.error && item.file) {
const retry = document.createElement("button"); const retry = document.createElement("button");
retry.type = "button"; retry.textContent = "Retry"; retry.disabled = busy; retry.type = "button"; retry.textContent = "Retry"; retry.disabled = busy;
retry.onclick = () => { void upload(item); }; retry.onclick = () => { void upload(item); };
@@ -51,11 +70,11 @@ export function createAttachmentComposer({onChange, onError}) {
} }
const remove = document.createElement("button"); const remove = document.createElement("button");
remove.type = "button"; remove.textContent = "×"; remove.disabled = busy; remove.type = "button"; remove.textContent = "×"; remove.disabled = busy;
remove.setAttribute("aria-label", `Remove ${item.file.name}`); remove.setAttribute("aria-label", `Remove ${item.file?.name || item.metadata?.originalName || "image"}`);
remove.onclick = () => { remove.onclick = () => {
items = items.filter(v => v !== item); items = items.filter(v => v !== item);
item.controller?.abort(); URL.revokeObjectURL(item.preview); item.controller?.abort(); URL.revokeObjectURL(item.preview);
if (item.metadata) void fetch(`/attachments/${item.metadata.id}`, {method: "DELETE"}); if (item.metadata) void del(`/attachments/${item.metadata.id}`);
render(); render();
}; };
card.append(remove); preview.append(card); card.append(remove); preview.append(card);
@@ -70,16 +89,16 @@ export function createAttachmentComposer({onChange, onError}) {
render(); render();
try { try {
const form = new FormData(); form.append("image", item.file); const form = new FormData(); form.append("image", item.file);
const response = await fetch("/uploads", {method: "POST", body: form, signal: item.controller.signal}); const result = await multipart("/uploads", form, {signal: item.controller.signal});
const result = await response.json(); if (result.error) throw new ApiError(result.error, result);
if (!response.ok) throw new Error(result.error || "Upload failed");
item.metadata = result; item.metadata = result;
if (![items, ...drafts.values()].some(draft => draft.includes(item))) void fetch(`/attachments/${result.id}`, {method: "DELETE"}); if (![items, ...drafts.values()].some(draft => draft.includes(item))) void del(`/attachments/${result.id}`);
} catch (error) { } catch (error) {
if (error.name !== "AbortError") item.error = error.message || "Upload failed"; if (error.code !== "ABORTED") item.error = error.message || "Upload failed";
} }
item.uploading = false; item.uploading = false;
item.controller = null; item.controller = null;
for (const [id, draft] of drafts) if (draft.includes(item)) persist(id, draft);
if (items.includes(item)) render(); if (items.includes(item)) render();
}; };
const add = files => { const add = files => {
@@ -106,26 +125,39 @@ export function createAttachmentComposer({onChange, onError}) {
if (images.length) { if (!event.clipboardData.getData("text/plain")) event.preventDefault(); add(images); } if (images.length) { if (!event.clipboardData.getData("text/plain")) event.preventDefault(); add(images); }
}); });
return { return {
get attachments() { return items.filter(v => v.metadata).map(v => v.metadata); }, setDraftStore(store) { draftStore = store; },
get pending() { return items.some(v => !v.metadata); }, adoptDraft(chatId) {
if (currentDraftId !== chatId) {
if (currentDraftId === "new") draftStore?.setAttachments("new", []);
currentDraftId = chatId;
persist();
}
},
get attachments() { return items.filter(v => v.metadata && !v.unavailable).map(v => v.metadata); },
get pending() { return items.some(v => !v.metadata || v.verifying || v.unavailable); },
setBusy(value) { busy = value; button.disabled = value; picker.disabled = value; render(); }, setBusy(value) { busy = value; button.disabled = value; picker.disabled = value; render(); },
clear() { items.forEach(v => URL.revokeObjectURL(v.preview)); items = []; render(); }, clear() { items.forEach(v => URL.revokeObjectURL(v.preview)); items = []; render(); },
discard() { discard() {
for (const item of items) { for (const item of items) {
item.controller?.abort(); item.controller?.abort();
URL.revokeObjectURL(item.preview); URL.revokeObjectURL(item.preview);
if (item.metadata) void fetch(`/attachments/${item.metadata.id}`, {method: "DELETE"}); if (item.metadata) void del(`/attachments/${item.metadata.id}`);
} }
items = []; items = [];
render(); render();
}, },
saveDraft(chatId) { saveDraft(chatId) {
if (chatId != null) drafts.set(chatId, items); chatId ??= "new";
drafts.set(chatId, items);
persist(chatId);
items = []; items = [];
currentDraftId = null;
render(); render();
}, },
restoreDraft(chatId) { restoreDraft(chatId) {
items = drafts.get(chatId) || []; currentDraftId = chatId ?? "new";
chatId ??= "new";
items = drafts.get(chatId) || (draftStore?.attachments(chatId) || []).map(metadata => ({metadata, recovered: true, verifying: true, preview: `/attachments/${metadata.id}`}));
drafts.delete(chatId); drafts.delete(chatId);
render(); render();
}, },
@@ -137,14 +169,17 @@ export function createAttachmentComposer({onChange, onError}) {
URL.revokeObjectURL(item.preview); URL.revokeObjectURL(item.preview);
return false; return false;
})); }));
const remaining = (draftStore?.attachments(chatId) || []).filter(item => !sent.has(item.id));
draftStore?.setAttachments(chatId, remaining);
}, },
discardDraft(chatId) { discardDraft(chatId) {
const saved = drafts.get(chatId) || []; const saved = drafts.get(chatId) || [];
drafts.delete(chatId); drafts.delete(chatId);
draftStore?.setAttachments(chatId, []);
for (const item of saved) { for (const item of saved) {
item.controller?.abort(); item.controller?.abort();
URL.revokeObjectURL(item.preview); URL.revokeObjectURL(item.preview);
if (item.metadata) void fetch(`/attachments/${item.metadata.id}`, {method: "DELETE"}); if (item.metadata) void del(`/attachments/${item.metadata.id}`);
} }
} }
}; };
+120
View File
@@ -0,0 +1,120 @@
import {get, post} from "./api.js";
import {createConversationPanel, actionButton} from "./conversationPanel.js";
export function createBranchExplorer({state, setActiveChat, setMessageContent}) {
const panel = createConversationPanel("branchExplorer", "Branches & answer comparison");
let request = 0;
return {async open() {
const chatId = state.activeChatId;
if (!chatId) return;
const current = ++request;
panel.open(); panel.notice("Loading branches..."); panel.content.replaceChildren();
const nodes = [], cache = new Map();
let next = 0, activeLeafId;
const layout = document.createElement("div"); layout.className = "branch-layout";
const sidebar = document.createElement("div"), comparison = document.createElement("div");
sidebar.className = "branch-sidebar"; comparison.className = "answer-comparison";
layout.append(sidebar, comparison); panel.content.append(layout);
const tree = document.createElement("ol"); tree.className = "branch-tree"; tree.setAttribute("aria-label", "Conversation branches");
const linked = state.chatSessions.filter(chat => chat.source_chat_id === chatId || chat.id === state.chatSessions.find(item => item.id === chatId)?.source_chat_id);
if (linked.length) {
const heading = document.createElement("h4"); heading.textContent = "Related conversations"; sidebar.append(heading);
for (const chat of linked) sidebar.append(actionButton(chat.title, async () => {
const result = await setActiveChat(chat.id);
if (!result?.error) panel.close(); else panel.notice(result.error, true);
}));
}
const hint = document.createElement("p"); hint.className = "subtitle";
hint.textContent = "Select a message to compare answers or versions. Opening this panel keeps your active branch unchanged.";
sidebar.append(hint, tree);
const panes = ["Left answer", "Right answer"].map(label => {
const section = document.createElement("section"); section.className = "comparison-pane";
const heading = document.createElement("h4"); heading.textContent = label;
const message = document.createElement("select"); message.setAttribute("aria-label", `${label} message`);
const version = document.createElement("select"); version.setAttribute("aria-label", `${label} version`);
const body = document.createElement("div"); body.className = "msg-content";
let revision = 0;
const render = async () => {
const token = ++revision, id = Number(message.value);
use.disabled = true; activate.disabled = true; version.disabled = true;
body.replaceChildren(); version.replaceChildren();
if (!id) return;
let data = cache.get(id);
if (!data) {
const loaded = await get(`/chats/${chatId}/messages/${id}/versions`);
if (token !== revision || current !== request) return;
if (loaded.error) return panel.notice(loaded.error, true);
data = loaded.message; cache.set(id, data);
}
const versions = data.responseVersions?.length ? data.responseVersions : [{content: data.content, modelName: data.modelName}];
use.hidden = data.role !== "bot" || versions.length < 2;
use.disabled = false; activate.disabled = false; version.disabled = false;
version.replaceChildren(...versions.map((item, index) => {
const option = document.createElement("option"); option.value = index;
option.textContent = `Version ${index + 1}${item.modelName ? ` · ${item.modelName}` : ""}${index === data.versionIndex ? " (selected)" : ""}`;
return option;
}));
version.value = String(label === "Left answer" && versions.length > 1 ? 0 : data.versionIndex || 0);
const show = () => setMessageContent(body, versions[Number(version.value)]?.content || "");
version.onchange = show; show();
};
message.onchange = render;
const activate = actionButton("Open this branch", async () => {
const result = await post(`/chats/${chatId}/branches/${message.value}/activate`, {includeDescendants: true});
if (result.error) return panel.notice(result.error, true);
const opened = await setActiveChat(chatId);
if (opened?.error) return panel.notice(opened.error, true);
panel.close();
});
const use = actionButton("Use this version", async () => {
const result = await post(`/chats/${chatId}/messages/${message.value}/versions/${version.value}`, {});
if (result.error) return panel.notice(result.error, true);
cache.delete(Number(message.value));
const refreshed = await setActiveChat(chatId);
if (refreshed?.error) return panel.notice(refreshed.error, true);
panel.notice("Selected answer version saved.");
});
const actions = document.createElement("div"); actions.className = "panel-actions"; actions.append(activate, use);
section.append(heading, message, version, actions, body); comparison.append(section);
return {message, render};
});
const loadMore = actionButton("Load more branches", async () => {
const result = await get(`/chats/${chatId}/branches?after=${next}`);
if (current !== request) return;
if (result.error) return panel.notice(result.error, true);
nodes.push(...result.nodes); next = result.next; activeLeafId = result.activeLeafId;
tree.replaceChildren();
const known = new Set(nodes.map(node => node.id)), children = new Map();
for (const node of nodes) {
const parent = known.has(node.parentMessageId) ? node.parentMessageId : null;
children.set(parent, [...(children.get(parent) || []), node]);
}
const stack = (children.get(null) || []).map(node => ({node, level: 0})).reverse();
while (stack.length) {
const {node, level} = stack.pop();
stack.push(...(children.get(node.id) || []).map(child => ({node: child, level: level + 1})).reverse());
const row = document.createElement("li"); row.style.setProperty("--branch-depth", Math.min(level, 8));
row.title = node.parentMessageId ? `Continues message #${node.parentMessageId}` : "Conversation root";
const button = actionButton(`${node.role === "bot" ? "Assistant" : "You"} #${node.id}${node.id === activeLeafId ? " · active tip" : ""} — ${node.preview}`, async () => {
panes[1].message.value = String(node.id); await panes[1].render();
});
if (node.id === activeLeafId) button.setAttribute("aria-current", "true");
row.append(button); tree.append(row);
}
for (const pane of panes) {
const selected = pane.message.value;
pane.message.replaceChildren(...nodes.map(node => {
const option = document.createElement("option"); option.value = node.id;
option.textContent = `${node.role === "bot" ? "Assistant" : "You"} #${node.id} — ${node.preview.slice(0, 65)}`;
return option;
}));
pane.message.value = selected || String(nodes.filter(node => node.role === "bot").at(-1)?.id || nodes.at(-1)?.id || "");
await pane.render();
}
loadMore.hidden = next == null;
panel.notice(nodes.length ? "" : "Send a message to start a conversation branch.");
});
sidebar.append(loadMore);
await loadMore.onclick();
}};
}
+27 -14
View File
@@ -74,7 +74,12 @@ export function createComposerController({
const {signal} = sendController; const {signal} = sendController;
const visible = () => state.activeChatId === chatId; const visible = () => state.activeChatId === chatId;
let userCard, loadingMsg, rendering, completedReply = null; let userCard, loadingMsg, rendering, completedReply = null;
let completedThinking = "", completedSources = null;
const userMessage = {role: "user", content: message, attachments}; const userMessage = {role: "user", content: message, attachments};
const clearSentDraft = () => {
if (state.chatDrafts.get(chatId)?.trim() === message) state.chatDrafts.delete(chatId);
attachmentComposer.clearSentDraft(chatId, attachments.map(item => item.id));
};
try { try {
setLoadingState(true, false); setLoadingState(true, false);
if (retryMessage && !msgInput.value.trim()) { if (retryMessage && !msgInput.value.trim()) {
@@ -115,11 +120,11 @@ export function createComposerController({
if (follow) scrollMessagesToBottom(); if (follow) scrollMessagesToBottom();
}; };
rendering = createStreamRenderer({ rendering = createStreamRenderer({
onThinking: text => render(() => { onThinking: text => { completedThinking = text; render(() => {
if (!reasoning) return; if (!reasoning) return;
updateReasoning(loadingMsg, text, true); updateReasoning(loadingMsg, text, true);
loadingShell?.classList.toggle("hidden", Boolean(text)); loadingShell?.classList.toggle("hidden", Boolean(text));
}), }); },
onReply: text => render(() => { onReply: text => render(() => {
if (text) finishReasoning(loadingMsg); if (text) finishReasoning(loadingMsg);
loadingShell?.classList.toggle("hidden", Boolean(text)); loadingShell?.classList.toggle("hidden", Boolean(text));
@@ -136,24 +141,36 @@ export function createComposerController({
: searchState === "no_relevant_results" ? "No sufficiently relevant results. Generating response..." : searchState === "no_relevant_results" ? "No sufficiently relevant results. Generating response..."
: sourceCount === 0 ? "No web results found. Generating response..." : "Generating response..."; : sourceCount === 0 ? "No web results found. Generating response..." : "Generating response...";
}, },
onDone: ({reply}) => { completedReply = reply; } onDone: ({reply, sources}) => { completedReply = reply; completedSources = sources; }
}, {signal}); }, {signal});
rendering.flush();
rendering.dispose(); rendering.dispose();
loadingMsg?.remove(); // Keep a completed answer in both state and the DOM until history replaces it.
if (visible()) {
const reply = {role: "bot", content: completedReply, modelName: model,
...(completedThinking ? {thinking: completedThinking} : {}), ...(completedSources ? {sources: completedSources} : {})};
state.currentMessages.push(reply);
if (loadingMsg?.isConnected) loadingMsg.replaceWith(addMessage(reply, false, false, {detached: true}));
else addMessage(reply);
} else loadingMsg?.remove();
webSearchToggle.reset(); reasoningToggle.reset(); webSearchToggle.reset(); reasoningToggle.reset();
if (visible()) { if (visible()) {
const followOutput = messagesDiv.scrollHeight - messagesDiv.scrollTop - messagesDiv.clientHeight < 100; const followOutput = messagesDiv.scrollHeight - messagesDiv.scrollTop - messagesDiv.clientHeight < 100;
const previousScrollTop = messagesDiv.scrollTop; const previousScrollTop = messagesDiv.scrollTop;
if (msgInput.value.trim() === message) { msgInput.value = ""; resizeComposerInput(); } const refreshed = await setActiveChat(chatId);
const currentAttachments = attachmentComposer.attachments; if (refreshed?.error) throw new Error(refreshed.error);
if (currentAttachments.length === attachments.length && currentAttachments.every((item, index) => item.id === attachments[index].id)) attachmentComposer.clear(); if (visible()) {
await setActiveChat(chatId); if (msgInput.value.trim() === message) { msgInput.value = ""; resizeComposerInput(); }
if (msgInput.value) state.chatDrafts.set(chatId, msgInput.value);
else state.chatDrafts.delete(chatId);
const currentAttachments = attachmentComposer.attachments;
if (currentAttachments.length === attachments.length && currentAttachments.every((item, index) => item.id === attachments[index].id)) attachmentComposer.clear();
} else clearSentDraft();
if (!followOutput && visible()) requestAnimationFrame(() => { if (!followOutput && visible()) requestAnimationFrame(() => {
if (visible()) messagesDiv.scrollTop = previousScrollTop; if (visible()) messagesDiv.scrollTop = previousScrollTop;
}); });
} else { } else {
if (state.chatDrafts.get(chatId)?.trim() === message) state.chatDrafts.delete(chatId); clearSentDraft();
attachmentComposer.clearSentDraft(chatId, attachments.map(item => item.id));
} }
await loadSummary(); await loadSummary();
markOnboardingCompleted(); maybeShowPersonaNudge(); markOnboardingCompleted(); maybeShowPersonaNudge();
@@ -162,10 +179,6 @@ export function createComposerController({
loadingMsg?.remove(); loadingMsg?.remove();
const messageText = signal.aborted ? "Generation stopped." : error?.message || "Unable to send message."; const messageText = signal.aborted ? "Generation stopped." : error?.message || "Unable to send message.";
if (completedReply !== null) { if (completedReply !== null) {
if (visible() && userCard?.isConnected) {
state.currentMessages.push({role: "bot", content: completedReply});
addMessage(completedReply);
}
setNotice(`Reply sent, but the conversation could not be refreshed: ${messageText}`, "error"); setNotice(`Reply sent, but the conversation could not be refreshed: ${messageText}`, "error");
} else { } else {
if (visible()) { if (visible()) {
+60 -15
View File
@@ -18,8 +18,10 @@ export function createConversationController({
renderMessages, renderMessages,
updateJumpToLatest, updateJumpToLatest,
updateSendState, updateSendState,
loadSummary loadSummary,
updateHistoryControls = () => {}
}) { }) {
let opening = false;
function getChatById(id) { return state.chatSessions.find((chat) => chat.id === id); } function getChatById(id) { return state.chatSessions.find((chat) => chat.id === id); }
async function openCreatedChat(chat) { async function openCreatedChat(chat) {
@@ -35,28 +37,34 @@ export function createConversationController({
throw new Error("The newly created chat was not persisted"); throw new Error("The newly created chat was not persisted");
} }
renderChatList(); renderChatList();
await setActiveChat(chat.id); const opened = await setActiveChat(chat.id);
if (opened?.error) throw new Error(opened.error);
} }
async function setActiveChat(id, {preserveComposer = false, matchMessageId = null} = {}) { async function setActiveChat(id, {preserveComposer = false, matchMessageId = null} = {}) {
const failed = response => { setNotice(response.error, "error"); return response; };
const openRequest = ++state.chatOpenRequest; const openRequest = ++state.chatOpenRequest;
if (isMobileLayout()) closeMobileSidebar(); if (isMobileLayout()) closeMobileSidebar();
setChatLoading(true, "Opening chat..."); if (!opening) {
setChatActivity(true, { opening = true;
eyebrow: "Opening chat", title: "Loading conversation", setChatLoading(true, "Opening chat...");
detail: "Fetching messages and restoring the current scene." setChatActivity(true, {
}); eyebrow: "Opening chat", title: "Loading conversation",
detail: "Fetching messages and restoring the current scene."
});
}
try { try {
let res = await get(`/chats/${id}/messages`); const historyUrl = `/chats/${id}/messages${matchMessageId ? `?around=${matchMessageId}` : ""}`;
let res = await get(historyUrl);
if (openRequest !== state.chatOpenRequest) return; if (openRequest !== state.chatOpenRequest) return;
if (res.error) return setNotice(res.error, "error"); if (res.error) return failed(res);
if (matchMessageId && !res.messages?.some(message => message.id === matchMessageId)) { if (matchMessageId && !res.messages?.some(message => message.id === matchMessageId)) {
const activated = await post(`/chats/${id}/branches/${matchMessageId}/activate`, {includeDescendants: true}); const activated = await post(`/chats/${id}/branches/${matchMessageId}/activate`, {includeDescendants: true});
if (openRequest !== state.chatOpenRequest) return; if (openRequest !== state.chatOpenRequest) return;
if (activated.error) return setNotice(activated.error, "error"); if (activated.error) return failed(activated);
res = await get(`/chats/${id}/messages`); res = await get(historyUrl);
if (openRequest !== state.chatOpenRequest) return; if (openRequest !== state.chatOpenRequest) return;
if (res.error) return setNotice(res.error, "error"); if (res.error) return failed(res);
} }
// Commit identity, content and drafts together, only for the latest successful load. // Commit identity, content and drafts together, only for the latest successful load.
if (!preserveComposer && id !== state.activeChatId) { if (!preserveComposer && id !== state.activeChatId) {
@@ -66,7 +74,11 @@ export function createConversationController({
msgInput.value = state.chatDrafts.get(id) || ""; msgInput.value = state.chatDrafts.get(id) || "";
attachmentComposer.restoreDraft(id); attachmentComposer.restoreDraft(id);
resizeComposerInput(); resizeComposerInput();
} else if (!msgInput.value && !attachmentComposer.pending && !attachmentComposer.attachments.length) { } else {
if (msgInput.value || attachmentComposer.pending || attachmentComposer.attachments.length) {
state.chatDrafts.set("new", msgInput.value);
attachmentComposer.saveDraft(null);
}
msgInput.value = state.chatDrafts.get(id) || ""; msgInput.value = state.chatDrafts.get(id) || "";
attachmentComposer.restoreDraft(id); attachmentComposer.restoreDraft(id);
resizeComposerInput(); resizeComposerInput();
@@ -75,7 +87,13 @@ export function createConversationController({
const existing = getChatById(id); const existing = getChatById(id);
if (res.chat && existing) Object.assign(existing, res.chat); if (res.chat && existing) Object.assign(existing, res.chat);
state.activeChatId = id; state.activeChatId = id;
attachmentComposer.adoptDraft?.(id);
if (preserveComposer) state.chatDrafts.delete("new");
state.chatDrafts.set(id, msgInput.value);
state.currentMessages = res.messages || []; state.currentMessages = res.messages || [];
state.historyPage = res.pagination || null;
state.historyLoading = false;
updateHistoryControls();
history.replaceState(null, "", `/app/chats/${id}`); history.replaceState(null, "", `/app/chats/${id}`);
state.messageRetryState = new Map(); state.messageRetryState = new Map();
updateWorkspaceCopy(); renderChatList(); updateChatActionState(); updateWorkspaceCopy(); renderChatList(); updateChatActionState();
@@ -88,14 +106,39 @@ export function createConversationController({
if (target) { target.scrollIntoView({block: "center"}); updateJumpToLatest(); } if (target) { target.scrollIntoView({block: "center"}); updateJumpToLatest(); }
}); });
updateSendState(); updateSendState();
return {ok: true};
} finally { } finally {
if (openRequest === state.chatOpenRequest) { if (openRequest === state.chatOpenRequest) {
opening = false;
setChatLoading(false); setChatLoading(false);
setChatActivity(false); setChatActivity(false);
} }
} }
} }
async function loadOlderMessages() {
if (state.historyLoading || !state.historyPage?.hasMore || !state.activeChatId) return;
const chatId = state.activeChatId, requestId = state.chatOpenRequest, page = state.historyPage;
state.historyLoading = true;
updateHistoryControls();
try {
const result = await get(`/chats/${chatId}/messages?before=${page.before}&leaf=${page.leafId}`);
if (chatId !== state.activeChatId || requestId !== state.chatOpenRequest) return;
if (result.error) { setNotice(result.error, "error"); return result; }
const oldHeight = messagesDiv.scrollHeight, oldTop = messagesDiv.scrollTop;
const existing = new Set(state.currentMessages.map(message => message.id));
state.currentMessages = [...result.messages.filter(message => !existing.has(message.id)), ...state.currentMessages];
state.historyPage = {...result.pagination, hasNewer: page.hasNewer};
renderMessages({preserveScroll: true});
messagesDiv.scrollTop = oldTop + messagesDiv.scrollHeight - oldHeight;
} finally {
if (state.historyPage === page || requestId === state.chatOpenRequest) {
state.historyLoading = false;
updateHistoryControls();
}
}
}
async function loadChatSessions() { async function loadChatSessions() {
setChatLoading(true, "Loading chats..."); setChatLoading(true, "Loading chats...");
const res = await get("/chats"); const res = await get("/chats");
@@ -169,7 +212,8 @@ export function createConversationController({
state.chatSessions.unshift(res.chat); state.chatSessions.unshift(res.chat);
await loadSummary(); renderChatList(); await loadSummary(); renderChatList();
if (!signal?.aborted && state.activeChatId === previousChatId) { if (!signal?.aborted && state.activeChatId === previousChatId) {
await setActiveChat(res.chat.id, {preserveComposer}); const opened = await setActiveChat(res.chat.id, {preserveComposer});
if (opened?.error) return opened;
msgInput.focus(); msgInput.focus();
} }
setNotice("New chat created.", "success"); setNotice("New chat created.", "success");
@@ -185,6 +229,7 @@ export function createConversationController({
setActiveChat, setActiveChat,
loadChatSessions, loadChatSessions,
scheduleChatSearch, scheduleChatSearch,
createNewChat createNewChat,
loadOlderMessages
}; };
} }
+31
View File
@@ -0,0 +1,31 @@
import {openModal, closeModal} from "./modalFocus.js";
export function createConversationPanel(id, title) {
const overlay = document.createElement("div");
overlay.id = id;
overlay.className = "modal-overlay hidden";
overlay.innerHTML = `<section class="modal-card conversation-panel" role="dialog" aria-modal="true" aria-labelledby="${id}Title" tabindex="-1">
<div class="modal-header"><h3 id="${id}Title"></h3><button type="button" class="secondary-action">Close</button></div>
<p class="panel-notice status" role="status"></p><div class="panel-content"></div></section>`;
overlay.querySelector("h3").textContent = title;
document.body.append(overlay);
const close = () => closeModal(overlay);
overlay.querySelector("button").onclick = close;
overlay.onclick = event => { if (event.target === overlay) close(); };
return {element: overlay, content: overlay.querySelector(".panel-content"), close,
open() { if (overlay.classList.contains("hidden")) openModal(overlay); },
notice(text = "", error = false) {
const notice = overlay.querySelector(".panel-notice");
notice.textContent = text;
notice.classList.toggle("error", error);
}};
}
export function actionButton(label, action) {
const button = document.createElement("button");
button.type = "button";
button.className = "secondary-action";
button.textContent = label;
button.onclick = async () => { button.disabled = true; try { await action(); } finally { button.disabled = false; } };
return button;
}
+2
View File
@@ -9,6 +9,8 @@ export function createConversationState() {
regenerationChatId: null, regenerationChatId: null,
chatSessions: [], chatSessions: [],
currentMessages: [], currentMessages: [],
historyPage: null,
historyLoading: false,
assistantPersonas: [], assistantPersonas: [],
userPersonas: [], userPersonas: [],
activeUserPersonaId: null, activeUserPersonaId: null,
+37
View File
@@ -0,0 +1,37 @@
/** Text and uploaded attachment references only, scoped to the signed-in account. */
export function createDraftStore(username, {storage = globalThis.localStorage, onError = () => {}} = {}) {
const key = `krishd-drafts-v1:${encodeURIComponent(username)}`;
let records = {};
let warned = false;
const report = () => { if (!warned) onError("Draft recovery is unavailable in this browser. Keep this page open to retain your draft."); warned = true; };
try {
const saved = JSON.parse(storage.getItem(key) || "{}");
if (saved && typeof saved === "object" && !Array.isArray(saved)) records = saved;
} catch { report(); }
const normalizeId = id => id == null ? "new" : String(id);
const save = () => {
for (const [id, record] of Object.entries(records)) {
if (!record.text && !record.attachments?.length) delete records[id];
}
try { storage.setItem(key, JSON.stringify(records)); } catch { report(); }
};
const update = (id, changes) => {
id = normalizeId(id);
records[id] = {...records[id], ...changes};
save();
};
const textDrafts = new Map(Object.entries(records).filter(([, record]) => typeof record?.text === "string")
.map(([id, record]) => [id === "new" ? id : Number(id), record.text]));
const set = textDrafts.set.bind(textDrafts), remove = textDrafts.delete.bind(textDrafts);
textDrafts.set = (id, text) => { update(id, {text}); return set(id, text); };
textDrafts.delete = id => { update(id, {text: ""}); return remove(id); };
return {
textDrafts,
attachments(id) {
const items = records[normalizeId(id)]?.attachments;
return Array.isArray(items) ? items.filter(item => typeof item?.id === "string" && /^[a-f0-9-]{36}$/.test(item.id)) : [];
},
setAttachments(id, attachments) { update(id, {attachments}); },
clear(id) { delete records[normalizeId(id)]; remove(id); save(); }
};
}
+82
View File
@@ -0,0 +1,82 @@
import {get, post, put, del} from "./api.js";
import {createConversationPanel, actionButton} from "./conversationPanel.js";
export function createMemoryPanel({state}) {
const panel = createConversationPanel("memoryPanel", "Conversation memory");
let chatId, request = 0;
function field(label, value = "", maxLength = 2000, key = "") {
const wrapper = document.createElement("label"); wrapper.className = "field";
const caption = document.createElement("span"); caption.textContent = label;
const input = document.createElement("textarea"); input.value = value; input.maxLength = maxLength; input.rows = 3;
input.dataset.draftKey = key; input.dataset.savedValue = value;
wrapper.append(caption, input);
return {wrapper, input};
}
async function load(edits = new Map()) {
const current = ++request, id = chatId;
panel.notice("Loading memory...");
const result = await get(`/chats/${id}/memories`);
if (current !== request) return;
if (result.error) { panel.notice(result.error, true); return result; }
panel.notice();
panel.content.replaceChildren();
const usage = document.createElement("p"); usage.className = "memory-context subtitle";
const context = result.context;
usage.textContent = `Estimated context: ${context.estimatedTokens.toLocaleString()} tokens${context.contextLength ? ` of ${context.contextLength.toLocaleString()}` : " (model context size not set)"}. Includes saved facts, summary, instructions and up to ${context.recentMessageLimit} recent messages. Token counts are approximate; your next message and new search results add to this.`;
panel.content.append(usage);
if (context.contextLength) {
const meter = document.createElement("meter"); meter.min = 0; meter.max = context.contextLength;
meter.value = Math.min(context.estimatedTokens, context.contextLength);
meter.setAttribute("aria-label", "Estimated context usage"); panel.content.append(meter);
}
const run = async (operation, savedKey) => {
const unsaved = new Map([...panel.content.querySelectorAll("textarea")]
.filter(input => input.value !== input.dataset.savedValue).map(input => [input.dataset.draftKey, input.value]));
const controls = [...panel.content.querySelectorAll("button, textarea")];
controls.forEach(control => { control.disabled = true; });
const response = await operation;
if (current !== request) return;
if (response.error) {
controls.forEach(control => { control.disabled = false; });
return panel.notice(response.error, true);
}
unsaved.delete(savedKey);
const refreshed = await load(unsaved);
if (refreshed?.error) {
controls.forEach(control => { control.disabled = false; });
return panel.notice(`Memory saved, but it could not be refreshed: ${refreshed.error}`, true);
}
panel.notice("Memory saved.");
};
const summary = field("Running summary", result.summary, result.summaryMaxChars, "summary");
summary.input.rows = 6;
const summaryHelp = document.createElement("p"); summaryHelp.className = "subtitle";
summaryHelp.textContent = "Correct outdated information here. Future automatic summaries build on your corrections. Clear the field and save to remove the summary.";
panel.content.append(summary.wrapper, summaryHelp, actionButton("Save summary", () => run(put(`/chats/${id}/summary`, {summary: summary.input.value, previousSummary: result.summary}), "summary")));
const heading = document.createElement("h4"); heading.textContent = "Saved facts";
const hint = document.createElement("p"); hint.className = "subtitle";
hint.textContent = `The ${context.memoryFactLimit} most recently updated facts are included in context. Edit or remove facts that are no longer accurate.`;
panel.content.append(heading, hint);
for (const [index, memory] of result.memories.entries()) {
const row = document.createElement("section"); row.className = "memory-fact";
const key = `fact-${memory.id}`;
const fact = field(`Fact ${index + 1}${index >= context.memoryFactLimit ? " (outside current context)" : ""}`, memory.fact, 800, key);
const actions = document.createElement("div"); actions.className = "panel-actions";
actions.append(actionButton("Save fact", () => run(put(`/chats/${id}/memories/${memory.id}`, {fact: fact.input.value}), key)),
actionButton("Remove fact", () => run(del(`/chats/${id}/memories/${memory.id}`), key)));
row.append(fact.wrapper, actions); panel.content.append(row);
}
const fresh = field("New fact", "", 800, "new");
panel.content.append(fresh.wrapper, actionButton("Add fact", () => run(post(`/chats/${id}/memories`, {fact: fresh.input.value}), "new")));
for (const input of panel.content.querySelectorAll("textarea")) {
if (edits.has(input.dataset.draftKey)) input.value = edits.get(input.dataset.draftKey);
}
return {ok: true};
}
return {async open() {
if (!state.activeChatId) return;
chatId = state.activeChatId;
panel.content.replaceChildren(); panel.open();
await load();
}};
}
+256
View File
@@ -0,0 +1,256 @@
import {post, put, del} from "./api.js";
import {regenerateInline} from "./regeneration.js";
export function createMessageActions({conversation, setNotice, promptPopup, confirmPopup, setActiveChat, openCreatedChat, setLoadingState, loadSummary, renderMessages, addMessage, setMessageContent, webSearchToggle, reasoningToggle, modelSelect, messagesDiv, msgInput}) {
function getMessageIndexById(messageId) {
return conversation.currentMessages.findIndex((message) => message.id === messageId);
}
async function resolveMessageTarget(messageId, fallbackIndex = -1) {
if (messageId) {
const index = getMessageIndexById(messageId);
if (index >= 0) {
return { index, message: conversation.currentMessages[index] };
}
}
if (conversation.activeChatId) {
await setActiveChat(conversation.activeChatId);
}
if (messageId) {
const index = getMessageIndexById(messageId);
if (index >= 0) {
return { index, message: conversation.currentMessages[index] };
}
}
if (fallbackIndex >= 0 && fallbackIndex < conversation.currentMessages.length) {
return { index: fallbackIndex, message: conversation.currentMessages[fallbackIndex] };
}
return null;
}
function getMessageEndpoint(target, suffix = "") {
if (!conversation.activeChatId || !target) return "";
if (target.message?.id) return `/chats/${conversation.activeChatId}/messages/${target.message.id}${suffix}`;
if (target.index >= 0) return `/chats/${conversation.activeChatId}/messages/by-index/${target.index}${suffix}`;
return "";
}
function getPreviousUserMessageId(messageId) {
const index = getMessageIndexById(messageId);
if (index <= 0) return null;
for (let i = index - 1; i >= 0; i -= 1) {
if (conversation.currentMessages[i].role === "user" && conversation.currentMessages[i].id) {
return conversation.currentMessages[i].id;
}
}
return null;
}
function getRetryStateFromMessage(message) {
const variants = Array.isArray(message?.retryVariants) && message.retryVariants.length
? [...message.retryVariants]
: [message?.content || ""];
const requestedActiveIndex = Number(message?.retryActiveIndex);
const activeIndex = Number.isInteger(requestedActiveIndex)
? Math.min(Math.max(requestedActiveIndex, 0), variants.length - 1)
: 0;
return {
variants,
activeIndex,
retriesUsed: Number(message?.retryRetriesUsed || 0),
promptMessageId: message?.retryPromptMessageId ?? getPreviousUserMessageId(message?.id)
};
}
function syncRetryStateFromMessage(message) {
if (!message?.id) return null;
const state = getRetryStateFromMessage(message);
conversation.messageRetryState.set(message.id, state);
return state;
}
async function editChatMessage(messageId, fallbackIndex = -1) {
const target = await resolveMessageTarget(messageId, fallbackIndex);
if (!target) return setNotice("Message is not ready yet. Try again.", "error");
const { index, message } = target;
const sourceChatId = conversation.activeChatId;
const nextContent = await promptPopup({
eyebrow: message.role === "user" ? "Your message" : "Assistant message",
title: "Edit message",
description: "Update this message.",
label: "Message",
value: message.content,
confirmLabel: "Save"
});
const content = (nextContent || "").trim();
if (!content) return;
if (message.role === "user") {
const confirmed = await confirmPopup({
eyebrow: "Create branch",
title: "Create an edited chat branch?",
description: "This creates a new chat from the messages before this one and sends your edited message there. The original chat stays unchanged.",
confirmLabel: "Create branch"
});
if (!confirmed || conversation.isProcessing) return;
conversation.regenerationChatId = sourceChatId;
setLoadingState(true, {
eyebrow: "Branching",
title: "Editing and resending",
detail: "Creating and opening a new chat branch."
});
try {
const res = await post(`/chats/${sourceChatId}/messages/${message.id}/edit-resend`, {
chatId: sourceChatId,
message: content,
model: modelSelect.value,
confirmed: true
}, {timeoutMs: 660000});
if (res.error) return setNotice(res.error, "error");
if (conversation.activeChatId === sourceChatId) await openCreatedChat(res.chat);
return setNotice("Created the edited chat branch.", "success");
} finally {
conversation.regenerationChatId = null;
setLoadingState(false);
}
}
const endpoint = getMessageEndpoint(target);
if (!endpoint) return setNotice("Message is not ready yet. Try again.", "error");
const res = await put(endpoint, { content });
if (res.error || !res.message) {
return setNotice(res.error || "Unable to edit message.", "error");
}
conversation.currentMessages[index] = { ...conversation.currentMessages[index], ...res.message };
syncRetryStateFromMessage(conversation.currentMessages[index]);
renderMessages();
setNotice("Message updated.", "success");
}
async function deleteChatMessage(messageId, fallbackIndex = -1) {
const target = await resolveMessageTarget(messageId, fallbackIndex);
if (!target) return setNotice("Message is not ready yet. Try again.", "error");
const { message } = target;
const confirmed = await confirmPopup({
eyebrow: message.role === "user" ? "Your message" : "Assistant message",
title: "Delete message",
description: "Delete this message from the chat?",
confirmLabel: "Delete",
danger: true
});
if (!confirmed) return;
const endpoint = getMessageEndpoint(target);
if (!endpoint) return setNotice("Message is not ready yet. Try again.", "error");
const res = await del(endpoint);
if (res.error) return setNotice(res.error, "error");
conversation.currentMessages = conversation.currentMessages.filter((item) => item.id !== message.id);
if (message.id) {
conversation.messageRetryState.delete(message.id);
for (const [key, state] of conversation.messageRetryState.entries()) {
if (state.promptMessageId === message.id) {
conversation.messageRetryState.delete(key);
}
}
}
renderMessages();
await loadSummary();
setNotice("Message deleted.", "success");
}
async function retryLatestAssistantMessage(messageId, fallbackIndex = -1) {
if (conversation.isProcessing) return;
const target = await resolveMessageTarget(messageId, fallbackIndex);
if (conversation.isProcessing) return;
if (!target) return setNotice("Message is not ready yet. Try again.", "error");
if (target.message.role !== "bot") return setNotice("Only assistant messages can be regenerated.", "error");
const retriesUsed = Math.max(0, Number(target.message.versionCount || 1) - 1);
if (retriesUsed >= 5) {
return setNotice("Retry limit reached (5).", "error");
}
const chatId = conversation.activeChatId;
const card = messagesDiv.querySelector(`[data-message-id="${target.message.id}"]`);
if (!card) return;
const payload = { model: modelSelect.value, webSearch: webSearchToggle.enabled, reasoning: reasoningToggle.enabled };
conversation.regenerationChatId = chatId;
setLoadingState(true, false);
try {
await regenerateInline({
card, scroller: messagesDiv, endpoint: `/chats/${chatId}/messages/${target.message.id}/retry/stream`, payload,
renderContent: setMessageContent,
complete: message => {
if (conversation.activeChatId !== chatId) return;
const index = getMessageIndexById(message.id);
if (index < 0) return;
conversation.currentMessages[index] = { ...conversation.currentMessages[index], ...message, thinking: message.thinking, sources: message.sources };
syncRetryStateFromMessage(conversation.currentMessages[index]);
const rendered = addMessage(conversation.currentMessages[index], false, false, { messageIndex: index, detached: true });
card.replaceChildren(...rendered.childNodes);
}
});
webSearchToggle.reset();
reasoningToggle.reset();
setNotice("Reply regenerated.", "success");
} catch (error) {
setNotice(error.message || "Unable to regenerate message.", "error");
} finally {
conversation.regenerationChatId = null;
setLoadingState(false);
}
}
async function switchResponseVersion(messageId, direction, fallbackIndex = -1) {
const target = await resolveMessageTarget(messageId, fallbackIndex);
if (!target) return setNotice("Message is not ready yet. Try again.", "error");
if (Number(target.message.versionCount) > 1) {
const chatId = conversation.activeChatId;
const card = messagesDiv.querySelector(`[data-message-id="${messageId}"]`);
if (!card || card.getAttribute("aria-busy") === "true") return;
const buttons = [...card.querySelectorAll("button")].map(button => [button, button.disabled]);
card.setAttribute("aria-busy", "true");
buttons.forEach(([button]) => { button.disabled = true; });
try {
const res = await post(`/chats/${chatId}/messages/${messageId}/versions/${target.message.versionIndex + direction}`, {});
if (res.error) return setNotice(res.error, "error");
if (conversation.activeChatId !== chatId) return;
const index = getMessageIndexById(messageId);
if (index < 0) return;
conversation.currentMessages[index] = { ...conversation.currentMessages[index], ...res.message, thinking: res.message.thinking, sources: res.message.sources };
syncRetryStateFromMessage(conversation.currentMessages[index]);
const rendered = addMessage(conversation.currentMessages[index], false, false, { messageIndex: index, detached: true });
card.replaceChildren(...rendered.childNodes);
} finally {
card.removeAttribute("aria-busy");
buttons.forEach(([button, disabled]) => { button.disabled = disabled; });
}
}
}
async function switchRetryVariant(messageId, direction, fallbackIndex = -1) {
const target = await resolveMessageTarget(messageId, fallbackIndex);
if (!target) return setNotice("Message is not ready yet. Try again.", "error");
const siblingId = direction < 0 ? target.message.previousSiblingId : target.message.nextSiblingId;
if (!siblingId) return;
const res = await post(`/chats/${conversation.activeChatId}/branches/${siblingId}/activate`, { includeDescendants: true });
if (res.error) return setNotice(res.error, "error");
conversation.currentMessages = res.messages || [];
conversation.historyPage = res.pagination || null;
renderMessages();
}
async function branchFromMessage(messageId) {
if (!conversation.activeChatId || !messageId) return;
const confirmed = await confirmPopup({
eyebrow: "Create branch",
title: "Create a new chat branch?",
description: "The new chat will contain the conversation through this message. The original chat stays unchanged.",
confirmLabel: "Create branch"
});
if (!confirmed) return;
const sourceChatId = conversation.activeChatId;
const res = await post(`/chats/${sourceChatId}/messages/${messageId}/branch`, { confirmed: true });
if (res.error) return setNotice(res.error, "error");
await openCreatedChat(res.chat);
msgInput.focus();
setNotice("Created and opened a new chat branch.", "success");
}
return {editChatMessage, deleteChatMessage, retryLatestAssistantMessage, switchResponseVersion, switchRetryVariant, branchFromMessage};
}
+2 -2
View File
@@ -222,7 +222,7 @@ export function createMessageRenderer({
return msgDiv; return msgDiv;
} }
function renderMessages() { function renderMessages({preserveScroll = false} = {}) {
if (scrollFrame !== null) { cancelAnimationFrame(scrollFrame); scrollFrame = null; } if (scrollFrame !== null) { cancelAnimationFrame(scrollFrame); scrollFrame = null; }
const sameChat = renderedChatId === state.activeChatId; const sameChat = renderedChatId === state.activeChatId;
const wasReadingEarlier = sameChat && messagesDiv.scrollHeight - messagesDiv.scrollTop - messagesDiv.clientHeight >= 100; const wasReadingEarlier = sameChat && messagesDiv.scrollHeight - messagesDiv.scrollTop - messagesDiv.clientHeight >= 100;
@@ -256,7 +256,7 @@ export function createMessageRenderer({
renderDrawerSummary(); renderDrawerSummary();
updateChatActionState(); updateChatActionState();
updateComposerPlaceholder(); updateComposerPlaceholder();
if (wasReadingEarlier) { if (wasReadingEarlier || preserveScroll) {
messagesDiv.scrollTop = previousScrollTop; messagesDiv.scrollTop = previousScrollTop;
updateJumpToLatest(); updateJumpToLatest();
} else if (state.currentMessages.length) { } else if (state.currentMessages.length) {
+45 -410
View File
@@ -1,3 +1,7 @@
import {createMemoryPanel} from "./memoryPanel.js";
import {createBranchExplorer} from "./branchExplorer.js";
import {createWorkspaceTransfer} from "./workspaceTransfer.js";
import {createDraftStore} from "./draftStore.js";
import {createMessageRenderer} from "./messageRenderer.js"; import {createMessageRenderer} from "./messageRenderer.js";
import {createPersonaController} from "./personaController.js"; import {createPersonaController} from "./personaController.js";
import {createConversationController} from "./conversationController.js"; import {createConversationController} from "./conversationController.js";
@@ -7,7 +11,7 @@ import {createConversationState} from "./conversationState.js";
* @file Steuert die Chat-Oberfläche mit Gesprächsauswahl, Nachrichteneingabe, Streaming und Persona-Verwaltung. * @file Steuert die Chat-Oberfläche mit Gesprächsauswahl, Nachrichteneingabe, Streaming und Persona-Verwaltung.
*/ */
import {del, get, post, put} from "./api.js?v=20260909-account-controls"; import {del, get, post, put} from "./api.js?v=20260909-account-controls";
import { regenerateInline } from "./regeneration.js"; import {createMessageActions} from "./messageActions.js";
import {createWebSearchToggle} from "./webSearch.js"; import {createWebSearchToggle} from "./webSearch.js";
import {createAttachmentComposer} from "./attachments.js?v=20260930-chat-usability"; import {createAttachmentComposer} from "./attachments.js?v=20260930-chat-usability";
import {createReasoningToggle} from "./reasoning.js?v=20260921-reasoning-capability"; import {createReasoningToggle} from "./reasoning.js?v=20260921-reasoning-capability";
@@ -168,7 +172,8 @@ const {
setActiveChat, setActiveChat,
loadChatSessions, loadChatSessions,
scheduleChatSearch, scheduleChatSearch,
createNewChat createNewChat,
loadOlderMessages
} = createConversationController({ } = createConversationController({
state: conversation, state: conversation,
renderChatList: (...args) => renderChatList(...args), renderChatList: (...args) => renderChatList(...args),
@@ -185,7 +190,8 @@ const {
renderMessages: (...args) => renderMessages(...args), renderMessages: (...args) => renderMessages(...args),
updateJumpToLatest: (...args) => updateJumpToLatest(...args), updateJumpToLatest: (...args) => updateJumpToLatest(...args),
updateSendState: (...args) => updateSendState(...args), updateSendState: (...args) => updateSendState(...args),
loadSummary: (...args) => loadSummary(...args) loadSummary: (...args) => loadSummary(...args),
updateHistoryControls: () => updateHistoryControls()
}); });
const {updateSendState, resizeComposerInput, setLoadingState, stopGeneration, sendMessage} = createComposerController({ const {updateSendState, resizeComposerInput, setLoadingState, stopGeneration, sendMessage} = createComposerController({
@@ -212,6 +218,16 @@ const {updateSendState, resizeComposerInput, setLoadingState, stopGeneration, se
maybeShowPersonaNudge: (...args) => maybeShowPersonaNudge(...args) maybeShowPersonaNudge: (...args) => maybeShowPersonaNudge(...args)
}); });
const {exportCurrentChat, backupWorkspace, importWorkspaceFromFile} = createWorkspaceTransfer({
conversation, getChatById, setNotice, promptPopup, confirmPopup, setChatLoading, loadPersonas, loadChatSessions
});
const {editChatMessage, deleteChatMessage, retryLatestAssistantMessage, switchResponseVersion, switchRetryVariant, branchFromMessage} = createMessageActions({
conversation, setNotice, promptPopup, confirmPopup, setActiveChat, openCreatedChat, setLoadingState, loadSummary, renderMessages, addMessage, setMessageContent, webSearchToggle, reasoningToggle, modelSelect, messagesDiv, msgInput
});
const memoryPanel = createMemoryPanel({state: conversation});
const branchExplorer = createBranchExplorer({state: conversation, setActiveChat, setMessageContent});
function setNotice(message = "", state = "") { function setNotice(message = "", state = "") {
const notice = document.getElementById("workspaceNotice"); const notice = document.getElementById("workspaceNotice");
if (notice) { notice.textContent = state === "error" ? message : ""; notice.classList.toggle("hidden", state !== "error"); } if (notice) { notice.textContent = state === "error" ? message : ""; notice.classList.toggle("hidden", state !== "error"); }
@@ -425,255 +441,6 @@ async function saveSelectedModel() {
messagesDiv.addEventListener("scroll", updateJumpToLatest, {passive: true}); messagesDiv.addEventListener("scroll", updateJumpToLatest, {passive: true});
document.getElementById("jumpToLatest").addEventListener("click", scrollMessagesToBottom); document.getElementById("jumpToLatest").addEventListener("click", scrollMessagesToBottom);
function getMessageIndexById(messageId) {
return conversation.currentMessages.findIndex((message) => message.id === messageId);
}
async function resolveMessageTarget(messageId, fallbackIndex = -1) {
if (messageId) {
const index = getMessageIndexById(messageId);
if (index >= 0) {
return { index, message: conversation.currentMessages[index] };
}
}
if (conversation.activeChatId) {
await setActiveChat(conversation.activeChatId);
}
if (messageId) {
const index = getMessageIndexById(messageId);
if (index >= 0) {
return { index, message: conversation.currentMessages[index] };
}
}
if (fallbackIndex >= 0 && fallbackIndex < conversation.currentMessages.length) {
return { index: fallbackIndex, message: conversation.currentMessages[fallbackIndex] };
}
return null;
}
function getMessageEndpoint(target, suffix = "") {
if (!conversation.activeChatId || !target) return "";
if (target.message?.id) return `/chats/${conversation.activeChatId}/messages/${target.message.id}${suffix}`;
if (target.index >= 0) return `/chats/${conversation.activeChatId}/messages/by-index/${target.index}${suffix}`;
return "";
}
function getPreviousUserMessageId(messageId) {
const index = getMessageIndexById(messageId);
if (index <= 0) return null;
for (let i = index - 1; i >= 0; i -= 1) {
if (conversation.currentMessages[i].role === "user" && conversation.currentMessages[i].id) {
return conversation.currentMessages[i].id;
}
}
return null;
}
function getRetryStateFromMessage(message) {
const variants = Array.isArray(message?.retryVariants) && message.retryVariants.length
? [...message.retryVariants]
: [message?.content || ""];
const requestedActiveIndex = Number(message?.retryActiveIndex);
const activeIndex = Number.isInteger(requestedActiveIndex)
? Math.min(Math.max(requestedActiveIndex, 0), variants.length - 1)
: 0;
return {
variants,
activeIndex,
retriesUsed: Number(message?.retryRetriesUsed || 0),
promptMessageId: message?.retryPromptMessageId ?? getPreviousUserMessageId(message?.id)
};
}
function syncRetryStateFromMessage(message) {
if (!message?.id) return null;
const state = getRetryStateFromMessage(message);
conversation.messageRetryState.set(message.id, state);
return state;
}
async function editChatMessage(messageId, fallbackIndex = -1) {
const target = await resolveMessageTarget(messageId, fallbackIndex);
if (!target) return setNotice("Message is not ready yet. Try again.", "error");
const { index, message } = target;
const sourceChatId = conversation.activeChatId;
const nextContent = await promptPopup({
eyebrow: message.role === "user" ? "Your message" : "Assistant message",
title: "Edit message",
description: "Update this message.",
label: "Message",
value: message.content,
confirmLabel: "Save"
});
const content = (nextContent || "").trim();
if (!content) return;
if (message.role === "user") {
const confirmed = await confirmPopup({
eyebrow: "Create branch",
title: "Create an edited chat branch?",
description: "This creates a new chat from the messages before this one and sends your edited message there. The original chat stays unchanged.",
confirmLabel: "Create branch"
});
if (!confirmed || conversation.isProcessing) return;
conversation.regenerationChatId = sourceChatId;
setLoadingState(true, {
eyebrow: "Branching",
title: "Editing and resending",
detail: "Creating and opening a new chat branch."
});
try {
const res = await post(`/chats/${sourceChatId}/messages/${message.id}/edit-resend`, {
chatId: sourceChatId,
message: content,
model: modelSelect.value,
confirmed: true
}, {timeoutMs: 660000});
if (res.error) return setNotice(res.error, "error");
if (conversation.activeChatId === sourceChatId) await openCreatedChat(res.chat);
return setNotice("Created the edited chat branch.", "success");
} finally {
conversation.regenerationChatId = null;
setLoadingState(false);
}
}
const endpoint = getMessageEndpoint(target);
if (!endpoint) return setNotice("Message is not ready yet. Try again.", "error");
const res = await put(endpoint, { content });
if (res.error || !res.message) {
return setNotice(res.error || "Unable to edit message.", "error");
}
conversation.currentMessages[index] = { ...conversation.currentMessages[index], ...res.message };
syncRetryStateFromMessage(conversation.currentMessages[index]);
renderMessages();
setNotice("Message updated.", "success");
}
async function deleteChatMessage(messageId, fallbackIndex = -1) {
const target = await resolveMessageTarget(messageId, fallbackIndex);
if (!target) return setNotice("Message is not ready yet. Try again.", "error");
const { message } = target;
const confirmed = await confirmPopup({
eyebrow: message.role === "user" ? "Your message" : "Assistant message",
title: "Delete message",
description: "Delete this message from the chat?",
confirmLabel: "Delete",
danger: true
});
if (!confirmed) return;
const endpoint = getMessageEndpoint(target);
if (!endpoint) return setNotice("Message is not ready yet. Try again.", "error");
const res = await del(endpoint);
if (res.error) return setNotice(res.error, "error");
conversation.currentMessages = conversation.currentMessages.filter((item) => item.id !== message.id);
if (message.id) {
conversation.messageRetryState.delete(message.id);
for (const [key, state] of conversation.messageRetryState.entries()) {
if (state.promptMessageId === message.id) {
conversation.messageRetryState.delete(key);
}
}
}
renderMessages();
await loadSummary();
setNotice("Message deleted.", "success");
}
async function retryLatestAssistantMessage(messageId, fallbackIndex = -1) {
if (conversation.isProcessing) return;
const target = await resolveMessageTarget(messageId, fallbackIndex);
if (conversation.isProcessing) return;
if (!target) return setNotice("Message is not ready yet. Try again.", "error");
if (target.message.role !== "bot") return setNotice("Only assistant messages can be regenerated.", "error");
const retriesUsed = Math.max(0, Number(target.message.versionCount || 1) - 1);
if (retriesUsed >= 5) {
return setNotice("Retry limit reached (5).", "error");
}
const chatId = conversation.activeChatId;
const card = messagesDiv.querySelector(`[data-message-id="${target.message.id}"]`);
if (!card) return;
const payload = { model: modelSelect.value, webSearch: webSearchToggle.enabled, reasoning: reasoningToggle.enabled };
conversation.regenerationChatId = chatId;
setLoadingState(true, false);
try {
await regenerateInline({
card, scroller: messagesDiv, endpoint: `/chats/${chatId}/messages/${target.message.id}/retry/stream`, payload,
renderContent: setMessageContent,
complete: message => {
if (conversation.activeChatId !== chatId) return;
const index = getMessageIndexById(message.id);
if (index < 0) return;
conversation.currentMessages[index] = { ...conversation.currentMessages[index], ...message, thinking: message.thinking, sources: message.sources };
syncRetryStateFromMessage(conversation.currentMessages[index]);
const rendered = addMessage(conversation.currentMessages[index], false, false, { messageIndex: index, detached: true });
card.replaceChildren(...rendered.childNodes);
}
});
webSearchToggle.reset();
reasoningToggle.reset();
setNotice("Reply regenerated.", "success");
} catch (error) {
setNotice(error.message || "Unable to regenerate message.", "error");
} finally {
conversation.regenerationChatId = null;
setLoadingState(false);
}
}
async function switchResponseVersion(messageId, direction, fallbackIndex = -1) {
const target = await resolveMessageTarget(messageId, fallbackIndex);
if (!target) return setNotice("Message is not ready yet. Try again.", "error");
if (Number(target.message.versionCount) > 1) {
const chatId = conversation.activeChatId;
const card = messagesDiv.querySelector(`[data-message-id="${messageId}"]`);
if (!card || card.getAttribute("aria-busy") === "true") return;
const buttons = [...card.querySelectorAll("button")].map(button => [button, button.disabled]);
card.setAttribute("aria-busy", "true");
buttons.forEach(([button]) => { button.disabled = true; });
try {
const res = await post(`/chats/${chatId}/messages/${messageId}/versions/${target.message.versionIndex + direction}`, {});
if (res.error) return setNotice(res.error, "error");
if (conversation.activeChatId !== chatId) return;
const index = getMessageIndexById(messageId);
if (index < 0) return;
conversation.currentMessages[index] = { ...conversation.currentMessages[index], ...res.message, thinking: res.message.thinking, sources: res.message.sources };
syncRetryStateFromMessage(conversation.currentMessages[index]);
const rendered = addMessage(conversation.currentMessages[index], false, false, { messageIndex: index, detached: true });
card.replaceChildren(...rendered.childNodes);
} finally {
card.removeAttribute("aria-busy");
buttons.forEach(([button, disabled]) => { button.disabled = disabled; });
}
}
}
async function switchRetryVariant(messageId, direction, fallbackIndex = -1) {
const target = await resolveMessageTarget(messageId, fallbackIndex);
if (!target) return setNotice("Message is not ready yet. Try again.", "error");
const siblingId = direction < 0 ? target.message.previousSiblingId : target.message.nextSiblingId;
if (!siblingId) return;
const res = await post(`/chats/${conversation.activeChatId}/branches/${siblingId}/activate`, { includeDescendants: true });
if (res.error) return setNotice(res.error, "error");
conversation.currentMessages = res.messages || [];
renderMessages();
}
async function branchFromMessage(messageId) {
if (!conversation.activeChatId || !messageId) return;
const confirmed = await confirmPopup({
eyebrow: "Create branch",
title: "Create a new chat branch?",
description: "The new chat will contain the conversation through this message. The original chat stays unchanged.",
confirmLabel: "Create branch"
});
if (!confirmed) return;
const sourceChatId = conversation.activeChatId;
const res = await post(`/chats/${sourceChatId}/messages/${messageId}/branch`, { confirmed: true });
if (res.error) return setNotice(res.error, "error");
await openCreatedChat(res.chat);
msgInput.focus();
setNotice("Created and opened a new chat branch.", "success");
}
function updateWorkspaceCopy() { function updateWorkspaceCopy() {
const activeChat = getChatById(conversation.activeChatId); const activeChat = getChatById(conversation.activeChatId);
if (activeChatTitle) { if (activeChatTitle) {
@@ -758,6 +525,7 @@ function applyThemeMode(modeKey, persist = true) {
} }
function updateChatActionState() { function updateChatActionState() {
updateHistoryControls();
const activeChat = getChatById(conversation.activeChatId); const activeChat = getChatById(conversation.activeChatId);
const hasChat = Boolean(activeChat); const hasChat = Boolean(activeChat);
renameChatBtn.disabled = !hasChat || Boolean(activeChat?.assistant_persona_id); renameChatBtn.disabled = !hasChat || Boolean(activeChat?.assistant_persona_id);
@@ -767,6 +535,7 @@ function updateChatActionState() {
} }
if (moveChatFolderBtn) moveChatFolderBtn.disabled = !hasChat; if (moveChatFolderBtn) moveChatFolderBtn.disabled = !hasChat;
if (manageChatMemoryBtn) manageChatMemoryBtn.disabled = !hasChat; if (manageChatMemoryBtn) manageChatMemoryBtn.disabled = !hasChat;
document.getElementById("exploreBranches").disabled = !hasChat;
if (managePersonaStateBtn) managePersonaStateBtn.disabled = !hasChat || !activeChat?.assistant_persona_id; if (managePersonaStateBtn) managePersonaStateBtn.disabled = !hasChat || !activeChat?.assistant_persona_id;
if (archiveChatBtn) { if (archiveChatBtn) {
archiveChatBtn.disabled = !hasChat; archiveChatBtn.disabled = !hasChat;
@@ -779,6 +548,14 @@ function updateChatActionState() {
if (headerModelButton) headerModelButton.disabled = !hasChat; if (headerModelButton) headerModelButton.disabled = !hasChat;
} }
function updateHistoryControls() {
const older = document.getElementById("loadOlderMessages");
older.hidden = !conversation.historyPage?.hasMore;
older.disabled = conversation.historyLoading;
older.textContent = conversation.historyLoading ? "Loading messages..." : "Load older messages";
document.getElementById("loadLatestMessages").hidden = !conversation.historyPage?.hasNewer;
}
function openModelSettings() { function openModelSettings() {
if (!conversation.activeChatId || !modelSection) return; if (!conversation.activeChatId || !modelSection) return;
modelSection.classList.remove("hidden"); modelSection.classList.remove("hidden");
@@ -982,165 +759,10 @@ async function clearChat(id) {
const res = await post(`/chats/${id}/clear`, {}); const res = await post(`/chats/${id}/clear`, {});
if (res.error) return setNotice(res.error, "error"); if (res.error) return setNotice(res.error, "error");
conversation.messageRetryState = new Map(); conversation.messageRetryState = new Map();
conversation.historyPage = null;
conversation.currentMessages = []; renderMessages(); await loadSummary(); setNotice("Chat cleared.", "success"); conversation.currentMessages = []; renderMessages(); await loadSummary(); setNotice("Chat cleared.", "success");
} }
function exportCurrentChat() {
const chat = getChatById(conversation.activeChatId);
if (!chat || !conversation.currentMessages.length) return;
void (async () => {
const format = await promptPopup({
eyebrow: "Export",
title: "Export format",
description: "Use txt, md, or json.",
label: "Format",
value: "md",
placeholder: "md",
confirmLabel: "Download"
});
const normalized = ["txt", "md", "json"].includes(String(format || "").trim().toLowerCase())
? String(format).trim().toLowerCase()
: "md";
const response = await fetch(`/exports/chats/${conversation.activeChatId}?format=${normalized}`);
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${(chat.title.replace(/[^a-z0-9-_]+/gi, "-").replace(/^-+|-+$/g, "") || "chat")}.${normalized}`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
setNotice(`Chat exported as ${normalized}.`, "success");
})();
}
async function backupWorkspace() {
const response = await fetch("/exports/workspace/archive");
if (!response.ok) return setNotice("Workspace archive could not be created.", "error");
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `workspace-backup-${new Date().toISOString().slice(0, 10)}.tar.gz`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
setNotice("Workspace archive downloaded with images.", "success");
}
function formatImportPreview(preview, warnings = []) {
const counts = [
`${preview.personas || 0} persona${preview.personas === 1 ? "" : "s"}`,
`${preview.chats || 0} chat${preview.chats === 1 ? "" : "s"}`,
`${preview.messages || 0} message${preview.messages === 1 ? "" : "s"}`,
`${preview.templates || 0} template${preview.templates === 1 ? "" : "s"}`
].join(", ");
const warningText = warnings.length ? ` Warnings: ${warnings.slice(0, 4).join("; ")}${warnings.length > 4 ? "; ..." : ""}` : "";
return `Import preview: ${counts}.${warningText}`;
}
async function importWorkspaceFromFile(file) {
if (!file) return;
setChatLoading(true, "Reading workspace backup...");
const isArchive = file.name.toLowerCase().endsWith(".tar.gz");
if (isArchive) {
const submit = async (path, allowDuplicates = false) => {
const body = new FormData();
body.append("archive", file);
if (allowDuplicates) body.append("allowDuplicates", "true");
const response = await fetch(path, {method: "POST", body});
return response.json();
};
try {
const preview = await submit("/imports/workspace/archive/preview");
setChatLoading(false);
if (preview.error || preview.ok === false) return setNotice((preview.errors || []).slice(0, 4).join("; ") || preview.error || "Invalid archive.", "error");
const confirmed = await confirmPopup({eyebrow: "Workspace import", title: "Review import",
description: formatImportPreview(preview.preview || {}, preview.warnings || []), confirmLabel: "Import"});
if (!confirmed) return;
setChatLoading(true, "Importing workspace and images...");
const result = await submit("/imports/workspace/archive", Boolean(preview.preview?.duplicates?.length));
if (result.error) return setNotice(result.error, "error");
await Promise.all([loadPersonas(), loadChatSessions()]);
setNotice(`Workspace imported: ${result.imported.chats} chats with their images.`, "success");
} catch { setNotice("Workspace archive could not be imported.", "error"); }
finally { setChatLoading(false); }
return;
}
let workspace;
try {
workspace = JSON.parse(await file.text());
} catch {
setChatLoading(false);
return setNotice("Import failed: choose a valid JSON workspace backup.", "error");
}
const preview = await post("/imports/workspace/preview", { workspace });
if (preview.error || preview.ok === false) {
setChatLoading(false);
const detail = (preview.errors || []).slice(0, 4).join("; ");
return setNotice(detail || preview.error || "Workspace import validation failed.", "error");
}
setChatLoading(false);
const confirmed = await confirmPopup({
eyebrow: "Workspace import",
title: "Review import",
description: formatImportPreview(preview.preview || {}, preview.warnings || []),
confirmLabel: "Import"
});
if (!confirmed) return;
setChatLoading(true, "Importing workspace...");
const imported = await post("/imports/workspace", {
workspace,
allowDuplicates: Boolean(preview.preview?.duplicates?.length)
});
if (imported.error) {
setChatLoading(false);
const detail = (imported.errors || imported.warnings || []).slice(0, 4).join("; ");
return setNotice(detail || imported.error, "error");
}
await Promise.all([loadPersonas(), loadChatSessions()]);
setChatLoading(false);
setNotice(`Workspace imported: ${imported.imported.personas} personas, ${imported.imported.chats} chats, ${imported.imported.templates} templates.`, "success");
}
function formatMemoryList(memories) {
if (!memories.length) return "No memory facts saved for this chat yet.";
return memories.map((memory, index) => `${index + 1}. ${memory.fact}`).join("\n");
}
async function manageChatMemory() {
if (!conversation.activeChatId) return;
const loaded = await get(`/chats/${conversation.activeChatId}/memories`);
if (loaded.error) return setNotice(loaded.error, "error");
const memories = loaded.memories || [];
const action = await promptPopup({
eyebrow: "Memory",
title: "Memory facts",
description: `${formatMemoryList(memories)}\n\nType a new fact to add it, or type delete 1 to remove a fact.`,
label: "Fact or command",
placeholder: "The user prefers terse answers.",
confirmLabel: "Save"
});
const value = String(action || "").trim();
if (!value) return;
const deleteMatch = value.match(/^delete\s+(\d+)$/i);
if (deleteMatch) {
const target = memories[Number(deleteMatch[1]) - 1];
if (!target) return setNotice("Memory number not found.", "error");
const removed = await del(`/chats/${conversation.activeChatId}/memories/${target.id}`);
if (removed.error) return setNotice(removed.error, "error");
return setNotice("Memory fact deleted.", "success");
}
const saved = await post(`/chats/${conversation.activeChatId}/memories`, { fact: value });
if (saved.error) return setNotice(saved.error, "error");
return setNotice("Memory fact saved.", "success");
}
const personaStateFields = [ const personaStateFields = [
["relationship_notes", "Relationship notes"], ["relationship_notes", "Relationship notes"],
["tone", "Tone"], ["tone", "Tone"],
@@ -1256,10 +878,18 @@ async function checkSession() {
const res = await get("/session"); const res = await get("/session");
if (!res.user) { window.location.replace(`/login?next=${encodeURIComponent(location.pathname + location.search)}`); return false; } if (!res.user) { window.location.replace(`/login?next=${encodeURIComponent(location.pathname + location.search)}`); return false; }
conversation.currentUsername = res.user; chatDiv.classList.remove("hidden"); conversation.currentUsername = res.user; chatDiv.classList.remove("hidden");
const drafts = createDraftStore(res.user, {onError: message => setNotice(message, "error")});
conversation.chatDrafts = drafts.textDrafts;
attachmentComposer.setDraftStore(drafts);
if (location.pathname.endsWith("/new")) {
msgInput.value = drafts.textDrafts.get("new") || "";
attachmentComposer.restoreDraft(null);
resizeComposerInput();
}
await Promise.all([displayModels(), loadSummary(), loadChatSessions(), loadPersonas()]); await Promise.all([displayModels(), loadSummary(), loadChatSessions(), loadPersonas()]);
// Starters are offered on workspace home. // Starters are offered on workspace home.
const starter = new URLSearchParams(location.search).get("prompt"); const starter = new URLSearchParams(location.search).get("prompt");
if (starter) { msgInput.value = starter; resizeComposerInput(); updateSendState(); } if (starter && !msgInput.value) { msgInput.value = starter; msgInput.dispatchEvent(new Event("input")); }
msgInput.focus(); msgInput.focus();
if (!starter && !conversation.activeChatId) maybeShowOnboarding(); if (!starter && !conversation.activeChatId) maybeShowOnboarding();
return true; return true;
@@ -1291,8 +921,9 @@ const chatActions = {
await updateChatOrganization(chat.id, { folderName: String(folderName || "").trim() || null }); await updateChatOrganization(chat.id, { folderName: String(folderName || "").trim() || null });
}, },
memory: async () => { memory: async () => {
await manageChatMemory(); await memoryPanel.open();
}, },
branches: () => branchExplorer.open(),
personaState: async () => { personaState: async () => {
await managePersonaState(); await managePersonaState();
}, },
@@ -1318,9 +949,12 @@ function bindChatDrawerActions() {
sendBtn.addEventListener("click", () => { void (conversation.isProcessing ? stopGeneration() : sendMessage()); }); sendBtn.addEventListener("click", () => { void (conversation.isProcessing ? stopGeneration() : sendMessage()); });
msgInput.addEventListener("keydown", (event) => { if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { event.preventDefault(); void sendMessage(); } }); msgInput.addEventListener("keydown", (event) => { if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { event.preventDefault(); void sendMessage(); } });
msgInput.addEventListener("input", function () { msgInput.addEventListener("input", function () {
if (conversation.currentUsername) conversation.chatDrafts.set(conversation.activeChatId ?? "new", msgInput.value);
resizeComposerInput(); resizeComposerInput();
updateSendState(); updateSendState();
}); });
document.getElementById("loadOlderMessages").addEventListener("click", () => { void loadOlderMessages(); });
document.getElementById("loadLatestMessages").addEventListener("click", () => { void setActiveChat(conversation.activeChatId); });
resizeComposerInput(); resizeComposerInput();
newChatBtn.addEventListener("click", () => { newChatBtn.addEventListener("click", () => {
closeChatDrawer(); closeChatDrawer();
@@ -1458,6 +1092,7 @@ document.addEventListener("keydown", (event) => {
else if (activeModal === personaModal) closePersonaForm(); else if (activeModal === personaModal) closePersonaForm();
else if (activeModal === roleplayStarterModal) closeRoleplayStarter(); else if (activeModal === roleplayStarterModal) closeRoleplayStarter();
else if (activeModal === onboardingModal) closeOnboarding(); else if (activeModal === onboardingModal) closeOnboarding();
else closeModal(activeModal);
return; return;
} }
const openMenu = document.querySelector(".msg-action-menu[open]"); const openMenu = document.querySelector(".msg-action-menu[open]");
+127
View File
@@ -0,0 +1,127 @@
import {download, multipart, post} from "./api.js";
export function createWorkspaceTransfer({conversation, getChatById, setNotice, promptPopup, confirmPopup, setChatLoading, loadPersonas, loadChatSessions}) {
async function exportCurrentChat() {
const chat = getChatById(conversation.activeChatId);
if (!chat || !conversation.currentMessages.length) return;
const format = await promptPopup({
eyebrow: "Export",
title: "Export format",
description: "Use txt, md, or json.",
label: "Format",
value: "md",
placeholder: "md",
confirmLabel: "Download"
});
if (format === null) return;
const normalized = ["txt", "md", "json"].includes(String(format || "").trim().toLowerCase())
? String(format).trim().toLowerCase()
: "md";
const response = await download(`/exports/chats/${chat.id}?format=${normalized}`);
if (response.error) return setNotice(response.error, "error");
const {blob} = response;
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${(chat.title.replace(/[^a-z0-9-_]+/gi, "-").replace(/^-+|-+$/g, "") || "chat")}.${normalized}`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
setNotice(`Chat exported as ${normalized}.`, "success");
}
async function backupWorkspace() {
const response = await download("/exports/workspace/archive");
if (response.error) return setNotice(response.error, "error");
const {blob} = response;
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `workspace-backup-${new Date().toISOString().slice(0, 10)}.tar.gz`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
setNotice("Workspace archive downloaded with images.", "success");
}
function formatImportPreview(preview, warnings = []) {
const counts = [
`${preview.personas || 0} persona${preview.personas === 1 ? "" : "s"}`,
`${preview.chats || 0} chat${preview.chats === 1 ? "" : "s"}`,
`${preview.messages || 0} message${preview.messages === 1 ? "" : "s"}`,
`${preview.templates || 0} template${preview.templates === 1 ? "" : "s"}`
].join(", ");
const warningText = warnings.length ? ` Warnings: ${warnings.slice(0, 4).join("; ")}${warnings.length > 4 ? "; ..." : ""}` : "";
return `Import preview: ${counts}.${warningText}`;
}
async function importWorkspaceFromFile(file) {
if (!file) return;
setChatLoading(true, "Reading workspace backup...");
const isArchive = file.name.toLowerCase().endsWith(".tar.gz");
if (isArchive) {
const submit = async (path, allowDuplicates = false) => {
const body = new FormData();
body.append("archive", file);
if (allowDuplicates) body.append("allowDuplicates", "true");
return multipart(path, body);
};
try {
const preview = await submit("/imports/workspace/archive/preview");
setChatLoading(false);
if (preview.error || preview.ok === false) return setNotice((preview.errors || []).slice(0, 4).join("; ") || preview.error || "Invalid archive.", "error");
const confirmed = await confirmPopup({eyebrow: "Workspace import", title: "Review import",
description: formatImportPreview(preview.preview || {}, preview.warnings || []), confirmLabel: "Import"});
if (!confirmed) return;
setChatLoading(true, "Importing workspace and images...");
const result = await submit("/imports/workspace/archive", Boolean(preview.preview?.duplicates?.length));
if (result.error) return setNotice(result.error, "error");
await Promise.all([loadPersonas(), loadChatSessions()]);
setNotice(`Workspace imported: ${result.imported.chats} chats with their images.`, "success");
} catch { setNotice("Workspace archive could not be imported.", "error"); }
finally { setChatLoading(false); }
return;
}
let workspace;
try {
workspace = JSON.parse(await file.text());
} catch {
setChatLoading(false);
return setNotice("Import failed: choose a valid JSON workspace backup.", "error");
}
const preview = await post("/imports/workspace/preview", { workspace });
if (preview.error || preview.ok === false) {
setChatLoading(false);
const detail = (preview.errors || []).slice(0, 4).join("; ");
return setNotice(detail || preview.error || "Workspace import validation failed.", "error");
}
setChatLoading(false);
const confirmed = await confirmPopup({
eyebrow: "Workspace import",
title: "Review import",
description: formatImportPreview(preview.preview || {}, preview.warnings || []),
confirmLabel: "Import"
});
if (!confirmed) return;
setChatLoading(true, "Importing workspace...");
const imported = await post("/imports/workspace", {
workspace,
allowDuplicates: Boolean(preview.preview?.duplicates?.length)
});
if (imported.error) {
setChatLoading(false);
const detail = (imported.errors || imported.warnings || []).slice(0, 4).join("; ");
return setNotice(detail || imported.error, "error");
}
await Promise.all([loadPersonas(), loadChatSessions()]);
setChatLoading(false);
setNotice(`Workspace imported: ${imported.imported.personas} personas, ${imported.imported.chats} chats, ${imported.imported.templates} templates.`, "success");
}
return {exportCurrentChat, backupWorkspace, importWorkspaceFromFile};
}
+2 -1
View File
@@ -19,7 +19,7 @@ import {createChatsRouter} from "./routes/chats.js";
import {createPagesRouter} from "./routes/pages.js"; import {createPagesRouter} from "./routes/pages.js";
import {createPersonasRouter} from "./routes/personas.js"; import {createPersonasRouter} from "./routes/personas.js";
import {createSettingsRouter} from "./routes/settings.js"; import {createSettingsRouter} from "./routes/settings.js";
import {createSystemRouter} from "./routes/system.js"; import {createHealthRouter, createSystemRouter} from "./routes/system.js";
import {createAttachmentService} from "./services/attachmentService.js"; import {createAttachmentService} from "./services/attachmentService.js";
import {createUploadsRouter} from "./routes/uploads.js"; import {createUploadsRouter} from "./routes/uploads.js";
import {createVisionDebugService} from "./services/visionDebugService.js"; import {createVisionDebugService} from "./services/visionDebugService.js";
@@ -271,6 +271,7 @@ export function createApp(options = {}) {
); );
app.use(observability.middleware); app.use(observability.middleware);
app.use(createHealthRouter({modelService}));
app.use(express.json({limit: "1mb"})); app.use(express.json({limit: "1mb"}));
app.use(session({ app.use(session({
store: options.sessionStore || (config.testMode ? undefined : new FileStore({path: config.sessionsDir, reapInterval: 3600})), store: options.sessionStore || (config.testMode ? undefined : new FileStore({path: config.sessionsDir, reapInterval: 3600})),
+80
View File
@@ -0,0 +1,80 @@
export function createAccountStatements(db) {
return {
insertUser: db.prepare("INSERT INTO users (username, password) VALUES (?, ?)"),
getUser: db.prepare("SELECT username, password, role, session_version FROM users WHERE username = ?"),
updateUsername: db.prepare("UPDATE users SET username = ? WHERE username = ?"),
updatePassword: db.prepare("UPDATE users SET password = ? WHERE username = ?"),
incrementSessionVersion: db.prepare("UPDATE users SET session_version = COALESCE(session_version, 0) + 1 WHERE username = ?"),
updateChatSessionsUsername: db.prepare("UPDATE chat_sessions SET username = ? WHERE username = ?"),
updatePersonasUsername: db.prepare("UPDATE personas SET username = ? WHERE username = ?"),
updatePersonasSourceCreator: db.prepare("UPDATE personas SET source_creator_username = ? WHERE source_creator_username = ?"),
updateUserSettingsUsername: db.prepare("UPDATE user_settings SET username = ? WHERE username = ?"),
updatePersonaMarketCreator: db.prepare("UPDATE persona_market SET creator_username = ? WHERE creator_username = ?"),
updatePersonaVersionsUsername: db.prepare("UPDATE persona_versions SET username = ? WHERE username = ?"),
updatePromptTemplatesUsername: db.prepare("UPDATE prompt_templates SET username = ? WHERE username = ?"),
updateChatMemoriesUsername: db.prepare("UPDATE chat_memories SET username = ? WHERE username = ?"),
updatePersonaChatStateUsername: db.prepare("UPDATE persona_chat_state SET username = ? WHERE username = ?"),
updateMarketFavoritesUsername: db.prepare("UPDATE persona_market_favorites SET username = ? WHERE username = ?"),
updateMarketRatingsUsername: db.prepare("UPDATE persona_market_ratings SET username = ? WHERE username = ?"),
updateMarketReportsUsername: db.prepare("UPDATE persona_market_reports SET reporter_username = ? WHERE reporter_username = ?"),
deleteChatMemoriesByUser: db.prepare("DELETE FROM chat_memories WHERE username = ?"),
deleteChatSessionsByUser: db.prepare("DELETE FROM chat_sessions WHERE username = ?"),
deletePersonasByUser: db.prepare("DELETE FROM personas WHERE username = ?"),
deleteUserSettingsByUser: db.prepare("DELETE FROM user_settings WHERE username = ?"),
deletePersonaVersionsByUser: db.prepare("DELETE FROM persona_versions WHERE username = ?"),
deletePromptTemplatesByUser: db.prepare("DELETE FROM prompt_templates WHERE username = ?"),
deleteMarketPersonasByCreator: db.prepare("DELETE FROM persona_market WHERE creator_username = ?"),
deleteMarketFavoritesByUser: db.prepare("DELETE FROM persona_market_favorites WHERE username = ?"),
deleteMarketRatingsByUser: db.prepare("DELETE FROM persona_market_ratings WHERE username = ?"),
deleteMarketReportsByUser: db.prepare("DELETE FROM persona_market_reports WHERE reporter_username = ?"),
deletePersonaChatStateByUser: db.prepare("DELETE FROM persona_chat_state WHERE username = ?"),
deleteUser: db.prepare("DELETE FROM users WHERE username = ?")
};
}
export function createAccountRepository(db, statements) {
const renameUserTransaction = db.transaction((currentUsername, nextUsername) => {
// Existing username references do not all cascade on update. Check them at commit,
// after every owned row has been moved to the new username.
db.pragma("defer_foreign_keys = ON");
statements.updateUsername.run(nextUsername, currentUsername);
statements.updateChatSessionsUsername.run(nextUsername, currentUsername);
statements.updatePersonasUsername.run(nextUsername, currentUsername);
statements.updatePersonasSourceCreator.run(nextUsername, currentUsername);
statements.updateUserSettingsUsername.run(nextUsername, currentUsername);
statements.updatePersonaMarketCreator.run(nextUsername, currentUsername);
statements.updatePersonaVersionsUsername.run(nextUsername, currentUsername);
statements.updatePromptTemplatesUsername.run(nextUsername, currentUsername);
statements.updateChatMemoriesUsername.run(nextUsername, currentUsername);
statements.updatePersonaChatStateUsername.run(nextUsername, currentUsername);
statements.updateMarketFavoritesUsername.run(nextUsername, currentUsername);
statements.updateMarketRatingsUsername.run(nextUsername, currentUsername);
statements.updateMarketReportsUsername.run(nextUsername, currentUsername);
});
const deleteUserTransaction = db.transaction((username) => {
statements.deleteMarketFavoritesByUser.run(username);
statements.deleteMarketRatingsByUser.run(username);
statements.deleteMarketReportsByUser.run(username);
statements.deleteMarketPersonasByCreator.run(username);
statements.deletePromptTemplatesByUser.run(username);
statements.deletePersonaVersionsByUser.run(username);
statements.deleteChatMemoriesByUser.run(username);
statements.deletePersonaChatStateByUser.run(username);
statements.deleteChatSessionsByUser.run(username);
statements.deleteUserSettingsByUser.run(username);
statements.deletePersonasByUser.run(username);
return statements.deleteUser.run(username);
});
return {
getUser: (username) => statements.getUser.get(username),
insertUser: (username, passwordHash) => statements.insertUser.run(username, passwordHash),
updatePassword: (passwordHash, username) => statements.updatePassword.run(passwordHash, username),
incrementSessionVersion(username) {
statements.incrementSessionVersion.run(username);
return statements.getUser.get(username);
},
renameUser: (currentUsername, nextUsername) => renameUserTransaction(currentUsername, nextUsername),
deleteUser: (username) => deleteUserTransaction(username)
};
}
+559
View File
@@ -0,0 +1,559 @@
export function createChatStatements(db) {
return {
insertChatSession: db.prepare(
"INSERT INTO chat_sessions (username, title, assistant_persona_id, user_persona_id, folder_name, is_pinned, archived_at, scenario_prompt, scenario_summary) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
),
listChatSessions: db.prepare(`
SELECT cs.id,
CASE
WHEN cs.assistant_persona_id IS NOT NULL AND cs.source_chat_id IS NULL THEN COALESCE(p.name, cs.title)
ELSE cs.title
END AS title,
cs.title AS stored_title,
cs.assistant_persona_id,
p.name AS assistant_persona_name,
cs.user_persona_id,
up.name AS user_persona_name,
cs.folder_name,
cs.is_pinned,
cs.archived_at,
cs.scenario_prompt,
cs.scenario_summary,
cs.context_summary,
cs.context_summary_message_id,
cs.active_leaf_message_id,
cs.source_chat_id,
cs.branched_from_message_id,
cs.preferred_model,
cs.temperature,
cs.context_length,
cs.response_length,
cs.system_instruction,
cs.created_at,
cs.updated_at
FROM chat_sessions cs
LEFT JOIN personas p ON p.id = cs.assistant_persona_id
LEFT JOIN personas up ON up.id = cs.user_persona_id
WHERE cs.username = ?
ORDER BY cs.is_pinned DESC, datetime(cs.updated_at) DESC
`),
countChatSessions: db.prepare("SELECT COUNT(*) as count FROM chat_sessions WHERE username = ? AND archived_at IS NULL"),
getChatSession: db.prepare(`
SELECT cs.id,
CASE
WHEN cs.assistant_persona_id IS NOT NULL AND cs.source_chat_id IS NULL THEN COALESCE(p.name, cs.title)
ELSE cs.title
END AS title,
cs.title AS stored_title,
cs.assistant_persona_id,
p.name AS assistant_persona_name,
cs.user_persona_id,
up.name AS user_persona_name,
cs.folder_name,
cs.is_pinned,
cs.archived_at,
cs.scenario_prompt,
cs.scenario_summary,
cs.context_summary,
cs.context_summary_message_id,
cs.active_leaf_message_id,
cs.source_chat_id,
cs.branched_from_message_id,
cs.preferred_model,
cs.temperature,
cs.context_length,
cs.response_length,
cs.system_instruction,
cs.created_at,
cs.updated_at
FROM chat_sessions cs
LEFT JOIN personas p ON p.id = cs.assistant_persona_id
LEFT JOIN personas up ON up.id = cs.user_persona_id
WHERE cs.id = ?
AND cs.username = ?
`),
getChatSessionByParticipants: db.prepare(`
SELECT cs.id,
CASE
WHEN cs.assistant_persona_id IS NOT NULL AND cs.source_chat_id IS NULL THEN COALESCE(p.name, cs.title)
ELSE cs.title
END AS title,
cs.title AS stored_title,
cs.assistant_persona_id,
p.name AS assistant_persona_name,
cs.user_persona_id,
up.name AS user_persona_name,
cs.folder_name,
cs.is_pinned,
cs.archived_at,
cs.scenario_prompt,
cs.scenario_summary,
cs.context_summary,
cs.context_summary_message_id,
cs.active_leaf_message_id,
cs.source_chat_id,
cs.branched_from_message_id,
cs.preferred_model,
cs.temperature,
cs.context_length,
cs.response_length,
cs.system_instruction,
cs.created_at,
cs.updated_at
FROM chat_sessions cs
LEFT JOIN personas p ON p.id = cs.assistant_persona_id
LEFT JOIN personas up ON up.id = cs.user_persona_id
WHERE cs.username = ?
AND cs.assistant_persona_id = ?
AND ((cs.user_persona_id IS NULL AND ? IS NULL) OR cs.user_persona_id = ?)
AND cs.source_chat_id IS NULL
ORDER BY cs.is_pinned DESC, datetime(cs.updated_at) DESC
LIMIT 1
`),
updateChatSessionTitle: db.prepare("UPDATE chat_sessions SET title = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?"),
updateChatSessionScene: db.prepare("UPDATE chat_sessions SET scenario_prompt = ?, scenario_summary = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?"),
updateChatContextSummary: db.prepare("UPDATE chat_sessions SET context_summary = ?, context_summary_message_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?"),
updateChatGenerationSettings: db.prepare(`
UPDATE chat_sessions
SET preferred_model = ?, temperature = ?, context_length = ?, response_length = ?, system_instruction = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND username = ?
`),
updateChatPreferredModel: db.prepare("UPDATE chat_sessions SET preferred_model = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?"),
updateChatActiveLeaf: db.prepare("UPDATE chat_sessions SET active_leaf_message_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?"),
updateChatBranchSource: db.prepare("UPDATE chat_sessions SET source_chat_id = ?, branched_from_message_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?"),
setChatActiveLeafById: db.prepare("UPDATE chat_sessions SET active_leaf_message_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"),
updateChatSessionOrganization: db.prepare(`
UPDATE chat_sessions
SET folder_name = ?,
is_pinned = ?,
archived_at = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
AND username = ?
`),
touchChatSession: db.prepare("UPDATE chat_sessions SET updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?"),
deleteChatSession: db.prepare("DELETE FROM chat_sessions WHERE id = ? AND username = ?"),
insertChatMessage: db.prepare("INSERT INTO chat_messages (chat_id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id, delivery_status, error_message, parent_message_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"),
updateChatMessageDelivery: db.prepare("UPDATE chat_messages SET delivery_status = ?, error_message = ? WHERE id = ? AND chat_id = ?"),
updateChatMessageSources: db.prepare("UPDATE chat_messages SET sources = ? WHERE id = ? AND chat_id = ?"),
updateChatMessageThinking: db.prepare("UPDATE chat_messages SET thinking = ? WHERE id = ? AND chat_id = ?"),
getChatMessages: db.prepare(`
SELECT thinking, sources, id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id, delivery_status, error_message, parent_message_id, created_at
FROM chat_messages
WHERE chat_id = ?
ORDER BY id
`),
getRecentChatMessages: db.prepare(`
WITH RECURSIVE branch AS (
SELECT cm.* FROM chat_messages cm
JOIN chat_sessions cs ON cs.id = cm.chat_id
WHERE cm.chat_id = ?
AND cm.id = COALESCE(cs.active_leaf_message_id, (SELECT MAX(id) FROM chat_messages WHERE chat_id = cm.chat_id))
UNION ALL
SELECT parent.* FROM chat_messages parent
JOIN branch child ON child.parent_message_id = parent.id
)
SELECT id, role, content FROM branch
WHERE delivery_status != 'failed'
ORDER BY id DESC
LIMIT ?
`),
getRecentChatMessagesUpToId: db.prepare(`
WITH RECURSIVE branch AS (
SELECT * FROM chat_messages WHERE chat_id = ? AND id = ?
UNION ALL
SELECT parent.* FROM chat_messages parent
JOIN branch child ON child.parent_message_id = parent.id
)
SELECT id, role, content FROM branch
WHERE delivery_status != 'failed'
ORDER BY id DESC
LIMIT ?
`),
getChatBranchThroughMessage: db.prepare(`
WITH RECURSIVE branch AS (
SELECT * FROM chat_messages WHERE chat_id = ? AND id = ?
UNION ALL
SELECT parent.* FROM chat_messages parent
JOIN branch child ON child.parent_message_id = parent.id
)
SELECT id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used,
retry_prompt_message_id, delivery_status, error_message, parent_message_id, created_at, sources, thinking
FROM branch
WHERE delivery_status = 'complete'
ORDER BY id
`),
getActiveBranchMessages: db.prepare(`
WITH RECURSIVE branch AS (
SELECT cm.* FROM chat_messages cm
JOIN chat_sessions cs ON cs.id = cm.chat_id
WHERE cm.chat_id = ?
AND cm.id = COALESCE(cs.active_leaf_message_id, (SELECT MAX(id) FROM chat_messages WHERE chat_id = cm.chat_id))
UNION ALL
SELECT parent.* FROM chat_messages parent
JOIN branch child ON child.parent_message_id = parent.id
)
SELECT id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used,
retry_prompt_message_id, delivery_status, error_message, parent_message_id, created_at, sources, thinking
FROM branch ORDER BY id
`),
getSiblingChatMessages: db.prepare(`
SELECT id, role, content, parent_message_id
FROM chat_messages
WHERE chat_id = ? AND role = ? AND parent_message_id IS ?
ORDER BY id
`),
getLatestDescendantMessage: db.prepare(`
WITH RECURSIVE descendants AS (
SELECT id FROM chat_messages WHERE chat_id = ? AND id = ?
UNION ALL
SELECT child.id FROM chat_messages child JOIN descendants parent ON child.parent_message_id = parent.id
WHERE child.chat_id = ? AND child.delivery_status != 'failed'
)
SELECT cm.* FROM chat_messages cm JOIN descendants d ON d.id = cm.id ORDER BY cm.id DESC LIMIT 1
`),
getChatMessageById: db.prepare(`
SELECT thinking, sources, id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id, delivery_status, error_message, parent_message_id, created_at
FROM chat_messages
WHERE id = ?
AND chat_id = ?
`),
getChatMessageByIndex: db.prepare(`
SELECT thinking, sources, id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id, delivery_status, error_message, parent_message_id, created_at
FROM chat_messages
WHERE chat_id = ?
ORDER BY id
LIMIT 1 OFFSET ?
`),
getLatestChatMessage: db.prepare(`
SELECT id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id, delivery_status, error_message, parent_message_id, created_at
FROM chat_messages
WHERE chat_id = ?
ORDER BY id DESC
LIMIT 1
`),
getPreviousUserMessage: db.prepare(`
SELECT id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id, delivery_status, error_message, parent_message_id, created_at
FROM chat_messages
WHERE chat_id = ?
AND role = 'user'
AND id < ?
ORDER BY id DESC
LIMIT 1
`),
listChatMemories: db.prepare(`
SELECT id, username, chat_id, assistant_persona_id, user_persona_id, fact, created_at, updated_at
FROM chat_memories
WHERE chat_id = ?
AND username = ?
ORDER BY datetime(updated_at) DESC, id DESC
`),
getChatMemory: db.prepare(`
SELECT id, username, chat_id, assistant_persona_id, user_persona_id, fact, created_at, updated_at
FROM chat_memories
WHERE id = ?
AND chat_id = ?
AND username = ?
`),
insertChatMemory: db.prepare(`
INSERT INTO chat_memories (username, chat_id, assistant_persona_id, user_persona_id, fact)
VALUES (?, ?, ?, ?, ?)
`),
updateChatMemory: db.prepare(`
UPDATE chat_memories
SET fact = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
AND chat_id = ?
AND username = ?
`),
deleteChatMemory: db.prepare("DELETE FROM chat_memories WHERE id = ? AND chat_id = ? AND username = ?"),
getPersonaChatState: db.prepare(`
SELECT id,
username,
chat_id,
assistant_persona_id,
user_persona_id,
relationship_notes,
tone,
current_location,
goals,
unresolved_threads,
boundaries,
created_at,
updated_at
FROM persona_chat_state
WHERE chat_id = ?
AND username = ?
`),
upsertPersonaChatState: db.prepare(`
INSERT INTO persona_chat_state (
username,
chat_id,
assistant_persona_id,
user_persona_id,
relationship_notes,
tone,
current_location,
goals,
unresolved_threads,
boundaries
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(chat_id) DO UPDATE SET
username = excluded.username,
assistant_persona_id = excluded.assistant_persona_id,
user_persona_id = excluded.user_persona_id,
relationship_notes = excluded.relationship_notes,
tone = excluded.tone,
current_location = excluded.current_location,
goals = excluded.goals,
unresolved_threads = excluded.unresolved_threads,
boundaries = excluded.boundaries,
updated_at = CURRENT_TIMESTAMP
`),
updateChatMessage: db.prepare("UPDATE chat_messages SET content = ? WHERE id = ? AND chat_id = ?"),
updateChatMessageWithRetryState: db.prepare("UPDATE chat_messages SET content = ?, retry_variants = ?, retry_active_index = ?, retry_retries_used = ?, retry_prompt_message_id = ? WHERE id = ? AND chat_id = ?"),
deleteChatMessage: db.prepare("DELETE FROM chat_messages WHERE id = ? AND chat_id = ?"),
deleteChatMessages: db.prepare("DELETE FROM chat_messages WHERE chat_id = ?"),
searchChats: db.prepare(`
SELECT cs.id,
CASE
WHEN cs.assistant_persona_id IS NOT NULL AND cs.source_chat_id IS NULL THEN COALESCE(p.name, cs.title)
ELSE cs.title
END AS title,
cs.title AS stored_title,
cs.assistant_persona_id,
p.name AS assistant_persona_name,
cs.user_persona_id,
up.name AS user_persona_name,
cs.folder_name,
cs.is_pinned,
cs.archived_at,
cs.scenario_prompt,
cs.scenario_summary,
cs.context_summary,
cs.context_summary_message_id,
cs.active_leaf_message_id,
cs.source_chat_id,
cs.branched_from_message_id,
cs.preferred_model,
cs.temperature,
cs.context_length,
cs.response_length,
cs.system_instruction,
cs.created_at,
cs.updated_at,
cm.id AS matched_message_id,
cm.content AS matched_content
FROM chat_sessions cs
LEFT JOIN personas p ON p.id = cs.assistant_persona_id
LEFT JOIN personas up ON up.id = cs.user_persona_id
LEFT JOIN chat_messages cm ON cm.id = (
SELECT m.id FROM chat_messages m
WHERE m.chat_id = cs.id AND LOWER(m.content) LIKE ?
ORDER BY m.id DESC LIMIT 1
)
WHERE cs.username = ?
AND (
LOWER(CASE
WHEN cs.assistant_persona_id IS NOT NULL AND cs.source_chat_id IS NULL THEN COALESCE(p.name, cs.title)
ELSE cs.title
END) LIKE ?
OR cm.id IS NOT NULL
)
ORDER BY cs.is_pinned DESC, datetime(cs.updated_at) DESC
`)
};
}
export function createChatRepository(db, config, statements) {
const completeChatTurnTransaction = db.transaction((chatId, userMessageId, assistantContent, retryState, modelName, sources, thinking) => {
const assistantResult = statements.insertChatMessage.run(
chatId,
"bot",
String(assistantContent || ""),
modelName || null,
JSON.stringify(retryState.retryVariants),
retryState.retryActiveIndex,
retryState.retryRetriesUsed,
retryState.retryPromptMessageId ?? null,
"complete",
null,
userMessageId
);
statements.updateChatMessageDelivery.run("complete", null, userMessageId, chatId);
if (sources) statements.updateChatMessageSources.run(JSON.stringify(sources), assistantResult.lastInsertRowid, chatId);
if (thinking) statements.updateChatMessageThinking.run(thinking, assistantResult.lastInsertRowid, chatId);
statements.setChatActiveLeafById.run(assistantResult.lastInsertRowid, chatId);
return assistantResult;
});
const insertBranchChatMessageTransaction = db.transaction((chatId, role, content, retryState, modelName, deliveryStatus, errorMessage, parentMessageId) => {
const result = statements.insertChatMessage.run(
chatId, role, String(content || ""), modelName || null,
retryState ? JSON.stringify(retryState.retryVariants) : null,
retryState?.retryActiveIndex ?? 0,
retryState?.retryRetriesUsed ?? 0,
retryState?.retryPromptMessageId ?? null,
deliveryStatus,
errorMessage,
parentMessageId ?? null
);
statements.setChatActiveLeafById.run(result.lastInsertRowid, chatId);
return result;
});
const failChatTurnTransaction = db.transaction((chatId, userMessageId, errorMessage, parentMessageId) => {
statements.updateChatMessageDelivery.run("failed", errorMessage, userMessageId, chatId);
statements.setChatActiveLeafById.run(parentMessageId ?? null, chatId);
});
return {
listChats: (username) => statements.listChatSessions.all(username),
searchChats(username, query) {
const like = `%${String(query || "").trim().toLowerCase()}%`;
return statements.searchChats.all(like, username, like);
},
countChats: (username) => statements.countChatSessions.get(username).count,
getChat: (chatId, username) => statements.getChatSession.get(chatId, username),
getChatByParticipants: (username, assistantPersonaId, userPersonaId) => statements.getChatSessionByParticipants.get(username, assistantPersonaId, userPersonaId, userPersonaId),
createChat(username, title, assistantPersonaId = null, userPersonaId = null, scenarioPrompt = null, scenarioSummary = null, organization = {}) {
const chatId = statements.insertChatSession.run(
username,
title,
assistantPersonaId,
userPersonaId,
organization.folderName ?? null,
organization.isPinned ? 1 : 0,
organization.archivedAt ?? null,
scenarioPrompt,
scenarioSummary
).lastInsertRowid;
return statements.getChatSession.get(chatId, username);
},
updateChatTitle: (chatId, username, title) => statements.updateChatSessionTitle.run(title, chatId, username),
updateChatScene: (chatId, username, scenarioPrompt, scenarioSummary) => statements.updateChatSessionScene.run(scenarioPrompt, scenarioSummary, chatId, username),
updateChatContextSummary: (chatId, username, summary, messageId) => statements.updateChatContextSummary.run(summary, Number(messageId || 0), chatId, username),
getPersonaChatState: (chatId, username) => statements.getPersonaChatState.get(chatId, username) || null,
updatePersonaChatState(username, chat, state) {
statements.upsertPersonaChatState.run(
username,
chat.id,
chat.assistant_persona_id ?? null,
chat.user_persona_id ?? null,
state.relationship_notes ?? null,
state.tone ?? null,
state.current_location ?? null,
state.goals ?? null,
state.unresolved_threads ?? null,
state.boundaries ?? null
);
return statements.getPersonaChatState.get(chat.id, username);
},
updateChatOrganization(chatId, username, {folderName = null, isPinned = false, archivedAt = null}) {
return statements.updateChatSessionOrganization.run(folderName, isPinned ? 1 : 0, archivedAt, chatId, username);
},
touchChat: (chatId, username) => statements.touchChatSession.run(chatId, username),
deleteChat: (chatId, username) => statements.deleteChatSession.run(chatId, username),
listChatMessages: (chatId) => statements.getChatMessages.all(chatId),
listActiveBranchMessages: (chatId) => statements.getActiveBranchMessages.all(chatId),
listChatBranchThroughMessage: (chatId, messageId) => statements.getChatBranchThroughMessage.all(chatId, messageId),
listSiblingChatMessages: (chatId, role, parentMessageId) => statements.getSiblingChatMessages.all(chatId, role, parentMessageId ?? null),
getLatestDescendantMessage: (chatId, messageId) => statements.getLatestDescendantMessage.get(chatId, messageId, chatId),
getRecentChatMessages: (chatId) => statements.getRecentChatMessages.all(chatId, config.chatHistoryLimit),
getRecentChatMessagesUpToId: (chatId, messageId) => statements.getRecentChatMessagesUpToId.all(chatId, messageId, config.chatHistoryLimit),
getChatMessage: (chatId, messageId) => statements.getChatMessageById.get(messageId, chatId),
getChatMessageByIndex: (chatId, index) => statements.getChatMessageByIndex.get(chatId, index),
getLatestChatMessage: (chatId) => statements.getLatestChatMessage.get(chatId),
getPreviousUserMessage: (chatId, messageId) => statements.getPreviousUserMessage.get(chatId, messageId),
listChatMemories: (chatId, username) => statements.listChatMemories.all(chatId, username),
getChatMemory: (chatId, memoryId, username) => statements.getChatMemory.get(memoryId, chatId, username),
createChatMemory(username, chat, fact) {
const result = statements.insertChatMemory.run(
username,
chat.id,
chat.assistant_persona_id ?? null,
chat.user_persona_id ?? null,
String(fact || "").trim()
);
return statements.getChatMemory.get(result.lastInsertRowid, chat.id, username);
},
updateChatMemory: (chatId, memoryId, username, fact) => statements.updateChatMemory.run(String(fact || "").trim(), memoryId, chatId, username),
deleteChatMemory: (chatId, memoryId, username) => statements.deleteChatMemory.run(memoryId, chatId, username),
insertChatMessage(chatId, role, content, retryState = null, modelName = null, deliveryStatus = "complete", errorMessage = null, parentMessageId = null) {
return statements.insertChatMessage.run(
chatId,
role,
String(content || ""),
modelName || null,
retryState ? JSON.stringify(retryState.retryVariants) : null,
retryState?.retryActiveIndex ?? 0,
retryState?.retryRetriesUsed ?? 0,
retryState?.retryPromptMessageId ?? null,
deliveryStatus,
errorMessage,
parentMessageId
);
},
insertBranchChatMessage: (chatId, role, content, retryState = null, modelName = null, deliveryStatus = "complete", errorMessage = null, parentMessageId = null) =>
insertBranchChatMessageTransaction(chatId, role, content, retryState, modelName, deliveryStatus, errorMessage, parentMessageId),
updateChatMessageDelivery: (chatId, messageId, deliveryStatus, errorMessage = null) =>
statements.updateChatMessageDelivery.run(deliveryStatus, errorMessage, messageId, chatId),
completeChatTurn: (chatId, userMessageId, assistantContent, retryState, modelName = null, sources = null, thinking = null) =>
completeChatTurnTransaction(chatId, userMessageId, assistantContent, retryState, modelName, sources, thinking),
updateChatMessageThinking: (chatId, messageId, thinking) =>
statements.updateChatMessageThinking.run(thinking, messageId, chatId),
updateChatMessageSources: (chatId, messageId, sources) =>
statements.updateChatMessageSources.run(JSON.stringify(sources), messageId, chatId),
failChatTurn: (chatId, userMessageId, errorMessage, parentMessageId = null) =>
failChatTurnTransaction(chatId, userMessageId, errorMessage, parentMessageId),
updateChatActiveLeaf: (chatId, username, messageId) => statements.updateChatActiveLeaf.run(messageId, chatId, username),
updateChatBranchSource: (chatId, username, sourceChatId, messageId) =>
statements.updateChatBranchSource.run(sourceChatId, messageId ?? null, chatId, username),
updateChatGenerationSettings: (chatId, username, settings) => statements.updateChatGenerationSettings.run(
settings.preferredModel ?? null,
settings.temperature ?? null,
settings.contextLength ?? null,
settings.responseLength ?? null,
settings.systemInstruction ?? null,
chatId,
username
),
updateChatPreferredModel: (chatId, username, model) => statements.updateChatPreferredModel.run(model || null, chatId, username),
listMessageVersions: (messageId) => db.prepare("SELECT * FROM chat_message_versions WHERE message_id = ? ORDER BY id").all(messageId),
saveMessageVersion: (chatId, messageId, version = null, selectedIndex = null) => db.transaction(() => {
const row = statements.getChatMessageById.get(messageId, chatId);
if (!row) throw new Error("Message not found");
let versions = db.prepare("SELECT * FROM chat_message_versions WHERE message_id = ? ORDER BY id").all(messageId);
const insert = db.prepare("INSERT INTO chat_message_versions (message_id, content, thinking, sources, model_name) VALUES (?, ?, ?, ?, ?)");
if (!versions.length) {
db.prepare("INSERT INTO chat_message_versions (message_id, content, thinking, sources, model_name, created_at) VALUES (?, ?, ?, ?, ?, ?)")
.run(messageId, row.content, row.thinking, row.sources, row.model_name, row.created_at);
} else {
// Edits keep their existing semantics: update the selected answer.
db.prepare("UPDATE chat_message_versions SET content = ?, thinking = ?, sources = ?, model_name = ? WHERE id = ?")
.run(row.content, row.thinking, row.sources, row.model_name, versions[row.retry_active_index]?.id ?? versions[0].id);
}
if (version) insert.run(messageId, version.content, version.thinking || null, JSON.stringify(version.sources ?? null), version.modelName);
versions = db.prepare("SELECT * FROM chat_message_versions WHERE message_id = ? ORDER BY id").all(messageId);
const index = version ? versions.length - 1 : selectedIndex;
if (!Number.isInteger(index) || !versions[index]) throw new Error("Version is out of range");
const active = versions[index];
db.prepare(`UPDATE chat_messages SET content = ?, thinking = ?, sources = ?, model_name = ?,
retry_variants = ?, retry_active_index = ?, retry_retries_used = ? WHERE id = ? AND chat_id = ?`)
.run(active.content, active.thinking, active.sources, active.model_name, JSON.stringify(versions.map(item => item.content)), index, versions.length - 1, messageId, chatId);
})(),
updateChatMessage: (chatId, messageId, content) => statements.updateChatMessage.run(content, messageId, chatId),
updateChatMessageRetryState: (chatId, messageId, payload) => statements.updateChatMessageWithRetryState.run(
payload.content,
JSON.stringify(payload.retryVariants),
payload.retryActiveIndex,
payload.retryRetriesUsed,
payload.retryPromptMessageId ?? null,
messageId,
chatId
),
deleteChatMessage: (chatId, messageId) => statements.deleteChatMessage.run(messageId, chatId),
clearChatMessages: (chatId) => statements.deleteChatMessages.run(chatId)
};
}
+65
View File
@@ -0,0 +1,65 @@
import {attachmentMetadata} from "./attachments.js";
/** Bounded history reads and batched metadata for a single page. */
export function createHistoryRepository(db) {
const activeLeaf = db.prepare(`SELECT COALESCE(active_leaf_message_id,
(SELECT MAX(id) FROM chat_messages WHERE chat_id = chat_sessions.id)) AS id
FROM chat_sessions WHERE id = ?`);
const ancestors = `WITH RECURSIVE branch(id, parent_message_id) AS (
SELECT id, parent_message_id FROM chat_messages WHERE chat_id = ? AND id = ?
UNION SELECT p.id, p.parent_message_id FROM chat_messages p JOIN branch c ON p.id = c.parent_message_id WHERE p.chat_id = ?
)`;
const onBranch = db.prepare(`${ancestors} SELECT * FROM branch WHERE id = ?`);
const page = db.prepare(`WITH RECURSIVE branch AS (
SELECT *, 0 AS depth FROM chat_messages WHERE chat_id = ? AND id = ?
UNION ALL SELECT p.*, c.depth + 1 FROM chat_messages p JOIN branch c ON p.id = c.parent_message_id
WHERE p.chat_id = ? AND c.depth < ?
) SELECT * FROM branch ORDER BY depth DESC`);
const versions = db.prepare("SELECT * FROM chat_message_versions WHERE message_id IN (SELECT value FROM json_each(?)) ORDER BY id");
const attachments = db.prepare(`SELECT a.*, m.message_id FROM attachments a JOIN message_attachments m ON a.id = m.attachment_id
WHERE m.message_id IN (SELECT value FROM json_each(?)) ORDER BY m.position`);
const siblings = db.prepare(`WITH families AS (SELECT DISTINCT role, parent_message_id FROM chat_messages
WHERE id IN (SELECT value FROM json_each(?)) AND chat_id = ?)
SELECT cm.id, ROW_NUMBER() OVER family - 1 AS siblingIndex, COUNT(*) OVER family AS siblingCount,
LAG(cm.id) OVER family AS previousSiblingId, LEAD(cm.id) OVER family AS nextSiblingId
FROM chat_messages cm JOIN families f ON cm.role = f.role AND cm.parent_message_id IS f.parent_message_id
WHERE cm.chat_id = ? WINDOW family AS (PARTITION BY cm.role, cm.parent_message_id ORDER BY cm.id
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)`);
const group = (rows, format = row => row) => {
const result = new Map();
for (const row of rows) result.set(row.message_id, [...(result.get(row.message_id) || []), format(row)]);
return result;
};
return {
getActiveBranchPage(chatId, {limit = 50, before = null, leaf = null, around = null} = {}) {
const leafId = activeLeaf.get(chatId)?.id ?? null;
if (leaf != null && leaf !== leafId) return {error: "The conversation branch changed. Reload the latest messages.", status: 409};
let startId = leafId;
let matchOnBranch = null;
if (around != null) {
matchOnBranch = Boolean(onBranch.get(chatId, leafId, chatId, around));
if (matchOnBranch) startId = around;
}
if (before != null) {
const cursor = onBranch.get(chatId, leafId, chatId, before);
if (!cursor) return {error: "History cursor is not on this branch.", status: 400};
startId = cursor.parent_message_id;
}
const rows = startId == null ? [] : page.all(chatId, startId, chatId, limit);
const hasMore = rows.length > limit;
if (hasMore) rows.shift();
return {rows, matchOnBranch, pagination: {leafId, hasMore, before: hasMore ? rows[0].id : null,
hasNewer: around != null && matchOnBranch && around !== leafId}};
},
getHistoryMetadata(chatId, ids) {
const wanted = new Set(ids);
const encoded = JSON.stringify(ids);
return {versions: group(versions.all(encoded)), attachments: group(attachments.all(encoded), attachmentMetadata),
siblings: new Map(siblings.all(encoded, chatId, chatId).filter(row => wanted.has(row.id)).map(row => [row.id, row]))};
},
listBranchNodes(chatId, {after = 0, limit = 200} = {}) {
return db.prepare(`SELECT id, role, parent_message_id AS parentMessageId, substr(content, 1, 180) AS preview,
retry_retries_used + 1 AS versionCount FROM chat_messages WHERE chat_id = ? AND id > ? ORDER BY id LIMIT ?`).all(chatId, after, limit + 1);
}
};
}
+195
View File
@@ -0,0 +1,195 @@
import {buildPersonaSnapshot} from "./personaSnapshot.js";
export function createPersonaStatements(db) {
return {
clearAssistantPersonaFromChats: db.prepare(`
UPDATE chat_sessions
SET assistant_persona_id = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE username = ?
AND assistant_persona_id = ?
`),
clearUserPersonaFromChats: db.prepare(`
UPDATE chat_sessions
SET user_persona_id = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE username = ?
AND user_persona_id = ?
`),
listPersonasByType: db.prepare(`
SELECT id, name, pronouns, appearance, background, details, example_dialogues, persona_type,
source_market_id, source_creator_username, created_at, updated_at
FROM personas
WHERE username = ?
AND persona_type = ?
ORDER BY datetime(updated_at) DESC
`),
listAllPersonas: db.prepare(`
SELECT id, username, name, pronouns, appearance, background, details, example_dialogues, persona_type,
source_market_id, source_creator_username, created_at, updated_at
FROM personas
WHERE username = ?
ORDER BY datetime(updated_at) DESC
`),
getPersona: db.prepare(`
SELECT id, name, pronouns, appearance, background, details, example_dialogues, persona_type,
source_market_id, source_creator_username, created_at, updated_at
FROM personas
WHERE id = ?
AND username = ?
`),
insertPersona: db.prepare("INSERT INTO personas (username, name, pronouns, appearance, background, details, example_dialogues, persona_type) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"),
updatePersona: db.prepare(`
UPDATE personas
SET name = ?,
pronouns = ?,
appearance = ?,
background = ?,
details = ?,
example_dialogues = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
AND username = ?
`),
deletePersona: db.prepare("DELETE FROM personas WHERE id = ? AND username = ?"),
getNextPersonaVersionNumber: db.prepare("SELECT COALESCE(MAX(version_number), 0) + 1 AS nextVersion FROM persona_versions WHERE persona_id = ?"),
insertPersonaVersion: db.prepare("INSERT INTO persona_versions (persona_id, username, version_number, snapshot_json) VALUES (?, ?, ?, ?)"),
listPersonaVersions: db.prepare(`
SELECT id, persona_id, username, version_number, snapshot_json, created_at
FROM persona_versions
WHERE persona_id = ?
AND username = ?
ORDER BY version_number DESC
`),
getPersonaVersion: db.prepare(`
SELECT id, persona_id, username, version_number, snapshot_json, created_at
FROM persona_versions
WHERE id = ?
AND persona_id = ?
AND username = ?
`),
getActivePersonaId: db.prepare("SELECT active_persona_id, active_user_persona_id FROM user_settings WHERE username = ?"),
setActiveUserPersona: db.prepare(`
INSERT INTO user_settings (username, active_user_persona_id)
VALUES (?, ?)
ON CONFLICT(username) DO UPDATE SET active_user_persona_id = excluded.active_user_persona_id
`),
getActiveUserPersona: db.prepare(`
SELECT p.id, p.name, p.pronouns, p.appearance, p.background, p.details, p.example_dialogues
FROM user_settings us
JOIN personas p ON p.id = us.active_user_persona_id
WHERE us.username = ?
`),
getAssistantPersonaForChat: db.prepare(`
SELECT p.id, p.name, p.pronouns, p.appearance, p.background, p.details, p.example_dialogues
FROM chat_sessions cs
JOIN personas p ON p.id = cs.assistant_persona_id
WHERE cs.id = ?
AND cs.username = ?
`),
getUserPersonaForChat: db.prepare(`
SELECT p.id, p.name, p.pronouns, p.appearance, p.background, p.details, p.example_dialogues
FROM chat_sessions cs
JOIN personas p ON p.id = cs.user_persona_id
WHERE cs.id = ?
AND cs.username = ?
`),
deleteMarketPersonaByPersonaId: db.prepare("DELETE FROM persona_market WHERE persona_id = ? AND creator_username = ?")
};
}
export function createPersonaRepository(db, statements) {
const savePersonaVersion = db.transaction((personaId, username) => {
const persona = statements.getPersona.get(personaId, username);
if (!persona) return null;
const versionNumber = statements.getNextPersonaVersionNumber.get(personaId).nextVersion;
statements.insertPersonaVersion.run(personaId, username, versionNumber, buildPersonaSnapshot(persona));
return versionNumber;
});
const clonePersonaTransaction = db.transaction((personaId, username) => {
const source = statements.getPersona.get(personaId, username);
if (!source) return null;
const cloneId = statements.insertPersona.run(
username,
`${source.name} Copy`,
source.pronouns,
source.appearance,
source.background,
source.details,
source.example_dialogues,
source.persona_type
).lastInsertRowid;
const clone = statements.getPersona.get(cloneId, username);
statements.insertPersonaVersion.run(cloneId, username, 1, buildPersonaSnapshot(clone));
return clone;
});
return {
listPersonasByType: (username, personaType) => statements.listPersonasByType.all(username, personaType),
listAllPersonas: (username) => statements.listAllPersonas.all(username),
getPersona: (personaId, username) => statements.getPersona.get(personaId, username),
createPersona(username, payload, personaType) {
const personaId = statements.insertPersona.run(
username,
payload.name,
payload.pronouns,
payload.appearance,
payload.background,
payload.details,
payload.exampleDialogues,
personaType
).lastInsertRowid;
const persona = statements.getPersona.get(personaId, username);
statements.insertPersonaVersion.run(personaId, username, 1, buildPersonaSnapshot(persona));
return persona;
},
updatePersona(personaId, username, payload) {
savePersonaVersion(personaId, username);
return statements.updatePersona.run(
payload.name,
payload.pronouns,
payload.appearance,
payload.background,
payload.details,
payload.exampleDialogues,
personaId,
username
);
},
restorePersonaVersion(personaId, versionId, username) {
const version = statements.getPersonaVersion.get(versionId, personaId, username);
if (!version) return null;
savePersonaVersion(personaId, username);
const snapshot = JSON.parse(version.snapshot_json);
statements.updatePersona.run(
snapshot.name,
snapshot.pronouns ?? null,
snapshot.appearance ?? null,
snapshot.background ?? null,
snapshot.details ?? null,
snapshot.example_dialogues ?? null,
personaId,
username
);
return statements.getPersona.get(personaId, username);
},
listPersonaVersions(personaId, username) {
return statements.listPersonaVersions.all(personaId, username).map((version) => ({
...version,
snapshot: JSON.parse(version.snapshot_json)
}));
},
clonePersona: (personaId, username) => clonePersonaTransaction(personaId, username),
deletePersona: (personaId, username) => statements.deletePersona.run(personaId, username),
clearPersonaReferences(username, personaId) {
statements.clearAssistantPersonaFromChats.run(username, personaId);
statements.clearUserPersonaFromChats.run(username, personaId);
statements.deleteMarketPersonaByPersonaId.run(personaId, username);
},
getActivePersonaIds: (username) => statements.getActivePersonaId.get(username) || {},
setActiveUserPersona: (username, personaId) => statements.setActiveUserPersona.run(username, personaId),
getActiveUserPersona: (username) => statements.getActiveUserPersona.get(username),
getAssistantPersonaForChat: (chatId, username) => statements.getAssistantPersonaForChat.get(chatId, username),
getUserPersonaForChat: (chatId, username) => statements.getUserPersonaForChat.get(chatId, username)
};
}
+11
View File
@@ -0,0 +1,11 @@
export const buildPersonaSnapshot = (persona) => JSON.stringify({
name: persona.name,
pronouns: persona.pronouns,
appearance: persona.appearance,
background: persona.background,
details: persona.details,
example_dialogues: persona.example_dialogues,
persona_type: persona.persona_type,
source_market_id: persona.source_market_id ?? null,
source_creator_username: persona.source_creator_username ?? null
});
+12 -820
View File
@@ -1,3 +1,8 @@
import {buildPersonaSnapshot} from "./personaSnapshot.js";
import {createHistoryRepository} from "./historyRepository.js";
import {createAccountStatements, createAccountRepository} from "./accountRepository.js";
import {createChatStatements, createChatRepository} from "./chatRepository.js";
import {createPersonaStatements, createPersonaRepository} from "./personaRepository.js";
/** /**
* @file Bündelt Datenbankzugriffe auf Konten, Chats, Nachrichten, Personas und den Marktplatz. * @file Bündelt Datenbankzugriffe auf Konten, Chats, Nachrichten, Personas und den Marktplatz.
*/ */
@@ -13,18 +18,6 @@ const parseJsonArray = (value) => {
} }
}; };
const buildPersonaSnapshot = (persona) => JSON.stringify({
name: persona.name,
pronouns: persona.pronouns,
appearance: persona.appearance,
background: persona.background,
details: persona.details,
example_dialogues: persona.example_dialogues,
persona_type: persona.persona_type,
source_market_id: persona.source_market_id ?? null,
source_creator_username: persona.source_creator_username ?? null
});
const MARKET_SORT_ORDERS = { const MARKET_SORT_ORDERS = {
best: "pm.favorite_count DESC, rating_average DESC, pm.usage_count DESC, datetime(pm.created_at) DESC, pm.id DESC", best: "pm.favorite_count DESC, rating_average DESC, pm.usage_count DESC, datetime(pm.created_at) DESC, pm.id DESC",
newest: "datetime(pm.created_at) DESC, pm.id DESC", newest: "datetime(pm.created_at) DESC, pm.id DESC",
@@ -45,413 +38,10 @@ function normalizeMarketPersonaType(personaType) {
export function createRepositories(db, config) { export function createRepositories(db, config) {
const statements = { const statements = {
insertUser: db.prepare("INSERT INTO users (username, password) VALUES (?, ?)"), ...createAccountStatements(db),
getUser: db.prepare("SELECT username, password, role, session_version FROM users WHERE username = ?"), ...createChatStatements(db),
updateUsername: db.prepare("UPDATE users SET username = ? WHERE username = ?"), ...createPersonaStatements(db),
updatePassword: db.prepare("UPDATE users SET password = ? WHERE username = ?"),
incrementSessionVersion: db.prepare("UPDATE users SET session_version = COALESCE(session_version, 0) + 1 WHERE username = ?"),
insertChatSession: db.prepare(
"INSERT INTO chat_sessions (username, title, assistant_persona_id, user_persona_id, folder_name, is_pinned, archived_at, scenario_prompt, scenario_summary) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
),
listChatSessions: db.prepare(`
SELECT cs.id,
CASE
WHEN cs.assistant_persona_id IS NOT NULL AND cs.source_chat_id IS NULL THEN COALESCE(p.name, cs.title)
ELSE cs.title
END AS title,
cs.title AS stored_title,
cs.assistant_persona_id,
p.name AS assistant_persona_name,
cs.user_persona_id,
up.name AS user_persona_name,
cs.folder_name,
cs.is_pinned,
cs.archived_at,
cs.scenario_prompt,
cs.scenario_summary,
cs.context_summary,
cs.context_summary_message_id,
cs.active_leaf_message_id,
cs.source_chat_id,
cs.branched_from_message_id,
cs.preferred_model,
cs.temperature,
cs.context_length,
cs.response_length,
cs.system_instruction,
cs.created_at,
cs.updated_at
FROM chat_sessions cs
LEFT JOIN personas p ON p.id = cs.assistant_persona_id
LEFT JOIN personas up ON up.id = cs.user_persona_id
WHERE cs.username = ?
ORDER BY cs.is_pinned DESC, datetime(cs.updated_at) DESC
`),
countChatSessions: db.prepare("SELECT COUNT(*) as count FROM chat_sessions WHERE username = ? AND archived_at IS NULL"),
getChatSession: db.prepare(`
SELECT cs.id,
CASE
WHEN cs.assistant_persona_id IS NOT NULL AND cs.source_chat_id IS NULL THEN COALESCE(p.name, cs.title)
ELSE cs.title
END AS title,
cs.title AS stored_title,
cs.assistant_persona_id,
p.name AS assistant_persona_name,
cs.user_persona_id,
up.name AS user_persona_name,
cs.folder_name,
cs.is_pinned,
cs.archived_at,
cs.scenario_prompt,
cs.scenario_summary,
cs.context_summary,
cs.context_summary_message_id,
cs.active_leaf_message_id,
cs.source_chat_id,
cs.branched_from_message_id,
cs.preferred_model,
cs.temperature,
cs.context_length,
cs.response_length,
cs.system_instruction,
cs.created_at,
cs.updated_at
FROM chat_sessions cs
LEFT JOIN personas p ON p.id = cs.assistant_persona_id
LEFT JOIN personas up ON up.id = cs.user_persona_id
WHERE cs.id = ?
AND cs.username = ?
`),
getChatSessionByParticipants: db.prepare(`
SELECT cs.id,
CASE
WHEN cs.assistant_persona_id IS NOT NULL AND cs.source_chat_id IS NULL THEN COALESCE(p.name, cs.title)
ELSE cs.title
END AS title,
cs.title AS stored_title,
cs.assistant_persona_id,
p.name AS assistant_persona_name,
cs.user_persona_id,
up.name AS user_persona_name,
cs.folder_name,
cs.is_pinned,
cs.archived_at,
cs.scenario_prompt,
cs.scenario_summary,
cs.context_summary,
cs.context_summary_message_id,
cs.active_leaf_message_id,
cs.source_chat_id,
cs.branched_from_message_id,
cs.preferred_model,
cs.temperature,
cs.context_length,
cs.response_length,
cs.system_instruction,
cs.created_at,
cs.updated_at
FROM chat_sessions cs
LEFT JOIN personas p ON p.id = cs.assistant_persona_id
LEFT JOIN personas up ON up.id = cs.user_persona_id
WHERE cs.username = ?
AND cs.assistant_persona_id = ?
AND ((cs.user_persona_id IS NULL AND ? IS NULL) OR cs.user_persona_id = ?)
AND cs.source_chat_id IS NULL
ORDER BY cs.is_pinned DESC, datetime(cs.updated_at) DESC
LIMIT 1
`),
updateChatSessionTitle: db.prepare("UPDATE chat_sessions SET title = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?"),
updateChatSessionScene: db.prepare("UPDATE chat_sessions SET scenario_prompt = ?, scenario_summary = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?"),
updateChatContextSummary: db.prepare("UPDATE chat_sessions SET context_summary = ?, context_summary_message_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?"),
updateChatGenerationSettings: db.prepare(`
UPDATE chat_sessions
SET preferred_model = ?, temperature = ?, context_length = ?, response_length = ?, system_instruction = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND username = ?
`),
updateChatPreferredModel: db.prepare("UPDATE chat_sessions SET preferred_model = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?"),
updateChatActiveLeaf: db.prepare("UPDATE chat_sessions SET active_leaf_message_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?"),
updateChatBranchSource: db.prepare("UPDATE chat_sessions SET source_chat_id = ?, branched_from_message_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?"),
setChatActiveLeafById: db.prepare("UPDATE chat_sessions SET active_leaf_message_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"),
updateChatSessionOrganization: db.prepare(`
UPDATE chat_sessions
SET folder_name = ?,
is_pinned = ?,
archived_at = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
AND username = ?
`),
touchChatSession: db.prepare("UPDATE chat_sessions SET updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?"),
deleteChatSession: db.prepare("DELETE FROM chat_sessions WHERE id = ? AND username = ?"),
clearAssistantPersonaFromChats: db.prepare(`
UPDATE chat_sessions
SET assistant_persona_id = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE username = ?
AND assistant_persona_id = ?
`),
clearUserPersonaFromChats: db.prepare(`
UPDATE chat_sessions
SET user_persona_id = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE username = ?
AND user_persona_id = ?
`),
insertChatMessage: db.prepare("INSERT INTO chat_messages (chat_id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id, delivery_status, error_message, parent_message_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"),
updateChatMessageDelivery: db.prepare("UPDATE chat_messages SET delivery_status = ?, error_message = ? WHERE id = ? AND chat_id = ?"),
updateChatMessageSources: db.prepare("UPDATE chat_messages SET sources = ? WHERE id = ? AND chat_id = ?"),
updateChatMessageThinking: db.prepare("UPDATE chat_messages SET thinking = ? WHERE id = ? AND chat_id = ?"),
updateChatMessageLinks: db.prepare("UPDATE chat_messages SET parent_message_id = ?, retry_prompt_message_id = ? WHERE id = ? AND chat_id = ?"), updateChatMessageLinks: db.prepare("UPDATE chat_messages SET parent_message_id = ?, retry_prompt_message_id = ? WHERE id = ? AND chat_id = ?"),
getChatMessages: db.prepare(`
SELECT thinking, sources, id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id, delivery_status, error_message, parent_message_id, created_at
FROM chat_messages
WHERE chat_id = ?
ORDER BY id
`),
getRecentChatMessages: db.prepare(`
WITH RECURSIVE branch AS (
SELECT cm.* FROM chat_messages cm
JOIN chat_sessions cs ON cs.id = cm.chat_id
WHERE cm.chat_id = ?
AND cm.id = COALESCE(cs.active_leaf_message_id, (SELECT MAX(id) FROM chat_messages WHERE chat_id = cm.chat_id))
UNION ALL
SELECT parent.* FROM chat_messages parent
JOIN branch child ON child.parent_message_id = parent.id
)
SELECT id, role, content FROM branch
WHERE delivery_status != 'failed'
ORDER BY id DESC
LIMIT ?
`),
getRecentChatMessagesUpToId: db.prepare(`
WITH RECURSIVE branch AS (
SELECT * FROM chat_messages WHERE chat_id = ? AND id = ?
UNION ALL
SELECT parent.* FROM chat_messages parent
JOIN branch child ON child.parent_message_id = parent.id
)
SELECT id, role, content FROM branch
WHERE delivery_status != 'failed'
ORDER BY id DESC
LIMIT ?
`),
getChatBranchThroughMessage: db.prepare(`
WITH RECURSIVE branch AS (
SELECT * FROM chat_messages WHERE chat_id = ? AND id = ?
UNION ALL
SELECT parent.* FROM chat_messages parent
JOIN branch child ON child.parent_message_id = parent.id
)
SELECT id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used,
retry_prompt_message_id, delivery_status, error_message, parent_message_id, created_at, sources, thinking
FROM branch
WHERE delivery_status = 'complete'
ORDER BY id
`),
getActiveBranchMessages: db.prepare(`
WITH RECURSIVE branch AS (
SELECT cm.* FROM chat_messages cm
JOIN chat_sessions cs ON cs.id = cm.chat_id
WHERE cm.chat_id = ?
AND cm.id = COALESCE(cs.active_leaf_message_id, (SELECT MAX(id) FROM chat_messages WHERE chat_id = cm.chat_id))
UNION ALL
SELECT parent.* FROM chat_messages parent
JOIN branch child ON child.parent_message_id = parent.id
)
SELECT id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used,
retry_prompt_message_id, delivery_status, error_message, parent_message_id, created_at, sources, thinking
FROM branch ORDER BY id
`),
getSiblingChatMessages: db.prepare(`
SELECT id, role, content, parent_message_id
FROM chat_messages
WHERE chat_id = ? AND role = ? AND parent_message_id IS ?
ORDER BY id
`),
getLatestDescendantMessage: db.prepare(`
WITH RECURSIVE descendants AS (
SELECT id FROM chat_messages WHERE chat_id = ? AND id = ?
UNION ALL
SELECT child.id FROM chat_messages child JOIN descendants parent ON child.parent_message_id = parent.id
WHERE child.chat_id = ? AND child.delivery_status != 'failed'
)
SELECT cm.* FROM chat_messages cm JOIN descendants d ON d.id = cm.id ORDER BY cm.id DESC LIMIT 1
`),
getChatMessageById: db.prepare(`
SELECT thinking, sources, id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id, delivery_status, error_message, parent_message_id, created_at
FROM chat_messages
WHERE id = ?
AND chat_id = ?
`),
getChatMessageByIndex: db.prepare(`
SELECT thinking, sources, id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id, delivery_status, error_message, parent_message_id, created_at
FROM chat_messages
WHERE chat_id = ?
ORDER BY id
LIMIT 1 OFFSET ?
`),
getLatestChatMessage: db.prepare(`
SELECT id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id, delivery_status, error_message, parent_message_id, created_at
FROM chat_messages
WHERE chat_id = ?
ORDER BY id DESC
LIMIT 1
`),
getPreviousUserMessage: db.prepare(`
SELECT id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id, delivery_status, error_message, parent_message_id, created_at
FROM chat_messages
WHERE chat_id = ?
AND role = 'user'
AND id < ?
ORDER BY id DESC
LIMIT 1
`),
listChatMemories: db.prepare(`
SELECT id, username, chat_id, assistant_persona_id, user_persona_id, fact, created_at, updated_at
FROM chat_memories
WHERE chat_id = ?
AND username = ?
ORDER BY datetime(updated_at) DESC, id DESC
`),
getChatMemory: db.prepare(`
SELECT id, username, chat_id, assistant_persona_id, user_persona_id, fact, created_at, updated_at
FROM chat_memories
WHERE id = ?
AND chat_id = ?
AND username = ?
`),
insertChatMemory: db.prepare(`
INSERT INTO chat_memories (username, chat_id, assistant_persona_id, user_persona_id, fact)
VALUES (?, ?, ?, ?, ?)
`),
updateChatMemory: db.prepare(`
UPDATE chat_memories
SET fact = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
AND chat_id = ?
AND username = ?
`),
deleteChatMemory: db.prepare("DELETE FROM chat_memories WHERE id = ? AND chat_id = ? AND username = ?"),
getPersonaChatState: db.prepare(`
SELECT id,
username,
chat_id,
assistant_persona_id,
user_persona_id,
relationship_notes,
tone,
current_location,
goals,
unresolved_threads,
boundaries,
created_at,
updated_at
FROM persona_chat_state
WHERE chat_id = ?
AND username = ?
`),
upsertPersonaChatState: db.prepare(`
INSERT INTO persona_chat_state (
username,
chat_id,
assistant_persona_id,
user_persona_id,
relationship_notes,
tone,
current_location,
goals,
unresolved_threads,
boundaries
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(chat_id) DO UPDATE SET
username = excluded.username,
assistant_persona_id = excluded.assistant_persona_id,
user_persona_id = excluded.user_persona_id,
relationship_notes = excluded.relationship_notes,
tone = excluded.tone,
current_location = excluded.current_location,
goals = excluded.goals,
unresolved_threads = excluded.unresolved_threads,
boundaries = excluded.boundaries,
updated_at = CURRENT_TIMESTAMP
`),
updateChatMessage: db.prepare("UPDATE chat_messages SET content = ? WHERE id = ? AND chat_id = ?"),
updateChatMessageWithRetryState: db.prepare("UPDATE chat_messages SET content = ?, retry_variants = ?, retry_active_index = ?, retry_retries_used = ?, retry_prompt_message_id = ? WHERE id = ? AND chat_id = ?"),
deleteChatMessage: db.prepare("DELETE FROM chat_messages WHERE id = ? AND chat_id = ?"),
deleteChatMessages: db.prepare("DELETE FROM chat_messages WHERE chat_id = ?"),
searchChats: db.prepare(`
SELECT cs.id,
CASE
WHEN cs.assistant_persona_id IS NOT NULL AND cs.source_chat_id IS NULL THEN COALESCE(p.name, cs.title)
ELSE cs.title
END AS title,
cs.title AS stored_title,
cs.assistant_persona_id,
p.name AS assistant_persona_name,
cs.user_persona_id,
up.name AS user_persona_name,
cs.folder_name,
cs.is_pinned,
cs.archived_at,
cs.scenario_prompt,
cs.scenario_summary,
cs.context_summary,
cs.context_summary_message_id,
cs.active_leaf_message_id,
cs.source_chat_id,
cs.branched_from_message_id,
cs.preferred_model,
cs.temperature,
cs.context_length,
cs.response_length,
cs.system_instruction,
cs.created_at,
cs.updated_at,
cm.id AS matched_message_id,
cm.content AS matched_content
FROM chat_sessions cs
LEFT JOIN personas p ON p.id = cs.assistant_persona_id
LEFT JOIN personas up ON up.id = cs.user_persona_id
LEFT JOIN chat_messages cm ON cm.id = (
SELECT m.id FROM chat_messages m
WHERE m.chat_id = cs.id AND LOWER(m.content) LIKE ?
ORDER BY m.id DESC LIMIT 1
)
WHERE cs.username = ?
AND (
LOWER(CASE
WHEN cs.assistant_persona_id IS NOT NULL AND cs.source_chat_id IS NULL THEN COALESCE(p.name, cs.title)
ELSE cs.title
END) LIKE ?
OR cm.id IS NOT NULL
)
ORDER BY cs.is_pinned DESC, datetime(cs.updated_at) DESC
`),
listPersonasByType: db.prepare(`
SELECT id, name, pronouns, appearance, background, details, example_dialogues, persona_type,
source_market_id, source_creator_username, created_at, updated_at
FROM personas
WHERE username = ?
AND persona_type = ?
ORDER BY datetime(updated_at) DESC
`),
listAllPersonas: db.prepare(`
SELECT id, username, name, pronouns, appearance, background, details, example_dialogues, persona_type,
source_market_id, source_creator_username, created_at, updated_at
FROM personas
WHERE username = ?
ORDER BY datetime(updated_at) DESC
`),
getPersona: db.prepare(`
SELECT id, name, pronouns, appearance, background, details, example_dialogues, persona_type,
source_market_id, source_creator_username, created_at, updated_at
FROM personas
WHERE id = ?
AND username = ?
`),
insertPersona: db.prepare("INSERT INTO personas (username, name, pronouns, appearance, background, details, example_dialogues, persona_type) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"),
insertMarketPersona: db.prepare("INSERT INTO personas (username, name, pronouns, appearance, background, details, example_dialogues, persona_type, source_market_id, source_creator_username) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"), insertMarketPersona: db.prepare("INSERT INTO personas (username, name, pronouns, appearance, background, details, example_dialogues, persona_type, source_market_id, source_creator_username) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"),
getPersonaBySourceMarket: db.prepare(` getPersonaBySourceMarket: db.prepare(`
SELECT id SELECT id
@@ -459,61 +49,6 @@ export function createRepositories(db, config) {
WHERE username = ? WHERE username = ?
AND source_market_id = ? AND source_market_id = ?
`), `),
updatePersona: db.prepare(`
UPDATE personas
SET name = ?,
pronouns = ?,
appearance = ?,
background = ?,
details = ?,
example_dialogues = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
AND username = ?
`),
deletePersona: db.prepare("DELETE FROM personas WHERE id = ? AND username = ?"),
getNextPersonaVersionNumber: db.prepare("SELECT COALESCE(MAX(version_number), 0) + 1 AS nextVersion FROM persona_versions WHERE persona_id = ?"),
insertPersonaVersion: db.prepare("INSERT INTO persona_versions (persona_id, username, version_number, snapshot_json) VALUES (?, ?, ?, ?)"),
listPersonaVersions: db.prepare(`
SELECT id, persona_id, username, version_number, snapshot_json, created_at
FROM persona_versions
WHERE persona_id = ?
AND username = ?
ORDER BY version_number DESC
`),
getPersonaVersion: db.prepare(`
SELECT id, persona_id, username, version_number, snapshot_json, created_at
FROM persona_versions
WHERE id = ?
AND persona_id = ?
AND username = ?
`),
getActivePersonaId: db.prepare("SELECT active_persona_id, active_user_persona_id FROM user_settings WHERE username = ?"),
setActiveUserPersona: db.prepare(`
INSERT INTO user_settings (username, active_user_persona_id)
VALUES (?, ?)
ON CONFLICT(username) DO UPDATE SET active_user_persona_id = excluded.active_user_persona_id
`),
getActiveUserPersona: db.prepare(`
SELECT p.id, p.name, p.pronouns, p.appearance, p.background, p.details, p.example_dialogues
FROM user_settings us
JOIN personas p ON p.id = us.active_user_persona_id
WHERE us.username = ?
`),
getAssistantPersonaForChat: db.prepare(`
SELECT p.id, p.name, p.pronouns, p.appearance, p.background, p.details, p.example_dialogues
FROM chat_sessions cs
JOIN personas p ON p.id = cs.assistant_persona_id
WHERE cs.id = ?
AND cs.username = ?
`),
getUserPersonaForChat: db.prepare(`
SELECT p.id, p.name, p.pronouns, p.appearance, p.background, p.details, p.example_dialogues
FROM chat_sessions cs
JOIN personas p ON p.id = cs.user_persona_id
WHERE cs.id = ?
AND cs.username = ?
`),
listMarketPersonas: db.prepare(` listMarketPersonas: db.prepare(`
SELECT pm.id, pm.persona_id, pm.creator_username, pm.name, pm.pronouns, pm.appearance, pm.background, pm.details, SELECT pm.id, pm.persona_id, pm.creator_username, pm.name, pm.pronouns, pm.appearance, pm.background, pm.details,
pm.example_dialogues, pm.persona_type, pm.tags, pm.usage_count, pm.favorite_count, pm.rating_total, pm.rating_count, pm.example_dialogues, pm.persona_type, pm.tags, pm.usage_count, pm.favorite_count, pm.rating_total, pm.rating_count,
@@ -691,135 +226,9 @@ export function createRepositories(db, config) {
WHERE creator_username = ? WHERE creator_username = ?
AND soft_deleted_at IS NULL AND soft_deleted_at IS NULL
`), `),
updateChatSessionsUsername: db.prepare("UPDATE chat_sessions SET username = ? WHERE username = ?"),
updatePersonasUsername: db.prepare("UPDATE personas SET username = ? WHERE username = ?"),
updatePersonasSourceCreator: db.prepare("UPDATE personas SET source_creator_username = ? WHERE source_creator_username = ?"),
updateUserSettingsUsername: db.prepare("UPDATE user_settings SET username = ? WHERE username = ?"),
updatePersonaMarketCreator: db.prepare("UPDATE persona_market SET creator_username = ? WHERE creator_username = ?"),
updatePersonaVersionsUsername: db.prepare("UPDATE persona_versions SET username = ? WHERE username = ?"),
updatePromptTemplatesUsername: db.prepare("UPDATE prompt_templates SET username = ? WHERE username = ?"),
updateChatMemoriesUsername: db.prepare("UPDATE chat_memories SET username = ? WHERE username = ?"),
updatePersonaChatStateUsername: db.prepare("UPDATE persona_chat_state SET username = ? WHERE username = ?"),
updateMarketFavoritesUsername: db.prepare("UPDATE persona_market_favorites SET username = ? WHERE username = ?"),
updateMarketRatingsUsername: db.prepare("UPDATE persona_market_ratings SET username = ? WHERE username = ?"),
updateMarketReportsUsername: db.prepare("UPDATE persona_market_reports SET reporter_username = ? WHERE reporter_username = ?"),
deleteChatMemoriesByUser: db.prepare("DELETE FROM chat_memories WHERE username = ?"),
deleteChatSessionsByUser: db.prepare("DELETE FROM chat_sessions WHERE username = ?"),
deletePersonasByUser: db.prepare("DELETE FROM personas WHERE username = ?"),
deleteUserSettingsByUser: db.prepare("DELETE FROM user_settings WHERE username = ?"),
deletePersonaVersionsByUser: db.prepare("DELETE FROM persona_versions WHERE username = ?"),
deletePromptTemplatesByUser: db.prepare("DELETE FROM prompt_templates WHERE username = ?"),
deleteMarketPersonasByCreator: db.prepare("DELETE FROM persona_market WHERE creator_username = ?"),
deleteMarketFavoritesByUser: db.prepare("DELETE FROM persona_market_favorites WHERE username = ?"),
deleteMarketRatingsByUser: db.prepare("DELETE FROM persona_market_ratings WHERE username = ?"),
deleteMarketReportsByUser: db.prepare("DELETE FROM persona_market_reports WHERE reporter_username = ?"),
deletePersonaChatStateByUser: db.prepare("DELETE FROM persona_chat_state WHERE username = ?"),
deleteUser: db.prepare("DELETE FROM users WHERE username = ?"),
deleteMarketPersonaByPersonaId: db.prepare("DELETE FROM persona_market WHERE persona_id = ? AND creator_username = ?"),
incrementMarketUsageCount: db.prepare("UPDATE persona_market SET usage_count = usage_count + 1 WHERE id = ?") incrementMarketUsageCount: db.prepare("UPDATE persona_market SET usage_count = usage_count + 1 WHERE id = ?")
}; };
const renameUserTransaction = db.transaction((currentUsername, nextUsername) => {
// Existing username references do not all cascade on update. Check them at commit,
// after every owned row has been moved to the new username.
db.pragma("defer_foreign_keys = ON");
statements.updateUsername.run(nextUsername, currentUsername);
statements.updateChatSessionsUsername.run(nextUsername, currentUsername);
statements.updatePersonasUsername.run(nextUsername, currentUsername);
statements.updatePersonasSourceCreator.run(nextUsername, currentUsername);
statements.updateUserSettingsUsername.run(nextUsername, currentUsername);
statements.updatePersonaMarketCreator.run(nextUsername, currentUsername);
statements.updatePersonaVersionsUsername.run(nextUsername, currentUsername);
statements.updatePromptTemplatesUsername.run(nextUsername, currentUsername);
statements.updateChatMemoriesUsername.run(nextUsername, currentUsername);
statements.updatePersonaChatStateUsername.run(nextUsername, currentUsername);
statements.updateMarketFavoritesUsername.run(nextUsername, currentUsername);
statements.updateMarketRatingsUsername.run(nextUsername, currentUsername);
statements.updateMarketReportsUsername.run(nextUsername, currentUsername);
});
const deleteUserTransaction = db.transaction((username) => {
statements.deleteMarketFavoritesByUser.run(username);
statements.deleteMarketRatingsByUser.run(username);
statements.deleteMarketReportsByUser.run(username);
statements.deleteMarketPersonasByCreator.run(username);
statements.deletePromptTemplatesByUser.run(username);
statements.deletePersonaVersionsByUser.run(username);
statements.deleteChatMemoriesByUser.run(username);
statements.deletePersonaChatStateByUser.run(username);
statements.deleteChatSessionsByUser.run(username);
statements.deleteUserSettingsByUser.run(username);
statements.deletePersonasByUser.run(username);
return statements.deleteUser.run(username);
});
const completeChatTurnTransaction = db.transaction((chatId, userMessageId, assistantContent, retryState, modelName, sources, thinking) => {
const assistantResult = statements.insertChatMessage.run(
chatId,
"bot",
String(assistantContent || ""),
modelName || null,
JSON.stringify(retryState.retryVariants),
retryState.retryActiveIndex,
retryState.retryRetriesUsed,
retryState.retryPromptMessageId ?? null,
"complete",
null,
userMessageId
);
statements.updateChatMessageDelivery.run("complete", null, userMessageId, chatId);
if (sources) statements.updateChatMessageSources.run(JSON.stringify(sources), assistantResult.lastInsertRowid, chatId);
if (thinking) statements.updateChatMessageThinking.run(thinking, assistantResult.lastInsertRowid, chatId);
statements.setChatActiveLeafById.run(assistantResult.lastInsertRowid, chatId);
return assistantResult;
});
const insertBranchChatMessageTransaction = db.transaction((chatId, role, content, retryState, modelName, deliveryStatus, errorMessage, parentMessageId) => {
const result = statements.insertChatMessage.run(
chatId, role, String(content || ""), modelName || null,
retryState ? JSON.stringify(retryState.retryVariants) : null,
retryState?.retryActiveIndex ?? 0,
retryState?.retryRetriesUsed ?? 0,
retryState?.retryPromptMessageId ?? null,
deliveryStatus,
errorMessage,
parentMessageId ?? null
);
statements.setChatActiveLeafById.run(result.lastInsertRowid, chatId);
return result;
});
const failChatTurnTransaction = db.transaction((chatId, userMessageId, errorMessage, parentMessageId) => {
statements.updateChatMessageDelivery.run("failed", errorMessage, userMessageId, chatId);
statements.setChatActiveLeafById.run(parentMessageId ?? null, chatId);
});
const savePersonaVersion = db.transaction((personaId, username) => {
const persona = statements.getPersona.get(personaId, username);
if (!persona) return null;
const versionNumber = statements.getNextPersonaVersionNumber.get(personaId).nextVersion;
statements.insertPersonaVersion.run(personaId, username, versionNumber, buildPersonaSnapshot(persona));
return versionNumber;
});
const clonePersonaTransaction = db.transaction((personaId, username) => {
const source = statements.getPersona.get(personaId, username);
if (!source) return null;
const cloneId = statements.insertPersona.run(
username,
`${source.name} Copy`,
source.pronouns,
source.appearance,
source.background,
source.details,
source.example_dialogues,
source.persona_type
).lastInsertRowid;
const clone = statements.getPersona.get(cloneId, username);
statements.insertPersonaVersion.run(cloneId, username, 1, buildPersonaSnapshot(clone));
return clone;
});
const importWorkspaceTransaction = db.transaction((username, workspace) => { const importWorkspaceTransaction = db.transaction((username, workspace) => {
const imported = {personas: 0, chats: 0, templates: 0}; const imported = {personas: 0, chats: 0, templates: 0};
const personaIds = new Map(); const personaIds = new Map();
@@ -965,227 +374,10 @@ export function createRepositories(db, config) {
}); });
return { return {
getUser: (username) => statements.getUser.get(username), ...createHistoryRepository(db),
insertUser: (username, passwordHash) => statements.insertUser.run(username, passwordHash), ...createAccountRepository(db, statements),
updatePassword: (passwordHash, username) => statements.updatePassword.run(passwordHash, username), ...createChatRepository(db, config, statements),
incrementSessionVersion(username) { ...createPersonaRepository(db, statements),
statements.incrementSessionVersion.run(username);
return statements.getUser.get(username);
},
renameUser: (currentUsername, nextUsername) => renameUserTransaction(currentUsername, nextUsername),
deleteUser: (username) => deleteUserTransaction(username),
listChats: (username) => statements.listChatSessions.all(username),
searchChats(username, query) {
const like = `%${String(query || "").trim().toLowerCase()}%`;
return statements.searchChats.all(like, username, like);
},
countChats: (username) => statements.countChatSessions.get(username).count,
getChat: (chatId, username) => statements.getChatSession.get(chatId, username),
getChatByParticipants: (username, assistantPersonaId, userPersonaId) => statements.getChatSessionByParticipants.get(username, assistantPersonaId, userPersonaId, userPersonaId),
createChat(username, title, assistantPersonaId = null, userPersonaId = null, scenarioPrompt = null, scenarioSummary = null, organization = {}) {
const chatId = statements.insertChatSession.run(
username,
title,
assistantPersonaId,
userPersonaId,
organization.folderName ?? null,
organization.isPinned ? 1 : 0,
organization.archivedAt ?? null,
scenarioPrompt,
scenarioSummary
).lastInsertRowid;
return statements.getChatSession.get(chatId, username);
},
updateChatTitle: (chatId, username, title) => statements.updateChatSessionTitle.run(title, chatId, username),
updateChatScene: (chatId, username, scenarioPrompt, scenarioSummary) => statements.updateChatSessionScene.run(scenarioPrompt, scenarioSummary, chatId, username),
updateChatContextSummary: (chatId, username, summary, messageId) => statements.updateChatContextSummary.run(summary, Number(messageId || 0), chatId, username),
getPersonaChatState: (chatId, username) => statements.getPersonaChatState.get(chatId, username) || null,
updatePersonaChatState(username, chat, state) {
statements.upsertPersonaChatState.run(
username,
chat.id,
chat.assistant_persona_id ?? null,
chat.user_persona_id ?? null,
state.relationship_notes ?? null,
state.tone ?? null,
state.current_location ?? null,
state.goals ?? null,
state.unresolved_threads ?? null,
state.boundaries ?? null
);
return statements.getPersonaChatState.get(chat.id, username);
},
updateChatOrganization(chatId, username, {folderName = null, isPinned = false, archivedAt = null}) {
return statements.updateChatSessionOrganization.run(folderName, isPinned ? 1 : 0, archivedAt, chatId, username);
},
touchChat: (chatId, username) => statements.touchChatSession.run(chatId, username),
deleteChat: (chatId, username) => statements.deleteChatSession.run(chatId, username),
listChatMessages: (chatId) => statements.getChatMessages.all(chatId),
listActiveBranchMessages: (chatId) => statements.getActiveBranchMessages.all(chatId),
listChatBranchThroughMessage: (chatId, messageId) => statements.getChatBranchThroughMessage.all(chatId, messageId),
listSiblingChatMessages: (chatId, role, parentMessageId) => statements.getSiblingChatMessages.all(chatId, role, parentMessageId ?? null),
getLatestDescendantMessage: (chatId, messageId) => statements.getLatestDescendantMessage.get(chatId, messageId, chatId),
getRecentChatMessages: (chatId) => statements.getRecentChatMessages.all(chatId, config.chatHistoryLimit),
getRecentChatMessagesUpToId: (chatId, messageId) => statements.getRecentChatMessagesUpToId.all(chatId, messageId, config.chatHistoryLimit),
getChatMessage: (chatId, messageId) => statements.getChatMessageById.get(messageId, chatId),
getChatMessageByIndex: (chatId, index) => statements.getChatMessageByIndex.get(chatId, index),
getLatestChatMessage: (chatId) => statements.getLatestChatMessage.get(chatId),
getPreviousUserMessage: (chatId, messageId) => statements.getPreviousUserMessage.get(chatId, messageId),
listChatMemories: (chatId, username) => statements.listChatMemories.all(chatId, username),
getChatMemory: (chatId, memoryId, username) => statements.getChatMemory.get(memoryId, chatId, username),
createChatMemory(username, chat, fact) {
const result = statements.insertChatMemory.run(
username,
chat.id,
chat.assistant_persona_id ?? null,
chat.user_persona_id ?? null,
String(fact || "").trim()
);
return statements.getChatMemory.get(result.lastInsertRowid, chat.id, username);
},
updateChatMemory: (chatId, memoryId, username, fact) => statements.updateChatMemory.run(String(fact || "").trim(), memoryId, chatId, username),
deleteChatMemory: (chatId, memoryId, username) => statements.deleteChatMemory.run(memoryId, chatId, username),
insertChatMessage(chatId, role, content, retryState = null, modelName = null, deliveryStatus = "complete", errorMessage = null, parentMessageId = null) {
return statements.insertChatMessage.run(
chatId,
role,
String(content || ""),
modelName || null,
retryState ? JSON.stringify(retryState.retryVariants) : null,
retryState?.retryActiveIndex ?? 0,
retryState?.retryRetriesUsed ?? 0,
retryState?.retryPromptMessageId ?? null,
deliveryStatus,
errorMessage,
parentMessageId
);
},
insertBranchChatMessage: (chatId, role, content, retryState = null, modelName = null, deliveryStatus = "complete", errorMessage = null, parentMessageId = null) =>
insertBranchChatMessageTransaction(chatId, role, content, retryState, modelName, deliveryStatus, errorMessage, parentMessageId),
updateChatMessageDelivery: (chatId, messageId, deliveryStatus, errorMessage = null) =>
statements.updateChatMessageDelivery.run(deliveryStatus, errorMessage, messageId, chatId),
completeChatTurn: (chatId, userMessageId, assistantContent, retryState, modelName = null, sources = null, thinking = null) =>
completeChatTurnTransaction(chatId, userMessageId, assistantContent, retryState, modelName, sources, thinking),
updateChatMessageThinking: (chatId, messageId, thinking) =>
statements.updateChatMessageThinking.run(thinking, messageId, chatId),
updateChatMessageSources: (chatId, messageId, sources) =>
statements.updateChatMessageSources.run(JSON.stringify(sources), messageId, chatId),
failChatTurn: (chatId, userMessageId, errorMessage, parentMessageId = null) =>
failChatTurnTransaction(chatId, userMessageId, errorMessage, parentMessageId),
updateChatActiveLeaf: (chatId, username, messageId) => statements.updateChatActiveLeaf.run(messageId, chatId, username),
updateChatBranchSource: (chatId, username, sourceChatId, messageId) =>
statements.updateChatBranchSource.run(sourceChatId, messageId ?? null, chatId, username),
updateChatGenerationSettings: (chatId, username, settings) => statements.updateChatGenerationSettings.run(
settings.preferredModel ?? null,
settings.temperature ?? null,
settings.contextLength ?? null,
settings.responseLength ?? null,
settings.systemInstruction ?? null,
chatId,
username
),
updateChatPreferredModel: (chatId, username, model) => statements.updateChatPreferredModel.run(model || null, chatId, username),
listMessageVersions: (messageId) => db.prepare("SELECT * FROM chat_message_versions WHERE message_id = ? ORDER BY id").all(messageId),
saveMessageVersion: (chatId, messageId, version = null, selectedIndex = null) => db.transaction(() => {
const row = statements.getChatMessageById.get(messageId, chatId);
if (!row) throw new Error("Message not found");
let versions = db.prepare("SELECT * FROM chat_message_versions WHERE message_id = ? ORDER BY id").all(messageId);
const insert = db.prepare("INSERT INTO chat_message_versions (message_id, content, thinking, sources, model_name) VALUES (?, ?, ?, ?, ?)");
if (!versions.length) {
db.prepare("INSERT INTO chat_message_versions (message_id, content, thinking, sources, model_name, created_at) VALUES (?, ?, ?, ?, ?, ?)")
.run(messageId, row.content, row.thinking, row.sources, row.model_name, row.created_at);
} else {
// Edits keep their existing semantics: update the selected answer.
db.prepare("UPDATE chat_message_versions SET content = ?, thinking = ?, sources = ?, model_name = ? WHERE id = ?")
.run(row.content, row.thinking, row.sources, row.model_name, versions[row.retry_active_index]?.id ?? versions[0].id);
}
if (version) insert.run(messageId, version.content, version.thinking || null, JSON.stringify(version.sources ?? null), version.modelName);
versions = db.prepare("SELECT * FROM chat_message_versions WHERE message_id = ? ORDER BY id").all(messageId);
const index = version ? versions.length - 1 : selectedIndex;
if (!Number.isInteger(index) || !versions[index]) throw new Error("Version is out of range");
const active = versions[index];
db.prepare(`UPDATE chat_messages SET content = ?, thinking = ?, sources = ?, model_name = ?,
retry_variants = ?, retry_active_index = ?, retry_retries_used = ? WHERE id = ? AND chat_id = ?`)
.run(active.content, active.thinking, active.sources, active.model_name, JSON.stringify(versions.map(item => item.content)), index, versions.length - 1, messageId, chatId);
})(),
updateChatMessage: (chatId, messageId, content) => statements.updateChatMessage.run(content, messageId, chatId),
updateChatMessageRetryState: (chatId, messageId, payload) => statements.updateChatMessageWithRetryState.run(
payload.content,
JSON.stringify(payload.retryVariants),
payload.retryActiveIndex,
payload.retryRetriesUsed,
payload.retryPromptMessageId ?? null,
messageId,
chatId
),
deleteChatMessage: (chatId, messageId) => statements.deleteChatMessage.run(messageId, chatId),
clearChatMessages: (chatId) => statements.deleteChatMessages.run(chatId),
listPersonasByType: (username, personaType) => statements.listPersonasByType.all(username, personaType),
listAllPersonas: (username) => statements.listAllPersonas.all(username),
getPersona: (personaId, username) => statements.getPersona.get(personaId, username),
createPersona(username, payload, personaType) {
const personaId = statements.insertPersona.run(
username,
payload.name,
payload.pronouns,
payload.appearance,
payload.background,
payload.details,
payload.exampleDialogues,
personaType
).lastInsertRowid;
const persona = statements.getPersona.get(personaId, username);
statements.insertPersonaVersion.run(personaId, username, 1, buildPersonaSnapshot(persona));
return persona;
},
updatePersona(personaId, username, payload) {
savePersonaVersion(personaId, username);
return statements.updatePersona.run(
payload.name,
payload.pronouns,
payload.appearance,
payload.background,
payload.details,
payload.exampleDialogues,
personaId,
username
);
},
restorePersonaVersion(personaId, versionId, username) {
const version = statements.getPersonaVersion.get(versionId, personaId, username);
if (!version) return null;
savePersonaVersion(personaId, username);
const snapshot = JSON.parse(version.snapshot_json);
statements.updatePersona.run(
snapshot.name,
snapshot.pronouns ?? null,
snapshot.appearance ?? null,
snapshot.background ?? null,
snapshot.details ?? null,
snapshot.example_dialogues ?? null,
personaId,
username
);
return statements.getPersona.get(personaId, username);
},
listPersonaVersions(personaId, username) {
return statements.listPersonaVersions.all(personaId, username).map((version) => ({
...version,
snapshot: JSON.parse(version.snapshot_json)
}));
},
clonePersona: (personaId, username) => clonePersonaTransaction(personaId, username),
deletePersona: (personaId, username) => statements.deletePersona.run(personaId, username),
clearPersonaReferences(username, personaId) {
statements.clearAssistantPersonaFromChats.run(username, personaId);
statements.clearUserPersonaFromChats.run(username, personaId);
statements.deleteMarketPersonaByPersonaId.run(personaId, username);
},
getActivePersonaIds: (username) => statements.getActivePersonaId.get(username) || {},
setActiveUserPersona: (username, personaId) => statements.setActiveUserPersona.run(username, personaId),
getActiveUserPersona: (username) => statements.getActiveUserPersona.get(username),
getAssistantPersonaForChat: (chatId, username) => statements.getAssistantPersonaForChat.get(chatId, username),
getUserPersonaForChat: (chatId, username) => statements.getUserPersonaForChat.get(chatId, username),
listMarketPersonas({admin = false, username = null, personaType = null, sort = "best"} = {}) { listMarketPersonas({admin = false, username = null, personaType = null, sort = "best"} = {}) {
const normalizedPersonaType = normalizeMarketPersonaType(personaType); const normalizedPersonaType = normalizeMarketPersonaType(personaType);
const normalizedSort = normalizeMarketSort(sort); const normalizedSort = normalizeMarketSort(sort);
+2
View File
@@ -299,6 +299,8 @@ export function runMigrations(db, {dropLegacyChats = true} = {}) {
ensureColumn("chat_messages", chatMessageColumns, "delivery_status", "ALTER TABLE chat_messages ADD COLUMN delivery_status TEXT DEFAULT 'complete'"); ensureColumn("chat_messages", chatMessageColumns, "delivery_status", "ALTER TABLE chat_messages ADD COLUMN delivery_status TEXT DEFAULT 'complete'");
ensureColumn("chat_messages", chatMessageColumns, "error_message", "ALTER TABLE chat_messages ADD COLUMN error_message TEXT"); ensureColumn("chat_messages", chatMessageColumns, "error_message", "ALTER TABLE chat_messages ADD COLUMN error_message TEXT");
ensureColumn("chat_messages", chatMessageColumns, "parent_message_id", "ALTER TABLE chat_messages ADD COLUMN parent_message_id INTEGER"); ensureColumn("chat_messages", chatMessageColumns, "parent_message_id", "ALTER TABLE chat_messages ADD COLUMN parent_message_id INTEGER");
db.exec(`CREATE INDEX IF NOT EXISTS chat_messages_family ON chat_messages(chat_id, parent_message_id, role, id);
CREATE INDEX IF NOT EXISTS chat_messages_chat_order ON chat_messages(chat_id, id);`);
db.prepare("UPDATE chat_messages SET retry_active_index = 0 WHERE retry_active_index IS NULL").run(); db.prepare("UPDATE chat_messages SET retry_active_index = 0 WHERE retry_active_index IS NULL").run();
db.prepare("UPDATE chat_messages SET retry_retries_used = 0 WHERE retry_retries_used IS NULL").run(); db.prepare("UPDATE chat_messages SET retry_retries_used = 0 WHERE retry_retries_used IS NULL").run();
db.prepare("UPDATE chat_messages SET delivery_status = 'complete' WHERE delivery_status IS NULL").run(); db.prepare("UPDATE chat_messages SET delivery_status = 'complete' WHERE delivery_status IS NULL").run();
+3
View File
@@ -5,6 +5,7 @@ import express from "express";
import {asyncHandler} from "../middleware/asyncHandler.js"; import {asyncHandler} from "../middleware/asyncHandler.js";
import bcrypt from "bcrypt"; import bcrypt from "bcrypt";
import {validateBody} from "../middleware/validate.js"; import {validateBody} from "../middleware/validate.js";
import {newPasswordError} from "../services/passwordPolicy.js";
const credentialsValidator = (body) => { const credentialsValidator = (body) => {
const username = String(body?.username || "").trim(); const username = String(body?.username || "").trim();
@@ -22,6 +23,8 @@ export function createAuthRouter({repositories, authRateLimiters, config}) {
router.post("/register", ...authRateLimiters, validateBody(credentialsValidator), asyncHandler(async (req, res) => { router.post("/register", ...authRateLimiters, validateBody(credentialsValidator), asyncHandler(async (req, res) => {
const {username, password, inviteCode} = req.validatedBody; const {username, password, inviteCode} = req.validatedBody;
const passwordError = newPasswordError(password);
if (passwordError) return res.status(400).json({error: passwordError, code: "PASSWORD_TOO_LONG"});
if (config.registrationInviteCode && inviteCode !== config.registrationInviteCode) { if (config.registrationInviteCode && inviteCode !== config.registrationInviteCode) {
return res.status(403).json({error: "Valid invite code is required"}); return res.status(403).json({error: "Valid invite code is required"});
} }
+53 -5
View File
@@ -217,10 +217,39 @@ export function createChatsRouter({repositories, chatService, modelService, conf
const chatId = Number(req.params.id); const chatId = Number(req.params.id);
const chat = repositories.getChat(chatId, req.session.user); const chat = repositories.getChat(chatId, req.session.user);
if (!chat) return res.status(404).json({error: "Chat not found"}); if (!chat) return res.status(404).json({error: "Chat not found"});
return res.json({ const options = {};
messages: chatService.listActiveBranchMessages(chatId), for (const key of ["limit", "before", "leaf", "around"]) {
chat if (req.query[key] === undefined) continue;
}); const value = Number(req.query[key]);
if (!Number.isSafeInteger(value) || value < 1 || (key === "limit" && value > 100)) {
return res.status(400).json({error: `Invalid history ${key}`});
}
options[key] = value;
}
const result = chatService.getHistoryPage(chatId, options);
if (result.error) return res.status(result.status).json(result);
return res.json({...result, chat});
});
router.get("/chats/:id/branches", (req, res) => {
const chatId = Number(req.params.id);
const chat = repositories.getChat(chatId, req.session.user);
if (!chat) return res.status(404).json({error: "Chat not found"});
const after = Number(req.query.after || 0);
if (!Number.isSafeInteger(after) || after < 0) return res.status(400).json({error: "Invalid branch cursor"});
const nodes = repositories.listBranchNodes(chatId, {after});
const hasMore = nodes.length > 200;
if (hasMore) nodes.pop();
return res.json({nodes, activeLeafId: chat.active_leaf_message_id || nodes.at(-1)?.id || null,
next: hasMore ? nodes.at(-1).id : null});
});
router.get("/chats/:id/messages/:messageId/versions", (req, res) => {
const chatId = Number(req.params.id);
if (!repositories.getChat(chatId, req.session.user)) return res.status(404).json({error: "Chat not found"});
const message = repositories.getChatMessage(chatId, Number(req.params.messageId));
if (!message) return res.status(404).json({error: "Message not found"});
return res.json({message: chatService.formatChatMessage(message)});
}); });
router.put("/chats/:id/generation-settings", validateBody(generationSettingsValidator), (req, res) => { router.put("/chats/:id/generation-settings", validateBody(generationSettingsValidator), (req, res) => {
@@ -406,7 +435,26 @@ export function createChatsRouter({repositories, chatService, modelService, conf
const chatId = Number(req.params.id); const chatId = Number(req.params.id);
const chat = repositories.getChat(chatId, req.session.user); const chat = repositories.getChat(chatId, req.session.user);
if (!chat) return res.status(404).json({error: "Chat not found"}); if (!chat) return res.status(404).json({error: "Chat not found"});
return res.json({memories: repositories.listChatMemories(chatId, req.session.user)}); const payload = chatService.buildMessagesPayload({user: req.session.user, chatId, session: chat});
const characters = payload.messagesPayload.reduce((total, message) => total + String(message.content || "").length, 0);
return res.json({memories: repositories.listChatMemories(chatId, req.session.user), summary: chat.context_summary || "",
summaryMessageId: chat.context_summary_message_id || 0, summaryMaxChars: config.chatSummaryMaxChars,
context: {estimatedTokens: Math.ceil(characters / 4), contextLength: chat.context_length || null,
recentMessageLimit: config.chatHistoryLimit, memoryFactLimit: 30}});
});
router.put("/chats/:id/summary", (req, res) => {
const chatId = Number(req.params.id);
const chat = repositories.getChat(chatId, req.session.user);
if (!chat) return res.status(404).json({error: "Chat not found"});
if (chatService.isGenerating(chatId)) return res.status(409).json({error: "Wait for the response to finish before editing its summary."});
const summary = req.body?.summary;
if (typeof summary !== "string" || summary.length > config.chatSummaryMaxChars) {
return res.status(400).json({error: `Summary must be text of at most ${config.chatSummaryMaxChars} characters.`});
}
if (req.body.previousSummary !== (chat.context_summary || "")) return res.status(409).json({error: "The summary changed. Reopen memory to review it before saving."});
repositories.updateChatContextSummary(chatId, req.session.user, summary.trim() || null, chat.context_summary_message_id);
return res.json({summary: summary.trim()});
}); });
router.post("/chats/:id/memories", validateBody(memoryValidator), (req, res) => { router.post("/chats/:id/memories", validateBody(memoryValidator), (req, res) => {
+3
View File
@@ -6,6 +6,7 @@ import {asyncHandler} from "../middleware/asyncHandler.js";
import bcrypt from "bcrypt"; import bcrypt from "bcrypt";
import {requireLogin} from "../middleware/auth.js"; import {requireLogin} from "../middleware/auth.js";
import {validateBody} from "../middleware/validate.js"; import {validateBody} from "../middleware/validate.js";
import {newPasswordError} from "../services/passwordPolicy.js";
const usernameValidator = (body) => { const usernameValidator = (body) => {
const username = String(body?.username || "").trim(); const username = String(body?.username || "").trim();
@@ -72,6 +73,8 @@ export function createSettingsRouter({repositories}) {
router.put("/settings/password", validateBody(passwordValidator), asyncHandler(async (req, res) => { router.put("/settings/password", validateBody(passwordValidator), asyncHandler(async (req, res) => {
const username = req.session.user; const username = req.session.user;
const {currentPassword, newPassword} = req.validatedBody; const {currentPassword, newPassword} = req.validatedBody;
const passwordError = newPasswordError(newPassword);
if (passwordError) return res.status(400).json({error: passwordError, code: "PASSWORD_TOO_LONG"});
const user = repositories.getUser(username); const user = repositories.getUser(username);
if (!user) return res.status(404).json({error: "User not found"}); if (!user) return res.status(404).json({error: "User not found"});
const valid = await bcrypt.compare(currentPassword, user.password); const valid = await bcrypt.compare(currentPassword, user.password);
+8 -2
View File
@@ -25,14 +25,20 @@ function buildConfigWarnings(config) {
return warnings; return warnings;
} }
export function createSystemRouter({modelService, config}) { export function createHealthRouter({modelService}) {
const router = express.Router(); const router = express.Router();
router.get("/health", asyncHandler(async (req, res) => { router.get("/health", asyncHandler(async (req, res) => {
const model = await modelService.checkHealth(); const model = await modelService.checkHealth();
return res.status(model.ok ? 200 : 503).json({ok: model.ok, model}); return res.status(model.ok ? 200 : 503).json({ok: Boolean(model.ok)});
})); }));
return router;
}
export function createSystemRouter({modelService, config}) {
const router = express.Router();
router.get("/admin/diagnostics", requireLogin, requireAdmin, asyncHandler(async (req, res) => { router.get("/admin/diagnostics", requireLogin, requireAdmin, asyncHandler(async (req, res) => {
const diagnostics = await modelService.getDiagnostics(); const diagnostics = await modelService.getDiagnostics();
return res.status(diagnostics.model.ok ? 200 : 503).json({ return res.status(diagnostics.model.ok ? 200 : 503).json({
+19 -16
View File
@@ -27,10 +27,10 @@ import { supportsReasoning } from "../../public/js/modelCapabilities.js";
import {VISION_EVIDENCE_GUIDANCE} from "./visionAnalysis.js"; import {VISION_EVIDENCE_GUIDANCE} from "./visionAnalysis.js";
export function createChatService(repositories, modelService, config, webSearchProvider = new SearXNGSearchProvider(config), attachmentService = null, visionDebugService = null) { export function createChatService(repositories, modelService, config, webSearchProvider = new SearXNGSearchProvider(config), attachmentService = null, visionDebugService = null) {
const formatChatMessage = row => { const formatChatMessage = (row, related = null) => {
const message = formatBaseChatMessage(row); const message = formatBaseChatMessage(row);
if (message?.role === "bot") { if (message?.role === "bot") {
const versions = repositories.listMessageVersions(row.id); const versions = related?.versions ? related.versions.get(row.id) || [] : repositories.listMessageVersions(row.id);
message.versionCount = versions.length || 1; message.versionCount = versions.length || 1;
message.versionIndex = versions.length ? Number(row.retry_active_index || 0) : 0; message.versionIndex = versions.length ? Number(row.retry_active_index || 0) : 0;
if (versions.length) message.responseVersions = versions.map(version => ({ if (versions.length) message.responseVersions = versions.map(version => ({
@@ -38,7 +38,7 @@ export function createChatService(repositories, modelService, config, webSearchP
sources: parseSources(version.sources), modelName: version.model_name, createdAt: version.created_at sources: parseSources(version.sources), modelName: version.model_name, createdAt: version.created_at
})); }));
} }
const attachments = row && attachmentService?.list(row.id); const attachments = row && (related?.attachments ? related.attachments.get(row.id) : attachmentService?.list(row.id));
return message && attachments?.length ? { ...message, attachments } : message; return message && attachments?.length ? { ...message, attachments } : message;
}; };
const chatGenerationQueues = new Map(); const chatGenerationQueues = new Map();
@@ -198,6 +198,9 @@ export function createChatService(repositories, modelService, config, webSearchP
const latestSummarized = summarizable[summarizable.length - 1]; const latestSummarized = summarizable[summarizable.length - 1];
if (!latestSummarized || !summary) return; if (!latestSummarized || !summary) return;
const current = repositories.getChat(chatId, user);
// A manual correction or branch switch during generation wins over the stale summary.
if (!current || current.context_summary !== session.context_summary || current.context_summary_message_id !== session.context_summary_message_id) return;
repositories.updateChatContextSummary(chatId, user, clampSummary(summary), latestSummarized.id); repositories.updateChatContextSummary(chatId, user, clampSummary(summary), latestSummarized.id);
}; };
@@ -573,18 +576,17 @@ export function createChatService(repositories, modelService, config, webSearchP
} }
}); });
const listActiveBranchMessages = (chatId) => repositories.listActiveBranchMessages(chatId).map((row) => { const formatHistory = (chatId, rows) => {
const message = formatChatMessage(row); const metadata = repositories.getHistoryMetadata(chatId, rows.map(row => row.id));
const siblings = repositories.listSiblingChatMessages(chatId, row.role, row.parent_message_id); return rows.map(row => ({...formatChatMessage(row, metadata), ...metadata.siblings.get(row.id)}));
const siblingIndex = siblings.findIndex((sibling) => sibling.id === row.id); };
return { const listActiveBranchMessages = chatId => formatHistory(chatId, repositories.listActiveBranchMessages(chatId));
...message, const getHistoryPage = (chatId, options) => {
siblingIndex, const result = repositories.getActiveBranchPage(chatId, options);
siblingCount: siblings.length, if (result.error) return result;
previousSiblingId: siblingIndex > 0 ? siblings[siblingIndex - 1].id : null, const {rows, ...details} = result;
nextSiblingId: siblingIndex >= 0 && siblingIndex < siblings.length - 1 ? siblings[siblingIndex + 1].id : null return {...details, messages: formatHistory(chatId, rows)};
}; };
});
const cloneChatThroughMessage = ({ user, sourceChat, messageId = null }) => { const cloneChatThroughMessage = ({ user, sourceChat, messageId = null }) => {
if (repositories.countChats(user) >= config.chatLimit) { if (repositories.countChats(user) >= config.chatLimit) {
@@ -779,9 +781,10 @@ export function createChatService(repositories, modelService, config, webSearchP
const leaf = includeDescendants ? repositories.getLatestDescendantMessage(chatId, messageId) : message; const leaf = includeDescendants ? repositories.getLatestDescendantMessage(chatId, messageId) : message;
repositories.updateChatActiveLeaf(chatId, user, leaf?.id || messageId); repositories.updateChatActiveLeaf(chatId, user, leaf?.id || messageId);
repositories.updateChatContextSummary(chatId, user, null, 0); repositories.updateChatContextSummary(chatId, user, null, 0);
return { messages: listActiveBranchMessages(chatId) }; return getHistoryPage(chatId);
}, },
listActiveBranchMessages, listActiveBranchMessages,
getHistoryPage,
stopGeneration({ user, chatId }) { stopGeneration({ user, chatId }) {
if (!repositories.getChat(chatId, user)) return { error: "Chat not found", status: 404 }; if (!repositories.getChat(chatId, user)) return { error: "Chat not found", status: 404 };
const controller = activeGenerations.get(chatId); const controller = activeGenerations.get(chatId);
+7
View File
@@ -0,0 +1,7 @@
// bcrypt uses the first 72 UTF-8 bytes. Apply this only when creating a new hash:
// existing credentials must remain usable for login and current-password checks.
export function newPasswordError(password) {
return Buffer.byteLength(password, "utf8") > 72
? "Password must be at most 72 UTF-8 bytes (some characters use multiple bytes)"
: null;
}
+25 -1
View File
@@ -1,6 +1,30 @@
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import {ApiError, get, post, stream} from "../public/js/api.js"; import {ApiError, get, post, stream, multipart, download} from "../public/js/api.js";
test("multipart and downloads share HTTP checks, cancellation and body deadlines", async t => {
const form = new FormData(); form.append("image", new Blob(["image"]), "image.png");
t.mock.method(globalThis, "fetch", async (_url, options) => {
assert.equal(options.body, form);
assert.equal(options.headers["Content-Type"], undefined);
return Response.json({id: "uploaded"});
});
assert.deepEqual(await multipart("/uploads", form), {id: "uploaded"});
t.mock.method(globalThis, "fetch", async () => Response.json({error: "Please log in", code: "LOGIN_REQUIRED"}, {status: 403}));
const failed = await download("/export");
assert.equal(failed.code, "LOGIN_REQUIRED");
assert.equal(failed.blob, undefined);
t.mock.method(globalThis, "fetch", async () => new Response("download content"));
assert.equal(await (await download("/export")).blob.text(), "download content");
t.mock.method(globalThis, "fetch", async () => new Response(new ReadableStream()));
assert.equal((await download("/stalled", {timeoutMs: 10})).code, "TIMEOUT");
t.mock.method(globalThis, "fetch", async () => new Promise(() => {}));
const controller = new AbortController();
const pending = multipart("/upload", form, {signal: controller.signal});
controller.abort();
assert.equal((await pending).code, "ABORTED");
assert.equal((await multipart("/upload", form, {timeoutMs: 10})).code, "TIMEOUT");
});
test("JSON requests retain HTTP metadata, handle empty success, and distinguish bad bodies from network errors", async t => { test("JSON requests retain HTTP metadata, handle empty success, and distinguish bad bodies from network errors", async t => {
let response; let response;
+96
View File
@@ -116,6 +116,102 @@ async function request(baseUrl, path, {method = "GET", body, jar} = {}) {
return {status: response.status, json}; return {status: response.status, json};
} }
test("public health checks reach the backend without exposing diagnostics or requiring a session", async t => {
const server = await startTestServer();
t.after(() => server.close());
let checks = 0;
server.app.locals.modelService.checkHealth = async () => { checks++; return {ok: checks === 1, backendUrl: "private-backend", error: "private detail"}; };
assert.deepEqual(await request(server.baseUrl, "/health"), {status: 200, json: {ok: true}});
assert.deepEqual(await request(server.baseUrl, "/health"), {status: 503, json: {ok: false}});
assert.equal(checks, 2);
assert.equal((await request(server.baseUrl, "/admin/diagnostics")).status, 403);
});
test("new passwords are limited to 72 UTF-8 bytes while legacy credentials remain usable", async t => {
const server = await startTestServer({config: {authRateLimitMax: 100}});
t.after(() => server.close());
for (const password of ["a".repeat(73), "é".repeat(37), "😀".repeat(19)]) {
const result = await request(server.baseUrl, "/register", {method: "POST", body: {username: "too-long", password}});
assert.equal(result.status, 400);
assert.equal(result.json.code, "PASSWORD_TOO_LONG");
assert.equal(server.app.locals.repositories.getUser("too-long"), undefined);
}
const jar = new CookieJar();
const boundary = "é".repeat(36);
assert.equal((await request(server.baseUrl, "/register", {method: "POST", jar, body: {username: "boundary", password: boundary}})).status, 200);
const change = newPassword => request(server.baseUrl, "/settings/password", {method: "PUT", jar, body: {currentPassword: boundary, newPassword}});
assert.equal((await change("a".repeat(73))).json.code, "PASSWORD_TOO_LONG");
assert.equal((await change("😀".repeat(19))).json.code, "PASSWORD_TOO_LONG");
assert.equal((await change("a".repeat(72))).status, 200);
const legacy = "a".repeat(72) + "existing suffix";
server.app.locals.repositories.insertUser("legacy", await bcrypt.hash(legacy, 4));
assert.equal((await request(server.baseUrl, "/login", {method: "POST", jar, body: {username: "legacy", password: legacy}})).status, 200);
assert.equal((await request(server.baseUrl, "/settings/password", {method: "PUT", jar, body: {currentPassword: legacy, newPassword: "new-password"}})).status, 200);
});
test("history pages are bounded, preserve branch identity and batch message metadata", async t => {
const server = await startTestServer(); t.after(() => server.close());
const jar = new CookieJar();
await request(server.baseUrl, "/register", {method: "POST", jar, body: {username: "paged", password: "password123"}});
const repo = server.app.locals.repositories;
const chat = repo.createChat("paged", "Long conversation");
let parent = null;
const ids = [];
for (let index = 0; index < 125; index++) {
parent = Number(repo.insertBranchChatMessage(chat.id, index % 2 ? "bot" : "user", `Message ${index}`, null, "test", "complete", null, parent).lastInsertRowid);
ids.push(parent);
}
repo.saveMessageVersion(chat.id, ids[123], {content: "Alternate answer", modelName: "other-model"});
const sibling = Number(repo.insertBranchChatMessage(chat.id, "bot", "Other branch", null, "test", "complete", null, ids[122]).lastInsertRowid);
repo.updateChatActiveLeaf(chat.id, "paged", parent);
t.mock.method(repo, "listMessageVersions", () => assert.fail("History should batch versions"));
t.mock.method(repo, "listSiblingChatMessages", () => assert.fail("History should batch siblings"));
t.mock.method(server.app.locals.attachmentService, "list", () => assert.fail("History should batch attachments"));
const first = await request(server.baseUrl, `/chats/${chat.id}/messages`, {jar});
assert.equal(first.status, 200); assert.equal(first.json.messages.length, 50);
assert.equal(first.json.messages[0].id, ids[75]);
const answer = first.json.messages.find(message => message.id === ids[123]);
assert.equal(answer.versionCount, 2); assert.equal(answer.nextSiblingId, sibling);
const page = first.json.pagination;
const second = await request(server.baseUrl, `/chats/${chat.id}/messages?before=${page.before}&leaf=${page.leafId}`, {jar});
assert.deepEqual(second.json.messages.map(message => message.id), ids.slice(25, 75));
const third = await request(server.baseUrl, `/chats/${chat.id}/messages?before=${second.json.pagination.before}&leaf=${page.leafId}`, {jar});
assert.deepEqual(third.json.messages.map(message => message.id), ids.slice(0, 25));
assert.equal(third.json.pagination.hasMore, false);
const oldMatch = await request(server.baseUrl, `/chats/${chat.id}/messages?around=${ids[4]}`, {jar});
assert.equal(oldMatch.json.matchOnBranch, true); assert.equal(oldMatch.json.pagination.hasNewer, true);
assert.equal(oldMatch.json.messages.at(-1).id, ids[4]);
assert.equal((await request(server.baseUrl, `/chats/${chat.id}/messages?before=${sibling}`, {jar})).status, 400);
repo.updateChatActiveLeaf(chat.id, "paged", sibling);
assert.equal((await request(server.baseUrl, `/chats/${chat.id}/messages?before=${page.before}&leaf=${page.leafId}`, {jar})).status, 409);
assert.equal((await request(server.baseUrl, `/chats/${chat.id}/messages?limit=101`, {jar})).status, 400);
assert.equal((await request(server.baseUrl, `/chats/${chat.id}/branches`, {jar})).json.nodes.length, 126);
const stranger = new CookieJar();
await request(server.baseUrl, "/register", {method: "POST", jar: stranger, body: {username: "stranger", password: "password123"}});
for (const suffix of ["messages", "branches", `messages/${ids[123]}/versions`, "memories"]) {
assert.equal((await request(server.baseUrl, `/chats/${chat.id}/${suffix}`, {jar: stranger})).status, 404);
}
});
test("conversation memory exposes context estimates and supports validated summary corrections", async t => {
const server = await startTestServer(); t.after(() => server.close());
const jar = new CookieJar();
await request(server.baseUrl, "/register", {method: "POST", jar, body: {username: "memory-editor", password: "password123"}});
const repo = server.app.locals.repositories;
const chat = repo.createChat("memory-editor", "Memory");
repo.createChatMemory("memory-editor", chat, "Prefers concise answers.");
repo.updateChatContextSummary(chat.id, "memory-editor", "Old summary", 0);
const loaded = (await request(server.baseUrl, `/chats/${chat.id}/memories`, {jar})).json;
assert.equal(loaded.summary, "Old summary"); assert.ok(loaded.context.estimatedTokens > 0);
const update = body => request(server.baseUrl, `/chats/${chat.id}/summary`, {method: "PUT", jar, body});
assert.equal((await update({summary: "Corrected summary", previousSummary: "Old summary"})).status, 200);
assert.equal(repo.getChat(chat.id, "memory-editor").context_summary, "Corrected summary");
assert.equal((await update({summary: "Stale edit", previousSummary: "Old summary"})).status, 409);
assert.equal((await update({summary: "x".repeat(loaded.summaryMaxChars + 1)})).status, 400);
assert.equal((await update({summary: "", previousSummary: "Corrected summary"})).status, 200);
assert.equal(repo.getChat(chat.id, "memory-editor").context_summary, null);
});
test("auth flow validates inputs and persists session", async () => { test("auth flow validates inputs and persists session", async () => {
const server = await startTestServer(); const server = await startTestServer();
const jar = new CookieJar(); const jar = new CookieJar();
+29
View File
@@ -4,6 +4,7 @@
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { appendAttachments, createAttachmentComposer } from "../public/js/attachments.js"; import { appendAttachments, createAttachmentComposer } from "../public/js/attachments.js";
import {createDraftStore} from "../public/js/draftStore.js";
class Element { class Element {
constructor() { this.children = []; this.listeners = {}; this.classList = { add() { }, remove() { } }; } constructor() { this.children = []; this.listeners = {}; this.classList = { add() { }, remove() { } }; }
@@ -13,6 +14,34 @@ class Element {
addEventListener(name, listener) { this.listeners[name] = listener; } addEventListener(name, listener) { this.listeners[name] = listener; }
click() { this.clicked = true; } click() { this.clicked = true; }
} }
test("uploaded draft references recover after reload and unavailable images cannot be sent", async t => {
const elements = Object.fromEntries(["attachImage", "imagePicker", "uploadPreviews", "composer"].map(id => [id, new Element()]));
const originalDocument = globalThis.document;
t.after(() => { globalThis.document = originalDocument; });
globalThis.document = {getElementById: id => elements[id], querySelector: () => elements.composer, createElement: () => new Element()};
t.mock.method(globalThis, "fetch", async () => Response.json({maxFiles: 5, maxSizeMb: 10}));
const values = new Map();
const storage = {getItem: key => values.get(key), setItem: (key, value) => values.set(key, value)};
const saved = createDraftStore("alice", {storage});
const metadata = {id: "12345678-1234-1234-1234-123456789abc", originalName: "draft.png"};
saved.setAttachments(7, [metadata]);
const composer = createAttachmentComposer({onChange() {}, onError: message => assert.fail(message)});
composer.setDraftStore(createDraftStore("alice", {storage}));
composer.restoreDraft(7);
assert.equal(elements.uploadPreviews.children[0].children[0].src, `/attachments/${metadata.id}`);
assert.equal(composer.pending, true);
elements.uploadPreviews.children[0].children[0].onload();
assert.equal(composer.pending, false);
assert.deepEqual(composer.attachments, [metadata]);
elements.uploadPreviews.children[0].children[0].onerror();
assert.equal(composer.pending, true);
assert.deepEqual(composer.attachments, []);
assert.deepEqual(createDraftStore("alice", {storage}).attachments(7), [metadata]);
elements.uploadPreviews.children[0].children.at(-1).onclick();
assert.equal(composer.pending, false);
assert.deepEqual(createDraftStore("alice", {storage}).attachments(7), []);
});
test("composer preview, pending state, upload failure/retry, removal, paste, and history rendering", async t => { test("composer preview, pending state, upload failure/retry, removal, paste, and history rendering", async t => {
const originalDocument = globalThis.document, originalFetch = globalThis.fetch; const originalDocument = globalThis.document, originalFetch = globalThis.fetch;
t.after(() => { globalThis.document = originalDocument; globalThis.fetch = originalFetch; }); t.after(() => { globalThis.document = originalDocument; globalThis.fetch = originalFetch; });
+26 -1
View File
@@ -24,7 +24,7 @@ function fixture(overrides = {}) {
getChatById: id => state.chatSessions.find(chat => chat.id === id), getChatById: id => state.chatSessions.find(chat => chat.id === id),
updateWorkspaceCopy: noop, renderChatList: noop, updateChatActionState: noop, scrollMessagesToBottom: noop, updateWorkspaceCopy: noop, renderChatList: noop, updateChatActionState: noop, scrollMessagesToBottom: noop,
addMessage: () => ({isConnected: true, querySelector: () => element(), remove: noop, replaceWith: noop}), addMessage: () => ({isConnected: true, querySelector: () => element(), remove: noop, replaceWith: noop}),
setMessageContent: noop, setActiveChat: async id => calls.refresh.push(id), loadSummary: noop, markOnboardingCompleted: noop, maybeShowPersonaNudge: noop, setMessageContent: noop, setActiveChat: async id => { calls.refresh.push(id); return overrides.refreshResult; }, loadSummary: noop, markOnboardingCompleted: noop, maybeShowPersonaNudge: noop,
api: {put: async () => overrides.rename ? await overrides.rename : {}, post: async () => assert.fail("Send cancellation must abort its own request"), api: {put: async () => overrides.rename ? await overrides.rename : {}, post: async () => assert.fail("Send cancellation must abort its own request"),
stream: async (_url, payload, handlers) => { stream: async (_url, payload, handlers) => {
calls.streams.push(payload); calls.streams.push(payload);
@@ -84,3 +84,28 @@ test("Stop during chat creation prevents a later stream and releases the send lo
assert.equal(s.state.isProcessing, false); assert.equal(s.state.isProcessing, false);
assert.equal(s.elements.msgInput.value, "Hello"); assert.equal(s.elements.msgInput.value, "Hello");
}); });
test("a completed reply and draft survive a failed history refresh without a false success notice", async () => {
const s = fixture({refreshResult: {error: "History unavailable", status: 503}});
await s.sendMessage();
assert.equal(s.state.currentMessages.at(-1).content, "Done");
assert.equal(s.state.currentMessages.filter(message => message.role === "bot").length, 1);
assert.equal(s.elements.msgInput.value, "Hello");
assert.match(s.calls.notices.at(-1), /could not be refreshed: History unavailable/);
assert.ok(!s.calls.notices.includes("Reply received."));
assert.equal(s.state.isProcessing, false);
});
test("navigation during refresh clears only the sent chat's saved draft", async () => {
const refresh = deferred();
const s = fixture({refreshResult: refresh.promise});
const sending = s.sendMessage();
await new Promise(resolve => setImmediate(resolve));
s.state.chatDrafts.set(1, "Hello");
s.state.activeChatId = 2;
s.elements.msgInput.value = "Other chat draft";
refresh.resolve({ok: true});
await sending;
assert.equal(s.state.chatDrafts.has(1), false);
assert.equal(s.elements.msgInput.value, "Other chat draft");
});
+33
View File
@@ -0,0 +1,33 @@
import test from "node:test";
import assert from "node:assert/strict";
import {createDraftStore} from "../public/js/draftStore.js";
test("drafts recover text and uploaded references per account and conversation", () => {
const values = new Map();
const storage = {getItem: key => values.get(key), setItem: (key, value) => values.set(key, value)};
const alice = createDraftStore("alice", {storage});
const metadata = {id: "12345678-1234-1234-1234-123456789abc", originalName: "image.png"};
alice.textDrafts.set(1, "Unsent text"); alice.textDrafts.set("new", "New conversation draft");
alice.setAttachments(1, [metadata]);
const recovered = createDraftStore("alice", {storage});
assert.equal(recovered.textDrafts.get(1), "Unsent text");
assert.equal(recovered.textDrafts.get("new"), "New conversation draft");
assert.deepEqual(recovered.attachments(1), [metadata]);
assert.equal(createDraftStore("bob", {storage}).textDrafts.size, 0);
assert.deepEqual(createDraftStore("bob", {storage}).attachments(1), []);
recovered.textDrafts.delete(1);
assert.deepEqual(createDraftStore("alice", {storage}).attachments(1), [metadata]);
recovered.setAttachments(1, []);
const sent = createDraftStore("alice", {storage});
assert.equal(sent.textDrafts.has(1), false);
assert.deepEqual(sent.attachments(1), []);
assert.equal(sent.textDrafts.get("new"), "New conversation draft");
});
test("unavailable storage reports recovery failure once while keeping drafts usable", () => {
const notices = [];
const drafts = createDraftStore("alice", {storage: {getItem() { throw new Error("Blocked"); }, setItem() { throw new Error("Full"); }}, onError: message => notices.push(message)});
drafts.textDrafts.set(1, "Still here"); drafts.setAttachments(1, []);
assert.equal(drafts.textDrafts.get(1), "Still here");
assert.equal(notices.length, 1);
});
+1 -1
View File
@@ -96,7 +96,7 @@ test("inline renderer streams in place, follows only nearby output, commits or r
}); });
test("Regenerate uses inline activity and never reloads or appends a chat message", async () => { test("Regenerate uses inline activity and never reloads or appends a chat message", async () => {
const source = await readFile(new URL("../public/js/script.js", import.meta.url), "utf8"); const source = await readFile(new URL("../public/js/messageActions.js", import.meta.url), "utf8");
const regenerate = source.slice(source.indexOf("async function retryLatestAssistantMessage"), source.indexOf("async function switchResponseVersion")); const regenerate = source.slice(source.indexOf("async function retryLatestAssistantMessage"), source.indexOf("async function switchResponseVersion"));
assert.match(regenerate, /setLoadingState\(true, false\)/); assert.match(regenerate, /setLoadingState\(true, false\)/);
assert.match(regenerate, /regenerateInline/); assert.match(regenerate, /regenerateInline/);
+17
View File
@@ -0,0 +1,17 @@
import test from "node:test";
import assert from "node:assert/strict";
import {createWorkspaceTransfer} from "../public/js/workspaceTransfer.js";
test("export cancellation makes no request and failed downloads report structured server errors", async t => {
let requests = 0, format = null;
t.mock.method(globalThis, "fetch", async () => { requests++; return Response.json({error: "Session expired", code: "LOGIN_REQUIRED"}, {status: 403}); });
const notices = [];
const transfer = createWorkspaceTransfer({conversation: {activeChatId: 1, currentMessages: [{}]}, getChatById: () => ({id: 1, title: "Chat"}),
setNotice: (text, kind) => notices.push({text, kind}), promptPopup: async () => format});
await transfer.exportCurrentChat(); assert.equal(requests, 0);
format = "md";
await transfer.exportCurrentChat();
await transfer.backupWorkspace();
assert.equal(requests, 2);
assert.deepEqual(notices, [{text: "Session expired", kind: "error"}, {text: "Session expired", kind: "error"}]);
});