diff --git a/public/index.html b/public/index.html index 6f9ad8e..e69a0d6 100644 --- a/public/index.html +++ b/public/index.html @@ -123,6 +123,27 @@

No user persona set.

+
+
+
+

Community

+

Persona Market

+
+
+ +
+
+
+
+

AI personas

+
+
+
+

User personas

+
+
+
+
diff --git a/public/script.js b/public/script.js index 7f2651e..024f37a 100644 --- a/public/script.js +++ b/public/script.js @@ -41,6 +41,9 @@ const personaModal = document.getElementById("personaModal"); const personaCloseBtn = document.getElementById("personaClose"); const personaMenuButton = document.getElementById("personaMenuButton"); const personaPopover = document.getElementById("personaPopover"); +const marketPersonaList = document.getElementById("marketPersonaList"); +const marketUserPersonaList = document.getElementById("marketUserPersonaList"); +const refreshMarketBtn = document.getElementById("refreshMarket"); let isProcessing = false; let chatSessions = []; @@ -51,6 +54,8 @@ let activePersonaId = null; let activeUserPersonaId = null; let editingPersonaId = null; let editingPersonaType = "assistant"; +let marketPersonas = []; +let publishedPersonaIds = new Set(); const displayModels = async () => { const select = document.getElementById("model"); @@ -291,6 +296,12 @@ function renderPersonaList(personaItems, activeId, listElement, personaType) { editBtn.textContent = 'Edit'; editBtn.addEventListener('click', () => openPersonaForm(persona)); + const publishBtn = document.createElement('button'); + publishBtn.type = 'button'; + const isPublished = publishedPersonaIds.has(persona.id); + publishBtn.textContent = isPublished ? 'Update listing' : 'Publish'; + publishBtn.addEventListener('click', () => publishPersona(persona.id)); + const deleteBtn = document.createElement('button'); deleteBtn.type = 'button'; deleteBtn.textContent = 'Delete'; @@ -298,6 +309,7 @@ function renderPersonaList(personaItems, activeId, listElement, personaType) { actions.appendChild(equipBtn); actions.appendChild(editBtn); + actions.appendChild(publishBtn); actions.appendChild(deleteBtn); item.appendChild(title); @@ -315,10 +327,54 @@ async function loadPersonas() { userPersonas = res.userPersonas || []; activePersonaId = res.activePersonaId || null; activeUserPersonaId = res.activeUserPersonaId || null; + publishedPersonaIds = new Set(res.publishedPersonaIds || []); renderPersonaList(assistantPersonas, activePersonaId, personaList, "assistant"); 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) { const endpoint = personaType === "user" ? `/personas/${personaId}/equip-user` @@ -379,6 +435,24 @@ async function deletePersona(personaId) { await loadPersonas(); } +async function publishPersona(personaId) { + const res = await post(`/personas/${personaId}/publish`, {}); + if (res.error) return; + 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) { isProcessing = loading; sendBtn.disabled = loading; @@ -494,6 +568,7 @@ async function checkSession() { chatDiv.style.display = "block"; await loadChatSessions(); await loadPersonas(); + await loadMarketPersonas(); msgInput.focus(); return true; } @@ -503,6 +578,7 @@ async function checkSession() { async function loadServerChat() { await loadChatSessions(); await loadPersonas(); + await loadMarketPersonas(); } async function handleAuth(endpoint, credentials) { @@ -613,6 +689,8 @@ document.getElementById("logout").onclick = async () => { userPersonas = []; activePersonaId = null; activeUserPersonaId = null; + marketPersonas = []; + publishedPersonaIds = new Set(); setAuthMessage("Logged out.", 'success'); showAuthScreen('login'); loginUsernameInput.value = ""; @@ -655,6 +733,8 @@ clearPersonaBtn.addEventListener('click', () => clearPersona("assistant")); clearUserPersonaBtn.addEventListener('click', () => clearPersona("user")); +refreshMarketBtn.addEventListener('click', () => loadMarketPersonas()); + personaForm.addEventListener('submit', (event) => { event.preventDefault(); savePersona(); diff --git a/public/style.css b/public/style.css index d7f6c06..6d0980e 100644 --- a/public/style.css +++ b/public/style.css @@ -842,6 +842,18 @@ button:disabled { flex-wrap: wrap; } +.persona-market-columns { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); + gap: 12px; +} + +.persona-market-column h3 { + margin: 0 0 6px; + font-size: 0.95rem; + color: var(--text-secondary); +} + .persona-popover .persona-list { max-height: 220px; } diff --git a/server.js b/server.js index e2eebed..71c921b 100644 --- a/server.js +++ b/server.js @@ -214,6 +214,51 @@ db.prepare(` ) ON DELETE SET NULL ) `).run(); +db.prepare(` + CREATE TABLE IF NOT EXISTS persona_market + ( + id + INTEGER + PRIMARY + KEY + AUTOINCREMENT, + persona_id + INTEGER, + creator_username + TEXT + NOT + NULL, + name + TEXT + NOT + NULL, + pronouns + TEXT, + appearance + TEXT, + background + TEXT, + details + TEXT, + persona_type + TEXT + DEFAULT + 'assistant', + created_at + DATETIME + DEFAULT + CURRENT_TIMESTAMP, + updated_at + DATETIME + DEFAULT + CURRENT_TIMESTAMP, + UNIQUE + ( + persona_id, + creator_username + ) + ) +`).run(); const insertUserStmt = db.prepare("INSERT INTO users (username, password) VALUES (?, ?)"); const getUserStmt = db.prepare("SELECT username, password FROM users WHERE username = ?"); @@ -342,6 +387,43 @@ const getActiveUserPersonaStmt = db.prepare(` WHERE us.username = ? `); +const listMarketPersonasStmt = db.prepare(` + SELECT id, persona_id, creator_username, name, pronouns, appearance, background, details, persona_type, created_at, updated_at + FROM persona_market + ORDER BY datetime(updated_at) DESC +`); + +const getMarketPersonaStmt = db.prepare(` + SELECT id, persona_id, creator_username, name, pronouns, appearance, background, details, persona_type + FROM persona_market + WHERE id = ? +`); + +const getMarketPersonaByPersonaIdStmt = db.prepare(` + SELECT id, persona_id, creator_username, name, pronouns, appearance, background, details, persona_type + FROM persona_market + WHERE persona_id = ? AND creator_username = ? +`); + +const upsertMarketPersonaStmt = db.prepare(` + INSERT INTO persona_market (persona_id, creator_username, name, pronouns, appearance, background, details, persona_type) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(persona_id, creator_username) DO UPDATE SET + name = excluded.name, + pronouns = excluded.pronouns, + appearance = excluded.appearance, + background = excluded.background, + details = excluded.details, + persona_type = excluded.persona_type, + updated_at = CURRENT_TIMESTAMP +`); + +const listPublishedPersonaIdsStmt = db.prepare(` + SELECT persona_id + FROM persona_market + WHERE creator_username = ? +`); + const migrationNeeded = db.prepare( "SELECT COUNT(*) as count FROM chat_messages" ).get(); @@ -377,6 +459,12 @@ if (!hasActiveUserPersona) { db.prepare("ALTER TABLE user_settings ADD COLUMN active_user_persona_id INTEGER").run(); } +const marketColumns = db.prepare("PRAGMA table_info(persona_market)").all(); +const hasPersonaIdColumn = marketColumns.some(column => column.name === "persona_id"); +if (!hasPersonaIdColumn) { + db.prepare("ALTER TABLE persona_market ADD COLUMN persona_id INTEGER").run(); +} + const FileStore = FileStoreFactory(session); app.use(bodyParser.json()); app.use(express.static("public")); @@ -536,11 +624,13 @@ app.get("/personas", requireLogin, (req, res) => { const assistantPersonas = listPersonasByTypeStmt.all(user, "assistant"); const userPersonas = listPersonasByTypeStmt.all(user, "user"); const activePersonas = getActivePersonaIdStmt.get(user) || {}; + const publishedPersonaIds = listPublishedPersonaIdsStmt.all(user).map(row => row.persona_id); res.json({ assistantPersonas, userPersonas, activePersonaId: activePersonas.active_persona_id ?? null, - activeUserPersonaId: activePersonas.active_user_persona_id ?? null + activeUserPersonaId: activePersonas.active_user_persona_id ?? null, + publishedPersonaIds }); }); @@ -649,6 +739,63 @@ app.post("/personas/user/clear", requireLogin, (req, res) => { res.json({activeUserPersonaId: null}); }); +app.get("/personas/market", requireLogin, (req, res) => { + const personas = listMarketPersonasStmt.all(); + res.json({personas}); +}); + +app.post("/personas/:id/publish", requireLogin, (req, res) => { + const user = req.session.user; + const personaId = Number(req.params.id); + const persona = getPersonaStmt.get(personaId, user); + if (!persona) { + return res.status(404).json({error: "Persona not found"}); + } + upsertMarketPersonaStmt.run( + personaId, + user, + persona.name, + persona.pronouns, + persona.appearance, + persona.background, + persona.details, + persona.persona_type + ); + const marketPersona = getMarketPersonaByPersonaIdStmt.get(personaId, user); + res.json({persona: marketPersona}); +}); + +app.post("/personas/market/:id/collect", requireLogin, (req, res) => { + const user = req.session.user; + const marketId = Number(req.params.id); + const marketPersona = getMarketPersonaStmt.get(marketId); + if (!marketPersona) { + return res.status(404).json({error: "Market persona not found"}); + } + const personaId = insertPersonaStmt.run( + user, + marketPersona.name, + marketPersona.pronouns, + marketPersona.appearance, + marketPersona.background, + marketPersona.details, + marketPersona.persona_type + ).lastInsertRowid; + let activePersonaId = null; + let activeUserPersonaId = null; + if (req.body?.equip) { + if (marketPersona.persona_type === "user") { + setActiveUserPersonaStmt.run(user, personaId); + activeUserPersonaId = personaId; + } else { + setActivePersonaStmt.run(user, personaId); + activePersonaId = personaId; + } + } + const persona = getPersonaStmt.get(personaId, user); + res.json({persona, activePersonaId, activeUserPersonaId}); +}); + app.post("/chat", requireLogin, async (req, res) => { const user = req.session.user; const {message, model, chatId} = req.body;