Main #15
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<meta charset="utf-8">
|
||||
<title>Conversation navigation regression tests</title>
|
||||
<h1>Conversation navigation regression tests</h1>
|
||||
<div id="testTitle"></div>
|
||||
<input id="testModel" aria-label="Model">
|
||||
<input id="chatSearchInput" aria-label="Search">
|
||||
<textarea id="msgInput" aria-label="Draft"></textarea>
|
||||
<div id="messages"></div>
|
||||
<pre id="results">Running...</pre>
|
||||
<script type="module">
|
||||
import {registerConversationTests} from "/browser-tests/conversationCases.js";
|
||||
const cases = [], results = [];
|
||||
const assert = {
|
||||
equal(actual, expected) { if (!Object.is(actual, expected)) throw new Error(`Expected ${expected}, received ${actual}`); },
|
||||
deepEqual(actual, expected) { this.equal(JSON.stringify(actual), JSON.stringify(expected)); }
|
||||
};
|
||||
registerConversationTests((name, run) => cases.push({name, run}), assert);
|
||||
for (const {name, run} of cases) {
|
||||
try { await run(); results.push(`PASS ${name}`); }
|
||||
catch (error) { results.push(`FAIL ${name}: ${error.stack}`); }
|
||||
}
|
||||
document.getElementById("results").textContent = results.join("\n");
|
||||
document.title = `${results.filter(result => result.startsWith("PASS")).length}/${cases.length} navigation tests passed`;
|
||||
</script>
|
||||
</html>
|
||||
@@ -0,0 +1,197 @@
|
||||
/** The same navigation regressions run in a real browser and Node's lightweight DOM fixture. */
|
||||
import {createConversationController} from "../public/js/conversationController.js";
|
||||
import {createConversationState} from "../public/js/conversationState.js";
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
function fixture() {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const pending = [];
|
||||
globalThis.fetch = (url, options) => new Promise(resolve => pending.push({url, options, resolve}));
|
||||
const state = createConversationState();
|
||||
state.chatSessions = [1, 2, 3].map(id => ({id, title: `Chat ${id}`}));
|
||||
state.activeChatId = 1;
|
||||
state.currentMessages = [{id: 11, content: "Chat A's answer"}];
|
||||
state.messageRetryState.set(11, {retryActiveIndex: 1});
|
||||
state.chatDrafts.set(2, "Draft B");
|
||||
state.chatDrafts.set(3, "Draft C");
|
||||
history.replaceState(null, "", "/app/chats/1");
|
||||
const input = document.getElementById("msgInput");
|
||||
const messages = document.getElementById("messages");
|
||||
const title = document.getElementById("testTitle");
|
||||
const model = document.getElementById("testModel");
|
||||
input.value = "Draft A";
|
||||
messages.textContent = "Chat A's answer";
|
||||
title.textContent = "Chat 1";
|
||||
model.value = "Model A";
|
||||
const attachmentDrafts = new Map([[2, ["Image B"]], [3, ["Image C"]]]);
|
||||
const attachments = {pending: false, attachments: ["Image A"],
|
||||
saveDraft(id) { attachmentDrafts.set(id, this.attachments); },
|
||||
restoreDraft(id) { this.attachments = attachmentDrafts.get(id) || []; }};
|
||||
const ui = {loading: false, activity: false, notices: [], renders: []};
|
||||
const controller = createConversationController({state, attachmentComposer: attachments,
|
||||
renderChatList: noop, resizeComposerInput: noop, isMobileLayout: () => false, closeMobileSidebar: noop,
|
||||
setChatLoading: value => { ui.loading = value; }, setChatActivity: value => { ui.activity = value; },
|
||||
updateWorkspaceCopy: () => { title.textContent = `Chat ${state.activeChatId}`; }, updateChatActionState: noop,
|
||||
setNotice: message => ui.notices.push(message),
|
||||
populateGenerationSettings: chat => { model.value = chat.preferred_model; },
|
||||
renderMessages: () => { messages.textContent = state.currentMessages.map(message => message.content).join("\n"); ui.renders.push(state.activeChatId); },
|
||||
updateJumpToLatest: noop, updateSendState: noop, loadSummary: noop});
|
||||
const reply = async (index, body, status = 200) => {
|
||||
const response = new Response(JSON.stringify(body), {status, headers: {"Content-Type": "application/json"}});
|
||||
// Avoid browser Blob-reading tasks: this fixture controls navigation order,
|
||||
// while api.test.js covers actual response-body reads and their deadlines.
|
||||
response.text = async () => JSON.stringify(body);
|
||||
pending[index].resolve(response);
|
||||
// Fetch, response body parsing and the controller each await a promise.
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
};
|
||||
const result = id => ({chat: {id, preferred_model: `Model ${id}`}, messages: [{id: id * 10 + 1, content: `Answer ${id}`}]});
|
||||
return {state, ui, input, messages, title, model, attachments, attachmentDrafts, pending, reply, result, ...controller,
|
||||
cleanup: () => { globalThis.fetch = originalFetch; }};
|
||||
}
|
||||
|
||||
export function registerConversationTests(test, assert) {
|
||||
const unchanged = s => {
|
||||
assert.equal(s.state.activeChatId, 1);
|
||||
assert.equal(location.pathname, "/app/chats/1");
|
||||
assert.equal(s.messages.textContent, "Chat A's answer");
|
||||
assert.equal(s.title.textContent, "Chat 1");
|
||||
assert.equal(s.model.value, "Model A");
|
||||
assert.equal(s.input.value, "Draft A");
|
||||
assert.deepEqual(s.attachments.attachments, ["Image A"]);
|
||||
assert.equal(s.state.messageRetryState.get(11).retryActiveIndex, 1);
|
||||
};
|
||||
test("failed navigation preserves chat identity, URL, messages, settings and both drafts", async () => {
|
||||
const s = fixture();
|
||||
try {
|
||||
const opening = s.setActiveChat(2);
|
||||
unchanged(s);
|
||||
await s.reply(0, {error: "Chat B failed to load"}, 503);
|
||||
const result = await opening;
|
||||
assert.equal(result.error, "Chat B failed to load");
|
||||
assert.equal(result.status, 503);
|
||||
unchanged(s);
|
||||
assert.equal(s.state.chatDrafts.get(2), "Draft B");
|
||||
assert.deepEqual(s.attachmentDrafts.get(2), ["Image B"]);
|
||||
assert.deepEqual(s.ui.notices, ["Chat B failed to load"]);
|
||||
assert.equal(s.ui.loading, false);
|
||||
assert.equal(s.ui.activity, false);
|
||||
} finally { s.cleanup(); }
|
||||
});
|
||||
|
||||
for (const order of ["older-first", "newer-first"]) {
|
||||
for (const staleFails of [false, true]) {
|
||||
test(`overlapping navigation (${order}, stale ${staleFails ? "failure" : "success"}) commits only the latest chat`, async () => {
|
||||
const s = fixture();
|
||||
try {
|
||||
const older = s.setActiveChat(2), newer = s.setActiveChat(3);
|
||||
unchanged(s);
|
||||
const replyOlder = () => s.reply(0, staleFails ? {error: "Stale error"} : s.result(2), staleFails ? 500 : 200);
|
||||
if (order === "older-first") {
|
||||
await replyOlder(); await older;
|
||||
unchanged(s);
|
||||
assert.equal(s.ui.loading, true);
|
||||
assert.equal(s.ui.activity, true);
|
||||
// Edits made while loading belong to the still-visible chat A.
|
||||
s.input.value = "Draft A updated";
|
||||
await s.reply(1, s.result(3)); await newer;
|
||||
} else {
|
||||
await s.reply(1, s.result(3)); await newer;
|
||||
await replyOlder(); await older;
|
||||
}
|
||||
assert.equal(s.state.activeChatId, 3);
|
||||
assert.equal(location.pathname, "/app/chats/3");
|
||||
assert.equal(s.messages.textContent, "Answer 3");
|
||||
assert.equal(s.title.textContent, "Chat 3");
|
||||
assert.equal(s.model.value, "Model 3");
|
||||
assert.equal(s.input.value, "Draft C");
|
||||
assert.equal(s.state.chatDrafts.get(1), order === "older-first" ? "Draft A updated" : "Draft A");
|
||||
assert.equal(s.state.chatDrafts.get(2), "Draft B");
|
||||
assert.deepEqual(s.attachments.attachments, ["Image C"]);
|
||||
assert.deepEqual(s.attachmentDrafts.get(1), ["Image A"]);
|
||||
assert.deepEqual(s.ui.renders, [3]);
|
||||
assert.deepEqual(s.ui.notices, []);
|
||||
assert.equal(s.state.messageRetryState.size, 0);
|
||||
assert.equal(s.ui.loading, false);
|
||||
} finally { s.cleanup(); }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test("a late success cannot replace A after the newer navigation fails", async () => {
|
||||
const s = fixture();
|
||||
try {
|
||||
const older = s.setActiveChat(2), newer = s.setActiveChat(3);
|
||||
await s.reply(1, {error: "Chat C failed"}, 500); await newer;
|
||||
await s.reply(0, s.result(2)); await older;
|
||||
unchanged(s);
|
||||
assert.deepEqual(s.ui.notices, ["Chat C failed"]);
|
||||
assert.equal(s.ui.loading, false);
|
||||
} finally { s.cleanup(); }
|
||||
});
|
||||
|
||||
for (const failure of ["activation", "reload"]) {
|
||||
test(`search-result branch ${failure} failure preserves the displayed chat`, async () => {
|
||||
const s = fixture();
|
||||
try {
|
||||
const opening = s.setActiveChat(2, {matchMessageId: 25});
|
||||
await s.reply(0, s.result(2));
|
||||
assert.equal(s.pending[1].url, "/chats/2/branches/25/activate");
|
||||
await s.reply(1, failure === "activation" ? {error: "Branch failed"} : {}, failure === "activation" ? 500 : 200);
|
||||
if (failure === "reload") await s.reply(2, {error: "Reload failed"}, 500);
|
||||
await opening;
|
||||
unchanged(s);
|
||||
assert.equal(s.ui.notices.length, 1);
|
||||
assert.equal(s.ui.loading, false);
|
||||
} finally { s.cleanup(); }
|
||||
});
|
||||
}
|
||||
|
||||
test("a stale branch activation cannot reload or clear a newer navigation's activity", async () => {
|
||||
const s = fixture();
|
||||
try {
|
||||
const older = s.setActiveChat(2, {matchMessageId: 25});
|
||||
await s.reply(0, s.result(2));
|
||||
const newer = s.setActiveChat(3);
|
||||
await s.reply(1, {}); await older;
|
||||
assert.equal(s.pending.length, 3);
|
||||
assert.equal(s.ui.loading, true);
|
||||
unchanged(s);
|
||||
await s.reply(2, s.result(3)); await newer;
|
||||
assert.equal(s.state.activeChatId, 3);
|
||||
} finally { s.cleanup(); }
|
||||
});
|
||||
|
||||
test("older messages prepend once and a failed page keeps the current history and draft", async () => {
|
||||
const s = fixture();
|
||||
try {
|
||||
s.state.historyPage = {hasMore: true, before: 11, leafId: 11};
|
||||
const older = s.loadOlderMessages();
|
||||
await s.loadOlderMessages();
|
||||
assert.equal(s.pending.length, 1);
|
||||
await s.reply(0, {messages: [{id: 9, content: "Older answer"}], pagination: {hasMore: true, before: 9, leafId: 11}});
|
||||
await older;
|
||||
assert.deepEqual(s.state.currentMessages.map(message => message.id), [9, 11]);
|
||||
assert.equal(s.input.value, "Draft A");
|
||||
const failed = s.loadOlderMessages();
|
||||
await s.reply(1, {error: "Page failed"}, 503); await failed;
|
||||
assert.deepEqual(s.state.currentMessages.map(message => message.id), [9, 11]);
|
||||
assert.equal(s.state.historyLoading, false);
|
||||
assert.equal(s.ui.notices.at(-1), "Page failed");
|
||||
} finally { s.cleanup(); }
|
||||
});
|
||||
|
||||
test("an older page arriving after navigation cannot contaminate the new conversation", async () => {
|
||||
const s = fixture();
|
||||
try {
|
||||
s.state.historyPage = {hasMore: true, before: 11, leafId: 11};
|
||||
const older = s.loadOlderMessages(), opening = s.setActiveChat(2);
|
||||
await s.reply(1, s.result(2)); await opening;
|
||||
await s.reply(0, {messages: [{id: 9, content: "Stale answer"}], pagination: {hasMore: false}}); await older;
|
||||
assert.deepEqual(s.state.currentMessages.map(message => message.id), [21]);
|
||||
assert.equal(s.state.historyLoading, false);
|
||||
assert.equal(s.state.activeChatId, 2);
|
||||
} finally { s.cleanup(); }
|
||||
});
|
||||
}
|
||||
+1
-1
@@ -8,7 +8,7 @@ import { defineConfig } from "eslint/config";
|
||||
export default defineConfig([
|
||||
{ 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"}
|
||||
},
|
||||
]);
|
||||
|
||||
+6
-1
@@ -215,7 +215,8 @@
|
||||
data-chat-action="folder">Move to folder</button>
|
||||
<button id="manageChatMemory"
|
||||
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 & compare</button>
|
||||
<button id="managePersonaState"
|
||||
class="secondary-action chat-drawer-action" type="button"
|
||||
data-chat-action="personaState">Roleplay state</button>
|
||||
@@ -265,6 +266,10 @@
|
||||
</div>
|
||||
|
||||
<div class="messages-wrap">
|
||||
<div class="history-controls">
|
||||
<button id="loadOlderMessages" class="secondary-action" type="button" hidden>Load older messages</button>
|
||||
<button id="loadLatestMessages" class="secondary-action" type="button" hidden>Back to latest messages</button>
|
||||
</div>
|
||||
<div id="messages" class="messages-panel messages-panel-focus" role="log" aria-live="polite"
|
||||
aria-relevant="additions text"></div>
|
||||
<button id="jumpToLatest" class="jump-to-latest secondary-action hidden" type="button">Jump to latest</button>
|
||||
|
||||
+26
-1
@@ -1,5 +1,30 @@
|
||||
/* 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 {
|
||||
margin: 0 0 12px;
|
||||
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; }
|
||||
.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 {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
|
||||
+16
-4
@@ -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);
|
||||
let response;
|
||||
try {
|
||||
response = await abortable(() => fetch(url, {
|
||||
method,
|
||||
credentials: "same-origin",
|
||||
headers: {"Accept": "application/json", "Content-Type": "application/json"},
|
||||
body: data == null ? undefined : JSON.stringify(data),
|
||||
headers,
|
||||
body,
|
||||
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) {
|
||||
return normalizeError(error, scope, response).toJSON();
|
||||
} 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 post = (url, data, options) => request(url, data, "POST", options);
|
||||
export const put = (url, data, options) => request(url, data, "PUT", options);
|
||||
|
||||
+52
-17
@@ -1,6 +1,8 @@
|
||||
/**
|
||||
* @file Verwaltet Bildauswahl, Uploads und Vorschauen sowie die Anzeige gespeicherter Anhänge.
|
||||
*/
|
||||
import {get, del, multipart, ApiError} from "./api.js";
|
||||
|
||||
export function appendAttachments(container, attachments = []) {
|
||||
if (!attachments.length) return;
|
||||
const gallery = document.createElement("div");
|
||||
@@ -27,11 +29,17 @@ export function createAttachmentComposer({onChange, onError}) {
|
||||
const preview = document.getElementById("uploadPreviews");
|
||||
const composer = document.querySelector(".input-container");
|
||||
let items = [];
|
||||
let draftStore = null;
|
||||
let currentDraftId = "new";
|
||||
const persist = (id = currentDraftId, values = items) => {
|
||||
if (id != null) draftStore?.setAttachments(id, values.filter(item => item.metadata).map(item => item.metadata));
|
||||
};
|
||||
const drafts = new Map();
|
||||
let busy = false;
|
||||
let 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 = () => {
|
||||
persist();
|
||||
preview.replaceChildren();
|
||||
preview.hidden = !items.length;
|
||||
for (const item of items) {
|
||||
@@ -39,11 +47,22 @@ export function createAttachmentComposer({onChange, onError}) {
|
||||
card.className = "upload-preview";
|
||||
const image = document.createElement("img");
|
||||
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");
|
||||
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);
|
||||
if (item.error) {
|
||||
if (item.error && item.file) {
|
||||
const retry = document.createElement("button");
|
||||
retry.type = "button"; retry.textContent = "Retry"; retry.disabled = busy;
|
||||
retry.onclick = () => { void upload(item); };
|
||||
@@ -51,11 +70,11 @@ export function createAttachmentComposer({onChange, onError}) {
|
||||
}
|
||||
const remove = document.createElement("button");
|
||||
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 = () => {
|
||||
items = items.filter(v => v !== item);
|
||||
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();
|
||||
};
|
||||
card.append(remove); preview.append(card);
|
||||
@@ -70,16 +89,16 @@ export function createAttachmentComposer({onChange, onError}) {
|
||||
render();
|
||||
try {
|
||||
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 response.json();
|
||||
if (!response.ok) throw new Error(result.error || "Upload failed");
|
||||
const result = await multipart("/uploads", form, {signal: item.controller.signal});
|
||||
if (result.error) throw new ApiError(result.error, 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) {
|
||||
if (error.name !== "AbortError") item.error = error.message || "Upload failed";
|
||||
if (error.code !== "ABORTED") item.error = error.message || "Upload failed";
|
||||
}
|
||||
item.uploading = false;
|
||||
item.controller = null;
|
||||
for (const [id, draft] of drafts) if (draft.includes(item)) persist(id, draft);
|
||||
if (items.includes(item)) render();
|
||||
};
|
||||
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); }
|
||||
});
|
||||
return {
|
||||
get attachments() { return items.filter(v => v.metadata).map(v => v.metadata); },
|
||||
get pending() { return items.some(v => !v.metadata); },
|
||||
setDraftStore(store) { draftStore = store; },
|
||||
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(); },
|
||||
clear() { items.forEach(v => URL.revokeObjectURL(v.preview)); items = []; render(); },
|
||||
discard() {
|
||||
for (const item of items) {
|
||||
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}`);
|
||||
}
|
||||
items = [];
|
||||
render();
|
||||
},
|
||||
saveDraft(chatId) {
|
||||
if (chatId != null) drafts.set(chatId, items);
|
||||
chatId ??= "new";
|
||||
drafts.set(chatId, items);
|
||||
persist(chatId);
|
||||
items = [];
|
||||
currentDraftId = null;
|
||||
render();
|
||||
},
|
||||
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);
|
||||
render();
|
||||
},
|
||||
@@ -137,14 +169,17 @@ export function createAttachmentComposer({onChange, onError}) {
|
||||
URL.revokeObjectURL(item.preview);
|
||||
return false;
|
||||
}));
|
||||
const remaining = (draftStore?.attachments(chatId) || []).filter(item => !sent.has(item.id));
|
||||
draftStore?.setAttachments(chatId, remaining);
|
||||
},
|
||||
discardDraft(chatId) {
|
||||
const saved = drafts.get(chatId) || [];
|
||||
drafts.delete(chatId);
|
||||
draftStore?.setAttachments(chatId, []);
|
||||
for (const item of saved) {
|
||||
item.controller?.abort();
|
||||
URL.revokeObjectURL(item.preview);
|
||||
if (item.metadata) void fetch(`/attachments/${item.metadata.id}`, {method: "DELETE"});
|
||||
if (item.metadata) void del(`/attachments/${item.metadata.id}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import {get, post} from "./api.js";
|
||||
import {createConversationPanel, actionButton} from "./conversationPanel.js";
|
||||
|
||||
export function createBranchExplorer({state, setActiveChat, setMessageContent}) {
|
||||
const panel = createConversationPanel("branchExplorer", "Branches & answer comparison");
|
||||
let request = 0;
|
||||
return {async open() {
|
||||
const chatId = state.activeChatId;
|
||||
if (!chatId) return;
|
||||
const current = ++request;
|
||||
panel.open(); panel.notice("Loading branches..."); panel.content.replaceChildren();
|
||||
const nodes = [], cache = new Map();
|
||||
let next = 0, activeLeafId;
|
||||
const layout = document.createElement("div"); layout.className = "branch-layout";
|
||||
const sidebar = document.createElement("div"), comparison = document.createElement("div");
|
||||
sidebar.className = "branch-sidebar"; comparison.className = "answer-comparison";
|
||||
layout.append(sidebar, comparison); panel.content.append(layout);
|
||||
const tree = document.createElement("ol"); tree.className = "branch-tree"; tree.setAttribute("aria-label", "Conversation branches");
|
||||
const linked = state.chatSessions.filter(chat => chat.source_chat_id === chatId || chat.id === state.chatSessions.find(item => item.id === chatId)?.source_chat_id);
|
||||
if (linked.length) {
|
||||
const heading = document.createElement("h4"); heading.textContent = "Related conversations"; sidebar.append(heading);
|
||||
for (const chat of linked) sidebar.append(actionButton(chat.title, async () => {
|
||||
const result = await setActiveChat(chat.id);
|
||||
if (!result?.error) panel.close(); else panel.notice(result.error, true);
|
||||
}));
|
||||
}
|
||||
const hint = document.createElement("p"); hint.className = "subtitle";
|
||||
hint.textContent = "Select a message to compare answers or versions. Opening this panel keeps your active branch unchanged.";
|
||||
sidebar.append(hint, tree);
|
||||
const panes = ["Left answer", "Right answer"].map(label => {
|
||||
const section = document.createElement("section"); section.className = "comparison-pane";
|
||||
const heading = document.createElement("h4"); heading.textContent = label;
|
||||
const message = document.createElement("select"); message.setAttribute("aria-label", `${label} message`);
|
||||
const version = document.createElement("select"); version.setAttribute("aria-label", `${label} version`);
|
||||
const body = document.createElement("div"); body.className = "msg-content";
|
||||
let revision = 0;
|
||||
const render = async () => {
|
||||
const token = ++revision, id = Number(message.value);
|
||||
use.disabled = true; activate.disabled = true; version.disabled = true;
|
||||
body.replaceChildren(); version.replaceChildren();
|
||||
if (!id) return;
|
||||
let data = cache.get(id);
|
||||
if (!data) {
|
||||
const loaded = await get(`/chats/${chatId}/messages/${id}/versions`);
|
||||
if (token !== revision || current !== request) return;
|
||||
if (loaded.error) return panel.notice(loaded.error, true);
|
||||
data = loaded.message; cache.set(id, data);
|
||||
}
|
||||
const versions = data.responseVersions?.length ? data.responseVersions : [{content: data.content, modelName: data.modelName}];
|
||||
use.hidden = data.role !== "bot" || versions.length < 2;
|
||||
use.disabled = false; activate.disabled = false; version.disabled = false;
|
||||
version.replaceChildren(...versions.map((item, index) => {
|
||||
const option = document.createElement("option"); option.value = index;
|
||||
option.textContent = `Version ${index + 1}${item.modelName ? ` · ${item.modelName}` : ""}${index === data.versionIndex ? " (selected)" : ""}`;
|
||||
return option;
|
||||
}));
|
||||
version.value = String(label === "Left answer" && versions.length > 1 ? 0 : data.versionIndex || 0);
|
||||
const show = () => setMessageContent(body, versions[Number(version.value)]?.content || "");
|
||||
version.onchange = show; show();
|
||||
};
|
||||
message.onchange = render;
|
||||
const activate = actionButton("Open this branch", async () => {
|
||||
const result = await post(`/chats/${chatId}/branches/${message.value}/activate`, {includeDescendants: true});
|
||||
if (result.error) return panel.notice(result.error, true);
|
||||
const opened = await setActiveChat(chatId);
|
||||
if (opened?.error) return panel.notice(opened.error, true);
|
||||
panel.close();
|
||||
});
|
||||
const use = actionButton("Use this version", async () => {
|
||||
const result = await post(`/chats/${chatId}/messages/${message.value}/versions/${version.value}`, {});
|
||||
if (result.error) return panel.notice(result.error, true);
|
||||
cache.delete(Number(message.value));
|
||||
const refreshed = await setActiveChat(chatId);
|
||||
if (refreshed?.error) return panel.notice(refreshed.error, true);
|
||||
panel.notice("Selected answer version saved.");
|
||||
});
|
||||
const actions = document.createElement("div"); actions.className = "panel-actions"; actions.append(activate, use);
|
||||
section.append(heading, message, version, actions, body); comparison.append(section);
|
||||
return {message, render};
|
||||
});
|
||||
const loadMore = actionButton("Load more branches", async () => {
|
||||
const result = await get(`/chats/${chatId}/branches?after=${next}`);
|
||||
if (current !== request) return;
|
||||
if (result.error) return panel.notice(result.error, true);
|
||||
nodes.push(...result.nodes); next = result.next; activeLeafId = result.activeLeafId;
|
||||
tree.replaceChildren();
|
||||
const known = new Set(nodes.map(node => node.id)), children = new Map();
|
||||
for (const node of nodes) {
|
||||
const parent = known.has(node.parentMessageId) ? node.parentMessageId : null;
|
||||
children.set(parent, [...(children.get(parent) || []), node]);
|
||||
}
|
||||
const stack = (children.get(null) || []).map(node => ({node, level: 0})).reverse();
|
||||
while (stack.length) {
|
||||
const {node, level} = stack.pop();
|
||||
stack.push(...(children.get(node.id) || []).map(child => ({node: child, level: level + 1})).reverse());
|
||||
const row = document.createElement("li"); row.style.setProperty("--branch-depth", Math.min(level, 8));
|
||||
row.title = node.parentMessageId ? `Continues message #${node.parentMessageId}` : "Conversation root";
|
||||
const button = actionButton(`${node.role === "bot" ? "Assistant" : "You"} #${node.id}${node.id === activeLeafId ? " · active tip" : ""} — ${node.preview}`, async () => {
|
||||
panes[1].message.value = String(node.id); await panes[1].render();
|
||||
});
|
||||
if (node.id === activeLeafId) button.setAttribute("aria-current", "true");
|
||||
row.append(button); tree.append(row);
|
||||
}
|
||||
for (const pane of panes) {
|
||||
const selected = pane.message.value;
|
||||
pane.message.replaceChildren(...nodes.map(node => {
|
||||
const option = document.createElement("option"); option.value = node.id;
|
||||
option.textContent = `${node.role === "bot" ? "Assistant" : "You"} #${node.id} — ${node.preview.slice(0, 65)}`;
|
||||
return option;
|
||||
}));
|
||||
pane.message.value = selected || String(nodes.filter(node => node.role === "bot").at(-1)?.id || nodes.at(-1)?.id || "");
|
||||
await pane.render();
|
||||
}
|
||||
loadMore.hidden = next == null;
|
||||
panel.notice(nodes.length ? "" : "Send a message to start a conversation branch.");
|
||||
});
|
||||
sidebar.append(loadMore);
|
||||
await loadMore.onclick();
|
||||
}};
|
||||
}
|
||||
@@ -74,7 +74,12 @@ export function createComposerController({
|
||||
const {signal} = sendController;
|
||||
const visible = () => state.activeChatId === chatId;
|
||||
let userCard, loadingMsg, rendering, completedReply = null;
|
||||
let completedThinking = "", completedSources = null;
|
||||
const userMessage = {role: "user", content: message, attachments};
|
||||
const clearSentDraft = () => {
|
||||
if (state.chatDrafts.get(chatId)?.trim() === message) state.chatDrafts.delete(chatId);
|
||||
attachmentComposer.clearSentDraft(chatId, attachments.map(item => item.id));
|
||||
};
|
||||
try {
|
||||
setLoadingState(true, false);
|
||||
if (retryMessage && !msgInput.value.trim()) {
|
||||
@@ -115,11 +120,11 @@ export function createComposerController({
|
||||
if (follow) scrollMessagesToBottom();
|
||||
};
|
||||
rendering = createStreamRenderer({
|
||||
onThinking: text => render(() => {
|
||||
onThinking: text => { completedThinking = text; render(() => {
|
||||
if (!reasoning) return;
|
||||
updateReasoning(loadingMsg, text, true);
|
||||
loadingShell?.classList.toggle("hidden", Boolean(text));
|
||||
}),
|
||||
}); },
|
||||
onReply: text => render(() => {
|
||||
if (text) finishReasoning(loadingMsg);
|
||||
loadingShell?.classList.toggle("hidden", Boolean(text));
|
||||
@@ -136,24 +141,36 @@ export function createComposerController({
|
||||
: searchState === "no_relevant_results" ? "No sufficiently relevant results. Generating response..."
|
||||
: sourceCount === 0 ? "No web results found. Generating response..." : "Generating response...";
|
||||
},
|
||||
onDone: ({reply}) => { completedReply = reply; }
|
||||
onDone: ({reply, sources}) => { completedReply = reply; completedSources = sources; }
|
||||
}, {signal});
|
||||
rendering.flush();
|
||||
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();
|
||||
if (visible()) {
|
||||
const followOutput = messagesDiv.scrollHeight - messagesDiv.scrollTop - messagesDiv.clientHeight < 100;
|
||||
const previousScrollTop = messagesDiv.scrollTop;
|
||||
if (msgInput.value.trim() === message) { msgInput.value = ""; resizeComposerInput(); }
|
||||
const currentAttachments = attachmentComposer.attachments;
|
||||
if (currentAttachments.length === attachments.length && currentAttachments.every((item, index) => item.id === attachments[index].id)) attachmentComposer.clear();
|
||||
await setActiveChat(chatId);
|
||||
const refreshed = await setActiveChat(chatId);
|
||||
if (refreshed?.error) throw new Error(refreshed.error);
|
||||
if (visible()) {
|
||||
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 (visible()) messagesDiv.scrollTop = previousScrollTop;
|
||||
});
|
||||
} else {
|
||||
if (state.chatDrafts.get(chatId)?.trim() === message) state.chatDrafts.delete(chatId);
|
||||
attachmentComposer.clearSentDraft(chatId, attachments.map(item => item.id));
|
||||
clearSentDraft();
|
||||
}
|
||||
await loadSummary();
|
||||
markOnboardingCompleted(); maybeShowPersonaNudge();
|
||||
@@ -162,10 +179,6 @@ export function createComposerController({
|
||||
loadingMsg?.remove();
|
||||
const messageText = signal.aborted ? "Generation stopped." : error?.message || "Unable to send message.";
|
||||
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");
|
||||
} else {
|
||||
if (visible()) {
|
||||
|
||||
@@ -18,8 +18,10 @@ export function createConversationController({
|
||||
renderMessages,
|
||||
updateJumpToLatest,
|
||||
updateSendState,
|
||||
loadSummary
|
||||
loadSummary,
|
||||
updateHistoryControls = () => {}
|
||||
}) {
|
||||
let opening = false;
|
||||
function getChatById(id) { return state.chatSessions.find((chat) => chat.id === id); }
|
||||
|
||||
async function openCreatedChat(chat) {
|
||||
@@ -35,28 +37,34 @@ export function createConversationController({
|
||||
throw new Error("The newly created chat was not persisted");
|
||||
}
|
||||
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} = {}) {
|
||||
const failed = response => { setNotice(response.error, "error"); return response; };
|
||||
const openRequest = ++state.chatOpenRequest;
|
||||
if (isMobileLayout()) closeMobileSidebar();
|
||||
setChatLoading(true, "Opening chat...");
|
||||
setChatActivity(true, {
|
||||
eyebrow: "Opening chat", title: "Loading conversation",
|
||||
detail: "Fetching messages and restoring the current scene."
|
||||
});
|
||||
if (!opening) {
|
||||
opening = true;
|
||||
setChatLoading(true, "Opening chat...");
|
||||
setChatActivity(true, {
|
||||
eyebrow: "Opening chat", title: "Loading conversation",
|
||||
detail: "Fetching messages and restoring the current scene."
|
||||
});
|
||||
}
|
||||
try {
|
||||
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 (res.error) return setNotice(res.error, "error");
|
||||
if (res.error) return failed(res);
|
||||
if (matchMessageId && !res.messages?.some(message => message.id === matchMessageId)) {
|
||||
const activated = await post(`/chats/${id}/branches/${matchMessageId}/activate`, {includeDescendants: true});
|
||||
if (openRequest !== state.chatOpenRequest) return;
|
||||
if (activated.error) return setNotice(activated.error, "error");
|
||||
res = await get(`/chats/${id}/messages`);
|
||||
if (activated.error) return failed(activated);
|
||||
res = await get(historyUrl);
|
||||
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.
|
||||
if (!preserveComposer && id !== state.activeChatId) {
|
||||
@@ -66,7 +74,11 @@ export function createConversationController({
|
||||
msgInput.value = state.chatDrafts.get(id) || "";
|
||||
attachmentComposer.restoreDraft(id);
|
||||
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) || "";
|
||||
attachmentComposer.restoreDraft(id);
|
||||
resizeComposerInput();
|
||||
@@ -75,7 +87,13 @@ export function createConversationController({
|
||||
const existing = getChatById(id);
|
||||
if (res.chat && existing) Object.assign(existing, res.chat);
|
||||
state.activeChatId = id;
|
||||
attachmentComposer.adoptDraft?.(id);
|
||||
if (preserveComposer) state.chatDrafts.delete("new");
|
||||
state.chatDrafts.set(id, msgInput.value);
|
||||
state.currentMessages = res.messages || [];
|
||||
state.historyPage = res.pagination || null;
|
||||
state.historyLoading = false;
|
||||
updateHistoryControls();
|
||||
history.replaceState(null, "", `/app/chats/${id}`);
|
||||
state.messageRetryState = new Map();
|
||||
updateWorkspaceCopy(); renderChatList(); updateChatActionState();
|
||||
@@ -88,14 +106,39 @@ export function createConversationController({
|
||||
if (target) { target.scrollIntoView({block: "center"}); updateJumpToLatest(); }
|
||||
});
|
||||
updateSendState();
|
||||
return {ok: true};
|
||||
} finally {
|
||||
if (openRequest === state.chatOpenRequest) {
|
||||
opening = false;
|
||||
setChatLoading(false);
|
||||
setChatActivity(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOlderMessages() {
|
||||
if (state.historyLoading || !state.historyPage?.hasMore || !state.activeChatId) return;
|
||||
const chatId = state.activeChatId, requestId = state.chatOpenRequest, page = state.historyPage;
|
||||
state.historyLoading = true;
|
||||
updateHistoryControls();
|
||||
try {
|
||||
const result = await get(`/chats/${chatId}/messages?before=${page.before}&leaf=${page.leafId}`);
|
||||
if (chatId !== state.activeChatId || requestId !== state.chatOpenRequest) return;
|
||||
if (result.error) { setNotice(result.error, "error"); return result; }
|
||||
const oldHeight = messagesDiv.scrollHeight, oldTop = messagesDiv.scrollTop;
|
||||
const existing = new Set(state.currentMessages.map(message => message.id));
|
||||
state.currentMessages = [...result.messages.filter(message => !existing.has(message.id)), ...state.currentMessages];
|
||||
state.historyPage = {...result.pagination, hasNewer: page.hasNewer};
|
||||
renderMessages({preserveScroll: true});
|
||||
messagesDiv.scrollTop = oldTop + messagesDiv.scrollHeight - oldHeight;
|
||||
} finally {
|
||||
if (state.historyPage === page || requestId === state.chatOpenRequest) {
|
||||
state.historyLoading = false;
|
||||
updateHistoryControls();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadChatSessions() {
|
||||
setChatLoading(true, "Loading chats...");
|
||||
const res = await get("/chats");
|
||||
@@ -169,7 +212,8 @@ export function createConversationController({
|
||||
state.chatSessions.unshift(res.chat);
|
||||
await loadSummary(); renderChatList();
|
||||
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();
|
||||
}
|
||||
setNotice("New chat created.", "success");
|
||||
@@ -185,6 +229,7 @@ export function createConversationController({
|
||||
setActiveChat,
|
||||
loadChatSessions,
|
||||
scheduleChatSearch,
|
||||
createNewChat
|
||||
createNewChat,
|
||||
loadOlderMessages
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import {openModal, closeModal} from "./modalFocus.js";
|
||||
|
||||
export function createConversationPanel(id, title) {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.id = id;
|
||||
overlay.className = "modal-overlay hidden";
|
||||
overlay.innerHTML = `<section class="modal-card conversation-panel" role="dialog" aria-modal="true" aria-labelledby="${id}Title" tabindex="-1">
|
||||
<div class="modal-header"><h3 id="${id}Title"></h3><button type="button" class="secondary-action">Close</button></div>
|
||||
<p class="panel-notice status" role="status"></p><div class="panel-content"></div></section>`;
|
||||
overlay.querySelector("h3").textContent = title;
|
||||
document.body.append(overlay);
|
||||
const close = () => closeModal(overlay);
|
||||
overlay.querySelector("button").onclick = close;
|
||||
overlay.onclick = event => { if (event.target === overlay) close(); };
|
||||
return {element: overlay, content: overlay.querySelector(".panel-content"), close,
|
||||
open() { if (overlay.classList.contains("hidden")) openModal(overlay); },
|
||||
notice(text = "", error = false) {
|
||||
const notice = overlay.querySelector(".panel-notice");
|
||||
notice.textContent = text;
|
||||
notice.classList.toggle("error", error);
|
||||
}};
|
||||
}
|
||||
|
||||
export function actionButton(label, action) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "secondary-action";
|
||||
button.textContent = label;
|
||||
button.onclick = async () => { button.disabled = true; try { await action(); } finally { button.disabled = false; } };
|
||||
return button;
|
||||
}
|
||||
@@ -9,6 +9,8 @@ export function createConversationState() {
|
||||
regenerationChatId: null,
|
||||
chatSessions: [],
|
||||
currentMessages: [],
|
||||
historyPage: null,
|
||||
historyLoading: false,
|
||||
assistantPersonas: [],
|
||||
userPersonas: [],
|
||||
activeUserPersonaId: null,
|
||||
|
||||
@@ -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(); }
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}};
|
||||
}
|
||||
@@ -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};
|
||||
}
|
||||
@@ -222,7 +222,7 @@ export function createMessageRenderer({
|
||||
return msgDiv;
|
||||
}
|
||||
|
||||
function renderMessages() {
|
||||
function renderMessages({preserveScroll = false} = {}) {
|
||||
if (scrollFrame !== null) { cancelAnimationFrame(scrollFrame); scrollFrame = null; }
|
||||
const sameChat = renderedChatId === state.activeChatId;
|
||||
const wasReadingEarlier = sameChat && messagesDiv.scrollHeight - messagesDiv.scrollTop - messagesDiv.clientHeight >= 100;
|
||||
@@ -256,7 +256,7 @@ export function createMessageRenderer({
|
||||
renderDrawerSummary();
|
||||
updateChatActionState();
|
||||
updateComposerPlaceholder();
|
||||
if (wasReadingEarlier) {
|
||||
if (wasReadingEarlier || preserveScroll) {
|
||||
messagesDiv.scrollTop = previousScrollTop;
|
||||
updateJumpToLatest();
|
||||
} else if (state.currentMessages.length) {
|
||||
|
||||
+45
-410
@@ -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 {createPersonaController} from "./personaController.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.
|
||||
*/
|
||||
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 {createAttachmentComposer} from "./attachments.js?v=20260930-chat-usability";
|
||||
import {createReasoningToggle} from "./reasoning.js?v=20260921-reasoning-capability";
|
||||
@@ -168,7 +172,8 @@ const {
|
||||
setActiveChat,
|
||||
loadChatSessions,
|
||||
scheduleChatSearch,
|
||||
createNewChat
|
||||
createNewChat,
|
||||
loadOlderMessages
|
||||
} = createConversationController({
|
||||
state: conversation,
|
||||
renderChatList: (...args) => renderChatList(...args),
|
||||
@@ -185,7 +190,8 @@ const {
|
||||
renderMessages: (...args) => renderMessages(...args),
|
||||
updateJumpToLatest: (...args) => updateJumpToLatest(...args),
|
||||
updateSendState: (...args) => updateSendState(...args),
|
||||
loadSummary: (...args) => loadSummary(...args)
|
||||
loadSummary: (...args) => loadSummary(...args),
|
||||
updateHistoryControls: () => updateHistoryControls()
|
||||
});
|
||||
|
||||
const {updateSendState, resizeComposerInput, setLoadingState, stopGeneration, sendMessage} = createComposerController({
|
||||
@@ -212,6 +218,16 @@ const {updateSendState, resizeComposerInput, setLoadingState, stopGeneration, se
|
||||
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 = "") {
|
||||
const notice = document.getElementById("workspaceNotice");
|
||||
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});
|
||||
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() {
|
||||
const activeChat = getChatById(conversation.activeChatId);
|
||||
if (activeChatTitle) {
|
||||
@@ -758,6 +525,7 @@ function applyThemeMode(modeKey, persist = true) {
|
||||
}
|
||||
|
||||
function updateChatActionState() {
|
||||
updateHistoryControls();
|
||||
const activeChat = getChatById(conversation.activeChatId);
|
||||
const hasChat = Boolean(activeChat);
|
||||
renameChatBtn.disabled = !hasChat || Boolean(activeChat?.assistant_persona_id);
|
||||
@@ -767,6 +535,7 @@ function updateChatActionState() {
|
||||
}
|
||||
if (moveChatFolderBtn) moveChatFolderBtn.disabled = !hasChat;
|
||||
if (manageChatMemoryBtn) manageChatMemoryBtn.disabled = !hasChat;
|
||||
document.getElementById("exploreBranches").disabled = !hasChat;
|
||||
if (managePersonaStateBtn) managePersonaStateBtn.disabled = !hasChat || !activeChat?.assistant_persona_id;
|
||||
if (archiveChatBtn) {
|
||||
archiveChatBtn.disabled = !hasChat;
|
||||
@@ -779,6 +548,14 @@ function updateChatActionState() {
|
||||
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() {
|
||||
if (!conversation.activeChatId || !modelSection) return;
|
||||
modelSection.classList.remove("hidden");
|
||||
@@ -982,165 +759,10 @@ async function clearChat(id) {
|
||||
const res = await post(`/chats/${id}/clear`, {});
|
||||
if (res.error) return setNotice(res.error, "error");
|
||||
conversation.messageRetryState = new Map();
|
||||
conversation.historyPage = null;
|
||||
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 = [
|
||||
["relationship_notes", "Relationship notes"],
|
||||
["tone", "Tone"],
|
||||
@@ -1256,10 +878,18 @@ async function checkSession() {
|
||||
const res = await get("/session");
|
||||
if (!res.user) { window.location.replace(`/login?next=${encodeURIComponent(location.pathname + location.search)}`); return false; }
|
||||
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()]);
|
||||
// Starters are offered on workspace home.
|
||||
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();
|
||||
if (!starter && !conversation.activeChatId) maybeShowOnboarding();
|
||||
return true;
|
||||
@@ -1291,8 +921,9 @@ const chatActions = {
|
||||
await updateChatOrganization(chat.id, { folderName: String(folderName || "").trim() || null });
|
||||
},
|
||||
memory: async () => {
|
||||
await manageChatMemory();
|
||||
await memoryPanel.open();
|
||||
},
|
||||
branches: () => branchExplorer.open(),
|
||||
personaState: async () => {
|
||||
await managePersonaState();
|
||||
},
|
||||
@@ -1318,9 +949,12 @@ function bindChatDrawerActions() {
|
||||
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("input", function () {
|
||||
if (conversation.currentUsername) conversation.chatDrafts.set(conversation.activeChatId ?? "new", msgInput.value);
|
||||
resizeComposerInput();
|
||||
updateSendState();
|
||||
});
|
||||
document.getElementById("loadOlderMessages").addEventListener("click", () => { void loadOlderMessages(); });
|
||||
document.getElementById("loadLatestMessages").addEventListener("click", () => { void setActiveChat(conversation.activeChatId); });
|
||||
resizeComposerInput();
|
||||
newChatBtn.addEventListener("click", () => {
|
||||
closeChatDrawer();
|
||||
@@ -1458,6 +1092,7 @@ document.addEventListener("keydown", (event) => {
|
||||
else if (activeModal === personaModal) closePersonaForm();
|
||||
else if (activeModal === roleplayStarterModal) closeRoleplayStarter();
|
||||
else if (activeModal === onboardingModal) closeOnboarding();
|
||||
else closeModal(activeModal);
|
||||
return;
|
||||
}
|
||||
const openMenu = document.querySelector(".msg-action-menu[open]");
|
||||
|
||||
@@ -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
@@ -19,7 +19,7 @@ import {createChatsRouter} from "./routes/chats.js";
|
||||
import {createPagesRouter} from "./routes/pages.js";
|
||||
import {createPersonasRouter} from "./routes/personas.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 {createUploadsRouter} from "./routes/uploads.js";
|
||||
import {createVisionDebugService} from "./services/visionDebugService.js";
|
||||
@@ -271,6 +271,7 @@ export function createApp(options = {}) {
|
||||
);
|
||||
|
||||
app.use(observability.middleware);
|
||||
app.use(createHealthRouter({modelService}));
|
||||
app.use(express.json({limit: "1mb"}));
|
||||
app.use(session({
|
||||
store: options.sessionStore || (config.testMode ? undefined : new FileStore({path: config.sessionsDir, reapInterval: 3600})),
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import {buildPersonaSnapshot} from "./personaSnapshot.js";
|
||||
|
||||
export function createPersonaStatements(db) {
|
||||
return {
|
||||
clearAssistantPersonaFromChats: db.prepare(`
|
||||
UPDATE chat_sessions
|
||||
SET assistant_persona_id = NULL,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ?
|
||||
AND assistant_persona_id = ?
|
||||
`),
|
||||
clearUserPersonaFromChats: db.prepare(`
|
||||
UPDATE chat_sessions
|
||||
SET user_persona_id = NULL,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ?
|
||||
AND user_persona_id = ?
|
||||
`),
|
||||
listPersonasByType: db.prepare(`
|
||||
SELECT id, name, pronouns, appearance, background, details, example_dialogues, persona_type,
|
||||
source_market_id, source_creator_username, created_at, updated_at
|
||||
FROM personas
|
||||
WHERE username = ?
|
||||
AND persona_type = ?
|
||||
ORDER BY datetime(updated_at) DESC
|
||||
`),
|
||||
listAllPersonas: db.prepare(`
|
||||
SELECT id, username, name, pronouns, appearance, background, details, example_dialogues, persona_type,
|
||||
source_market_id, source_creator_username, created_at, updated_at
|
||||
FROM personas
|
||||
WHERE username = ?
|
||||
ORDER BY datetime(updated_at) DESC
|
||||
`),
|
||||
getPersona: db.prepare(`
|
||||
SELECT id, name, pronouns, appearance, background, details, example_dialogues, persona_type,
|
||||
source_market_id, source_creator_username, created_at, updated_at
|
||||
FROM personas
|
||||
WHERE id = ?
|
||||
AND username = ?
|
||||
`),
|
||||
insertPersona: db.prepare("INSERT INTO personas (username, name, pronouns, appearance, background, details, example_dialogues, persona_type) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"),
|
||||
updatePersona: db.prepare(`
|
||||
UPDATE personas
|
||||
SET name = ?,
|
||||
pronouns = ?,
|
||||
appearance = ?,
|
||||
background = ?,
|
||||
details = ?,
|
||||
example_dialogues = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
AND username = ?
|
||||
`),
|
||||
deletePersona: db.prepare("DELETE FROM personas WHERE id = ? AND username = ?"),
|
||||
getNextPersonaVersionNumber: db.prepare("SELECT COALESCE(MAX(version_number), 0) + 1 AS nextVersion FROM persona_versions WHERE persona_id = ?"),
|
||||
insertPersonaVersion: db.prepare("INSERT INTO persona_versions (persona_id, username, version_number, snapshot_json) VALUES (?, ?, ?, ?)"),
|
||||
listPersonaVersions: db.prepare(`
|
||||
SELECT id, persona_id, username, version_number, snapshot_json, created_at
|
||||
FROM persona_versions
|
||||
WHERE persona_id = ?
|
||||
AND username = ?
|
||||
ORDER BY version_number DESC
|
||||
`),
|
||||
getPersonaVersion: db.prepare(`
|
||||
SELECT id, persona_id, username, version_number, snapshot_json, created_at
|
||||
FROM persona_versions
|
||||
WHERE id = ?
|
||||
AND persona_id = ?
|
||||
AND username = ?
|
||||
`),
|
||||
getActivePersonaId: db.prepare("SELECT active_persona_id, active_user_persona_id FROM user_settings WHERE username = ?"),
|
||||
setActiveUserPersona: db.prepare(`
|
||||
INSERT INTO user_settings (username, active_user_persona_id)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(username) DO UPDATE SET active_user_persona_id = excluded.active_user_persona_id
|
||||
`),
|
||||
getActiveUserPersona: db.prepare(`
|
||||
SELECT p.id, p.name, p.pronouns, p.appearance, p.background, p.details, p.example_dialogues
|
||||
FROM user_settings us
|
||||
JOIN personas p ON p.id = us.active_user_persona_id
|
||||
WHERE us.username = ?
|
||||
`),
|
||||
getAssistantPersonaForChat: db.prepare(`
|
||||
SELECT p.id, p.name, p.pronouns, p.appearance, p.background, p.details, p.example_dialogues
|
||||
FROM chat_sessions cs
|
||||
JOIN personas p ON p.id = cs.assistant_persona_id
|
||||
WHERE cs.id = ?
|
||||
AND cs.username = ?
|
||||
`),
|
||||
getUserPersonaForChat: db.prepare(`
|
||||
SELECT p.id, p.name, p.pronouns, p.appearance, p.background, p.details, p.example_dialogues
|
||||
FROM chat_sessions cs
|
||||
JOIN personas p ON p.id = cs.user_persona_id
|
||||
WHERE cs.id = ?
|
||||
AND cs.username = ?
|
||||
`),
|
||||
deleteMarketPersonaByPersonaId: db.prepare("DELETE FROM persona_market WHERE persona_id = ? AND creator_username = ?")
|
||||
};
|
||||
}
|
||||
|
||||
export function createPersonaRepository(db, statements) {
|
||||
const savePersonaVersion = db.transaction((personaId, username) => {
|
||||
const persona = statements.getPersona.get(personaId, username);
|
||||
if (!persona) return null;
|
||||
const versionNumber = statements.getNextPersonaVersionNumber.get(personaId).nextVersion;
|
||||
statements.insertPersonaVersion.run(personaId, username, versionNumber, buildPersonaSnapshot(persona));
|
||||
return versionNumber;
|
||||
});
|
||||
|
||||
const clonePersonaTransaction = db.transaction((personaId, username) => {
|
||||
const source = statements.getPersona.get(personaId, username);
|
||||
if (!source) return null;
|
||||
const cloneId = statements.insertPersona.run(
|
||||
username,
|
||||
`${source.name} Copy`,
|
||||
source.pronouns,
|
||||
source.appearance,
|
||||
source.background,
|
||||
source.details,
|
||||
source.example_dialogues,
|
||||
source.persona_type
|
||||
).lastInsertRowid;
|
||||
const clone = statements.getPersona.get(cloneId, username);
|
||||
statements.insertPersonaVersion.run(cloneId, username, 1, buildPersonaSnapshot(clone));
|
||||
return clone;
|
||||
});
|
||||
return {
|
||||
listPersonasByType: (username, personaType) => statements.listPersonasByType.all(username, personaType),
|
||||
listAllPersonas: (username) => statements.listAllPersonas.all(username),
|
||||
getPersona: (personaId, username) => statements.getPersona.get(personaId, username),
|
||||
createPersona(username, payload, personaType) {
|
||||
const personaId = statements.insertPersona.run(
|
||||
username,
|
||||
payload.name,
|
||||
payload.pronouns,
|
||||
payload.appearance,
|
||||
payload.background,
|
||||
payload.details,
|
||||
payload.exampleDialogues,
|
||||
personaType
|
||||
).lastInsertRowid;
|
||||
const persona = statements.getPersona.get(personaId, username);
|
||||
statements.insertPersonaVersion.run(personaId, username, 1, buildPersonaSnapshot(persona));
|
||||
return persona;
|
||||
},
|
||||
updatePersona(personaId, username, payload) {
|
||||
savePersonaVersion(personaId, username);
|
||||
return statements.updatePersona.run(
|
||||
payload.name,
|
||||
payload.pronouns,
|
||||
payload.appearance,
|
||||
payload.background,
|
||||
payload.details,
|
||||
payload.exampleDialogues,
|
||||
personaId,
|
||||
username
|
||||
);
|
||||
},
|
||||
restorePersonaVersion(personaId, versionId, username) {
|
||||
const version = statements.getPersonaVersion.get(versionId, personaId, username);
|
||||
if (!version) return null;
|
||||
savePersonaVersion(personaId, username);
|
||||
const snapshot = JSON.parse(version.snapshot_json);
|
||||
statements.updatePersona.run(
|
||||
snapshot.name,
|
||||
snapshot.pronouns ?? null,
|
||||
snapshot.appearance ?? null,
|
||||
snapshot.background ?? null,
|
||||
snapshot.details ?? null,
|
||||
snapshot.example_dialogues ?? null,
|
||||
personaId,
|
||||
username
|
||||
);
|
||||
return statements.getPersona.get(personaId, username);
|
||||
},
|
||||
listPersonaVersions(personaId, username) {
|
||||
return statements.listPersonaVersions.all(personaId, username).map((version) => ({
|
||||
...version,
|
||||
snapshot: JSON.parse(version.snapshot_json)
|
||||
}));
|
||||
},
|
||||
clonePersona: (personaId, username) => clonePersonaTransaction(personaId, username),
|
||||
deletePersona: (personaId, username) => statements.deletePersona.run(personaId, username),
|
||||
clearPersonaReferences(username, personaId) {
|
||||
statements.clearAssistantPersonaFromChats.run(username, personaId);
|
||||
statements.clearUserPersonaFromChats.run(username, personaId);
|
||||
statements.deleteMarketPersonaByPersonaId.run(personaId, username);
|
||||
},
|
||||
getActivePersonaIds: (username) => statements.getActivePersonaId.get(username) || {},
|
||||
setActiveUserPersona: (username, personaId) => statements.setActiveUserPersona.run(username, personaId),
|
||||
getActiveUserPersona: (username) => statements.getActiveUserPersona.get(username),
|
||||
getAssistantPersonaForChat: (chatId, username) => statements.getAssistantPersonaForChat.get(chatId, username),
|
||||
getUserPersonaForChat: (chatId, username) => statements.getUserPersonaForChat.get(chatId, username)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export const buildPersonaSnapshot = (persona) => JSON.stringify({
|
||||
name: persona.name,
|
||||
pronouns: persona.pronouns,
|
||||
appearance: persona.appearance,
|
||||
background: persona.background,
|
||||
details: persona.details,
|
||||
example_dialogues: persona.example_dialogues,
|
||||
persona_type: persona.persona_type,
|
||||
source_market_id: persona.source_market_id ?? null,
|
||||
source_creator_username: persona.source_creator_username ?? null
|
||||
});
|
||||
+12
-820
@@ -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.
|
||||
*/
|
||||
@@ -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 = {
|
||||
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",
|
||||
@@ -45,413 +38,10 @@ function normalizeMarketPersonaType(personaType) {
|
||||
|
||||
export function createRepositories(db, config) {
|
||||
const statements = {
|
||||
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 = ?"),
|
||||
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 = ?"),
|
||||
...createAccountStatements(db),
|
||||
...createChatStatements(db),
|
||||
...createPersonaStatements(db),
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"),
|
||||
getPersonaBySourceMarket: db.prepare(`
|
||||
SELECT id
|
||||
@@ -459,61 +49,6 @@ export function createRepositories(db, config) {
|
||||
WHERE username = ?
|
||||
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(`
|
||||
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,
|
||||
@@ -691,135 +226,9 @@ export function createRepositories(db, config) {
|
||||
WHERE creator_username = ?
|
||||
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 = ?")
|
||||
};
|
||||
|
||||
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 imported = {personas: 0, chats: 0, templates: 0};
|
||||
const personaIds = new Map();
|
||||
@@ -965,227 +374,10 @@ export function createRepositories(db, config) {
|
||||
});
|
||||
|
||||
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),
|
||||
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),
|
||||
...createHistoryRepository(db),
|
||||
...createAccountRepository(db, statements),
|
||||
...createChatRepository(db, config, statements),
|
||||
...createPersonaRepository(db, statements),
|
||||
listMarketPersonas({admin = false, username = null, personaType = null, sort = "best"} = {}) {
|
||||
const normalizedPersonaType = normalizeMarketPersonaType(personaType);
|
||||
const normalizedSort = normalizeMarketSort(sort);
|
||||
|
||||
@@ -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, "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");
|
||||
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_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();
|
||||
|
||||
@@ -5,6 +5,7 @@ import express from "express";
|
||||
import {asyncHandler} from "../middleware/asyncHandler.js";
|
||||
import bcrypt from "bcrypt";
|
||||
import {validateBody} from "../middleware/validate.js";
|
||||
import {newPasswordError} from "../services/passwordPolicy.js";
|
||||
|
||||
const credentialsValidator = (body) => {
|
||||
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) => {
|
||||
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) {
|
||||
return res.status(403).json({error: "Valid invite code is required"});
|
||||
}
|
||||
|
||||
+53
-5
@@ -217,10 +217,39 @@ export function createChatsRouter({repositories, chatService, modelService, conf
|
||||
const chatId = Number(req.params.id);
|
||||
const chat = repositories.getChat(chatId, req.session.user);
|
||||
if (!chat) return res.status(404).json({error: "Chat not found"});
|
||||
return res.json({
|
||||
messages: chatService.listActiveBranchMessages(chatId),
|
||||
chat
|
||||
});
|
||||
const options = {};
|
||||
for (const key of ["limit", "before", "leaf", "around"]) {
|
||||
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) => {
|
||||
@@ -406,7 +435,26 @@ export function createChatsRouter({repositories, chatService, modelService, conf
|
||||
const chatId = Number(req.params.id);
|
||||
const chat = repositories.getChat(chatId, req.session.user);
|
||||
if (!chat) return res.status(404).json({error: "Chat not found"});
|
||||
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) => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {asyncHandler} from "../middleware/asyncHandler.js";
|
||||
import bcrypt from "bcrypt";
|
||||
import {requireLogin} from "../middleware/auth.js";
|
||||
import {validateBody} from "../middleware/validate.js";
|
||||
import {newPasswordError} from "../services/passwordPolicy.js";
|
||||
|
||||
const usernameValidator = (body) => {
|
||||
const username = String(body?.username || "").trim();
|
||||
@@ -72,6 +73,8 @@ export function createSettingsRouter({repositories}) {
|
||||
router.put("/settings/password", validateBody(passwordValidator), asyncHandler(async (req, res) => {
|
||||
const username = req.session.user;
|
||||
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);
|
||||
if (!user) return res.status(404).json({error: "User not found"});
|
||||
const valid = await bcrypt.compare(currentPassword, user.password);
|
||||
|
||||
@@ -25,14 +25,20 @@ function buildConfigWarnings(config) {
|
||||
return warnings;
|
||||
}
|
||||
|
||||
export function createSystemRouter({modelService, config}) {
|
||||
export function createHealthRouter({modelService}) {
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/health", asyncHandler(async (req, res) => {
|
||||
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) => {
|
||||
const diagnostics = await modelService.getDiagnostics();
|
||||
return res.status(diagnostics.model.ok ? 200 : 503).json({
|
||||
|
||||
+19
-16
@@ -27,10 +27,10 @@ import { supportsReasoning } from "../../public/js/modelCapabilities.js";
|
||||
import {VISION_EVIDENCE_GUIDANCE} from "./visionAnalysis.js";
|
||||
|
||||
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);
|
||||
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.versionIndex = versions.length ? Number(row.retry_active_index || 0) : 0;
|
||||
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
|
||||
}));
|
||||
}
|
||||
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;
|
||||
};
|
||||
const chatGenerationQueues = new Map();
|
||||
@@ -198,6 +198,9 @@ export function createChatService(repositories, modelService, config, webSearchP
|
||||
|
||||
const latestSummarized = summarizable[summarizable.length - 1];
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -573,18 +576,17 @@ export function createChatService(repositories, modelService, config, webSearchP
|
||||
}
|
||||
});
|
||||
|
||||
const listActiveBranchMessages = (chatId) => repositories.listActiveBranchMessages(chatId).map((row) => {
|
||||
const message = formatChatMessage(row);
|
||||
const siblings = repositories.listSiblingChatMessages(chatId, row.role, row.parent_message_id);
|
||||
const siblingIndex = siblings.findIndex((sibling) => sibling.id === row.id);
|
||||
return {
|
||||
...message,
|
||||
siblingIndex,
|
||||
siblingCount: siblings.length,
|
||||
previousSiblingId: siblingIndex > 0 ? siblings[siblingIndex - 1].id : null,
|
||||
nextSiblingId: siblingIndex >= 0 && siblingIndex < siblings.length - 1 ? siblings[siblingIndex + 1].id : null
|
||||
};
|
||||
});
|
||||
const formatHistory = (chatId, rows) => {
|
||||
const metadata = repositories.getHistoryMetadata(chatId, rows.map(row => row.id));
|
||||
return rows.map(row => ({...formatChatMessage(row, metadata), ...metadata.siblings.get(row.id)}));
|
||||
};
|
||||
const listActiveBranchMessages = chatId => formatHistory(chatId, repositories.listActiveBranchMessages(chatId));
|
||||
const getHistoryPage = (chatId, options) => {
|
||||
const result = repositories.getActiveBranchPage(chatId, options);
|
||||
if (result.error) return result;
|
||||
const {rows, ...details} = result;
|
||||
return {...details, messages: formatHistory(chatId, rows)};
|
||||
};
|
||||
|
||||
const cloneChatThroughMessage = ({ user, sourceChat, messageId = null }) => {
|
||||
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;
|
||||
repositories.updateChatActiveLeaf(chatId, user, leaf?.id || messageId);
|
||||
repositories.updateChatContextSummary(chatId, user, null, 0);
|
||||
return { messages: listActiveBranchMessages(chatId) };
|
||||
return getHistoryPage(chatId);
|
||||
},
|
||||
listActiveBranchMessages,
|
||||
getHistoryPage,
|
||||
stopGeneration({ user, chatId }) {
|
||||
if (!repositories.getChat(chatId, user)) return { error: "Chat not found", status: 404 };
|
||||
const controller = activeGenerations.get(chatId);
|
||||
|
||||
@@ -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
@@ -1,6 +1,30 @@
|
||||
import test from "node:test";
|
||||
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 => {
|
||||
let response;
|
||||
|
||||
@@ -116,6 +116,102 @@ async function request(baseUrl, path, {method = "GET", body, jar} = {}) {
|
||||
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 () => {
|
||||
const server = await startTestServer();
|
||||
const jar = new CookieJar();
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { appendAttachments, createAttachmentComposer } from "../public/js/attachments.js";
|
||||
import {createDraftStore} from "../public/js/draftStore.js";
|
||||
|
||||
class Element {
|
||||
constructor() { this.children = []; this.listeners = {}; this.classList = { add() { }, remove() { } }; }
|
||||
@@ -13,6 +14,34 @@ class Element {
|
||||
addEventListener(name, listener) { this.listeners[name] = listener; }
|
||||
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 => {
|
||||
const originalDocument = globalThis.document, originalFetch = globalThis.fetch;
|
||||
t.after(() => { globalThis.document = originalDocument; globalThis.fetch = originalFetch; });
|
||||
|
||||
+26
-1
@@ -24,7 +24,7 @@ function fixture(overrides = {}) {
|
||||
getChatById: id => state.chatSessions.find(chat => chat.id === id),
|
||||
updateWorkspaceCopy: noop, renderChatList: noop, updateChatActionState: noop, scrollMessagesToBottom: 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"),
|
||||
stream: async (_url, payload, handlers) => {
|
||||
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.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");
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 () => {
|
||||
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"));
|
||||
assert.match(regenerate, /setLoadingState\(true, false\)/);
|
||||
assert.match(regenerate, /regenerateInline/);
|
||||
|
||||
@@ -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"}]);
|
||||
});
|
||||
Reference in New Issue
Block a user