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