moved js files in public into js and css into css folder
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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(); }
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
@@ -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"}
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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)}`
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}, {});
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
})();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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('/');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user