From 6e2aa2b353c4c781019b5d191528fd89f19ed4e6 Mon Sep 17 00:00:00 2001 From: fretuks Date: Thu, 9 Apr 2026 23:54:41 +0200 Subject: [PATCH] make app more intuitive --- public/index.html | 107 +++++++++---- public/market.html | 25 +--- public/market.js | 170 ++++++++------------- public/script.js | 347 ++++++++++++++++++++++++++++++++++++++++--- public/settings.html | 39 ++--- public/settings.js | 30 ++-- public/style.css | 153 ++++++++++++++++++- 7 files changed, 660 insertions(+), 211 deletions(-) diff --git a/public/index.html b/public/index.html index f8c9592..21172d0 100644 --- a/public/index.html +++ b/public/index.html @@ -22,7 +22,7 @@
-

Log in to continue your chats. Personas, shared presets, and theme changes are available after sign-in.

+

Sign in to start a chat immediately, then add personas when you want richer roleplay.

@@ -80,10 +80,11 @@
+ @@ -109,18 +122,21 @@

Conversation

New chat

-
- Assistant mode - - - +
+ +
-
- - - -
@@ -172,6 +188,26 @@
+
+ + +
@@ -179,7 +215,7 @@
-

Enter sends. Shift+Enter adds a new line.

+

Enter to send ยท Shift+Enter for new line

@@ -263,31 +299,30 @@
-

Pick a character, optionally choose who you are in the scene, and the character will open the conversation with the first message.

+

Pick a character, choose who you are in the scene, and generate an opening message in one step.

- Settings
@@ -315,6 +350,24 @@ + + diff --git a/public/market.html b/public/market.html index 02be067..54beb93 100644 --- a/public/market.html +++ b/public/market.html @@ -15,7 +15,7 @@

Shared personas

FakeGPT Market

-

Browse shared AI Characters and User Personas, or manage your own AI Characters.

+

Find characters and personas for roleplay, then start with an explicit persona choice.

@@ -35,7 +35,6 @@
@@ -45,8 +44,9 @@

Discover

AI Characters

-

Find shared characters to start roleplay chats with.

+

Pick a character, choose your persona, and start roleplay.

+
@@ -58,26 +58,13 @@

Discover

User Personas

-

Browse shared user personas you can collect for roleplay context.

+

Collect personas you can use as your identity in roleplay scenes.

+
- -
-
-
-
-

Manage

-

My Personas

-

Create and manage your own AI Characters.

-
- -
-
-
-
@@ -118,7 +105,7 @@
diff --git a/public/market.js b/public/market.js index 98d968b..1961767 100644 --- a/public/market.js +++ b/public/market.js @@ -1,13 +1,13 @@ const $ = (id) => document.getElementById(id); const marketPersonaList = $("marketPersonaList"); const marketUserPersonaList = $("marketUserPersonaList"); -const marketOwnedPersonaList = $("marketOwnedPersonaList"); const refreshMarketBtn = $("refreshMarket"); const marketStatus = $("marketStatus"); const marketSearchInput = $("marketSearch"); const marketViewButtons = document.querySelectorAll("[data-market-view]"); const marketPanels = document.querySelectorAll("[data-market-panel]"); -const marketCreatePersonaBtn = $("marketCreatePersona"); +const marketCreateAiCharacterBtn = $("marketCreateAiCharacter"); +const marketCreateUserPersonaBtn = $("marketCreateUserPersona"); const marketPreviewModal = $("marketPreviewModal"); const marketPreviewName = $("marketPreviewName"); const marketPreviewMeta = $("marketPreviewMeta"); @@ -45,9 +45,10 @@ const themes = { let allMarketPersonas = []; let assistantPersonas = []; let userPersonas = []; -let publishedPersonaIds = new Set(); let pendingMarketPersona = null; let editingPersonaId = null; +let activeUserPersonaId = null; +let activeMarketView = "ai-characters"; async function request(url, data, method = "POST") { try { @@ -65,7 +66,6 @@ async function request(url, data, method = "POST") { 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 applyTheme(themeKey) { const nextTheme = themes[themeKey] ? themeKey : "fakegpt"; @@ -92,11 +92,14 @@ function setPersonaFormNotice(message = "", state = "") { } function setMarketView(view) { - marketViewButtons.forEach((item) => item.classList.toggle("active", item.dataset.marketView === view)); - marketPanels.forEach((panel) => panel.classList.toggle("active", panel.dataset.marketPanel === view)); + const normalizedView = view === "user-personas" ? "user-personas" : "ai-characters"; + 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", view); + params.set("view", normalizedView); history.replaceState(null, "", `${window.location.pathname}?${params.toString()}`); + applyMarketFilter(); } function buildPersonaMeta(persona) { @@ -113,7 +116,6 @@ function closeMarketPreview() { marketPreviewModal.classList.add("hidden"); pendingMarketPersona = null; marketPreviewUserPersonaSelect.innerHTML = ""; - marketPreviewConfirm.classList.add("hidden"); } function populateUserPersonaChoices() { @@ -121,7 +123,7 @@ function populateUserPersonaChoices() { const promptOption = document.createElement("option"); promptOption.value = ""; - promptOption.textContent = "Choose who you are"; + promptOption.textContent = "Choose who you are in this roleplay"; promptOption.disabled = true; promptOption.selected = true; marketPreviewUserPersonaSelect.appendChild(promptOption); @@ -135,6 +137,7 @@ function populateUserPersonaChoices() { const option = document.createElement("option"); option.value = String(persona.id); option.textContent = persona.name; + option.selected = persona.id === activeUserPersonaId; marketPreviewUserPersonaSelect.appendChild(option); }); @@ -156,9 +159,13 @@ function openMarketPreview(persona) { const isAssistant = persona.persona_type === "assistant"; marketPreviewUserPersonaField.classList.toggle("hidden", !isAssistant); - marketPreviewConfirm.textContent = isAssistant ? "Start chat" : "Collect as user persona"; - marketPreviewConfirm.classList.toggle("hidden", isAssistant); - if (isAssistant) populateUserPersonaChoices(); + marketPreviewConfirm.textContent = isAssistant ? "Start roleplay" : "Collect persona"; + if (isAssistant) { + populateUserPersonaChoices(); + marketPreviewConfirm.classList.add("hidden"); + } else { + marketPreviewConfirm.classList.remove("hidden"); + } marketPreviewModal.classList.remove("hidden"); } @@ -210,9 +217,19 @@ function renderMarketList(items, listElement, type) { meta.textContent = buildPersonaMeta(persona); detailsBtn.type = "button"; - detailsBtn.textContent = "View"; + detailsBtn.textContent = "Preview"; detailsBtn.addEventListener("click", () => openMarketPreview(persona)); + if (type !== "assistant") { + const quickActionBtn = document.createElement("button"); + quickActionBtn.type = "button"; + quickActionBtn.textContent = "Collect"; + quickActionBtn.addEventListener("click", () => { + void collectMarketPersona(persona.id); + }); + actions.appendChild(quickActionBtn); + } + titleRow.append(title, tag); actions.appendChild(detailsBtn); item.append(titleRow, meta, actions); @@ -220,58 +237,6 @@ function renderMarketList(items, listElement, type) { }); } -function renderOwnedPersonaList() { - marketOwnedPersonaList.innerHTML = ""; - if (!assistantPersonas.length) { - const empty = document.createElement("p"); - empty.className = "status persona-status"; - empty.textContent = "No AI Characters yet. Create one to get started."; - marketOwnedPersonaList.appendChild(empty); - return; - } - - assistantPersonas.forEach((persona) => { - const item = document.createElement("div"); - const titleRow = document.createElement("div"); - const title = document.createElement("h4"); - const tag = document.createElement("span"); - const meta = document.createElement("p"); - const actions = document.createElement("div"); - - item.className = "persona-item"; - titleRow.className = "persona-title-row"; - actions.className = "persona-item-actions"; - tag.className = "persona-role-tag ai"; - tag.textContent = "AI Character"; - title.textContent = persona.name; - meta.textContent = formatPersonaField(persona.pronouns) === "Not provided." ? "Pronouns: n/a" : `Pronouns: ${persona.pronouns}`; - - [ - ["Edit", () => openPersonaForm(persona)], - [publishedPersonaIds.has(persona.id) ? "Update listing" : "Publish", () => publishPersona(persona.id)], - ["Delete", () => deletePersona(persona.id)] - ].forEach(([label, handler]) => { - const button = document.createElement("button"); - button.type = "button"; - button.textContent = label; - button.addEventListener("click", handler); - actions.appendChild(button); - }); - - if (publishedPersonaIds.has(persona.id)) { - const unpublish = document.createElement("button"); - unpublish.type = "button"; - unpublish.textContent = "Unpublish"; - unpublish.addEventListener("click", () => unpublishPersona(persona.id)); - actions.appendChild(unpublish); - } - - titleRow.append(title, tag); - item.append(titleRow, meta, actions); - marketOwnedPersonaList.appendChild(item); - }); -} - function filterMarketPersonas() { const query = marketSearchInput.value.trim().toLowerCase(); if (!query) return allMarketPersonas; @@ -283,15 +248,24 @@ function filterMarketPersonas() { function applyMarketFilter() { const filtered = filterMarketPersonas(); - renderMarketList(filtered.filter((persona) => persona.persona_type === "assistant"), marketPersonaList, "assistant"); - renderMarketList(filtered.filter((persona) => persona.persona_type === "user"), marketUserPersonaList, "user"); - renderOwnedPersonaList(); + const assistantItems = filtered.filter((persona) => persona.persona_type === "assistant"); + const userItems = filtered.filter((persona) => persona.persona_type === "user"); + renderMarketList(assistantItems, marketPersonaList, "assistant"); + renderMarketList(userItems, marketUserPersonaList, "user"); if (marketSearchInput.value.trim() && !filtered.length) { setMarketStatus("No personas match your search.", "error"); return; } - setMarketStatus(`${filtered.length} persona${filtered.length === 1 ? "" : "s"} shown.`); + if (activeMarketView === "ai-characters") { + setMarketStatus(`${assistantItems.length} AI Character${assistantItems.length === 1 ? "" : "s"} ready for roleplay.`); + return; + } + if (activeMarketView === "user-personas") { + setMarketStatus(`${userItems.length} User Persona${userItems.length === 1 ? "" : "s"} available to collect.`); + return; + } + setMarketStatus(`${assistantItems.length + userItems.length} persona${assistantItems.length + userItems.length === 1 ? "" : "s"} shown.`); } async function loadOwnedPersonas() { @@ -299,7 +273,7 @@ async function loadOwnedPersonas() { if (res.error) return false; assistantPersonas = res.assistantPersonas || []; userPersonas = res.userPersonas || []; - publishedPersonaIds = new Set(res.publishedPersonaIds || []); + activeUserPersonaId = res.activeUserPersonaId || null; return true; } @@ -330,8 +304,12 @@ async function collectMarketPersona(marketId) { } async function startMarketPersonaChat(marketId, userPersonaSelection) { + if (!userPersonaSelection) { + setMarketStatus("Choose who you are before starting roleplay.", "error"); + return; + } if (userPersonaSelection === "create_new") { - window.location.href = "/settings?view=my-personas&create=user"; + window.location.href = "/settings?view=my-roleplay-companions&create=user"; return; } @@ -372,40 +350,10 @@ async function savePersona() { await loadMarketPersonas(); closePersonaForm(); - setMarketView("my-personas"); + setMarketView("ai-characters"); setMarketStatus(editingPersonaId ? "AI Character updated." : "AI Character created.", "success"); } -async function deletePersona(id) { - const res = await del(`/personas/${id}`); - if (res.error) { - setMarketStatus(res.error, "error"); - return; - } - await loadMarketPersonas(); - setMarketStatus("AI Character deleted.", "success"); -} - -async function publishPersona(id) { - const res = await post(`/personas/${id}/publish`, {}); - if (res.error) { - setMarketStatus(res.error, "error"); - return; - } - await loadMarketPersonas(); - setMarketStatus("AI Character published.", "success"); -} - -async function unpublishPersona(id) { - const res = await post(`/personas/${id}/unpublish`, {}); - if (res.error) { - setMarketStatus(res.error, "error"); - return; - } - await loadMarketPersonas(); - setMarketStatus("AI Character unpublished.", "success"); -} - refreshMarketBtn.addEventListener("click", () => { void loadMarketPersonas(); }); @@ -413,7 +361,14 @@ marketSearchInput.addEventListener("input", applyMarketFilter); marketViewButtons.forEach((button) => { button.addEventListener("click", () => setMarketView(button.dataset.marketView)); }); -marketCreatePersonaBtn.addEventListener("click", () => openPersonaForm()); +if (marketCreateAiCharacterBtn) { + marketCreateAiCharacterBtn.addEventListener("click", () => openPersonaForm()); +} +if (marketCreateUserPersonaBtn) { + marketCreateUserPersonaBtn.addEventListener("click", () => { + window.location.href = "/settings?view=my-roleplay-companions&create=user"; + }); +} marketPreviewClose.addEventListener("click", closeMarketPreview); marketPreviewModal.addEventListener("click", (event) => { if (event.target === marketPreviewModal) closeMarketPreview(); @@ -421,7 +376,7 @@ marketPreviewModal.addEventListener("click", (event) => { marketPreviewUserPersonaSelect.addEventListener("change", () => { if (!pendingMarketPersona || pendingMarketPersona.persona_type !== "assistant") return; if (marketPreviewUserPersonaSelect.value === "create_new") { - window.location.href = "/settings?view=my-personas&create=user"; + window.location.href = "/settings?view=my-roleplay-companions&create=user"; return; } marketPreviewConfirm.classList.toggle("hidden", !marketPreviewUserPersonaSelect.value); @@ -430,7 +385,7 @@ marketPreviewConfirm.addEventListener("click", async () => { if (!pendingMarketPersona) return; const marketId = pendingMarketPersona.id; if (pendingMarketPersona.persona_type === "assistant") { - await startMarketPersonaChat(marketId, marketPreviewUserPersonaSelect.value || ""); + await startMarketPersonaChat(marketId, marketPreviewUserPersonaSelect.value || "self"); closeMarketPreview(); return; } @@ -451,5 +406,8 @@ window.addEventListener("load", async () => { const params = new URLSearchParams(window.location.search); setMarketView(params.get("view") || "ai-characters"); await loadMarketPersonas(); - if (params.get("create") === "assistant") openPersonaForm(); + if (params.get("create") === "assistant") { + setMarketView("ai-characters"); + openPersonaForm(); + } }); diff --git a/public/script.js b/public/script.js index ec8f039..4ff20ab 100644 --- a/public/script.js +++ b/public/script.js @@ -9,8 +9,11 @@ const loginUsernameInput = $("loginUsername"), loginPasswordInput = $("loginPass const registerUsernameInput = $("registerUsername"), registerPasswordInput = $("registerPassword"); const loginSubmit = $("loginSubmit"), registerSubmit = $("registerSubmit"); const chatList = $("chatList"), chatSearchInput = $("chatSearch"), newChatBtn = $("newChat"); +const startRoleplayBtn = $("startRoleplay"); const renameChatBtn = $("renameChat"), clearChatBtn = $("clearChat"), exportChatBtn = $("exportChat"); const activeChatTitle = $("activeChatTitle"), sessionUser = $("sessionUser"); +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"); @@ -28,8 +31,11 @@ const popupModal = $("popupModal"), popupCloseBtn = $("popupClose"), popupCancel 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; let isProcessing = false, activeChatId = null, editingPersonaId = null, editingPersonaType = "assistant", currentUsername = ""; @@ -38,6 +44,14 @@ let activeUserPersonaId = null, currentSummary = null, publishedPersonaIds = new let popupResolver = null, popupMode = null, popupLastFocus = null; let roleplayPresetCharacterId = null; let noticeTimer = null; +let workspaceMode = localStorage.getItem("krishd-workspace-mode") || "basic"; +let onboardingStep = 1, onboardingIntent = "ask"; + +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"}, @@ -98,6 +112,90 @@ function setPersonaFormNotice(message = "", state = "") { personaFormNotice.textContent = message; personaFormNotice.className = state ? `status ${state}` : "status"; } + +function applyWorkspaceMode(nextMode, persist = true) { + workspaceMode = nextMode === "advanced" ? "advanced" : "basic"; + document.body.dataset.workspaceMode = workspaceMode; + if (persist) localStorage.setItem("krishd-workspace-mode", workspaceMode); + if (workspaceMode === "basic") { + closeModelPopover(); + closePersonaPopover(); + } + if (modelMenu) modelMenu.classList.toggle("hidden", workspaceMode === "basic"); + if (personaMenu) personaMenu.classList.toggle("hidden", workspaceMode === "basic"); + updateComposerPlaceholder(); + renderQuickPrompts(); +} + +function hasCompletedOnboarding() { + return localStorage.getItem("krishd-onboarding-complete") === "true"; +} + +function markOnboardingCompleted() { + localStorage.setItem("krishd-onboarding-complete", "true"); + closeOnboarding(); +} + +function closeOnboarding() { + if (!onboardingModal) return; + onboardingModal.classList.add("hidden"); +} + +function renderOnboardingStep() { + if (!onboardingChoices || !onboardingTitle || !onboardingSubtitle) return; + onboardingChoices.innerHTML = ""; + onboardingBack.classList.toggle("hidden", onboardingStep === 1); + onboardingContinue.classList.toggle("hidden", onboardingStep === 2); + if (onboardingStep === 1) { + onboardingTitle.textContent = "What do you want to do?"; + onboardingSubtitle.textContent = "Choose one path. You can switch anytime."; + [["ask", "Ask questions"], ["brainstorm", "Brainstorm ideas"], ["roleplay", "Roleplay with a character"]].forEach(([key, label]) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = `secondary-action${onboardingIntent === key ? " active" : ""}`; + button.textContent = label; + button.addEventListener("click", () => { + onboardingIntent = key; + renderOnboardingStep(); + }); + onboardingChoices.appendChild(button); + }); + return; + } + onboardingTitle.textContent = "Choose a starter"; + onboardingSubtitle.textContent = "Pick one template and send your first message."; + onboardingPrompts[onboardingIntent].forEach((template) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "secondary-action"; + button.textContent = template; + button.addEventListener("click", async () => { + if (onboardingIntent === "roleplay") { + closeOnboarding(); + if (assistantPersonas.length) { + openRoleplayStarter(); + } else { + openPersonaForm(null, "assistant"); + } + return; + } + if (!activeChatId) await createNewChat(); + msgInput.value = template; + msgInput.dispatchEvent(new Event("input")); + msgInput.focus(); + closeOnboarding(); + }); + onboardingChoices.appendChild(button); + }); +} + +function maybeShowOnboarding() { + if (!onboardingModal || hasCompletedOnboarding()) return; + onboardingStep = 1; + onboardingIntent = "ask"; + renderOnboardingStep(); + onboardingModal.classList.remove("hidden"); +} function setMessageContent(el, content) { const markdownParser = window.marked; const purifier = window.DOMPurify; @@ -163,9 +261,10 @@ 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", prompts.length === 0); - prompts.forEach((prompt) => { + quickPrompts.classList.toggle("hidden", visiblePrompts.length === 0); + visiblePrompts.forEach((prompt) => { const button = document.createElement("button"); button.type = "button"; button.className = "prompt-chip"; @@ -180,9 +279,20 @@ function renderQuickPrompts() { } function updateComposerPlaceholder() { const activeChat = getChatById(activeChatId); - msgInput.placeholder = activeChat?.assistant_persona_id - ? "Reply in the scene..." - : "Type your message..."; + if (!activeChat) { + msgInput.placeholder = "Create a chat to begin..."; + return; + } + if (activeChat.assistant_persona_id) { + msgInput.placeholder = "Continue the scene..."; + return; + } + msgInput.placeholder = workspaceMode === "basic" ? "Ask anything..." : "Type your message..."; +} + +function updateSendState() { + const hasText = Boolean(msgInput.value.trim()); + sendBtn.disabled = isProcessing || !hasText; } function addMessage(content, isUser = false, isLoading = false) { @@ -216,13 +326,78 @@ function addMessage(content, isUser = false, isLoading = false) { function renderMessages() { messagesDiv.innerHTML = ""; - currentMessages.forEach((msg) => addMessage(msg.content, msg.role === "user")); + const chat = getChatById(activeChatId); + if (!currentMessages.length && (!chat || chat.assistant_persona_id)) { + renderMessageEmptyState(); + } else { + currentMessages.forEach((msg) => addMessage(msg.content, msg.role === "user")); + } updateChatParticipants(); updateChatActionState(); renderQuickPrompts(); updateComposerPlaceholder(); } +function createStarterAction(label, className, handler) { + const button = document.createElement("button"); + button.type = "button"; + button.className = className; + button.textContent = label; + button.addEventListener("click", handler); + return button; +} + +function createMessageStarterPrompt(label, prompt) { + return createStarterAction(label, "secondary-action", async () => { + if (!activeChatId) await createNewChat(); + msgInput.value = prompt; + msgInput.dispatchEvent(new Event("input")); + msgInput.focus(); + }); +} + +function renderMessageEmptyState() { + const empty = document.createElement("section"); + const title = document.createElement("h3"); + const description = document.createElement("p"); + const actions = document.createElement("div"); + const chat = getChatById(activeChatId); + + empty.className = "messages-empty"; + title.className = "messages-empty-title"; + description.className = "messages-empty-copy"; + actions.className = "messages-empty-actions"; + + if (!chat) { + title.textContent = "Start your first conversation"; + description.textContent = "Create a chat, or jump directly into roleplay."; + actions.append( + createStarterAction("New chat", "primary-action", () => { void createNewChat(); }), + createStarterAction("Start roleplay", "secondary-action", () => { + if (assistantPersonas.length) { + openRoleplayStarter(); + return; + } + openPersonaForm(null, "assistant"); + }), + createMessageStarterPrompt("Try a starter 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."; + 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()) + ); + } else { + return; + } + + empty.append(title, description, actions); + messagesDiv.appendChild(empty); +} + function updateWorkspaceCopy() { const activeChat = getChatById(activeChatId); if (activeChatTitle) { @@ -255,6 +430,7 @@ function updateChatParticipants() { chatCharacterPill.classList.add("hidden"); chatUserPersonaPill.classList.add("hidden"); chatScenePill.classList.add("hidden"); + if (contextSummaryLabel) contextSummaryLabel.textContent = "None"; return; } if (chat.assistant_persona_id) { @@ -274,12 +450,14 @@ function updateChatParticipants() { } 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"; } function updateContextRail() { @@ -367,6 +545,14 @@ function updateChatActionState() { exportChatBtn.disabled = !hasChat || currentMessages.length === 0; } +function maybeShowPersonaNudge() { + if (localStorage.getItem("krishd-persona-nudge-shown") === "true") return; + const assistantOnlyChats = chatSessions.filter((chat) => !chat.assistant_persona_id).length; + if (assistantOnlyChats < 3) return; + localStorage.setItem("krishd-persona-nudge-shown", "true"); + setNotice("Want to personalize responses? Try personas in Tools.", "success"); +} + function renderChatList() { const query = chatSearchInput.value.trim().toLowerCase(); const filtered = chatSessions.filter((chat) => !query || chat.title.toLowerCase().includes(query)); @@ -437,9 +623,31 @@ 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 closeContextPopover() { + if (!contextPopover || !contextChipButton) return; + contextPopover.classList.add("hidden"); + contextChipButton.setAttribute("aria-expanded", "false"); +} +function closeChatActionsPopover() { + if (!chatActionsPopover || !chatActionsMenuButton) return; + chatActionsPopover.classList.add("hidden"); + chatActionsMenuButton.setAttribute("aria-expanded", "false"); +} +function closeAuxiliaryPopovers() { + closeModelPopover(); + closePersonaPopover(); + closeContextPopover(); + closeChatActionsPopover(); +} function populateRoleplayStarter() { if (!roleplayCharacterSelect || !roleplayUserPersonaSelect) return; roleplayCharacterSelect.innerHTML = ""; + const placeholder = document.createElement("option"); + placeholder.value = ""; + placeholder.textContent = "Choose AI Character"; + placeholder.disabled = true; + placeholder.selected = true; + roleplayCharacterSelect.appendChild(placeholder); assistantPersonas.forEach((persona) => { const option = document.createElement("option"); option.value = String(persona.id); @@ -447,10 +655,13 @@ function populateRoleplayStarter() { option.selected = persona.id === roleplayPresetCharacterId; roleplayCharacterSelect.appendChild(option); }); + if (roleplayPresetCharacterId) { + roleplayCharacterSelect.value = String(roleplayPresetCharacterId); + } roleplayUserPersonaSelect.innerHTML = ""; const noneOption = document.createElement("option"); noneOption.value = ""; - noneOption.textContent = "No user persona"; + noneOption.textContent = "Myself (no user persona)"; roleplayUserPersonaSelect.appendChild(noneOption); userPersonas.forEach((persona) => { const option = document.createElement("option"); @@ -459,11 +670,17 @@ function populateRoleplayStarter() { option.selected = persona.id === activeUserPersonaId; roleplayUserPersonaSelect.appendChild(option); }); + if (assistantPersonas.length === 0) { + setRoleplayStarterNotice("Create an AI Character first, then start roleplay.", "error"); + roleplayStarterConfirm.disabled = true; + } else { + setRoleplayStarterNotice(""); + roleplayStarterConfirm.disabled = false; + } updateRoleplaySuggestions(); } function openRoleplayStarter(characterId = null) { - closePersonaPopover(); - closeModelPopover(); + closeAuxiliaryPopovers(); roleplayPresetCharacterId = characterId; populateRoleplayStarter(); if (roleplayScenarioInput) roleplayScenarioInput.value = ""; @@ -480,6 +697,7 @@ function closeRoleplayStarter() { setRoleplayStarterNotice(""); } function closeModelPopover() { + if (!modelPopover || !modelMenuButton) return; modelPopover.classList.add("hidden"); modelMenuButton.setAttribute("aria-expanded", "false"); } @@ -653,13 +871,15 @@ async function publishPersona(id) { const res = await post(`/personas/${id}/publ async function unpublishPersona(id) { const res = await post(`/personas/${id}/unpublish`, {}); if (res.error) return setNotice(res.error, "error"); await loadPersonas(); setNotice("Persona unpublished.", "success"); } function setLoadingState(loading) { - isProcessing = loading; sendBtn.disabled = loading; msgInput.disabled = loading; + isProcessing = loading; + msgInput.disabled = loading; sendBtn.classList.toggle("loading", loading); if (loading) { msgInput.placeholder = "Processing response..."; } else { updateComposerPlaceholder(); } + updateSendState(); } function showAuthScreen(target) { @@ -676,6 +896,7 @@ async function setActiveChat(id) { const existing = getChatById(id); if (res.chat && existing) Object.assign(existing, res.chat); currentMessages = res.messages || []; renderMessages(); + updateSendState(); } async function loadChatSessions() { @@ -686,13 +907,19 @@ async function loadChatSessions() { const preferredChatId = (requestedChatId && getChatById(requestedChatId)) ? requestedChatId : activeChatId && getChatById(activeChatId) ? activeChatId : chatSessions[0].id; return setActiveChat(preferredChatId); } - return createNewChat(); + activeChatId = null; + currentMessages = []; + renderMessages(); + updateWorkspaceCopy(); + updateSendState(); + return setNotice("Pick a starting action to begin.", "success"); } async function createNewChat() { const res = await post("/chats", {title: "New chat"}); if (res.error || !res.chat) return setNotice(res.error || "Unable to create chat.", "error"); chatSessions.unshift(res.chat); await loadSummary(); renderChatList(); await setActiveChat(res.chat.id); setNotice("New chat created.", "success"); + msgInput.focus(); } async function startRoleplay() { @@ -807,6 +1034,7 @@ async function checkSession() { if (!res.user) return false; currentUsername = res.user; authDiv.style.display = "none"; chatDiv.style.display = "block"; await Promise.all([displayModels(), loadSummary(), loadChatSessions(), loadPersonas()]); + maybeShowOnboarding(); msgInput.focus(); return true; } @@ -820,6 +1048,7 @@ async function handleAuth(endpoint, credentials) { if (endpoint === "login") { currentUsername = username; authDiv.style.display = "none"; chatDiv.style.display = "block"; await Promise.all([displayModels(), loadSummary(), loadChatSessions(), loadPersonas()]); + maybeShowOnboarding(); setNotice("Ready.", "success"); msgInput.focus(); } else { setAuthMessage("Registration successful. You can now log in.", "success"); @@ -844,7 +1073,12 @@ async function sendMessage() { const res = await post("/chat", {message, model: modelSelect.value, chatId: activeChatId}); if (messagesDiv.contains(loadingMsg)) messagesDiv.removeChild(loadingMsg); if (res.reply) { - currentMessages.push({role: "bot", content: res.reply}); addMessage(res.reply); await loadSummary(); setNotice("Reply received.", "success"); + currentMessages.push({role: "bot", content: res.reply}); + addMessage(res.reply); + await loadSummary(); + markOnboardingCompleted(); + maybeShowPersonaNudge(); + setNotice("Reply received.", "success"); } else { addMessage(`[Error: ${res.error}]`); setNotice(res.error || "Failed to generate reply.", "error"); } @@ -859,6 +1093,8 @@ async function sendMessage() { function togglePersonaPopover(event) { event.stopPropagation(); closeModelPopover(); + closeContextPopover(); + closeChatActionsPopover(); const isHidden = personaPopover.classList.contains("hidden"); personaPopover.classList.toggle("hidden", !isHidden); personaMenuButton.setAttribute("aria-expanded", String(isHidden)); @@ -867,11 +1103,35 @@ function togglePersonaPopover(event) { function toggleModelPopover(event) { event.stopPropagation(); closePersonaPopover(); + closeContextPopover(); + closeChatActionsPopover(); const isHidden = modelPopover.classList.contains("hidden"); modelPopover.classList.toggle("hidden", !isHidden); modelMenuButton.setAttribute("aria-expanded", String(isHidden)); } +function toggleContextPopover(event) { + if (!contextPopover || !contextChipButton) return; + event.stopPropagation(); + closeModelPopover(); + closePersonaPopover(); + closeChatActionsPopover(); + const isHidden = contextPopover.classList.contains("hidden"); + contextPopover.classList.toggle("hidden", !isHidden); + contextChipButton.setAttribute("aria-expanded", String(isHidden)); +} + +function toggleChatActionsPopover(event) { + if (!chatActionsPopover || !chatActionsMenuButton) return; + event.stopPropagation(); + closeModelPopover(); + closePersonaPopover(); + closeContextPopover(); + const isHidden = chatActionsPopover.classList.contains("hidden"); + chatActionsPopover.classList.toggle("hidden", !isHidden); + chatActionsMenuButton.setAttribute("aria-expanded", String(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()}); }); @@ -879,12 +1139,23 @@ $("logout").addEventListener("click", async () => { await post("/logout", {}); chatDiv.style.display = "none"; authDiv.style.display = "grid"; activeChatId = null; currentUsername = ""; currentSummary = null; chatSessions = []; currentMessages = []; assistantPersonas = []; userPersonas = []; activeUserPersonaId = null; publishedPersonaIds = new Set(); messagesDiv.innerHTML = ""; renderChatList(); updateChatActionState(); + updateComposerPlaceholder(); + updateSendState(); showAuthScreen("login"); setAuthMessage("Logged out.", "success"); setNotice("Ready."); }); sendBtn.addEventListener("click", () => { void sendMessage(); }); msgInput.addEventListener("keydown", (event) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); void sendMessage(); } }); -msgInput.addEventListener("input", function () { this.style.height = "auto"; this.style.height = `${Math.min(this.scrollHeight, 180)}px`; }); +msgInput.addEventListener("input", function () { + this.style.height = "auto"; + 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"; + }); +} 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(); }); @@ -919,19 +1190,57 @@ popupInput.addEventListener("keydown", (event) => { closePopup(popupInput.value); } }); -modelMenuButton.addEventListener("click", toggleModelPopover); modelPopover.addEventListener("click", (event) => event.stopPropagation()); -personaMenuButton.addEventListener("click", togglePersonaPopover); personaPopover.addEventListener("click", (event) => event.stopPropagation()); -document.addEventListener("click", () => { closeModelPopover(); closePersonaPopover(); }); +if (modelMenuButton && modelPopover) { + modelMenuButton.addEventListener("click", toggleModelPopover); + modelPopover.addEventListener("click", (event) => event.stopPropagation()); +} +if (personaMenuButton && personaPopover) { + personaMenuButton.addEventListener("click", togglePersonaPopover); + personaPopover.addEventListener("click", (event) => event.stopPropagation()); +} +if (contextChipButton && contextPopover) { + contextChipButton.addEventListener("click", toggleContextPopover); + contextPopover.addEventListener("click", (event) => event.stopPropagation()); +} +if (chatActionsMenuButton && chatActionsPopover) { + chatActionsMenuButton.addEventListener("click", toggleChatActionsPopover); + chatActionsPopover.addEventListener("click", (event) => event.stopPropagation()); +} +if (onboardingContinue) { + onboardingContinue.addEventListener("click", () => { + onboardingStep = 2; + renderOnboardingStep(); + }); +} +if (onboardingBack) { + onboardingBack.addEventListener("click", () => { + onboardingStep = 1; + renderOnboardingStep(); + }); +} +if (onboardingSkip) { + onboardingSkip.addEventListener("click", markOnboardingCompleted); +} +if (onboardingModal) { + onboardingModal.addEventListener("click", (event) => { + if (event.target === onboardingModal) markOnboardingCompleted(); + }); +} +document.addEventListener("click", () => { closeAuxiliaryPopovers(); }); document.addEventListener("keydown", (event) => { if (event.key === "Escape") { - closeModelPopover(); - closePersonaPopover(); + closeAuxiliaryPopovers(); closeRoleplayStarter(); closePersonaForm(); closePopup(false); + closeOnboarding(); } }); window.addEventListener("load", async () => { applyTheme(localStorage.getItem("krishd-theme") || "fakegpt", false); - setNotice("Ready."); updateChatActionState(); await checkSession(); + applyWorkspaceMode(localStorage.getItem("krishd-workspace-mode") || "basic", false); + setNotice("Ready."); + updateChatActionState(); + updateSendState(); + await checkSession(); }); diff --git a/public/settings.html b/public/settings.html index e8a7734..1baef27 100644 --- a/public/settings.html +++ b/public/settings.html @@ -25,16 +25,10 @@

Loading settings...

-
- - -
-
@@ -89,30 +83,22 @@
-
+

Create & manage

-

My AI Characters

+

My Roleplay Companions

Build characters for roleplay chats and publish them to the market when they are ready.

- -
-
-
-
- -
-
-
-
-

Create & manage

-

My Personas

-

Create the personas you want to speak as, then set one as default for faster roleplay setup.

+
+ +
-
+

AI Characters

+
+

User Personas

@@ -138,6 +124,13 @@ +
diff --git a/public/settings.js b/public/settings.js index 43fab1e..303875c 100644 --- a/public/settings.js +++ b/public/settings.js @@ -1,6 +1,7 @@ const $ = (id) => document.getElementById(id); const settingsNotice = $("settingsNotice"); const settingsThemeSelect = $("settingsThemeSelect"); +const workspaceModeSelect = $("workspaceModeSelect"); const currentUsernameInput = $("currentUsername"); const newUsernameInput = $("newUsername"); const usernamePasswordInput = $("usernamePassword"); @@ -13,8 +14,6 @@ const settingsNavItems = document.querySelectorAll("[data-settings-view]"); const settingsPanels = document.querySelectorAll("[data-settings-panel]"); const createAiCharacterBtn = $("createAiCharacter"); const createUserPersonaBtn = $("createUserPersona"); -const quickCreateAiCharacterBtn = $("quickCreateAiCharacter"); -const quickCreateUserPersonaBtn = $("quickCreateUserPersona"); const settingsAiCharacterList = $("settingsAiCharacterList"); const settingsUserPersonaList = $("settingsUserPersonaList"); const settingsPersonaModal = $("settingsPersonaModal"); @@ -88,10 +87,11 @@ function applyTheme(themeKey, persist = true) { } function setSettingsView(view) { - settingsNavItems.forEach((item) => item.classList.toggle("active", item.dataset.settingsView === view)); - settingsPanels.forEach((panel) => panel.classList.toggle("active", panel.dataset.settingsPanel === view)); + const normalizedView = ["my-ai-characters", "my-personas"].includes(view) ? "my-roleplay-companions" : view; + settingsNavItems.forEach((item) => item.classList.toggle("active", item.dataset.settingsView === normalizedView)); + settingsPanels.forEach((panel) => panel.classList.toggle("active", panel.dataset.settingsPanel === normalizedView)); const params = new URLSearchParams(window.location.search); - params.set("view", view); + params.set("view", normalizedView); history.replaceState(null, "", `${window.location.pathname}?${params.toString()}`); } @@ -248,7 +248,7 @@ async function savePersona() { await loadPersonas(); closePersonaModal(); - setSettingsView(payload.personaType === "assistant" ? "my-ai-characters" : "my-personas"); + setSettingsView("my-roleplay-companions"); setNotice(wasEditing ? "Persona updated." : "Persona created.", "success"); } @@ -329,16 +329,13 @@ settingsThemeSelect.addEventListener("change", (event) => { applyTheme(event.target.value); setNotice("Theme updated.", "success"); }); +workspaceModeSelect.addEventListener("change", (event) => { + const mode = event.target.value === "advanced" ? "advanced" : "basic"; + localStorage.setItem("krishd-workspace-mode", mode); + setNotice(`Workspace mode set to ${mode}.`, "success"); +}); settingsNavItems.forEach((item) => item.addEventListener("click", () => setSettingsView(item.dataset.settingsView))); -quickCreateAiCharacterBtn.addEventListener("click", () => { - setSettingsView("my-ai-characters"); - openPersonaModal(null, "assistant"); -}); -quickCreateUserPersonaBtn.addEventListener("click", () => { - setSettingsView("my-personas"); - openPersonaModal(null, "user"); -}); createAiCharacterBtn.addEventListener("click", () => openPersonaModal(null, "assistant")); createUserPersonaBtn.addEventListener("click", () => openPersonaModal(null, "user")); settingsPersonaClose.addEventListener("click", closePersonaModal); @@ -353,6 +350,7 @@ settingsPersonaForm.addEventListener("submit", (event) => { window.addEventListener("load", async () => { applyTheme(localStorage.getItem("krishd-theme") || "fakegpt", false); + workspaceModeSelect.value = localStorage.getItem("krishd-workspace-mode") || "basic"; const params = new URLSearchParams(window.location.search); const view = params.get("view") || "personal"; setSettingsView(view); @@ -361,10 +359,10 @@ window.addEventListener("load", async () => { const create = params.get("create"); if (create === "assistant") { - setSettingsView("my-ai-characters"); + setSettingsView("my-roleplay-companions"); openPersonaModal(null, "assistant"); } else if (create === "user") { - setSettingsView("my-personas"); + setSettingsView("my-roleplay-companions"); openPersonaModal(null, "user"); } }); diff --git a/public/style.css b/public/style.css index 4655021..fed4a35 100644 --- a/public/style.css +++ b/public/style.css @@ -631,6 +631,26 @@ select:focus { padding-right: 4px; } +.chat-loading { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-radius: 12px; + border: 1px solid rgba(24, 32, 31, 0.08); + background: rgba(24, 32, 31, 0.04); + color: var(--text-soft); + font-size: 0.9rem; +} + +.chat-loading-dot { + width: 10px; + height: 10px; + border-radius: 999px; + background: var(--brand); + animation: pulse 1s ease-in-out infinite; +} + .chat-item, .persona-item { display: grid; @@ -704,6 +724,24 @@ select:focus { box-shadow: none; } +.secondary-action.active { + background: rgba(164, 85, 45, 0.18); + border-color: rgba(164, 85, 45, 0.34); +} + +.action-pair { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + width: 100%; +} + +.action-pair .secondary-action, +.action-pair button { + width: 100%; + min-width: 0; +} + .danger { background: linear-gradient(145deg, #b34b40, #8f3026); } @@ -717,6 +755,30 @@ select:focus { gap: 10px; } +.tools-details { + border: 1px solid rgba(24, 32, 31, 0.08); + border-radius: 14px; + background: rgba(24, 32, 31, 0.04); + padding: 8px 10px; +} + +.tools-details summary { + cursor: pointer; + font-weight: 700; + color: var(--text); +} + +.tools-links { + display: grid; + gap: 8px; + margin-top: 10px; +} + +.tools-details .session-chip, +.tools-details button { + margin-top: 10px; +} + .session-chip { display: grid; gap: 4px; @@ -818,6 +880,34 @@ select:focus { margin-top: 8px; } +.chat-context { + position: relative; + margin-top: 8px; +} + +.context-chip-button { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 40px; + padding: 8px 12px; + border-radius: 999px; + background: rgba(24, 32, 31, 0.06); + color: var(--text); + border: 1px solid rgba(24, 32, 31, 0.12); +} + +.context-chip-button span { + font-size: 0.75rem; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-muted); +} + +.context-chip-button strong { + font-size: 0.9rem; +} + .sidebar-header-actions { justify-content: flex-end; flex-wrap: wrap; @@ -829,6 +919,10 @@ select:focus { align-self: end; } +.chat-actions-menu { + position: relative; +} + .chat-header { display: grid; grid-template-columns: minmax(0, 1fr) auto; @@ -1066,6 +1160,37 @@ select:focus { min-height: 0; } +.messages-empty { + margin: auto 0; + padding: 20px; + border-radius: 16px; + background: var(--surface-strong); + border: 1px dashed var(--line-strong); + display: grid; + gap: 10px; +} + +.messages-empty-title { + font-size: 1.2rem; +} + +.messages-empty-copy { + margin: 0; + color: var(--text-soft); + line-height: 1.5; +} + +.messages-empty-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.messages-empty-actions .primary-action, +.messages-empty-actions .secondary-action { + width: auto; +} + .msg { max-width: min(78%, 780px); padding: 14px 16px; @@ -1283,7 +1408,8 @@ select:focus { } .chat-header-actions .model-menu, -.chat-header-actions .persona-menu { +.chat-header-actions .persona-menu, +.chat-header-actions .chat-actions-menu { display: flex; align-items: stretch; align-self: stretch; @@ -1391,6 +1517,17 @@ select:focus { display: none !important; } +.onboarding-card { + width: min(640px, 94vw); + display: grid; + gap: 14px; +} + +.onboarding-choices { + display: grid; + gap: 10px; +} + .modal-overlay { position: fixed; inset: 0; @@ -2395,6 +2532,16 @@ body[data-theme="beta-ai"] .persona-form .field { display: grid; grid-template-columns: 1fr; } + + .messages-empty-actions { + display: grid; + grid-template-columns: 1fr; + } + + .messages-empty-actions .primary-action, + .messages-empty-actions .secondary-action { + width: 100%; + } } @media (max-width: 560px) { @@ -2433,6 +2580,10 @@ body[data-theme="beta-ai"] .persona-form .field { min-width: 0; } + .action-pair { + grid-template-columns: 1fr; + } + .model-popover { left: 0; right: auto;