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.
+
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;