make huge overhauls to backend
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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") {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user