make huge overhauls to backend

This commit is contained in:
2026-07-02 12:58:14 +02:00
parent 66f2407473
commit 65e4162651
18 changed files with 1159 additions and 14 deletions
+6
View File
@@ -38,8 +38,11 @@ const clearChatBtn = $("clearChat");
const exportChatBtn = $("exportChat");
const pinChatBtn = $("pinChat");
const moveChatFolderBtn = $("moveChatFolder");
const manageChatMemoryBtn = $("manageChatMemory");
const archiveChatBtn = $("archiveChat");
const backupWorkspaceBtn = $("backupWorkspace");
const importWorkspaceBtn = $("importWorkspace");
const importWorkspaceFileInput = $("importWorkspaceFile");
const activeChatTitle = $("activeChatTitle");
const sessionUser = $("sessionUser");
const chatActivityOverlay = $("chatActivityOverlay");
@@ -133,8 +136,11 @@ export {
exportChatBtn,
pinChatBtn,
moveChatFolderBtn,
manageChatMemoryBtn,
archiveChatBtn,
backupWorkspaceBtn,
importWorkspaceBtn,
importWorkspaceFileInput,
loginForm,
loginPasswordInput,
loginSubmit,
+3
View File
@@ -208,7 +208,10 @@
</summary>
<div class="chat-drawer-action-list chat-drawer-disclosure-panel">
<button id="moveChatFolder" class="secondary-action chat-drawer-action" type="button" 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>
<button id="backupWorkspace" class="secondary-action chat-drawer-action" type="button">Backup workspace</button>
<button id="importWorkspace" class="secondary-action chat-drawer-action" type="button">Import workspace</button>
<input id="importWorkspaceFile" class="hidden" type="file" accept="application/json,.json"/>
<button id="logout" type="button" class="secondary-action chat-drawer-action">Logout</button>
</div>
</details>
+102
View File
@@ -26,6 +26,9 @@ import {
backupWorkspaceBtn,
clearUserPersonaBtn,
exportChatBtn,
importWorkspaceBtn,
manageChatMemoryBtn,
importWorkspaceFileInput,
moveChatFolderBtn,
loginForm,
loginPasswordInput,
@@ -891,6 +894,7 @@ function updateChatActionState() {
if (hasChat) pinChatBtn.textContent = activeChat.is_pinned ? "Unpin" : "Pin";
}
if (moveChatFolderBtn) moveChatFolderBtn.disabled = !hasChat;
if (manageChatMemoryBtn) manageChatMemoryBtn.disabled = !hasChat;
if (archiveChatBtn) {
archiveChatBtn.disabled = !hasChat;
if (hasChat) archiveChatBtn.textContent = activeChat.archived_at ? "Unarchive" : "Archive";
@@ -898,6 +902,7 @@ function updateChatActionState() {
clearChatBtn.disabled = !hasChat;
exportChatBtn.disabled = !hasChat || currentMessages.length === 0;
if (backupWorkspaceBtn) backupWorkspaceBtn.disabled = !currentUsername;
if (importWorkspaceBtn) importWorkspaceBtn.disabled = !currentUsername;
}
function renderPersonaSection() {
@@ -1497,6 +1502,92 @@ async function backupWorkspace() {
setNotice("Workspace backup downloaded.", "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...");
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 (!activeChatId) return;
const loaded = await get(`/chats/${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/${activeChatId}/memories/${target.id}`);
if (removed.error) return setNotice(removed.error, "error");
return setNotice("Memory fact deleted.", "success");
}
const saved = await post(`/chats/${activeChatId}/memories`, {fact: value});
if (saved.error) return setNotice(saved.error, "error");
return setNotice("Memory fact saved.", "success");
}
async function deleteChat(id) {
setChatLoading(true, "Deleting chat...");
const chat = getChatById(id);
@@ -1652,6 +1743,9 @@ const chatActions = {
if (folderName === null) return;
await updateChatOrganization(chat.id, {folderName: String(folderName || "").trim() || null});
},
memory: async () => {
await manageChatMemory();
},
pin: async () => {
const chat = getChatById(activeChatId);
if (chat) await updateChatOrganization(chat.id, {isPinned: !chat.is_pinned});
@@ -1700,6 +1794,14 @@ if (backupWorkspaceBtn) backupWorkspaceBtn.addEventListener("click", () => {
closeChatDrawer();
void backupWorkspace();
});
if (importWorkspaceBtn) importWorkspaceBtn.addEventListener("click", () => {
closeChatDrawer();
importWorkspaceFileInput.value = "";
importWorkspaceFileInput.click();
});
if (importWorkspaceFileInput) importWorkspaceFileInput.addEventListener("change", () => {
void importWorkspaceFromFile(importWorkspaceFileInput.files?.[0]);
});
chatSearchInput.addEventListener("input", () => { void loadChatSessions(); });
modelSelect.addEventListener("change", () => {
renderModelSection();
+46
View File
@@ -29,6 +29,7 @@
<button class="settings-nav-item active" type="button" role="tab" aria-selected="true" data-settings-view="personal">Account</button>
<button class="settings-nav-item" type="button" role="tab" aria-selected="false" data-settings-view="my-roleplay-companions">Personas</button>
<button class="settings-nav-item" type="button" role="tab" aria-selected="false" data-settings-view="design">Appearance</button>
<button id="diagnosticsNavItem" class="settings-nav-item hidden" type="button" role="tab" aria-selected="false" data-settings-view="diagnostics">Diagnostics</button>
</aside>
<div class="settings-content">
@@ -139,6 +140,51 @@
</div>
</div>
</section>
<section class="settings-view" role="tabpanel" aria-hidden="true" data-settings-panel="diagnostics">
<div class="settings-panel">
<div class="settings-section-header">
<div>
<p class="eyebrow">Admin</p>
<h2>Model diagnostics</h2>
<p class="subtitle">Backend status, model inventory, latency, and configuration warnings.</p>
</div>
<button id="refreshDiagnostics" type="button" class="secondary-action">Refresh</button>
</div>
<div class="diagnostics-grid">
<div class="diagnostics-card">
<span>Status</span>
<strong id="diagnosticsStatus">Unknown</strong>
<p id="diagnosticsBackend" class="subtitle">Backend unavailable</p>
</div>
<div class="diagnostics-card">
<span>Latency</span>
<strong id="diagnosticsLatency">n/a</strong>
<p id="diagnosticsCheckedAt" class="subtitle">Not checked yet</p>
</div>
<div class="diagnostics-card">
<span>Models</span>
<strong id="diagnosticsModelCount">0</strong>
<p id="diagnosticsCacheAge" class="subtitle">Cache age n/a</p>
</div>
<div class="diagnostics-card">
<span>Active requests</span>
<strong id="diagnosticsActiveRequests">0</strong>
<p id="diagnosticsLastFailure" class="subtitle">No recorded failures</p>
</div>
</div>
<div class="diagnostics-columns">
<div>
<h3>Available models</h3>
<div id="diagnosticsModels" class="diagnostics-list"></div>
</div>
<div>
<h3>Config warnings</h3>
<div id="diagnosticsWarnings" class="diagnostics-list"></div>
</div>
</div>
</div>
</section>
</div>
</section>
</div>
+74 -2
View File
@@ -3,6 +3,18 @@ const settingsNotice = $("settingsNotice");
const settingsThemeSelect = $("settingsThemeSelect");
const themeModeSelect = $("themeModeSelect");
const workspaceModeSelect = $("workspaceModeSelect");
const diagnosticsNavItem = $("diagnosticsNavItem");
const refreshDiagnosticsBtn = $("refreshDiagnostics");
const diagnosticsStatus = $("diagnosticsStatus");
const diagnosticsBackend = $("diagnosticsBackend");
const diagnosticsLatency = $("diagnosticsLatency");
const diagnosticsCheckedAt = $("diagnosticsCheckedAt");
const diagnosticsModelCount = $("diagnosticsModelCount");
const diagnosticsCacheAge = $("diagnosticsCacheAge");
const diagnosticsActiveRequests = $("diagnosticsActiveRequests");
const diagnosticsLastFailure = $("diagnosticsLastFailure");
const diagnosticsModels = $("diagnosticsModels");
const diagnosticsWarnings = $("diagnosticsWarnings");
const currentUsernameInput = $("currentUsername");
const newUsernameInput = $("newUsername");
const usernamePasswordInput = $("usernamePassword");
@@ -56,6 +68,7 @@ let userPersonas = [];
let publishedPersonaIds = new Set();
let editingPersonaId = null;
let settingsPopupResolver = null;
let isAdminUser = false;
async function request(url, data, method = "POST") {
try {
@@ -141,6 +154,10 @@ function applyThemeMode(modeKey, persist = true) {
function setSettingsView(view) {
const normalizedView = ["my-ai-characters", "my-personas"].includes(view) ? "my-roleplay-companions" : view;
if (normalizedView === "diagnostics" && !isAdminUser) {
setNotice("Diagnostics are available to admin users only.", "error");
return;
}
settingsNavItems.forEach((item) => {
const isActive = item.dataset.settingsView === normalizedView;
item.classList.toggle("active", isActive);
@@ -154,6 +171,57 @@ function setSettingsView(view) {
const params = new URLSearchParams(window.location.search);
params.set("view", normalizedView);
history.replaceState(null, "", `${window.location.pathname}?${params.toString()}`);
if (normalizedView === "diagnostics") void loadDiagnostics();
}
function formatDuration(ms) {
if (!Number.isFinite(ms)) return "n/a";
if (ms < 1000) return `${Math.round(ms)} ms`;
return `${(ms / 1000).toFixed(1)} s`;
}
function renderDiagnosticsList(element, items, emptyText) {
element.innerHTML = "";
if (!items.length) {
const empty = document.createElement("p");
empty.className = "status persona-status";
empty.textContent = emptyText;
element.appendChild(empty);
return;
}
items.forEach((item) => {
const row = document.createElement("div");
row.className = "diagnostics-list-item";
row.textContent = item;
element.appendChild(row);
});
}
async function loadDiagnostics() {
if (!isAdminUser) return false;
diagnosticsStatus.textContent = "Checking...";
const res = await get("/admin/diagnostics");
if (res.error) {
setNotice(res.error, "error");
diagnosticsStatus.textContent = "Unavailable";
return false;
}
const model = res.model || {};
const lastFailure = model.lastFailure;
const activeRequests = (model.activeRequests || []).reduce((sum, item) => sum + Number(item.activeRequests || 0), 0);
diagnosticsStatus.textContent = model.ok ? "Online" : "Degraded";
diagnosticsBackend.textContent = model.backendUrl || "Backend unavailable";
diagnosticsLatency.textContent = formatDuration(model.latencyMs ?? model.listLatencyMs);
diagnosticsCheckedAt.textContent = model.checkedAt ? `Checked ${new Date(model.checkedAt).toLocaleString()}` : "Not checked yet";
diagnosticsModelCount.textContent = String(model.modelCount || 0);
diagnosticsCacheAge.textContent = `Cache age ${formatDuration(model.cacheAgeMs)}`;
diagnosticsActiveRequests.textContent = String(activeRequests);
diagnosticsLastFailure.textContent = lastFailure ? `${lastFailure.error} (${new Date(lastFailure.at).toLocaleString()})` : "No recorded failures";
renderDiagnosticsList(diagnosticsModels, model.availableModels || [], "No models returned by the backend.");
renderDiagnosticsList(diagnosticsWarnings, res.configWarnings || [], "No config warnings.");
setNotice("Diagnostics loaded.", model.ok ? "success" : "error");
return true;
}
function formatMeta(persona) {
@@ -266,6 +334,8 @@ async function loadProfile() {
}
currentUsernameInput.value = res.username || "";
newUsernameInput.value = res.username || "";
isAdminUser = res.username === "admin";
diagnosticsNavItem.classList.toggle("hidden", !isAdminUser);
return true;
}
@@ -444,6 +514,7 @@ workspaceModeSelect.addEventListener("change", (event) => {
localStorage.setItem("krishd-workspace-mode", mode);
setNotice(`Workspace mode set to ${mode}.`, "success");
});
refreshDiagnosticsBtn.addEventListener("click", () => { void loadDiagnostics(); });
settingsNavItems.forEach((item) => item.addEventListener("click", () => setSettingsView(item.dataset.settingsView)));
createAiCharacterBtn.addEventListener("click", () => openPersonaModal(null, "assistant"));
@@ -480,9 +551,10 @@ window.addEventListener("load", async () => {
workspaceModeSelect.value = localStorage.getItem("krishd-workspace-mode") || "basic";
const params = new URLSearchParams(window.location.search);
const view = params.get("view") || "personal";
const profileOk = await loadProfile();
setSettingsView(view);
const [profileOk, personasOk] = await Promise.all([loadProfile(), loadPersonas()]);
if (profileOk && personasOk) setNotice("Settings loaded.");
const personasOk = await loadPersonas();
if (profileOk && personasOk && view !== "diagnostics") setNotice("Settings loaded.");
const create = params.get("create");
if (create === "assistant") {
+46
View File
@@ -2351,6 +2351,52 @@ select:focus {
align-items: end;
}
.diagnostics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
}
.diagnostics-card {
border: 1px solid var(--pill-border);
border-radius: 8px;
padding: 14px;
background: var(--pill-surface);
}
.diagnostics-card span {
display: block;
color: var(--text-muted);
font-size: 0.82rem;
font-weight: 700;
text-transform: uppercase;
}
.diagnostics-card strong {
display: block;
margin-top: 6px;
font-size: 1.4rem;
}
.diagnostics-columns {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 16px;
}
.diagnostics-list {
display: grid;
gap: 8px;
}
.diagnostics-list-item {
border: 1px solid var(--pill-border);
border-radius: 8px;
padding: 10px 12px;
background: var(--surface-strong);
overflow-wrap: anywhere;
}
.settings-theme-note {
display: grid;
gap: 4px;