moved js files in public into js and css into css folder

This commit is contained in:
2026-09-22 21:55:02 +02:00
parent 99a3277e64
commit 08f5f3f8d9
40 changed files with 1772 additions and 1555 deletions
+89
View File
@@ -0,0 +1,89 @@
export async function request(url, data, method = "POST") {
try {
const res = await fetch(url, {
method,
credentials: "same-origin",
headers: {"Content-Type": "application/json"},
body: data ? JSON.stringify(data) : undefined
});
return await res.json();
} catch {
return {error: "Network error - please try again."};
}
}
export const get = (url) => request(url, null, "GET");
export const post = (url, data) => request(url, data, "POST");
export const put = (url, data) => request(url, data, "PUT");
export const del = (url) => request(url, null, "DELETE");
/**
* @typedef {"skipped"|"failed"|"no_results"|"no_relevant_results"|"succeeded"} SearchState
* @typedef {{event: "status", data: {status: "accepted"|"searching"|"generating", sourceCount?: number, searchState?: SearchState}}
* | {event: "chunk", data: {chunk: string, fullReply: string}}
* | {event: "thinking", data: {chunk: string, fullThinking: string}}
* | {event: "done", data: {reply: string, sources?: import("./webSearch.js").WebSource[], searchState?: SearchState}}
* | {event: "error", data: {error: string, code?: string, searchState?: SearchState}}} ChatStreamEvent
*/
export async function stream(url, data, handlers = {}, signal = undefined) {
const response = await fetch(url, {
method: "POST",
credentials: "same-origin",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(data || {}),
signal
});
if (!response.ok || !response.body) {
let error = "Streaming request failed";
try {
const payload = await response.json();
error = payload.error || error;
} catch {
// Keep the generic error if the server did not send JSON.
}
if (handlers.onError) {
handlers.onError({error});
return;
}
throw new Error(error);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let terminal = false;
try {
while (true) {
const {value, done} = await reader.read();
if (done) break;
buffer += decoder.decode(value, {stream: true});
const parts = buffer.split(/\r?\n\r?\n/);
buffer = parts.pop() || "";
for (const part of parts) {
const lines = part.split("\n");
const eventLine = lines.find((line) => line.startsWith("event:"));
const dataLine = lines.find((line) => line.startsWith("data:"));
if (!eventLine || !dataLine) continue;
const event = eventLine.slice(6).trim();
const payload = JSON.parse(dataLine.slice(5).trim());
if (!payload || typeof payload !== "object") throw new Error("Invalid response stream.");
const textFields = {chunk: "fullReply", thinking: "fullThinking", status: "status", done: "reply", error: "error"};
if (textFields[event] && typeof payload[textFields[event]] !== "string") throw new Error("Invalid response stream.");
if (terminal) continue;
if (event === "chunk" && handlers.onChunk) handlers.onChunk(payload);
if (event === "thinking" && handlers.onThinking) handlers.onThinking(payload);
if (event === "status" && handlers.onStatus) handlers.onStatus(payload);
if (event === "done" && handlers.onDone) handlers.onDone(payload);
if (event === "error" && handlers.onError) handlers.onError(payload);
if (event === "done" || event === "error") terminal = true;
}
if (terminal) break;
}
if (!terminal) throw new Error("Response was interrupted. Please try again.");
} finally {
await reader.cancel().catch(() => {});
reader.releaseLock();
}
}
+106
View File
@@ -0,0 +1,106 @@
export function appendAttachments(container, attachments = []) {
if (!attachments.length) return;
const gallery = document.createElement("div");
gallery.className = "message-images";
for (const item of attachments) {
if (!/^[a-f0-9-]{36}$/.test(item.id)) continue;
const link = document.createElement("a");
link.href = `/attachments/${item.id}`;
link.target = "_blank";
link.rel = "noopener";
const image = document.createElement("img");
image.src = link.href;
image.alt = item.originalName || "Attached image";
image.loading = "lazy";
link.append(image);
gallery.append(link);
}
container.append(gallery);
}
export function createAttachmentComposer({onChange, onError}) {
const button = document.getElementById("attachImage");
const picker = document.getElementById("imagePicker");
const preview = document.getElementById("uploadPreviews");
const composer = document.querySelector(".input-container");
let items = [];
let busy = false;
let limits = {maxSizeMb: 10, maxFiles: 5};
fetch("/uploads/config").then(r => r.ok ? r.json() : limits).then(value => { limits = value; }).catch(() => {});
const render = () => {
preview.replaceChildren();
preview.hidden = !items.length;
for (const item of items) {
const card = document.createElement("div");
card.className = "upload-preview";
const image = document.createElement("img");
image.src = item.preview;
image.alt = item.file.name;
const label = document.createElement("span");
label.textContent = `${item.file.name} — ${item.error || (item.metadata ? "Ready" : "Uploading…")}`;
card.append(image, label);
if (item.error) {
const retry = document.createElement("button");
retry.type = "button"; retry.textContent = "Retry"; retry.disabled = busy;
retry.onclick = () => { void upload(item); };
card.append(retry);
}
const remove = document.createElement("button");
remove.type = "button"; remove.textContent = "×"; remove.disabled = busy;
remove.setAttribute("aria-label", `Remove ${item.file.name}`);
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"});
render();
};
card.append(remove); preview.append(card);
}
onChange();
};
const upload = async item => {
item.error = null;
item.controller = new AbortController();
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");
item.metadata = result;
if (!items.includes(item)) void fetch(`/attachments/${result.id}`, {method: "DELETE"});
} catch (error) {
if (error.name !== "AbortError") item.error = error.message || "Upload failed";
}
render();
};
const add = files => {
if (busy) return;
for (const file of files) {
if (items.length >= limits.maxFiles) { onError(`Attach at most ${limits.maxFiles} images`); break; }
if (!["image/png", "image/jpeg", "image/webp"].includes(file.type)) { onError("Use PNG, JPEG or WebP images"); continue; }
if (!file.size || file.size > limits.maxSizeMb * 1024 * 1024) { onError(`Images must be non-empty and at most ${limits.maxSizeMb} MB`); continue; }
const item = {file, preview: URL.createObjectURL(file)};
items.push(item); void upload(item);
}
};
button.onclick = () => picker.click();
picker.onchange = () => { add(picker.files); picker.value = ""; };
composer.addEventListener("dragover", event => {
if (event.dataTransfer.types.includes("Files")) { event.preventDefault(); composer.classList.add("image-drag-target"); }
});
composer.addEventListener("dragleave", () => composer.classList.remove("image-drag-target"));
composer.addEventListener("drop", event => {
event.preventDefault(); composer.classList.remove("image-drag-target"); add(event.dataTransfer.files);
});
composer.addEventListener("paste", event => {
const images = [...(event.clipboardData?.files || [])].filter(file => file.type.startsWith("image/"));
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); },
setBusy(value) { busy = value; button.disabled = value; picker.disabled = value; render(); },
clear() { items.forEach(v => URL.revokeObjectURL(v.preview)); items = []; render(); }
};
}
+127
View File
@@ -0,0 +1,127 @@
import {chatDrawer, chatDrawerButton, chatDrawerCloseBtn} from "./dom.js?v=20260909-account-controls";
let lastFocusedElement = null;
const focusableSelector = [
"a[href]",
"button:not([disabled])",
"textarea:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"summary",
"[tabindex]:not([tabindex='-1'])"
].join(",");
if (chatDrawer && chatDrawer.parentElement !== document.body) {
document.body.appendChild(chatDrawer);
}
function getFocusableElements() {
if (!chatDrawer) return [];
return Array.from(chatDrawer.querySelectorAll(focusableSelector))
.filter((element) => element instanceof HTMLElement && element.offsetParent !== null);
}
function syncChatDrawerState(isOpen) {
if (!chatDrawer || !chatDrawerButton) return;
if (isOpen) {
lastFocusedElement = document.activeElement instanceof HTMLElement ? document.activeElement : null;
document.body.classList.remove("sidebar-open");
}
chatDrawer.classList.toggle("hidden", !isOpen);
chatDrawerButton.setAttribute("aria-expanded", String(isOpen));
document.body.classList.toggle("chat-drawer-open", isOpen);
if (isOpen) {
window.requestAnimationFrame(() => {
const firstFocusable = getFocusableElements()[0];
(chatDrawerCloseBtn || firstFocusable)?.focus();
});
} else if (lastFocusedElement) {
lastFocusedElement.focus();
lastFocusedElement = null;
}
}
function isChatDrawerOpen() {
return Boolean(chatDrawer && !chatDrawer.classList.contains("hidden"));
}
function openChatDrawer() {
syncChatDrawerState(true);
}
function closeChatDrawer() {
syncChatDrawerState(false);
}
function toggleChatDrawer() {
syncChatDrawerState(!isChatDrawerOpen());
}
function handleDisclosureToggle(event) {
const disclosure = event.target;
if (!(disclosure instanceof HTMLDetailsElement) || !chatDrawer?.contains(disclosure)) return;
if (disclosure.matches(".chat-drawer-more, .chat-drawer-danger")) {
const hint = disclosure.querySelector(":scope > summary .chat-drawer-row-hint");
if (hint) hint.textContent = disclosure.open ? "Hide" : "Show";
}
if (disclosure.open) {
chatDrawer.querySelectorAll("details[open]").forEach((other) => {
if (other !== disclosure) other.open = false;
});
}
}
function handleDocumentClick(event) {
if (!isChatDrawerOpen()) return;
const target = event.target;
if (!(target instanceof Node)) return;
if (chatDrawerButton?.contains(target)) return;
if (chatDrawer?.querySelector(".chat-drawer-panel")?.contains(target)) return;
closeChatDrawer();
}
function handleDocumentKeydown(event) {
if (!isChatDrawerOpen()) return;
if (event.key === "Escape") {
closeChatDrawer();
return;
}
if (event.key !== "Tab") return;
const focusableElements = getFocusableElements();
if (!focusableElements.length) return;
const first = focusableElements[0];
const last = focusableElements[focusableElements.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
return;
}
if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
if (chatDrawerButton && chatDrawer) {
chatDrawerButton.addEventListener("click", () => {
toggleChatDrawer();
});
}
if (chatDrawerCloseBtn) {
chatDrawerCloseBtn.addEventListener("click", closeChatDrawer);
}
document.addEventListener("click", handleDocumentClick);
document.addEventListener("keydown", handleDocumentKeydown);
chatDrawer?.addEventListener("toggle", handleDisclosureToggle, true);
export {
closeChatDrawer,
isChatDrawerOpen,
openChatDrawer,
toggleChatDrawer
};
+17
View File
@@ -0,0 +1,17 @@
export const requestedChatId = Number(window.location.pathname.match(/^\/app\/chats\/(\d+)$/)?.[1] || new URLSearchParams(window.location.search).get("chat")) || null;
export const onboardingPrompts = {
ask: ["Explain this topic in simple terms.", "Compare these options and recommend one."],
brainstorm: ["Give me 10 practical ideas for this problem.", "Suggest 3 creative directions and tradeoffs."],
roleplay: ["Start a roleplay scene with immediate tension.", "Give me a dramatic opening with clear stakes."]
};
// Stable keys preserve existing saved appearance preferences.
export const themes = {
"fakegpt": {name: "Grove", short: "GR"},
"fraud": {name: "Parchment", short: "PA"},
"germini": {name: "Aurora", short: "AU"},
"slopilot": {name: "Current", short: "CU"},
"beta-ai": {name: "Iris", short: "IR"},
"confusity": {name: "Graphite", short: "GP"}
};
+206
View File
@@ -0,0 +1,206 @@
const $ = (id) => document.getElementById(id);
const chatDiv = $("chat");
const messagesDiv = $("messages");
const modelSelect = $("model");
const modelPanel = $("modelPanel");
const modelBadgeName = $("modelBadgeName");
const headerModelName = $("headerModelName");
const headerPersonaName = $("headerPersonaName");
const modelCount = $("modelCount");
const modelHelpTitle = $("modelHelpTitle");
const modelHelpBadge = $("modelHelpBadge");
const modelHelpSummary = $("modelHelpSummary");
const chatTemperatureInput = $("chatTemperature");
const chatContextLengthInput = $("chatContextLength");
const chatResponseLengthInput = $("chatResponseLength");
const chatSystemInstructionInput = $("chatSystemInstruction");
const saveGenerationSettingsBtn = $("saveGenerationSettings");
const chatDrawerPersonaValue = $("chatDrawerPersonaValue");
const msgInput = $("msgInput");
const sendBtn = $("send");
const chatList = $("chatList");
const chatSearchInput = $("chatSearch");
const chatSidebar = $("chatSidebar");
const chatSidebarToggle = $("chatSidebarToggle");
const chatDrawerCloseBtn = $("chatDrawerClose");
const chatDrawerButton = $("chatDrawerButton");
const chatDrawer = $("chatDrawer");
const newChatBtn = $("newChat");
const chatListLoading = $("chatListLoading");
const chatListLoadingText = $("chatListLoadingText");
const renameChatBtn = $("renameChat");
const clearChatBtn = $("clearChat");
const exportChatBtn = $("exportChat");
const pinChatBtn = $("pinChat");
const moveChatFolderBtn = $("moveChatFolder");
const manageChatMemoryBtn = $("manageChatMemory");
const managePersonaStateBtn = $("managePersonaState");
const archiveChatBtn = $("archiveChat");
const backupWorkspaceBtn = $("backupWorkspace");
const importWorkspaceBtn = $("importWorkspace");
const importWorkspaceFileInput = $("importWorkspaceFile");
const activeChatTitle = $("activeChatTitle");
const chatActivityOverlay = $("chatActivityOverlay");
const chatActivityEyebrow = $("chatActivityEyebrow");
const chatActivityTitle = $("chatActivityTitle");
const chatActivityDetail = $("chatActivityDetail");
const chatCharacterPill = $("chatCharacterPill");
const chatUserPersonaPill = $("chatUserPersonaPill");
const userPersonaList = $("userPersonaList");
const personaForm = $("personaForm");
const personaFormTitle = $("personaFormTitle");
const personaTypeSelect = $("personaType");
const personaNameInput = $("personaName");
const personaPronounsInput = $("personaPronouns");
const personaAppearanceInput = $("personaAppearance");
const personaBackgroundInput = $("personaBackground");
const personaDetailsInput = $("personaDetails");
const personaExamplesField = $("personaExamplesField");
const personaExampleDialoguesInput = $("personaExampleDialogues");
const personaFormNotice = $("personaFormNotice");
const roleplayNewPersonaBtn = $("roleplayNewPersona");
const clearUserPersonaBtn = $("clearUserPersona");
const activeUserPersonaStatus = $("activeUserPersonaStatus");
const personaModal = $("personaModal");
const personaCloseBtn = $("personaClose");
const personaPanel = $("personaPanel");
const roleplayStarterModal = $("roleplayStarterModal");
const roleplayStarterClose = $("roleplayStarterClose");
const roleplayStarterCancel = $("roleplayStarterCancel");
const roleplayStarterConfirm = $("roleplayStarterConfirm");
const roleplayStarterNotice = $("roleplayStarterNotice");
const roleplayCharacterSelect = $("roleplayCharacterSelect");
const roleplayUserPersonaSelect = $("roleplayUserPersonaSelect");
const roleplayStarterTitle = $("roleplayStarterTitle");
const roleplayStarterIntroCopy = $("roleplayStarterIntroCopy");
const roleplayStarterBack = $("roleplayStarterBack");
const roleplayStarterStepOne = $("roleplayStarterStepOne");
const roleplayStarterStepTwo = $("roleplayStarterStepTwo");
const roleplayScenarioInput = $("roleplayScenarioInput");
const roleplayStarterSuggestions = $("roleplayStarterSuggestions");
const popupModal = $("popupModal");
const popupCloseBtn = $("popupClose");
const popupCancelBtn = $("popupCancel");
const popupConfirmBtn = $("popupConfirm");
const popupTitle = $("popupTitle");
const popupEyebrow = $("popupEyebrow");
const popupDescription = $("popupDescription");
const popupField = $("popupField");
const popupInputLabel = $("popupInputLabel");
const popupInput = $("popupInput");
const onboardingModal = $("onboardingModal");
const onboardingTitle = $("onboardingTitle");
const onboardingSubtitle = $("onboardingSubtitle");
const onboardingChoices = $("onboardingChoices");
const onboardingBack = $("onboardingBack");
const onboardingContinue = $("onboardingContinue");
const onboardingSkip = $("onboardingSkip");
const brandNameTargets = document.querySelectorAll("[data-brand-name]");
const brandLogoTargets = document.querySelectorAll("[data-brand-logo]");
const modelSection = document.querySelector(".chat-drawer-section--model");
const personaSection = document.querySelector(".chat-drawer-section--persona");
export {
$,
activeChatTitle,
activeUserPersonaStatus,
chatActivityDetail,
chatActivityEyebrow,
chatActivityOverlay,
chatActivityTitle,
chatCharacterPill,
chatDrawerPersonaValue,
chatDrawer,
chatDrawerButton,
chatDiv,
chatList,
chatListLoading,
chatListLoadingText,
chatSidebar,
chatSidebarToggle,
chatDrawerCloseBtn,
chatSearchInput,
chatUserPersonaPill,
chatTemperatureInput,
chatContextLengthInput,
chatResponseLengthInput,
chatSystemInstructionInput,
clearChatBtn,
clearUserPersonaBtn,
exportChatBtn,
pinChatBtn,
moveChatFolderBtn,
manageChatMemoryBtn,
managePersonaStateBtn,
archiveChatBtn,
backupWorkspaceBtn,
importWorkspaceBtn,
importWorkspaceFileInput,
messagesDiv,
modelBadgeName,
headerModelName,
headerPersonaName,
modelCount,
modelHelpBadge,
modelHelpSummary,
modelHelpTitle,
modelPanel,
modelSection,
modelSelect,
msgInput,
newChatBtn,
onboardingBack,
onboardingChoices,
onboardingContinue,
onboardingModal,
onboardingSkip,
onboardingSubtitle,
onboardingTitle,
personaCloseBtn,
personaDetailsInput,
personaExampleDialoguesInput,
personaExamplesField,
personaForm,
personaFormNotice,
personaFormTitle,
personaModal,
personaNameInput,
personaPanel,
personaSection,
personaPronounsInput,
personaTypeSelect,
personaAppearanceInput,
personaBackgroundInput,
popupCancelBtn,
popupCloseBtn,
popupConfirmBtn,
popupDescription,
popupEyebrow,
popupField,
popupInput,
popupInputLabel,
popupModal,
popupTitle,
renameChatBtn,
roleplayCharacterSelect,
roleplayNewPersonaBtn,
roleplayScenarioInput,
roleplayStarterBack,
roleplayStarterCancel,
roleplayStarterClose,
roleplayStarterConfirm,
roleplayStarterIntroCopy,
roleplayStarterModal,
roleplayStarterNotice,
roleplayStarterStepOne,
roleplayStarterStepTwo,
roleplayStarterSuggestions,
roleplayStarterTitle,
roleplayUserPersonaSelect,
sendBtn,
saveGenerationSettingsBtn,
brandLogoTargets,
brandNameTargets,
userPersonaList
};
+688
View File
@@ -0,0 +1,688 @@
import { get, post, put } from "./js/api.js?v=20260909-account-controls";
import { initializeAppearance } from "./js/themeController.js?v=20260909-account-controls";
import { clearStatus, setStatus } from "./js/statusNotice.js?v=20260909-account-controls";
import { populatePersonaForm, readPersonaForm } from "./js/personaForm.js?v=20260909-account-controls";
const $ = (id) => document.getElementById(id);
const marketPersonaList = $("marketPersonaList");
const marketUserPersonaList = $("marketUserPersonaList");
const refreshMarketBtn = $("refreshMarket");
const marketStatus = $("marketStatus");
const marketSearchInput = $("marketSearch");
const marketAssistantSort = $("marketAssistantSort");
const marketUserSort = $("marketUserSort");
const marketViewButtons = document.querySelectorAll("[data-market-view]");
const marketPanels = document.querySelectorAll("[data-market-panel]");
const marketCreateAiCharacterBtn = $("marketCreateAiCharacter");
const marketCreateUserPersonaBtn = $("marketCreateUserPersona");
const marketPreviewModal = $("marketPreviewModal");
const marketPreviewTitle = $("marketPreviewTitle");
const marketPreviewName = $("marketPreviewName");
const marketPreviewMeta = $("marketPreviewMeta");
const marketPreviewBody = $("marketPreviewBody");
const marketPreviewPronouns = $("marketPreviewPronouns");
const marketPreviewAppearance = $("marketPreviewAppearance");
const marketPreviewBackground = $("marketPreviewBackground");
const marketPreviewDetails = $("marketPreviewDetails");
const marketPreviewExampleDialogues = $("marketPreviewExampleDialogues");
const marketPreviewIntro = $("marketPreviewIntro");
const marketPreviewUserPersonaField = $("marketPreviewUserPersonaField");
const marketPreviewUserPersonaSelect = $("marketPreviewUserPersonaSelect");
const marketPreviewScenarioField = $("marketPreviewScenarioField");
const marketPreviewScenario = $("marketPreviewScenario");
const marketPreviewStepOne = $("marketPreviewStepOne");
const marketPreviewStepTwo = $("marketPreviewStepTwo");
const marketPreviewConfirm = $("marketPreviewConfirm");
const marketPreviewClose = $("marketPreviewClose");
const marketPersonaModal = $("marketPersonaModal");
const marketPersonaClose = $("marketPersonaClose");
const marketPersonaForm = $("marketPersonaForm");
const marketPersonaFormTitle = $("marketPersonaFormTitle");
const marketPersonaFormNotice = $("marketPersonaFormNotice");
const marketPersonaName = $("marketPersonaName");
const marketPersonaPronouns = $("marketPersonaPronouns");
const marketPersonaAppearance = $("marketPersonaAppearance");
const marketPersonaBackground = $("marketPersonaBackground");
const marketPersonaDetails = $("marketPersonaDetails");
const marketPersonaExampleDialogues = $("marketPersonaExampleDialogues");
const marketActivityOverlay = $("marketActivityOverlay");
const marketActivityEyebrow = $("marketActivityEyebrow");
const marketActivityTitle = $("marketActivityTitle");
const marketActivityDetail = $("marketActivityDetail");
const marketPopupModal = $("marketPopupModal");
const marketPopupClose = $("marketPopupClose");
const marketPopupCancel = $("marketPopupCancel");
const marketPopupConfirm = $("marketPopupConfirm");
const marketPopupTitle = $("marketPopupTitle");
const marketPopupEyebrow = $("marketPopupEyebrow");
const marketPopupDescription = $("marketPopupDescription");
const marketPopupField = $("marketPopupField");
const marketPopupInputLabel = $("marketPopupInputLabel");
const marketPopupInput = $("marketPopupInput");
const brandNameTargets = document.querySelectorAll("[data-brand-name]");
const marketPersonaFields = {
name: marketPersonaName,
pronouns: marketPersonaPronouns,
appearance: marketPersonaAppearance,
background: marketPersonaBackground,
details: marketPersonaDetails,
exampleDialogues: marketPersonaExampleDialogues
};
const validMarketSorts = new Set(["best", "newest", "most_favorited", "most_popular", "top_rated", "alphabetical"]);
let marketPersonasByType = { assistant: [], user: [] };
let assistantPersonas = [];
let userPersonas = [];
let pendingMarketPersona = null;
let editingPersonaId = null;
let activeUserPersonaId = null;
let activeMarketView = "ai-characters";
let marketSorts = { assistant: "best", user: "best" };
let marketActivityDepth = 0;
let marketPreviewStep = 1;
let marketPopupResolver = null;
function setMarketStatus(message, state = "") {
setStatus(marketStatus, message, state, "status persona-status");
}
function closeMarketPopup(value = null) {
marketPopupModal.classList.add("hidden");
if (marketPopupResolver) {
const resolve = marketPopupResolver;
marketPopupResolver = null;
resolve(value);
}
}
function promptMarketPopup({
eyebrow = "Action",
title = "Enter a value",
description = "",
label = "Value",
value = "",
placeholder = "",
confirmLabel = "Confirm"
} = {}) {
marketPopupEyebrow.textContent = eyebrow;
marketPopupTitle.textContent = title;
marketPopupDescription.textContent = description;
marketPopupInputLabel.textContent = label;
marketPopupInput.value = value;
marketPopupInput.placeholder = placeholder;
marketPopupConfirm.textContent = confirmLabel;
marketPopupField.classList.remove("hidden");
marketPopupModal.classList.remove("hidden");
marketPopupInput.focus();
marketPopupInput.select();
return new Promise((resolve) => {
marketPopupResolver = resolve;
});
}
function setPersonaFormNotice(message = "", state = "") {
if (!message) {
clearStatus(marketPersonaFormNotice);
return;
}
setStatus(marketPersonaFormNotice, message, state);
}
function setMarketActivity(active, {
eyebrow = "Please wait",
title = "Opening roleplay",
detail = "The AI character is preparing the first message."
} = {}) {
if (!marketActivityOverlay || !marketActivityEyebrow || !marketActivityTitle || !marketActivityDetail) return;
if (active) {
marketActivityDepth += 1;
marketActivityEyebrow.textContent = eyebrow;
marketActivityTitle.textContent = title;
marketActivityDetail.textContent = detail;
marketActivityOverlay.classList.remove("hidden");
return;
}
marketActivityDepth = Math.max(0, marketActivityDepth - 1);
if (marketActivityDepth === 0) {
marketActivityOverlay.classList.add("hidden");
marketActivityEyebrow.textContent = "Please wait";
marketActivityTitle.textContent = "Opening roleplay";
marketActivityDetail.textContent = "The AI character is preparing the first message.";
}
}
function setMarketView(view) {
const normalizedView = view === "user-personas" ? "user-personas" : "ai-characters";
activeMarketView = normalizedView;
marketViewButtons.forEach((item) => {
const isActive = item.dataset.marketView === normalizedView;
item.classList.toggle("active", isActive);
item.setAttribute("aria-selected", String(isActive));
});
marketPanels.forEach((panel) => {
const isActive = panel.dataset.marketPanel === normalizedView;
panel.classList.toggle("active", isActive);
panel.setAttribute("aria-hidden", String(!isActive));
});
syncMarketQuery();
applyMarketFilter();
}
function normalizeMarketSort(sort) {
const normalized = String(sort || "best").trim().toLowerCase().replace(/-/g, "_");
return validMarketSorts.has(normalized) ? normalized : "best";
}
function syncMarketQuery() {
const params = new URLSearchParams(window.location.search);
params.set("view", activeMarketView);
params.set("assistantSort", marketSorts.assistant);
params.set("userSort", marketSorts.user);
history.replaceState(null, "", `${window.location.pathname}?${params.toString()}`);
}
function setMarketSort(personaType, sort, { reload = true } = {}) {
const normalizedType = personaType === "user" ? "user" : "assistant";
const normalizedSort = normalizeMarketSort(sort);
marketSorts[normalizedType] = normalizedSort;
if (normalizedType === "assistant" && marketAssistantSort) marketAssistantSort.value = normalizedSort;
if (normalizedType === "user" && marketUserSort) marketUserSort.value = normalizedSort;
syncMarketQuery();
if (reload) {
void loadMarketPersonas();
}
}
function buildPersonaMeta(persona) {
const creator = persona.creator_username || "You";
const pronouns = persona.pronouns ? `Pronouns: ${persona.pronouns}` : "Pronouns: n/a";
return `${creator} | ${pronouns}`;
}
function formatPersonaField(value) {
return value && value.trim() ? value.trim() : "Not provided.";
}
function setCollapsiblePreviewText(element, value, maxLength = 220) {
const text = formatPersonaField(value);
element.dataset.fullText = text;
let toggle = element.nextElementSibling;
if (!toggle || !toggle.classList.contains("market-preview-toggle")) {
toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "market-preview-toggle hidden";
element.insertAdjacentElement("afterend", toggle);
toggle.addEventListener("click", () => {
const isExpanded = element.dataset.expanded === "true";
element.dataset.expanded = isExpanded ? "false" : "true";
if (element.dataset.expanded === "true") {
element.textContent = element.dataset.fullText;
toggle.textContent = "Show less";
} else {
element.textContent = `${element.dataset.fullText.slice(0, maxLength).trimEnd()}...`;
toggle.textContent = "Show more";
}
});
}
const shouldCollapse = text !== "Not provided." && text.length > maxLength;
element.dataset.expanded = "false";
if (shouldCollapse) {
element.textContent = `${text.slice(0, maxLength).trimEnd()}...`;
toggle.textContent = "Show more";
toggle.classList.remove("hidden");
} else {
element.textContent = text;
toggle.classList.add("hidden");
}
}
function closeMarketPreview() {
marketPreviewModal.classList.add("hidden");
pendingMarketPersona = null;
marketPreviewStep = 1;
marketPreviewUserPersonaSelect.innerHTML = "";
marketPreviewScenario.value = "";
}
function renderMarketPreviewStep() {
const isScenarioStep = marketPreviewStep === 2;
const isAssistant = pendingMarketPersona?.persona_type === "assistant";
if (marketPreviewBody) marketPreviewBody.classList.toggle("hidden", isScenarioStep && isAssistant);
if (marketPreviewStepOne) marketPreviewStepOne.classList.toggle("hidden", isScenarioStep || pendingMarketPersona?.persona_type !== "assistant");
if (marketPreviewStepTwo) marketPreviewStepTwo.classList.toggle("hidden", !isScenarioStep || pendingMarketPersona?.persona_type !== "assistant");
if (marketPreviewTitle) {
marketPreviewTitle.textContent = isAssistant && isScenarioStep ? "Set the opening scene" : "Persona details";
}
if (marketPreviewIntro) {
marketPreviewIntro.textContent = pendingMarketPersona?.persona_type === "assistant"
? (isScenarioStep
? "Add an optional custom scenario. If left empty, the AI Character will invent its own opening scene."
: "Choose who you are in this roleplay before moving on to the opening scene.")
: "Review this persona and choose how you want to use it.";
}
if (marketPreviewConfirm) {
marketPreviewConfirm.textContent = pendingMarketPersona?.persona_type !== "assistant" ? "Collect persona" : "Start roleplay";
}
}
function populateUserPersonaChoices() {
marketPreviewUserPersonaSelect.innerHTML = "";
const promptOption = document.createElement("option");
promptOption.value = "";
promptOption.textContent = "Choose who you are in this roleplay";
promptOption.disabled = true;
promptOption.selected = true;
marketPreviewUserPersonaSelect.appendChild(promptOption);
const selfOption = document.createElement("option");
selfOption.value = "self";
selfOption.textContent = "Yourself (no persona)";
marketPreviewUserPersonaSelect.appendChild(selfOption);
userPersonas.forEach((persona) => {
const option = document.createElement("option");
option.value = String(persona.id);
option.textContent = persona.name;
option.selected = persona.id === activeUserPersonaId;
marketPreviewUserPersonaSelect.appendChild(option);
});
const createOption = document.createElement("option");
createOption.value = "create_new";
createOption.textContent = "Create new persona";
marketPreviewUserPersonaSelect.appendChild(createOption);
}
function openMarketPreview(persona) {
pendingMarketPersona = persona;
marketPreviewStep = 1;
marketPreviewName.textContent = persona.name;
marketPreviewMeta.textContent = buildPersonaMeta(persona);
setCollapsiblePreviewText(marketPreviewPronouns, persona.pronouns, 80);
setCollapsiblePreviewText(marketPreviewAppearance, persona.appearance);
setCollapsiblePreviewText(marketPreviewBackground, persona.background);
setCollapsiblePreviewText(marketPreviewDetails, persona.details);
setCollapsiblePreviewText(marketPreviewExampleDialogues, persona.example_dialogues);
const isAssistant = persona.persona_type === "assistant";
marketPreviewUserPersonaField.classList.toggle("hidden", !isAssistant);
if (isAssistant) {
populateUserPersonaChoices();
marketPreviewConfirm.classList.add("hidden");
} else {
marketPreviewConfirm.classList.remove("hidden");
}
renderMarketPreviewStep();
marketPreviewModal.classList.remove("hidden");
}
function openPersonaForm(persona = null) {
editingPersonaId = persona ? persona.id : null;
marketPersonaFormTitle.textContent = persona ? "Edit AI Character" : "Create AI Character";
populatePersonaForm(marketPersonaFields, persona || {});
setPersonaFormNotice("");
marketPersonaModal.classList.remove("hidden");
marketPersonaName.focus();
}
function closePersonaForm() {
marketPersonaModal.classList.add("hidden");
editingPersonaId = null;
setPersonaFormNotice("");
}
function renderMarketList(items, listElement, type) {
listElement.innerHTML = "";
if (!items.length) {
const empty = document.createElement("p");
empty.className = "status persona-status";
empty.textContent = type === "assistant" ? "No AI Characters found." : "No User Personas found.";
listElement.appendChild(empty);
return;
}
items.forEach((persona) => {
const item = document.createElement("div");
const titleRow = document.createElement("div");
const title = document.createElement("h4");
const tag = document.createElement("span");
const meta = document.createElement("p");
const stats = document.createElement("p");
const actions = document.createElement("div");
const detailsBtn = document.createElement("button");
const favoriteBtn = document.createElement("button");
item.className = "persona-item";
titleRow.className = "persona-title-row";
actions.className = "persona-item-actions";
tag.className = `persona-role-tag ${type === "assistant" ? "ai" : "user"}`;
tag.textContent = type === "assistant" ? "AI Character" : "User";
title.textContent = persona.name;
meta.textContent = buildPersonaMeta(persona);
const tags = Array.isArray(persona.tags) && persona.tags.length ? `Tags: ${persona.tags.join(", ")}` : "Tags: none";
const rating = persona.rating_count ? `${Number(persona.rating_average || 0).toFixed(1)}★` : "Unrated";
stats.className = "subtitle";
stats.textContent = `${tags} | Favorites: ${persona.favorite_count || 0} | Uses: ${persona.usage_count || 0} | Rating: ${rating}`;
detailsBtn.type = "button";
detailsBtn.textContent = "Preview";
detailsBtn.addEventListener("click", () => openMarketPreview(persona));
actions.appendChild(detailsBtn);
favoriteBtn.type = "button";
favoriteBtn.className = persona.is_favorite ? "icon-button market-favorite-button active" : "icon-button market-favorite-button";
favoriteBtn.textContent = persona.is_favorite ? "♥" : "♡";
favoriteBtn.title = persona.is_favorite ? "Unfavorite persona" : "Favorite persona";
favoriteBtn.setAttribute("aria-label", favoriteBtn.title);
favoriteBtn.setAttribute("aria-pressed", persona.is_favorite ? "true" : "false");
favoriteBtn.addEventListener("click", () => { void toggleFavorite(persona.id); });
actions.appendChild(favoriteBtn);
const rateBtn = document.createElement("button");
rateBtn.type = "button";
rateBtn.textContent = "Rate";
rateBtn.addEventListener("click", () => { void ratePersona(persona.id); });
actions.appendChild(rateBtn);
const reportBtn = document.createElement("button");
reportBtn.type = "button";
reportBtn.textContent = "Report";
reportBtn.addEventListener("click", () => { void reportPersona(persona.id); });
actions.appendChild(reportBtn);
if (type !== "assistant") {
const quickActionBtn = document.createElement("button");
quickActionBtn.type = "button";
quickActionBtn.textContent = "Collect";
quickActionBtn.addEventListener("click", () => {
void collectMarketPersona(persona.id);
});
actions.appendChild(quickActionBtn);
}
titleRow.append(title, tag);
item.append(titleRow, meta, stats, actions);
listElement.appendChild(item);
});
}
function filterMarketPersonas(items) {
const query = marketSearchInput.value.trim().toLowerCase();
if (!query) return items;
return items.filter((persona) =>
[persona.name, persona.creator_username, persona.pronouns, persona.appearance, persona.background, persona.details, persona.example_dialogues]
.some((field) => field && field.toLowerCase().includes(query))
);
}
function applyMarketFilter() {
const assistantItems = filterMarketPersonas(marketPersonasByType.assistant);
const userItems = filterMarketPersonas(marketPersonasByType.user);
renderMarketList(assistantItems, marketPersonaList, "assistant");
renderMarketList(userItems, marketUserPersonaList, "user");
if (marketSearchInput.value.trim() && !assistantItems.length && !userItems.length) {
setMarketStatus("No personas match your search.", "error");
return;
}
if (activeMarketView === "ai-characters") {
setMarketStatus(`${assistantItems.length} AI Character${assistantItems.length === 1 ? "" : "s"} ready for roleplay.`);
return;
}
if (activeMarketView === "user-personas") {
setMarketStatus(`${userItems.length} User Persona${userItems.length === 1 ? "" : "s"} available to collect.`);
return;
}
setMarketStatus(`${assistantItems.length + userItems.length} persona${assistantItems.length + userItems.length === 1 ? "" : "s"} shown.`);
}
async function refreshMarket() {
const [assistantRes, userRes] = await Promise.all([
get(`/personas/market?personaType=assistant&sort=${encodeURIComponent(marketSorts.assistant)}`),
get(`/personas/market?personaType=user&sort=${encodeURIComponent(marketSorts.user)}`)
]);
if (assistantRes.error || userRes.error) {
setMarketStatus(assistantRes.error || userRes.error, "error");
return;
}
marketPersonasByType = {
assistant: assistantRes.personas || [],
user: userRes.personas || []
};
applyMarketFilter();
}
async function toggleFavorite(marketId) {
const res = await post(`/personas/market/${marketId}/favorite`, {});
if (res.error) return setMarketStatus(res.error, "error");
await refreshMarket();
setMarketStatus(res.favorite ? "Favorited persona." : "Removed favorite.", "success");
}
async function ratePersona(marketId) {
const raw = await promptMarketPopup({
eyebrow: "Rating",
title: "Rate this persona",
description: "Enter a whole number from 1 to 5.",
label: "Rating",
value: "5",
placeholder: "5",
confirmLabel: "Save rating"
});
if (raw === null) return;
const rating = Number(raw);
if (!rating) return;
const res = await post(`/personas/market/${marketId}/rate`, { rating });
if (res.error) return setMarketStatus(res.error, "error");
await refreshMarket();
setMarketStatus("Rating saved.", "success");
}
async function reportPersona(marketId) {
const reason = await promptMarketPopup({
eyebrow: "Report",
title: "Report persona",
description: "Describe the main issue.",
label: "Reason",
placeholder: "Abusive content",
confirmLabel: "Continue"
});
if (!reason) return;
const details = await promptMarketPopup({
eyebrow: "Report",
title: "Extra details",
description: "Add optional context for the report.",
label: "Details",
placeholder: "Optional",
confirmLabel: "Submit report"
});
if (details === null) return;
const res = await post(`/personas/market/${marketId}/report`, { reason, details });
if (res.error) return setMarketStatus(res.error, "error");
setMarketStatus("Report submitted.", "success");
}
async function loadOwnedPersonas() {
const res = await get("/personas");
if (res.error) return false;
assistantPersonas = res.assistantPersonas || [];
userPersonas = res.userPersonas || [];
activeUserPersonaId = res.activeUserPersonaId || null;
return true;
}
async function loadMarketPersonas() {
setMarketStatus("Loading personas...");
const ownedOk = await loadOwnedPersonas();
if (!ownedOk) {
setMarketStatus("Unable to load your personas.", "error");
return;
}
await refreshMarket();
}
async function collectMarketPersona(marketId) {
setMarketStatus("Collecting User Persona...");
const res = await post(`/personas/market/${marketId}/collect`, { equip: true });
if (res.error) {
setMarketStatus(res.error, "error");
return;
}
await loadMarketPersonas();
setMarketStatus("User Persona collected.", "success");
}
async function startMarketPersonaChat(marketId, userPersonaSelection, scenarioPrompt = "") {
if (!userPersonaSelection) {
setMarketStatus("Choose who you are before starting roleplay.", "error");
return;
}
if (userPersonaSelection === "create_new") {
window.location.href = "/js/personas?view=my-roleplay-companions&create=user";
return;
}
setMarketStatus("Starting AI Character chat...");
setMarketActivity(true, {
eyebrow: "Starting roleplay",
title: "Opening AI Character chat",
detail: "Generating the initial message and preparing the chat window."
});
const userPersonaId = userPersonaSelection === "self" ? null : Number(userPersonaSelection);
const res = await post(`/personas/market/${marketId}/chat`, {
userPersonaId,
scenarioPrompt: scenarioPrompt.trim()
});
if (res.error || !res.chat) {
setMarketActivity(false);
setMarketStatus(res.error || "Unable to start chat.", "error");
return;
}
window.location.href = `/app/chats/${res.chat.id}`;
}
async function savePersona() {
const payload = {
...readPersonaForm(marketPersonaFields),
personaType: "assistant",
};
if (!payload.name) {
setPersonaFormNotice("Character name is required.", "error");
return;
}
const res = editingPersonaId
? await put(`/personas/${editingPersonaId}`, payload)
: await post("/personas", payload);
if (res.error) {
setPersonaFormNotice(res.error, "error");
return;
}
await loadMarketPersonas();
closePersonaForm();
setMarketView("ai-characters");
setMarketStatus(editingPersonaId ? "AI Character updated." : "AI Character created.", "success");
}
refreshMarketBtn.addEventListener("click", () => {
void loadMarketPersonas();
});
marketPopupConfirm.addEventListener("click", () => closeMarketPopup(marketPopupInput.value));
marketPopupCancel.addEventListener("click", () => closeMarketPopup(null));
marketPopupClose.addEventListener("click", () => closeMarketPopup(null));
marketPopupModal.addEventListener("click", (event) => {
if (event.target === marketPopupModal) closeMarketPopup(null);
});
marketPopupInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
closeMarketPopup(marketPopupInput.value);
}
if (event.key === "Escape") {
event.preventDefault();
closeMarketPopup(null);
}
});
marketSearchInput.addEventListener("input", applyMarketFilter);
if (marketAssistantSort) {
marketAssistantSort.addEventListener("change", () => setMarketSort("assistant", marketAssistantSort.value));
}
if (marketUserSort) {
marketUserSort.addEventListener("change", () => setMarketSort("user", marketUserSort.value));
}
marketViewButtons.forEach((button) => {
button.addEventListener("click", () => setMarketView(button.dataset.marketView));
});
if (marketCreateAiCharacterBtn) {
marketCreateAiCharacterBtn.addEventListener("click", () => openPersonaForm());
}
if (marketCreateUserPersonaBtn) {
marketCreateUserPersonaBtn.addEventListener("click", () => {
window.location.href = "/js/personas?view=my-roleplay-companions&create=user";
});
}
marketPreviewClose.addEventListener("click", closeMarketPreview);
marketPreviewModal.addEventListener("click", (event) => {
if (event.target === marketPreviewModal) closeMarketPreview();
});
marketPreviewUserPersonaSelect.addEventListener("change", () => {
if (!pendingMarketPersona || pendingMarketPersona.persona_type !== "assistant") return;
if (marketPreviewUserPersonaSelect.value === "create_new") {
window.location.href = "/js/personas?view=my-roleplay-companions&create=user";
return;
}
marketPreviewConfirm.classList.toggle("hidden", !marketPreviewUserPersonaSelect.value);
});
marketPreviewConfirm.addEventListener("click", async () => {
if (!pendingMarketPersona) return;
const marketId = pendingMarketPersona.id;
if (pendingMarketPersona.persona_type === "assistant") {
if (marketPreviewStep === 1) {
if (!marketPreviewUserPersonaSelect.value) {
setMarketStatus("Choose who you are before continuing.", "error");
return;
}
marketPreviewStep = 2;
renderMarketPreviewStep();
marketPreviewScenario.focus();
return;
}
await startMarketPersonaChat(
marketId,
marketPreviewUserPersonaSelect.value || "self",
marketPreviewScenario.value || ""
);
closeMarketPreview();
return;
}
closeMarketPreview();
await collectMarketPersona(marketId);
});
marketPersonaClose.addEventListener("click", closePersonaForm);
marketPersonaModal.addEventListener("click", (event) => {
if (event.target === marketPersonaModal) closePersonaForm();
});
marketPersonaForm.addEventListener("submit", (event) => {
event.preventDefault();
void savePersona();
});
window.addEventListener("load", async () => {
initializeAppearance({ title: "Elsewhere Market", nameTargets: brandNameTargets });
const params = new URLSearchParams(window.location.search);
setMarketSort("assistant", params.get("assistantSort") || "best", { reload: false });
setMarketSort("user", params.get("userSort") || "best", { reload: false });
setMarketView(params.get("view") || "ai-characters");
await loadMarketPersonas();
const selectedPersona = [...marketPersonasByType.assistant, ...marketPersonasByType.user].find(persona => persona.id === Number(params.get("persona")));
if (selectedPersona) openMarketPreview(selectedPersona);
if (params.get("create") === "assistant") {
setMarketView("ai-characters");
openPersonaForm();
}
});
+12
View File
@@ -0,0 +1,12 @@
// Conservative allowlist for models supporting the boolean think API.
// Unknown models remain usable for normal chat.
const reasoningFamilies = new Set(["qwen3", "deepseek-r1", "deepseek-v3.1"]);
export function getModelFamily(model) {
const id = typeof model === "string" ? model : model?.model || model?.name || model?.id || "";
return String(id).trim().toLowerCase().split("/").at(-1).split(":")[0];
}
export function supportsReasoning(model) {
return reasoningFamilies.has(getModelFamily(model));
}
+89
View File
@@ -0,0 +1,89 @@
export const defaultModelProfile = {
badge: "General",
summary: "Best for general questions, drafting, and everyday conversation. Use a specialized model when you need stronger coding, reasoning, or roleplay behavior."
};
const modelProfileRules = [
{
matches: ["catgirl"],
badge: "Roleplay",
summary: "Best for playful catgirl character roleplay and expressive, in-character conversation. Not intended for neutral factual answers or technical work."
},
{
matches: ["femboy"],
badge: "Roleplay",
summary: "Best for personality-led roleplay, casual conversation, and maintaining a stylized character voice. Not intended for precise research or technical tasks."
},
{
matches: ["buenzli", "bünzli"],
badge: "Roleplay",
summary: "Best for Swiss-flavored character roleplay, local humor, and casual conversation. Choose a general model for neutral writing or factual questions."
},
{
matches: ["deepseek-r1", "deepseek-r1-distill"],
badge: "Reasoning",
summary: "Best for problems that benefit from deliberate reasoning, such as logic, mathematics, planning, and difficult technical questions. It can be slower and more verbose for simple chat."
},
{
matches: ["codellama", "code-llama", "deepseek-coder", "qwen2.5-coder", "qwen3-coder", "starcoder", "codestral", "coder"],
badge: "Coding",
summary: "Best for writing, explaining, reviewing, and debugging code. Choose a general model for creative conversation or broad non-technical questions."
},
{
matches: ["dolphin"],
badge: "Creative",
summary: "Best for open-ended conversation, brainstorming, creative writing, and roleplay. Its answers can be less restrained and less dependable for factual or safety-critical topics."
},
{
matches: ["mixtral"],
badge: "Versatile",
summary: "Best for detailed writing, summarization, multilingual tasks, and general problem-solving. It is a capable all-rounder but may respond more slowly than smaller models."
},
{
matches: ["mistral"],
badge: "Balanced",
summary: "Best for everyday questions, concise writing, summaries, and light coding. A strong default when you want a balance of speed and answer quality."
},
{
matches: ["gemma"],
badge: "Efficient",
summary: "Best for quick answers, rewriting, summarization, and lightweight general chat. Larger or reasoning-focused models are better for complex multi-step problems."
},
{
matches: ["qwen"],
badge: "Versatile",
summary: "Best for multilingual conversation, structured writing, instruction following, and general technical tasks. Coder variants are the better choice for code-heavy work."
},
{
matches: ["llama"],
badge: "General",
summary: "Best for general conversation, writing, summarization, and broad knowledge tasks. Specialized coding or reasoning models may perform better in those areas."
},
{
matches: ["phi"],
badge: "Compact",
summary: "Best for fast, focused questions, short summaries, and lightweight assistance. Its compact size makes it less suitable for demanding reasoning or long, nuanced responses."
}
];
function describeBuild(model) {
const parameterSize = String(model?.details?.parameter_size || "").trim();
const quantization = String(model?.details?.quantization_level || "").trim();
const details = [parameterSize, quantization].filter(Boolean);
return details.length ? ` Available build: ${details.join(", ")}.` : "";
}
export function getModelProfile(model = {}) {
const modelId = String(model.model || model.name || "");
const modelName = String(model.name || model.model || "");
const family = String(model?.details?.family || "");
const families = Array.isArray(model?.details?.families) ? model.details.families.join(" ") : "";
const haystack = `${modelId} ${modelName} ${family} ${families}`.toLowerCase();
const match = modelProfileRules.find((profile) => profile.matches.some((token) => haystack.includes(token)));
const profile = match || defaultModelProfile;
return {
badge: profile.badge,
summary: `${profile.summary}${describeBuild(model)}`
};
}
+135
View File
@@ -0,0 +1,135 @@
import { get, post } from "/js/api.js?v=20260909-account-controls";
const params = new URLSearchParams(location.search);
function returnPath() {
const candidate = params.get("next");
if (!candidate) return "/app";
try {
const url = new URL(candidate, location.origin);
if (url.origin === location.origin && (url.pathname === "/market" || /^\/app(?:\/|$)/.test(url.pathname))) return url.pathname + url.search;
} catch { /* Use workspace home for invalid return URLs. */ }
return "/app";
}
const session = await get("/session");
if (session.user) {
document.querySelectorAll("[data-start-link]").forEach(link => { link.href = "/app"; link.textContent = "Open workspace"; });
}
const form = document.querySelector("#accountForm");
if (form) {
const register = location.pathname === "/register";
const status = document.querySelector("#accountStatus");
const submit = document.querySelector("#accountSubmit");
function showAccountStatus(message, error = false) {
status.dataset.state = error ? "error" : "pending";
status.setAttribute("role", error ? "alert" : "status");
status.setAttribute("aria-live", error ? "assertive" : "polite");
status.classList.toggle("hidden", !message);
document.querySelector("#accountStatusText").textContent = message;
if (error) status.focus();
}
const label = register ? "Create account" : "Sign in";
document.title = `${label} · Elsewhere`;
document.querySelector("#accountTitle").textContent = label;
submit.textContent = label;
form.elements.password.autocomplete = register ? "new-password" : "current-password";
document.querySelector("#consentField").classList.toggle("hidden", !register);
form.elements.consent.required = register;
const invite = register && session.inviteOnlyRegistration;
document.querySelector("#inviteField").classList.toggle("hidden", !invite);
form.elements.inviteCode.required = Boolean(invite);
if (register) {
document.querySelector("#accountHeadline").textContent = "Your next conversation starts here.";
document.querySelector("#accountDescription").textContent = "Choose a username and a password of at least 6 characters.";
}
if (session.error) showAccountStatus(session.error, true);
const switchText = document.querySelector("#accountSwitch");
switchText.append(register ? "Already have an account? " : "New to Elsewhere? ");
const link = document.createElement("a");
link.href = `${register ? "/login" : "/register"}?next=${encodeURIComponent(returnPath())}`;
link.textContent = register ? "Sign in" : "Create account";
switchText.append(link);
document.querySelector("#showPassword").addEventListener("click", event => {
const show = form.elements.password.type === "password";
form.elements.password.type = show ? "text" : "password";
const label = show ? "Hide password" : "Show password";
event.currentTarget.setAttribute("aria-label", label);
event.currentTarget.title = label;
event.currentTarget.setAttribute("aria-pressed", String(show));
});
form.addEventListener("submit", async event => {
event.preventDefault();
submit.disabled = true;
form.elements.username.removeAttribute("aria-invalid");
form.elements.username.removeAttribute("aria-describedby");
showAccountStatus(register ? "Creating your account..." : "Signing in...");
const result = await post(register ? "/register" : "/login", {
username: form.elements.username.value.trim(), password: form.elements.password.value,
...(register ? { inviteCode: form.elements.inviteCode.value.trim() } : {})
});
if (result.error) {
const duplicate = register && result.error === "User already exists";
if (duplicate) {
form.elements.username.setAttribute("aria-invalid", "true");
form.elements.username.setAttribute("aria-describedby", "accountStatus");
}
showAccountStatus(duplicate ? "That username is already taken. Choose another, or sign in below." : result.error, true);
submit.disabled = false;
return;
}
location.assign(returnPath());
});
}
const recent = document.querySelector("#recentChats");
if (recent) {
if (!session.user) location.replace(`/login?next=${encodeURIComponent(location.pathname)}`);
else {
document.querySelector("#welcomeName").textContent = `Welcome home, ${session.user}`;
const result = await get("/chats");
recent.replaceChildren();
if (result.error) recent.textContent = result.error;
else if (!result.chats?.length) {
const empty = document.createElement("div"); empty.className = "empty-state";
const title = document.createElement("h3"); title.textContent = "Your first conversation is waiting";
const copy = document.createElement("p"); copy.textContent = "Choose a starter, or bring a thought of your own.";
const starters = document.createElement("div"); starters.className = "starter-links";
for (const prompt of ["Explain a complex topic in simple terms.", "Help me brainstorm a story idea."]) {
const link = document.createElement("a"); link.className = "secondary-action"; link.href = `/app/chats/new?prompt=${encodeURIComponent(prompt)}`; link.textContent = prompt; starters.append(link);
}
empty.append(title, copy, starters); recent.append(empty);
} else for (const chat of result.chats.slice(0, 6)) {
const link = document.createElement("a"); link.className = "chat-row"; link.href = `/app/chats/${chat.id}`;
const title = document.createElement("strong"); title.textContent = chat.title || "Untitled conversation";
const action = document.createElement("span"); action.textContent = "Continue \u2192";
link.append(title, action); recent.append(link);
}
}
}
const featured = document.querySelector("#featuredPersonas");
if (featured) {
const result = await get("/api/discover");
const search = document.querySelector("#publicSearch");
function render() {
featured.replaceChildren();
if (result.error) { featured.textContent = result.error; return; }
const query = search?.value.trim().toLowerCase() || "";
let personas = (result.personas || []).filter(persona => `${persona.name} ${persona.description || ""}`.toLowerCase().includes(query));
if (!search) personas = personas.slice(0, 3);
if (!personas.length) { featured.textContent = query ? "No personas match your search." : "The community is just getting started. Create a persona and give the next story its voice."; return; }
for (const persona of personas) {
const card = document.createElement("article"); card.className = "feature-card";
const category = document.createElement("span"); category.className = "feature-number"; category.textContent = persona.persona_type === "assistant" ? "AI CHARACTER" : "USER PERSONA";
const title = document.createElement("h3"); title.textContent = persona.name;
const description = document.createElement("p"); description.textContent = persona.description || "A community persona, ready for a new conversation.";
const creator = document.createElement("p"); creator.textContent = `By ${persona.creator_username}`;
const link = document.createElement("a"); const destination = `/market?persona=${persona.id}`;
link.href = session.user ? destination : `/login?next=${encodeURIComponent(destination)}`;
link.textContent = session.user ? "Explore persona \u2192" : "Sign in to use persona \u2192";
card.append(category, title, description, creator, link); featured.append(card);
}
}
render(); search?.addEventListener("input", render);
}
+19
View File
@@ -0,0 +1,19 @@
const personaFieldNames = ["name", "pronouns", "appearance", "background", "details", "exampleDialogues"];
export function populatePersonaForm(fields, persona = {}) {
personaFieldNames.forEach((name) => {
const field = fields[name];
if (!field) return;
const sourceName = name === "exampleDialogues" ? "example_dialogues" : name;
field.value = persona[sourceName] || "";
});
}
export function readPersonaForm(fields) {
return personaFieldNames.reduce((payload, name) => {
const field = fields[name];
if (!field) return payload;
payload[name] = field.value.trim();
return payload;
}, {});
}
+57
View File
@@ -0,0 +1,57 @@
import {supportsReasoning} from "./modelCapabilities.js?v=20260921-reasoning-capability";
export function createReasoningToggle(button) {
let reasoningEnabled = false;
let supported = false;
let busy = false;
const render = () => {
button.disabled = !supported || busy;
button.setAttribute("aria-pressed", String(reasoningEnabled));
button.title = !supported ? "This model does not support reasoning"
: reasoningEnabled ? "Reasoning enabled" : "Enable reasoning";
button.setAttribute("aria-label", button.title);
};
button.addEventListener("click", () => {
if (!supported || busy) return;
reasoningEnabled = !reasoningEnabled;
render();
});
render();
return {
get enabled() { return reasoningEnabled; },
reset() { reasoningEnabled = false; render(); },
setModel(model) { supported = supportsReasoning(model); if (!supported) reasoningEnabled = false; render(); },
setBusy(value) { busy = value; render(); }
};
}
export function updateReasoning(messageElement, thinking, streaming = false) {
let details = messageElement.querySelector(".msg-reasoning");
if (!thinking) {
details?.remove();
return;
}
if (!details) {
details = document.createElement("details");
details.className = "msg-reasoning";
details.open = streaming;
const summary = document.createElement("summary");
const content = document.createElement("div");
content.className = "msg-reasoning-content";
details.append(summary, content);
messageElement.insertBefore(details, messageElement.querySelector(".msg-content"));
}
details.querySelector("summary").textContent = streaming ? "Reasoning · Thinking…" : "Reasoning";
const content = details.querySelector(".msg-reasoning-content");
const follow = content.scrollHeight - content.scrollTop - content.clientHeight < 40;
content.textContent = thinking;
if (streaming && follow) content.scrollTop = content.scrollHeight;
}
export function finishReasoning(messageElement) {
const details = messageElement.querySelector(".msg-reasoning");
if (!details || details.dataset.complete) return;
details.dataset.complete = "true";
details.open = false;
details.querySelector("summary").textContent = "Reasoning · Completed";
}
+52
View File
@@ -0,0 +1,52 @@
import {stream} from "./api.js";
import {updateReasoning, finishReasoning} from "./reasoning.js";
import {appendSources} from "./webSearch.js";
// Keep the original nodes (including action handlers) until a committed answer arrives.
export async function regenerateInline({card, scroller, endpoint, payload, renderContent, complete}) {
const original = [...card.childNodes];
const status = document.createElement("div");
status.className = "loading-shell";
status.setAttribute("role", "status");
status.textContent = "Generating...";
const body = document.createElement("div");
body.className = "msg-content streaming-content";
let committed = false;
const update = operation => {
const bottom = card.getBoundingClientRect().bottom;
const viewport = scroller.getBoundingClientRect();
const follow = bottom >= viewport.top && Math.abs(bottom - viewport.bottom) < 100;
operation();
if (follow) scroller.scrollTop += card.getBoundingClientRect().bottom - bottom;
};
// Anchor to this response, including when a long old answer collapses.
update(() => card.replaceChildren(original[0].cloneNode(true), status, body));
card.setAttribute("aria-busy", "true");
try {
await stream(endpoint, payload, {
onStatus: data => update(() => {
status.textContent = data.status === "searching" ? "Searching the web..."
: data.status === "analyzing" ? "Analyzing attachments..." : "Generating...";
if ("sources" in data) {
card.querySelector(".msg-sources")?.remove();
appendSources(card, data.sources);
}
}),
onThinking: ({fullThinking}) => update(() => updateReasoning(card, fullThinking, true)),
onChunk: ({fullReply}) => update(() => {
status.hidden = Boolean(fullReply);
if (fullReply) finishReasoning(card);
renderContent(body, fullReply);
}),
onError: ({error}) => { throw new Error(error || "Unable to regenerate message."); },
onDone: ({message}) => {
if (!message) throw new Error("Missing regenerated response.");
update(() => complete(message));
committed = true;
}
});
} finally {
if (!committed) update(() => card.replaceChildren(...original));
card.removeAttribute("aria-busy");
}
}
+2126
View File
File diff suppressed because it is too large Load Diff
+597
View File
@@ -0,0 +1,597 @@
import { del, get, post, put } from "./js/api.js?v=20260909-account-controls";
import {
applyTheme as applySharedTheme,
applyThemeMode as applySharedThemeMode,
initializeAppearance
} from "./js/themeController.js?v=20260909-account-controls";
import { clearStatus, setStatus } from "./js/statusNotice.js?v=20260909-account-controls";
import { populatePersonaForm, readPersonaForm } from "./js/personaForm.js?v=20260909-account-controls";
const $ = (id) => document.getElementById(id);
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");
const currentPasswordInput = $("currentPassword");
const newPasswordInput = $("newPassword");
const repeatPasswordInput = $("repeatPassword");
const exportAccountBtn = $("exportAccount");
const logoutAllSessionsBtn = $("logoutAllSessions");
const deleteAccountBtn = $("deleteAccount");
const usernameForm = $("usernameForm");
const passwordForm = $("passwordForm");
const settingsNavItems = document.querySelectorAll("[data-settings-view]");
const settingsPanels = document.querySelectorAll("[data-settings-panel]");
const createAiCharacterBtn = $("createAiCharacter");
const createUserPersonaBtn = $("createUserPersona");
const settingsAiCharacterList = $("settingsAiCharacterList");
const settingsUserPersonaList = $("settingsUserPersonaList");
const settingsPersonaModal = $("settingsPersonaModal");
const settingsPersonaClose = $("settingsPersonaClose");
const settingsPersonaForm = $("settingsPersonaForm");
const settingsPersonaFormTitle = $("settingsPersonaFormTitle");
const settingsPersonaFormNotice = $("settingsPersonaFormNotice");
const settingsPersonaHelper = $("settingsPersonaHelper");
const settingsPersonaType = $("settingsPersonaType");
const settingsPersonaName = $("settingsPersonaName");
const settingsPersonaPronouns = $("settingsPersonaPronouns");
const settingsPersonaAppearance = $("settingsPersonaAppearance");
const settingsPersonaBackground = $("settingsPersonaBackground");
const settingsPersonaDetails = $("settingsPersonaDetails");
const settingsPersonaExamplesField = $("settingsPersonaExamplesField");
const settingsPersonaExampleDialogues = $("settingsPersonaExampleDialogues");
const settingsPopupModal = $("settingsPopupModal");
const settingsPopupClose = $("settingsPopupClose");
const settingsPopupCancel = $("settingsPopupCancel");
const settingsPopupConfirm = $("settingsPopupConfirm");
const settingsPopupTitle = $("settingsPopupTitle");
const settingsPopupEyebrow = $("settingsPopupEyebrow");
const settingsPopupDescription = $("settingsPopupDescription");
const settingsPopupField = $("settingsPopupField");
const settingsPopupInputLabel = $("settingsPopupInputLabel");
const settingsPopupInput = $("settingsPopupInput");
const settingsPersonaFields = {
name: settingsPersonaName,
pronouns: settingsPersonaPronouns,
appearance: settingsPersonaAppearance,
background: settingsPersonaBackground,
details: settingsPersonaDetails,
exampleDialogues: settingsPersonaExampleDialogues
};
let assistantPersonas = [];
let userPersonas = [];
let publishedPersonaIds = new Set();
let editingPersonaId = null;
let settingsPopupResolver = null;
let isAdminUser = false;
function setNotice(message, state = "") {
setStatus(settingsNotice, message, state);
}
function setPersonaNotice(message = "", state = "") {
if (!message) {
clearStatus(settingsPersonaFormNotice);
return;
}
setStatus(settingsPersonaFormNotice, message, state);
}
function closeSettingsPopup(value = null) {
settingsPopupModal.classList.add("hidden");
if (settingsPopupResolver) {
const resolve = settingsPopupResolver;
settingsPopupResolver = null;
resolve(value);
}
}
function promptSettingsPopup({
eyebrow = "Action",
title = "Enter a value",
description = "",
label = "Value",
value = "",
placeholder = "",
confirmLabel = "Confirm"
} = {}) {
settingsPopupEyebrow.textContent = eyebrow;
settingsPopupTitle.textContent = title;
settingsPopupDescription.textContent = description;
settingsPopupInputLabel.textContent = label;
settingsPopupInput.value = value;
settingsPopupInput.placeholder = placeholder;
settingsPopupConfirm.textContent = confirmLabel;
settingsPopupModal.classList.remove("hidden");
settingsPopupInput.focus();
settingsPopupInput.select();
return new Promise((resolve) => {
settingsPopupResolver = resolve;
});
}
function applyTheme(themeKey, persist = true) {
return applySharedTheme(themeKey, { persist, title: "Elsewhere Settings", themeSelect: settingsThemeSelect });
}
function applyThemeMode(modeKey, persist = true) {
return applySharedThemeMode(modeKey, { persist, modeSelect: themeModeSelect });
}
function setSettingsView(view) {
const normalizedView = ["my-ai-characters", "my-personas"].includes(view) ? "my-roleplay-companions" : view;
if (normalizedView === "my-roleplay-companions" && location.pathname !== "/app/personas") { location.assign("/app/personas" + location.search); return; }
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);
item.setAttribute("aria-selected", String(isActive));
});
settingsPanels.forEach((panel) => {
const isActive = panel.dataset.settingsPanel === normalizedView;
panel.classList.toggle("active", isActive);
panel.setAttribute("aria-hidden", String(!isActive));
});
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) {
return persona.pronouns ? `Pronouns: ${persona.pronouns}` : "Pronouns: n/a";
}
function syncPersonaExamplesField() {
const isAssistant = settingsPersonaType.value === "assistant";
settingsPersonaExamplesField.classList.toggle("hidden", !isAssistant);
settingsPersonaHelper.textContent = isAssistant
? "Create the character you want to roleplay with."
: "Create the persona you want to speak as in roleplay.";
$("settingsPersonaSave").textContent = isAssistant ? "Save AI Character" : "Save persona";
}
function openPersonaModal(persona = null, personaType = "assistant") {
editingPersonaId = persona ? persona.id : null;
settingsPersonaType.disabled = Boolean(persona);
settingsPersonaType.value = persona ? persona.persona_type : personaType;
settingsPersonaFormTitle.textContent = persona
? `Edit ${settingsPersonaType.value === "assistant" ? "AI Character" : "persona"}`
: `Create ${personaType === "assistant" ? "AI Character" : "persona"}`;
populatePersonaForm(settingsPersonaFields, persona || {});
syncPersonaExamplesField();
setPersonaNotice("");
settingsPersonaModal.classList.remove("hidden");
settingsPersonaName.focus();
}
function closePersonaModal() {
settingsPersonaModal.classList.add("hidden");
editingPersonaId = null;
setPersonaNotice("");
}
function renderPersonaList(personas, listElement, type) {
listElement.innerHTML = "";
if (!personas.length) {
const empty = document.createElement("div");
const message = document.createElement("p");
const button = document.createElement("button");
empty.className = "persona-empty-state";
message.className = "status persona-status";
message.textContent = type === "assistant" ? "No AI Characters yet." : "No user personas yet.";
button.type = "button";
button.className = "secondary-action";
button.textContent = type === "assistant" ? "Create AI Character" : "Create user persona";
button.addEventListener("click", () => openPersonaModal(null, type));
empty.append(message, button);
listElement.appendChild(empty);
return;
}
personas.forEach((persona) => {
const item = document.createElement("div");
const titleRow = document.createElement("div");
const title = document.createElement("h4");
const tag = document.createElement("span");
const meta = document.createElement("p");
const actions = document.createElement("div");
item.className = "persona-item";
titleRow.className = "persona-title-row";
actions.className = "persona-item-actions";
tag.className = `persona-role-tag ${type === "assistant" ? "ai" : "user"}`;
tag.textContent = type === "assistant" ? "AI Character" : "User";
title.textContent = persona.name;
meta.textContent = formatMeta(persona);
[
["Edit", () => openPersonaModal(persona)],
["Clone", () => clonePersona(persona.id)],
["History", () => showPersonaHistory(persona.id)],
[publishedPersonaIds.has(persona.id) ? "Update listing" : "Publish", () => publishPersona(persona.id)],
["Delete", () => deletePersona(persona.id)]
]
.filter(([label]) => Boolean(label))
.forEach(([label, handler]) => {
const button = document.createElement("button");
button.type = "button";
button.textContent = label;
button.addEventListener("click", handler);
actions.appendChild(button);
});
if (publishedPersonaIds.has(persona.id)) {
const unpublishButton = document.createElement("button");
unpublishButton.type = "button";
unpublishButton.textContent = "Unpublish";
unpublishButton.addEventListener("click", () => unpublishPersona(persona.id));
actions.appendChild(unpublishButton);
}
titleRow.append(title, tag);
item.append(titleRow, meta, actions);
listElement.appendChild(item);
});
}
async function loadProfile() {
const res = await get("/settings/profile");
if (res.error) {
setNotice(res.error, "error");
return false;
}
const accountName = document.querySelector(".shell-username");
if (accountName) { accountName.textContent = res.username || ""; accountName.title = res.username || ""; }
currentUsernameInput.value = res.username || "";
newUsernameInput.value = res.username || "";
isAdminUser = res.username === "admin";
diagnosticsNavItem.classList.toggle("hidden", !isAdminUser);
return true;
}
async function loadPersonas() {
const res = await get("/personas");
if (res.error) {
setNotice(res.error, "error");
return false;
}
assistantPersonas = res.assistantPersonas || [];
userPersonas = res.userPersonas || [];
publishedPersonaIds = new Set(res.publishedPersonaIds || []);
renderPersonaList(assistantPersonas, settingsAiCharacterList, "assistant");
renderPersonaList(userPersonas, settingsUserPersonaList, "user");
return true;
}
async function savePersona() {
const wasEditing = Boolean(editingPersonaId);
const payload = {
...readPersonaForm(settingsPersonaFields),
personaType: settingsPersonaType.value,
exampleDialogues: settingsPersonaType.value === "assistant" ? settingsPersonaExampleDialogues.value.trim() : ""
};
if (!payload.name) {
setPersonaNotice("Name is required.", "error");
return;
}
const res = editingPersonaId
? await put(`/personas/${editingPersonaId}`, payload)
: await post("/personas", payload);
if (res.error) {
setPersonaNotice(res.error, "error");
return;
}
await loadPersonas();
closePersonaModal();
setSettingsView("my-roleplay-companions");
setNotice(wasEditing ? "Persona updated." : "Persona created.", "success");
}
async function deletePersona(id) {
const res = await del(`/personas/${id}`);
if (res.error) {
setNotice(res.error, "error");
return;
}
await loadPersonas();
setNotice("Persona deleted.", "success");
}
async function publishPersona(id) {
const tagsInput = await promptSettingsPopup({
eyebrow: "Marketplace",
title: "Persona tags",
description: "Add optional comma-separated tags to improve discovery.",
label: "Tags",
placeholder: "mentor, fantasy, villain",
confirmLabel: "Publish"
});
if (tagsInput === null) return;
const tags = String(tagsInput || "").split(",").map((tag) => tag.trim()).filter(Boolean);
const res = await post(`/personas/${id}/publish`, { tags });
if (res.error) {
setNotice(res.error, "error");
return;
}
await loadPersonas();
setNotice("Persona published.", "success");
}
async function clonePersona(id) {
const res = await post(`/personas/${id}/clone`, {});
if (res.error) return setNotice(res.error, "error");
await loadPersonas();
setNotice("Persona cloned.", "success");
}
async function showPersonaHistory(id) {
const res = await get(`/personas/${id}/versions`);
if (res.error) return setNotice(res.error, "error");
const versions = res.versions || [];
const summary = versions.length
? versions.map((version) => `v${version.version_number} - ${new Date(version.created_at).toLocaleString()}`).join("\n")
: "No saved versions yet.";
const choice = await promptSettingsPopup({
eyebrow: "Persona history",
title: "Restore a version",
description: summary,
label: "Version number",
placeholder: "1",
confirmLabel: "Restore"
});
if (choice === null) return;
const selected = Number(choice);
if (!selected) return;
const version = versions.find((item) => item.version_number === selected);
if (!version) return setNotice("Version not found.", "error");
const restore = await post(`/personas/${id}/versions/${version.id}/restore`, {});
if (restore.error) return setNotice(restore.error, "error");
await loadPersonas();
setNotice(`Restored persona version ${selected}.`, "success");
}
async function unpublishPersona(id) {
const res = await post(`/personas/${id}/unpublish`, {});
if (res.error) {
setNotice(res.error, "error");
return;
}
await loadPersonas();
setNotice("Persona unpublished.", "success");
}
function downloadJson(filename, payload) {
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
async function exportAccount() {
const res = await get("/settings/export");
if (res.error) return setNotice(res.error, "error");
const username = currentUsernameInput.value || "account";
downloadJson(`account-export-${username}-${new Date().toISOString().slice(0, 10)}.json`, res.account);
setNotice("Account export downloaded.", "success");
}
async function logoutAllSessions() {
const confirmed = await promptSettingsPopup({
eyebrow: "Security",
title: "Logout all sessions",
description: "Type logout to sign out this browser and every other active session.",
label: "Confirmation",
placeholder: "logout",
confirmLabel: "Logout all"
});
if (String(confirmed || "").trim().toLowerCase() !== "logout") return;
const res = await post("/settings/logout-all-sessions", {});
if (res.error) return setNotice(res.error, "error");
window.location.href = "/";
}
async function deleteAccount() {
const currentPassword = await promptSettingsPopup({
eyebrow: "Danger zone",
title: "Delete account",
description: "Enter your current password to permanently delete your account and all account data.",
label: "Current password",
placeholder: "Password",
confirmLabel: "Delete account"
});
if (currentPassword === null) return;
const res = await request("/settings/account", { currentPassword }, "DELETE");
if (res.error) return setNotice(res.error, "error");
window.location.href = "/";
}
usernameForm.addEventListener("submit", async (event) => {
event.preventDefault();
const username = newUsernameInput.value.trim();
const password = usernamePasswordInput.value;
if (!username || !password) {
setNotice("New username and current password are required.", "error");
return;
}
const res = await request("/settings/username", { username, password }, "PUT");
if (res.error) {
setNotice(res.error, "error");
return;
}
currentUsernameInput.value = res.username;
newUsernameInput.value = res.username;
usernamePasswordInput.value = "";
setNotice("Username updated.", "success");
});
passwordForm.addEventListener("submit", async (event) => {
event.preventDefault();
const currentPassword = currentPasswordInput.value;
const nextPassword = newPasswordInput.value;
const repeatPassword = repeatPasswordInput.value;
if (!currentPassword || !nextPassword || !repeatPassword) {
setNotice("Fill in all password fields.", "error");
return;
}
if (nextPassword !== repeatPassword) {
setNotice("New passwords do not match.", "error");
return;
}
const res = await request("/settings/password", { currentPassword, newPassword: nextPassword }, "PUT");
if (res.error) {
setNotice(res.error, "error");
return;
}
currentPasswordInput.value = "";
newPasswordInput.value = "";
repeatPasswordInput.value = "";
setNotice("Password updated.", "success");
});
settingsThemeSelect.addEventListener("change", (event) => {
applyTheme(event.target.value);
setNotice("Style updated.", "success");
});
themeModeSelect.addEventListener("change", (event) => {
applyThemeMode(event.target.value);
setNotice(`Appearance set to ${document.body.dataset.themeMode}.`, "success");
});
workspaceModeSelect.addEventListener("change", (event) => {
const mode = event.target.value === "advanced" ? "advanced" : "basic";
localStorage.setItem("krishd-workspace-mode", mode);
setNotice(`Workspace mode set to ${mode}.`, "success");
});
exportAccountBtn.addEventListener("click", () => { void exportAccount(); });
logoutAllSessionsBtn.addEventListener("click", () => { void logoutAllSessions(); });
deleteAccountBtn.addEventListener("click", () => { void deleteAccount(); });
refreshDiagnosticsBtn.addEventListener("click", () => { void loadDiagnostics(); });
settingsNavItems.forEach((item) => item.addEventListener("click", () => setSettingsView(item.dataset.settingsView)));
createAiCharacterBtn.addEventListener("click", () => openPersonaModal(null, "assistant"));
createUserPersonaBtn.addEventListener("click", () => openPersonaModal(null, "user"));
settingsPersonaClose.addEventListener("click", closePersonaModal);
settingsPersonaModal.addEventListener("click", (event) => {
if (event.target === settingsPersonaModal) closePersonaModal();
});
settingsPersonaType.addEventListener("change", syncPersonaExamplesField);
settingsPersonaForm.addEventListener("submit", (event) => {
event.preventDefault();
void savePersona();
});
settingsPopupConfirm.addEventListener("click", () => closeSettingsPopup(settingsPopupInput.value));
settingsPopupCancel.addEventListener("click", () => closeSettingsPopup(null));
settingsPopupClose.addEventListener("click", () => closeSettingsPopup(null));
settingsPopupModal.addEventListener("click", (event) => {
if (event.target === settingsPopupModal) closeSettingsPopup(null);
});
settingsPopupInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
closeSettingsPopup(settingsPopupInput.value);
}
if (event.key === "Escape") {
event.preventDefault();
closeSettingsPopup(null);
}
});
window.addEventListener("load", async () => {
initializeAppearance({ title: "Elsewhere Settings", themeSelect: settingsThemeSelect, modeSelect: themeModeSelect });
workspaceModeSelect.value = localStorage.getItem("krishd-workspace-mode") || "basic";
const params = new URLSearchParams(window.location.search);
const personaPage = location.pathname === "/app/personas";
const view = personaPage ? "my-roleplay-companions" : params.get("view") || "personal";
if (personaPage) { document.querySelector(".market-header .eyebrow").textContent = "Your collection"; document.querySelector("h1").textContent = "My personas"; document.title = "My personas - Elsewhere"; document.querySelector(".settings-nav").hidden = true; document.querySelector(".settings-layout").style.gridTemplateColumns = "1fr"; }
const profileOk = await loadProfile();
setSettingsView(view);
const personasOk = await loadPersonas();
if (profileOk && personasOk && view !== "diagnostics") setNotice(personaPage ? "Personas loaded." : "Settings loaded.");
const create = params.get("create");
if (create === "assistant") {
setSettingsView("my-roleplay-companions");
openPersonaModal(null, "assistant");
} else if (create === "user") {
setSettingsView("my-roleplay-companions");
openPersonaModal(null, "user");
}
});
+9
View File
@@ -0,0 +1,9 @@
export function setStatus(element, message = "", state = "", baseClass = "status") {
if (!element) return;
element.textContent = message;
element.className = [baseClass, state].filter(Boolean).join(" ");
}
export function clearStatus(element, baseClass = "status") {
setStatus(element, "", "hidden", baseClass);
}
+14
View File
@@ -0,0 +1,14 @@
(() => {
const themes = new Set(["fakegpt", "fraud", "germini", "slopilot", "beta-ai", "confusity"]);
const storedTheme = localStorage.getItem("krishd-theme");
const storedMode = localStorage.getItem("krishd-theme-mode");
const theme = themes.has(storedTheme) ? storedTheme : "fakegpt";
const mode = storedMode === "dark" ? "dark" : "light";
document.documentElement.dataset.theme = theme;
document.documentElement.dataset.themeMode = mode;
document.addEventListener("readystatechange", () => {
if (!document.body) return;
document.body.dataset.theme = theme;
document.body.dataset.themeMode = mode;
});
})();
+62
View File
@@ -0,0 +1,62 @@
import {themes} from "./constants.js?v=20260909-account-controls";
export const THEME_STORAGE_KEY = "krishd-theme";
export const THEME_MODE_STORAGE_KEY = "krishd-theme-mode";
export const DEFAULT_THEME = "fakegpt";
export const DEFAULT_THEME_MODE = "light";
export function normalizeTheme(themeKey) {
return Object.hasOwn(themes, themeKey) ? themeKey : DEFAULT_THEME;
}
export function normalizeThemeMode(modeKey) {
return modeKey === "dark" ? "dark" : DEFAULT_THEME_MODE;
}
export function readStoredAppearance() {
return {
theme: normalizeTheme(localStorage.getItem(THEME_STORAGE_KEY)),
mode: normalizeThemeMode(localStorage.getItem(THEME_MODE_STORAGE_KEY))
};
}
export function applyTheme(themeKey, {
persist = true,
title = null,
themeSelect = null,
nameTargets = document.querySelectorAll("[data-brand-name]"),
logoTargets = document.querySelectorAll("[data-brand-logo]")
} = {}) {
const nextTheme = normalizeTheme(themeKey);
const theme = themes[nextTheme];
document.documentElement.dataset.theme = nextTheme;
document.body.dataset.theme = nextTheme;
if (typeof title === "function") document.title = title(theme);
if (typeof title === "string") document.title = title;
nameTargets.forEach((target) => { target.textContent = "elsewhere"; });
logoTargets.forEach((target) => { target.textContent = ""; target.classList.add("doorway-mark"); });
if (themeSelect) themeSelect.value = nextTheme;
if (persist) localStorage.setItem(THEME_STORAGE_KEY, nextTheme);
document.dispatchEvent(new CustomEvent("appearancechange", {detail: {theme: nextTheme}}));
return nextTheme;
}
export function applyThemeMode(modeKey, {persist = true, modeSelect = null} = {}) {
const nextMode = normalizeThemeMode(modeKey);
document.documentElement.dataset.themeMode = nextMode;
document.body.dataset.themeMode = nextMode;
if (modeSelect) modeSelect.value = nextMode;
if (persist) localStorage.setItem(THEME_MODE_STORAGE_KEY, nextMode);
document.dispatchEvent(new CustomEvent("appearancechange", {detail: {mode: nextMode}}));
return nextMode;
}
export function initializeAppearance(options = {}) {
const appearance = readStoredAppearance();
applyTheme(appearance.theme, {...options, persist: false});
applyThemeMode(appearance.mode, {
persist: false,
modeSelect: options.modeSelect || null
});
return appearance;
}
+52
View File
@@ -0,0 +1,52 @@
/** @typedef {{title: string, url: string, snippet: string}} WebSource */
/** @typedef {{message: string, model: string, chatId: number, webSearch?: boolean, reasoning?: boolean}} ChatRequest */
/** @typedef {{role: string, content: string, thinking?: string, sources?: WebSource[]}} ChatMessage */
export function createWebSearchToggle(button) {
let enabled = false;
const reset = () => {
enabled = false;
render();
};
const render = () => {
button.setAttribute("aria-pressed", String(enabled));
button.title = enabled ? "Web search enabled" : "Search the web";
button.setAttribute("aria-label", button.title);
};
button.addEventListener("click", () => {
enabled = !enabled;
render();
});
render();
return {get enabled() { return enabled; }, reset};
}
/** @param {HTMLElement} container @param {WebSource[] | undefined} sources */
export function appendSources(container, sources) {
if (!Array.isArray(sources) || !sources.length) return;
const section = document.createElement("section");
section.className = "msg-sources";
section.setAttribute("aria-label", "Sources");
const heading = document.createElement("div");
heading.className = "msg-sources-heading";
heading.textContent = "Sources";
section.appendChild(heading);
sources.slice(0, 5).forEach((source, index) => {
let url;
try { url = new URL(source.url); } catch { return; }
if (!["https:", "http:"].includes(url.protocol) || url.username || url.password) return;
const link = document.createElement("a");
link.href = url.href;
link.target = "_blank";
link.rel = "noopener noreferrer";
link.className = "msg-source";
const title = document.createElement("span");
title.textContent = `[${index + 1}] ${source.title}`;
title.title = source.title;
const domain = document.createElement("small");
domain.textContent = url.hostname;
link.append(title, domain);
section.appendChild(link);
});
if (section.childElementCount > 1) container.appendChild(section);
}
+17
View File
@@ -0,0 +1,17 @@
import {post} from './api.js?v=20260909-account-controls';
// Every page gets this control from the same server-rendered header.
for (const button of document.querySelectorAll('[data-sign-out]')) {
button.addEventListener('click', async () => {
button.disabled = true;
const result = await post('/logout', {});
if (result.error) {
button.disabled = false;
const notice = document.querySelector('#shellNotice');
notice.textContent = result.error;
notice.classList.remove('hidden');
return;
}
location.assign('/');
});
}