438 lines
21 KiB
JavaScript
438 lines
21 KiB
JavaScript
import {
|
|
personaFormNotice,
|
|
roleplayCharacterSelect,
|
|
roleplayUserPersonaSelect,
|
|
roleplayStarterSuggestions,
|
|
roleplayScenarioInput,
|
|
roleplayStarterNotice,
|
|
roleplayStarterStepOne,
|
|
roleplayStarterStepTwo,
|
|
roleplayStarterBack,
|
|
roleplayStarterTitle,
|
|
roleplayStarterIntroCopy,
|
|
roleplayStarterCancel,
|
|
roleplayStarterConfirm,
|
|
activeUserPersonaStatus,
|
|
chatDrawerPersonaValue,
|
|
headerPersonaName,
|
|
clearUserPersonaBtn,
|
|
personaModal,
|
|
personaNameInput,
|
|
personaFormTitle,
|
|
personaTypeSelect,
|
|
personaPronounsInput,
|
|
personaAppearanceInput,
|
|
personaBackgroundInput,
|
|
personaDetailsInput,
|
|
personaExampleDialoguesInput,
|
|
personaExamplesField,
|
|
roleplayStarterModal,
|
|
userPersonaList,
|
|
modelSelect
|
|
} from "./dom.js?v=20260909-account-controls";
|
|
import {closeChatDrawer} from "./chatDrawer.js?v=20260909-account-controls";
|
|
import {openModal, closeModal} from "./modalFocus.js";
|
|
import {get, post, put, del} from "./api.js?v=20260909-account-controls";
|
|
|
|
export function createPersonaController({
|
|
state,
|
|
setNotice,
|
|
closePopup,
|
|
updateWorkspaceCopy,
|
|
loadChatSessions,
|
|
confirmPopup,
|
|
setChatLoading,
|
|
setChatActivity,
|
|
setActiveChat
|
|
}) {
|
|
function setPersonaFormNotice(message = "", noticeState = "") {
|
|
if (!personaFormNotice) return;
|
|
if (!message) {
|
|
personaFormNotice.textContent = "";
|
|
personaFormNotice.className = "status hidden";
|
|
return;
|
|
}
|
|
personaFormNotice.textContent = message;
|
|
personaFormNotice.className = noticeState ? `status ${noticeState}` : "status";
|
|
}
|
|
|
|
function getPersonaName(id, items) {
|
|
return items.find((persona) => persona.id === id)?.name || "";
|
|
}
|
|
|
|
function getRoleplayStarterSuggestions() {
|
|
const assistantPersonaId = Number(roleplayCharacterSelect?.value);
|
|
const userPersonaId = roleplayUserPersonaSelect?.value ? Number(roleplayUserPersonaSelect.value) : null;
|
|
const assistantName = getPersonaName(assistantPersonaId, state.assistantPersonas) || "the character";
|
|
const userName = userPersonaId ? getPersonaName(userPersonaId, state.userPersonas) : "me";
|
|
return [
|
|
`${assistantName} unexpectedly crosses paths with ${userName} in a place that matters to them both.`,
|
|
`${assistantName} already knows something important about ${userName} and confronts them with it.`,
|
|
`${assistantName} and ${userName} are forced to cooperate while something goes wrong around them.`,
|
|
`${assistantName} meets ${userName} in a quiet, intimate scene with unresolved tension.`,
|
|
`${assistantName} draws ${userName} into a high-stakes problem that starts immediately.`
|
|
];
|
|
}
|
|
|
|
function updateRoleplaySuggestions() {
|
|
if (!roleplayStarterSuggestions) return;
|
|
roleplayStarterSuggestions.innerHTML = "";
|
|
getRoleplayStarterSuggestions().forEach((suggestion) => {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.className = "prompt-chip";
|
|
button.textContent = suggestion;
|
|
button.addEventListener("click", () => {
|
|
if (roleplayScenarioInput) {
|
|
roleplayScenarioInput.value = suggestion;
|
|
roleplayScenarioInput.focus();
|
|
}
|
|
});
|
|
roleplayStarterSuggestions.appendChild(button);
|
|
});
|
|
}
|
|
|
|
function setRoleplayStarterNotice(message = "", noticeState = "") {
|
|
if (!roleplayStarterNotice) return;
|
|
if (!message) {
|
|
roleplayStarterNotice.textContent = "";
|
|
roleplayStarterNotice.className = "status hidden";
|
|
return;
|
|
}
|
|
roleplayStarterNotice.textContent = message;
|
|
roleplayStarterNotice.className = noticeState ? `status ${noticeState}` : "status";
|
|
}
|
|
|
|
function renderRoleplayStarterStep() {
|
|
const isSceneStep = state.roleplayStarterStep === 2;
|
|
if (roleplayStarterStepOne) roleplayStarterStepOne.classList.toggle("hidden", isSceneStep);
|
|
if (roleplayStarterStepTwo) roleplayStarterStepTwo.classList.toggle("hidden", !isSceneStep);
|
|
if (roleplayStarterBack) roleplayStarterBack.classList.toggle("hidden", !isSceneStep);
|
|
if (roleplayStarterTitle) {
|
|
roleplayStarterTitle.textContent = isSceneStep ? "Shape the opening scene" : "Choose your roleplay";
|
|
}
|
|
if (roleplayStarterIntroCopy) {
|
|
roleplayStarterIntroCopy.textContent = isSceneStep
|
|
? "Add an optional custom scenario. If you leave it empty, the AI Character will invent its own opening scene."
|
|
: "Pick a character and choose who you are before moving on to the opening scene.";
|
|
}
|
|
if (roleplayStarterCancel) {
|
|
roleplayStarterCancel.textContent = isSceneStep ? "Close" : "Cancel";
|
|
}
|
|
if (roleplayStarterConfirm) {
|
|
roleplayStarterConfirm.textContent = isSceneStep ? "Start roleplay" : "Continue";
|
|
}
|
|
}
|
|
|
|
function renderPersonaSection() {
|
|
const activeChat = state.chatSessions.find((chat) => chat.id === state.activeChatId);
|
|
const chatPersona = state.userPersonas.find((persona) => persona.id === activeChat?.user_persona_id);
|
|
if (activeUserPersonaStatus) {
|
|
activeUserPersonaStatus.textContent = activeChat
|
|
? `Using in this chat: ${chatPersona?.name || "yourself"}. Default: ${getPersonaName(state.activeUserPersonaId, state.userPersonas) || "yourself"}.`
|
|
: `Default user persona: ${getPersonaName(state.activeUserPersonaId, state.userPersonas) || "yourself"}.`;
|
|
}
|
|
if (chatDrawerPersonaValue) {
|
|
chatDrawerPersonaValue.textContent = chatPersona?.name || "Yourself";
|
|
if (headerPersonaName) headerPersonaName.textContent = chatPersona?.name || "Yourself";
|
|
}
|
|
if (clearUserPersonaBtn) clearUserPersonaBtn.disabled = !activeChat?.user_persona_id;
|
|
}
|
|
|
|
function maybeShowPersonaNudge() {
|
|
if (localStorage.getItem("krishd-persona-nudge-shown") === "true") return;
|
|
const assistantOnlyChats = state.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 openPersonaForm(persona = null, personaType = "assistant") {
|
|
closeChatDrawer();
|
|
closePopup(false);
|
|
openModal(personaModal, personaNameInput);
|
|
if (persona) {
|
|
state.editingPersonaId = persona.id; state.editingPersonaType = persona.persona_type || "assistant";
|
|
personaFormTitle.textContent = `Edit ${state.editingPersonaType === "user" ? "your persona" : "AI Character"}`;
|
|
personaTypeSelect.value = state.editingPersonaType; personaTypeSelect.disabled = true;
|
|
personaNameInput.value = persona.name || ""; personaPronounsInput.value = persona.pronouns || "";
|
|
personaAppearanceInput.value = persona.appearance || ""; personaBackgroundInput.value = persona.background || ""; personaDetailsInput.value = persona.details || "";
|
|
personaExampleDialoguesInput.value = persona.example_dialogues || "";
|
|
} else {
|
|
state.editingPersonaId = null; state.editingPersonaType = personaType;
|
|
personaFormTitle.textContent = `Create ${personaType === "user" ? "your persona" : "AI Character"}`;
|
|
personaTypeSelect.value = personaType; personaTypeSelect.disabled = false;
|
|
personaNameInput.value = ""; personaPronounsInput.value = ""; personaAppearanceInput.value = ""; personaBackgroundInput.value = ""; personaDetailsInput.value = "";
|
|
personaExampleDialoguesInput.value = "";
|
|
}
|
|
personaExamplesField.classList.toggle("hidden", personaTypeSelect.value !== "assistant");
|
|
setPersonaFormNotice("");
|
|
personaNameInput.focus();
|
|
}
|
|
|
|
function closePersonaForm() { closeModal(personaModal); state.editingPersonaId = null; state.editingPersonaType = "assistant"; personaTypeSelect.disabled = false; setPersonaFormNotice(""); }
|
|
|
|
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);
|
|
state.assistantPersonas.forEach((persona) => {
|
|
const option = document.createElement("option");
|
|
option.value = String(persona.id);
|
|
option.textContent = persona.name;
|
|
option.selected = persona.id === state.roleplayPresetCharacterId;
|
|
roleplayCharacterSelect.appendChild(option);
|
|
});
|
|
if (state.roleplayPresetCharacterId) {
|
|
roleplayCharacterSelect.value = String(state.roleplayPresetCharacterId);
|
|
}
|
|
roleplayUserPersonaSelect.innerHTML = "";
|
|
const noneOption = document.createElement("option");
|
|
noneOption.value = "";
|
|
noneOption.textContent = "Myself (no user persona)";
|
|
roleplayUserPersonaSelect.appendChild(noneOption);
|
|
state.userPersonas.forEach((persona) => {
|
|
const option = document.createElement("option");
|
|
option.value = String(persona.id);
|
|
option.textContent = persona.name;
|
|
option.selected = persona.id === state.activeUserPersonaId;
|
|
roleplayUserPersonaSelect.appendChild(option);
|
|
});
|
|
if (state.assistantPersonas.length === 0) {
|
|
setRoleplayStarterNotice("Create an AI Character first, then start roleplay.", "error");
|
|
roleplayStarterConfirm.disabled = true;
|
|
} else {
|
|
setRoleplayStarterNotice("");
|
|
roleplayStarterConfirm.disabled = false;
|
|
}
|
|
updateRoleplaySuggestions();
|
|
renderRoleplayStarterStep();
|
|
}
|
|
|
|
function openRoleplayStarter(characterId = null) {
|
|
closeChatDrawer();
|
|
state.roleplayPresetCharacterId = characterId;
|
|
state.roleplayStarterStep = 1;
|
|
populateRoleplayStarter();
|
|
if (roleplayScenarioInput) roleplayScenarioInput.value = "";
|
|
setRoleplayStarterNotice("");
|
|
openModal(roleplayStarterModal, roleplayCharacterSelect);
|
|
}
|
|
|
|
function closeRoleplayStarter() {
|
|
if (!roleplayStarterModal) return;
|
|
closeModal(roleplayStarterModal);
|
|
state.roleplayPresetCharacterId = null;
|
|
state.roleplayStarterStep = 1;
|
|
roleplayStarterConfirm.disabled = false;
|
|
if (roleplayScenarioInput) roleplayScenarioInput.value = "";
|
|
setRoleplayStarterNotice("");
|
|
renderRoleplayStarterStep();
|
|
}
|
|
|
|
function goToRoleplayStarterSceneStep() {
|
|
if (!state.assistantPersonas.length) {
|
|
return setRoleplayStarterNotice("Create an AI Character first, then start roleplay.", "error");
|
|
}
|
|
if (!roleplayCharacterSelect.value) {
|
|
return setRoleplayStarterNotice("Choose a character to continue.", "error");
|
|
}
|
|
state.roleplayStarterStep = 2;
|
|
setRoleplayStarterNotice("");
|
|
renderRoleplayStarterStep();
|
|
if (roleplayScenarioInput) roleplayScenarioInput.focus();
|
|
}
|
|
|
|
function renderPersonaList(items, activeId, listElement, personaType) {
|
|
if (!listElement) {
|
|
renderPersonaSection();
|
|
return;
|
|
}
|
|
listElement.innerHTML = "";
|
|
if (!items.length) {
|
|
const empty = document.createElement("p");
|
|
empty.className = "status persona-status";
|
|
empty.textContent = "No personas yet. Create one to get started.";
|
|
listElement.appendChild(empty);
|
|
renderPersonaSection();
|
|
return;
|
|
}
|
|
const selectedId = personaType === "user"
|
|
? state.chatSessions.find((chat) => chat.id === state.activeChatId)?.user_persona_id ?? null
|
|
: activeId;
|
|
items.forEach((persona) => {
|
|
const item = document.createElement("div"), titleRow = document.createElement("div"), title = document.createElement("h4");
|
|
const tag = document.createElement("span"), meta = document.createElement("p"), actions = document.createElement("div");
|
|
item.className = `persona-item${personaType === "user" && persona.id === selectedId ? " active" : ""}`; titleRow.className = "persona-title-row"; actions.className = "persona-item-actions";
|
|
tag.className = `persona-role-tag ${personaType === "user" ? "user" : "ai"}`; tag.textContent = personaType === "user" ? "You" : "AI";
|
|
title.textContent = persona.name; meta.textContent = persona.pronouns ? `Pronouns: ${persona.pronouns}` : "Pronouns: n/a";
|
|
[[personaType === "assistant" ? "Start roleplay" : "Use in this chat", () => personaType === "user" ? setChatUserPersona(persona.id) : equipPersona(persona.id, personaType), personaType === "user" && persona.id === selectedId],
|
|
...(personaType === "user" ? [["Set default", () => equipPersona(persona.id, personaType), persona.id === state.activeUserPersonaId]] : []),
|
|
["Edit", () => openPersonaForm(persona), false],
|
|
[state.publishedPersonaIds.has(persona.id) ? "Update listing" : "Publish", () => publishPersona(persona.id), false],
|
|
["Delete", () => deletePersona(persona.id), false]]
|
|
.forEach(([label, handler, disabled]) => {
|
|
const btn = document.createElement("button");
|
|
btn.type = "button"; btn.textContent = label; btn.disabled = disabled; btn.addEventListener("click", handler); actions.appendChild(btn);
|
|
});
|
|
if (state.publishedPersonaIds.has(persona.id)) {
|
|
const btn = document.createElement("button");
|
|
btn.type = "button"; btn.textContent = "Unpublish"; btn.addEventListener("click", () => unpublishPersona(persona.id)); actions.appendChild(btn);
|
|
}
|
|
titleRow.append(title, tag); item.append(titleRow, meta, actions); listElement.appendChild(item);
|
|
});
|
|
renderPersonaSection();
|
|
}
|
|
|
|
async function loadPersonas() {
|
|
const res = await get("/personas");
|
|
if (res.error) return setNotice(res.error, "error");
|
|
state.assistantPersonas = res.assistantPersonas || []; state.userPersonas = res.userPersonas || [];
|
|
state.activeUserPersonaId = res.activeUserPersonaId || null; state.publishedPersonaIds = new Set(res.publishedPersonaIds || []);
|
|
renderPersonaList(state.userPersonas, state.activeUserPersonaId, userPersonaList, "user");
|
|
populateRoleplayStarter();
|
|
renderPersonaSection();
|
|
updateWorkspaceCopy();
|
|
}
|
|
|
|
async function equipPersona(id, type) {
|
|
if (type === "assistant") {
|
|
openRoleplayStarter(id);
|
|
return;
|
|
}
|
|
const res = await post(`/personas/${id}/equip-user`, {});
|
|
if (res.error) return setNotice(res.error, "error");
|
|
state.activeUserPersonaId = id;
|
|
renderPersonaList(state.userPersonas, state.activeUserPersonaId, userPersonaList, "user");
|
|
setNotice("User persona updated.", "success");
|
|
}
|
|
|
|
async function setChatUserPersona(id) {
|
|
if (!state.activeChatId) return setNotice("Open a chat before choosing a user persona.", "error");
|
|
const res = await put(`/chats/${state.activeChatId}/user-persona`, {userPersonaId: id});
|
|
if (res.error) return setNotice(res.error, "error");
|
|
const chat = state.chatSessions.find((item) => item.id === state.activeChatId);
|
|
if (chat) Object.assign(chat, res.chat);
|
|
renderPersonaList(state.userPersonas, res.chat.user_persona_id, userPersonaList, "user");
|
|
setNotice("User persona switched for this chat.", "success");
|
|
}
|
|
|
|
async function clearChatUserPersona() {
|
|
if (!state.activeChatId) return;
|
|
const res = await put(`/chats/${state.activeChatId}/user-persona`, {userPersonaId: null});
|
|
if (res.error) return setNotice(res.error, "error");
|
|
const chat = state.chatSessions.find((item) => item.id === state.activeChatId);
|
|
if (chat) Object.assign(chat, res.chat);
|
|
renderPersonaList(state.userPersonas, null, userPersonaList, "user");
|
|
setNotice("This chat will use yourself.", "success");
|
|
}
|
|
|
|
async function savePersona() {
|
|
const payload = {
|
|
personaType: personaTypeSelect.value,
|
|
name: personaNameInput.value.trim(),
|
|
pronouns: personaPronounsInput.value.trim(),
|
|
appearance: personaAppearanceInput.value.trim(),
|
|
background: personaBackgroundInput.value.trim(),
|
|
details: personaDetailsInput.value.trim(),
|
|
exampleDialogues: personaTypeSelect.value === "assistant" ? personaExampleDialoguesInput.value.trim() : ""
|
|
};
|
|
if (!payload.name) return setPersonaFormNotice("Persona name is required.", "error");
|
|
const res = state.editingPersonaId ? await put(`/personas/${state.editingPersonaId}`, payload) : await post("/personas", payload);
|
|
if (res.error) return setPersonaFormNotice(res.error, "error");
|
|
const wasEditing = Boolean(state.editingPersonaId);
|
|
await loadPersonas();
|
|
if (payload.personaType === "assistant") await loadChatSessions();
|
|
closePersonaForm(); setNotice(wasEditing ? "Persona updated." : "Persona created.", "success");
|
|
}
|
|
|
|
async function deletePersona(id) {
|
|
const persona = state.assistantPersonas.find((item) => item.id === id) || state.userPersonas.find((item) => item.id === id);
|
|
if (!persona) return;
|
|
const confirmed = await confirmPopup({
|
|
eyebrow: persona.persona_type === "user" ? "Your persona" : "AI Character",
|
|
title: "Delete persona",
|
|
description: `Delete "${persona.name}"? This removes it from your library and any active slot.`,
|
|
confirmLabel: "Delete",
|
|
danger: true
|
|
});
|
|
if (!confirmed) return;
|
|
const res = await del(`/personas/${id}`);
|
|
if (res.error) return setNotice(res.error, "error");
|
|
await loadPersonas();
|
|
if (persona.persona_type === "assistant") await loadChatSessions();
|
|
setNotice("Persona deleted.", "success");
|
|
}
|
|
|
|
async function publishPersona(id) { const res = await post(`/personas/${id}/publish`, {}); if (res.error) return setNotice(res.error, "error"); await loadPersonas(); setNotice("Persona published.", "success"); }
|
|
|
|
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"); }
|
|
|
|
async function startRoleplay() {
|
|
if (state.roleplayStarterStep === 1) {
|
|
goToRoleplayStarterSceneStep();
|
|
return;
|
|
}
|
|
setChatLoading(true, "Starting roleplay...");
|
|
setChatActivity(true, {
|
|
eyebrow: "Starting roleplay",
|
|
title: "Building the opening scene",
|
|
detail: "Generating the first beat and opening the roleplay chat."
|
|
});
|
|
const assistantPersonaId = Number(roleplayCharacterSelect.value);
|
|
const userPersonaId = roleplayUserPersonaSelect.value ? Number(roleplayUserPersonaSelect.value) : null;
|
|
const scenarioPrompt = roleplayScenarioInput?.value.trim() || "";
|
|
if (!assistantPersonaId) {
|
|
setChatLoading(false);
|
|
setChatActivity(false);
|
|
return setRoleplayStarterNotice("Choose a character to start the roleplay.", "error");
|
|
}
|
|
roleplayStarterConfirm.disabled = true;
|
|
setRoleplayStarterNotice("Building the opening scene...");
|
|
const res = await post("/roleplays/start", {
|
|
assistantPersonaId,
|
|
userPersonaId,
|
|
scenarioPrompt,
|
|
model: modelSelect.value
|
|
}, {timeoutMs: 660000});
|
|
roleplayStarterConfirm.disabled = false;
|
|
if (res.error || !res.chat) {
|
|
setChatLoading(false);
|
|
setChatActivity(false);
|
|
return setRoleplayStarterNotice(res.error || "Unable to start roleplay.", "error");
|
|
}
|
|
closeRoleplayStarter();
|
|
await loadChatSessions();
|
|
await setActiveChat(res.chat.id);
|
|
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);
|
|
setChatActivity(false);
|
|
}
|
|
|
|
return {
|
|
renderPersonaSection,
|
|
updateRoleplaySuggestions,
|
|
setRoleplayStarterNotice,
|
|
renderRoleplayStarterStep,
|
|
maybeShowPersonaNudge,
|
|
openPersonaForm,
|
|
closePersonaForm,
|
|
openRoleplayStarter,
|
|
closeRoleplayStarter,
|
|
loadPersonas,
|
|
clearChatUserPersona,
|
|
savePersona,
|
|
startRoleplay
|
|
};
|
|
}
|