diff --git a/public/market.html b/public/market.html index 554b5fd..e3b7346 100644 --- a/public/market.html +++ b/public/market.html @@ -13,6 +13,10 @@

Community

Persona Market

Browse shared personas and choose who should embody them.

+
diff --git a/public/market.js b/public/market.js index e853fbb..584a505 100644 --- a/public/market.js +++ b/public/market.js @@ -2,6 +2,8 @@ const marketPersonaList = document.getElementById("marketPersonaList"); const marketUserPersonaList = document.getElementById("marketUserPersonaList"); const refreshMarketBtn = document.getElementById("refreshMarket"); const marketStatus = document.getElementById("marketStatus"); +const marketSearchInput = document.getElementById("marketSearch"); +let allMarketPersonas = []; async function request(url, data, method = "POST") { try { @@ -37,7 +39,9 @@ function setMarketStatus(message, state = "") { function buildPersonaMeta(persona) { const pronouns = persona.pronouns ? `Pronouns: ${persona.pronouns}` : "Pronouns: —"; - return `By ${persona.creator_username} • ${pronouns}`; + const usageCount = Number(persona.usage_count || 0); + const usageLabel = `Used ${usageCount} ${usageCount === 1 ? "time" : "times"}`; + return `By ${persona.creator_username} • ${pronouns} • ${usageLabel}`; } function renderMarketList(personaItems, listElement, personaType) { @@ -88,6 +92,35 @@ function renderMarketList(personaItems, listElement, personaType) { }); } +function filterMarketPersonas() { + const query = marketSearchInput?.value.trim().toLowerCase() || ""; + if (!query) return allMarketPersonas; + return allMarketPersonas.filter(persona => { + const fields = [ + persona.name, + persona.creator_username, + persona.pronouns, + persona.appearance, + persona.background, + persona.details + ]; + return fields.some(field => field && field.toLowerCase().includes(query)); + }); +} + +function applyMarketFilter() { + const filtered = filterMarketPersonas(); + const assistantMarket = filtered.filter(persona => persona.persona_type === "assistant"); + const userMarket = filtered.filter(persona => persona.persona_type === "user"); + renderMarketList(assistantMarket, marketPersonaList, "assistant"); + renderMarketList(userMarket, marketUserPersonaList, "user"); + if (marketSearchInput?.value.trim() && !filtered.length) { + setMarketStatus("No personas match your search.", "error"); + return; + } + setMarketStatus("Choose an AI persona or a user persona to equip."); +} + async function loadMarketPersonas() { setMarketStatus("Loading market personas..."); const res = await get("/personas/market"); @@ -95,12 +128,8 @@ async function loadMarketPersonas() { 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."); + allMarketPersonas = res.personas || []; + applyMarketFilter(); } async function collectMarketPersona(marketId, personaType) { @@ -114,6 +143,7 @@ async function collectMarketPersona(marketId, personaType) { setMarketStatus(res.error, "error"); return; } + await loadMarketPersonas(); setMarketStatus( personaType === "assistant" ? "AI persona equipped. Head back to chat to use it." @@ -126,6 +156,10 @@ if (refreshMarketBtn) { refreshMarketBtn.addEventListener("click", () => loadMarketPersonas()); } +if (marketSearchInput) { + marketSearchInput.addEventListener("input", () => applyMarketFilter()); +} + window.addEventListener("load", () => { loadMarketPersonas(); }); diff --git a/public/style.css b/public/style.css index a94e23f..a8c243f 100644 --- a/public/style.css +++ b/public/style.css @@ -928,6 +928,33 @@ a.secondary-action { margin: 4px 0; } +.market-search { + display: grid; + gap: 6px; + margin-top: 12px; + max-width: 320px; +} + +.market-search label { + font-size: 0.78rem; + color: var(--text-muted); +} + +.market-search input { + padding: 10px 12px; + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(10, 10, 25, 0.8); + color: var(--text-primary); + font-size: 0.9rem; +} + +.market-search input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(112, 86, 255, 0.25); +} + .market-header-actions { display: flex; gap: 10px; diff --git a/server.js b/server.js index 3325e08..7dc54b1 100644 --- a/server.js +++ b/server.js @@ -203,6 +203,10 @@ db.prepare(` TEXT DEFAULT 'assistant', + source_market_id + INTEGER, + source_creator_username + TEXT, created_at DATETIME DEFAULT @@ -272,6 +276,10 @@ db.prepare(` TEXT DEFAULT 'assistant', + usage_count + INTEGER + DEFAULT + 0, created_at DATETIME DEFAULT @@ -362,6 +370,8 @@ const listPersonasByTypeStmt = db.prepare(` background, details, persona_type, + source_market_id, + source_creator_username, created_at, updated_at FROM personas @@ -377,10 +387,36 @@ const getPersonaStmt = db.prepare(` AND username = ? `); +const getPersonaForPublishStmt = db.prepare(` + SELECT id, + name, + pronouns, + appearance, + background, + details, + persona_type, + source_market_id, + source_creator_username + FROM personas + WHERE id = ? + AND username = ? +`); + const insertPersonaStmt = db.prepare( "INSERT INTO personas (username, name, pronouns, appearance, background, details, persona_type) VALUES (?, ?, ?, ?, ?, ?, ?)" ); +const insertMarketPersonaStmt = db.prepare( + "INSERT INTO personas (username, name, pronouns, appearance, background, details, persona_type, source_market_id, source_creator_username) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" +); + +const getPersonaBySourceMarketStmt = db.prepare(` + SELECT id + FROM personas + WHERE username = ? + AND source_market_id = ? +`); + const updatePersonaStmt = db.prepare(` UPDATE personas SET name = ?, @@ -437,6 +473,7 @@ const listMarketPersonasStmt = db.prepare(` background, details, persona_type, + usage_count, created_at, updated_at FROM persona_market @@ -452,7 +489,8 @@ const getMarketPersonaStmt = db.prepare(` appearance, background, details, - persona_type + persona_type, + usage_count FROM persona_market WHERE id = ? `); @@ -491,6 +529,12 @@ const listPublishedPersonaIdsStmt = db.prepare(` WHERE creator_username = ? `); +const incrementMarketUsageCountStmt = db.prepare(` + UPDATE persona_market + SET usage_count = usage_count + 1 + WHERE id = ? +`); + const migrationNeeded = db.prepare( "SELECT COUNT(*) as count FROM chat_messages" ).get(); @@ -519,6 +563,14 @@ const hasPersonaType = personaColumns.some(column => column.name === "persona_ty if (!hasPersonaType) { db.prepare("ALTER TABLE personas ADD COLUMN persona_type TEXT DEFAULT 'assistant'").run(); } +const hasSourceMarketId = personaColumns.some(column => column.name === "source_market_id"); +if (!hasSourceMarketId) { + db.prepare("ALTER TABLE personas ADD COLUMN source_market_id INTEGER").run(); +} +const hasSourceCreatorUsername = personaColumns.some(column => column.name === "source_creator_username"); +if (!hasSourceCreatorUsername) { + db.prepare("ALTER TABLE personas ADD COLUMN source_creator_username TEXT").run(); +} const userSettingsColumns = db.prepare("PRAGMA table_info(user_settings)").all(); const hasActiveUserPersona = userSettingsColumns.some(column => column.name === "active_user_persona_id"); @@ -531,6 +583,11 @@ const hasPersonaIdColumn = marketColumns.some(column => column.name === "persona if (!hasPersonaIdColumn) { db.prepare("ALTER TABLE persona_market ADD COLUMN persona_id INTEGER").run(); } +const hasUsageCount = marketColumns.some(column => column.name === "usage_count"); +if (!hasUsageCount) { + db.prepare("ALTER TABLE persona_market ADD COLUMN usage_count INTEGER DEFAULT 0").run(); +} +db.prepare("UPDATE persona_market SET usage_count = 0 WHERE usage_count IS NULL").run(); const FileStore = FileStoreFactory(session); app.use(bodyParser.json()); @@ -823,10 +880,13 @@ app.get("/personas/market", requireLogin, (req, res) => { 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); + const persona = getPersonaForPublishStmt.get(personaId, user); if (!persona) { return res.status(404).json({error: "Persona not found"}); } + if (persona.source_market_id) { + return res.status(403).json({error: "Collected personas cannot be published."}); + } upsertMarketPersonaStmt.run( personaId, user, @@ -848,15 +908,25 @@ app.post("/personas/market/:id/collect", requireLogin, (req, res) => { if (!marketPersona) { return res.status(404).json({error: "Market persona not found"}); } - const personaId = insertPersonaStmt.run( + if (marketPersona.creator_username === user) { + return res.status(400).json({error: "You already own this persona."}); + } + const existingPersona = getPersonaBySourceMarketStmt.get(user, marketId); + if (existingPersona) { + return res.status(400).json({error: "You already collected this persona."}); + } + const personaId = insertMarketPersonaStmt.run( user, marketPersona.name, marketPersona.pronouns, marketPersona.appearance, marketPersona.background, marketPersona.details, - marketPersona.persona_type + marketPersona.persona_type, + marketId, + marketPersona.creator_username ).lastInsertRowid; + incrementMarketUsageCountStmt.run(marketId); let activePersonaId = null; let activeUserPersonaId = null; if (req.body?.equip) {