Move persona market to its own page

This commit is contained in:
2026-01-14 14:53:56 +01:00
parent 6685080b4f
commit b14ede6d48
5 changed files with 272 additions and 90 deletions
+4 -21
View File
@@ -89,6 +89,7 @@
<option disabled selected>Loading models...</option> <option disabled selected>Loading models...</option>
</select> </select>
</div> </div>
<a href="/market.html" class="secondary-action market-link">Persona Market</a>
<div class="persona-menu"> <div class="persona-menu">
<button id="personaMenuButton" class="secondary-action" type="button" <button id="personaMenuButton" class="secondary-action" type="button"
aria-expanded="false" aria-controls="personaPopover"> aria-expanded="false" aria-controls="personaPopover">
@@ -100,6 +101,7 @@
<div> <div>
<p class="eyebrow">Roleplay</p> <p class="eyebrow">Roleplay</p>
<h2>AI Persona</h2> <h2>AI Persona</h2>
<p class="persona-guidance">Controls how the assistant sounds and behaves.</p>
</div> </div>
<div class="persona-section-actions"> <div class="persona-section-actions">
<button id="newPersona" class="secondary-action" type="button">New AI</button> <button id="newPersona" class="secondary-action" type="button">New AI</button>
@@ -114,6 +116,7 @@
<div> <div>
<p class="eyebrow">Roleplay</p> <p class="eyebrow">Roleplay</p>
<h2>Your Persona</h2> <h2>Your Persona</h2>
<p class="persona-guidance">Defines how the assistant should portray you.</p>
</div> </div>
<div class="persona-section-actions"> <div class="persona-section-actions">
<button id="newUserPersona" class="secondary-action" type="button">New you</button> <button id="newUserPersona" class="secondary-action" type="button">New you</button>
@@ -123,27 +126,6 @@
<p id="activeUserPersonaStatus" class="status persona-status">No user persona set.</p> <p id="activeUserPersonaStatus" class="status persona-status">No user persona set.</p>
<div id="userPersonaList" class="persona-list"></div> <div id="userPersonaList" class="persona-list"></div>
</div> </div>
<div class="persona-section">
<div class="persona-section-header">
<div>
<p class="eyebrow">Community</p>
<h2>Persona Market</h2>
</div>
<div class="persona-section-actions">
<button id="refreshMarket" class="secondary-action" type="button">Refresh</button>
</div>
</div>
<div class="persona-market-columns">
<div class="persona-market-column">
<h3>AI personas</h3>
<div id="marketPersonaList" class="persona-list"></div>
</div>
<div class="persona-market-column">
<h3>User personas</h3>
<div id="marketUserPersonaList" class="persona-list"></div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -175,6 +157,7 @@
<option value="user">Your persona</option> <option value="user">Your persona</option>
</select> </select>
</label> </label>
<p class="persona-helper">AI personas shape how the assistant speaks. Your persona describes how you should be portrayed.</p>
<label class="field"> <label class="field">
<span>Name</span> <span>Name</span>
<input id="personaName" placeholder="Persona name" required/> <input id="personaName" placeholder="Persona name" required/>
+37
View File
@@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<title>Persona Market</title>
<link rel="stylesheet" href="style.css"/>
</head>
<body>
<div class="market-shell card">
<header class="market-header">
<div>
<p class="eyebrow">Community</p>
<h1>Persona Market</h1>
<p class="subtitle">Browse shared personas and choose who should embody them.</p>
</div>
<div class="market-header-actions">
<button id="refreshMarket" class="secondary-action" type="button">Refresh listings</button>
<a href="/" class="secondary-action">Back to chat</a>
</div>
</header>
<p id="marketStatus" class="status persona-status">Loading market personas...</p>
<div class="persona-market-columns market-page-columns">
<div class="persona-market-column">
<h3>AI personas</h3>
<p class="persona-guidance">These shape how the assistant speaks and behaves.</p>
<div id="marketPersonaList" class="persona-list"></div>
</div>
<div class="persona-market-column">
<h3>User personas</h3>
<p class="persona-guidance">These describe how the assistant should portray you.</p>
<div id="marketUserPersonaList" class="persona-list"></div>
</div>
</div>
</div>
<script src="market.js" defer></script>
</body>
</html>
+131
View File
@@ -0,0 +1,131 @@
const marketPersonaList = document.getElementById("marketPersonaList");
const marketUserPersonaList = document.getElementById("marketUserPersonaList");
const refreshMarketBtn = document.getElementById("refreshMarket");
const marketStatus = document.getElementById("marketStatus");
async function request(url, data, method = "POST") {
try {
const res = await fetch(url, {
method,
headers: {"Content-Type": "application/json"},
body: data ? JSON.stringify(data) : undefined
});
return await res.json();
} catch (err) {
return {error: "Network error — please try again."};
}
}
async function post(url, data) {
return request(url, data, "POST");
}
async function get(url) {
try {
const res = await fetch(url);
return await res.json();
} catch (err) {
return {error: "Network error — please try again."};
}
}
function setMarketStatus(message, state = "") {
if (!marketStatus) return;
marketStatus.textContent = message;
marketStatus.className = state ? `status persona-status ${state}` : "status persona-status";
}
function buildPersonaMeta(persona) {
const pronouns = persona.pronouns ? `Pronouns: ${persona.pronouns}` : "Pronouns: —";
return `By ${persona.creator_username} • ${pronouns}`;
}
function renderMarketList(personaItems, listElement, personaType) {
if (!listElement) return;
listElement.innerHTML = "";
if (!personaItems.length) {
const empty = document.createElement("p");
empty.className = "status persona-status";
empty.textContent = personaType === "assistant"
? "No AI personas listed yet. Publish the first one!"
: "No user personas listed yet. Publish the first one!";
listElement.appendChild(empty);
return;
}
personaItems.forEach(persona => {
const item = document.createElement("div");
item.className = "persona-item";
const titleRow = document.createElement("div");
titleRow.className = "persona-title-row";
const title = document.createElement("h4");
title.textContent = persona.name;
const roleTag = document.createElement("span");
roleTag.className = `persona-role-tag ${personaType === "assistant" ? "ai" : "user"}`;
roleTag.textContent = personaType === "assistant" ? "AI" : "You";
titleRow.appendChild(title);
titleRow.appendChild(roleTag);
const meta = document.createElement("p");
meta.textContent = buildPersonaMeta(persona);
const actions = document.createElement("div");
actions.className = "persona-item-actions";
const getBtn = document.createElement("button");
getBtn.type = "button";
getBtn.textContent = personaType === "assistant" ? "Get & equip as AI" : "Get & equip as You";
getBtn.addEventListener("click", () => collectMarketPersona(persona.id, persona.persona_type));
actions.appendChild(getBtn);
item.appendChild(titleRow);
item.appendChild(meta);
item.appendChild(actions);
listElement.appendChild(item);
});
}
async function loadMarketPersonas() {
setMarketStatus("Loading market personas...");
const res = await get("/personas/market");
if (res.error) {
setMarketStatus(res.error, "error");
return;
}
const marketPersonas = res.personas || [];
const assistantMarket = marketPersonas.filter(persona => persona.persona_type === "assistant");
const userMarket = marketPersonas.filter(persona => persona.persona_type === "user");
renderMarketList(assistantMarket, marketPersonaList, "assistant");
renderMarketList(userMarket, marketUserPersonaList, "user");
setMarketStatus("Choose an AI persona or a user persona to equip.");
}
async function collectMarketPersona(marketId, personaType) {
setMarketStatus(
personaType === "assistant"
? "Equipping the AI persona..."
: "Equipping the user persona..."
);
const res = await post(`/personas/market/${marketId}/collect`, {equip: true});
if (res.error) {
setMarketStatus(res.error, "error");
return;
}
setMarketStatus(
personaType === "assistant"
? "AI persona equipped. Head back to chat to use it."
: "User persona equipped. Head back to chat to use it.",
"success"
);
}
if (refreshMarketBtn) {
refreshMarketBtn.addEventListener("click", () => loadMarketPersonas());
}
window.addEventListener("load", () => {
loadMarketPersonas();
});
+13 -69
View File
@@ -41,9 +41,6 @@ const personaModal = document.getElementById("personaModal");
const personaCloseBtn = document.getElementById("personaClose"); const personaCloseBtn = document.getElementById("personaClose");
const personaMenuButton = document.getElementById("personaMenuButton"); const personaMenuButton = document.getElementById("personaMenuButton");
const personaPopover = document.getElementById("personaPopover"); const personaPopover = document.getElementById("personaPopover");
const marketPersonaList = document.getElementById("marketPersonaList");
const marketUserPersonaList = document.getElementById("marketUserPersonaList");
const refreshMarketBtn = document.getElementById("refreshMarket");
let isProcessing = false; let isProcessing = false;
let chatSessions = []; let chatSessions = [];
@@ -54,7 +51,6 @@ let activePersonaId = null;
let activeUserPersonaId = null; let activeUserPersonaId = null;
let editingPersonaId = null; let editingPersonaId = null;
let editingPersonaType = "assistant"; let editingPersonaType = "assistant";
let marketPersonas = [];
let publishedPersonaIds = new Set(); let publishedPersonaIds = new Set();
const displayModels = async () => { const displayModels = async () => {
@@ -214,15 +210,15 @@ function setActivePersonaStatus() {
assistantPersonas, assistantPersonas,
activePersonaId, activePersonaId,
activePersonaStatus, activePersonaStatus,
'No persona equipped.', 'No AI persona equipped.',
'AI equipped' 'AI persona equipped'
); );
updateActivePersonaStatus( updateActivePersonaStatus(
userPersonas, userPersonas,
activeUserPersonaId, activeUserPersonaId,
activeUserPersonaStatus, activeUserPersonaStatus,
'No user persona set.', 'No user persona set.',
'You are' 'User persona active'
); );
clearPersonaBtn.disabled = !activePersonaId; clearPersonaBtn.disabled = !activePersonaId;
clearUserPersonaBtn.disabled = !activeUserPersonaId; clearUserPersonaBtn.disabled = !activeUserPersonaId;
@@ -278,8 +274,15 @@ function renderPersonaList(personaItems, activeId, listElement, personaType) {
personaItems.forEach(persona => { personaItems.forEach(persona => {
const item = document.createElement('div'); const item = document.createElement('div');
item.className = `persona-item${persona.id === activeId ? ' active' : ''}`; item.className = `persona-item${persona.id === activeId ? ' active' : ''}`;
const titleRow = document.createElement('div');
titleRow.className = 'persona-title-row';
const title = document.createElement('h4'); const title = document.createElement('h4');
title.textContent = persona.name; title.textContent = persona.name;
const roleTag = document.createElement('span');
roleTag.className = `persona-role-tag ${personaType === "user" ? "user" : "ai"}`;
roleTag.textContent = personaType === "user" ? "You" : "AI";
titleRow.appendChild(title);
titleRow.appendChild(roleTag);
const meta = document.createElement('p'); const meta = document.createElement('p');
meta.textContent = persona.pronouns ? `Pronouns: ${persona.pronouns}` : 'Pronouns: —'; meta.textContent = persona.pronouns ? `Pronouns: ${persona.pronouns}` : 'Pronouns: —';
const actions = document.createElement('div'); const actions = document.createElement('div');
@@ -287,7 +290,8 @@ function renderPersonaList(personaItems, activeId, listElement, personaType) {
const equipBtn = document.createElement('button'); const equipBtn = document.createElement('button');
equipBtn.type = 'button'; equipBtn.type = 'button';
equipBtn.textContent = persona.id === activeId ? 'Equipped' : 'Equip'; const equipLabel = personaType === "user" ? "Equip as You" : "Equip as AI";
equipBtn.textContent = persona.id === activeId ? `${equipLabel} (Active)` : equipLabel;
equipBtn.disabled = persona.id === activeId; equipBtn.disabled = persona.id === activeId;
equipBtn.addEventListener('click', () => equipPersona(persona.id, personaType)); equipBtn.addEventListener('click', () => equipPersona(persona.id, personaType));
@@ -312,7 +316,7 @@ function renderPersonaList(personaItems, activeId, listElement, personaType) {
actions.appendChild(publishBtn); actions.appendChild(publishBtn);
actions.appendChild(deleteBtn); actions.appendChild(deleteBtn);
item.appendChild(title); item.appendChild(titleRow);
item.appendChild(meta); item.appendChild(meta);
item.appendChild(actions); item.appendChild(actions);
listElement.appendChild(item); listElement.appendChild(item);
@@ -332,49 +336,6 @@ async function loadPersonas() {
renderPersonaList(userPersonas, activeUserPersonaId, userPersonaList, "user"); renderPersonaList(userPersonas, activeUserPersonaId, userPersonaList, "user");
} }
function renderMarketList(personaItems, listElement) {
listElement.innerHTML = '';
if (!personaItems.length) {
const empty = document.createElement('p');
empty.className = 'status persona-status';
empty.textContent = 'No market personas yet. Be the first to publish!';
listElement.appendChild(empty);
return;
}
personaItems.forEach(persona => {
const item = document.createElement('div');
item.className = 'persona-item';
const title = document.createElement('h4');
title.textContent = persona.name;
const meta = document.createElement('p');
const pronouns = persona.pronouns ? `Pronouns: ${persona.pronouns}` : 'Pronouns: —';
meta.textContent = `By ${persona.creator_username} • ${pronouns}`;
const actions = document.createElement('div');
actions.className = 'persona-item-actions';
const getBtn = document.createElement('button');
getBtn.type = 'button';
getBtn.textContent = 'Get & equip';
getBtn.addEventListener('click', () => collectMarketPersona(persona.id, persona.persona_type));
actions.appendChild(getBtn);
item.appendChild(title);
item.appendChild(meta);
item.appendChild(actions);
listElement.appendChild(item);
});
}
async function loadMarketPersonas() {
const res = await get('/personas/market');
if (res.error) return;
marketPersonas = res.personas || [];
const assistantMarket = marketPersonas.filter(persona => persona.persona_type === 'assistant');
const userMarket = marketPersonas.filter(persona => persona.persona_type === 'user');
renderMarketList(assistantMarket, marketPersonaList);
renderMarketList(userMarket, marketUserPersonaList);
}
async function equipPersona(personaId, personaType) { async function equipPersona(personaId, personaType) {
const endpoint = personaType === "user" const endpoint = personaType === "user"
? `/personas/${personaId}/equip-user` ? `/personas/${personaId}/equip-user`
@@ -439,18 +400,6 @@ async function publishPersona(personaId) {
const res = await post(`/personas/${personaId}/publish`, {}); const res = await post(`/personas/${personaId}/publish`, {});
if (res.error) return; if (res.error) return;
await loadPersonas(); await loadPersonas();
await loadMarketPersonas();
}
async function collectMarketPersona(marketId, personaType) {
const res = await post(`/personas/market/${marketId}/collect`, {equip: true});
if (res.error) return;
if (personaType === "user") {
activeUserPersonaId = res.activeUserPersonaId || res.persona?.id || null;
} else {
activePersonaId = res.activePersonaId || res.persona?.id || null;
}
await loadPersonas();
} }
function setLoadingState(loading) { function setLoadingState(loading) {
@@ -568,7 +517,6 @@ async function checkSession() {
chatDiv.style.display = "block"; chatDiv.style.display = "block";
await loadChatSessions(); await loadChatSessions();
await loadPersonas(); await loadPersonas();
await loadMarketPersonas();
msgInput.focus(); msgInput.focus();
return true; return true;
} }
@@ -578,7 +526,6 @@ async function checkSession() {
async function loadServerChat() { async function loadServerChat() {
await loadChatSessions(); await loadChatSessions();
await loadPersonas(); await loadPersonas();
await loadMarketPersonas();
} }
async function handleAuth(endpoint, credentials) { async function handleAuth(endpoint, credentials) {
@@ -689,7 +636,6 @@ document.getElementById("logout").onclick = async () => {
userPersonas = []; userPersonas = [];
activePersonaId = null; activePersonaId = null;
activeUserPersonaId = null; activeUserPersonaId = null;
marketPersonas = [];
publishedPersonaIds = new Set(); publishedPersonaIds = new Set();
setAuthMessage("Logged out.", 'success'); setAuthMessage("Logged out.", 'success');
showAuthScreen('login'); showAuthScreen('login');
@@ -733,8 +679,6 @@ clearPersonaBtn.addEventListener('click', () => clearPersona("assistant"));
clearUserPersonaBtn.addEventListener('click', () => clearPersona("user")); clearUserPersonaBtn.addEventListener('click', () => clearPersona("user"));
refreshMarketBtn.addEventListener('click', () => loadMarketPersonas());
personaForm.addEventListener('submit', (event) => { personaForm.addEventListener('submit', (event) => {
event.preventDefault(); event.preventDefault();
savePersona(); savePersona();
+87
View File
@@ -324,6 +324,13 @@ button:disabled {
border-radius: 12px; border-radius: 12px;
} }
a.secondary-action {
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
}
.chat-list { .chat-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -411,6 +418,36 @@ button:disabled {
gap: 8px; gap: 8px;
} }
.persona-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.persona-role-tag {
font-size: 0.7rem;
letter-spacing: 0.1em;
text-transform: uppercase;
padding: 4px 8px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.15);
color: var(--text-secondary);
background: rgba(255, 255, 255, 0.05);
}
.persona-role-tag.ai {
border-color: rgba(99, 102, 241, 0.6);
color: #c7d2fe;
background: rgba(99, 102, 241, 0.15);
}
.persona-role-tag.user {
border-color: rgba(34, 197, 94, 0.6);
color: #bbf7d0;
background: rgba(34, 197, 94, 0.15);
}
.persona-item.active { .persona-item.active {
border-color: rgba(99, 102, 241, 0.6); border-color: rgba(99, 102, 241, 0.6);
box-shadow: 0 8px 18px rgba(99, 102, 241, 0.18); box-shadow: 0 8px 18px rgba(99, 102, 241, 0.18);
@@ -448,6 +485,13 @@ button:disabled {
padding: 12px; padding: 12px;
} }
.persona-helper {
margin: 0;
font-size: 0.82rem;
color: var(--text-muted);
line-height: 1.4;
}
.persona-form.hidden { .persona-form.hidden {
display: none; display: none;
} }
@@ -836,6 +880,13 @@ button:disabled {
gap: 12px; gap: 12px;
} }
.persona-guidance {
margin: 4px 0 0;
font-size: 0.82rem;
color: var(--text-muted);
line-height: 1.4;
}
.persona-section-actions { .persona-section-actions {
display: flex; display: flex;
gap: 8px; gap: 8px;
@@ -858,6 +909,42 @@ button:disabled {
max-height: 220px; max-height: 220px;
} }
.market-shell {
width: min(980px, 95vw);
padding: 28px;
display: grid;
gap: 18px;
}
.market-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 20px;
flex-wrap: wrap;
}
.market-header h1 {
margin: 4px 0;
}
.market-header-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.market-page-columns .persona-list {
max-height: 360px;
}
.market-link {
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 6px;
}
.model-select select:hover, .model-select select:focus { .model-select select:hover, .model-select select:focus {
border-color: var(--accent); border-color: var(--accent);
background: rgba(15, 15, 35, 1); background: rgba(15, 15, 35, 1);