implement an entire base refacotr and alot of styling overhauls and remakes.

This commit is contained in:
2026-04-14 23:21:01 +02:00
parent 554ad494b0
commit feaa2e42c4
32 changed files with 6031 additions and 2638 deletions
+52
View File
@@ -0,0 +1,52 @@
export async function request(url, data, method = "POST") {
try {
const res = await fetch(url, {
method,
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");
export async function stream(url, data, handlers = {}) {
const response = await fetch(url, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(data || {})
});
if (!response.ok || !response.body) {
throw new Error("Streaming request failed");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const {value, done} = await reader.read();
if (done) break;
buffer += decoder.decode(value, {stream: true});
const parts = buffer.split("\n\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 (event === "chunk" && handlers.onChunk) handlers.onChunk(payload);
if (event === "done" && handlers.onDone) handlers.onDone(payload);
if (event === "error" && handlers.onError) handlers.onError(payload);
}
}
}
+21
View File
@@ -0,0 +1,21 @@
export const requestedChatId = Number(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."]
};
export const themes = {
"fakegpt": {name: "FakeGPT", short: "FG"},
"fraud": {name: "Fraud", short: "FR"},
"germini": {name: "Germini", short: "GE"},
"slopilot": {name: "Slopilot", short: "SP"},
"beta-ai": {name: "Beta AI", short: "BA"},
"confusity": {name: "Confusity", short: "CF"}
};
export const defaultModelProfile = {
badge: "General",
summary: "General-purpose chat model. Start here if you are unsure which model to use."
};
+226
View File
@@ -0,0 +1,226 @@
const $ = (id) => document.getElementById(id);
const authDiv = $("auth");
const chatDiv = $("chat");
const authMsg = $("authMsg");
const logoutButton = $("logout");
const messagesDiv = $("messages");
const modelSelect = $("model");
const modelMenuButton = $("modelMenuButton");
const modelPopover = $("modelPopover");
const modelBadgeName = $("modelBadgeName");
const modelCount = $("modelCount");
const modelHelpTitle = $("modelHelpTitle");
const modelHelpBadge = $("modelHelpBadge");
const modelHelpSummary = $("modelHelpSummary");
const loginForm = $("loginForm");
const registerForm = $("registerForm");
const msgInput = $("msgInput");
const sendBtn = $("send");
const loginUsernameInput = $("loginUsername");
const loginPasswordInput = $("loginPassword");
const registerUsernameInput = $("registerUsername");
const registerPasswordInput = $("registerPassword");
const loginSubmit = $("loginSubmit");
const registerSubmit = $("registerSubmit");
const chatList = $("chatList");
const chatSearchInput = $("chatSearch");
const chatSidebar = $("chatSidebar");
const chatDrawerCloseBtn = $("chatDrawerClose");
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 archiveChatBtn = $("archiveChat");
const backupWorkspaceBtn = $("backupWorkspace");
const activeChatTitle = $("activeChatTitle");
const sessionUser = $("sessionUser");
const chatActivityOverlay = $("chatActivityOverlay");
const chatActivityEyebrow = $("chatActivityEyebrow");
const chatActivityTitle = $("chatActivityTitle");
const chatActivityDetail = $("chatActivityDetail");
const contextChipButton = $("contextChipButton");
const contextPopover = $("contextPopover");
const contextSummaryLabel = $("contextSummaryLabel");
const chatActionsMenuButton = $("chatActionsMenuButton");
const chatActionsPopover = $("chatActionsPopover");
const chatModePill = $("chatModePill");
const chatCharacterPill = $("chatCharacterPill");
const chatUserPersonaPill = $("chatUserPersonaPill");
const personaList = $("personaList");
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 activePersonaStatus = $("activePersonaStatus");
const activeUserPersonaStatus = $("activeUserPersonaStatus");
const personaModal = $("personaModal");
const personaCloseBtn = $("personaClose");
const personaMenuButton = $("personaMenuButton");
const personaPopover = $("personaPopover");
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 appNotice = $("appNotice");
const onboardingModal = $("onboardingModal");
const onboardingTitle = $("onboardingTitle");
const onboardingSubtitle = $("onboardingSubtitle");
const onboardingChoices = $("onboardingChoices");
const onboardingBack = $("onboardingBack");
const onboardingContinue = $("onboardingContinue");
const onboardingSkip = $("onboardingSkip");
const authScreens = document.querySelectorAll(".auth-screen");
const toggleButtons = document.querySelectorAll(".auth-toggle .toggle");
const themeNameTargets = document.querySelectorAll("[data-theme-name]");
const themeLogoTargets = document.querySelectorAll("[data-theme-logo]");
const modelMenu = document.querySelector(".model-menu");
const personaMenu = document.querySelector(".persona-menu");
export {
$,
activeChatTitle,
activePersonaStatus,
activeUserPersonaStatus,
appNotice,
authDiv,
authMsg,
authScreens,
chatActionsMenuButton,
chatActionsPopover,
chatActivityDetail,
chatActivityEyebrow,
chatActivityOverlay,
chatActivityTitle,
chatCharacterPill,
chatDiv,
chatList,
chatListLoading,
chatListLoadingText,
chatSidebar,
chatDrawerCloseBtn,
chatModePill,
chatSearchInput,
chatUserPersonaPill,
clearChatBtn,
clearUserPersonaBtn,
contextChipButton,
contextPopover,
contextSummaryLabel,
exportChatBtn,
pinChatBtn,
moveChatFolderBtn,
archiveChatBtn,
backupWorkspaceBtn,
loginForm,
loginPasswordInput,
loginSubmit,
loginUsernameInput,
logoutButton,
messagesDiv,
modelBadgeName,
modelCount,
modelHelpBadge,
modelHelpSummary,
modelHelpTitle,
modelMenu,
modelMenuButton,
modelPopover,
modelSelect,
msgInput,
newChatBtn,
onboardingBack,
onboardingChoices,
onboardingContinue,
onboardingModal,
onboardingSkip,
onboardingSubtitle,
onboardingTitle,
personaCloseBtn,
personaDetailsInput,
personaExampleDialoguesInput,
personaExamplesField,
personaForm,
personaFormNotice,
personaFormTitle,
personaList,
personaMenu,
personaMenuButton,
personaModal,
personaNameInput,
personaPopover,
personaPronounsInput,
personaTypeSelect,
personaAppearanceInput,
personaBackgroundInput,
popupCancelBtn,
popupCloseBtn,
popupConfirmBtn,
popupDescription,
popupEyebrow,
popupField,
popupInput,
popupInputLabel,
popupModal,
popupTitle,
registerForm,
registerPasswordInput,
registerSubmit,
registerUsernameInput,
renameChatBtn,
roleplayCharacterSelect,
roleplayNewPersonaBtn,
roleplayScenarioInput,
roleplayStarterBack,
roleplayStarterCancel,
roleplayStarterClose,
roleplayStarterConfirm,
roleplayStarterIntroCopy,
roleplayStarterModal,
roleplayStarterNotice,
roleplayStarterStepOne,
roleplayStarterStepTwo,
roleplayStarterSuggestions,
roleplayStarterTitle,
roleplayUserPersonaSelect,
sendBtn,
sessionUser,
themeLogoTargets,
themeNameTargets,
toggleButtons,
userPersonaList
};
+115 -109
View File
@@ -16,13 +16,13 @@
<div class="brand">
<div class="logo" data-theme-logo>FG</div>
<div>
<p class="eyebrow">Private chat</p>
<p class="eyebrow">Private workspace</p>
<h1 data-theme-name>FakeGPT</h1>
</div>
</div>
<div class="hero-grid">
<article class="hero-panel">
<p>Sign in to start a chat immediately, then add personas when you want richer roleplay.</p>
<p>Private chat and roleplay.</p>
</article>
</div>
</div>
@@ -75,44 +75,33 @@
<section id="chat" class="card chat-card chat-focus-card" style="display:none;">
<div class="chat-layout chat-layout-focus">
<aside class="chat-sidebar">
<aside id="chatSidebar" class="chat-sidebar">
<div class="sidebar-top">
<div class="sidebar-header">
<div class="sidebar-workspace-card">
<div>
<p class="eyebrow">Chats</p>
<h2>Conversations</h2>
<p class="eyebrow">Workspace</p>
<h2 data-theme-name>FakeGPT</h2>
<p id="sessionUser" class="sidebar-account-name">Signed out</p>
</div>
<div class="sidebar-header-actions action-pair">
<button id="newChat" class="secondary-action" type="button">New chat</button>
<button id="startRoleplay" class="secondary-action" type="button">Start roleplay</button>
<div class="sidebar-header-actions sidebar-workspace-actions">
<button id="newChat" class="primary-action" type="button">New chat</button>
</div>
</div>
<label class="field compact-field">
<span>Search</span>
<input id="chatSearch" type="search" placeholder="Filter chat titles"/>
<input id="chatSearch" type="search" placeholder="Search chats"/>
</label>
</div>
<div id="chatList" class="chat-list"></div>
<div id="chatListLoading" class="chat-loading hidden" role="status" aria-live="polite">
<span class="chat-loading-dot" aria-hidden="true"></span>
<span id="chatListLoadingText">Loading chats...</span>
<div class="sidebar-middle">
<div id="chatList" class="chat-list chat-list-grouped"></div>
<div id="chatListLoading" class="chat-loading hidden" role="status" aria-live="polite">
<span class="chat-loading-dot" aria-hidden="true"></span>
<span id="chatListLoadingText">Loading chats...</span>
</div>
</div>
<div class="sidebar-footer sidebar-footer-stack">
<details id="toolsDetails" class="tools-details">
<summary>Tools</summary>
<div class="tools-links">
<a href="/market" class="secondary-action">Browse personas</a>
<a href="/settings" class="secondary-action">Settings</a>
</div>
</details>
<details id="accountDetails" class="tools-details">
<summary>Account</summary>
<div class="session-chip">
<span class="session-chip-label">Signed in as</span>
<strong id="sessionUser">-</strong>
</div>
<button id="logout" type="button">Logout</button>
</details>
<div class="sidebar-footer">
<a href="/settings" class="secondary-action">Settings</a>
<a href="/market" class="secondary-action">Market</a>
</div>
</aside>
@@ -120,90 +109,106 @@
<header class="chat-header chat-header-focus">
<div class="chat-title-block">
<div class="chat-title">
<p class="eyebrow">Conversation</p>
<h2 id="activeChatTitle">New chat</h2>
<div class="chat-context">
<button id="contextChipButton" class="context-chip-button" type="button" aria-expanded="false" aria-controls="contextPopover">
<span>Context</span>
<strong id="contextSummaryLabel">Assistant</strong>
</button>
<div id="contextPopover" class="persona-popover hidden" role="dialog">
<div id="chatParticipants" class="chat-participants">
<span id="chatModePill" class="context-pill chat-mode-pill">Assistant mode</span>
<span id="chatCharacterPill" class="context-pill hidden">Character: none</span>
<span id="chatUserPersonaPill" class="context-pill hidden">User persona: none</span>
<span id="chatScenePill" class="context-pill hidden">Scene: none</span>
</div>
</div>
<div id="chatParticipants" class="chat-participants chat-participants-inline">
<span id="chatModePill" class="context-pill chat-mode-pill">Assistant mode</span>
</div>
</div>
</div>
<div class="chat-header-actions">
<div class="model-menu">
<button id="modelMenuButton" class="model-badge-button" type="button" aria-expanded="false" aria-controls="modelPopover">
<span class="model-badge-label"></span>
<strong id="modelBadgeName">Loading...</strong>
</button>
<div id="modelPopover" class="model-popover hidden" role="dialog">
<div class="model-select">
<div class="model-select-header">
<label for="model">Model</label>
<span id="modelCount" class="model-count">Loading</span>
</div>
<div class="model-select-control">
<select id="model">
<option disabled selected>Loading models...</option>
</select>
<span class="model-select-chevron" aria-hidden="true"></span>
</div>
</div>
<div id="modelHelp" class="model-help" aria-live="polite">
<div class="model-help-header">
<strong id="modelHelpTitle">Choose a model</strong>
<span id="modelHelpBadge" class="model-help-badge">Waiting</span>
</div>
<p id="modelHelpSummary" class="model-help-summary">Pick a model to see what it is best suited for.</p>
</div>
</div>
</div>
<div class="persona-menu">
<button id="personaMenuButton" class="secondary-action" type="button" aria-expanded="false" aria-controls="personaPopover">
You
</button>
<div id="personaPopover" class="persona-popover hidden" role="dialog">
<div class="persona-section">
<div class="persona-section-header">
<div>
<p class="eyebrow">User</p>
<h2>Your persona</h2>
<p class="persona-guidance">Adds context for how the assistant should read you.</p>
</div>
<div class="persona-section-actions">
<button id="clearUserPersona" class="secondary-action" type="button">Clear</button>
<a href="/settings" class="secondary-action">Manage</a>
</div>
</div>
<p id="activeUserPersonaStatus" class="status persona-status">No default user persona set.</p>
<div id="userPersonaList" class="persona-list"></div>
</div>
</div>
</div>
<div class="chat-actions-menu">
<button id="chatActionsMenuButton" class="secondary-action" type="button" aria-expanded="false" aria-controls="chatActionsPopover">
More
<button id="chatActionsMenuButton" class="secondary-action overflow-trigger" type="button" aria-expanded="false" aria-controls="chatActionsPopover">
Menu
</button>
<div id="chatActionsPopover" class="persona-popover hidden" role="dialog">
<div class="persona-section">
<div class="persona-section-header">
<div id="chatActionsPopover" class="chat-drawer hidden" role="dialog" aria-modal="false" aria-labelledby="chatDrawerTitle">
<div class="chat-drawer-panel">
<div class="chat-drawer-header">
<div>
<p class="eyebrow">Conversation</p>
<h2>Actions</h2>
<h2 id="chatDrawerTitle">Controls</h2>
</div>
<button id="chatDrawerClose" class="icon-button" type="button" aria-label="Close panel">Close</button>
</div>
<div class="persona-item-actions">
<button id="renameChat" class="icon-button" type="button">Rename</button>
<button id="clearChat" class="icon-button" type="button">Clear</button>
<button id="exportChat" class="icon-button" type="button">Export</button>
<div class="chat-drawer-body">
<div class="chat-drawer-section chat-drawer-summary">
<p class="dropdown-section-label">Current mode</p>
<div class="chat-drawer-pills">
<span id="chatCharacterPill" class="context-pill hidden">Character: none</span>
<span id="chatUserPersonaPill" class="context-pill hidden">User persona: none</span>
</div>
</div>
<div class="chat-drawer-section model-menu">
<div class="dropdown-header">
<p class="eyebrow">Model</p>
<p class="dropdown-subtitle">Choose the engine for this chat.</p>
</div>
<div id="modelPopover" class="model-popover" data-static-panel="true">
<div class="model-select">
<div class="model-select-header">
<label for="model">Model</label>
<span id="modelCount" class="model-count">Loading</span>
</div>
<div class="model-select-control">
<select id="model">
<option disabled selected>Loading models...</option>
</select>
<span class="model-select-chevron" aria-hidden="true"></span>
</div>
</div>
<div id="modelHelp" class="model-help" aria-live="polite">
<div class="model-help-header">
<strong id="modelHelpTitle">Choose a model</strong>
<span id="modelHelpBadge" class="model-help-badge">Waiting</span>
</div>
<p id="modelHelpSummary" class="model-help-summary">Pick a model to see what it is best suited for.</p>
</div>
<div class="model-inline-badge">
<span class="model-badge-label">Current</span>
<strong id="modelBadgeName">Loading...</strong>
</div>
</div>
</div>
<div class="chat-drawer-section persona-menu">
<div class="dropdown-header">
<p class="eyebrow">Persona</p>
<p class="dropdown-subtitle">Set who you are in roleplay chats.</p>
</div>
<div id="personaPopover" class="persona-popover" data-static-panel="true">
<div class="persona-section">
<div class="persona-section-header">
<div>
<h2>Your persona</h2>
</div>
<div class="persona-section-actions">
<button id="clearUserPersona" class="secondary-action" type="button">Clear</button>
<a href="/settings" class="secondary-action">Manage</a>
</div>
</div>
<p id="activeUserPersonaStatus" class="status persona-status">No default user persona set.</p>
<div id="userPersonaList" class="persona-list"></div>
</div>
</div>
</div>
<div class="chat-drawer-section">
<p class="dropdown-section-label">Workspace</p>
<div class="dropdown-action-grid">
<a href="/market" class="secondary-action">Market</a>
<a href="/settings" class="secondary-action">Settings</a>
<button id="logout" type="button" class="secondary-action">Logout</button>
<button id="backupWorkspace" class="secondary-action" type="button">Backup</button>
</div>
</div>
<div class="chat-drawer-section">
<p class="dropdown-section-label">Current chat</p>
<div class="dropdown-action-grid">
<button id="renameChat" class="secondary-action" type="button">Rename</button>
<button id="pinChat" class="secondary-action" type="button">Pin</button>
<button id="moveChatFolder" class="secondary-action" type="button">Folder</button>
<button id="archiveChat" class="secondary-action" type="button">Archive</button>
<button id="clearChat" class="secondary-action" type="button">Clear</button>
<button id="exportChat" class="secondary-action" type="button">Export</button>
</div>
</div>
</div>
</div>
</div>
@@ -211,6 +216,8 @@
</div>
</header>
<p id="appNotice" class="status inline-status hidden"></p>
<div id="chatActivityOverlay" class="chat-activity-overlay hidden" role="status" aria-live="polite" aria-atomic="true">
<div class="chat-activity-card">
<div class="chat-activity-mark" aria-hidden="true">
@@ -230,7 +237,6 @@
<div class="input-meta">
<p class="composer-hint">Enter to send · Shift+Enter for new line</p>
</div>
<div id="quickPrompts" class="quick-prompts" aria-label="Suggested starters"></div>
<div class="input-container">
<textarea id="msgInput" placeholder="Type your message..."></textarea>
<button id="send" type="button" aria-label="Send message"></button>
@@ -241,8 +247,8 @@
</section>
</div>
<div id="personaModal" class="modal-overlay hidden">
<div class="modal-card persona-modal-card">
<div id="personaModal" class="modal-overlay side-panel-overlay hidden">
<div class="modal-card persona-modal-card side-panel-card">
<div class="modal-header">
<div class="persona-modal-heading">
<p class="eyebrow">Persona editor</p>
@@ -301,8 +307,8 @@
</div>
</div>
<div id="roleplayStarterModal" class="modal-overlay hidden">
<div class="modal-card roleplay-starter-card">
<div id="roleplayStarterModal" class="modal-overlay side-panel-overlay hidden">
<div class="modal-card roleplay-starter-card side-panel-card">
<div class="modal-header">
<div class="persona-modal-heading">
<p class="eyebrow">Start roleplay</p>
@@ -386,6 +392,6 @@
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.6/dist/purify.min.js" defer></script>
<script src="script.js" defer></script>
<script type="module" src="script.js"></script>
</body>
</html>
+54 -8
View File
@@ -15,7 +15,6 @@
<div>
<p class="eyebrow">Shared personas</p>
<h1><span data-theme-name>FakeGPT</span> Market</h1>
<p class="subtitle">Find characters and personas for roleplay, then start with an explicit persona choice.</p>
</div>
<div class="market-header-actions">
<button id="refreshMarket" class="secondary-action" type="button">Refresh</button>
@@ -25,7 +24,7 @@
</header>
<div class="market-search">
<label for="marketSearch">Search personas</label>
<label for="marketSearch">Search</label>
<input id="marketSearch" type="search" placeholder="Search by name, creator, or description"/>
</div>
@@ -33,8 +32,8 @@
<section class="settings-layout market-settings-layout">
<aside class="settings-nav" aria-label="Market sections">
<button class="settings-nav-item active" type="button" data-market-view="ai-characters">AI Characters</button>
<button class="settings-nav-item" type="button" data-market-view="user-personas">User Personas</button>
<button class="settings-nav-item active" type="button" data-market-view="ai-characters">Characters</button>
<button class="settings-nav-item" type="button" data-market-view="user-personas">User personas</button>
</aside>
<div class="settings-content">
@@ -44,9 +43,21 @@
<div>
<p class="eyebrow">Discover</p>
<h2>AI Characters</h2>
<p class="subtitle">Pick a character, choose your persona, and start roleplay.</p>
</div>
<button id="marketCreateAiCharacter" type="button" class="secondary-action">Create AI Character</button>
<div class="market-section-actions">
<label class="market-sort-control" for="marketAssistantSort">
<span>Sort</span>
<select id="marketAssistantSort">
<option value="best">Best</option>
<option value="newest">Newest</option>
<option value="most_favorited">Most Favorited</option>
<option value="most_popular">Most Popular</option>
<option value="top_rated">Top Rated</option>
<option value="alphabetical">A-Z</option>
</select>
</label>
<button id="marketCreateAiCharacter" type="button" class="secondary-action">Create AI Character</button>
</div>
</div>
<div id="marketPersonaList" class="persona-list"></div>
</div>
@@ -58,9 +69,21 @@
<div>
<p class="eyebrow">Discover</p>
<h2>User Personas</h2>
<p class="subtitle">Collect personas you can use as your identity in roleplay scenes.</p>
</div>
<button id="marketCreateUserPersona" type="button" class="secondary-action">Create User Persona</button>
<div class="market-section-actions">
<label class="market-sort-control" for="marketUserSort">
<span>Sort</span>
<select id="marketUserSort">
<option value="best">Best</option>
<option value="newest">Newest</option>
<option value="most_favorited">Most Favorited</option>
<option value="most_popular">Most Popular</option>
<option value="top_rated">Top Rated</option>
<option value="alphabetical">A-Z</option>
</select>
</label>
<button id="marketCreateUserPersona" type="button" class="secondary-action">Create User Persona</button>
</div>
</div>
<div id="marketUserPersonaList" class="persona-list"></div>
</div>
@@ -191,6 +214,29 @@
<p id="marketActivityDetail" class="subtitle">The AI character is preparing the first message.</p>
</div>
</div>
<div id="marketPopupModal" class="modal-overlay hidden">
<div class="modal-card popup-card" role="dialog" aria-modal="true" aria-labelledby="marketPopupTitle" aria-describedby="marketPopupDescription">
<div class="modal-header">
<div class="persona-modal-heading">
<p id="marketPopupEyebrow" class="eyebrow">Action</p>
<h3 id="marketPopupTitle">Confirm action</h3>
</div>
<button id="marketPopupClose" type="button" class="icon-button">Close</button>
</div>
<div class="popup-content">
<p id="marketPopupDescription" class="subtitle">Please confirm this action.</p>
<label id="marketPopupField" class="field">
<span id="marketPopupInputLabel">Value</span>
<input id="marketPopupInput" type="text" autocomplete="off"/>
</label>
</div>
<div class="popup-actions">
<button id="marketPopupCancel" type="button" class="secondary-action">Cancel</button>
<button id="marketPopupConfirm" type="button">Confirm</button>
</div>
</div>
</div>
<script src="market.js" defer></script>
</body>
</html>
+203 -20
View File
@@ -4,6 +4,8 @@ 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");
@@ -42,6 +44,16 @@ 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 themeNameTargets = document.querySelectorAll("[data-theme-name]");
const themes = {
@@ -53,15 +65,18 @@ const themes = {
"confusity": {name: "Confusity"}
};
let allMarketPersonas = [];
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;
async function request(url, data, method = "POST") {
try {
@@ -94,6 +109,40 @@ function setMarketStatus(message, state = "") {
marketStatus.className = state ? `status persona-status ${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) {
marketPersonaFormNotice.textContent = "";
@@ -132,12 +181,35 @@ function setMarketView(view) {
activeMarketView = normalizedView;
marketViewButtons.forEach((item) => item.classList.toggle("active", item.dataset.marketView === normalizedView));
marketPanels.forEach((panel) => panel.classList.toggle("active", panel.dataset.marketPanel === normalizedView));
const params = new URLSearchParams(window.location.search);
params.set("view", normalizedView);
history.replaceState(null, "", `${window.location.pathname}?${params.toString()}`);
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";
@@ -299,8 +371,10 @@ function renderMarketList(items, listElement, type) {
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";
@@ -309,10 +383,36 @@ function renderMarketList(items, listElement, type) {
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");
@@ -325,29 +425,27 @@ function renderMarketList(items, listElement, type) {
}
titleRow.append(title, tag);
actions.appendChild(detailsBtn);
item.append(titleRow, meta, actions);
item.append(titleRow, meta, stats, actions);
listElement.appendChild(item);
});
}
function filterMarketPersonas() {
function filterMarketPersonas(items) {
const query = marketSearchInput.value.trim().toLowerCase();
if (!query) return allMarketPersonas;
return allMarketPersonas.filter((persona) =>
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 filtered = filterMarketPersonas();
const assistantItems = filtered.filter((persona) => persona.persona_type === "assistant");
const userItems = filtered.filter((persona) => persona.persona_type === "user");
const assistantItems = filterMarketPersonas(marketPersonasByType.assistant);
const userItems = filterMarketPersonas(marketPersonasByType.user);
renderMarketList(assistantItems, marketPersonaList, "assistant");
renderMarketList(userItems, marketUserPersonaList, "user");
if (marketSearchInput.value.trim() && !filtered.length) {
if (marketSearchInput.value.trim() && !assistantItems.length && !userItems.length) {
setMarketStatus("No personas match your search.", "error");
return;
}
@@ -362,6 +460,72 @@ function applyMarketFilter() {
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;
@@ -373,17 +537,12 @@ async function loadOwnedPersonas() {
async function loadMarketPersonas() {
setMarketStatus("Loading personas...");
const [marketRes, ownedOk] = await Promise.all([get("/personas/market"), loadOwnedPersonas()]);
if (marketRes.error) {
setMarketStatus(marketRes.error, "error");
return;
}
const ownedOk = await loadOwnedPersonas();
if (!ownedOk) {
setMarketStatus("Unable to load your personas.", "error");
return;
}
allMarketPersonas = marketRes.personas || [];
applyMarketFilter();
await refreshMarket();
}
async function collectMarketPersona(marketId) {
@@ -460,7 +619,29 @@ async function savePersona() {
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));
});
@@ -521,6 +702,8 @@ marketPersonaForm.addEventListener("submit", (event) => {
window.addEventListener("load", async () => {
applyTheme(localStorage.getItem("krishd-theme") || "fakegpt");
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();
if (params.get("create") === "assistant") {
+359 -164
View File
@@ -1,46 +1,117 @@
const $ = (id) => document.getElementById(id);
const authDiv = $("auth"), chatDiv = $("chat"), authMsg = $("authMsg");
const messagesDiv = $("messages"), modelSelect = $("model");
const modelMenuButton = $("modelMenuButton"), modelPopover = $("modelPopover"), modelBadgeName = $("modelBadgeName");
const modelCount = $("modelCount");
const modelHelpTitle = $("modelHelpTitle"), modelHelpBadge = $("modelHelpBadge"), modelHelpSummary = $("modelHelpSummary");
const loginForm = $("loginForm"), registerForm = $("registerForm"), msgInput = $("msgInput"), sendBtn = $("send");
const loginUsernameInput = $("loginUsername"), loginPasswordInput = $("loginPassword");
const registerUsernameInput = $("registerUsername"), registerPasswordInput = $("registerPassword");
const loginSubmit = $("loginSubmit"), registerSubmit = $("registerSubmit");
const chatList = $("chatList"), chatSearchInput = $("chatSearch"), newChatBtn = $("newChat");
const startRoleplayBtn = $("startRoleplay");
const chatListLoading = $("chatListLoading"), chatListLoadingText = $("chatListLoadingText");
const renameChatBtn = $("renameChat"), clearChatBtn = $("clearChat"), exportChatBtn = $("exportChat");
const activeChatTitle = $("activeChatTitle"), sessionUser = $("sessionUser");
const chatActivityOverlay = $("chatActivityOverlay"), chatActivityEyebrow = $("chatActivityEyebrow"), chatActivityTitle = $("chatActivityTitle"), chatActivityDetail = $("chatActivityDetail");
const contextChipButton = $("contextChipButton"), contextPopover = $("contextPopover"), contextSummaryLabel = $("contextSummaryLabel");
const chatActionsMenuButton = $("chatActionsMenuButton"), chatActionsPopover = $("chatActionsPopover");
const chatModePill = $("chatModePill"), chatCharacterPill = $("chatCharacterPill"), chatUserPersonaPill = $("chatUserPersonaPill"), chatScenePill = $("chatScenePill");
const personaList = $("personaList"), userPersonaList = $("userPersonaList");
const personaForm = $("personaForm"), personaFormTitle = $("personaFormTitle"), personaTypeSelect = $("personaType");
const personaNameInput = $("personaName"), personaPronounsInput = $("personaPronouns"), personaAppearanceInput = $("personaAppearance");
const personaBackgroundInput = $("personaBackground"), personaDetailsInput = $("personaDetails");
const personaExamplesField = $("personaExamplesField"), personaExampleDialoguesInput = $("personaExampleDialogues");
const personaFormNotice = $("personaFormNotice");
const roleplayNewPersonaBtn = $("roleplayNewPersona"), clearUserPersonaBtn = $("clearUserPersona");
const activePersonaStatus = $("activePersonaStatus"), activeUserPersonaStatus = $("activeUserPersonaStatus");
const personaModal = $("personaModal"), personaCloseBtn = $("personaClose"), personaMenuButton = $("personaMenuButton"), personaPopover = $("personaPopover");
const roleplayStarterModal = $("roleplayStarterModal"), roleplayStarterClose = $("roleplayStarterClose"), roleplayStarterCancel = $("roleplayStarterCancel"), roleplayStarterConfirm = $("roleplayStarterConfirm");
const roleplayStarterNotice = $("roleplayStarterNotice"), roleplayCharacterSelect = $("roleplayCharacterSelect"), roleplayUserPersonaSelect = $("roleplayUserPersonaSelect");
const roleplayStarterTitle = $("roleplayStarterTitle"), roleplayStarterIntroCopy = $("roleplayStarterIntroCopy"), roleplayStarterBack = $("roleplayStarterBack");
const roleplayStarterStepOne = $("roleplayStarterStepOne"), roleplayStarterStepTwo = $("roleplayStarterStepTwo");
const roleplayScenarioInput = $("roleplayScenarioInput"), roleplayStarterSuggestions = $("roleplayStarterSuggestions");
const popupModal = $("popupModal"), popupCloseBtn = $("popupClose"), popupCancelBtn = $("popupCancel"), popupConfirmBtn = $("popupConfirm");
const popupTitle = $("popupTitle"), popupEyebrow = $("popupEyebrow"), popupDescription = $("popupDescription"), popupField = $("popupField");
const popupInputLabel = $("popupInputLabel"), popupInput = $("popupInput");
const appNotice = $("appNotice"), quickPrompts = $("quickPrompts");
const onboardingModal = $("onboardingModal"), onboardingTitle = $("onboardingTitle"), onboardingSubtitle = $("onboardingSubtitle");
const onboardingChoices = $("onboardingChoices"), onboardingBack = $("onboardingBack"), onboardingContinue = $("onboardingContinue"), onboardingSkip = $("onboardingSkip");
const authScreens = document.querySelectorAll(".auth-screen"), toggleButtons = document.querySelectorAll(".auth-toggle .toggle");
const themeNameTargets = document.querySelectorAll("[data-theme-name]"), themeLogoTargets = document.querySelectorAll("[data-theme-logo]");
const modelMenu = document.querySelector(".model-menu"), personaMenu = document.querySelector(".persona-menu");
const requestedChatId = Number(new URLSearchParams(window.location.search).get("chat")) || null;
import {del, get, post, put, stream} from "./app/api.js";
import {defaultModelProfile, onboardingPrompts, requestedChatId, themes} from "./app/constants.js";
import {
activeChatTitle,
activePersonaStatus,
activeUserPersonaStatus,
appNotice,
authDiv,
authMsg,
authScreens,
chatActionsMenuButton,
chatActionsPopover,
chatActivityDetail,
chatActivityEyebrow,
chatActivityOverlay,
chatActivityTitle,
chatCharacterPill,
chatDiv,
chatList,
chatListLoading,
chatListLoadingText,
chatSidebar,
chatDrawerCloseBtn,
chatModePill,
chatSearchInput,
chatUserPersonaPill,
clearChatBtn,
archiveChatBtn,
backupWorkspaceBtn,
clearUserPersonaBtn,
contextChipButton,
contextPopover,
contextSummaryLabel,
exportChatBtn,
moveChatFolderBtn,
loginForm,
loginPasswordInput,
loginSubmit,
loginUsernameInput,
logoutButton,
messagesDiv,
modelBadgeName,
modelCount,
modelHelpBadge,
modelHelpSummary,
modelHelpTitle,
modelMenu,
modelMenuButton,
modelPopover,
modelSelect,
msgInput,
newChatBtn,
onboardingBack,
onboardingChoices,
onboardingContinue,
onboardingModal,
onboardingSkip,
onboardingSubtitle,
onboardingTitle,
pinChatBtn,
personaCloseBtn,
personaDetailsInput,
personaExampleDialoguesInput,
personaExamplesField,
personaForm,
personaFormNotice,
personaFormTitle,
personaList,
personaMenu,
personaMenuButton,
personaModal,
personaNameInput,
personaPopover,
personaPronounsInput,
personaTypeSelect,
personaAppearanceInput,
personaBackgroundInput,
popupCancelBtn,
popupCloseBtn,
popupConfirmBtn,
popupDescription,
popupEyebrow,
popupField,
popupInput,
popupInputLabel,
popupModal,
popupTitle,
registerForm,
registerPasswordInput,
registerSubmit,
registerUsernameInput,
renameChatBtn,
roleplayCharacterSelect,
roleplayNewPersonaBtn,
roleplayScenarioInput,
roleplayStarterBack,
roleplayStarterCancel,
roleplayStarterClose,
roleplayStarterConfirm,
roleplayStarterIntroCopy,
roleplayStarterModal,
roleplayStarterNotice,
roleplayStarterStepOne,
roleplayStarterStepTwo,
roleplayStarterSuggestions,
roleplayStarterTitle,
roleplayUserPersonaSelect,
sendBtn,
sessionUser,
themeLogoTargets,
themeNameTargets,
toggleButtons,
userPersonaList
} from "./app/dom.js";
let isProcessing = false, activeChatId = null, editingPersonaId = null, editingPersonaType = "assistant", currentUsername = "";
let chatSessions = [], currentMessages = [], assistantPersonas = [], userPersonas = [];
@@ -55,38 +126,6 @@ let chatLoadingDepth = 0;
let chatActivityDepth = 0;
let messageRetryState = new Map();
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."]
};
const themes = {
"fakegpt": {name: "FakeGPT", short: "FG"},
"fraud": {name: "Fraud", short: "FR"},
"germini": {name: "Germini", short: "GE"},
"slopilot": {name: "Slopilot", short: "SP"},
"beta-ai": {name: "Beta AI", short: "BA"},
"confusity": {name: "Confusity", short: "CF"}
};
const defaultModelProfile = {
badge: "General",
summary: "General-purpose chat model. Start here if you are unsure which model to use."
};
async function request(url, data, method = "POST") {
try {
const res = await fetch(url, {method, headers: {"Content-Type": "application/json"}, body: data ? JSON.stringify(data) : undefined});
return await res.json();
} catch {
return {error: "Network error - please try again."};
}
}
const get = (url) => request(url, null, "GET");
const post = (url, data) => request(url, data, "POST");
const put = (url, data) => request(url, data, "PUT");
const del = (url) => request(url, null, "DELETE");
function setAuthMessage(message, state = "") { authMsg.textContent = message; authMsg.className = state ? `status ${state}` : "status"; }
function setNotice(message = "", state = "") {
@@ -166,7 +205,15 @@ function applyWorkspaceMode(nextMode, persist = true) {
if (modelMenu) modelMenu.classList.toggle("hidden", workspaceMode === "basic");
if (personaMenu) personaMenu.classList.toggle("hidden", workspaceMode === "basic");
updateComposerPlaceholder();
renderQuickPrompts();
}
function isMobileLayout() {
return window.matchMedia("(max-width: 820px)").matches;
}
function closeMobileSidebar() {
if (!chatSidebar) return;
document.body.classList.remove("sidebar-open");
}
function hasCompletedOnboarding() {
@@ -286,29 +333,6 @@ function updateRoleplaySuggestions() {
roleplayStarterSuggestions.appendChild(button);
});
}
function getQuickPromptsForChat(chat) {
return [];
}
function renderQuickPrompts() {
if (!quickPrompts) return;
const activeChat = getChatById(activeChatId);
const prompts = getQuickPromptsForChat(activeChat);
const visiblePrompts = workspaceMode === "basic" ? prompts.slice(0, 3) : prompts;
quickPrompts.innerHTML = "";
quickPrompts.classList.toggle("hidden", visiblePrompts.length === 0);
visiblePrompts.forEach((prompt) => {
const button = document.createElement("button");
button.type = "button";
button.className = "prompt-chip";
button.textContent = prompt;
button.addEventListener("click", () => {
msgInput.value = prompt;
msgInput.dispatchEvent(new Event("input"));
msgInput.focus();
});
quickPrompts.appendChild(button);
});
}
function updateComposerPlaceholder() {
const activeChat = getChatById(activeChatId);
if (!activeChat) {
@@ -484,7 +508,16 @@ async function retryLatestAssistantMessage(messageId, fallbackIndex = -1) {
try {
const endpoint = getMessageEndpoint(target, "/retry");
if (!endpoint) return setNotice("Message is not ready yet. Try again.", "error");
const res = await post(endpoint, {model: modelSelect.value});
const style = await promptPopup({
eyebrow: "Retry style",
title: "Retry modifier",
description: "Leave empty for a normal retry, or use shorter, direct, emotional, stay-in-character, or dialogue.",
label: "Style",
value: "",
placeholder: "shorter",
confirmLabel: "Retry"
});
const res = await post(endpoint, {model: modelSelect.value, style: String(style || "").trim()});
if (res.error || !res.message?.content) {
return setNotice(res.error || "Unable to retry message.", "error");
}
@@ -636,7 +669,6 @@ function renderMessages() {
}
updateChatParticipants();
updateChatActionState();
renderQuickPrompts();
updateComposerPlaceholder();
}
@@ -671,26 +703,26 @@ function renderMessageEmptyState() {
actions.className = "messages-empty-actions";
if (!chat) {
title.textContent = "Start your first conversation";
description.textContent = "Create a chat, or jump directly into roleplay.";
title.textContent = "Start a chat";
description.textContent = "Choose a starting point.";
actions.append(
createStarterAction("New chat", "primary-action", () => { void createNewChat(); }),
createStarterAction("Start roleplay", "secondary-action", () => {
createStarterAction("Roleplay", "secondary-action", () => {
if (assistantPersonas.length) {
openRoleplayStarter();
return;
}
openPersonaForm(null, "assistant");
}),
createMessageStarterPrompt("Try a starter prompt", "Help me plan my day in 3 steps.")
createMessageStarterPrompt("Try a prompt", "Help me plan my day in 3 steps.")
);
} else if (chat.assistant_persona_id) {
title.textContent = "Your scene is ready";
description.textContent = "Send your first line, or use a prompt to set the tone.";
title.textContent = "Roleplay is ready";
description.textContent = "Send a line or pick a prompt.";
actions.append(
createMessageStarterPrompt("Add tension", "Open with a tense line that raises the stakes immediately."),
createMessageStarterPrompt("Set the scene", "Describe what your character notices first in this scene."),
createStarterAction("Pick another roleplay setup", "secondary-action", () => openRoleplayStarter())
createStarterAction("Change setup", "secondary-action", () => openRoleplayStarter())
);
} else {
return;
@@ -709,7 +741,6 @@ function updateWorkspaceCopy() {
sessionUser.textContent = currentUsername || "-";
}
updateChatParticipants();
renderQuickPrompts();
updateComposerPlaceholder();
}
@@ -747,12 +778,11 @@ function renderRoleplayStarterStep() {
function updateChatParticipants() {
const chat = getChatById(activeChatId);
if (!chatModePill || !chatCharacterPill || !chatUserPersonaPill || !chatScenePill) return;
if (!chatModePill || !chatCharacterPill || !chatUserPersonaPill) return;
if (!chat) {
chatModePill.textContent = "No chat selected";
chatCharacterPill.classList.add("hidden");
chatUserPersonaPill.classList.add("hidden");
chatScenePill.classList.add("hidden");
if (contextSummaryLabel) contextSummaryLabel.textContent = "None";
return;
}
@@ -767,19 +797,12 @@ function updateChatParticipants() {
chatUserPersonaPill.textContent = "You as: yourself";
chatUserPersonaPill.classList.remove("hidden");
}
if (chat.scenario_summary) {
chatScenePill.textContent = `Scene: ${summarizeText(chat.scenario_summary, 112)}`;
chatScenePill.classList.remove("hidden");
} else {
chatScenePill.classList.add("hidden");
}
if (contextSummaryLabel) contextSummaryLabel.textContent = chat.assistant_persona_name || "Roleplay";
return;
}
chatModePill.textContent = "Assistant mode";
chatCharacterPill.classList.add("hidden");
chatUserPersonaPill.classList.add("hidden");
chatScenePill.classList.add("hidden");
if (contextSummaryLabel) contextSummaryLabel.textContent = "Assistant";
}
@@ -864,8 +887,18 @@ function updateChatActionState() {
const activeChat = getChatById(activeChatId);
const hasChat = Boolean(activeChat);
renameChatBtn.disabled = !hasChat || Boolean(activeChat?.assistant_persona_id);
if (pinChatBtn) {
pinChatBtn.disabled = !hasChat;
if (hasChat) pinChatBtn.textContent = activeChat.is_pinned ? "Unpin" : "Pin";
}
if (moveChatFolderBtn) moveChatFolderBtn.disabled = !hasChat;
if (archiveChatBtn) {
archiveChatBtn.disabled = !hasChat;
if (hasChat) archiveChatBtn.textContent = activeChat.archived_at ? "Unarchive" : "Archive";
}
clearChatBtn.disabled = !hasChat;
exportChatBtn.disabled = !hasChat || currentMessages.length === 0;
if (backupWorkspaceBtn) backupWorkspaceBtn.disabled = !currentUsername;
}
function maybeShowPersonaNudge() {
@@ -878,7 +911,9 @@ function maybeShowPersonaNudge() {
function renderChatList() {
const query = chatSearchInput.value.trim().toLowerCase();
const filtered = chatSessions.filter((chat) => !query || chat.title.toLowerCase().includes(query));
const filtered = chatSessions.filter((chat) => !query
|| chat.title.toLowerCase().includes(query)
|| String(chat.matched_content || "").toLowerCase().includes(query));
chatList.innerHTML = "";
if (!filtered.length) {
const empty = document.createElement("p");
@@ -887,21 +922,51 @@ function renderChatList() {
chatList.appendChild(empty);
return;
}
filtered.forEach((chat) => {
const item = document.createElement("div"), content = document.createElement("div"), title = document.createElement("span"), meta = document.createElement("small"), remove = document.createElement("button");
item.className = `chat-item${chat.id === activeChatId ? " active" : ""}`;
content.className = "chat-item-copy";
title.textContent = chat.title;
meta.className = "chat-item-meta";
meta.textContent = chat.assistant_persona_id
? `Roleplay${chat.user_persona_name ? ` · ${chat.user_persona_name}` : ""}`
: "Assistant chat";
remove.type = "button"; remove.textContent = "Delete";
remove.addEventListener("click", async (event) => { event.stopPropagation(); await deleteChat(chat.id); });
content.append(title, meta);
item.append(content, remove);
item.addEventListener("click", () => setActiveChat(chat.id));
chatList.appendChild(item);
const folderGroups = filtered
.filter((chat) => chat.folder_name && !chat.archived_at)
.reduce((map, chat) => {
const key = String(chat.folder_name).trim();
const items = map.get(key) || [];
items.push(chat);
map.set(key, items);
return map;
}, new Map());
const groups = [
{label: "Pinned", items: filtered.filter((chat) => chat.is_pinned && !chat.archived_at)},
...Array.from(folderGroups.entries()).map(([label, items]) => ({label, items})),
{label: "Recent", items: filtered.filter((chat) => !chat.is_pinned && !chat.folder_name && !chat.archived_at)},
{label: "Archived", items: filtered.filter((chat) => chat.archived_at)}
].filter((group) => group.items.length);
groups.forEach((group) => {
const section = document.createElement("section");
const heading = document.createElement("div");
const list = document.createElement("div");
section.className = "chat-list-section";
heading.className = "chat-list-section-header";
heading.textContent = group.label;
list.className = "chat-list-section-body";
section.append(heading, list);
group.items.forEach((chat) => {
const item = document.createElement("div"), content = document.createElement("div"), title = document.createElement("span"), meta = document.createElement("small"), remove = document.createElement("button");
item.className = `chat-item${chat.id === activeChatId ? " active" : ""}`;
content.className = "chat-item-copy";
title.textContent = chat.title;
meta.className = "chat-item-meta";
meta.textContent = chat.assistant_persona_id
? `Roleplay${chat.user_persona_name ? ` · ${chat.user_persona_name}` : ""}`
: "Assistant chat";
remove.type = "button";
remove.textContent = "Delete";
remove.addEventListener("click", async (event) => {
event.stopPropagation();
await deleteChat(chat.id);
});
content.append(title, meta);
item.append(content, remove);
item.addEventListener("click", () => setActiveChat(chat.id));
list.appendChild(item);
});
chatList.appendChild(section);
});
}
@@ -945,7 +1010,11 @@ function openPersonaForm(persona = null, personaType = "assistant") {
}
function closePersonaForm() { personaModal.classList.add("hidden"); editingPersonaId = null; editingPersonaType = "assistant"; personaTypeSelect.disabled = false; setPersonaFormNotice(""); }
function closePersonaPopover() { personaPopover.classList.add("hidden"); personaMenuButton.setAttribute("aria-expanded", "false"); }
function closePersonaPopover() {
if (!personaPopover || personaPopover.dataset.staticPanel === "true") return;
personaPopover.classList.add("hidden");
if (personaMenuButton) personaMenuButton.setAttribute("aria-expanded", "false");
}
function closeContextPopover() {
if (!contextPopover || !contextChipButton) return;
contextPopover.classList.add("hidden");
@@ -955,6 +1024,7 @@ function closeChatActionsPopover() {
if (!chatActionsPopover || !chatActionsMenuButton) return;
chatActionsPopover.classList.add("hidden");
chatActionsMenuButton.setAttribute("aria-expanded", "false");
document.body.classList.remove("chat-drawer-open");
}
function closeAuxiliaryPopovers() {
closeModelPopover();
@@ -1037,9 +1107,9 @@ function goToRoleplayStarterSceneStep() {
if (roleplayScenarioInput) roleplayScenarioInput.focus();
}
function closeModelPopover() {
if (!modelPopover || !modelMenuButton) return;
if (!modelPopover || modelPopover.dataset.staticPanel === "true") return;
modelPopover.classList.add("hidden");
modelMenuButton.setAttribute("aria-expanded", "false");
if (modelMenuButton) modelMenuButton.setAttribute("aria-expanded", "false");
}
function isPopupOpen() { return popupModal && !popupModal.classList.contains("hidden"); }
function finishPopup(result) {
@@ -1231,6 +1301,7 @@ function showAuthScreen(target) {
}
async function setActiveChat(id) {
if (isMobileLayout()) closeMobileSidebar();
setChatLoading(true, "Opening chat...");
setChatActivity(true, {
eyebrow: "Opening chat",
@@ -1256,7 +1327,8 @@ async function setActiveChat(id) {
async function loadChatSessions() {
setChatLoading(true, "Loading chats...");
const res = await get("/chats");
const query = chatSearchInput?.value.trim();
const res = query ? await get(`/chats/search?q=${encodeURIComponent(query)}`) : await get("/chats");
if (res.error) {
setChatLoading(false);
return setNotice(res.error, "error");
@@ -1354,6 +1426,23 @@ async function renameChat(id) {
chat.title = title; updateWorkspaceCopy(); renderChatList(); setNotice("Chat renamed.", "success");
}
async function updateChatOrganization(id, changes) {
const chat = getChatById(id);
if (!chat) return;
const payload = {
folderName: changes.folderName !== undefined ? changes.folderName : chat.folder_name,
isPinned: changes.isPinned !== undefined ? changes.isPinned : Boolean(chat.is_pinned),
archived: changes.archived !== undefined ? changes.archived : Boolean(chat.archived_at)
};
const res = await put(`/chats/${id}/organization`, payload);
if (res.error) return setNotice(res.error, "error");
const index = chatSessions.findIndex((item) => item.id === id);
if (index >= 0) chatSessions[index] = res.chat;
updateWorkspaceCopy();
renderChatList();
updateChatActionState();
}
async function clearChat(id) {
const chat = getChatById(id);
if (!chat) return;
@@ -1374,11 +1463,46 @@ async function clearChat(id) {
function exportCurrentChat() {
const chat = getChatById(activeChatId);
if (!chat || !currentMessages.length) return;
const lines = [`Chat: ${chat.title}`, `Exported: ${new Date().toISOString()}`, ""];
currentMessages.forEach((msg) => { lines.push(`${msg.role === "user" ? "You" : "Assistant"}:`); lines.push(msg.content, ""); });
const blob = new Blob([lines.join("\n")], {type: "text/plain;charset=utf-8"}), url = URL.createObjectURL(blob), link = document.createElement("a");
link.href = url; link.download = `${(chat.title.replace(/[^a-z0-9-_]+/gi, "-").replace(/^-+|-+$/g, "") || "chat")}.txt`;
document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(url); setNotice("Chat exported.", "success");
void (async () => {
const format = await promptPopup({
eyebrow: "Export",
title: "Export format",
description: "Use txt, md, or json.",
label: "Format",
value: "md",
placeholder: "md",
confirmLabel: "Download"
});
const normalized = ["txt", "md", "json"].includes(String(format || "").trim().toLowerCase())
? String(format).trim().toLowerCase()
: "md";
const response = await fetch(`/exports/chats/${activeChatId}?format=${normalized}`);
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${(chat.title.replace(/[^a-z0-9-_]+/gi, "-").replace(/^-+|-+$/g, "") || "chat")}.${normalized}`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
setNotice(`Chat exported as ${normalized}.`, "success");
})();
}
async function backupWorkspace() {
const response = await fetch("/exports/workspace");
const data = await response.json();
const blob = new Blob([JSON.stringify(data.workspace, null, 2)], {type: "application/json;charset=utf-8"});
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `workspace-backup-${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
setNotice("Workspace backup downloaded.", "success");
}
async function deleteChat(id) {
@@ -1479,19 +1603,30 @@ async function sendMessage() {
title: "Generating response",
detail: "The assistant is reading your message and preparing a reply."
}); setNotice("Generating reply...");
const loadingMsg = addMessage("", false, true);
const loadingMsg = addMessage({role: "bot", content: ""}, false, false, {isNewest: true});
const loadingBody = loadingMsg.querySelector(".msg-content");
try {
const res = await post("/chat", {message, model: modelSelect.value, chatId: activeChatId});
let streamError = null;
await stream("/chat/stream", {message, model: modelSelect.value, chatId: activeChatId}, {
onChunk: ({fullReply}) => {
if (loadingBody) setMessageContent(loadingBody, fullReply);
},
onDone: () => {},
onError: ({error}) => {
streamError = error || "Failed to generate reply.";
}
});
if (messagesDiv.contains(loadingMsg)) messagesDiv.removeChild(loadingMsg);
if (res.reply) {
await setActiveChat(activeChatId);
await loadSummary();
markOnboardingCompleted();
maybeShowPersonaNudge();
setNotice("Reply received.", "success");
} else {
addMessage(`[Error: ${res.error}]`); setNotice(res.error || "Failed to generate reply.", "error");
if (streamError) {
addMessage(`[Error: ${streamError}]`);
setNotice(streamError, "error");
return;
}
await setActiveChat(activeChatId);
await loadSummary();
markOnboardingCompleted();
maybeShowPersonaNudge();
setNotice("Reply received.", "success");
} catch {
if (messagesDiv.contains(loadingMsg)) messagesDiv.removeChild(loadingMsg);
addMessage("[Error: Network error]"); setNotice("Network error while sending message.", "error");
@@ -1537,15 +1672,18 @@ function toggleChatActionsPopover(event) {
closeModelPopover();
closePersonaPopover();
closeContextPopover();
if (isMobileLayout()) closeMobileSidebar();
const isHidden = chatActionsPopover.classList.contains("hidden");
chatActionsPopover.classList.toggle("hidden", !isHidden);
chatActionsMenuButton.setAttribute("aria-expanded", String(isHidden));
document.body.classList.toggle("chat-drawer-open", isHidden);
}
toggleButtons.forEach((btn) => btn.addEventListener("click", () => showAuthScreen(btn.dataset.target)));
loginForm.addEventListener("submit", (event) => { event.preventDefault(); void handleAuth("login", {username: loginUsernameInput.value.trim(), password: loginPasswordInput.value.trim()}); });
registerForm.addEventListener("submit", (event) => { event.preventDefault(); void handleAuth("register", {username: registerUsernameInput.value.trim(), password: registerPasswordInput.value.trim()}); });
$("logout").addEventListener("click", async () => {
logoutButton.addEventListener("click", async () => {
closeChatActionsPopover();
await post("/logout", {}); chatDiv.style.display = "none"; authDiv.style.display = "grid";
activeChatId = null; currentUsername = ""; currentSummary = null; chatSessions = []; currentMessages = []; assistantPersonas = []; userPersonas = [];
messageRetryState = new Map();
@@ -1561,17 +1699,64 @@ msgInput.addEventListener("input", function () {
this.style.height = `${Math.min(this.scrollHeight, 180)}px`;
updateSendState();
});
newChatBtn.addEventListener("click", () => { void createNewChat(); });
if (startRoleplayBtn) {
startRoleplayBtn.addEventListener("click", () => {
window.location.href = "/market?view=ai-characters";
newChatBtn.addEventListener("click", () => {
closeChatActionsPopover();
closeMobileSidebar();
void createNewChat();
});
renameChatBtn.addEventListener("click", () => {
closeChatActionsPopover();
if (activeChatId) void renameChat(activeChatId);
});
clearChatBtn.addEventListener("click", () => {
closeChatActionsPopover();
if (activeChatId) void clearChat(activeChatId);
});
exportChatBtn.addEventListener("click", () => {
closeChatActionsPopover();
exportCurrentChat();
});
if (pinChatBtn) pinChatBtn.addEventListener("click", () => {
closeChatActionsPopover();
const chat = getChatById(activeChatId);
if (chat) void updateChatOrganization(chat.id, {isPinned: !chat.is_pinned});
});
if (moveChatFolderBtn) moveChatFolderBtn.addEventListener("click", async () => {
closeChatActionsPopover();
const chat = getChatById(activeChatId);
if (!chat) return;
const folderName = await promptPopup({
eyebrow: "Folder",
title: "Move chat to folder",
description: "Leave blank to remove the folder.",
label: "Folder name",
value: chat.folder_name || "",
placeholder: "Research"
});
}
renameChatBtn.addEventListener("click", () => { if (activeChatId) void renameChat(activeChatId); });
clearChatBtn.addEventListener("click", () => { if (activeChatId) void clearChat(activeChatId); }); exportChatBtn.addEventListener("click", exportCurrentChat);
chatSearchInput.addEventListener("input", renderChatList); modelSelect.addEventListener("change", () => { updateModelHelp(); updateContextRail(); });
if (folderName === null) return;
void updateChatOrganization(chat.id, {folderName: String(folderName || "").trim() || null});
});
if (archiveChatBtn) archiveChatBtn.addEventListener("click", () => {
closeChatActionsPopover();
const chat = getChatById(activeChatId);
if (chat) void updateChatOrganization(chat.id, {archived: !chat.archived_at});
});
if (backupWorkspaceBtn) backupWorkspaceBtn.addEventListener("click", () => {
closeChatActionsPopover();
void backupWorkspace();
});
chatSearchInput.addEventListener("input", () => { void loadChatSessions(); });
if (chatDrawerCloseBtn) chatDrawerCloseBtn.addEventListener("click", closeChatActionsPopover);
modelSelect.addEventListener("change", () => {
updateModelHelp();
updateContextRail();
closeModelPopover();
});
roleplayNewPersonaBtn.addEventListener("click", () => openPersonaForm(null, "assistant"));
clearUserPersonaBtn.addEventListener("click", () => { void clearPersona("user"); });
clearUserPersonaBtn.addEventListener("click", () => {
closePersonaPopover();
void clearPersona("user");
});
personaForm.addEventListener("submit", (event) => { event.preventDefault(); void savePersona(); }); personaCloseBtn.addEventListener("click", closePersonaForm);
personaTypeSelect.addEventListener("change", () => {
const isAssistant = personaTypeSelect.value === "assistant";
@@ -1625,6 +1810,9 @@ if (chatActionsMenuButton && chatActionsPopover) {
chatActionsMenuButton.addEventListener("click", toggleChatActionsPopover);
chatActionsPopover.addEventListener("click", (event) => event.stopPropagation());
}
if (chatSidebar) {
chatSidebar.addEventListener("click", (event) => event.stopPropagation());
}
if (onboardingContinue) {
onboardingContinue.addEventListener("click", () => {
onboardingStep = 2;
@@ -1645,10 +1833,17 @@ if (onboardingModal) {
if (event.target === onboardingModal) markOnboardingCompleted();
});
}
document.addEventListener("click", () => { closeAuxiliaryPopovers(); });
document.addEventListener("click", () => {
closeAuxiliaryPopovers();
closeMobileSidebar();
});
window.addEventListener("resize", () => {
if (!isMobileLayout()) closeMobileSidebar();
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
closeAuxiliaryPopovers();
closeMobileSidebar();
closeRoleplayStarter();
closePersonaForm();
closePopup(false);
+27 -6
View File
@@ -15,7 +15,6 @@
<div>
<p class="eyebrow">Settings</p>
<h1>Preferences</h1>
<p class="subtitle">Manage your account, AI Characters, user personas, and appearance.</p>
</div>
<div class="market-header-actions">
<a href="/" class="secondary-action">Back to chat</a>
@@ -27,9 +26,9 @@
<section class="settings-layout">
<aside class="settings-nav" aria-label="Settings sections">
<button class="settings-nav-item active" type="button" data-settings-view="personal">Personal</button>
<button class="settings-nav-item" type="button" data-settings-view="my-roleplay-companions">My Roleplay Companions</button>
<button class="settings-nav-item" type="button" data-settings-view="design">Design</button>
<button class="settings-nav-item active" type="button" data-settings-view="personal">Account</button>
<button class="settings-nav-item" type="button" data-settings-view="my-roleplay-companions">Personas</button>
<button class="settings-nav-item" type="button" data-settings-view="design">Appearance</button>
</aside>
<div class="settings-content">
@@ -88,8 +87,7 @@
<div class="settings-section-header">
<div>
<p class="eyebrow">Create & manage</p>
<h2>My Roleplay Companions</h2>
<p class="subtitle">Build characters for roleplay chats and publish them to the market when they are ready.</p>
<h2>My personas</h2>
</div>
<div class="market-header-actions action-pair">
<button id="createAiCharacter" type="button" class="secondary-action">Create AI Character</button>
@@ -197,6 +195,29 @@
</form>
</div>
</div>
<div id="settingsPopupModal" class="modal-overlay hidden">
<div class="modal-card popup-card" role="dialog" aria-modal="true" aria-labelledby="settingsPopupTitle" aria-describedby="settingsPopupDescription">
<div class="modal-header">
<div class="persona-modal-heading">
<p id="settingsPopupEyebrow" class="eyebrow">Action</p>
<h3 id="settingsPopupTitle">Confirm action</h3>
</div>
<button id="settingsPopupClose" type="button" class="icon-button">Close</button>
</div>
<div class="popup-content">
<p id="settingsPopupDescription" class="subtitle">Please confirm this action.</p>
<label id="settingsPopupField" class="field">
<span id="settingsPopupInputLabel">Value</span>
<input id="settingsPopupInput" type="text" autocomplete="off"/>
</label>
</div>
<div class="popup-actions">
<button id="settingsPopupCancel" type="button" class="secondary-action">Cancel</button>
<button id="settingsPopupConfirm" type="button">Confirm</button>
</div>
</div>
</div>
<script src="settings.js" defer></script>
</body>
</html>
+106 -1
View File
@@ -30,6 +30,16 @@ 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 themes = {
"fakegpt": {name: "FakeGPT"},
@@ -44,6 +54,7 @@ let assistantPersonas = [];
let userPersonas = [];
let publishedPersonaIds = new Set();
let editingPersonaId = null;
let settingsPopupResolver = null;
async function request(url, data, method = "POST") {
try {
@@ -78,6 +89,39 @@ function setPersonaNotice(message = "", state = "") {
settingsPersonaFormNotice.className = state ? `status ${state}` : "status";
}
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) {
const nextTheme = themes[themeKey] ? themeKey : "fakegpt";
document.body.dataset.theme = nextTheme;
@@ -169,6 +213,8 @@ function renderPersonaList(personas, listElement, type) {
[
["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)]
]
@@ -263,7 +309,17 @@ async function deletePersona(id) {
}
async function publishPersona(id) {
const res = await post(`/personas/${id}/publish`, {});
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;
@@ -272,6 +328,39 @@ async function publishPersona(id) {
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) {
@@ -347,6 +436,22 @@ 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 () => {
applyTheme(localStorage.getItem("krishd-theme") || "fakegpt", false);
+1313
View File
File diff suppressed because it is too large Load Diff