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
+73 -1
View File
@@ -1,6 +1,78 @@
# KrisHD_AI # KrisHD_AI
AI Frontend for ai.krishd.ch
KrisHD_AI is an Express + SQLite app for account-based chat, persona management, roleplay chat sessions, and a simple persona market backed by a remote model API.
## Setup
1. Install dependencies with `npm install`.
2. Copy `.env.example` to `.env` and set at least `SESSION_SECRET`.
3. Start the app with `npm run dev` for local development or `npm start` for a normal server run.
## Scripts
- `npm start`: run the production server entrypoint.
- `npm run dev`: run the server with Node watch mode.
- `npm run lint`: syntax-check the backend and browser modules.
- `npm test`: run API tests with Node's built-in test runner.
## Environment Variables
- `PORT`: HTTP port for the Express server.
- `DB_PATH`: SQLite database path. Use `:memory:` for isolated tests.
- `SESSION_SECRET`: session signing secret. Required outside local throwaway environments.
- `COOKIE_SECURE`: set to `true` when serving over HTTPS.
- `SESSION_MAX_AGE_MS`: cookie lifetime.
- `CHAT_LIMIT`: per-account maximum chat sessions.
- `CHAT_HISTORY_LIMIT`: number of recent messages included in model prompts.
- `MODEL_API_BASE_URL`: base URL of the remote model backend. The server expects `/chat` and `/tags`.
- `MODEL_REQUEST_TIMEOUT_MS`: timeout for chat generation requests.
- `MODEL_LIST_TIMEOUT_MS`: timeout for model list requests.
- `MODEL_UNLOAD_AFTER_MS`: delay before an idle model unload is attempted.
- `MODELS_CACHE_TTL_MS`: model list cache TTL.
- `AUTH_RATE_LIMIT_*`: rate-limit window and thresholds for registration/login.
- `CHAT_RATE_LIMIT_*`: rate-limit window and thresholds for chat and retry endpoints.
- `DROP_LEGACY_CHATS`: when `true`, migrate any legacy `chats` table into `chat_sessions` / `chat_messages` and remove the old table.
## Model Backend Expectations
The server is configured around an Ollama-compatible API surface:
- `POST /chat`: accepts `{ model, messages, stream: true }` and returns newline-delimited JSON stream chunks with `message.content`.
- `GET /tags`: returns the available model list.
The refactor adds explicit upstream timeout handling, cached model listing, and `GET /health` for degraded-state visibility.
## Schema Notes
Primary runtime tables are:
- `users`
- `chat_sessions`
- `chat_messages`
- `personas`
- `user_settings`
- `persona_market`
Legacy `chats` migration is handled during startup. New code only reads the session/message schema.
## Persona And Market Flow
- Assistant and user personas are stored separately through `persona_type`.
- Roleplay chats can bind an assistant persona, an optional user persona, and scenario metadata to a chat session.
- Publishing copies a local persona into `persona_market`.
- Collecting clones a market persona back into the current user's `personas` table with source metadata.
## Frontend Structure
The main browser entrypoint is still `public/script.js`, but shared API/bootstrap concerns are now split into `public/app/` modules:
- `api.js`: JSON request helpers
- `constants.js`: theme and onboarding constants
- `dom.js`: cached DOM references
This keeps the next round of UI extraction lower-risk without changing the app's behavior surface.
## Legal ## Legal
- [Privacy Policy](PRIVACY_POLICY.md) - [Privacy Policy](PRIVACY_POLICY.md)
- [Terms of Service](TERMS_OF_SERVICE.md) - [Terms of Service](TERMS_OF_SERVICE.md)
+4 -1
View File
@@ -4,7 +4,10 @@
"type": "module", "type": "module",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
"start": "node server.js" "start": "node server.js",
"dev": "node --watch server.js",
"lint": "node scripts/lint.mjs",
"test": "node --test"
}, },
"dependencies": { "dependencies": {
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
+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
};
+77 -71
View File
@@ -16,13 +16,13 @@
<div class="brand"> <div class="brand">
<div class="logo" data-theme-logo>FG</div> <div class="logo" data-theme-logo>FG</div>
<div> <div>
<p class="eyebrow">Private chat</p> <p class="eyebrow">Private workspace</p>
<h1 data-theme-name>FakeGPT</h1> <h1 data-theme-name>FakeGPT</h1>
</div> </div>
</div> </div>
<div class="hero-grid"> <div class="hero-grid">
<article class="hero-panel"> <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> </article>
</div> </div>
</div> </div>
@@ -75,44 +75,33 @@
<section id="chat" class="card chat-card chat-focus-card" style="display:none;"> <section id="chat" class="card chat-card chat-focus-card" style="display:none;">
<div class="chat-layout chat-layout-focus"> <div class="chat-layout chat-layout-focus">
<aside class="chat-sidebar"> <aside id="chatSidebar" class="chat-sidebar">
<div class="sidebar-top"> <div class="sidebar-top">
<div class="sidebar-header"> <div class="sidebar-workspace-card">
<div> <div>
<p class="eyebrow">Chats</p> <p class="eyebrow">Workspace</p>
<h2>Conversations</h2> <h2 data-theme-name>FakeGPT</h2>
<p id="sessionUser" class="sidebar-account-name">Signed out</p>
</div> </div>
<div class="sidebar-header-actions action-pair"> <div class="sidebar-header-actions sidebar-workspace-actions">
<button id="newChat" class="secondary-action" type="button">New chat</button> <button id="newChat" class="primary-action" type="button">New chat</button>
<button id="startRoleplay" class="secondary-action" type="button">Start roleplay</button>
</div> </div>
</div> </div>
<label class="field compact-field"> <label class="field compact-field">
<span>Search</span> <span>Search</span>
<input id="chatSearch" type="search" placeholder="Filter chat titles"/> <input id="chatSearch" type="search" placeholder="Search chats"/>
</label> </label>
</div> </div>
<div id="chatList" class="chat-list"></div> <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"> <div id="chatListLoading" class="chat-loading hidden" role="status" aria-live="polite">
<span class="chat-loading-dot" aria-hidden="true"></span> <span class="chat-loading-dot" aria-hidden="true"></span>
<span id="chatListLoadingText">Loading chats...</span> <span id="chatListLoadingText">Loading chats...</span>
</div> </div>
<div class="sidebar-footer sidebar-footer-stack"> </div>
<details id="toolsDetails" class="tools-details"> <div class="sidebar-footer">
<summary>Tools</summary>
<div class="tools-links">
<a href="/market" class="secondary-action">Browse personas</a>
<a href="/settings" class="secondary-action">Settings</a> <a href="/settings" class="secondary-action">Settings</a>
</div> <a href="/market" class="secondary-action">Market</a>
</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> </div>
</aside> </aside>
@@ -120,31 +109,40 @@
<header class="chat-header chat-header-focus"> <header class="chat-header chat-header-focus">
<div class="chat-title-block"> <div class="chat-title-block">
<div class="chat-title"> <div class="chat-title">
<p class="eyebrow">Conversation</p>
<h2 id="activeChatTitle">New chat</h2> <h2 id="activeChatTitle">New chat</h2>
<div class="chat-context"> <div id="chatParticipants" class="chat-participants chat-participants-inline">
<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="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> </div>
</div> </div>
</div> </div>
<div class="chat-header-actions"> <div class="chat-header-actions">
<div class="model-menu"> <div class="chat-actions-menu">
<button id="modelMenuButton" class="model-badge-button" type="button" aria-expanded="false" aria-controls="modelPopover"> <button id="chatActionsMenuButton" class="secondary-action overflow-trigger" type="button" aria-expanded="false" aria-controls="chatActionsPopover">
<span class="model-badge-label"></span> Menu
<strong id="modelBadgeName">Loading...</strong>
</button> </button>
<div id="modelPopover" class="model-popover hidden" role="dialog"> <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 id="chatDrawerTitle">Controls</h2>
</div>
<button id="chatDrawerClose" class="icon-button" type="button" aria-label="Close panel">Close</button>
</div>
<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">
<div class="model-select-header"> <div class="model-select-header">
<label for="model">Model</label> <label for="model">Model</label>
@@ -164,19 +162,22 @@
</div> </div>
<p id="modelHelpSummary" class="model-help-summary">Pick a model to see what it is best suited for.</p> <p id="modelHelpSummary" class="model-help-summary">Pick a model to see what it is best suited for.</p>
</div> </div>
<div class="model-inline-badge">
<span class="model-badge-label">Current</span>
<strong id="modelBadgeName">Loading...</strong>
</div> </div>
</div> </div>
<div class="persona-menu"> </div>
<button id="personaMenuButton" class="secondary-action" type="button" aria-expanded="false" aria-controls="personaPopover"> <div class="chat-drawer-section persona-menu">
You <div class="dropdown-header">
</button> <p class="eyebrow">Persona</p>
<div id="personaPopover" class="persona-popover hidden" role="dialog"> <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">
<div class="persona-section-header"> <div class="persona-section-header">
<div> <div>
<p class="eyebrow">User</p>
<h2>Your persona</h2> <h2>Your persona</h2>
<p class="persona-guidance">Adds context for how the assistant should read you.</p>
</div> </div>
<div class="persona-section-actions"> <div class="persona-section-actions">
<button id="clearUserPersona" class="secondary-action" type="button">Clear</button> <button id="clearUserPersona" class="secondary-action" type="button">Clear</button>
@@ -188,22 +189,26 @@
</div> </div>
</div> </div>
</div> </div>
<div class="chat-actions-menu"> <div class="chat-drawer-section">
<button id="chatActionsMenuButton" class="secondary-action" type="button" aria-expanded="false" aria-controls="chatActionsPopover"> <p class="dropdown-section-label">Workspace</p>
More <div class="dropdown-action-grid">
</button> <a href="/market" class="secondary-action">Market</a>
<div id="chatActionsPopover" class="persona-popover hidden" role="dialog"> <a href="/settings" class="secondary-action">Settings</a>
<div class="persona-section"> <button id="logout" type="button" class="secondary-action">Logout</button>
<div class="persona-section-header"> <button id="backupWorkspace" class="secondary-action" type="button">Backup</button>
<div> </div>
<p class="eyebrow">Conversation</p> </div>
<h2>Actions</h2> <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 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> </div>
</div> </div>
</div> </div>
@@ -211,6 +216,8 @@
</div> </div>
</header> </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 id="chatActivityOverlay" class="chat-activity-overlay hidden" role="status" aria-live="polite" aria-atomic="true">
<div class="chat-activity-card"> <div class="chat-activity-card">
<div class="chat-activity-mark" aria-hidden="true"> <div class="chat-activity-mark" aria-hidden="true">
@@ -230,7 +237,6 @@
<div class="input-meta"> <div class="input-meta">
<p class="composer-hint">Enter to send · Shift+Enter for new line</p> <p class="composer-hint">Enter to send · Shift+Enter for new line</p>
</div> </div>
<div id="quickPrompts" class="quick-prompts" aria-label="Suggested starters"></div>
<div class="input-container"> <div class="input-container">
<textarea id="msgInput" placeholder="Type your message..."></textarea> <textarea id="msgInput" placeholder="Type your message..."></textarea>
<button id="send" type="button" aria-label="Send message"></button> <button id="send" type="button" aria-label="Send message"></button>
@@ -241,8 +247,8 @@
</section> </section>
</div> </div>
<div id="personaModal" class="modal-overlay hidden"> <div id="personaModal" class="modal-overlay side-panel-overlay hidden">
<div class="modal-card persona-modal-card"> <div class="modal-card persona-modal-card side-panel-card">
<div class="modal-header"> <div class="modal-header">
<div class="persona-modal-heading"> <div class="persona-modal-heading">
<p class="eyebrow">Persona editor</p> <p class="eyebrow">Persona editor</p>
@@ -301,8 +307,8 @@
</div> </div>
</div> </div>
<div id="roleplayStarterModal" class="modal-overlay hidden"> <div id="roleplayStarterModal" class="modal-overlay side-panel-overlay hidden">
<div class="modal-card roleplay-starter-card"> <div class="modal-card roleplay-starter-card side-panel-card">
<div class="modal-header"> <div class="modal-header">
<div class="persona-modal-heading"> <div class="persona-modal-heading">
<p class="eyebrow">Start roleplay</p> <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/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="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> </body>
</html> </html>
+52 -6
View File
@@ -15,7 +15,6 @@
<div> <div>
<p class="eyebrow">Shared personas</p> <p class="eyebrow">Shared personas</p>
<h1><span data-theme-name>FakeGPT</span> Market</h1> <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>
<div class="market-header-actions"> <div class="market-header-actions">
<button id="refreshMarket" class="secondary-action" type="button">Refresh</button> <button id="refreshMarket" class="secondary-action" type="button">Refresh</button>
@@ -25,7 +24,7 @@
</header> </header>
<div class="market-search"> <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"/> <input id="marketSearch" type="search" placeholder="Search by name, creator, or description"/>
</div> </div>
@@ -33,8 +32,8 @@
<section class="settings-layout market-settings-layout"> <section class="settings-layout market-settings-layout">
<aside class="settings-nav" aria-label="Market sections"> <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 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> <button class="settings-nav-item" type="button" data-market-view="user-personas">User personas</button>
</aside> </aside>
<div class="settings-content"> <div class="settings-content">
@@ -44,10 +43,22 @@
<div> <div>
<p class="eyebrow">Discover</p> <p class="eyebrow">Discover</p>
<h2>AI Characters</h2> <h2>AI Characters</h2>
<p class="subtitle">Pick a character, choose your persona, and start roleplay.</p>
</div> </div>
<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> <button id="marketCreateAiCharacter" type="button" class="secondary-action">Create AI Character</button>
</div> </div>
</div>
<div id="marketPersonaList" class="persona-list"></div> <div id="marketPersonaList" class="persona-list"></div>
</div> </div>
</section> </section>
@@ -58,10 +69,22 @@
<div> <div>
<p class="eyebrow">Discover</p> <p class="eyebrow">Discover</p>
<h2>User Personas</h2> <h2>User Personas</h2>
<p class="subtitle">Collect personas you can use as your identity in roleplay scenes.</p>
</div> </div>
<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> <button id="marketCreateUserPersona" type="button" class="secondary-action">Create User Persona</button>
</div> </div>
</div>
<div id="marketUserPersonaList" class="persona-list"></div> <div id="marketUserPersonaList" class="persona-list"></div>
</div> </div>
</section> </section>
@@ -191,6 +214,29 @@
<p id="marketActivityDetail" class="subtitle">The AI character is preparing the first message.</p> <p id="marketActivityDetail" class="subtitle">The AI character is preparing the first message.</p>
</div> </div>
</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> <script src="market.js" defer></script>
</body> </body>
</html> </html>
+203 -20
View File
@@ -4,6 +4,8 @@ const marketUserPersonaList = $("marketUserPersonaList");
const refreshMarketBtn = $("refreshMarket"); const refreshMarketBtn = $("refreshMarket");
const marketStatus = $("marketStatus"); const marketStatus = $("marketStatus");
const marketSearchInput = $("marketSearch"); const marketSearchInput = $("marketSearch");
const marketAssistantSort = $("marketAssistantSort");
const marketUserSort = $("marketUserSort");
const marketViewButtons = document.querySelectorAll("[data-market-view]"); const marketViewButtons = document.querySelectorAll("[data-market-view]");
const marketPanels = document.querySelectorAll("[data-market-panel]"); const marketPanels = document.querySelectorAll("[data-market-panel]");
const marketCreateAiCharacterBtn = $("marketCreateAiCharacter"); const marketCreateAiCharacterBtn = $("marketCreateAiCharacter");
@@ -42,6 +44,16 @@ const marketActivityOverlay = $("marketActivityOverlay");
const marketActivityEyebrow = $("marketActivityEyebrow"); const marketActivityEyebrow = $("marketActivityEyebrow");
const marketActivityTitle = $("marketActivityTitle"); const marketActivityTitle = $("marketActivityTitle");
const marketActivityDetail = $("marketActivityDetail"); 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 themeNameTargets = document.querySelectorAll("[data-theme-name]");
const themes = { const themes = {
@@ -53,15 +65,18 @@ const themes = {
"confusity": {name: "Confusity"} "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 assistantPersonas = [];
let userPersonas = []; let userPersonas = [];
let pendingMarketPersona = null; let pendingMarketPersona = null;
let editingPersonaId = null; let editingPersonaId = null;
let activeUserPersonaId = null; let activeUserPersonaId = null;
let activeMarketView = "ai-characters"; let activeMarketView = "ai-characters";
let marketSorts = {assistant: "best", user: "best"};
let marketActivityDepth = 0; let marketActivityDepth = 0;
let marketPreviewStep = 1; let marketPreviewStep = 1;
let marketPopupResolver = null;
async function request(url, data, method = "POST") { async function request(url, data, method = "POST") {
try { try {
@@ -94,6 +109,40 @@ function setMarketStatus(message, state = "") {
marketStatus.className = state ? `status persona-status ${state}` : "status persona-status"; 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 = "") { function setPersonaFormNotice(message = "", state = "") {
if (!message) { if (!message) {
marketPersonaFormNotice.textContent = ""; marketPersonaFormNotice.textContent = "";
@@ -132,12 +181,35 @@ function setMarketView(view) {
activeMarketView = normalizedView; activeMarketView = normalizedView;
marketViewButtons.forEach((item) => item.classList.toggle("active", item.dataset.marketView === normalizedView)); marketViewButtons.forEach((item) => item.classList.toggle("active", item.dataset.marketView === normalizedView));
marketPanels.forEach((panel) => panel.classList.toggle("active", panel.dataset.marketPanel === normalizedView)); marketPanels.forEach((panel) => panel.classList.toggle("active", panel.dataset.marketPanel === normalizedView));
const params = new URLSearchParams(window.location.search); syncMarketQuery();
params.set("view", normalizedView);
history.replaceState(null, "", `${window.location.pathname}?${params.toString()}`);
applyMarketFilter(); 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) { function buildPersonaMeta(persona) {
const creator = persona.creator_username || "You"; const creator = persona.creator_username || "You";
const pronouns = persona.pronouns ? `Pronouns: ${persona.pronouns}` : "Pronouns: n/a"; 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 title = document.createElement("h4");
const tag = document.createElement("span"); const tag = document.createElement("span");
const meta = document.createElement("p"); const meta = document.createElement("p");
const stats = document.createElement("p");
const actions = document.createElement("div"); const actions = document.createElement("div");
const detailsBtn = document.createElement("button"); const detailsBtn = document.createElement("button");
const favoriteBtn = document.createElement("button");
item.className = "persona-item"; item.className = "persona-item";
titleRow.className = "persona-title-row"; titleRow.className = "persona-title-row";
@@ -309,10 +383,36 @@ function renderMarketList(items, listElement, type) {
tag.textContent = type === "assistant" ? "AI Character" : "User"; tag.textContent = type === "assistant" ? "AI Character" : "User";
title.textContent = persona.name; title.textContent = persona.name;
meta.textContent = buildPersonaMeta(persona); 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.type = "button";
detailsBtn.textContent = "Preview"; detailsBtn.textContent = "Preview";
detailsBtn.addEventListener("click", () => openMarketPreview(persona)); 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") { if (type !== "assistant") {
const quickActionBtn = document.createElement("button"); const quickActionBtn = document.createElement("button");
@@ -325,29 +425,27 @@ function renderMarketList(items, listElement, type) {
} }
titleRow.append(title, tag); titleRow.append(title, tag);
actions.appendChild(detailsBtn); item.append(titleRow, meta, stats, actions);
item.append(titleRow, meta, actions);
listElement.appendChild(item); listElement.appendChild(item);
}); });
} }
function filterMarketPersonas() { function filterMarketPersonas(items) {
const query = marketSearchInput.value.trim().toLowerCase(); const query = marketSearchInput.value.trim().toLowerCase();
if (!query) return allMarketPersonas; if (!query) return items;
return allMarketPersonas.filter((persona) => return items.filter((persona) =>
[persona.name, persona.creator_username, persona.pronouns, persona.appearance, persona.background, persona.details, persona.example_dialogues] [persona.name, persona.creator_username, persona.pronouns, persona.appearance, persona.background, persona.details, persona.example_dialogues]
.some((field) => field && field.toLowerCase().includes(query)) .some((field) => field && field.toLowerCase().includes(query))
); );
} }
function applyMarketFilter() { function applyMarketFilter() {
const filtered = filterMarketPersonas(); const assistantItems = filterMarketPersonas(marketPersonasByType.assistant);
const assistantItems = filtered.filter((persona) => persona.persona_type === "assistant"); const userItems = filterMarketPersonas(marketPersonasByType.user);
const userItems = filtered.filter((persona) => persona.persona_type === "user");
renderMarketList(assistantItems, marketPersonaList, "assistant"); renderMarketList(assistantItems, marketPersonaList, "assistant");
renderMarketList(userItems, marketUserPersonaList, "user"); 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"); setMarketStatus("No personas match your search.", "error");
return; return;
} }
@@ -362,6 +460,72 @@ function applyMarketFilter() {
setMarketStatus(`${assistantItems.length + userItems.length} persona${assistantItems.length + userItems.length === 1 ? "" : "s"} shown.`); 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() { async function loadOwnedPersonas() {
const res = await get("/personas"); const res = await get("/personas");
if (res.error) return false; if (res.error) return false;
@@ -373,17 +537,12 @@ async function loadOwnedPersonas() {
async function loadMarketPersonas() { async function loadMarketPersonas() {
setMarketStatus("Loading personas..."); setMarketStatus("Loading personas...");
const [marketRes, ownedOk] = await Promise.all([get("/personas/market"), loadOwnedPersonas()]); const ownedOk = await loadOwnedPersonas();
if (marketRes.error) {
setMarketStatus(marketRes.error, "error");
return;
}
if (!ownedOk) { if (!ownedOk) {
setMarketStatus("Unable to load your personas.", "error"); setMarketStatus("Unable to load your personas.", "error");
return; return;
} }
allMarketPersonas = marketRes.personas || []; await refreshMarket();
applyMarketFilter();
} }
async function collectMarketPersona(marketId) { async function collectMarketPersona(marketId) {
@@ -460,7 +619,29 @@ async function savePersona() {
refreshMarketBtn.addEventListener("click", () => { refreshMarketBtn.addEventListener("click", () => {
void loadMarketPersonas(); 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); 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) => { marketViewButtons.forEach((button) => {
button.addEventListener("click", () => setMarketView(button.dataset.marketView)); button.addEventListener("click", () => setMarketView(button.dataset.marketView));
}); });
@@ -521,6 +702,8 @@ marketPersonaForm.addEventListener("submit", (event) => {
window.addEventListener("load", async () => { window.addEventListener("load", async () => {
applyTheme(localStorage.getItem("krishd-theme") || "fakegpt"); applyTheme(localStorage.getItem("krishd-theme") || "fakegpt");
const params = new URLSearchParams(window.location.search); 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"); setMarketView(params.get("view") || "ai-characters");
await loadMarketPersonas(); await loadMarketPersonas();
if (params.get("create") === "assistant") { if (params.get("create") === "assistant") {
+344 -149
View File
@@ -1,46 +1,117 @@
const $ = (id) => document.getElementById(id); import {del, get, post, put, stream} from "./app/api.js";
const authDiv = $("auth"), chatDiv = $("chat"), authMsg = $("authMsg"); import {defaultModelProfile, onboardingPrompts, requestedChatId, themes} from "./app/constants.js";
const messagesDiv = $("messages"), modelSelect = $("model"); import {
const modelMenuButton = $("modelMenuButton"), modelPopover = $("modelPopover"), modelBadgeName = $("modelBadgeName"); activeChatTitle,
const modelCount = $("modelCount"); activePersonaStatus,
const modelHelpTitle = $("modelHelpTitle"), modelHelpBadge = $("modelHelpBadge"), modelHelpSummary = $("modelHelpSummary"); activeUserPersonaStatus,
const loginForm = $("loginForm"), registerForm = $("registerForm"), msgInput = $("msgInput"), sendBtn = $("send"); appNotice,
const loginUsernameInput = $("loginUsername"), loginPasswordInput = $("loginPassword"); authDiv,
const registerUsernameInput = $("registerUsername"), registerPasswordInput = $("registerPassword"); authMsg,
const loginSubmit = $("loginSubmit"), registerSubmit = $("registerSubmit"); authScreens,
const chatList = $("chatList"), chatSearchInput = $("chatSearch"), newChatBtn = $("newChat"); chatActionsMenuButton,
const startRoleplayBtn = $("startRoleplay"); chatActionsPopover,
const chatListLoading = $("chatListLoading"), chatListLoadingText = $("chatListLoadingText"); chatActivityDetail,
const renameChatBtn = $("renameChat"), clearChatBtn = $("clearChat"), exportChatBtn = $("exportChat"); chatActivityEyebrow,
const activeChatTitle = $("activeChatTitle"), sessionUser = $("sessionUser"); chatActivityOverlay,
const chatActivityOverlay = $("chatActivityOverlay"), chatActivityEyebrow = $("chatActivityEyebrow"), chatActivityTitle = $("chatActivityTitle"), chatActivityDetail = $("chatActivityDetail"); chatActivityTitle,
const contextChipButton = $("contextChipButton"), contextPopover = $("contextPopover"), contextSummaryLabel = $("contextSummaryLabel"); chatCharacterPill,
const chatActionsMenuButton = $("chatActionsMenuButton"), chatActionsPopover = $("chatActionsPopover"); chatDiv,
const chatModePill = $("chatModePill"), chatCharacterPill = $("chatCharacterPill"), chatUserPersonaPill = $("chatUserPersonaPill"), chatScenePill = $("chatScenePill"); chatList,
const personaList = $("personaList"), userPersonaList = $("userPersonaList"); chatListLoading,
const personaForm = $("personaForm"), personaFormTitle = $("personaFormTitle"), personaTypeSelect = $("personaType"); chatListLoadingText,
const personaNameInput = $("personaName"), personaPronounsInput = $("personaPronouns"), personaAppearanceInput = $("personaAppearance"); chatSidebar,
const personaBackgroundInput = $("personaBackground"), personaDetailsInput = $("personaDetails"); chatDrawerCloseBtn,
const personaExamplesField = $("personaExamplesField"), personaExampleDialoguesInput = $("personaExampleDialogues"); chatModePill,
const personaFormNotice = $("personaFormNotice"); chatSearchInput,
const roleplayNewPersonaBtn = $("roleplayNewPersona"), clearUserPersonaBtn = $("clearUserPersona"); chatUserPersonaPill,
const activePersonaStatus = $("activePersonaStatus"), activeUserPersonaStatus = $("activeUserPersonaStatus"); clearChatBtn,
const personaModal = $("personaModal"), personaCloseBtn = $("personaClose"), personaMenuButton = $("personaMenuButton"), personaPopover = $("personaPopover"); archiveChatBtn,
const roleplayStarterModal = $("roleplayStarterModal"), roleplayStarterClose = $("roleplayStarterClose"), roleplayStarterCancel = $("roleplayStarterCancel"), roleplayStarterConfirm = $("roleplayStarterConfirm"); backupWorkspaceBtn,
const roleplayStarterNotice = $("roleplayStarterNotice"), roleplayCharacterSelect = $("roleplayCharacterSelect"), roleplayUserPersonaSelect = $("roleplayUserPersonaSelect"); clearUserPersonaBtn,
const roleplayStarterTitle = $("roleplayStarterTitle"), roleplayStarterIntroCopy = $("roleplayStarterIntroCopy"), roleplayStarterBack = $("roleplayStarterBack"); contextChipButton,
const roleplayStarterStepOne = $("roleplayStarterStepOne"), roleplayStarterStepTwo = $("roleplayStarterStepTwo"); contextPopover,
const roleplayScenarioInput = $("roleplayScenarioInput"), roleplayStarterSuggestions = $("roleplayStarterSuggestions"); contextSummaryLabel,
const popupModal = $("popupModal"), popupCloseBtn = $("popupClose"), popupCancelBtn = $("popupCancel"), popupConfirmBtn = $("popupConfirm"); exportChatBtn,
const popupTitle = $("popupTitle"), popupEyebrow = $("popupEyebrow"), popupDescription = $("popupDescription"), popupField = $("popupField"); moveChatFolderBtn,
const popupInputLabel = $("popupInputLabel"), popupInput = $("popupInput"); loginForm,
const appNotice = $("appNotice"), quickPrompts = $("quickPrompts"); loginPasswordInput,
const onboardingModal = $("onboardingModal"), onboardingTitle = $("onboardingTitle"), onboardingSubtitle = $("onboardingSubtitle"); loginSubmit,
const onboardingChoices = $("onboardingChoices"), onboardingBack = $("onboardingBack"), onboardingContinue = $("onboardingContinue"), onboardingSkip = $("onboardingSkip"); loginUsernameInput,
const authScreens = document.querySelectorAll(".auth-screen"), toggleButtons = document.querySelectorAll(".auth-toggle .toggle"); logoutButton,
const themeNameTargets = document.querySelectorAll("[data-theme-name]"), themeLogoTargets = document.querySelectorAll("[data-theme-logo]"); messagesDiv,
const modelMenu = document.querySelector(".model-menu"), personaMenu = document.querySelector(".persona-menu"); modelBadgeName,
const requestedChatId = Number(new URLSearchParams(window.location.search).get("chat")) || null; 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 isProcessing = false, activeChatId = null, editingPersonaId = null, editingPersonaType = "assistant", currentUsername = "";
let chatSessions = [], currentMessages = [], assistantPersonas = [], userPersonas = []; let chatSessions = [], currentMessages = [], assistantPersonas = [], userPersonas = [];
@@ -55,38 +126,6 @@ let chatLoadingDepth = 0;
let chatActivityDepth = 0; let chatActivityDepth = 0;
let messageRetryState = new Map(); 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 setAuthMessage(message, state = "") { authMsg.textContent = message; authMsg.className = state ? `status ${state}` : "status"; }
function setNotice(message = "", state = "") { function setNotice(message = "", state = "") {
@@ -166,7 +205,15 @@ function applyWorkspaceMode(nextMode, persist = true) {
if (modelMenu) modelMenu.classList.toggle("hidden", workspaceMode === "basic"); if (modelMenu) modelMenu.classList.toggle("hidden", workspaceMode === "basic");
if (personaMenu) personaMenu.classList.toggle("hidden", workspaceMode === "basic"); if (personaMenu) personaMenu.classList.toggle("hidden", workspaceMode === "basic");
updateComposerPlaceholder(); updateComposerPlaceholder();
renderQuickPrompts(); }
function isMobileLayout() {
return window.matchMedia("(max-width: 820px)").matches;
}
function closeMobileSidebar() {
if (!chatSidebar) return;
document.body.classList.remove("sidebar-open");
} }
function hasCompletedOnboarding() { function hasCompletedOnboarding() {
@@ -286,29 +333,6 @@ function updateRoleplaySuggestions() {
roleplayStarterSuggestions.appendChild(button); 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() { function updateComposerPlaceholder() {
const activeChat = getChatById(activeChatId); const activeChat = getChatById(activeChatId);
if (!activeChat) { if (!activeChat) {
@@ -484,7 +508,16 @@ async function retryLatestAssistantMessage(messageId, fallbackIndex = -1) {
try { try {
const endpoint = getMessageEndpoint(target, "/retry"); const endpoint = getMessageEndpoint(target, "/retry");
if (!endpoint) return setNotice("Message is not ready yet. Try again.", "error"); 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) { if (res.error || !res.message?.content) {
return setNotice(res.error || "Unable to retry message.", "error"); return setNotice(res.error || "Unable to retry message.", "error");
} }
@@ -636,7 +669,6 @@ function renderMessages() {
} }
updateChatParticipants(); updateChatParticipants();
updateChatActionState(); updateChatActionState();
renderQuickPrompts();
updateComposerPlaceholder(); updateComposerPlaceholder();
} }
@@ -671,26 +703,26 @@ function renderMessageEmptyState() {
actions.className = "messages-empty-actions"; actions.className = "messages-empty-actions";
if (!chat) { if (!chat) {
title.textContent = "Start your first conversation"; title.textContent = "Start a chat";
description.textContent = "Create a chat, or jump directly into roleplay."; description.textContent = "Choose a starting point.";
actions.append( actions.append(
createStarterAction("New chat", "primary-action", () => { void createNewChat(); }), createStarterAction("New chat", "primary-action", () => { void createNewChat(); }),
createStarterAction("Start roleplay", "secondary-action", () => { createStarterAction("Roleplay", "secondary-action", () => {
if (assistantPersonas.length) { if (assistantPersonas.length) {
openRoleplayStarter(); openRoleplayStarter();
return; return;
} }
openPersonaForm(null, "assistant"); 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) { } else if (chat.assistant_persona_id) {
title.textContent = "Your scene is ready"; title.textContent = "Roleplay is ready";
description.textContent = "Send your first line, or use a prompt to set the tone."; description.textContent = "Send a line or pick a prompt.";
actions.append( actions.append(
createMessageStarterPrompt("Add tension", "Open with a tense line that raises the stakes immediately."), 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."), 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 { } else {
return; return;
@@ -709,7 +741,6 @@ function updateWorkspaceCopy() {
sessionUser.textContent = currentUsername || "-"; sessionUser.textContent = currentUsername || "-";
} }
updateChatParticipants(); updateChatParticipants();
renderQuickPrompts();
updateComposerPlaceholder(); updateComposerPlaceholder();
} }
@@ -747,12 +778,11 @@ function renderRoleplayStarterStep() {
function updateChatParticipants() { function updateChatParticipants() {
const chat = getChatById(activeChatId); const chat = getChatById(activeChatId);
if (!chatModePill || !chatCharacterPill || !chatUserPersonaPill || !chatScenePill) return; if (!chatModePill || !chatCharacterPill || !chatUserPersonaPill) return;
if (!chat) { if (!chat) {
chatModePill.textContent = "No chat selected"; chatModePill.textContent = "No chat selected";
chatCharacterPill.classList.add("hidden"); chatCharacterPill.classList.add("hidden");
chatUserPersonaPill.classList.add("hidden"); chatUserPersonaPill.classList.add("hidden");
chatScenePill.classList.add("hidden");
if (contextSummaryLabel) contextSummaryLabel.textContent = "None"; if (contextSummaryLabel) contextSummaryLabel.textContent = "None";
return; return;
} }
@@ -767,19 +797,12 @@ function updateChatParticipants() {
chatUserPersonaPill.textContent = "You as: yourself"; chatUserPersonaPill.textContent = "You as: yourself";
chatUserPersonaPill.classList.remove("hidden"); 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"; if (contextSummaryLabel) contextSummaryLabel.textContent = chat.assistant_persona_name || "Roleplay";
return; return;
} }
chatModePill.textContent = "Assistant mode"; chatModePill.textContent = "Assistant mode";
chatCharacterPill.classList.add("hidden"); chatCharacterPill.classList.add("hidden");
chatUserPersonaPill.classList.add("hidden"); chatUserPersonaPill.classList.add("hidden");
chatScenePill.classList.add("hidden");
if (contextSummaryLabel) contextSummaryLabel.textContent = "Assistant"; if (contextSummaryLabel) contextSummaryLabel.textContent = "Assistant";
} }
@@ -864,8 +887,18 @@ function updateChatActionState() {
const activeChat = getChatById(activeChatId); const activeChat = getChatById(activeChatId);
const hasChat = Boolean(activeChat); const hasChat = Boolean(activeChat);
renameChatBtn.disabled = !hasChat || Boolean(activeChat?.assistant_persona_id); 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; clearChatBtn.disabled = !hasChat;
exportChatBtn.disabled = !hasChat || currentMessages.length === 0; exportChatBtn.disabled = !hasChat || currentMessages.length === 0;
if (backupWorkspaceBtn) backupWorkspaceBtn.disabled = !currentUsername;
} }
function maybeShowPersonaNudge() { function maybeShowPersonaNudge() {
@@ -878,7 +911,9 @@ function maybeShowPersonaNudge() {
function renderChatList() { function renderChatList() {
const query = chatSearchInput.value.trim().toLowerCase(); 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 = ""; chatList.innerHTML = "";
if (!filtered.length) { if (!filtered.length) {
const empty = document.createElement("p"); const empty = document.createElement("p");
@@ -887,7 +922,31 @@ function renderChatList() {
chatList.appendChild(empty); chatList.appendChild(empty);
return; return;
} }
filtered.forEach((chat) => { 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"); 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" : ""}`; item.className = `chat-item${chat.id === activeChatId ? " active" : ""}`;
content.className = "chat-item-copy"; content.className = "chat-item-copy";
@@ -896,12 +955,18 @@ function renderChatList() {
meta.textContent = chat.assistant_persona_id meta.textContent = chat.assistant_persona_id
? `Roleplay${chat.user_persona_name ? ` · ${chat.user_persona_name}` : ""}` ? `Roleplay${chat.user_persona_name ? ` · ${chat.user_persona_name}` : ""}`
: "Assistant chat"; : "Assistant chat";
remove.type = "button"; remove.textContent = "Delete"; remove.type = "button";
remove.addEventListener("click", async (event) => { event.stopPropagation(); await deleteChat(chat.id); }); remove.textContent = "Delete";
remove.addEventListener("click", async (event) => {
event.stopPropagation();
await deleteChat(chat.id);
});
content.append(title, meta); content.append(title, meta);
item.append(content, remove); item.append(content, remove);
item.addEventListener("click", () => setActiveChat(chat.id)); item.addEventListener("click", () => setActiveChat(chat.id));
chatList.appendChild(item); 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 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() { function closeContextPopover() {
if (!contextPopover || !contextChipButton) return; if (!contextPopover || !contextChipButton) return;
contextPopover.classList.add("hidden"); contextPopover.classList.add("hidden");
@@ -955,6 +1024,7 @@ function closeChatActionsPopover() {
if (!chatActionsPopover || !chatActionsMenuButton) return; if (!chatActionsPopover || !chatActionsMenuButton) return;
chatActionsPopover.classList.add("hidden"); chatActionsPopover.classList.add("hidden");
chatActionsMenuButton.setAttribute("aria-expanded", "false"); chatActionsMenuButton.setAttribute("aria-expanded", "false");
document.body.classList.remove("chat-drawer-open");
} }
function closeAuxiliaryPopovers() { function closeAuxiliaryPopovers() {
closeModelPopover(); closeModelPopover();
@@ -1037,9 +1107,9 @@ function goToRoleplayStarterSceneStep() {
if (roleplayScenarioInput) roleplayScenarioInput.focus(); if (roleplayScenarioInput) roleplayScenarioInput.focus();
} }
function closeModelPopover() { function closeModelPopover() {
if (!modelPopover || !modelMenuButton) return; if (!modelPopover || modelPopover.dataset.staticPanel === "true") return;
modelPopover.classList.add("hidden"); 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 isPopupOpen() { return popupModal && !popupModal.classList.contains("hidden"); }
function finishPopup(result) { function finishPopup(result) {
@@ -1231,6 +1301,7 @@ function showAuthScreen(target) {
} }
async function setActiveChat(id) { async function setActiveChat(id) {
if (isMobileLayout()) closeMobileSidebar();
setChatLoading(true, "Opening chat..."); setChatLoading(true, "Opening chat...");
setChatActivity(true, { setChatActivity(true, {
eyebrow: "Opening chat", eyebrow: "Opening chat",
@@ -1256,7 +1327,8 @@ async function setActiveChat(id) {
async function loadChatSessions() { async function loadChatSessions() {
setChatLoading(true, "Loading chats..."); 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) { if (res.error) {
setChatLoading(false); setChatLoading(false);
return setNotice(res.error, "error"); return setNotice(res.error, "error");
@@ -1354,6 +1426,23 @@ async function renameChat(id) {
chat.title = title; updateWorkspaceCopy(); renderChatList(); setNotice("Chat renamed.", "success"); 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) { async function clearChat(id) {
const chat = getChatById(id); const chat = getChatById(id);
if (!chat) return; if (!chat) return;
@@ -1374,11 +1463,46 @@ async function clearChat(id) {
function exportCurrentChat() { function exportCurrentChat() {
const chat = getChatById(activeChatId); const chat = getChatById(activeChatId);
if (!chat || !currentMessages.length) return; if (!chat || !currentMessages.length) return;
const lines = [`Chat: ${chat.title}`, `Exported: ${new Date().toISOString()}`, ""]; void (async () => {
currentMessages.forEach((msg) => { lines.push(`${msg.role === "user" ? "You" : "Assistant"}:`); lines.push(msg.content, ""); }); const format = await promptPopup({
const blob = new Blob([lines.join("\n")], {type: "text/plain;charset=utf-8"}), url = URL.createObjectURL(blob), link = document.createElement("a"); eyebrow: "Export",
link.href = url; link.download = `${(chat.title.replace(/[^a-z0-9-_]+/gi, "-").replace(/^-+|-+$/g, "") || "chat")}.txt`; title: "Export format",
document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(url); setNotice("Chat exported.", "success"); 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) { async function deleteChat(id) {
@@ -1479,19 +1603,30 @@ async function sendMessage() {
title: "Generating response", title: "Generating response",
detail: "The assistant is reading your message and preparing a reply." detail: "The assistant is reading your message and preparing a reply."
}); setNotice("Generating 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 { 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 (messagesDiv.contains(loadingMsg)) messagesDiv.removeChild(loadingMsg);
if (res.reply) { if (streamError) {
addMessage(`[Error: ${streamError}]`);
setNotice(streamError, "error");
return;
}
await setActiveChat(activeChatId); await setActiveChat(activeChatId);
await loadSummary(); await loadSummary();
markOnboardingCompleted(); markOnboardingCompleted();
maybeShowPersonaNudge(); maybeShowPersonaNudge();
setNotice("Reply received.", "success"); setNotice("Reply received.", "success");
} else {
addMessage(`[Error: ${res.error}]`); setNotice(res.error || "Failed to generate reply.", "error");
}
} catch { } catch {
if (messagesDiv.contains(loadingMsg)) messagesDiv.removeChild(loadingMsg); if (messagesDiv.contains(loadingMsg)) messagesDiv.removeChild(loadingMsg);
addMessage("[Error: Network error]"); setNotice("Network error while sending message.", "error"); addMessage("[Error: Network error]"); setNotice("Network error while sending message.", "error");
@@ -1537,15 +1672,18 @@ function toggleChatActionsPopover(event) {
closeModelPopover(); closeModelPopover();
closePersonaPopover(); closePersonaPopover();
closeContextPopover(); closeContextPopover();
if (isMobileLayout()) closeMobileSidebar();
const isHidden = chatActionsPopover.classList.contains("hidden"); const isHidden = chatActionsPopover.classList.contains("hidden");
chatActionsPopover.classList.toggle("hidden", !isHidden); chatActionsPopover.classList.toggle("hidden", !isHidden);
chatActionsMenuButton.setAttribute("aria-expanded", String(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))); 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()}); }); 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()}); }); 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"; await post("/logout", {}); chatDiv.style.display = "none"; authDiv.style.display = "grid";
activeChatId = null; currentUsername = ""; currentSummary = null; chatSessions = []; currentMessages = []; assistantPersonas = []; userPersonas = []; activeChatId = null; currentUsername = ""; currentSummary = null; chatSessions = []; currentMessages = []; assistantPersonas = []; userPersonas = [];
messageRetryState = new Map(); messageRetryState = new Map();
@@ -1561,17 +1699,64 @@ msgInput.addEventListener("input", function () {
this.style.height = `${Math.min(this.scrollHeight, 180)}px`; this.style.height = `${Math.min(this.scrollHeight, 180)}px`;
updateSendState(); updateSendState();
}); });
newChatBtn.addEventListener("click", () => { void createNewChat(); }); newChatBtn.addEventListener("click", () => {
if (startRoleplayBtn) { closeChatActionsPopover();
startRoleplayBtn.addEventListener("click", () => { closeMobileSidebar();
window.location.href = "/market?view=ai-characters"; 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"
}); });
} if (folderName === null) return;
renameChatBtn.addEventListener("click", () => { if (activeChatId) void renameChat(activeChatId); }); void updateChatOrganization(chat.id, {folderName: String(folderName || "").trim() || null});
clearChatBtn.addEventListener("click", () => { if (activeChatId) void clearChat(activeChatId); }); exportChatBtn.addEventListener("click", exportCurrentChat); });
chatSearchInput.addEventListener("input", renderChatList); modelSelect.addEventListener("change", () => { updateModelHelp(); updateContextRail(); }); 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")); 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); personaForm.addEventListener("submit", (event) => { event.preventDefault(); void savePersona(); }); personaCloseBtn.addEventListener("click", closePersonaForm);
personaTypeSelect.addEventListener("change", () => { personaTypeSelect.addEventListener("change", () => {
const isAssistant = personaTypeSelect.value === "assistant"; const isAssistant = personaTypeSelect.value === "assistant";
@@ -1625,6 +1810,9 @@ if (chatActionsMenuButton && chatActionsPopover) {
chatActionsMenuButton.addEventListener("click", toggleChatActionsPopover); chatActionsMenuButton.addEventListener("click", toggleChatActionsPopover);
chatActionsPopover.addEventListener("click", (event) => event.stopPropagation()); chatActionsPopover.addEventListener("click", (event) => event.stopPropagation());
} }
if (chatSidebar) {
chatSidebar.addEventListener("click", (event) => event.stopPropagation());
}
if (onboardingContinue) { if (onboardingContinue) {
onboardingContinue.addEventListener("click", () => { onboardingContinue.addEventListener("click", () => {
onboardingStep = 2; onboardingStep = 2;
@@ -1645,10 +1833,17 @@ if (onboardingModal) {
if (event.target === onboardingModal) markOnboardingCompleted(); if (event.target === onboardingModal) markOnboardingCompleted();
}); });
} }
document.addEventListener("click", () => { closeAuxiliaryPopovers(); }); document.addEventListener("click", () => {
closeAuxiliaryPopovers();
closeMobileSidebar();
});
window.addEventListener("resize", () => {
if (!isMobileLayout()) closeMobileSidebar();
});
document.addEventListener("keydown", (event) => { document.addEventListener("keydown", (event) => {
if (event.key === "Escape") { if (event.key === "Escape") {
closeAuxiliaryPopovers(); closeAuxiliaryPopovers();
closeMobileSidebar();
closeRoleplayStarter(); closeRoleplayStarter();
closePersonaForm(); closePersonaForm();
closePopup(false); closePopup(false);
+27 -6
View File
@@ -15,7 +15,6 @@
<div> <div>
<p class="eyebrow">Settings</p> <p class="eyebrow">Settings</p>
<h1>Preferences</h1> <h1>Preferences</h1>
<p class="subtitle">Manage your account, AI Characters, user personas, and appearance.</p>
</div> </div>
<div class="market-header-actions"> <div class="market-header-actions">
<a href="/" class="secondary-action">Back to chat</a> <a href="/" class="secondary-action">Back to chat</a>
@@ -27,9 +26,9 @@
<section class="settings-layout"> <section class="settings-layout">
<aside class="settings-nav" aria-label="Settings sections"> <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 active" type="button" data-settings-view="personal">Account</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="my-roleplay-companions">Personas</button>
<button class="settings-nav-item" type="button" data-settings-view="design">Design</button> <button class="settings-nav-item" type="button" data-settings-view="design">Appearance</button>
</aside> </aside>
<div class="settings-content"> <div class="settings-content">
@@ -88,8 +87,7 @@
<div class="settings-section-header"> <div class="settings-section-header">
<div> <div>
<p class="eyebrow">Create & manage</p> <p class="eyebrow">Create & manage</p>
<h2>My Roleplay Companions</h2> <h2>My personas</h2>
<p class="subtitle">Build characters for roleplay chats and publish them to the market when they are ready.</p>
</div> </div>
<div class="market-header-actions action-pair"> <div class="market-header-actions action-pair">
<button id="createAiCharacter" type="button" class="secondary-action">Create AI Character</button> <button id="createAiCharacter" type="button" class="secondary-action">Create AI Character</button>
@@ -197,6 +195,29 @@
</form> </form>
</div> </div>
</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> <script src="settings.js" defer></script>
</body> </body>
</html> </html>
+106 -1
View File
@@ -30,6 +30,16 @@ const settingsPersonaBackground = $("settingsPersonaBackground");
const settingsPersonaDetails = $("settingsPersonaDetails"); const settingsPersonaDetails = $("settingsPersonaDetails");
const settingsPersonaExamplesField = $("settingsPersonaExamplesField"); const settingsPersonaExamplesField = $("settingsPersonaExamplesField");
const settingsPersonaExampleDialogues = $("settingsPersonaExampleDialogues"); 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 = { const themes = {
"fakegpt": {name: "FakeGPT"}, "fakegpt": {name: "FakeGPT"},
@@ -44,6 +54,7 @@ let assistantPersonas = [];
let userPersonas = []; let userPersonas = [];
let publishedPersonaIds = new Set(); let publishedPersonaIds = new Set();
let editingPersonaId = null; let editingPersonaId = null;
let settingsPopupResolver = null;
async function request(url, data, method = "POST") { async function request(url, data, method = "POST") {
try { try {
@@ -78,6 +89,39 @@ function setPersonaNotice(message = "", state = "") {
settingsPersonaFormNotice.className = state ? `status ${state}` : "status"; 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) { function applyTheme(themeKey, persist = true) {
const nextTheme = themes[themeKey] ? themeKey : "fakegpt"; const nextTheme = themes[themeKey] ? themeKey : "fakegpt";
document.body.dataset.theme = nextTheme; document.body.dataset.theme = nextTheme;
@@ -169,6 +213,8 @@ function renderPersonaList(personas, listElement, type) {
[ [
["Edit", () => openPersonaModal(persona)], ["Edit", () => openPersonaModal(persona)],
["Clone", () => clonePersona(persona.id)],
["History", () => showPersonaHistory(persona.id)],
[publishedPersonaIds.has(persona.id) ? "Update listing" : "Publish", () => publishPersona(persona.id)], [publishedPersonaIds.has(persona.id) ? "Update listing" : "Publish", () => publishPersona(persona.id)],
["Delete", () => deletePersona(persona.id)] ["Delete", () => deletePersona(persona.id)]
] ]
@@ -263,7 +309,17 @@ async function deletePersona(id) {
} }
async function publishPersona(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) { if (res.error) {
setNotice(res.error, "error"); setNotice(res.error, "error");
return; return;
@@ -272,6 +328,39 @@ async function publishPersona(id) {
setNotice("Persona published.", "success"); 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) { async function unpublishPersona(id) {
const res = await post(`/personas/${id}/unpublish`, {}); const res = await post(`/personas/${id}/unpublish`, {});
if (res.error) { if (res.error) {
@@ -347,6 +436,22 @@ settingsPersonaForm.addEventListener("submit", (event) => {
event.preventDefault(); event.preventDefault();
void savePersona(); 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 () => { window.addEventListener("load", async () => {
applyTheme(localStorage.getItem("krishd-theme") || "fakegpt", false); applyTheme(localStorage.getItem("krishd-theme") || "fakegpt", false);
+1313
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
import {execFileSync} from "node:child_process";
const files = [
"server.js",
"src/app.js",
"src/config.js",
"src/db/index.js",
"src/db/repositories.js",
"src/db/schema.js",
"src/middleware/auth.js",
"src/middleware/rateLimit.js",
"src/middleware/validate.js",
"src/routes/auth.js",
"src/routes/chats.js",
"src/routes/pages.js",
"src/routes/personas.js",
"src/routes/settings.js",
"src/routes/system.js",
"src/services/chatService.js",
"src/services/modelService.js",
"src/services/personaService.js",
"public/script.js",
"public/app/api.js",
"public/app/constants.js",
"public/app/dom.js"
];
for (const file of files) {
execFileSync(process.execPath, ["--check", file], {stdio: "inherit"});
}
console.log(`Syntax check passed for ${files.length} files.`);
+2 -2327
View File
File diff suppressed because it is too large Load Diff
+123
View File
@@ -0,0 +1,123 @@
import fs from "fs";
import {performance} from "perf_hooks";
import express from "express";
import session from "express-session";
import FileStoreFactory from "session-file-store";
import {createConfig} from "./config.js";
import {createDatabase} from "./db/index.js";
import {createRepositories} from "./db/repositories.js";
import {createRateLimiter} from "./middleware/rateLimit.js";
import {createModelService} from "./services/modelService.js";
import {createChatService} from "./services/chatService.js";
import {createAuthRouter} from "./routes/auth.js";
import {createChatsRouter} from "./routes/chats.js";
import {createPagesRouter} from "./routes/pages.js";
import {createPersonasRouter} from "./routes/personas.js";
import {createSettingsRouter} from "./routes/settings.js";
import {createSystemRouter} from "./routes/system.js";
export function createApp(options = {}) {
const config = createConfig(options.config);
const app = express();
if (config.dbPath !== ":memory:") {
fs.mkdirSync(config.sessionsDir, {recursive: true});
}
let last = performance.now();
const lagMonitor = setInterval(() => {
const now = performance.now();
const drift = now - last - 1000;
last = now;
if (drift > 50) {
console.log("EVENT LOOP LAG", Math.round(drift), "ms", new Date().toISOString());
}
}, 1000);
lagMonitor.unref();
app.use((req, res, next) => {
const start = Date.now();
res.on("finish", () => {
const ms = Date.now() - start;
if (ms > 50) console.log("SLOW REQ", `${ms}ms`, req.method, req.url);
});
next();
});
const db = createDatabase(config);
const repositories = createRepositories(db, config);
const modelService = options.modelService || createModelService(config, options.modelOverrides);
const chatService = createChatService(repositories, modelService, config);
const FileStore = FileStoreFactory(session);
const authRateLimiters = [
createRateLimiter({
windowMs: config.authRateLimitWindowMs,
max: config.authRateLimitMaxPerIp,
keyGenerator: (req) => `auth:ip:${req.ip}`,
message: "Too many authentication attempts from this IP. Please try again later."
}),
createRateLimiter({
windowMs: config.authRateLimitWindowMs,
max: config.authRateLimitMaxPerAccount,
keyGenerator: (req) => {
const username = String(req.body?.username || "").trim().toLowerCase();
return username ? `auth:user:${username}` : null;
},
message: "Too many authentication attempts for this account. Please try again later."
})
];
const chatRateLimiters = [
createRateLimiter({
windowMs: config.chatRateLimitWindowMs,
max: config.chatRateLimitMaxPerIp,
keyGenerator: (req) => `chat:ip:${req.ip}`,
message: "Too many chat requests from this IP. Please slow down."
}),
createRateLimiter({
windowMs: config.chatRateLimitWindowMs,
max: config.chatRateLimitMaxPerAccount,
keyGenerator: (req) => req.session.user ? `chat:user:${req.session.user}` : null,
message: "Too many chat requests for this account. Please slow down."
})
];
app.use(express.json({limit: "1mb"}));
app.use(express.static(config.publicDir));
app.use(session({
store: config.testMode ? undefined : new FileStore({path: config.sessionsDir, reapInterval: 3600}),
secret: config.sessionSecret,
resave: false,
saveUninitialized: false,
cookie: {
secure: config.cookieSecure,
httpOnly: true,
sameSite: "lax",
maxAge: config.sessionMaxAgeMs
}
}));
app.locals.config = config;
app.locals.db = db;
app.locals.repositories = repositories;
app.locals.modelService = modelService;
app.use(createAuthRouter({repositories, authRateLimiters}));
app.use(createSettingsRouter({repositories}));
app.use(createChatsRouter({repositories, chatService, modelService, config, chatRateLimiters}));
app.use(createPersonasRouter({repositories, chatService, modelService, config}));
app.use(createSystemRouter({modelService}));
app.use(createPagesRouter(config));
return app;
}
export function startServer(options = {}) {
const app = createApp(options);
const {port} = app.locals.config;
const server = app.listen(port, () => {
console.log(`Server running at http://localhost:${port}`);
});
return {app, server};
}
+46
View File
@@ -0,0 +1,46 @@
import path from "path";
const bool = (value, fallback = false) => {
if (value == null || value === "") return fallback;
return ["1", "true", "yes", "on"].includes(String(value).toLowerCase());
};
const int = (value, fallback) => {
const parsed = Number.parseInt(value, 10);
return Number.isInteger(parsed) ? parsed : fallback;
};
export function createConfig(overrides = {}) {
const rootDir = overrides.rootDir || process.cwd();
const dataDir = overrides.dataDir || path.resolve(rootDir, "data");
const sessionsDir = overrides.sessionsDir || path.resolve(rootDir, "sessions");
const config = {
rootDir,
publicDir: path.resolve(rootDir, "public"),
dataDir,
sessionsDir,
port: int(process.env.PORT, 3000),
dbPath: process.env.DB_PATH || path.resolve(dataDir, "app.db"),
sessionSecret: process.env.SESSION_SECRET || "change-me-session-secret",
cookieSecure: bool(process.env.COOKIE_SECURE, false),
sessionMaxAgeMs: int(process.env.SESSION_MAX_AGE_MS, 7 * 24 * 60 * 60 * 1000),
chatLimit: int(process.env.CHAT_LIMIT, 10),
chatHistoryLimit: int(process.env.CHAT_HISTORY_LIMIT, 20),
modelApiBaseUrl: process.env.MODEL_API_BASE_URL || "https://ai.krishd.ch/api",
modelRequestTimeoutMs: int(process.env.MODEL_REQUEST_TIMEOUT_MS, 15000),
modelListTimeoutMs: int(process.env.MODEL_LIST_TIMEOUT_MS, 4000),
modelUnloadAfterMs: int(process.env.MODEL_UNLOAD_AFTER_MS, 30 * 1000),
modelsCacheTtlMs: int(process.env.MODELS_CACHE_TTL_MS, 5 * 60 * 1000),
authRateLimitWindowMs: int(process.env.AUTH_RATE_LIMIT_WINDOW_MS, 15 * 60 * 1000),
authRateLimitMaxPerIp: int(process.env.AUTH_RATE_LIMIT_MAX_PER_IP, 20),
authRateLimitMaxPerAccount: int(process.env.AUTH_RATE_LIMIT_MAX_PER_ACCOUNT, 10),
chatRateLimitWindowMs: int(process.env.CHAT_RATE_LIMIT_WINDOW_MS, 60 * 1000),
chatRateLimitMaxPerIp: int(process.env.CHAT_RATE_LIMIT_MAX_PER_IP, 60),
chatRateLimitMaxPerAccount: int(process.env.CHAT_RATE_LIMIT_MAX_PER_ACCOUNT, 30),
testMode: bool(process.env.TEST_MODE, process.env.NODE_ENV === "test"),
dropLegacyChats: bool(process.env.DROP_LEGACY_CHATS, true)
};
return {...config, ...overrides};
}
+14
View File
@@ -0,0 +1,14 @@
import fs from "fs";
import Database from "better-sqlite3";
import {initializeSchema, runMigrations} from "./schema.js";
export function createDatabase(config) {
if (config.dbPath !== ":memory:") {
fs.mkdirSync(config.dataDir, {recursive: true});
}
const db = new Database(config.dbPath);
initializeSchema(db);
runMigrations(db, config);
return db;
}
+910
View File
@@ -0,0 +1,910 @@
const parseJsonArray = (value) => {
if (!value) return [];
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
};
const buildPersonaSnapshot = (persona) => JSON.stringify({
name: persona.name,
pronouns: persona.pronouns,
appearance: persona.appearance,
background: persona.background,
details: persona.details,
example_dialogues: persona.example_dialogues,
persona_type: persona.persona_type,
source_market_id: persona.source_market_id ?? null,
source_creator_username: persona.source_creator_username ?? null
});
const MARKET_SORT_ORDERS = {
best: "pm.favorite_count DESC, rating_average DESC, pm.usage_count DESC, datetime(pm.created_at) DESC, pm.id DESC",
newest: "datetime(pm.created_at) DESC, pm.id DESC",
most_favorited: "pm.favorite_count DESC, rating_average DESC, pm.usage_count DESC, datetime(pm.created_at) DESC, pm.id DESC",
most_popular: "pm.usage_count DESC, pm.favorite_count DESC, rating_average DESC, datetime(pm.created_at) DESC, pm.id DESC",
top_rated: "rating_average DESC, pm.rating_count DESC, pm.favorite_count DESC, datetime(pm.created_at) DESC, pm.id DESC",
alphabetical: "LOWER(pm.name) ASC, datetime(pm.created_at) DESC, pm.id DESC"
};
function normalizeMarketSort(sort) {
const normalized = String(sort || "best").trim().toLowerCase().replace(/-/g, "_");
return MARKET_SORT_ORDERS[normalized] ? normalized : "best";
}
function normalizeMarketPersonaType(personaType) {
return ["assistant", "user"].includes(personaType) ? personaType : null;
}
export function createRepositories(db, config) {
const statements = {
insertUser: db.prepare("INSERT INTO users (username, password) VALUES (?, ?)"),
getUser: db.prepare("SELECT username, password FROM users WHERE username = ?"),
updateUsername: db.prepare("UPDATE users SET username = ? WHERE username = ?"),
updatePassword: db.prepare("UPDATE users SET password = ? WHERE username = ?"),
insertChatSession: db.prepare(
"INSERT INTO chat_sessions (username, title, assistant_persona_id, user_persona_id, folder_name, is_pinned, archived_at, scenario_prompt, scenario_summary) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
),
listChatSessions: db.prepare(`
SELECT cs.id,
CASE
WHEN cs.assistant_persona_id IS NOT NULL THEN COALESCE(p.name, cs.title)
ELSE cs.title
END AS title,
cs.title AS stored_title,
cs.assistant_persona_id,
p.name AS assistant_persona_name,
cs.user_persona_id,
up.name AS user_persona_name,
cs.folder_name,
cs.is_pinned,
cs.archived_at,
cs.scenario_prompt,
cs.scenario_summary,
cs.created_at,
cs.updated_at
FROM chat_sessions cs
LEFT JOIN personas p ON p.id = cs.assistant_persona_id
LEFT JOIN personas up ON up.id = cs.user_persona_id
WHERE cs.username = ?
ORDER BY cs.is_pinned DESC, datetime(cs.updated_at) DESC
`),
countChatSessions: db.prepare("SELECT COUNT(*) as count FROM chat_sessions WHERE username = ?"),
getChatSession: db.prepare(`
SELECT cs.id,
CASE
WHEN cs.assistant_persona_id IS NOT NULL THEN COALESCE(p.name, cs.title)
ELSE cs.title
END AS title,
cs.title AS stored_title,
cs.assistant_persona_id,
p.name AS assistant_persona_name,
cs.user_persona_id,
up.name AS user_persona_name,
cs.folder_name,
cs.is_pinned,
cs.archived_at,
cs.scenario_prompt,
cs.scenario_summary,
cs.created_at,
cs.updated_at
FROM chat_sessions cs
LEFT JOIN personas p ON p.id = cs.assistant_persona_id
LEFT JOIN personas up ON up.id = cs.user_persona_id
WHERE cs.id = ?
AND cs.username = ?
`),
getChatSessionByParticipants: db.prepare(`
SELECT cs.id,
CASE
WHEN cs.assistant_persona_id IS NOT NULL THEN COALESCE(p.name, cs.title)
ELSE cs.title
END AS title,
cs.title AS stored_title,
cs.assistant_persona_id,
p.name AS assistant_persona_name,
cs.user_persona_id,
up.name AS user_persona_name,
cs.folder_name,
cs.is_pinned,
cs.archived_at,
cs.scenario_prompt,
cs.scenario_summary,
cs.created_at,
cs.updated_at
FROM chat_sessions cs
LEFT JOIN personas p ON p.id = cs.assistant_persona_id
LEFT JOIN personas up ON up.id = cs.user_persona_id
WHERE cs.username = ?
AND cs.assistant_persona_id = ?
AND ((cs.user_persona_id IS NULL AND ? IS NULL) OR cs.user_persona_id = ?)
ORDER BY cs.is_pinned DESC, datetime(cs.updated_at) DESC
LIMIT 1
`),
updateChatSessionTitle: db.prepare("UPDATE chat_sessions SET title = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?"),
updateChatSessionScene: db.prepare("UPDATE chat_sessions SET scenario_prompt = ?, scenario_summary = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?"),
updateChatSessionOrganization: db.prepare(`
UPDATE chat_sessions
SET folder_name = ?,
is_pinned = ?,
archived_at = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
AND username = ?
`),
touchChatSession: db.prepare("UPDATE chat_sessions SET updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ?"),
deleteChatSession: db.prepare("DELETE FROM chat_sessions WHERE id = ? AND username = ?"),
clearAssistantPersonaFromChats: db.prepare(`
UPDATE chat_sessions
SET assistant_persona_id = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE username = ?
AND assistant_persona_id = ?
`),
clearUserPersonaFromChats: db.prepare(`
UPDATE chat_sessions
SET user_persona_id = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE username = ?
AND user_persona_id = ?
`),
insertChatMessage: db.prepare("INSERT INTO chat_messages (chat_id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"),
getChatMessages: db.prepare(`
SELECT id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id, created_at
FROM chat_messages
WHERE chat_id = ?
ORDER BY id
`),
getRecentChatMessages: db.prepare(`
SELECT role, content
FROM chat_messages
WHERE chat_id = ?
ORDER BY id DESC
LIMIT ?
`),
getRecentChatMessagesUpToId: db.prepare(`
SELECT role, content
FROM chat_messages
WHERE chat_id = ?
AND id <= ?
ORDER BY id DESC
LIMIT ?
`),
getChatMessageById: db.prepare(`
SELECT id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id, created_at
FROM chat_messages
WHERE id = ?
AND chat_id = ?
`),
getChatMessageByIndex: db.prepare(`
SELECT id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id, created_at
FROM chat_messages
WHERE chat_id = ?
ORDER BY id
LIMIT 1 OFFSET ?
`),
getLatestChatMessage: db.prepare(`
SELECT id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id, created_at
FROM chat_messages
WHERE chat_id = ?
ORDER BY id DESC
LIMIT 1
`),
getPreviousUserMessage: db.prepare(`
SELECT id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id, created_at
FROM chat_messages
WHERE chat_id = ?
AND role = 'user'
AND id < ?
ORDER BY id DESC
LIMIT 1
`),
updateChatMessage: db.prepare("UPDATE chat_messages SET content = ? WHERE id = ? AND chat_id = ?"),
updateChatMessageWithRetryState: db.prepare("UPDATE chat_messages SET content = ?, retry_variants = ?, retry_active_index = ?, retry_retries_used = ?, retry_prompt_message_id = ? WHERE id = ? AND chat_id = ?"),
deleteChatMessage: db.prepare("DELETE FROM chat_messages WHERE id = ? AND chat_id = ?"),
deleteChatMessages: db.prepare("DELETE FROM chat_messages WHERE chat_id = ?"),
searchChats: db.prepare(`
SELECT DISTINCT cs.id,
CASE
WHEN cs.assistant_persona_id IS NOT NULL THEN COALESCE(p.name, cs.title)
ELSE cs.title
END AS title,
cs.title AS stored_title,
cs.assistant_persona_id,
p.name AS assistant_persona_name,
cs.user_persona_id,
up.name AS user_persona_name,
cs.folder_name,
cs.is_pinned,
cs.archived_at,
cs.scenario_prompt,
cs.scenario_summary,
cs.created_at,
cs.updated_at,
cm.content AS matched_content
FROM chat_sessions cs
LEFT JOIN personas p ON p.id = cs.assistant_persona_id
LEFT JOIN personas up ON up.id = cs.user_persona_id
LEFT JOIN chat_messages cm ON cm.chat_id = cs.id
WHERE cs.username = ?
AND (
LOWER(CASE
WHEN cs.assistant_persona_id IS NOT NULL THEN COALESCE(p.name, cs.title)
ELSE cs.title
END) LIKE ?
OR LOWER(COALESCE(cm.content, '')) LIKE ?
)
ORDER BY cs.is_pinned DESC, datetime(cs.updated_at) DESC
`),
listPersonasByType: db.prepare(`
SELECT id, name, pronouns, appearance, background, details, example_dialogues, persona_type,
source_market_id, source_creator_username, created_at, updated_at
FROM personas
WHERE username = ?
AND persona_type = ?
ORDER BY datetime(updated_at) DESC
`),
listAllPersonas: db.prepare(`
SELECT id, username, name, pronouns, appearance, background, details, example_dialogues, persona_type,
source_market_id, source_creator_username, created_at, updated_at
FROM personas
WHERE username = ?
ORDER BY datetime(updated_at) DESC
`),
getPersona: db.prepare(`
SELECT id, name, pronouns, appearance, background, details, example_dialogues, persona_type,
source_market_id, source_creator_username, created_at, updated_at
FROM personas
WHERE id = ?
AND username = ?
`),
insertPersona: db.prepare("INSERT INTO personas (username, name, pronouns, appearance, background, details, example_dialogues, persona_type) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"),
insertMarketPersona: db.prepare("INSERT INTO personas (username, name, pronouns, appearance, background, details, example_dialogues, persona_type, source_market_id, source_creator_username) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"),
getPersonaBySourceMarket: db.prepare(`
SELECT id
FROM personas
WHERE username = ?
AND source_market_id = ?
`),
updatePersona: db.prepare(`
UPDATE personas
SET name = ?,
pronouns = ?,
appearance = ?,
background = ?,
details = ?,
example_dialogues = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
AND username = ?
`),
deletePersona: db.prepare("DELETE FROM personas WHERE id = ? AND username = ?"),
getNextPersonaVersionNumber: db.prepare("SELECT COALESCE(MAX(version_number), 0) + 1 AS nextVersion FROM persona_versions WHERE persona_id = ?"),
insertPersonaVersion: db.prepare("INSERT INTO persona_versions (persona_id, username, version_number, snapshot_json) VALUES (?, ?, ?, ?)"),
listPersonaVersions: db.prepare(`
SELECT id, persona_id, username, version_number, snapshot_json, created_at
FROM persona_versions
WHERE persona_id = ?
AND username = ?
ORDER BY version_number DESC
`),
getPersonaVersion: db.prepare(`
SELECT id, persona_id, username, version_number, snapshot_json, created_at
FROM persona_versions
WHERE id = ?
AND persona_id = ?
AND username = ?
`),
getActivePersonaId: db.prepare("SELECT active_persona_id, active_user_persona_id FROM user_settings WHERE username = ?"),
setActiveUserPersona: db.prepare(`
INSERT INTO user_settings (username, active_user_persona_id)
VALUES (?, ?)
ON CONFLICT(username) DO UPDATE SET active_user_persona_id = excluded.active_user_persona_id
`),
getActiveUserPersona: db.prepare(`
SELECT p.id, p.name, p.pronouns, p.appearance, p.background, p.details, p.example_dialogues
FROM user_settings us
JOIN personas p ON p.id = us.active_user_persona_id
WHERE us.username = ?
`),
getAssistantPersonaForChat: db.prepare(`
SELECT p.id, p.name, p.pronouns, p.appearance, p.background, p.details, p.example_dialogues
FROM chat_sessions cs
JOIN personas p ON p.id = cs.assistant_persona_id
WHERE cs.id = ?
AND cs.username = ?
`),
getUserPersonaForChat: db.prepare(`
SELECT p.id, p.name, p.pronouns, p.appearance, p.background, p.details, p.example_dialogues
FROM chat_sessions cs
JOIN personas p ON p.id = cs.user_persona_id
WHERE cs.id = ?
AND cs.username = ?
`),
listMarketPersonas: db.prepare(`
SELECT pm.id, pm.persona_id, pm.creator_username, pm.name, pm.pronouns, pm.appearance, pm.background, pm.details,
pm.example_dialogues, pm.persona_type, pm.tags, pm.usage_count, pm.favorite_count, pm.rating_total, pm.rating_count,
pm.moderation_status, pm.moderation_notes, pm.created_at, pm.updated_at, pm.soft_deleted_at,
CASE WHEN pm.rating_count > 0 THEN CAST(pm.rating_total AS REAL) / pm.rating_count ELSE 0 END AS rating_average
FROM persona_market pm
WHERE pm.soft_deleted_at IS NULL
AND pm.moderation_status != 'rejected'
ORDER BY pm.favorite_count DESC, rating_average DESC, datetime(pm.updated_at) DESC
`),
listMarketPersonasAdmin: db.prepare(`
SELECT pm.id, pm.persona_id, pm.creator_username, pm.name, pm.pronouns, pm.appearance, pm.background, pm.details,
pm.example_dialogues, pm.persona_type, pm.tags, pm.usage_count, pm.favorite_count, pm.rating_total, pm.rating_count,
pm.moderation_status, pm.moderation_notes, pm.created_at, pm.updated_at, pm.soft_deleted_at,
CASE WHEN pm.rating_count > 0 THEN CAST(pm.rating_total AS REAL) / pm.rating_count ELSE 0 END AS rating_average
FROM persona_market pm
ORDER BY datetime(pm.updated_at) DESC
`),
getMarketPersona: db.prepare(`
SELECT pm.id, pm.persona_id, pm.creator_username, pm.name, pm.pronouns, pm.appearance, pm.background, pm.details,
pm.example_dialogues, pm.persona_type, pm.tags, pm.usage_count, pm.favorite_count, pm.rating_total, pm.rating_count,
pm.moderation_status, pm.moderation_notes, pm.soft_deleted_at,
CASE WHEN pm.rating_count > 0 THEN CAST(pm.rating_total AS REAL) / pm.rating_count ELSE 0 END AS rating_average
FROM persona_market pm
WHERE pm.id = ?
`),
getMarketPersonaByPersonaId: db.prepare(`
SELECT id, persona_id, creator_username, name, pronouns, appearance, background, details,
example_dialogues, persona_type, tags, usage_count, favorite_count, rating_total, rating_count,
moderation_status, moderation_notes, soft_deleted_at
FROM persona_market
WHERE persona_id = ?
AND creator_username = ?
`),
upsertMarketPersona: db.prepare(`
INSERT INTO persona_market (persona_id, creator_username, name, pronouns, appearance, background, details, example_dialogues, persona_type, tags)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(persona_id, creator_username) DO UPDATE SET
name = excluded.name,
pronouns = excluded.pronouns,
appearance = excluded.appearance,
background = excluded.background,
details = excluded.details,
example_dialogues = excluded.example_dialogues,
persona_type = excluded.persona_type,
tags = excluded.tags,
moderation_status = 'approved',
moderation_notes = NULL,
soft_deleted_at = NULL,
updated_at = CURRENT_TIMESTAMP
`),
listPublishedPersonaIds: db.prepare("SELECT persona_id FROM persona_market WHERE creator_username = ? AND soft_deleted_at IS NULL"),
insertMarketFavorite: db.prepare("INSERT OR IGNORE INTO persona_market_favorites (market_id, username) VALUES (?, ?)"),
deleteMarketFavorite: db.prepare("DELETE FROM persona_market_favorites WHERE market_id = ? AND username = ?"),
isMarketFavorite: db.prepare("SELECT 1 AS favorite FROM persona_market_favorites WHERE market_id = ? AND username = ?"),
recountMarketFavorites: db.prepare("UPDATE persona_market SET favorite_count = (SELECT COUNT(*) FROM persona_market_favorites WHERE market_id = ?) WHERE id = ?"),
upsertMarketRating: db.prepare(`
INSERT INTO persona_market_ratings (market_id, username, rating)
VALUES (?, ?, ?)
ON CONFLICT(market_id, username) DO UPDATE SET
rating = excluded.rating,
updated_at = CURRENT_TIMESTAMP
`),
recountMarketRatings: db.prepare(`
UPDATE persona_market
SET rating_total = COALESCE((SELECT SUM(rating) FROM persona_market_ratings WHERE market_id = ?), 0),
rating_count = COALESCE((SELECT COUNT(*) FROM persona_market_ratings WHERE market_id = ?), 0)
WHERE id = ?
`),
insertMarketReport: db.prepare("INSERT INTO persona_market_reports (market_id, reporter_username, reason, details) VALUES (?, ?, ?, ?)"),
listMarketReports: db.prepare(`
SELECT id, market_id, reporter_username, reason, details, status, created_at, updated_at
FROM persona_market_reports
ORDER BY CASE WHEN status = 'open' THEN 0 ELSE 1 END, datetime(created_at) DESC
`),
updateMarketReportStatus: db.prepare("UPDATE persona_market_reports SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"),
updateMarketModeration: db.prepare(`
UPDATE persona_market
SET moderation_status = ?,
moderation_notes = ?,
soft_deleted_at = CASE WHEN ? THEN COALESCE(soft_deleted_at, CURRENT_TIMESTAMP) ELSE NULL END,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`),
insertPromptTemplate: db.prepare(`
INSERT INTO prompt_templates (username, persona_id, name, description, category, prompt_text, starter_text, is_shared)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`),
getPromptTemplate: db.prepare(`
SELECT id, username, persona_id, name, description, category, prompt_text, starter_text, is_shared, created_at, updated_at
FROM prompt_templates
WHERE id = ?
`),
updatePromptTemplate: db.prepare(`
UPDATE prompt_templates
SET name = ?,
description = ?,
category = ?,
prompt_text = ?,
starter_text = ?,
is_shared = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
AND (username = ? OR (username IS NULL AND ? = 'system'))
`),
deletePromptTemplate: db.prepare("DELETE FROM prompt_templates WHERE id = ? AND username = ?"),
listPromptTemplates: db.prepare(`
SELECT id, username, persona_id, name, description, category, prompt_text, starter_text, is_shared, created_at, updated_at
FROM prompt_templates
WHERE username = ?
OR (is_shared = 1)
OR (username IS NULL)
ORDER BY is_shared DESC, datetime(updated_at) DESC
`),
listPersonaPromptTemplates: db.prepare(`
SELECT id, username, persona_id, name, description, category, prompt_text, starter_text, is_shared, created_at, updated_at
FROM prompt_templates
WHERE (persona_id = ? AND (username = ? OR username IS NULL OR is_shared = 1))
OR (persona_id IS NULL AND (username = ? OR username IS NULL OR is_shared = 1))
ORDER BY is_shared DESC, datetime(updated_at) DESC
`),
getChatMessageCountForUser: db.prepare(`
SELECT COUNT(*) as count
FROM chat_messages cm
JOIN chat_sessions cs ON cs.id = cm.chat_id
WHERE cs.username = ?
`),
getPersonaCountForUser: db.prepare("SELECT COUNT(*) as count FROM personas WHERE username = ?"),
getPublishedPersonaCountForUser: db.prepare("SELECT COUNT(*) as count FROM persona_market WHERE creator_username = ? AND soft_deleted_at IS NULL"),
getRecentActivity: db.prepare(`
SELECT cs.id AS chat_id,
cs.title,
MAX(cm.created_at) AS last_message_at,
COUNT(cm.id) AS message_count
FROM chat_sessions cs
LEFT JOIN chat_messages cm ON cm.chat_id = cs.id
WHERE cs.username = ?
GROUP BY cs.id
ORDER BY datetime(MAX(COALESCE(cm.created_at, cs.updated_at))) DESC
LIMIT 5
`),
getMostUsedPersonas: db.prepare(`
SELECT p.id, p.name, COUNT(cs.id) AS usage_count
FROM personas p
LEFT JOIN chat_sessions cs ON cs.assistant_persona_id = p.id OR cs.user_persona_id = p.id
WHERE p.username = ?
GROUP BY p.id
ORDER BY usage_count DESC, datetime(p.updated_at) DESC
LIMIT 5
`),
getAverageChatLength: db.prepare(`
SELECT AVG(message_count) AS average_length
FROM (
SELECT COUNT(cm.id) AS message_count
FROM chat_sessions cs
LEFT JOIN chat_messages cm ON cm.chat_id = cs.id
WHERE cs.username = ?
GROUP BY cs.id
)
`),
getFavoriteModel: db.prepare(`
SELECT model_name, COUNT(*) AS usage_count
FROM chat_messages cm
JOIN chat_sessions cs ON cs.id = cm.chat_id
WHERE cs.username = ?
AND cm.model_name IS NOT NULL
GROUP BY model_name
ORDER BY usage_count DESC
LIMIT 1
`),
getCollectedPersonaCount: db.prepare("SELECT COUNT(*) AS count FROM personas WHERE username = ? AND source_market_id IS NOT NULL"),
getPublishedPersonaUsage: db.prepare(`
SELECT COUNT(*) AS published_count, COALESCE(SUM(usage_count), 0) AS usage_total
FROM persona_market
WHERE creator_username = ?
AND soft_deleted_at IS NULL
`),
updateChatSessionsUsername: db.prepare("UPDATE chat_sessions SET username = ? WHERE username = ?"),
updatePersonasUsername: db.prepare("UPDATE personas SET username = ? WHERE username = ?"),
updatePersonasSourceCreator: db.prepare("UPDATE personas SET source_creator_username = ? WHERE source_creator_username = ?"),
updateUserSettingsUsername: db.prepare("UPDATE user_settings SET username = ? WHERE username = ?"),
updatePersonaMarketCreator: db.prepare("UPDATE persona_market SET creator_username = ? WHERE creator_username = ?"),
updatePersonaVersionsUsername: db.prepare("UPDATE persona_versions SET username = ? WHERE username = ?"),
updatePromptTemplatesUsername: db.prepare("UPDATE prompt_templates SET username = ? WHERE username = ?"),
updateMarketFavoritesUsername: db.prepare("UPDATE persona_market_favorites SET username = ? WHERE username = ?"),
updateMarketRatingsUsername: db.prepare("UPDATE persona_market_ratings SET username = ? WHERE username = ?"),
updateMarketReportsUsername: db.prepare("UPDATE persona_market_reports SET reporter_username = ? WHERE reporter_username = ?"),
deleteMarketPersonaByPersonaId: db.prepare("DELETE FROM persona_market WHERE persona_id = ? AND creator_username = ?"),
incrementMarketUsageCount: db.prepare("UPDATE persona_market SET usage_count = usage_count + 1 WHERE id = ?")
};
const renameUserTransaction = db.transaction((currentUsername, nextUsername) => {
statements.updateUsername.run(nextUsername, currentUsername);
statements.updateChatSessionsUsername.run(nextUsername, currentUsername);
statements.updatePersonasUsername.run(nextUsername, currentUsername);
statements.updatePersonasSourceCreator.run(nextUsername, currentUsername);
statements.updateUserSettingsUsername.run(nextUsername, currentUsername);
statements.updatePersonaMarketCreator.run(nextUsername, currentUsername);
statements.updatePersonaVersionsUsername.run(nextUsername, currentUsername);
statements.updatePromptTemplatesUsername.run(nextUsername, currentUsername);
statements.updateMarketFavoritesUsername.run(nextUsername, currentUsername);
statements.updateMarketRatingsUsername.run(nextUsername, currentUsername);
statements.updateMarketReportsUsername.run(nextUsername, currentUsername);
});
const savePersonaVersion = db.transaction((personaId, username) => {
const persona = statements.getPersona.get(personaId, username);
if (!persona) return null;
const versionNumber = statements.getNextPersonaVersionNumber.get(personaId).nextVersion;
statements.insertPersonaVersion.run(personaId, username, versionNumber, buildPersonaSnapshot(persona));
return versionNumber;
});
const clonePersonaTransaction = db.transaction((personaId, username) => {
const source = statements.getPersona.get(personaId, username);
if (!source) return null;
const cloneId = statements.insertPersona.run(
username,
`${source.name} Copy`,
source.pronouns,
source.appearance,
source.background,
source.details,
source.example_dialogues,
source.persona_type
).lastInsertRowid;
const clone = statements.getPersona.get(cloneId, username);
statements.insertPersonaVersion.run(cloneId, username, 1, buildPersonaSnapshot(clone));
return clone;
});
const importWorkspaceTransaction = db.transaction((username, workspace) => {
const imported = {personas: 0, chats: 0, templates: 0};
for (const persona of workspace.personas || []) {
const personaId = statements.insertPersona.run(
username,
persona.name,
persona.pronouns ?? null,
persona.appearance ?? null,
persona.background ?? null,
persona.details ?? null,
persona.example_dialogues ?? null,
persona.persona_type || "assistant"
).lastInsertRowid;
statements.insertPersonaVersion.run(personaId, username, 1, JSON.stringify(persona));
imported.personas += 1;
}
for (const template of workspace.templates || []) {
statements.insertPromptTemplate.run(
username,
null,
template.name,
template.description ?? null,
template.category ?? null,
template.prompt_text,
template.starter_text ?? null,
template.is_shared ? 1 : 0
);
imported.templates += 1;
}
for (const chat of workspace.chats || []) {
const chatId = statements.insertChatSession.run(
username,
chat.stored_title || chat.title || "Imported chat",
null,
null,
chat.folder_name ?? null,
chat.is_pinned ? 1 : 0,
chat.archived_at ?? null,
chat.scenario_prompt ?? null,
chat.scenario_summary ?? null
).lastInsertRowid;
for (const message of chat.messages || []) {
statements.insertChatMessage.run(
chatId,
message.role,
message.content,
message.model_name ?? null,
JSON.stringify(message.retryVariants || [message.content || ""]),
Number(message.retryActiveIndex || 0),
Number(message.retryRetriesUsed || 0),
message.retryPromptMessageId ?? null
);
}
imported.chats += 1;
}
return imported;
});
return {
getUser: (username) => statements.getUser.get(username),
insertUser: (username, passwordHash) => statements.insertUser.run(username, passwordHash),
updatePassword: (passwordHash, username) => statements.updatePassword.run(passwordHash, username),
renameUser: (currentUsername, nextUsername) => renameUserTransaction(currentUsername, nextUsername),
listChats: (username) => statements.listChatSessions.all(username),
searchChats(username, query) {
const like = `%${String(query || "").trim().toLowerCase()}%`;
return statements.searchChats.all(username, like, like);
},
countChats: (username) => statements.countChatSessions.get(username).count,
getChat: (chatId, username) => statements.getChatSession.get(chatId, username),
getChatByParticipants: (username, assistantPersonaId, userPersonaId) => statements.getChatSessionByParticipants.get(username, assistantPersonaId, userPersonaId, userPersonaId),
createChat(username, title, assistantPersonaId = null, userPersonaId = null, scenarioPrompt = null, scenarioSummary = null, organization = {}) {
const chatId = statements.insertChatSession.run(
username,
title,
assistantPersonaId,
userPersonaId,
organization.folderName ?? null,
organization.isPinned ? 1 : 0,
organization.archivedAt ?? null,
scenarioPrompt,
scenarioSummary
).lastInsertRowid;
return statements.getChatSession.get(chatId, username);
},
updateChatTitle: (chatId, username, title) => statements.updateChatSessionTitle.run(title, chatId, username),
updateChatScene: (chatId, username, scenarioPrompt, scenarioSummary) => statements.updateChatSessionScene.run(scenarioPrompt, scenarioSummary, chatId, username),
updateChatOrganization(chatId, username, {folderName = null, isPinned = false, archivedAt = null}) {
return statements.updateChatSessionOrganization.run(folderName, isPinned ? 1 : 0, archivedAt, chatId, username);
},
touchChat: (chatId, username) => statements.touchChatSession.run(chatId, username),
deleteChat: (chatId, username) => statements.deleteChatSession.run(chatId, username),
listChatMessages: (chatId) => statements.getChatMessages.all(chatId),
getRecentChatMessages: (chatId) => statements.getRecentChatMessages.all(chatId, config.chatHistoryLimit),
getRecentChatMessagesUpToId: (chatId, messageId) => statements.getRecentChatMessagesUpToId.all(chatId, messageId, config.chatHistoryLimit),
getChatMessage: (chatId, messageId) => statements.getChatMessageById.get(messageId, chatId),
getChatMessageByIndex: (chatId, index) => statements.getChatMessageByIndex.get(chatId, index),
getLatestChatMessage: (chatId) => statements.getLatestChatMessage.get(chatId),
getPreviousUserMessage: (chatId, messageId) => statements.getPreviousUserMessage.get(chatId, messageId),
insertChatMessage(chatId, role, content, retryState = null, modelName = null) {
return statements.insertChatMessage.run(
chatId,
role,
String(content || ""),
modelName || null,
retryState ? JSON.stringify(retryState.retryVariants) : null,
retryState?.retryActiveIndex ?? 0,
retryState?.retryRetriesUsed ?? 0,
retryState?.retryPromptMessageId ?? null
);
},
updateChatMessage: (chatId, messageId, content) => statements.updateChatMessage.run(content, messageId, chatId),
updateChatMessageRetryState: (chatId, messageId, payload) => statements.updateChatMessageWithRetryState.run(
payload.content,
JSON.stringify(payload.retryVariants),
payload.retryActiveIndex,
payload.retryRetriesUsed,
payload.retryPromptMessageId ?? null,
messageId,
chatId
),
deleteChatMessage: (chatId, messageId) => statements.deleteChatMessage.run(messageId, chatId),
clearChatMessages: (chatId) => statements.deleteChatMessages.run(chatId),
listPersonasByType: (username, personaType) => statements.listPersonasByType.all(username, personaType),
listAllPersonas: (username) => statements.listAllPersonas.all(username),
getPersona: (personaId, username) => statements.getPersona.get(personaId, username),
createPersona(username, payload, personaType) {
const personaId = statements.insertPersona.run(
username,
payload.name,
payload.pronouns,
payload.appearance,
payload.background,
payload.details,
payload.exampleDialogues,
personaType
).lastInsertRowid;
const persona = statements.getPersona.get(personaId, username);
statements.insertPersonaVersion.run(personaId, username, 1, buildPersonaSnapshot(persona));
return persona;
},
updatePersona(personaId, username, payload) {
savePersonaVersion(personaId, username);
return statements.updatePersona.run(
payload.name,
payload.pronouns,
payload.appearance,
payload.background,
payload.details,
payload.exampleDialogues,
personaId,
username
);
},
restorePersonaVersion(personaId, versionId, username) {
const version = statements.getPersonaVersion.get(versionId, personaId, username);
if (!version) return null;
savePersonaVersion(personaId, username);
const snapshot = JSON.parse(version.snapshot_json);
statements.updatePersona.run(
snapshot.name,
snapshot.pronouns ?? null,
snapshot.appearance ?? null,
snapshot.background ?? null,
snapshot.details ?? null,
snapshot.example_dialogues ?? null,
personaId,
username
);
return statements.getPersona.get(personaId, username);
},
listPersonaVersions(personaId, username) {
return statements.listPersonaVersions.all(personaId, username).map((version) => ({
...version,
snapshot: JSON.parse(version.snapshot_json)
}));
},
clonePersona: (personaId, username) => clonePersonaTransaction(personaId, username),
deletePersona: (personaId, username) => statements.deletePersona.run(personaId, username),
clearPersonaReferences(username, personaId) {
statements.clearAssistantPersonaFromChats.run(username, personaId);
statements.clearUserPersonaFromChats.run(username, personaId);
statements.deleteMarketPersonaByPersonaId.run(personaId, username);
},
getActivePersonaIds: (username) => statements.getActivePersonaId.get(username) || {},
setActiveUserPersona: (username, personaId) => statements.setActiveUserPersona.run(username, personaId),
getActiveUserPersona: (username) => statements.getActiveUserPersona.get(username),
getAssistantPersonaForChat: (chatId, username) => statements.getAssistantPersonaForChat.get(chatId, username),
getUserPersonaForChat: (chatId, username) => statements.getUserPersonaForChat.get(chatId, username),
listMarketPersonas({admin = false, username = null, personaType = null, sort = "best"} = {}) {
const normalizedPersonaType = normalizeMarketPersonaType(personaType);
const normalizedSort = normalizeMarketSort(sort);
const params = [username];
const filters = [];
if (!admin) {
filters.push("pm.soft_deleted_at IS NULL");
filters.push("pm.moderation_status != 'rejected'");
}
if (normalizedPersonaType) {
filters.push("pm.persona_type = ?");
params.push(normalizedPersonaType);
}
const whereClause = filters.length ? `WHERE ${filters.join(" AND ")}` : "";
const query = db.prepare(`
SELECT pm.id, pm.persona_id, pm.creator_username, pm.name, pm.pronouns, pm.appearance, pm.background, pm.details,
pm.example_dialogues, pm.persona_type, pm.tags, pm.usage_count, pm.favorite_count, pm.rating_total, pm.rating_count,
pm.moderation_status, pm.moderation_notes, pm.created_at, pm.updated_at, pm.soft_deleted_at,
CASE WHEN pm.rating_count > 0 THEN CAST(pm.rating_total AS REAL) / pm.rating_count ELSE 0 END AS rating_average,
CASE WHEN mf.username IS NULL THEN 0 ELSE 1 END AS is_favorite
FROM persona_market pm
LEFT JOIN persona_market_favorites mf ON mf.market_id = pm.id AND mf.username = ?
${whereClause}
ORDER BY ${MARKET_SORT_ORDERS[normalizedSort]}
`);
return query.all(...params).map((persona) => ({
...persona,
is_favorite: Boolean(persona.is_favorite),
tags: parseJsonArray(persona.tags)
}));
},
getMarketPersona: (marketId) => {
const persona = statements.getMarketPersona.get(marketId);
return persona ? {...persona, tags: parseJsonArray(persona.tags)} : null;
},
getMarketPersonaByPersonaId: (personaId, username) => {
const persona = statements.getMarketPersonaByPersonaId.get(personaId, username);
return persona ? {...persona, tags: parseJsonArray(persona.tags)} : null;
},
upsertMarketPersona(personaId, username, persona, tags = []) {
statements.upsertMarketPersona.run(
personaId,
username,
persona.name,
persona.pronouns,
persona.appearance,
persona.background,
persona.details,
persona.example_dialogues,
persona.persona_type,
JSON.stringify(tags)
);
return this.getMarketPersonaByPersonaId(personaId, username);
},
listPublishedPersonaIds: (username) => statements.listPublishedPersonaIds.all(username).map((row) => row.persona_id),
getPersonaBySourceMarket: (username, marketId) => statements.getPersonaBySourceMarket.get(username, marketId),
collectMarketPersona(username, marketPersona) {
const personaId = statements.insertMarketPersona.run(
username,
marketPersona.name,
marketPersona.pronouns,
marketPersona.appearance,
marketPersona.background,
marketPersona.details,
marketPersona.example_dialogues,
marketPersona.persona_type,
marketPersona.id,
marketPersona.creator_username
).lastInsertRowid;
const persona = statements.getPersona.get(personaId, username);
statements.insertPersonaVersion.run(personaId, username, 1, buildPersonaSnapshot(persona));
return persona;
},
incrementMarketUsageCount: (marketId) => statements.incrementMarketUsageCount.run(marketId),
toggleMarketFavorite(marketId, username) {
const favorite = statements.isMarketFavorite.get(marketId, username);
if (favorite) statements.deleteMarketFavorite.run(marketId, username);
else statements.insertMarketFavorite.run(marketId, username);
statements.recountMarketFavorites.run(marketId, marketId);
return !favorite;
},
rateMarketPersona(marketId, username, rating) {
statements.upsertMarketRating.run(marketId, username, rating);
statements.recountMarketRatings.run(marketId, marketId, marketId);
return this.getMarketPersona(marketId);
},
reportMarketPersona: (marketId, username, reason, details) => statements.insertMarketReport.run(marketId, username, reason, details),
listMarketReports: () => statements.listMarketReports.all(),
updateMarketReportStatus: (reportId, status) => statements.updateMarketReportStatus.run(status, reportId),
updateMarketModeration: (marketId, {status, notes = null, softDelete = false}) => statements.updateMarketModeration.run(status, notes, softDelete ? 1 : 0, marketId),
deletePublishedPersona: (personaId, username) => statements.deleteMarketPersonaByPersonaId.run(personaId, username),
createPromptTemplate(username, payload) {
const id = statements.insertPromptTemplate.run(
username,
payload.personaId ?? null,
payload.name,
payload.description ?? null,
payload.category ?? null,
payload.promptText,
payload.starterText ?? null,
payload.isShared ? 1 : 0
).lastInsertRowid;
return statements.getPromptTemplate.get(id);
},
updatePromptTemplate(templateId, username, payload) {
return statements.updatePromptTemplate.run(
payload.name,
payload.description ?? null,
payload.category ?? null,
payload.promptText,
payload.starterText ?? null,
payload.isShared ? 1 : 0,
templateId,
username,
username
);
},
deletePromptTemplate: (templateId, username) => statements.deletePromptTemplate.run(templateId, username),
listPromptTemplates: (username) => statements.listPromptTemplates.all(username),
listPromptTemplatesForPersona: (username, personaId = null) => statements.listPersonaPromptTemplates.all(personaId, username, username),
getPromptTemplate: (templateId) => statements.getPromptTemplate.get(templateId),
getDashboardSummary(username) {
const favoriteModel = statements.getFavoriteModel.get(username);
const publishedUsage = statements.getPublishedPersonaUsage.get(username);
return {
chats: statements.countChatSessions.get(username).count,
messages: statements.getChatMessageCountForUser.get(username).count,
personas: statements.getPersonaCountForUser.get(username).count,
published: statements.getPublishedPersonaCountForUser.get(username).count,
marketPersonas: statements.listMarketPersonas.all().length,
mostUsedPersonas: statements.getMostUsedPersonas.all(username),
averageChatLength: Number(statements.getAverageChatLength.get(username)?.average_length || 0),
favoriteModel: favoriteModel?.model_name || null,
recentActivity: statements.getRecentActivity.all(username),
collectedPersonaCount: statements.getCollectedPersonaCount.get(username).count,
publishedPersonaPerformance: {
publishedCount: publishedUsage.published_count,
totalUses: publishedUsage.usage_total
}
};
},
exportWorkspace(username) {
return {
exportedAt: new Date().toISOString(),
chats: this.listChats(username).map((chat) => ({
...chat,
messages: this.listChatMessages(chat.id).map((message) => ({
...message,
retryVariants: parseJsonArray(message.retry_variants)
}))
})),
personas: this.listAllPersonas(username),
templates: this.listPromptTemplates(username)
};
},
importWorkspace: (username, workspace) => importWorkspaceTransaction(username, workspace)
};
}
+336
View File
@@ -0,0 +1,336 @@
export function initializeSchema(db) {
db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");
db.prepare(`
CREATE TABLE IF NOT EXISTS users
(
username TEXT PRIMARY KEY,
password TEXT NOT NULL
)
`).run();
db.prepare(`
CREATE TABLE IF NOT EXISTS chat_sessions
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
title TEXT NOT NULL,
assistant_persona_id INTEGER,
user_persona_id INTEGER,
folder_name TEXT,
is_pinned INTEGER DEFAULT 0,
archived_at DATETIME,
scenario_prompt TEXT,
scenario_summary TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (assistant_persona_id) REFERENCES personas (id) ON DELETE SET NULL,
FOREIGN KEY (user_persona_id) REFERENCES personas (id) ON DELETE SET NULL
)
`).run();
db.prepare(`
CREATE TABLE IF NOT EXISTS chat_messages
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_id INTEGER NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
model_name TEXT,
retry_variants TEXT,
retry_active_index INTEGER DEFAULT 0,
retry_retries_used INTEGER DEFAULT 0,
retry_prompt_message_id INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (chat_id) REFERENCES chat_sessions (id) ON DELETE CASCADE
)
`).run();
db.prepare(`
CREATE TABLE IF NOT EXISTS personas
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
name TEXT NOT NULL,
pronouns TEXT,
appearance TEXT,
background TEXT,
details TEXT,
custom_fields TEXT,
persona_type TEXT DEFAULT 'assistant',
source_market_id INTEGER,
source_creator_username TEXT,
example_dialogues TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`).run();
db.prepare(`
CREATE TABLE IF NOT EXISTS user_settings
(
username TEXT PRIMARY KEY,
active_persona_id INTEGER,
active_user_persona_id INTEGER,
FOREIGN KEY (username) REFERENCES users (username) ON DELETE CASCADE,
FOREIGN KEY (active_persona_id) REFERENCES personas (id) ON DELETE SET NULL,
FOREIGN KEY (active_user_persona_id) REFERENCES personas (id) ON DELETE SET NULL
)
`).run();
db.prepare(`
CREATE TABLE IF NOT EXISTS persona_market
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
persona_id INTEGER,
creator_username TEXT NOT NULL,
name TEXT NOT NULL,
pronouns TEXT,
appearance TEXT,
background TEXT,
details TEXT,
example_dialogues TEXT,
persona_type TEXT DEFAULT 'assistant',
tags TEXT,
usage_count INTEGER DEFAULT 0,
favorite_count INTEGER DEFAULT 0,
rating_total INTEGER DEFAULT 0,
rating_count INTEGER DEFAULT 0,
moderation_status TEXT DEFAULT 'approved',
moderation_notes TEXT,
soft_deleted_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE (persona_id, creator_username)
)
`).run();
db.prepare(`
CREATE TABLE IF NOT EXISTS persona_versions
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
persona_id INTEGER NOT NULL,
username TEXT NOT NULL,
version_number INTEGER NOT NULL,
snapshot_json TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (persona_id) REFERENCES personas (id) ON DELETE CASCADE
)
`).run();
db.prepare(`
CREATE TABLE IF NOT EXISTS persona_market_favorites
(
market_id INTEGER NOT NULL,
username TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (market_id, username),
FOREIGN KEY (market_id) REFERENCES persona_market (id) ON DELETE CASCADE
)
`).run();
db.prepare(`
CREATE TABLE IF NOT EXISTS persona_market_ratings
(
market_id INTEGER NOT NULL,
username TEXT NOT NULL,
rating INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (market_id, username),
FOREIGN KEY (market_id) REFERENCES persona_market (id) ON DELETE CASCADE
)
`).run();
db.prepare(`
CREATE TABLE IF NOT EXISTS persona_market_reports
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
market_id INTEGER NOT NULL,
reporter_username TEXT NOT NULL,
reason TEXT NOT NULL,
details TEXT,
status TEXT DEFAULT 'open',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (market_id) REFERENCES persona_market (id) ON DELETE CASCADE
)
`).run();
db.prepare(`
CREATE TABLE IF NOT EXISTS prompt_templates
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT,
persona_id INTEGER,
name TEXT NOT NULL,
description TEXT,
category TEXT,
prompt_text TEXT NOT NULL,
starter_text TEXT,
is_shared INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (persona_id) REFERENCES personas (id) ON DELETE CASCADE
)
`).run();
}
export function runMigrations(db, {dropLegacyChats = true} = {}) {
const ensureColumn = (tableName, columns, name, sql) => {
if (!columns.some((column) => column.name === name)) {
db.prepare(sql).run();
}
};
const personaColumns = db.prepare("PRAGMA table_info(personas)").all();
ensureColumn("personas", personaColumns, "custom_fields", "ALTER TABLE personas ADD COLUMN custom_fields TEXT");
ensureColumn("personas", personaColumns, "persona_type", "ALTER TABLE personas ADD COLUMN persona_type TEXT DEFAULT 'assistant'");
ensureColumn("personas", personaColumns, "source_market_id", "ALTER TABLE personas ADD COLUMN source_market_id INTEGER");
ensureColumn("personas", personaColumns, "source_creator_username", "ALTER TABLE personas ADD COLUMN source_creator_username TEXT");
ensureColumn("personas", personaColumns, "example_dialogues", "ALTER TABLE personas ADD COLUMN example_dialogues TEXT");
const userSettingsColumns = db.prepare("PRAGMA table_info(user_settings)").all();
ensureColumn("user_settings", userSettingsColumns, "active_user_persona_id", "ALTER TABLE user_settings ADD COLUMN active_user_persona_id INTEGER");
const marketColumns = db.prepare("PRAGMA table_info(persona_market)").all();
ensureColumn("persona_market", marketColumns, "persona_id", "ALTER TABLE persona_market ADD COLUMN persona_id INTEGER");
ensureColumn("persona_market", marketColumns, "usage_count", "ALTER TABLE persona_market ADD COLUMN usage_count INTEGER DEFAULT 0");
ensureColumn("persona_market", marketColumns, "example_dialogues", "ALTER TABLE persona_market ADD COLUMN example_dialogues TEXT");
ensureColumn("persona_market", marketColumns, "tags", "ALTER TABLE persona_market ADD COLUMN tags TEXT");
ensureColumn("persona_market", marketColumns, "favorite_count", "ALTER TABLE persona_market ADD COLUMN favorite_count INTEGER DEFAULT 0");
ensureColumn("persona_market", marketColumns, "rating_total", "ALTER TABLE persona_market ADD COLUMN rating_total INTEGER DEFAULT 0");
ensureColumn("persona_market", marketColumns, "rating_count", "ALTER TABLE persona_market ADD COLUMN rating_count INTEGER DEFAULT 0");
ensureColumn("persona_market", marketColumns, "moderation_status", "ALTER TABLE persona_market ADD COLUMN moderation_status TEXT DEFAULT 'approved'");
ensureColumn("persona_market", marketColumns, "moderation_notes", "ALTER TABLE persona_market ADD COLUMN moderation_notes TEXT");
ensureColumn("persona_market", marketColumns, "soft_deleted_at", "ALTER TABLE persona_market ADD COLUMN soft_deleted_at DATETIME");
db.prepare("UPDATE persona_market SET usage_count = 0 WHERE usage_count IS NULL").run();
db.prepare("UPDATE persona_market SET favorite_count = 0 WHERE favorite_count IS NULL").run();
db.prepare("UPDATE persona_market SET rating_total = 0 WHERE rating_total IS NULL").run();
db.prepare("UPDATE persona_market SET rating_count = 0 WHERE rating_count IS NULL").run();
const chatMessageColumns = db.prepare("PRAGMA table_info(chat_messages)").all();
ensureColumn("chat_messages", chatMessageColumns, "model_name", "ALTER TABLE chat_messages ADD COLUMN model_name TEXT");
ensureColumn("chat_messages", chatMessageColumns, "retry_variants", "ALTER TABLE chat_messages ADD COLUMN retry_variants TEXT");
ensureColumn("chat_messages", chatMessageColumns, "retry_active_index", "ALTER TABLE chat_messages ADD COLUMN retry_active_index INTEGER DEFAULT 0");
ensureColumn("chat_messages", chatMessageColumns, "retry_retries_used", "ALTER TABLE chat_messages ADD COLUMN retry_retries_used INTEGER DEFAULT 0");
ensureColumn("chat_messages", chatMessageColumns, "retry_prompt_message_id", "ALTER TABLE chat_messages ADD COLUMN retry_prompt_message_id INTEGER");
db.prepare("UPDATE chat_messages SET retry_active_index = 0 WHERE retry_active_index IS NULL").run();
db.prepare("UPDATE chat_messages SET retry_retries_used = 0 WHERE retry_retries_used IS NULL").run();
const chatSessionColumns = db.prepare("PRAGMA table_info(chat_sessions)").all();
ensureColumn("chat_sessions", chatSessionColumns, "assistant_persona_id", "ALTER TABLE chat_sessions ADD COLUMN assistant_persona_id INTEGER");
ensureColumn("chat_sessions", chatSessionColumns, "user_persona_id", "ALTER TABLE chat_sessions ADD COLUMN user_persona_id INTEGER");
ensureColumn("chat_sessions", chatSessionColumns, "folder_name", "ALTER TABLE chat_sessions ADD COLUMN folder_name TEXT");
ensureColumn("chat_sessions", chatSessionColumns, "is_pinned", "ALTER TABLE chat_sessions ADD COLUMN is_pinned INTEGER DEFAULT 0");
ensureColumn("chat_sessions", chatSessionColumns, "archived_at", "ALTER TABLE chat_sessions ADD COLUMN archived_at DATETIME");
ensureColumn("chat_sessions", chatSessionColumns, "scenario_prompt", "ALTER TABLE chat_sessions ADD COLUMN scenario_prompt TEXT");
ensureColumn("chat_sessions", chatSessionColumns, "scenario_summary", "ALTER TABLE chat_sessions ADD COLUMN scenario_summary TEXT");
db.prepare("UPDATE chat_sessions SET is_pinned = 0 WHERE is_pinned IS NULL").run();
db.prepare(`
CREATE TABLE IF NOT EXISTS persona_versions
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
persona_id INTEGER NOT NULL,
username TEXT NOT NULL,
version_number INTEGER NOT NULL,
snapshot_json TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (persona_id) REFERENCES personas (id) ON DELETE CASCADE
)
`).run();
db.prepare(`
CREATE TABLE IF NOT EXISTS persona_market_favorites
(
market_id INTEGER NOT NULL,
username TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (market_id, username),
FOREIGN KEY (market_id) REFERENCES persona_market (id) ON DELETE CASCADE
)
`).run();
db.prepare(`
CREATE TABLE IF NOT EXISTS persona_market_ratings
(
market_id INTEGER NOT NULL,
username TEXT NOT NULL,
rating INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (market_id, username),
FOREIGN KEY (market_id) REFERENCES persona_market (id) ON DELETE CASCADE
)
`).run();
db.prepare(`
CREATE TABLE IF NOT EXISTS persona_market_reports
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
market_id INTEGER NOT NULL,
reporter_username TEXT NOT NULL,
reason TEXT NOT NULL,
details TEXT,
status TEXT DEFAULT 'open',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (market_id) REFERENCES persona_market (id) ON DELETE CASCADE
)
`).run();
db.prepare(`
CREATE TABLE IF NOT EXISTS prompt_templates
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT,
persona_id INTEGER,
name TEXT NOT NULL,
description TEXT,
category TEXT,
prompt_text TEXT NOT NULL,
starter_text TEXT,
is_shared INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (persona_id) REFERENCES personas (id) ON DELETE CASCADE
)
`).run();
const legacyChatsExist = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'chats'").get();
if (!legacyChatsExist) {
return;
}
const migrationNeeded = db.prepare("SELECT COUNT(*) as count FROM chat_messages").get();
if (migrationNeeded.count === 0) {
const insertChatSessionStmt = db.prepare(
"INSERT INTO chat_sessions (username, title, assistant_persona_id, user_persona_id, scenario_prompt, scenario_summary) VALUES (?, ?, ?, ?, ?, ?)"
);
const getLegacyUsersStmt = db.prepare("SELECT DISTINCT username FROM chats");
const getLegacyChatsStmt = db.prepare(`
SELECT role, content
FROM chats
WHERE username = ?
ORDER BY datetime(created_at)
`);
const insertChatMessageStmt = db.prepare(
"INSERT INTO chat_messages (chat_id, role, content, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id) VALUES (?, ?, ?, ?, ?, ?, ?)"
);
const migrateLegacyChats = db.transaction(() => {
for (const {username} of getLegacyUsersStmt.all()) {
const chatId = insertChatSessionStmt.run(username, "Imported chat", null, null, null, null).lastInsertRowid;
for (const row of getLegacyChatsStmt.all(username)) {
insertChatMessageStmt.run(chatId, row.role, row.content, null, 0, 0, null);
}
}
});
migrateLegacyChats();
}
if (dropLegacyChats) {
db.prepare("DROP TABLE chats").run();
}
}
+6
View File
@@ -0,0 +1,6 @@
export function requireLogin(req, res, next) {
if (!req.session.user) {
return res.status(403).json({error: "Not logged in"});
}
return next();
}
+28
View File
@@ -0,0 +1,28 @@
function pruneBucket(bucket, windowMs, now) {
const cutoff = now - windowMs;
while (bucket.length && bucket[0] <= cutoff) {
bucket.shift();
}
}
export function createRateLimiter({windowMs, max, keyGenerator, message}) {
const hits = new Map();
return (req, res, next) => {
const key = keyGenerator(req);
if (!key) {
return next();
}
const now = Date.now();
const bucket = hits.get(key) || [];
pruneBucket(bucket, windowMs, now);
if (bucket.length >= max) {
return res.status(429).json({error: message});
}
bucket.push(now);
hits.set(key, bucket);
return next();
};
}
+10
View File
@@ -0,0 +1,10 @@
export function validateBody(validator) {
return (req, res, next) => {
const result = validator(req.body || {}, req);
if (result?.error) {
return res.status(400).json({error: result.error});
}
req.validatedBody = result?.value ?? req.body ?? {};
return next();
};
}
+48
View File
@@ -0,0 +1,48 @@
import express from "express";
import bcrypt from "bcrypt";
import {validateBody} from "../middleware/validate.js";
const credentialsValidator = (body) => {
const username = String(body?.username || "").trim();
const password = String(body?.password || "");
if (!username) return {error: "Username is required"};
if (username.length < 3) return {error: "Username must be at least 3 characters"};
if (!password) return {error: "Password is required"};
if (password.length < 6) return {error: "Password must be at least 6 characters"};
return {value: {username, password}};
};
export function createAuthRouter({repositories, authRateLimiters}) {
const router = express.Router();
router.post("/register", ...authRateLimiters, validateBody(credentialsValidator), async (req, res) => {
const {username, password} = req.validatedBody;
if (repositories.getUser(username)) {
return res.status(400).json({error: "User already exists"});
}
const hashed = await bcrypt.hash(password, 10);
repositories.insertUser(username, hashed);
return res.json({message: "Registration successful"});
});
router.post("/login", ...authRateLimiters, validateBody(credentialsValidator), async (req, res) => {
const {username, password} = req.validatedBody;
const user = repositories.getUser(username);
if (!user) return res.status(400).json({error: "User not found"});
const valid = await bcrypt.compare(password, user.password);
if (!valid) return res.status(403).json({error: "Invalid password"});
req.session.user = username;
return res.json({message: "Login successful"});
});
router.post("/logout", (req, res) => {
req.session.destroy(() => res.json({message: "Logged out"}));
});
router.get("/session", (req, res) => {
return res.json({user: req.session.user || null});
});
return router;
}
+334
View File
@@ -0,0 +1,334 @@
import express from "express";
import {requireLogin} from "../middleware/auth.js";
import {validateBody} from "../middleware/validate.js";
const createChatValidator = (body) => ({
value: {
title: String(body?.title || "New chat").trim() || "New chat",
assistantPersonaId: body?.assistantPersonaId ? Number(body.assistantPersonaId) : null
}
});
const messageValidator = (body) => {
const content = String(body?.content || "").trim();
if (!content) return {error: "Message content is required"};
return {value: {content, retryActiveIndex: body?.retryActiveIndex}};
};
const sendMessageValidator = (body) => {
const message = String(body?.message || "").trim();
const chatId = Number(body?.chatId);
if (!chatId) return {error: "chatId is required"};
if (!message) return {error: "message is required"};
return {value: {message, chatId, model: String(body?.model || "mistral:latest")}};
};
const organizationValidator = (body) => ({
value: {
folderName: String(body?.folderName || "").trim() || null,
isPinned: Boolean(body?.isPinned),
archived: Boolean(body?.archived)
}
});
export function createChatsRouter({repositories, chatService, modelService, config, chatRateLimiters}) {
const router = express.Router();
router.use(requireLogin);
router.get("/models", async (req, res) => {
try {
return res.json(await modelService.listModels());
} catch (error) {
const mapped = modelService.mapError(error);
return res.status(mapped.status).json(mapped.body);
}
});
router.get("/chats", (req, res) => res.json({chats: repositories.listChats(req.session.user)}));
router.get("/chats/search", (req, res) => res.json({chats: repositories.searchChats(req.session.user, req.query.q || "")}));
router.post("/chats", validateBody(createChatValidator), (req, res) => {
const user = req.session.user;
const {assistantPersonaId, title} = req.validatedBody;
if (assistantPersonaId) {
const persona = repositories.getPersona(assistantPersonaId, user);
if (!persona || persona.persona_type !== "assistant") {
return res.status(404).json({error: "AI Character not found"});
}
const existingChat = repositories.getChatByParticipants(user, assistantPersonaId, null);
try {
const chat = chatService.getOrCreatePersonaChat(user, persona, null);
return res.json({chat, existing: Boolean(existingChat)});
} catch (error) {
if (error.message === "CHAT_LIMIT_REACHED") {
return res.status(400).json({error: `Maximum of ${config.chatLimit} chats reached. Please delete an old chat to create a new one.`});
}
throw error;
}
}
if (repositories.countChats(user) >= config.chatLimit) {
return res.status(400).json({error: `Maximum of ${config.chatLimit} chats reached. Please delete an old chat to create a new one.`});
}
return res.json({chat: repositories.createChat(user, title), existing: false});
});
router.put("/chats/:id", validateBody((body) => {
const title = String(body?.title || "").trim();
return title ? {value: {title}} : {error: "Title is required"};
}), (req, res) => {
const chatId = Number(req.params.id);
const result = repositories.updateChatTitle(chatId, req.session.user, req.validatedBody.title);
if (result.changes === 0) return res.status(404).json({error: "Chat not found"});
return res.json({chat: {id: chatId, title: req.validatedBody.title}});
});
router.put("/chats/:id/organization", validateBody(organizationValidator), (req, res) => {
const chatId = Number(req.params.id);
const chat = repositories.getChat(chatId, req.session.user);
if (!chat) return res.status(404).json({error: "Chat not found"});
repositories.updateChatOrganization(chatId, req.session.user, {
folderName: req.validatedBody.folderName,
isPinned: req.validatedBody.isPinned,
archivedAt: req.validatedBody.archived ? new Date().toISOString() : null
});
return res.json({chat: repositories.getChat(chatId, req.session.user)});
});
router.delete("/chats/:id", (req, res) => {
const result = repositories.deleteChat(Number(req.params.id), req.session.user);
if (result.changes === 0) return res.status(404).json({error: "Chat not found"});
return res.json({message: "Chat deleted"});
});
router.get("/chats/:id/messages", (req, res) => {
const chatId = Number(req.params.id);
const chat = repositories.getChat(chatId, req.session.user);
if (!chat) return res.status(404).json({error: "Chat not found"});
return res.json({
messages: repositories.listChatMessages(chatId).map(chatService.formatChatMessage),
chat
});
});
router.put("/chats/:id/messages/:messageId", validateBody(messageValidator), (req, res) => {
const chatId = Number(req.params.id);
const messageId = Number(req.params.messageId);
const chat = repositories.getChat(chatId, req.session.user);
if (!chat) return res.status(404).json({error: "Chat not found"});
const existingMessage = chatService.formatChatMessage(repositories.getChatMessage(chatId, messageId));
if (!existingMessage) return res.status(404).json({error: "Message not found"});
if (existingMessage.role === "bot") {
const retryVariants = [...existingMessage.retryVariants];
const activeIndex = Number.isInteger(Number(req.validatedBody.retryActiveIndex))
? Number(req.validatedBody.retryActiveIndex)
: existingMessage.retryActiveIndex;
retryVariants[activeIndex] = req.validatedBody.content;
chatService.persistChatMessageRetryState({
chatId,
messageId,
content: req.validatedBody.content,
retryVariants,
retryActiveIndex: activeIndex,
retryRetriesUsed: existingMessage.retryRetriesUsed,
retryPromptMessageId: existingMessage.retryPromptMessageId
});
} else {
repositories.updateChatMessage(chatId, messageId, req.validatedBody.content);
}
repositories.touchChat(chatId, req.session.user);
return res.json({message: chatService.formatChatMessage(repositories.getChatMessage(chatId, messageId))});
});
router.put("/chats/:id/messages/by-index/:index", validateBody(messageValidator), (req, res) => {
const chatId = Number(req.params.id);
const chat = repositories.getChat(chatId, req.session.user);
if (!chat) return res.status(404).json({error: "Chat not found"});
const target = chatService.getChatMessageByIndex(chatId, req.params.index);
if (!target) return res.status(404).json({error: "Message not found"});
const existingMessage = chatService.formatChatMessage(repositories.getChatMessage(chatId, target.id));
if (!existingMessage) return res.status(404).json({error: "Message not found"});
if (existingMessage.role === "bot") {
const retryVariants = [...existingMessage.retryVariants];
const activeIndex = Number.isInteger(Number(req.validatedBody.retryActiveIndex))
? Number(req.validatedBody.retryActiveIndex)
: existingMessage.retryActiveIndex;
retryVariants[activeIndex] = req.validatedBody.content;
chatService.persistChatMessageRetryState({
chatId,
messageId: target.id,
content: req.validatedBody.content,
retryVariants,
retryActiveIndex: activeIndex,
retryRetriesUsed: existingMessage.retryRetriesUsed,
retryPromptMessageId: existingMessage.retryPromptMessageId
});
} else {
repositories.updateChatMessage(chatId, target.id, req.validatedBody.content);
}
repositories.touchChat(chatId, req.session.user);
return res.json({message: chatService.formatChatMessage(repositories.getChatMessage(chatId, target.id))});
});
router.delete("/chats/:id/messages/:messageId", (req, res) => {
const chatId = Number(req.params.id);
const chat = repositories.getChat(chatId, req.session.user);
if (!chat) return res.status(404).json({error: "Chat not found"});
const result = repositories.deleteChatMessage(chatId, Number(req.params.messageId));
if (result.changes === 0) return res.status(404).json({error: "Message not found"});
repositories.touchChat(chatId, req.session.user);
return res.json({message: "Message deleted"});
});
router.delete("/chats/:id/messages/by-index/:index", (req, res) => {
const chatId = Number(req.params.id);
const chat = repositories.getChat(chatId, req.session.user);
if (!chat) return res.status(404).json({error: "Chat not found"});
const target = chatService.getChatMessageByIndex(chatId, req.params.index);
if (!target) return res.status(404).json({error: "Message not found"});
repositories.deleteChatMessage(chatId, target.id);
repositories.touchChat(chatId, req.session.user);
return res.json({message: "Message deleted"});
});
router.post("/chats/:id/clear", (req, res) => {
const chatId = Number(req.params.id);
const chat = repositories.getChat(chatId, req.session.user);
if (!chat) return res.status(404).json({error: "Chat not found"});
repositories.clearChatMessages(chatId);
repositories.touchChat(chatId, req.session.user);
return res.json({message: "Chat cleared"});
});
router.post("/chat", ...chatRateLimiters, validateBody(sendMessageValidator), async (req, res) => {
try {
const result = await chatService.sendChatMessage({
user: req.session.user,
chatId: req.validatedBody.chatId,
message: req.validatedBody.message,
model: req.validatedBody.model
});
if (result.error) return res.status(result.status).json({error: result.error});
return res.json({reply: result.reply});
} catch (error) {
const mapped = modelService.mapError(error);
return res.status(mapped.status).json(mapped.body);
}
});
router.post("/chat/stream", ...chatRateLimiters, validateBody(sendMessageValidator), async (req, res) => {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive"
});
const sendEvent = (event, data) => {
res.write(`event: ${event}\n`);
res.write(`data: ${JSON.stringify(data)}\n\n`);
};
try {
const result = await chatService.streamChatMessage({
user: req.session.user,
chatId: req.validatedBody.chatId,
message: req.validatedBody.message,
model: req.validatedBody.model,
onChunk: (chunk, fullReply) => sendEvent("chunk", {chunk, fullReply})
});
if (result.error) {
sendEvent("error", {error: result.error});
} else {
sendEvent("done", {reply: result.reply});
}
} catch (error) {
const mapped = modelService.mapError(error);
sendEvent("error", mapped.body);
} finally {
res.end();
}
});
router.post("/chats/:id/messages/:messageId/retry", ...chatRateLimiters, async (req, res) => {
const chatId = Number(req.params.id);
const messageId = Number(req.params.messageId);
const targetMessage = repositories.getChatMessage(chatId, messageId);
if (!targetMessage) return res.status(404).json({error: "Message not found"});
const latestMessage = repositories.getLatestChatMessage(chatId);
if (!latestMessage || latestMessage.id !== messageId || latestMessage.role !== "bot") {
return res.status(400).json({error: "Only the newest assistant message can be retried"});
}
try {
const result = await chatService.retryMessage({
user: req.session.user,
chatId,
selectedModel: String(req.body?.model || "mistral:latest"),
targetMessage,
retryStyle: String(req.body?.style || "").trim() || null
});
if (result.error) return res.status(result.status).json({error: result.error});
return res.json(result);
} catch (error) {
const mapped = modelService.mapError(error);
return res.status(mapped.status).json(mapped.body);
}
});
router.post("/chats/:id/messages/by-index/:index/retry", ...chatRateLimiters, async (req, res) => {
const chatId = Number(req.params.id);
const targetMessage = chatService.getChatMessageByIndex(chatId, req.params.index);
if (!targetMessage) return res.status(404).json({error: "Message not found"});
const latestMessage = repositories.getLatestChatMessage(chatId);
if (!latestMessage || latestMessage.id !== targetMessage.id || latestMessage.role !== "bot") {
return res.status(400).json({error: "Only the newest assistant message can be retried"});
}
try {
const result = await chatService.retryMessage({
user: req.session.user,
chatId,
selectedModel: String(req.body?.model || "mistral:latest"),
targetMessage,
retryStyle: String(req.body?.style || "").trim() || null
});
if (result.error) return res.status(result.status).json({error: result.error});
return res.json(result);
} catch (error) {
const mapped = modelService.mapError(error);
return res.status(mapped.status).json(mapped.body);
}
});
router.get("/exports/chats/:id", (req, res) => {
const chatId = Number(req.params.id);
const format = String(req.query.format || "txt").toLowerCase();
const chat = repositories.getChat(chatId, req.session.user);
if (!chat) return res.status(404).json({error: "Chat not found"});
const messages = repositories.listChatMessages(chatId).map(chatService.formatChatMessage);
if (format === "json") {
return res.json({chat, messages});
}
if (format === "md") {
const body = [`# ${chat.title}`, "", ...messages.flatMap((message) => [`## ${message.role === "user" ? "You" : "Assistant"}`, "", message.content, ""])].join("\n");
res.type("text/markdown");
return res.send(body);
}
const body = [`Chat: ${chat.title}`, "", ...messages.flatMap((message) => [`${message.role === "user" ? "You" : "Assistant"}:`, message.content, ""])].join("\n");
res.type("text/plain");
return res.send(body);
});
router.get("/exports/workspace", (req, res) => res.json({workspace: repositories.exportWorkspace(req.session.user)}));
router.post("/imports/workspace", (req, res) => res.json({imported: repositories.importWorkspace(req.session.user, req.body?.workspace || {})}));
router.get("/chat/history", (req, res) => res.json({history: repositories.listChats(req.session.user)}));
return router;
}
+11
View File
@@ -0,0 +1,11 @@
import express from "express";
export function createPagesRouter(config) {
const router = express.Router();
router.get("/market", (req, res) => res.sendFile(`${config.publicDir}/market.html`));
router.get("/settings", (req, res) => res.sendFile(`${config.publicDir}/settings.html`));
router.get("/", (req, res) => res.sendFile(`${config.publicDir}/index.html`));
return router;
}
+377
View File
@@ -0,0 +1,377 @@
import express from "express";
import {requireLogin} from "../middleware/auth.js";
import {validateBody} from "../middleware/validate.js";
import {normalizeOptionalText, validatePersonaPayload} from "../services/personaService.js";
const personaBodyValidator = (body) => {
const personaType = String(body?.personaType || "assistant").trim();
if (!["assistant", "user"].includes(personaType)) {
return {error: "Invalid persona type"};
}
const validated = validatePersonaPayload(body, personaType);
if (validated.error) return validated;
return {value: {personaType, payload: validated.value}};
};
const roleplayStartValidator = (body) => {
const assistantPersonaId = Number(body?.assistantPersonaId);
if (!assistantPersonaId) return {error: "assistantPersonaId is required"};
return {
value: {
assistantPersonaId,
userPersonaId: body?.userPersonaId ? Number(body.userPersonaId) : null,
scenarioPrompt: normalizeOptionalText(body?.scenarioPrompt),
model: String(body?.model || "mistral:latest")
}
};
};
const templateValidator = (body) => {
const name = String(body?.name || "").trim();
const promptText = String(body?.promptText || "").trim();
if (!name || !promptText) return {error: "Template name and prompt text are required"};
return {
value: {
personaId: body?.personaId ? Number(body.personaId) : null,
name,
description: normalizeOptionalText(body?.description),
category: normalizeOptionalText(body?.category),
promptText,
starterText: normalizeOptionalText(body?.starterText),
isShared: Boolean(body?.isShared)
}
};
};
export function createPersonasRouter({repositories, chatService, modelService, config}) {
const router = express.Router();
router.use(requireLogin);
const isAdmin = (req) => req.session.user === "admin";
const parseMarketPersonaType = (value) => ["assistant", "user"].includes(value) ? value : null;
router.get("/personas", (req, res) => {
const user = req.session.user;
const active = repositories.getActivePersonaIds(user);
return res.json({
assistantPersonas: repositories.listPersonasByType(user, "assistant"),
userPersonas: repositories.listPersonasByType(user, "user"),
activeUserPersonaId: active.active_user_persona_id ?? null,
publishedPersonaIds: repositories.listPublishedPersonaIds(user)
});
});
router.post("/personas", validateBody(personaBodyValidator), (req, res) => {
return res.json({
persona: repositories.createPersona(req.session.user, req.validatedBody.payload, req.validatedBody.personaType)
});
});
router.put("/personas/:id", validateBody(personaBodyValidator), (req, res) => {
const personaId = Number(req.params.id);
const result = repositories.updatePersona(personaId, req.session.user, req.validatedBody.payload);
if (result.changes === 0) return res.status(404).json({error: "Persona not found"});
return res.json({persona: repositories.getPersona(personaId, req.session.user)});
});
router.post("/personas/:id/clone", (req, res) => {
const persona = repositories.clonePersona(Number(req.params.id), req.session.user);
if (!persona) return res.status(404).json({error: "Persona not found"});
return res.json({persona});
});
router.get("/personas/:id/versions", (req, res) => {
const personaId = Number(req.params.id);
const persona = repositories.getPersona(personaId, req.session.user);
if (!persona) return res.status(404).json({error: "Persona not found"});
return res.json({versions: repositories.listPersonaVersions(personaId, req.session.user)});
});
router.post("/personas/:id/versions/:versionId/restore", (req, res) => {
const persona = repositories.restorePersonaVersion(Number(req.params.id), Number(req.params.versionId), req.session.user);
if (!persona) return res.status(404).json({error: "Version not found"});
return res.json({persona});
});
router.delete("/personas/:id", (req, res) => {
const personaId = Number(req.params.id);
const active = repositories.getActivePersonaIds(req.session.user);
const result = repositories.deletePersona(personaId, req.session.user);
if (result.changes === 0) return res.status(404).json({error: "Persona not found"});
if (active.active_user_persona_id === personaId) {
repositories.setActiveUserPersona(req.session.user, null);
}
repositories.clearPersonaReferences(req.session.user, personaId);
return res.json({message: "Persona deleted"});
});
router.post("/personas/:id/chat", (req, res) => {
const user = req.session.user;
const personaId = Number(req.params.id);
const userPersonaId = req.body?.userPersonaId ? Number(req.body.userPersonaId) : null;
const persona = repositories.getPersona(personaId, user);
if (!persona || persona.persona_type !== "assistant") return res.status(404).json({error: "Persona not found"});
if (userPersonaId) {
const userPersona = repositories.getPersona(userPersonaId, user);
if (!userPersona || userPersona.persona_type !== "user") return res.status(404).json({error: "User persona not found"});
}
try {
return res.json({chat: chatService.getOrCreatePersonaChat(user, persona, userPersonaId, null, null)});
} catch (error) {
if (error.message === "CHAT_LIMIT_REACHED") {
return res.status(400).json({error: `Maximum of ${config.chatLimit} chats reached. Please delete an old chat to create a new one.`});
}
throw error;
}
});
router.post("/personas/:id/equip-user", (req, res) => {
const persona = repositories.getPersona(Number(req.params.id), req.session.user);
if (!persona || persona.persona_type !== "user") return res.status(404).json({error: "Persona not found"});
repositories.setActiveUserPersona(req.session.user, persona.id);
return res.json({activeUserPersonaId: persona.id});
});
router.post("/personas/user/clear", (req, res) => {
repositories.setActiveUserPersona(req.session.user, null);
return res.json({activeUserPersonaId: null});
});
router.get("/personas/market", (req, res) => {
return res.json({
personas: repositories.listMarketPersonas({
admin: isAdmin(req),
username: req.session.user,
personaType: parseMarketPersonaType(String(req.query.personaType || "").trim()),
sort: String(req.query.sort || "best").trim()
})
});
});
router.get("/dashboard/summary", (req, res) => {
return res.json({username: req.session.user, stats: repositories.getDashboardSummary(req.session.user)});
});
router.post("/personas/:id/publish", (req, res) => {
const persona = repositories.getPersona(Number(req.params.id), req.session.user);
if (!persona) return res.status(404).json({error: "Persona not found"});
if (persona.source_market_id) return res.status(403).json({error: "Collected personas cannot be published."});
const tags = Array.isArray(req.body?.tags) ? req.body.tags.map((tag) => String(tag || "").trim()).filter(Boolean).slice(0, 8) : [];
return res.json({persona: repositories.upsertMarketPersona(persona.id, req.session.user, persona, tags)});
});
router.post("/personas/:id/unpublish", (req, res) => {
const persona = repositories.getPersona(Number(req.params.id), req.session.user);
if (!persona) return res.status(404).json({error: "Persona not found"});
if (persona.source_market_id) return res.status(403).json({error: "Collected personas cannot be unpublished."});
const result = repositories.deletePublishedPersona(persona.id, req.session.user);
if (result.changes === 0) return res.status(404).json({error: "Persona is not published."});
return res.json({message: "Persona unpublished"});
});
router.post("/personas/market/:id/collect", (req, res) => {
const user = req.session.user;
const marketPersona = repositories.getMarketPersona(Number(req.params.id));
if (!marketPersona || marketPersona.soft_deleted_at || marketPersona.moderation_status === "rejected") return res.status(404).json({error: "Market persona not found"});
if (marketPersona.creator_username === user) return res.status(400).json({error: "You already own this persona."});
if (repositories.getPersonaBySourceMarket(user, marketPersona.id)) return res.status(400).json({error: "You already collected this persona."});
const persona = repositories.collectMarketPersona(user, marketPersona);
repositories.incrementMarketUsageCount(marketPersona.id);
let activeUserPersonaId = null;
if (req.body?.equip && marketPersona.persona_type === "user") {
repositories.setActiveUserPersona(user, persona.id);
activeUserPersonaId = persona.id;
}
return res.json({persona, activeUserPersonaId});
});
router.post("/personas/market/:id/favorite", (req, res) => {
const marketId = Number(req.params.id);
const persona = repositories.getMarketPersona(marketId);
if (!persona) return res.status(404).json({error: "Market persona not found"});
const active = repositories.toggleMarketFavorite(marketId, req.session.user);
return res.json({favorite: active, persona: repositories.getMarketPersona(marketId)});
});
router.post("/personas/market/:id/rate", (req, res) => {
const marketId = Number(req.params.id);
const rating = Number(req.body?.rating);
if (!Number.isInteger(rating) || rating < 1 || rating > 5) return res.status(400).json({error: "Rating must be between 1 and 5"});
const persona = repositories.getMarketPersona(marketId);
if (!persona) return res.status(404).json({error: "Market persona not found"});
return res.json({persona: repositories.rateMarketPersona(marketId, req.session.user, rating)});
});
router.post("/personas/market/:id/report", (req, res) => {
const marketId = Number(req.params.id);
const reason = String(req.body?.reason || "").trim();
if (!reason) return res.status(400).json({error: "Reason is required"});
const persona = repositories.getMarketPersona(marketId);
if (!persona) return res.status(404).json({error: "Market persona not found"});
repositories.reportMarketPersona(marketId, req.session.user, reason, normalizeOptionalText(req.body?.details));
return res.json({message: "Report submitted"});
});
router.post("/personas/market/:id/chat", async (req, res) => {
const user = req.session.user;
const marketPersona = repositories.getMarketPersona(Number(req.params.id));
const userPersonaId = req.body?.userPersonaId ? Number(req.body.userPersonaId) : null;
if (!marketPersona || marketPersona.persona_type !== "assistant") {
return res.status(404).json({error: "Market AI Character not found"});
}
let userPersona = null;
if (userPersonaId) {
userPersona = repositories.getPersona(userPersonaId, user);
if (!userPersona || userPersona.persona_type !== "user") {
return res.status(404).json({error: "User persona not found"});
}
}
const existingPersona = repositories.getPersonaBySourceMarket(user, marketPersona.id);
const persona = existingPersona
? repositories.getPersona(existingPersona.id, user)
: repositories.collectMarketPersona(user, marketPersona);
if (!existingPersona) {
repositories.incrementMarketUsageCount(marketPersona.id);
}
const scenarioPrompt = normalizeOptionalText(req.body?.scenarioPrompt);
const sceneSummary = chatService.buildRoleplaySceneSummary(persona, userPersona, scenarioPrompt);
let chat;
try {
chat = chatService.getOrCreatePersonaChat(user, persona, userPersonaId, scenarioPrompt, sceneSummary);
} catch (error) {
if (error.message === "CHAT_LIMIT_REACHED") {
return res.status(400).json({error: `Maximum of ${config.chatLimit} chats reached. Please delete an old chat to create a new one.`});
}
throw error;
}
if (repositories.listChatMessages(chat.id).length > 0) {
return res.json({persona, chat, existing: true, generatedInitialMessage: false});
}
repositories.updateChatScene(chat.id, user, scenarioPrompt, sceneSummary);
chat = repositories.getChat(chat.id, user);
try {
const opener = await chatService.generateRoleplayOpener({
selectedModel: "mistral:latest",
assistantPersona: persona,
userPersona,
scenarioPrompt,
sceneSummary
});
if (!opener || chatService.containsUserVoiceInRoleplayOpener(opener, userPersona)) {
return res.json({persona, chat, existing: false, generatedInitialMessage: false});
}
chatService.insertChatMessage(chat.id, "bot", opener, {
retryVariants: [opener],
retryActiveIndex: 0,
retryRetriesUsed: 0,
retryPromptMessageId: null
});
repositories.touchChat(chat.id, user);
return res.json({persona, chat: repositories.getChat(chat.id, user), existing: false, generatedInitialMessage: true, opener});
} catch {
return res.json({persona, chat, existing: false, generatedInitialMessage: false, degraded: true});
}
});
router.post("/roleplays/start", validateBody(roleplayStartValidator), async (req, res) => {
const user = req.session.user;
const {assistantPersonaId, userPersonaId, scenarioPrompt, model} = req.validatedBody;
const assistantPersona = repositories.getPersona(assistantPersonaId, user);
if (!assistantPersona || assistantPersona.persona_type !== "assistant") return res.status(404).json({error: "Character not found"});
let userPersona = null;
if (userPersonaId) {
userPersona = repositories.getPersona(userPersonaId, user);
if (!userPersona || userPersona.persona_type !== "user") return res.status(404).json({error: "User persona not found"});
}
const sceneSummary = chatService.buildRoleplaySceneSummary(assistantPersona, userPersona, scenarioPrompt);
let chat;
try {
chat = chatService.getOrCreatePersonaChat(user, assistantPersona, userPersonaId, scenarioPrompt, sceneSummary);
} catch (error) {
if (error.message === "CHAT_LIMIT_REACHED") {
return res.status(400).json({error: `Maximum of ${config.chatLimit} chats reached. Please delete an old chat to create a new one.`});
}
throw error;
}
if (repositories.listChatMessages(chat.id).length > 0) {
return res.json({chat, existing: true, generatedInitialMessage: false});
}
repositories.updateChatScene(chat.id, user, scenarioPrompt, sceneSummary);
chat = repositories.getChat(chat.id, user);
try {
const opener = await chatService.generateRoleplayOpener({
selectedModel: model,
assistantPersona,
userPersona,
scenarioPrompt,
sceneSummary
});
if (!opener) return res.status(500).json({error: "Failed to generate the character opener"});
chatService.insertChatMessage(chat.id, "bot", opener, {
retryVariants: [opener],
retryActiveIndex: 0,
retryRetriesUsed: 0,
retryPromptMessageId: null
});
repositories.touchChat(chat.id, user);
return res.json({chat: repositories.getChat(chat.id, user), existing: false, generatedInitialMessage: true, opener});
} catch (error) {
const mapped = modelService.mapError(error);
return res.status(mapped.status).json(mapped.body);
}
});
router.get("/templates", (req, res) => {
const personaId = req.query.personaId ? Number(req.query.personaId) : null;
return res.json({templates: repositories.listPromptTemplatesForPersona(req.session.user, personaId)});
});
router.post("/templates", validateBody(templateValidator), (req, res) => {
return res.json({template: repositories.createPromptTemplate(req.session.user, req.validatedBody)});
});
router.put("/templates/:id", validateBody(templateValidator), (req, res) => {
const result = repositories.updatePromptTemplate(Number(req.params.id), req.session.user, req.validatedBody);
if (result.changes === 0) return res.status(404).json({error: "Template not found"});
return res.json({template: repositories.getPromptTemplate(Number(req.params.id))});
});
router.delete("/templates/:id", (req, res) => {
const result = repositories.deletePromptTemplate(Number(req.params.id), req.session.user);
if (result.changes === 0) return res.status(404).json({error: "Template not found"});
return res.json({message: "Template deleted"});
});
router.get("/admin/reports", (req, res) => {
if (!isAdmin(req)) return res.status(403).json({error: "Admin only"});
return res.json({reports: repositories.listMarketReports(), personas: repositories.listMarketPersonas({admin: true, username: req.session.user})});
});
router.post("/admin/reports/:id", (req, res) => {
if (!isAdmin(req)) return res.status(403).json({error: "Admin only"});
const status = String(req.body?.status || "").trim() || "reviewed";
repositories.updateMarketReportStatus(Number(req.params.id), status);
return res.json({message: "Report updated"});
});
router.post("/admin/personas/market/:id/moderate", (req, res) => {
if (!isAdmin(req)) return res.status(403).json({error: "Admin only"});
const status = String(req.body?.status || "").trim() || "approved";
repositories.updateMarketModeration(Number(req.params.id), {
status,
notes: normalizeOptionalText(req.body?.notes),
softDelete: Boolean(req.body?.softDelete)
});
return res.json({persona: repositories.getMarketPersona(Number(req.params.id))});
});
return router;
}
+66
View File
@@ -0,0 +1,66 @@
import express from "express";
import bcrypt from "bcrypt";
import {requireLogin} from "../middleware/auth.js";
import {validateBody} from "../middleware/validate.js";
const usernameValidator = (body) => {
const username = String(body?.username || "").trim();
const password = String(body?.password || "");
if (!username) return {error: "New username is required"};
if (username.length < 3) return {error: "Username must be at least 3 characters"};
if (!password) return {error: "Current password is required"};
return {value: {username, password}};
};
const passwordValidator = (body) => {
const currentPassword = String(body?.currentPassword || "");
const newPassword = String(body?.newPassword || "");
if (!currentPassword || !newPassword) return {error: "Current and new password are required"};
if (newPassword.length < 6) return {error: "New password must be at least 6 characters"};
return {value: {currentPassword, newPassword}};
};
export function createSettingsRouter({repositories}) {
const router = express.Router();
router.use(requireLogin);
router.get("/settings/profile", (req, res) => {
const user = repositories.getUser(req.session.user);
if (!user) return res.status(404).json({error: "User not found"});
return res.json({username: user.username});
});
router.put("/settings/username", validateBody(usernameValidator), async (req, res) => {
const currentUsername = req.session.user;
const {username: nextUsername, password} = req.validatedBody;
if (nextUsername === currentUsername) {
return res.status(400).json({error: "That is already your username"});
}
const currentUser = repositories.getUser(currentUsername);
if (!currentUser) return res.status(404).json({error: "User not found"});
const valid = await bcrypt.compare(password, currentUser.password);
if (!valid) return res.status(403).json({error: "Current password is incorrect"});
if (repositories.getUser(nextUsername)) {
return res.status(400).json({error: "Username already exists"});
}
repositories.renameUser(currentUsername, nextUsername);
req.session.user = nextUsername;
return res.json({username: nextUsername});
});
router.put("/settings/password", validateBody(passwordValidator), async (req, res) => {
const username = req.session.user;
const {currentPassword, newPassword} = req.validatedBody;
const user = repositories.getUser(username);
if (!user) return res.status(404).json({error: "User not found"});
const valid = await bcrypt.compare(currentPassword, user.password);
if (!valid) return res.status(403).json({error: "Current password is incorrect"});
const hashed = await bcrypt.hash(newPassword, 10);
repositories.updatePassword(hashed, username);
return res.json({message: "Password updated"});
});
return router;
}
+12
View File
@@ -0,0 +1,12 @@
import express from "express";
export function createSystemRouter({modelService}) {
const router = express.Router();
router.get("/health", async (req, res) => {
const model = await modelService.checkHealth();
return res.status(model.ok ? 200 : 503).json({ok: model.ok, model});
});
return router;
}
+319
View File
@@ -0,0 +1,319 @@
import {
buildPersonaPrompt,
buildRoleplayDirectionPrompt,
buildRoleplayOpenerPrompt,
buildRoleplayReplyGuardPrompt,
buildRoleplaySceneSummary,
buildUserPersonaPrompt,
containsUserVoiceInRoleplayOpener
} from "./personaService.js";
export function createChatService(repositories, modelService, config) {
const retryStylePrompts = {
shorter: "Rewrite the assistant reply to be shorter and tighter while preserving its meaning.",
direct: "Rewrite the assistant reply to be more direct and less padded.",
emotional: "Rewrite the assistant reply to be more emotionally expressive without changing the core meaning.",
"stay-in-character": "Rewrite the assistant reply to stay more strongly in character and voice.",
dialogue: "Rewrite the assistant reply as dialogue only. Remove narration unless required for clarity."
};
const clampRetryActiveIndex = (index, variants) => {
const numeric = Number(index);
if (!Number.isInteger(numeric) || numeric < 0) return 0;
return Math.min(numeric, Math.max(0, variants.length - 1));
};
const parseRetryVariants = (rawValue, content) => {
if (!rawValue) return [content || ""];
try {
const parsed = JSON.parse(rawValue);
if (Array.isArray(parsed)) {
const normalized = parsed.map((item) => String(item || "").trim()).filter(Boolean);
if (normalized.length) return normalized;
}
} catch {
return [content || ""];
}
return [content || ""];
};
const formatChatMessage = (row) => {
if (!row) return null;
const retryVariants = parseRetryVariants(row.retry_variants, row.content);
const retryActiveIndex = clampRetryActiveIndex(row.retry_active_index, retryVariants);
return {
id: row.id,
role: row.role,
content: row.content,
modelName: row.model_name || null,
retryVariants,
retryActiveIndex,
retryRetriesUsed: Number(row.retry_retries_used || 0),
retryPromptMessageId: row.retry_prompt_message_id ?? null
};
};
const insertChatMessage = (chatId, role, content, retryState = null, modelName = null) => {
const normalizedContent = String(content || "");
const payload = retryState
? {
retryVariants: Array.isArray(retryState.retryVariants) && retryState.retryVariants.length ? retryState.retryVariants : [normalizedContent],
retryActiveIndex: clampRetryActiveIndex(retryState.retryActiveIndex ?? 0, retryState.retryVariants || [normalizedContent]),
retryRetriesUsed: Number(retryState.retryRetriesUsed || 0),
retryPromptMessageId: retryState.retryPromptMessageId ?? null
}
: {
retryVariants: [normalizedContent],
retryActiveIndex: 0,
retryRetriesUsed: 0,
retryPromptMessageId: null
};
repositories.insertChatMessage(chatId, role, normalizedContent, payload, modelName);
};
const persistChatMessageRetryState = ({chatId, messageId, content, retryVariants, retryActiveIndex, retryRetriesUsed, retryPromptMessageId}) => {
repositories.updateChatMessageRetryState(chatId, messageId, {
content,
retryVariants: retryVariants || [content || ""],
retryActiveIndex: clampRetryActiveIndex(retryActiveIndex, retryVariants || [content || ""]),
retryRetriesUsed: Number(retryRetriesUsed || 0),
retryPromptMessageId: retryPromptMessageId ?? null
});
};
const getChatMessageByIndex = (chatId, index) => {
const safeIndex = Number(index);
if (!Number.isInteger(safeIndex) || safeIndex < 0) return null;
return repositories.getChatMessageByIndex(chatId, safeIndex) || null;
};
const getOrCreatePersonaChat = (user, persona, userPersonaId = null, scenarioPrompt = null, scenarioSummary = null) => {
const existing = repositories.getChatByParticipants(user, persona.id, userPersonaId);
if (existing) return existing;
if (repositories.countChats(user) >= config.chatLimit) {
throw new Error("CHAT_LIMIT_REACHED");
}
return repositories.createChat(user, persona.name, persona.id, userPersonaId, scenarioPrompt, scenarioSummary);
};
const generateRoleplayOpener = async ({selectedModel = "mistral:latest", assistantPersona, userPersona, scenarioPrompt, sceneSummary}) => {
const baseMessages = [
{role: "system", content: buildPersonaPrompt(assistantPersona)},
...(userPersona ? [{role: "system", content: buildUserPersonaPrompt(userPersona)}] : []),
{role: "system", content: buildRoleplayDirectionPrompt({assistantPersona, userPersona, scenarioPrompt, sceneSummary})},
{role: "user", content: buildRoleplayOpenerPrompt({assistantPersona, userPersona, scenarioPrompt, sceneSummary})}
];
let opener = await modelService.generateReply(selectedModel, baseMessages);
if (!containsUserVoiceInRoleplayOpener(opener, userPersona)) {
return opener;
}
return modelService.generateReply(selectedModel, [
...baseMessages,
{role: "assistant", content: opener || ""},
{
role: "user",
content: "Rewrite this opening. Hard rule: only the assistant character may speak or act. Never write or imply user speech, thoughts, feelings, reactions, decisions, actions, or labels like 'User:'/'You:'. Stop before the user's reply."
}
]);
};
const generateValidatedRoleplayReply = async ({selectedModel, messagesPayload, userPersona}) => {
let reply = await modelService.generateReply(selectedModel, messagesPayload);
if (reply && !containsUserVoiceInRoleplayOpener(reply, userPersona)) {
return reply;
}
reply = await modelService.generateReply(selectedModel, [
...messagesPayload,
{role: "assistant", content: reply || ""},
{
role: "user",
content: "Rewrite your last reply. Hard rule: only the assistant character may speak or act. Never write or imply user speech, thoughts, feelings, reactions, decisions, or actions. Stop before the user's reply."
}
]);
if (!reply || containsUserVoiceInRoleplayOpener(reply, userPersona)) {
return null;
}
return reply;
};
const appendRetryStyleInstruction = (messagesPayload, retryStyle) => {
if (!retryStyle || !retryStylePrompts[retryStyle]) return messagesPayload;
return [...messagesPayload, {role: "user", content: retryStylePrompts[retryStyle]}];
};
const buildRetryPayload = async ({chatId, user, session, selectedModel, targetMessage, retryStyle = null}) => {
const activePersona = repositories.getAssistantPersonaForChat(chatId, user);
const activeUserPersona = repositories.getUserPersonaForChat(chatId, user) || repositories.getActiveUserPersona(user);
const promptMessage = repositories.getPreviousUserMessage(chatId, targetMessage.id);
if (!promptMessage) {
if (!activePersona || !session.scenario_summary) {
return {error: "No previous user prompt found for retry"};
}
const opener = await generateRoleplayOpener({
selectedModel,
assistantPersona: activePersona,
userPersona: activeUserPersona,
scenarioPrompt: session.scenario_prompt,
sceneSummary: session.scenario_summary
});
if (!opener || containsUserVoiceInRoleplayOpener(opener, activeUserPersona)) {
return {error: "Failed to regenerate message"};
}
return {fullReply: opener, promptMessageId: null};
}
const conversation = repositories.getRecentChatMessagesUpToId(chatId, promptMessage.id).reverse();
const messagesPayload = [];
if (activePersona) messagesPayload.push({role: "system", content: buildPersonaPrompt(activePersona)});
if (activeUserPersona) messagesPayload.push({role: "system", content: buildUserPersonaPrompt(activeUserPersona)});
if (activePersona && session.scenario_summary) {
messagesPayload.push({
role: "system",
content: buildRoleplayDirectionPrompt({
assistantPersona: activePersona,
userPersona: activeUserPersona,
scenarioPrompt: session.scenario_prompt,
sceneSummary: session.scenario_summary
})
});
}
messagesPayload.push(...conversation.map((message) => ({
role: message.role === "bot" ? "assistant" : "user",
content: message.content
})));
if (activePersona) messagesPayload.push({role: "system", content: buildRoleplayReplyGuardPrompt()});
const styledPayload = appendRetryStyleInstruction(messagesPayload, retryStyle);
const fullReply = activePersona
? await generateValidatedRoleplayReply({selectedModel, messagesPayload: styledPayload, userPersona: activeUserPersona})
: await modelService.generateReply(selectedModel, styledPayload);
if (!fullReply) return {error: "Failed to regenerate message"};
return {fullReply, promptMessageId: promptMessage.id};
};
return {
formatChatMessage,
getChatMessageByIndex,
getOrCreatePersonaChat,
buildRoleplaySceneSummary,
generateRoleplayOpener,
insertChatMessage,
containsUserVoiceInRoleplayOpener,
persistChatMessageRetryState,
buildMessagesPayload({user, chatId, message}) {
const session = repositories.getChat(chatId, user);
if (!session) return null;
const activePersona = repositories.getAssistantPersonaForChat(chatId, user);
const activeUserPersona = repositories.getUserPersonaForChat(chatId, user) || repositories.getActiveUserPersona(user);
const history = repositories.getRecentChatMessages(chatId).reverse();
const conversation = [...history, {role: "user", content: message}];
const messagesPayload = [];
if (activePersona) messagesPayload.push({role: "system", content: buildPersonaPrompt(activePersona)});
if (activeUserPersona) messagesPayload.push({role: "system", content: buildUserPersonaPrompt(activeUserPersona)});
if (activePersona && session.scenario_summary) {
messagesPayload.push({
role: "system",
content: buildRoleplayDirectionPrompt({
assistantPersona: activePersona,
userPersona: activeUserPersona,
scenarioPrompt: session.scenario_prompt,
sceneSummary: session.scenario_summary
})
});
}
messagesPayload.push(...conversation.map((entry) => ({
role: entry.role === "bot" ? "assistant" : "user",
content: entry.content
})));
if (activePersona) messagesPayload.push({role: "system", content: buildRoleplayReplyGuardPrompt()});
return {session, activePersona, activeUserPersona, messagesPayload};
},
async sendChatMessage({user, chatId, message, model}) {
const session = repositories.getChat(chatId, user);
if (!session) return {error: "Chat not found", status: 404};
const payload = this.buildMessagesPayload({user, chatId, message});
insertChatMessage(chatId, "user", message);
repositories.touchChat(chatId, user);
const activePersona = payload.activePersona;
const activeUserPersona = payload.activeUserPersona;
const messagesPayload = payload.messagesPayload;
const fullReply = activePersona
? await generateValidatedRoleplayReply({selectedModel: model, messagesPayload, userPersona: activeUserPersona})
: await modelService.generateReply(model, messagesPayload);
if (!fullReply) return {error: "Failed to generate a valid character reply", status: 500};
insertChatMessage(chatId, "bot", fullReply, {
retryVariants: [fullReply],
retryActiveIndex: 0,
retryRetriesUsed: 0,
retryPromptMessageId: null
}, model);
repositories.touchChat(chatId, user);
return {reply: fullReply};
},
async streamChatMessage({user, chatId, message, model, onChunk}) {
const session = repositories.getChat(chatId, user);
if (!session) return {error: "Chat not found", status: 404};
const payload = this.buildMessagesPayload({user, chatId, message});
insertChatMessage(chatId, "user", message);
repositories.touchChat(chatId, user);
const activePersona = payload.activePersona;
const activeUserPersona = payload.activeUserPersona;
const messagesPayload = payload.messagesPayload;
const fullReply = activePersona
? await modelService.streamReply(model, messagesPayload, onChunk)
: await modelService.streamReply(model, messagesPayload, onChunk);
if (!fullReply) return {error: "Failed to generate a valid character reply", status: 500};
insertChatMessage(chatId, "bot", fullReply, {
retryVariants: [fullReply],
retryActiveIndex: 0,
retryRetriesUsed: 0,
retryPromptMessageId: null
}, model);
repositories.touchChat(chatId, user);
if (activePersona && containsUserVoiceInRoleplayOpener(fullReply, activeUserPersona)) {
return {error: "Failed to generate a valid character reply", status: 500};
}
return {reply: fullReply};
},
async retryMessage({user, chatId, selectedModel, targetMessage, retryStyle = null}) {
const session = repositories.getChat(chatId, user);
if (!session) return {error: "Chat not found", status: 404};
const retryResult = await buildRetryPayload({chatId, user, session, selectedModel, targetMessage, retryStyle});
if (retryResult.error) return {error: retryResult.error, status: 400};
const existingMessage = formatChatMessage(targetMessage);
const retryVariants = [...existingMessage.retryVariants, retryResult.fullReply];
persistChatMessageRetryState({
chatId,
messageId: targetMessage.id,
content: retryResult.fullReply,
retryVariants,
retryActiveIndex: retryVariants.length - 1,
retryRetriesUsed: existingMessage.retryRetriesUsed + 1,
retryPromptMessageId: retryResult.promptMessageId
});
repositories.touchChat(chatId, user);
return {
message: formatChatMessage(repositories.getChatMessage(chatId, targetMessage.id)),
promptMessageId: retryResult.promptMessageId,
retryStyle
};
}
};
}
+196
View File
@@ -0,0 +1,196 @@
import {exec} from "child_process";
class ModelServiceError extends Error {
constructor(code, message, status = 500) {
super(message);
this.code = code;
this.status = status;
}
}
export function createModelService(config, overrides = {}) {
const modelState = new Map();
let modelsCache = null;
let modelsCacheAt = 0;
let lastHealth = {ok: true, checkedAt: null, error: null};
const fetchImpl = overrides.fetch || global.fetch;
const ensureModelState = (model) => {
if (!modelState.has(model)) {
modelState.set(model, {activeRequests: 0, timer: null});
}
return modelState.get(model);
};
const scheduleModelUnload = (model) => {
const state = ensureModelState(model);
if (state.timer) clearTimeout(state.timer);
state.timer = setTimeout(() => {
if (state.activeRequests > 0) return;
exec(`ollama stop ${model}`, () => {
modelState.delete(model);
});
}, config.modelUnloadAfterMs);
};
const withTimeout = async (timeoutMs, callback) => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await callback(controller.signal);
} catch (error) {
if (error?.name === "AbortError") {
throw new ModelServiceError("MODEL_TIMEOUT", "Model backend timed out.", 504);
}
throw error;
} finally {
clearTimeout(timer);
}
};
const readStreamingReply = async (response, onChunk = null) => {
if (!response.body) {
throw new ModelServiceError("MODEL_BAD_RESPONSE", "Model backend returned no body.", 502);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let fullReply = "";
while (true) {
const {value, done} = await reader.read();
if (done) break;
buffer += decoder.decode(value, {stream: true});
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
const data = JSON.parse(trimmed);
if (data.message?.content) {
fullReply += data.message.content;
if (onChunk) onChunk(data.message.content, fullReply);
}
}
}
if (buffer.trim()) {
const data = JSON.parse(buffer.trim());
if (data.message?.content) {
fullReply += data.message.content;
if (onChunk) onChunk(data.message.content, fullReply);
}
}
return fullReply.trim();
};
return {
async generateReply(model, messagesPayload) {
const state = ensureModelState(model);
state.activeRequests += 1;
try {
const response = await withTimeout(config.modelRequestTimeoutMs, (signal) => fetchImpl(`${config.modelApiBaseUrl}/chat`, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({model, messages: messagesPayload, stream: true}),
signal
}));
if (!response.ok) {
throw new ModelServiceError("MODEL_UPSTREAM_ERROR", `Model backend returned ${response.status}.`, 502);
}
const fullReply = await readStreamingReply(response);
lastHealth = {ok: true, checkedAt: new Date().toISOString(), error: null};
return fullReply;
} catch (error) {
lastHealth = {
ok: false,
checkedAt: new Date().toISOString(),
error: error instanceof ModelServiceError ? error.message : "Model backend unavailable."
};
if (error instanceof ModelServiceError) throw error;
throw new ModelServiceError("MODEL_UNAVAILABLE", "Model backend unavailable.", 502);
} finally {
state.activeRequests = Math.max(0, state.activeRequests - 1);
scheduleModelUnload(model);
}
},
async streamReply(model, messagesPayload, onChunk) {
const state = ensureModelState(model);
state.activeRequests += 1;
try {
const response = await withTimeout(config.modelRequestTimeoutMs, (signal) => fetchImpl(`${config.modelApiBaseUrl}/chat`, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({model, messages: messagesPayload, stream: true}),
signal
}));
if (!response.ok) {
throw new ModelServiceError("MODEL_UPSTREAM_ERROR", `Model backend returned ${response.status}.`, 502);
}
const fullReply = await readStreamingReply(response, onChunk);
lastHealth = {ok: true, checkedAt: new Date().toISOString(), error: null};
return fullReply;
} catch (error) {
lastHealth = {
ok: false,
checkedAt: new Date().toISOString(),
error: error instanceof ModelServiceError ? error.message : "Model backend unavailable."
};
if (error instanceof ModelServiceError) throw error;
throw new ModelServiceError("MODEL_UNAVAILABLE", "Model backend unavailable.", 502);
} finally {
state.activeRequests = Math.max(0, state.activeRequests - 1);
scheduleModelUnload(model);
}
},
async listModels() {
if (modelsCache && Date.now() - modelsCacheAt < config.modelsCacheTtlMs) {
return modelsCache;
}
try {
const response = await withTimeout(config.modelListTimeoutMs, (signal) => fetchImpl(`${config.modelApiBaseUrl}/tags`, {signal}));
if (!response.ok) {
throw new ModelServiceError("MODEL_LIST_FAILED", "Failed to load models.", 502);
}
modelsCache = await response.json();
modelsCacheAt = Date.now();
lastHealth = {ok: true, checkedAt: new Date().toISOString(), error: null};
return modelsCache;
} catch (error) {
lastHealth = {
ok: false,
checkedAt: new Date().toISOString(),
error: error instanceof ModelServiceError ? error.message : "Failed to load models."
};
if (error instanceof ModelServiceError) throw error;
throw new ModelServiceError("MODEL_UNAVAILABLE", "Failed to load models.", 502);
}
},
async checkHealth() {
try {
await this.listModels();
return {ok: true, checkedAt: new Date().toISOString(), error: null};
} catch (error) {
return {ok: false, checkedAt: new Date().toISOString(), error: error.message};
}
},
getLastHealth() {
return lastHealth;
},
mapError(error) {
if (error instanceof ModelServiceError) {
return {status: error.status, body: {error: error.message, code: error.code}};
}
return {status: 500, body: {error: "Model request failed", code: "MODEL_UNKNOWN"}};
}
};
}
+211
View File
@@ -0,0 +1,211 @@
const PERSONA_FIELD_LIMITS = {
name: 200,
pronouns: 40,
appearance: 9000,
background: 9000,
details: 9000,
exampleDialogues: 12000
};
const blockedPersonaPatterns = [
/\b(child|children|kid|kids|minor|underage|under-aged|teenager|young girl|young boy|loli|shota)\b/i,
/\b(nazi|hitler|third reich|kkk|ku klux klan|white power|nigger|nigga)\b/i,
/\b(rape|rapist|sexual assault|molest|molestation|incest|bestiality|zoophilia|necrophilia)\b/i,
/\b(self-harm|self harm|suicide fetish)\b/i
];
const normalizePersonaField = (value) => {
const trimmed = (value || "").trim();
return trimmed || null;
};
export function validatePersonaPayload(payload, personaType = payload?.personaType) {
const sanitized = {
name: (payload?.name || "").trim(),
pronouns: normalizePersonaField(payload?.pronouns),
appearance: normalizePersonaField(payload?.appearance),
background: normalizePersonaField(payload?.background),
details: normalizePersonaField(payload?.details),
exampleDialogues: personaType === "assistant" ? normalizePersonaField(payload?.exampleDialogues) : null
};
if (!sanitized.name) {
return {error: "Name is required"};
}
for (const [key, limit] of Object.entries(PERSONA_FIELD_LIMITS)) {
const value = sanitized[key];
if (value && value.length > limit) {
return {error: `${key.charAt(0).toUpperCase() + key.slice(1)} is too long`};
}
}
const combinedText = Object.values(sanitized).filter(Boolean).join("\n");
if (blockedPersonaPatterns.some((pattern) => pattern.test(combinedText))) {
return {error: "This persona contains disallowed content. Remove sexual content involving minors, extreme sexual violence, or hateful/extremist material."};
}
return {value: sanitized};
}
export function normalizeOptionalText(value) {
const normalized = String(value || "").trim();
return normalized || null;
}
export function clampText(value, limit = 260) {
const normalized = String(value || "").replace(/\s+/g, " ").trim();
if (normalized.length <= limit) return normalized;
return `${normalized.slice(0, limit - 3).trimEnd()}...`;
}
export function summarizePersona(persona) {
if (!persona) return "";
return [
persona.name ? `Name: ${persona.name}` : null,
persona.pronouns ? `Pronouns: ${persona.pronouns}` : null,
persona.background ? `Background: ${persona.background}` : null,
persona.details ? `Traits: ${persona.details}` : null
].filter(Boolean).join(" | ");
}
export function buildPersonaPrompt(persona) {
const lines = [
"SYSTEM ROLE:",
"You are the AI assistant in this conversation.",
"You must speak, think, and respond AS the persona described below.",
"Do NOT speak as, write for, decide for, or roleplay as the user or the user's persona.",
"Do NOT narrate the user's dialogue, thoughts, feelings, choices, or actions.",
"Do NOT imply what the user says next, how they feel, what they decide, or what they physically do.",
"Never complete both sides of the exchange. Only produce the assistant character's side.",
"Do NOT switch roles or perspectives.",
"Always respond in-character, using first-person language where appropriate.",
"",
"ASSISTANT PERSONA:"
];
if (persona.name) lines.push(`Name: ${persona.name}`);
if (persona.pronouns) lines.push(`Pronouns: ${persona.pronouns}`);
if (persona.appearance) lines.push(`Appearance: ${persona.appearance}`);
if (persona.background) lines.push(`Background: ${persona.background}`);
if (persona.details) lines.push(`Additional Traits: ${persona.details}`);
if (persona.example_dialogues) {
lines.push("", "EXAMPLE DIALOGUES:");
lines.push("Use these examples to mirror tone, cadence, and phrasing without rigidly repeating them.");
lines.push(persona.example_dialogues);
}
return lines.join("\n");
}
export function buildUserPersonaPrompt(persona) {
const lines = [
"USER CONTEXT:",
"The human user is roleplaying as the persona below.",
"This information is for context only.",
"Do NOT speak as this character.",
"Do NOT write this character's dialogue, thoughts, feelings, choices, or actions unless the user explicitly supplies them.",
"Do NOT predict, script, continue, or resolve the user's side of the scene.",
"Respond TO this persona, not AS them.",
"",
"USER PERSONA:"
];
if (persona.name) lines.push(`Name: ${persona.name}`);
if (persona.pronouns) lines.push(`Pronouns: ${persona.pronouns}`);
if (persona.appearance) lines.push(`Appearance: ${persona.appearance}`);
if (persona.background) lines.push(`Background: ${persona.background}`);
if (persona.details) lines.push(`Additional Traits: ${persona.details}`);
return lines.join("\n");
}
export function buildRoleplaySceneSummary(assistantPersona, userPersona, scenarioPrompt = "") {
const assistantName = assistantPersona?.name || "The assistant";
const userName = userPersona?.name || "the user";
const assistantHook = assistantPersona?.details || assistantPersona?.background || "stays strongly in character";
const userHook = userPersona?.details || userPersona?.background || "enters the scene as themselves";
const scenarioSeed = scenarioPrompt
? clampText(scenarioPrompt, 180)
: `A fitting opening grows naturally from ${assistantName}'s role, goals, and manner toward ${userName}.`;
return clampText(`${assistantName} leads the opening. Their approach is shaped by ${assistantHook}. ${userName} is framed through ${userHook}. Scene seed: ${scenarioSeed}`, 340);
}
export function buildRoleplayDirectionPrompt({assistantPersona, userPersona, scenarioPrompt, sceneSummary}) {
const lines = [
"ROLEPLAY DIRECTION:",
"Create an immersive one-on-one roleplay between the assistant persona and the user.",
"Ground the interaction in a specific scenario, place, and immediate dramatic situation.",
"Make the assistant's first instinct, language, and priorities fit their persona exactly.",
"Use the user persona as interaction context so the assistant addresses them in a fitting way.",
"Format scene description, body language, and nonverbal actions in italics using *word*.",
"Format direct speech in double quotes like \"word\".",
"Format shouted words, sharp emphasis, or explosive expressions in bold using **word**.",
"Keep formatting readable and intentional instead of wrapping every sentence in markdown.",
"Avoid generic openings, meta commentary, and requests for permission to begin.",
"Do not write dialogue, internal thoughts, choices, reactions, or actions for the user.",
"Never output any lines like 'User:' or 'You:'.",
"Never narrate what the user does, says, thinks, feels, wants, decides, notices, or remembers.",
"Never include quoted text that belongs to the user.",
"The opening must contain only the assistant character's own spoken words and optional self-actions.",
"Prefer a concrete opening beat over vague exposition.",
"Include a subtle hook, tension, invitation, or problem that gives the user something to respond to.",
"",
`SCENE SUMMARY: ${sceneSummary}`
];
if (scenarioPrompt) lines.push(`USER SCENARIO SEED: ${scenarioPrompt}`);
if (assistantPersona) lines.push("", `ASSISTANT SNAPSHOT: ${summarizePersona(assistantPersona)}`);
if (userPersona) lines.push(`USER SNAPSHOT: ${summarizePersona(userPersona)}`);
return lines.join("\n");
}
export function buildRoleplayOpenerPrompt({assistantPersona, userPersona, scenarioPrompt, sceneSummary}) {
const assistantName = assistantPersona?.name || "the assistant";
const userName = userPersona?.name || "the user";
const sceneSeed = scenarioPrompt
? `Use this scenario seed: ${scenarioPrompt}`
: `Invent a fresh scenario that suits ${assistantName} and how they would realistically meet or confront ${userName}.`;
return [
`Write the first in-character message from ${assistantName}.`,
sceneSeed,
`Address ${userName} naturally within the scene.`,
"The message should establish the situation immediately instead of explaining setup out of character.",
"Use 1 to 3 short paragraphs. Sensory detail is allowed, but keep momentum.",
"Write scene description, body language, and nonverbal actions in italics using *word*.",
"Write direct speech in double quotes like \"word\".",
"Use bold markdown like **word** only for loud, forceful, or strongly emphasized expressions.",
"Write only the assistant character's words and optional self-actions.",
"Do not write any user dialogue, quoted lines, thoughts, feelings, reactions, choices, or actions.",
"Do not use labels such as 'User:' or 'You:'.",
"Do not script a back-and-forth exchange. Stop before the user's reply.",
"End with a line, question, action, or reveal that gives the user an obvious way to answer.",
`Keep this scene continuity in mind: ${sceneSummary}`
].join("\n");
}
export function buildRoleplayReplyGuardPrompt() {
return [
"ROLEPLAY REPLY RULES:",
"Write only the assistant character's next reply.",
"Never speak as the user.",
"Never write the user's dialogue.",
"Never write the user's thoughts, feelings, reactions, decisions, or actions.",
"Never imply what the user says or does next.",
"Never produce labels like 'User:' or 'You:'.",
"Stop after the assistant character's own reply."
].join("\n");
}
const escapeRegExp = (value) => String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
export function containsUserVoiceInRoleplayOpener(opener, userPersona) {
const text = String(opener || "").trim();
if (!text) return true;
const userName = (userPersona?.name || "").trim();
const nameParts = userName ? userName.split(/\s+/).filter(Boolean).slice(0, 2) : [];
const tokens = ["user", "you", ...nameParts].filter(Boolean).map(escapeRegExp);
if (!tokens.length) return false;
if (new RegExp(`(^|\\n)\\s*(?:${tokens.join("|")})\\s*:`, "i").test(text)) return true;
if (new RegExp(`(^|\\n)\\s*(?:\\*\\s*)?(?:${tokens.join("|")})\\s+(?:say|says|said|ask|asks|asked|reply|replies|replied|think|thinks|thought|feel|feels|felt|walk|walks|walked|step|steps|stepped|look|looks|looked|nod|nods|nodded|smile|smiles|smiled|enter|enters|entered|turn|turns|turned)\\b`, "i").test(text)) return true;
if (new RegExp(`["“][^"”\\n]{1,240}["”]\\s*(?:,?\\s*)?(?:${tokens.join("|")})\\s*(?:say|says|said|ask|asks|asked|reply|replies|replied|murmur|murmurs|murmured|whisper|whispers|whispered)\\b`, "i").test(text)) return true;
return new RegExp(`(?:^|\\n|[.!?]\\s+)(?:${tokens.join("|")})\\s+(?:is|was|seems|looks|feels|hesitates|freezes|flinches|nods|smiles|frowns|steps|walks|turns|glances|stares|swallows|breathes)\\b`, "i").test(text);
}
+396
View File
@@ -0,0 +1,396 @@
import test from "node:test";
import assert from "node:assert/strict";
import {createApp} from "../src/app.js";
class CookieJar {
constructor() {
this.cookie = "";
}
update(response) {
const values = typeof response.headers.getSetCookie === "function"
? response.headers.getSetCookie()
: [response.headers.get("set-cookie")].filter(Boolean);
if (!values.length) return;
this.cookie = values.map((value) => value.split(";")[0]).join("; ");
}
headers(extra = {}) {
return this.cookie ? {...extra, Cookie: this.cookie} : extra;
}
}
async function startTestServer() {
let replyCount = 0;
const modelService = {
async generateReply() {
replyCount += 1;
return `stub-reply-${replyCount}`;
},
async streamReply(model, messagesPayload, onChunk) {
replyCount += 1;
const reply = `stub-reply-${replyCount}`;
if (onChunk) {
onChunk(reply.slice(0, 5), reply.slice(0, 5));
onChunk(reply.slice(5), reply);
}
return reply;
},
async listModels() {
return {models: [{name: "mistral:latest"}]};
},
async checkHealth() {
return {ok: true, checkedAt: new Date().toISOString(), error: null};
},
mapError(error) {
return {status: 500, body: {error: error.message, code: "TEST_MODEL"}};
}
};
const app = createApp({
config: {
port: 0,
dbPath: ":memory:",
testMode: true,
sessionSecret: "test-secret"
},
modelService
});
const server = await new Promise((resolve) => {
const instance = app.listen(0, () => resolve(instance));
});
const {port} = server.address();
const baseUrl = `http://127.0.0.1:${port}`;
return {
app,
baseUrl,
async close() {
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
}
};
}
async function request(baseUrl, path, {method = "GET", body, jar} = {}) {
const response = await fetch(`${baseUrl}${path}`, {
method,
headers: {
"Content-Type": "application/json",
...(jar ? jar.headers() : {})
},
body: body ? JSON.stringify(body) : undefined
});
if (jar) jar.update(response);
const json = await response.json();
return {status: response.status, json};
}
test("auth flow validates inputs and persists session", async () => {
const server = await startTestServer();
const jar = new CookieJar();
await request(server.baseUrl, "/register", {
method: "POST",
body: {username: "ab", password: "123456"}
}).then(({status, json}) => {
assert.equal(status, 400);
assert.match(json.error, /at least 3 characters/i);
});
await request(server.baseUrl, "/register", {
method: "POST",
body: {username: "alice", password: "password123"}
}).then(({status}) => assert.equal(status, 200));
await request(server.baseUrl, "/login", {
method: "POST",
body: {username: "alice", password: "password123"},
jar
}).then(({status}) => assert.equal(status, 200));
await request(server.baseUrl, "/session", {jar}).then(({json}) => {
assert.equal(json.user, "alice");
});
await server.close();
});
test("chat creation, send, and retry endpoints work with stubbed model service", async () => {
const server = await startTestServer();
const jar = new CookieJar();
await request(server.baseUrl, "/register", {method: "POST", body: {username: "bob", password: "password123"}});
await request(server.baseUrl, "/login", {method: "POST", body: {username: "bob", password: "password123"}, jar});
const createChat = await request(server.baseUrl, "/chats", {
method: "POST",
body: {title: "Test chat"},
jar
});
assert.equal(createChat.status, 200);
const chatId = createChat.json.chat.id;
const sendMessage = await request(server.baseUrl, "/chat", {
method: "POST",
body: {chatId, message: "hello", model: "mistral:latest"},
jar
});
assert.equal(sendMessage.status, 200);
assert.equal(sendMessage.json.reply, "stub-reply-1");
const messages = await request(server.baseUrl, `/chats/${chatId}/messages`, {jar});
assert.equal(messages.status, 200);
assert.equal(messages.json.messages.length, 2);
const botMessage = messages.json.messages.find((message) => message.role === "bot");
const retry = await request(server.baseUrl, `/chats/${chatId}/messages/${botMessage.id}/retry`, {
method: "POST",
body: {model: "mistral:latest"},
jar
});
assert.equal(retry.status, 200);
assert.equal(retry.json.message.content, "stub-reply-2");
assert.equal(retry.json.message.retryVariants.length, 2);
await server.close();
});
test("persona publish and collect flow works across accounts", async () => {
const server = await startTestServer();
const creatorJar = new CookieJar();
const collectorJar = new CookieJar();
await request(server.baseUrl, "/register", {method: "POST", body: {username: "creator", password: "password123"}});
await request(server.baseUrl, "/login", {method: "POST", body: {username: "creator", password: "password123"}, jar: creatorJar});
const persona = await request(server.baseUrl, "/personas", {
method: "POST",
body: {personaType: "assistant", name: "Agent", details: "Helpful"},
jar: creatorJar
});
assert.equal(persona.status, 200);
const publish = await request(server.baseUrl, `/personas/${persona.json.persona.id}/publish`, {
method: "POST",
body: {},
jar: creatorJar
});
assert.equal(publish.status, 200);
await request(server.baseUrl, "/register", {method: "POST", body: {username: "collector", password: "password123"}});
await request(server.baseUrl, "/login", {method: "POST", body: {username: "collector", password: "password123"}, jar: collectorJar});
const market = await request(server.baseUrl, "/personas/market", {jar: collectorJar});
assert.equal(market.status, 200);
assert.equal(market.json.personas.length, 1);
const collect = await request(server.baseUrl, `/personas/market/${market.json.personas[0].id}/collect`, {
method: "POST",
body: {},
jar: collectorJar
});
assert.equal(collect.status, 200);
assert.equal(collect.json.persona.name, "Agent");
await server.close();
});
test("chat search, organization, and workspace export work", async () => {
const server = await startTestServer();
const jar = new CookieJar();
await request(server.baseUrl, "/register", {method: "POST", body: {username: "searcher", password: "password123"}});
await request(server.baseUrl, "/login", {method: "POST", body: {username: "searcher", password: "password123"}, jar});
const createChat = await request(server.baseUrl, "/chats", {
method: "POST",
body: {title: "Notes"},
jar
});
const chatId = createChat.json.chat.id;
await request(server.baseUrl, "/chat", {
method: "POST",
body: {chatId, message: "findable keyword", model: "mistral:latest"},
jar
});
const search = await request(server.baseUrl, "/chats/search?q=keyword", {jar});
assert.equal(search.status, 200);
assert.equal(search.json.chats.length, 1);
const organize = await request(server.baseUrl, `/chats/${chatId}/organization`, {
method: "PUT",
body: {folderName: "Research", isPinned: true, archived: false},
jar
});
assert.equal(organize.status, 200);
assert.equal(organize.json.chat.folder_name, "Research");
assert.equal(organize.json.chat.is_pinned, 1);
const exported = await request(server.baseUrl, "/exports/workspace", {jar});
assert.equal(exported.status, 200);
assert.equal(exported.json.workspace.chats.length, 1);
await server.close();
});
test("persona clone, version restore, and market feedback endpoints work", async () => {
const server = await startTestServer();
const jar = new CookieJar();
await request(server.baseUrl, "/register", {method: "POST", body: {username: "maker", password: "password123"}});
await request(server.baseUrl, "/login", {method: "POST", body: {username: "maker", password: "password123"}, jar});
const persona = await request(server.baseUrl, "/personas", {
method: "POST",
body: {personaType: "assistant", name: "Historian", details: "Original"},
jar
});
const personaId = persona.json.persona.id;
const clone = await request(server.baseUrl, `/personas/${personaId}/clone`, {method: "POST", body: {}, jar});
assert.equal(clone.status, 200);
assert.match(clone.json.persona.name, /copy/i);
await request(server.baseUrl, `/personas/${personaId}`, {
method: "PUT",
body: {personaType: "assistant", name: "Historian", details: "Updated"},
jar
});
const versions = await request(server.baseUrl, `/personas/${personaId}/versions`, {jar});
assert.equal(versions.status, 200);
assert.ok(versions.json.versions.length >= 1);
const restoreTarget = versions.json.versions[0];
const restored = await request(server.baseUrl, `/personas/${personaId}/versions/${restoreTarget.id}/restore`, {
method: "POST",
body: {},
jar
});
assert.equal(restored.status, 200);
const publish = await request(server.baseUrl, `/personas/${personaId}/publish`, {
method: "POST",
body: {tags: ["mentor", "history"]},
jar
});
assert.equal(publish.status, 200);
const marketId = publish.json.persona.id;
const favorite = await request(server.baseUrl, `/personas/market/${marketId}/favorite`, {
method: "POST",
body: {},
jar
});
assert.equal(favorite.status, 200);
assert.equal(favorite.json.favorite, true);
const rating = await request(server.baseUrl, `/personas/market/${marketId}/rate`, {
method: "POST",
body: {rating: 5},
jar
});
assert.equal(rating.status, 200);
assert.equal(rating.json.persona.rating_count, 1);
await server.close();
});
test("market listing supports persona type filters, sorting, and favorite state", async () => {
const server = await startTestServer();
const creatorJar = new CookieJar();
const fanJar = new CookieJar();
const runnerJar = new CookieJar();
await request(server.baseUrl, "/register", {method: "POST", body: {username: "creator2", password: "password123"}});
await request(server.baseUrl, "/login", {method: "POST", body: {username: "creator2", password: "password123"}, jar: creatorJar});
await request(server.baseUrl, "/register", {method: "POST", body: {username: "fan", password: "password123"}});
await request(server.baseUrl, "/login", {method: "POST", body: {username: "fan", password: "password123"}, jar: fanJar});
await request(server.baseUrl, "/register", {method: "POST", body: {username: "runner", password: "password123"}});
await request(server.baseUrl, "/login", {method: "POST", body: {username: "runner", password: "password123"}, jar: runnerJar});
const assistantOne = await request(server.baseUrl, "/personas", {
method: "POST",
body: {personaType: "assistant", name: "Archivist", details: "Keeps records"},
jar: creatorJar
});
const assistantTwo = await request(server.baseUrl, "/personas", {
method: "POST",
body: {personaType: "assistant", name: "Pilot", details: "Fast paced"},
jar: creatorJar
});
const userPersona = await request(server.baseUrl, "/personas", {
method: "POST",
body: {personaType: "user", name: "Scout", details: "Observant"},
jar: creatorJar
});
const publishAssistantOne = await request(server.baseUrl, `/personas/${assistantOne.json.persona.id}/publish`, {
method: "POST",
body: {tags: ["lore"]},
jar: creatorJar
});
const publishAssistantTwo = await request(server.baseUrl, `/personas/${assistantTwo.json.persona.id}/publish`, {
method: "POST",
body: {tags: ["action"]},
jar: creatorJar
});
const publishUser = await request(server.baseUrl, `/personas/${userPersona.json.persona.id}/publish`, {
method: "POST",
body: {tags: ["identity"]},
jar: creatorJar
});
const archivistMarketId = publishAssistantOne.json.persona.id;
const pilotMarketId = publishAssistantTwo.json.persona.id;
const scoutMarketId = publishUser.json.persona.id;
await request(server.baseUrl, `/personas/market/${archivistMarketId}/favorite`, {
method: "POST",
body: {},
jar: fanJar
});
await request(server.baseUrl, `/personas/market/${archivistMarketId}/favorite`, {
method: "POST",
body: {},
jar: runnerJar
});
await request(server.baseUrl, `/personas/market/${archivistMarketId}/rate`, {
method: "POST",
body: {rating: 5},
jar: fanJar
});
await request(server.baseUrl, `/personas/market/${pilotMarketId}/collect`, {
method: "POST",
body: {},
jar: fanJar
});
await request(server.baseUrl, `/personas/market/${pilotMarketId}/chat`, {
method: "POST",
body: {},
jar: runnerJar
});
const assistantsNewest = await request(server.baseUrl, "/personas/market?personaType=assistant&sort=newest", {jar: fanJar});
assert.equal(assistantsNewest.status, 200);
assert.deepEqual(assistantsNewest.json.personas.map((persona) => persona.name), ["Pilot", "Archivist"]);
const assistantsFavorited = await request(server.baseUrl, "/personas/market?personaType=assistant&sort=most_favorited", {jar: fanJar});
assert.equal(assistantsFavorited.status, 200);
assert.equal(assistantsFavorited.json.personas[0].name, "Archivist");
assert.equal(assistantsFavorited.json.personas[0].is_favorite, true);
assert.equal(assistantsFavorited.json.personas[1].is_favorite, false);
const assistantsPopular = await request(server.baseUrl, "/personas/market?personaType=assistant&sort=most_popular", {jar: fanJar});
assert.equal(assistantsPopular.status, 200);
assert.equal(assistantsPopular.json.personas[0].name, "Pilot");
const userOnly = await request(server.baseUrl, "/personas/market?personaType=user&sort=best", {jar: fanJar});
assert.equal(userOnly.status, 200);
assert.deepEqual(userOnly.json.personas.map((persona) => persona.name), ["Scout"]);
assert.equal(userOnly.json.personas[0].id, scoutMarketId);
await server.close();
});