fix more stuff and improve styling immensely
This commit is contained in:
+14
-1
@@ -2,6 +2,7 @@ export async function request(url, data, method = "POST") {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method,
|
method,
|
||||||
|
credentials: "same-origin",
|
||||||
headers: {"Content-Type": "application/json"},
|
headers: {"Content-Type": "application/json"},
|
||||||
body: data ? JSON.stringify(data) : undefined
|
body: data ? JSON.stringify(data) : undefined
|
||||||
});
|
});
|
||||||
@@ -19,12 +20,24 @@ export const del = (url) => request(url, null, "DELETE");
|
|||||||
export async function stream(url, data, handlers = {}) {
|
export async function stream(url, data, handlers = {}) {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
headers: {"Content-Type": "application/json"},
|
headers: {"Content-Type": "application/json"},
|
||||||
body: JSON.stringify(data || {})
|
body: JSON.stringify(data || {})
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok || !response.body) {
|
if (!response.ok || !response.body) {
|
||||||
throw new Error("Streaming request failed");
|
let error = "Streaming request failed";
|
||||||
|
try {
|
||||||
|
const payload = await response.json();
|
||||||
|
error = payload.error || error;
|
||||||
|
} catch {
|
||||||
|
// Keep the generic error if the server did not send JSON.
|
||||||
|
}
|
||||||
|
if (handlers.onError) {
|
||||||
|
handlers.onError({error});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const reader = response.body.getReader();
|
const reader = response.body.getReader();
|
||||||
|
|||||||
@@ -1,11 +1,41 @@
|
|||||||
import {chatDrawer, chatDrawerButton, chatDrawerCloseBtn} from "./dom.js";
|
import {chatDrawer, chatDrawerButton, chatDrawerCloseBtn} from "./dom.js";
|
||||||
|
|
||||||
|
let lastFocusedElement = null;
|
||||||
|
|
||||||
|
const focusableSelector = [
|
||||||
|
"a[href]",
|
||||||
|
"button:not([disabled])",
|
||||||
|
"textarea:not([disabled])",
|
||||||
|
"input:not([disabled])",
|
||||||
|
"select:not([disabled])",
|
||||||
|
"summary",
|
||||||
|
"[tabindex]:not([tabindex='-1'])"
|
||||||
|
].join(",");
|
||||||
|
|
||||||
|
function getFocusableElements() {
|
||||||
|
if (!chatDrawer) return [];
|
||||||
|
return Array.from(chatDrawer.querySelectorAll(focusableSelector))
|
||||||
|
.filter((element) => element instanceof HTMLElement && element.offsetParent !== null);
|
||||||
|
}
|
||||||
|
|
||||||
function syncChatDrawerState(isOpen) {
|
function syncChatDrawerState(isOpen) {
|
||||||
if (!chatDrawer || !chatDrawerButton) return;
|
if (!chatDrawer || !chatDrawerButton) return;
|
||||||
if (isOpen) document.body.classList.remove("sidebar-open");
|
if (isOpen) {
|
||||||
|
lastFocusedElement = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||||
|
document.body.classList.remove("sidebar-open");
|
||||||
|
}
|
||||||
chatDrawer.classList.toggle("hidden", !isOpen);
|
chatDrawer.classList.toggle("hidden", !isOpen);
|
||||||
chatDrawerButton.setAttribute("aria-expanded", String(isOpen));
|
chatDrawerButton.setAttribute("aria-expanded", String(isOpen));
|
||||||
document.body.classList.toggle("chat-drawer-open", isOpen);
|
document.body.classList.toggle("chat-drawer-open", isOpen);
|
||||||
|
if (isOpen) {
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
const firstFocusable = getFocusableElements()[0];
|
||||||
|
(chatDrawerCloseBtn || firstFocusable)?.focus();
|
||||||
|
});
|
||||||
|
} else if (lastFocusedElement) {
|
||||||
|
lastFocusedElement.focus();
|
||||||
|
lastFocusedElement = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isChatDrawerOpen() {
|
function isChatDrawerOpen() {
|
||||||
@@ -34,7 +64,27 @@ function handleDocumentClick(event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleDocumentKeydown(event) {
|
function handleDocumentKeydown(event) {
|
||||||
if (event.key === "Escape") closeChatDrawer();
|
if (!isChatDrawerOpen()) return;
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
closeChatDrawer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key !== "Tab") return;
|
||||||
|
|
||||||
|
const focusableElements = getFocusableElements();
|
||||||
|
if (!focusableElements.length) return;
|
||||||
|
|
||||||
|
const first = focusableElements[0];
|
||||||
|
const last = focusableElements[focusableElements.length - 1];
|
||||||
|
if (event.shiftKey && document.activeElement === first) {
|
||||||
|
event.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!event.shiftKey && document.activeElement === last) {
|
||||||
|
event.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (chatDrawerButton && chatDrawer) {
|
if (chatDrawerButton && chatDrawer) {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const registerSubmit = $("registerSubmit");
|
|||||||
const chatList = $("chatList");
|
const chatList = $("chatList");
|
||||||
const chatSearchInput = $("chatSearch");
|
const chatSearchInput = $("chatSearch");
|
||||||
const chatSidebar = $("chatSidebar");
|
const chatSidebar = $("chatSidebar");
|
||||||
|
const chatSidebarToggle = $("chatSidebarToggle");
|
||||||
const chatDrawerCloseBtn = $("chatDrawerClose");
|
const chatDrawerCloseBtn = $("chatDrawerClose");
|
||||||
const chatDrawerButton = $("chatDrawerButton");
|
const chatDrawerButton = $("chatDrawerButton");
|
||||||
const chatDrawer = $("chatDrawer");
|
const chatDrawer = $("chatDrawer");
|
||||||
@@ -123,6 +124,7 @@ export {
|
|||||||
chatListLoading,
|
chatListLoading,
|
||||||
chatListLoadingText,
|
chatListLoadingText,
|
||||||
chatSidebar,
|
chatSidebar,
|
||||||
|
chatSidebarToggle,
|
||||||
chatDrawerCloseBtn,
|
chatDrawerCloseBtn,
|
||||||
chatSearchInput,
|
chatSearchInput,
|
||||||
chatUserPersonaPill,
|
chatUserPersonaPill,
|
||||||
|
|||||||
+7
-5
@@ -68,9 +68,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="chat" class="card chat-card chat-focus-card" style="display:none;">
|
<section id="chat" class="card chat-card chat-focus-card" aria-label="AI chat workspace" style="display:none;">
|
||||||
<div class="chat-layout chat-layout-focus">
|
<div class="chat-layout chat-layout-focus">
|
||||||
<aside id="chatSidebar" class="chat-sidebar">
|
<aside id="chatSidebar" class="chat-sidebar" aria-label="Workspace navigation">
|
||||||
<div class="sidebar-top">
|
<div class="sidebar-top">
|
||||||
<div class="sidebar-workspace-card">
|
<div class="sidebar-workspace-card">
|
||||||
<div>
|
<div>
|
||||||
@@ -100,8 +100,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section class="chat-main chat-main-focus">
|
<section class="chat-main chat-main-focus" aria-label="Active conversation">
|
||||||
<header class="chat-header chat-header-focus">
|
<header class="chat-header chat-header-focus">
|
||||||
|
<button id="chatSidebarToggle" class="icon-button sidebar-toggle" type="button" aria-controls="chatSidebar" aria-expanded="false" aria-label="Open chat sidebar">Menu</button>
|
||||||
<div class="chat-title-block">
|
<div class="chat-title-block">
|
||||||
<div class="chat-title">
|
<div class="chat-title">
|
||||||
<h2 id="activeChatTitle">New chat</h2>
|
<h2 id="activeChatTitle">New chat</h2>
|
||||||
@@ -112,7 +113,7 @@
|
|||||||
<button id="chatDrawerButton" class="secondary-action overflow-trigger" type="button" aria-expanded="false" aria-controls="chatDrawer">
|
<button id="chatDrawerButton" class="secondary-action overflow-trigger" type="button" aria-expanded="false" aria-controls="chatDrawer">
|
||||||
<span class="overflow-trigger-label">Controls</span>
|
<span class="overflow-trigger-label">Controls</span>
|
||||||
</button>
|
</button>
|
||||||
<div id="chatDrawer" class="chat-drawer hidden" role="dialog" aria-modal="false" aria-labelledby="chatDrawerTitle">
|
<div id="chatDrawer" class="chat-drawer hidden" role="dialog" aria-modal="true" aria-labelledby="chatDrawerTitle">
|
||||||
<div class="chat-drawer-panel">
|
<div class="chat-drawer-panel">
|
||||||
<div class="chat-drawer-header">
|
<div class="chat-drawer-header">
|
||||||
<div>
|
<div>
|
||||||
@@ -242,13 +243,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="messages" class="messages-panel messages-panel-focus"></div>
|
<div id="messages" class="messages-panel messages-panel-focus" role="log" aria-live="polite" aria-relevant="additions text"></div>
|
||||||
|
|
||||||
<div class="input-shell input-shell-focus">
|
<div class="input-shell input-shell-focus">
|
||||||
<div class="input-meta">
|
<div class="input-meta">
|
||||||
<p class="composer-hint">Enter to send · Shift+Enter for new line</p>
|
<p class="composer-hint">Enter to send · Shift+Enter for new line</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="input-container">
|
<div class="input-container">
|
||||||
|
<label class="visually-hidden" for="msgInput">Message</label>
|
||||||
<textarea id="msgInput" rows="1" placeholder="Type your message..."></textarea>
|
<textarea id="msgInput" rows="1" placeholder="Type your message..."></textarea>
|
||||||
<button id="send" type="button" aria-label="Send message"></button>
|
<button id="send" type="button" aria-label="Send message"></button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+5
-5
@@ -31,13 +31,13 @@
|
|||||||
<p id="marketStatus" class="status persona-status">Loading market personas...</p>
|
<p id="marketStatus" class="status persona-status">Loading market personas...</p>
|
||||||
|
|
||||||
<section class="settings-layout market-settings-layout">
|
<section class="settings-layout market-settings-layout">
|
||||||
<aside class="settings-nav" aria-label="Market sections">
|
<aside class="settings-nav" aria-label="Market sections" role="tablist">
|
||||||
<button class="settings-nav-item active" type="button" data-market-view="ai-characters">Characters</button>
|
<button class="settings-nav-item active" type="button" role="tab" aria-selected="true" data-market-view="ai-characters">Characters</button>
|
||||||
<button class="settings-nav-item" type="button" data-market-view="user-personas">User personas</button>
|
<button class="settings-nav-item" type="button" role="tab" aria-selected="false" data-market-view="user-personas">User personas</button>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div class="settings-content">
|
<div class="settings-content">
|
||||||
<section class="settings-view active" data-market-panel="ai-characters">
|
<section class="settings-view active" role="tabpanel" data-market-panel="ai-characters">
|
||||||
<div class="settings-panel">
|
<div class="settings-panel">
|
||||||
<div class="settings-section-header">
|
<div class="settings-section-header">
|
||||||
<div>
|
<div>
|
||||||
@@ -63,7 +63,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="settings-view" data-market-panel="user-personas">
|
<section class="settings-view" role="tabpanel" aria-hidden="true" data-market-panel="user-personas">
|
||||||
<div class="settings-panel">
|
<div class="settings-panel">
|
||||||
<div class="settings-section-header">
|
<div class="settings-section-header">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
+11
-2
@@ -82,6 +82,7 @@ async function request(url, data, method = "POST") {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method,
|
method,
|
||||||
|
credentials: "same-origin",
|
||||||
headers: {"Content-Type": "application/json"},
|
headers: {"Content-Type": "application/json"},
|
||||||
body: data ? JSON.stringify(data) : undefined
|
body: data ? JSON.stringify(data) : undefined
|
||||||
});
|
});
|
||||||
@@ -183,8 +184,16 @@ function setMarketActivity(active, {
|
|||||||
function setMarketView(view) {
|
function setMarketView(view) {
|
||||||
const normalizedView = view === "user-personas" ? "user-personas" : "ai-characters";
|
const normalizedView = view === "user-personas" ? "user-personas" : "ai-characters";
|
||||||
activeMarketView = normalizedView;
|
activeMarketView = normalizedView;
|
||||||
marketViewButtons.forEach((item) => item.classList.toggle("active", item.dataset.marketView === normalizedView));
|
marketViewButtons.forEach((item) => {
|
||||||
marketPanels.forEach((panel) => panel.classList.toggle("active", panel.dataset.marketPanel === normalizedView));
|
const isActive = item.dataset.marketView === normalizedView;
|
||||||
|
item.classList.toggle("active", isActive);
|
||||||
|
item.setAttribute("aria-selected", String(isActive));
|
||||||
|
});
|
||||||
|
marketPanels.forEach((panel) => {
|
||||||
|
const isActive = panel.dataset.marketPanel === normalizedView;
|
||||||
|
panel.classList.toggle("active", isActive);
|
||||||
|
panel.setAttribute("aria-hidden", String(!isActive));
|
||||||
|
});
|
||||||
syncMarketQuery();
|
syncMarketQuery();
|
||||||
applyMarketFilter();
|
applyMarketFilter();
|
||||||
}
|
}
|
||||||
|
|||||||
+51
-2
@@ -18,6 +18,7 @@ import {
|
|||||||
chatListLoading,
|
chatListLoading,
|
||||||
chatListLoadingText,
|
chatListLoadingText,
|
||||||
chatSidebar,
|
chatSidebar,
|
||||||
|
chatSidebarToggle,
|
||||||
chatSearchInput,
|
chatSearchInput,
|
||||||
chatUserPersonaPill,
|
chatUserPersonaPill,
|
||||||
clearChatBtn,
|
clearChatBtn,
|
||||||
@@ -179,6 +180,13 @@ function isMobileLayout() {
|
|||||||
function closeMobileSidebar() {
|
function closeMobileSidebar() {
|
||||||
if (!chatSidebar) return;
|
if (!chatSidebar) return;
|
||||||
document.body.classList.remove("sidebar-open");
|
document.body.classList.remove("sidebar-open");
|
||||||
|
if (chatSidebarToggle) chatSidebarToggle.setAttribute("aria-expanded", "false");
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMobileSidebar() {
|
||||||
|
if (!chatSidebar) return;
|
||||||
|
document.body.classList.add("sidebar-open");
|
||||||
|
if (chatSidebarToggle) chatSidebarToggle.setAttribute("aria-expanded", "true");
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasCompletedOnboarding() {
|
function hasCompletedOnboarding() {
|
||||||
@@ -541,7 +549,10 @@ async function switchRetryVariant(messageId, direction, fallbackIndex = -1) {
|
|||||||
function addMessage(contentOrMessage, isUser = false, isLoading = false, options = {}) {
|
function addMessage(contentOrMessage, isUser = false, isLoading = false, options = {}) {
|
||||||
const msgDiv = document.createElement("div");
|
const msgDiv = document.createElement("div");
|
||||||
msgDiv.className = `msg ${isUser ? "user" : "bot"}${isLoading ? " loading" : ""}`;
|
msgDiv.className = `msg ${isUser ? "user" : "bot"}${isLoading ? " loading" : ""}`;
|
||||||
|
msgDiv.setAttribute("role", "article");
|
||||||
|
msgDiv.tabIndex = 0;
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
|
msgDiv.setAttribute("aria-label", "Assistant is responding");
|
||||||
msgDiv.innerHTML = `
|
msgDiv.innerHTML = `
|
||||||
<div class="loading-shell" aria-live="polite" aria-label="Assistant is responding">
|
<div class="loading-shell" aria-live="polite" aria-label="Assistant is responding">
|
||||||
<div class="loading-bars">
|
<div class="loading-bars">
|
||||||
@@ -560,9 +571,11 @@ function addMessage(contentOrMessage, isUser = false, isLoading = false, options
|
|||||||
: {id: null, role: isUser ? "user" : "bot", content: String(contentOrMessage || "")};
|
: {id: null, role: isUser ? "user" : "bot", content: String(contentOrMessage || "")};
|
||||||
const isMessageUser = message.role === "user";
|
const isMessageUser = message.role === "user";
|
||||||
msgDiv.className = `msg ${isMessageUser ? "user" : "bot"}`;
|
msgDiv.className = `msg ${isMessageUser ? "user" : "bot"}`;
|
||||||
|
msgDiv.setAttribute("aria-label", isMessageUser ? "Your message" : "Assistant message");
|
||||||
const header = document.createElement("div"), body = document.createElement("div"), actions = document.createElement("div");
|
const header = document.createElement("div"), body = document.createElement("div"), actions = document.createElement("div");
|
||||||
header.className = "msg-header"; body.className = "msg-content";
|
header.className = "msg-header"; body.className = "msg-content";
|
||||||
actions.className = "msg-actions";
|
actions.className = "msg-actions";
|
||||||
|
actions.setAttribute("aria-label", "Message actions");
|
||||||
const activeChat = getChatById(activeChatId);
|
const activeChat = getChatById(activeChatId);
|
||||||
header.textContent = isMessageUser ? "You" : (activeChat?.assistant_persona_name || "Assistant");
|
header.textContent = isMessageUser ? "You" : (activeChat?.assistant_persona_name || "Assistant");
|
||||||
setMessageContent(body, message.content);
|
setMessageContent(body, message.content);
|
||||||
@@ -571,6 +584,7 @@ function addMessage(contentOrMessage, isUser = false, isLoading = false, options
|
|||||||
editBtn.type = "button";
|
editBtn.type = "button";
|
||||||
editBtn.className = "msg-action-btn";
|
editBtn.className = "msg-action-btn";
|
||||||
editBtn.textContent = "Edit";
|
editBtn.textContent = "Edit";
|
||||||
|
editBtn.setAttribute("aria-label", `Edit ${isMessageUser ? "your" : "assistant"} message`);
|
||||||
editBtn.addEventListener("click", () => {
|
editBtn.addEventListener("click", () => {
|
||||||
void editChatMessage(message.id, options.messageIndex ?? -1);
|
void editChatMessage(message.id, options.messageIndex ?? -1);
|
||||||
});
|
});
|
||||||
@@ -580,6 +594,7 @@ function addMessage(contentOrMessage, isUser = false, isLoading = false, options
|
|||||||
deleteBtn.type = "button";
|
deleteBtn.type = "button";
|
||||||
deleteBtn.className = "msg-action-btn";
|
deleteBtn.className = "msg-action-btn";
|
||||||
deleteBtn.textContent = "Delete";
|
deleteBtn.textContent = "Delete";
|
||||||
|
deleteBtn.setAttribute("aria-label", `Delete ${isMessageUser ? "your" : "assistant"} message`);
|
||||||
deleteBtn.addEventListener("click", () => {
|
deleteBtn.addEventListener("click", () => {
|
||||||
void deleteChatMessage(message.id, options.messageIndex ?? -1);
|
void deleteChatMessage(message.id, options.messageIndex ?? -1);
|
||||||
});
|
});
|
||||||
@@ -591,6 +606,7 @@ function addMessage(contentOrMessage, isUser = false, isLoading = false, options
|
|||||||
retryBtn.type = "button";
|
retryBtn.type = "button";
|
||||||
retryBtn.className = "msg-action-btn";
|
retryBtn.className = "msg-action-btn";
|
||||||
retryBtn.textContent = `Retry (${state.retriesUsed}/5)`;
|
retryBtn.textContent = `Retry (${state.retriesUsed}/5)`;
|
||||||
|
retryBtn.setAttribute("aria-label", `Retry assistant message, ${state.retriesUsed} of 5 retries used`);
|
||||||
retryBtn.disabled = state.retriesUsed >= 5 || isProcessing;
|
retryBtn.disabled = state.retriesUsed >= 5 || isProcessing;
|
||||||
retryBtn.addEventListener("click", () => {
|
retryBtn.addEventListener("click", () => {
|
||||||
void retryLatestAssistantMessage(message.id, options.messageIndex ?? -1);
|
void retryLatestAssistantMessage(message.id, options.messageIndex ?? -1);
|
||||||
@@ -602,6 +618,7 @@ function addMessage(contentOrMessage, isUser = false, isLoading = false, options
|
|||||||
prevBtn.type = "button";
|
prevBtn.type = "button";
|
||||||
prevBtn.className = "msg-action-btn";
|
prevBtn.className = "msg-action-btn";
|
||||||
prevBtn.textContent = "Prev";
|
prevBtn.textContent = "Prev";
|
||||||
|
prevBtn.setAttribute("aria-label", "Show previous retry variant");
|
||||||
prevBtn.disabled = state.activeIndex <= 0;
|
prevBtn.disabled = state.activeIndex <= 0;
|
||||||
prevBtn.addEventListener("click", () => {
|
prevBtn.addEventListener("click", () => {
|
||||||
void switchRetryVariant(message.id, -1, options.messageIndex ?? -1);
|
void switchRetryVariant(message.id, -1, options.messageIndex ?? -1);
|
||||||
@@ -611,6 +628,7 @@ function addMessage(contentOrMessage, isUser = false, isLoading = false, options
|
|||||||
nextBtn.type = "button";
|
nextBtn.type = "button";
|
||||||
nextBtn.className = "msg-action-btn";
|
nextBtn.className = "msg-action-btn";
|
||||||
nextBtn.textContent = "Next";
|
nextBtn.textContent = "Next";
|
||||||
|
nextBtn.setAttribute("aria-label", "Show next retry variant");
|
||||||
nextBtn.disabled = state.activeIndex >= state.variants.length - 1;
|
nextBtn.disabled = state.activeIndex >= state.variants.length - 1;
|
||||||
nextBtn.addEventListener("click", () => {
|
nextBtn.addEventListener("click", () => {
|
||||||
void switchRetryVariant(message.id, 1, options.messageIndex ?? -1);
|
void switchRetryVariant(message.id, 1, options.messageIndex ?? -1);
|
||||||
@@ -935,10 +953,15 @@ function renderChatList() {
|
|||||||
heading.className = "chat-list-section-header";
|
heading.className = "chat-list-section-header";
|
||||||
heading.textContent = group.label;
|
heading.textContent = group.label;
|
||||||
list.className = "chat-list-section-body";
|
list.className = "chat-list-section-body";
|
||||||
|
list.setAttribute("role", "list");
|
||||||
section.append(heading, list);
|
section.append(heading, list);
|
||||||
group.items.forEach((chat) => {
|
group.items.forEach((chat) => {
|
||||||
const item = document.createElement("div"), content = document.createElement("div"), title = document.createElement("span"), meta = document.createElement("small"), remove = document.createElement("button");
|
const item = document.createElement("div"), content = document.createElement("div"), title = document.createElement("span"), meta = document.createElement("small"), remove = document.createElement("button");
|
||||||
item.className = `chat-item${chat.id === activeChatId ? " active" : ""}`;
|
item.className = `chat-item${chat.id === activeChatId ? " active" : ""}`;
|
||||||
|
item.setAttribute("role", "button");
|
||||||
|
item.tabIndex = 0;
|
||||||
|
item.setAttribute("aria-label", `Open chat ${chat.title}`);
|
||||||
|
if (chat.id === activeChatId) item.setAttribute("aria-current", "page");
|
||||||
content.className = "chat-item-copy";
|
content.className = "chat-item-copy";
|
||||||
title.textContent = chat.title;
|
title.textContent = chat.title;
|
||||||
meta.className = "chat-item-meta";
|
meta.className = "chat-item-meta";
|
||||||
@@ -946,14 +969,25 @@ function renderChatList() {
|
|||||||
? `Roleplay${chat.user_persona_name ? ` · ${chat.user_persona_name}` : ""}`
|
? `Roleplay${chat.user_persona_name ? ` · ${chat.user_persona_name}` : ""}`
|
||||||
: "Assistant chat";
|
: "Assistant chat";
|
||||||
remove.type = "button";
|
remove.type = "button";
|
||||||
|
remove.className = "chat-item-delete";
|
||||||
remove.textContent = "Delete";
|
remove.textContent = "Delete";
|
||||||
|
remove.setAttribute("aria-label", `Delete chat ${chat.title}`);
|
||||||
remove.addEventListener("click", async (event) => {
|
remove.addEventListener("click", async (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
await deleteChat(chat.id);
|
await deleteChat(chat.id);
|
||||||
});
|
});
|
||||||
content.append(title, meta);
|
content.append(title, meta);
|
||||||
item.append(content, remove);
|
item.append(content, remove);
|
||||||
item.addEventListener("click", () => setActiveChat(chat.id));
|
item.addEventListener("click", () => {
|
||||||
|
closeMobileSidebar();
|
||||||
|
setActiveChat(chat.id);
|
||||||
|
});
|
||||||
|
item.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key !== "Enter" && event.key !== " ") return;
|
||||||
|
event.preventDefault();
|
||||||
|
closeMobileSidebar();
|
||||||
|
void setActiveChat(chat.id);
|
||||||
|
});
|
||||||
list.appendChild(item);
|
list.appendChild(item);
|
||||||
});
|
});
|
||||||
chatList.appendChild(section);
|
chatList.appendChild(section);
|
||||||
@@ -1356,7 +1390,12 @@ async function startRoleplay() {
|
|||||||
closeRoleplayStarter();
|
closeRoleplayStarter();
|
||||||
await loadChatSessions();
|
await loadChatSessions();
|
||||||
await setActiveChat(res.chat.id);
|
await setActiveChat(res.chat.id);
|
||||||
setNotice(res.generatedInitialMessage ? "Roleplay started with a fresh scene." : "Roleplay reopened.", "success");
|
setNotice(
|
||||||
|
res.degraded
|
||||||
|
? "Roleplay started, but the opening scene timed out. Send the first message to continue."
|
||||||
|
: res.generatedInitialMessage ? "Roleplay started with a fresh scene." : "Roleplay reopened.",
|
||||||
|
res.degraded ? "error" : "success"
|
||||||
|
);
|
||||||
setChatLoading(false);
|
setChatLoading(false);
|
||||||
setChatActivity(false);
|
setChatActivity(false);
|
||||||
}
|
}
|
||||||
@@ -1711,6 +1750,16 @@ bindChatDrawerActions();
|
|||||||
if (chatSidebar) {
|
if (chatSidebar) {
|
||||||
chatSidebar.addEventListener("click", (event) => event.stopPropagation());
|
chatSidebar.addEventListener("click", (event) => event.stopPropagation());
|
||||||
}
|
}
|
||||||
|
if (chatSidebarToggle) {
|
||||||
|
chatSidebarToggle.addEventListener("click", (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (document.body.classList.contains("sidebar-open")) {
|
||||||
|
closeMobileSidebar();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openMobileSidebar();
|
||||||
|
});
|
||||||
|
}
|
||||||
if (onboardingContinue) {
|
if (onboardingContinue) {
|
||||||
onboardingContinue.addEventListener("click", () => {
|
onboardingContinue.addEventListener("click", () => {
|
||||||
onboardingStep = 2;
|
onboardingStep = 2;
|
||||||
|
|||||||
@@ -25,14 +25,14 @@
|
|||||||
<p id="settingsNotice" class="status">Loading settings...</p>
|
<p id="settingsNotice" class="status">Loading settings...</p>
|
||||||
|
|
||||||
<section class="settings-layout">
|
<section class="settings-layout">
|
||||||
<aside class="settings-nav" aria-label="Settings sections">
|
<aside class="settings-nav" aria-label="Settings sections" role="tablist">
|
||||||
<button class="settings-nav-item active" type="button" data-settings-view="personal">Account</button>
|
<button class="settings-nav-item active" type="button" role="tab" aria-selected="true" 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" role="tab" aria-selected="false" data-settings-view="my-roleplay-companions">Personas</button>
|
||||||
<button class="settings-nav-item" type="button" data-settings-view="design">Appearance</button>
|
<button class="settings-nav-item" type="button" role="tab" aria-selected="false" data-settings-view="design">Appearance</button>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div class="settings-content">
|
<div class="settings-content">
|
||||||
<section class="settings-view active" data-settings-panel="personal">
|
<section class="settings-view active" role="tabpanel" data-settings-panel="personal">
|
||||||
<div class="settings-panel">
|
<div class="settings-panel">
|
||||||
<div class="settings-section-header">
|
<div class="settings-section-header">
|
||||||
<div>
|
<div>
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="settings-view" data-settings-panel="my-roleplay-companions">
|
<section class="settings-view" role="tabpanel" aria-hidden="true" data-settings-panel="my-roleplay-companions">
|
||||||
<div class="settings-panel">
|
<div class="settings-panel">
|
||||||
<div class="settings-section-header">
|
<div class="settings-section-header">
|
||||||
<div>
|
<div>
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="settings-view" data-settings-panel="design">
|
<section class="settings-view" role="tabpanel" aria-hidden="true" data-settings-panel="design">
|
||||||
<div class="settings-panel settings-panel-compact">
|
<div class="settings-panel settings-panel-compact">
|
||||||
<div class="settings-section-header">
|
<div class="settings-section-header">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
+11
-2
@@ -61,6 +61,7 @@ async function request(url, data, method = "POST") {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method,
|
method,
|
||||||
|
credentials: "same-origin",
|
||||||
headers: {"Content-Type": "application/json"},
|
headers: {"Content-Type": "application/json"},
|
||||||
body: data ? JSON.stringify(data) : undefined
|
body: data ? JSON.stringify(data) : undefined
|
||||||
});
|
});
|
||||||
@@ -140,8 +141,16 @@ function applyThemeMode(modeKey, persist = true) {
|
|||||||
|
|
||||||
function setSettingsView(view) {
|
function setSettingsView(view) {
|
||||||
const normalizedView = ["my-ai-characters", "my-personas"].includes(view) ? "my-roleplay-companions" : 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));
|
settingsNavItems.forEach((item) => {
|
||||||
settingsPanels.forEach((panel) => panel.classList.toggle("active", panel.dataset.settingsPanel === normalizedView));
|
const isActive = item.dataset.settingsView === normalizedView;
|
||||||
|
item.classList.toggle("active", isActive);
|
||||||
|
item.setAttribute("aria-selected", String(isActive));
|
||||||
|
});
|
||||||
|
settingsPanels.forEach((panel) => {
|
||||||
|
const isActive = panel.dataset.settingsPanel === normalizedView;
|
||||||
|
panel.classList.toggle("active", isActive);
|
||||||
|
panel.setAttribute("aria-hidden", String(!isActive));
|
||||||
|
});
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
params.set("view", normalizedView);
|
params.set("view", normalizedView);
|
||||||
history.replaceState(null, "", `${window.location.pathname}?${params.toString()}`);
|
history.replaceState(null, "", `${window.location.pathname}?${params.toString()}`);
|
||||||
|
|||||||
+1273
File diff suppressed because it is too large
Load Diff
+11
-2
@@ -314,7 +314,9 @@ export function createPersonasRouter({repositories, chatService, modelService, c
|
|||||||
scenarioPrompt,
|
scenarioPrompt,
|
||||||
sceneSummary
|
sceneSummary
|
||||||
});
|
});
|
||||||
if (!opener) return res.status(500).json({error: "Failed to generate the character opener"});
|
if (!opener || chatService.containsUserVoiceInRoleplayOpener(opener, userPersona)) {
|
||||||
|
return res.json({chat, existing: false, generatedInitialMessage: false, degraded: true});
|
||||||
|
}
|
||||||
chatService.insertChatMessage(chat.id, "bot", opener, {
|
chatService.insertChatMessage(chat.id, "bot", opener, {
|
||||||
retryVariants: [opener],
|
retryVariants: [opener],
|
||||||
retryActiveIndex: 0,
|
retryActiveIndex: 0,
|
||||||
@@ -325,7 +327,14 @@ export function createPersonasRouter({repositories, chatService, modelService, c
|
|||||||
return res.json({chat: repositories.getChat(chat.id, user), existing: false, generatedInitialMessage: true, opener});
|
return res.json({chat: repositories.getChat(chat.id, user), existing: false, generatedInitialMessage: true, opener});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const mapped = modelService.mapError(error);
|
const mapped = modelService.mapError(error);
|
||||||
return res.status(mapped.status).json(mapped.body);
|
return res.json({
|
||||||
|
chat,
|
||||||
|
existing: false,
|
||||||
|
generatedInitialMessage: false,
|
||||||
|
degraded: true,
|
||||||
|
modelError: mapped.body.error,
|
||||||
|
modelErrorCode: mapped.body.code
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -278,6 +278,9 @@ export function createChatService(repositories, modelService, config) {
|
|||||||
: 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};
|
if (!fullReply) return {error: "Failed to generate a valid character reply", status: 500};
|
||||||
|
if (activePersona && containsUserVoiceInRoleplayOpener(fullReply, activeUserPersona)) {
|
||||||
|
return {error: "Failed to generate a valid character reply", status: 500};
|
||||||
|
}
|
||||||
insertChatMessage(chatId, "bot", fullReply, {
|
insertChatMessage(chatId, "bot", fullReply, {
|
||||||
retryVariants: [fullReply],
|
retryVariants: [fullReply],
|
||||||
retryActiveIndex: 0,
|
retryActiveIndex: 0,
|
||||||
@@ -285,9 +288,6 @@ export function createChatService(repositories, modelService, config) {
|
|||||||
retryPromptMessageId: null
|
retryPromptMessageId: null
|
||||||
}, model);
|
}, model);
|
||||||
repositories.touchChat(chatId, user);
|
repositories.touchChat(chatId, user);
|
||||||
if (activePersona && containsUserVoiceInRoleplayOpener(fullReply, activeUserPersona)) {
|
|
||||||
return {error: "Failed to generate a valid character reply", status: 500};
|
|
||||||
}
|
|
||||||
return {reply: fullReply};
|
return {reply: fullReply};
|
||||||
},
|
},
|
||||||
async retryMessage({user, chatId, selectedModel, targetMessage, retryStyle = null}) {
|
async retryMessage({user, chatId, selectedModel, targetMessage, retryStyle = null}) {
|
||||||
|
|||||||
@@ -92,18 +92,20 @@ export function createModelService(config, overrides = {}) {
|
|||||||
state.activeRequests += 1;
|
state.activeRequests += 1;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await withTimeout(config.modelRequestTimeoutMs, (signal) => fetchImpl(`${config.modelApiBaseUrl}/chat`, {
|
const fullReply = await withTimeout(config.modelRequestTimeoutMs, async (signal) => {
|
||||||
|
const response = await fetchImpl(`${config.modelApiBaseUrl}/chat`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {"Content-Type": "application/json"},
|
headers: {"Content-Type": "application/json"},
|
||||||
body: JSON.stringify({model, messages: messagesPayload, stream: true}),
|
body: JSON.stringify({model, messages: messagesPayload, stream: true}),
|
||||||
signal
|
signal
|
||||||
}));
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new ModelServiceError("MODEL_UPSTREAM_ERROR", `Model backend returned ${response.status}.`, 502);
|
throw new ModelServiceError("MODEL_UPSTREAM_ERROR", `Model backend returned ${response.status}.`, 502);
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullReply = await readStreamingReply(response);
|
return readStreamingReply(response);
|
||||||
|
});
|
||||||
lastHealth = {ok: true, checkedAt: new Date().toISOString(), error: null};
|
lastHealth = {ok: true, checkedAt: new Date().toISOString(), error: null};
|
||||||
return fullReply;
|
return fullReply;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -124,18 +126,20 @@ export function createModelService(config, overrides = {}) {
|
|||||||
state.activeRequests += 1;
|
state.activeRequests += 1;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await withTimeout(config.modelRequestTimeoutMs, (signal) => fetchImpl(`${config.modelApiBaseUrl}/chat`, {
|
const fullReply = await withTimeout(config.modelRequestTimeoutMs, async (signal) => {
|
||||||
|
const response = await fetchImpl(`${config.modelApiBaseUrl}/chat`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {"Content-Type": "application/json"},
|
headers: {"Content-Type": "application/json"},
|
||||||
body: JSON.stringify({model, messages: messagesPayload, stream: true}),
|
body: JSON.stringify({model, messages: messagesPayload, stream: true}),
|
||||||
signal
|
signal
|
||||||
}));
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new ModelServiceError("MODEL_UPSTREAM_ERROR", `Model backend returned ${response.status}.`, 502);
|
throw new ModelServiceError("MODEL_UPSTREAM_ERROR", `Model backend returned ${response.status}.`, 502);
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullReply = await readStreamingReply(response, onChunk);
|
return readStreamingReply(response, onChunk);
|
||||||
|
});
|
||||||
lastHealth = {ok: true, checkedAt: new Date().toISOString(), error: null};
|
lastHealth = {ok: true, checkedAt: new Date().toISOString(), error: null};
|
||||||
return fullReply;
|
return fullReply;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -157,11 +161,13 @@ export function createModelService(config, overrides = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await withTimeout(config.modelListTimeoutMs, (signal) => fetchImpl(`${config.modelApiBaseUrl}/tags`, {signal}));
|
modelsCache = await withTimeout(config.modelListTimeoutMs, async (signal) => {
|
||||||
|
const response = await fetchImpl(`${config.modelApiBaseUrl}/tags`, {signal});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new ModelServiceError("MODEL_LIST_FAILED", "Failed to load models.", 502);
|
throw new ModelServiceError("MODEL_LIST_FAILED", "Failed to load models.", 502);
|
||||||
}
|
}
|
||||||
modelsCache = await response.json();
|
return response.json();
|
||||||
|
});
|
||||||
modelsCacheAt = Date.now();
|
modelsCacheAt = Date.now();
|
||||||
lastHealth = {ok: true, checkedAt: new Date().toISOString(), error: null};
|
lastHealth = {ok: true, checkedAt: new Date().toISOString(), error: null};
|
||||||
return modelsCache;
|
return modelsCache;
|
||||||
|
|||||||
@@ -7,6 +7,15 @@ const PERSONA_FIELD_LIMITS = {
|
|||||||
exampleDialogues: 12000
|
exampleDialogues: 12000
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PERSONA_PROMPT_FIELD_LIMITS = {
|
||||||
|
appearance: 1200,
|
||||||
|
background: 1400,
|
||||||
|
details: 1400,
|
||||||
|
exampleDialogues: 1800,
|
||||||
|
summaryField: 360,
|
||||||
|
scenarioPrompt: 700
|
||||||
|
};
|
||||||
|
|
||||||
const blockedPersonaPatterns = [
|
const blockedPersonaPatterns = [
|
||||||
/\b(child|children|kid|kids|minor|underage|under-aged|teenager|young girl|young boy|loli|shota)\b/i,
|
/\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(nazi|hitler|third reich|kkk|ku klux klan|white power|nigger|nigga)\b/i,
|
||||||
@@ -59,13 +68,19 @@ export function clampText(value, limit = 260) {
|
|||||||
return `${normalized.slice(0, limit - 3).trimEnd()}...`;
|
return `${normalized.slice(0, limit - 3).trimEnd()}...`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clampPromptText(value, limit) {
|
||||||
|
const normalized = String(value || "").trim();
|
||||||
|
if (normalized.length <= limit) return normalized;
|
||||||
|
return `${normalized.slice(0, limit - 3).trimEnd()}...`;
|
||||||
|
}
|
||||||
|
|
||||||
export function summarizePersona(persona) {
|
export function summarizePersona(persona) {
|
||||||
if (!persona) return "";
|
if (!persona) return "";
|
||||||
return [
|
return [
|
||||||
persona.name ? `Name: ${persona.name}` : null,
|
persona.name ? `Name: ${persona.name}` : null,
|
||||||
persona.pronouns ? `Pronouns: ${persona.pronouns}` : null,
|
persona.pronouns ? `Pronouns: ${persona.pronouns}` : null,
|
||||||
persona.background ? `Background: ${persona.background}` : null,
|
persona.background ? `Background: ${clampText(persona.background, PERSONA_PROMPT_FIELD_LIMITS.summaryField)}` : null,
|
||||||
persona.details ? `Traits: ${persona.details}` : null
|
persona.details ? `Traits: ${clampText(persona.details, PERSONA_PROMPT_FIELD_LIMITS.summaryField)}` : null
|
||||||
].filter(Boolean).join(" | ");
|
].filter(Boolean).join(" | ");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,13 +100,13 @@ export function buildPersonaPrompt(persona) {
|
|||||||
];
|
];
|
||||||
if (persona.name) lines.push(`Name: ${persona.name}`);
|
if (persona.name) lines.push(`Name: ${persona.name}`);
|
||||||
if (persona.pronouns) lines.push(`Pronouns: ${persona.pronouns}`);
|
if (persona.pronouns) lines.push(`Pronouns: ${persona.pronouns}`);
|
||||||
if (persona.appearance) lines.push(`Appearance: ${persona.appearance}`);
|
if (persona.appearance) lines.push(`Appearance: ${clampPromptText(persona.appearance, PERSONA_PROMPT_FIELD_LIMITS.appearance)}`);
|
||||||
if (persona.background) lines.push(`Background: ${persona.background}`);
|
if (persona.background) lines.push(`Background: ${clampPromptText(persona.background, PERSONA_PROMPT_FIELD_LIMITS.background)}`);
|
||||||
if (persona.details) lines.push(`Additional Traits: ${persona.details}`);
|
if (persona.details) lines.push(`Additional Traits: ${clampPromptText(persona.details, PERSONA_PROMPT_FIELD_LIMITS.details)}`);
|
||||||
if (persona.example_dialogues) {
|
if (persona.example_dialogues) {
|
||||||
lines.push("", "EXAMPLE DIALOGUES:");
|
lines.push("", "EXAMPLE DIALOGUES:");
|
||||||
lines.push("Use these examples to mirror tone, cadence, and phrasing without rigidly repeating them.");
|
lines.push("Use these examples to mirror tone, cadence, and phrasing without rigidly repeating them.");
|
||||||
lines.push(persona.example_dialogues);
|
lines.push(clampPromptText(persona.example_dialogues, PERSONA_PROMPT_FIELD_LIMITS.exampleDialogues));
|
||||||
}
|
}
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
@@ -110,9 +125,9 @@ export function buildUserPersonaPrompt(persona) {
|
|||||||
];
|
];
|
||||||
if (persona.name) lines.push(`Name: ${persona.name}`);
|
if (persona.name) lines.push(`Name: ${persona.name}`);
|
||||||
if (persona.pronouns) lines.push(`Pronouns: ${persona.pronouns}`);
|
if (persona.pronouns) lines.push(`Pronouns: ${persona.pronouns}`);
|
||||||
if (persona.appearance) lines.push(`Appearance: ${persona.appearance}`);
|
if (persona.appearance) lines.push(`Appearance: ${clampPromptText(persona.appearance, PERSONA_PROMPT_FIELD_LIMITS.appearance)}`);
|
||||||
if (persona.background) lines.push(`Background: ${persona.background}`);
|
if (persona.background) lines.push(`Background: ${clampPromptText(persona.background, PERSONA_PROMPT_FIELD_LIMITS.background)}`);
|
||||||
if (persona.details) lines.push(`Additional Traits: ${persona.details}`);
|
if (persona.details) lines.push(`Additional Traits: ${clampPromptText(persona.details, PERSONA_PROMPT_FIELD_LIMITS.details)}`);
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,6 +143,7 @@ export function buildRoleplaySceneSummary(assistantPersona, userPersona, scenari
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildRoleplayDirectionPrompt({assistantPersona, userPersona, scenarioPrompt, sceneSummary}) {
|
export function buildRoleplayDirectionPrompt({assistantPersona, userPersona, scenarioPrompt, sceneSummary}) {
|
||||||
|
const scenarioSeed = clampPromptText(scenarioPrompt || "", PERSONA_PROMPT_FIELD_LIMITS.scenarioPrompt);
|
||||||
const lines = [
|
const lines = [
|
||||||
"ROLEPLAY DIRECTION:",
|
"ROLEPLAY DIRECTION:",
|
||||||
"Create an immersive one-on-one roleplay between the assistant persona and the user.",
|
"Create an immersive one-on-one roleplay between the assistant persona and the user.",
|
||||||
@@ -149,7 +165,7 @@ export function buildRoleplayDirectionPrompt({assistantPersona, userPersona, sce
|
|||||||
"",
|
"",
|
||||||
`SCENE SUMMARY: ${sceneSummary}`
|
`SCENE SUMMARY: ${sceneSummary}`
|
||||||
];
|
];
|
||||||
if (scenarioPrompt) lines.push(`USER SCENARIO SEED: ${scenarioPrompt}`);
|
if (scenarioSeed) lines.push(`USER SCENARIO SEED: ${scenarioSeed}`);
|
||||||
if (assistantPersona) lines.push("", `ASSISTANT SNAPSHOT: ${summarizePersona(assistantPersona)}`);
|
if (assistantPersona) lines.push("", `ASSISTANT SNAPSHOT: ${summarizePersona(assistantPersona)}`);
|
||||||
if (userPersona) lines.push(`USER SNAPSHOT: ${summarizePersona(userPersona)}`);
|
if (userPersona) lines.push(`USER SNAPSHOT: ${summarizePersona(userPersona)}`);
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
@@ -158,8 +174,9 @@ export function buildRoleplayDirectionPrompt({assistantPersona, userPersona, sce
|
|||||||
export function buildRoleplayOpenerPrompt({assistantPersona, userPersona, scenarioPrompt, sceneSummary}) {
|
export function buildRoleplayOpenerPrompt({assistantPersona, userPersona, scenarioPrompt, sceneSummary}) {
|
||||||
const assistantName = assistantPersona?.name || "the assistant";
|
const assistantName = assistantPersona?.name || "the assistant";
|
||||||
const userName = userPersona?.name || "the user";
|
const userName = userPersona?.name || "the user";
|
||||||
|
const scenarioSeedText = clampPromptText(scenarioPrompt || "", PERSONA_PROMPT_FIELD_LIMITS.scenarioPrompt);
|
||||||
const sceneSeed = scenarioPrompt
|
const sceneSeed = scenarioPrompt
|
||||||
? `Use this scenario seed: ${scenarioPrompt}`
|
? `Use this scenario seed: ${scenarioSeedText}`
|
||||||
: `Invent a fresh scenario that suits ${assistantName} and how they would realistically meet or confront ${userName}.`;
|
: `Invent a fresh scenario that suits ${assistantName} and how they would realistically meet or confront ${userName}.`;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|||||||
+88
-3
@@ -20,9 +20,9 @@ class CookieJar {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startTestServer() {
|
async function startTestServer(overrides = {}) {
|
||||||
let replyCount = 0;
|
let replyCount = 0;
|
||||||
const modelService = {
|
const modelService = overrides.modelService || {
|
||||||
async generateReply() {
|
async generateReply() {
|
||||||
replyCount += 1;
|
replyCount += 1;
|
||||||
return `stub-reply-${replyCount}`;
|
return `stub-reply-${replyCount}`;
|
||||||
@@ -52,7 +52,8 @@ async function startTestServer() {
|
|||||||
port: 0,
|
port: 0,
|
||||||
dbPath: ":memory:",
|
dbPath: ":memory:",
|
||||||
testMode: true,
|
testMode: true,
|
||||||
sessionSecret: "test-secret"
|
sessionSecret: "test-secret",
|
||||||
|
...(overrides.config || {})
|
||||||
},
|
},
|
||||||
modelService
|
modelService
|
||||||
});
|
});
|
||||||
@@ -156,6 +157,90 @@ test("chat creation, send, and retry endpoints work with stubbed model service",
|
|||||||
await server.close();
|
await server.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("roleplay start keeps the session and roleplay messages work", async () => {
|
||||||
|
const server = await startTestServer();
|
||||||
|
const jar = new CookieJar();
|
||||||
|
|
||||||
|
await request(server.baseUrl, "/register", {method: "POST", body: {username: "roleplayer", password: "password123"}});
|
||||||
|
await request(server.baseUrl, "/login", {method: "POST", body: {username: "roleplayer", password: "password123"}, jar});
|
||||||
|
|
||||||
|
const persona = await request(server.baseUrl, "/personas", {
|
||||||
|
method: "POST",
|
||||||
|
body: {personaType: "assistant", name: "Guide", details: "Patient and direct"},
|
||||||
|
jar
|
||||||
|
});
|
||||||
|
assert.equal(persona.status, 200);
|
||||||
|
|
||||||
|
const started = await request(server.baseUrl, "/roleplays/start", {
|
||||||
|
method: "POST",
|
||||||
|
body: {assistantPersonaId: persona.json.persona.id, scenarioPrompt: "A quiet station.", model: "mistral:latest"},
|
||||||
|
jar
|
||||||
|
});
|
||||||
|
assert.equal(started.status, 200);
|
||||||
|
assert.equal(started.json.generatedInitialMessage, true);
|
||||||
|
assert.ok(started.json.chat.id);
|
||||||
|
|
||||||
|
const session = await request(server.baseUrl, "/session", {jar});
|
||||||
|
assert.equal(session.status, 200);
|
||||||
|
assert.equal(session.json.user, "roleplayer");
|
||||||
|
|
||||||
|
const sendMessage = await request(server.baseUrl, "/chat", {
|
||||||
|
method: "POST",
|
||||||
|
body: {chatId: started.json.chat.id, message: "I step closer.", model: "mistral:latest"},
|
||||||
|
jar
|
||||||
|
});
|
||||||
|
assert.equal(sendMessage.status, 200);
|
||||||
|
assert.equal(sendMessage.json.reply, "stub-reply-2");
|
||||||
|
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("roleplay start degrades to an open chat when opener generation fails", async () => {
|
||||||
|
const modelService = {
|
||||||
|
async generateReply() {
|
||||||
|
throw new Error("Model backend timed out.");
|
||||||
|
},
|
||||||
|
async streamReply() {
|
||||||
|
return "unused";
|
||||||
|
},
|
||||||
|
async listModels() {
|
||||||
|
return {models: [{name: "mistral:latest"}]};
|
||||||
|
},
|
||||||
|
async checkHealth() {
|
||||||
|
return {ok: false, checkedAt: new Date().toISOString(), error: "Model backend timed out."};
|
||||||
|
},
|
||||||
|
mapError(error) {
|
||||||
|
return {status: 504, body: {error: error.message, code: "MODEL_TIMEOUT"}};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const server = await startTestServer({modelService});
|
||||||
|
const jar = new CookieJar();
|
||||||
|
|
||||||
|
await request(server.baseUrl, "/register", {method: "POST", body: {username: "degraded", password: "password123"}});
|
||||||
|
await request(server.baseUrl, "/login", {method: "POST", body: {username: "degraded", password: "password123"}, jar});
|
||||||
|
|
||||||
|
const persona = await request(server.baseUrl, "/personas", {
|
||||||
|
method: "POST",
|
||||||
|
body: {personaType: "assistant", name: "Guide", details: "Patient and direct"},
|
||||||
|
jar
|
||||||
|
});
|
||||||
|
|
||||||
|
const started = await request(server.baseUrl, "/roleplays/start", {
|
||||||
|
method: "POST",
|
||||||
|
body: {assistantPersonaId: persona.json.persona.id, model: "mistral:latest"},
|
||||||
|
jar
|
||||||
|
});
|
||||||
|
assert.equal(started.status, 200);
|
||||||
|
assert.equal(started.json.degraded, true);
|
||||||
|
assert.equal(started.json.generatedInitialMessage, false);
|
||||||
|
assert.ok(started.json.chat.id);
|
||||||
|
|
||||||
|
const session = await request(server.baseUrl, "/session", {jar});
|
||||||
|
assert.equal(session.json.user, "degraded");
|
||||||
|
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
test("persona publish and collect flow works across accounts", async () => {
|
test("persona publish and collect flow works across accounts", async () => {
|
||||||
const server = await startTestServer();
|
const server = await startTestServer();
|
||||||
const creatorJar = new CookieJar();
|
const creatorJar = new CookieJar();
|
||||||
|
|||||||
Reference in New Issue
Block a user