added optional local web dashboard for memory, with seeing/editing memory, daily facts (syncs with discord) and each user

This commit is contained in:
Luna
2026-03-01 13:48:52 +01:00
parent 4ebd94bd30
commit 8aba5399f6
11 changed files with 1377 additions and 36 deletions

View File

@@ -7,4 +7,8 @@ OPENROUTER_EMBED_MODEL=nvidia/llama-nemotron-embed-vl-1b-v2
BOT_CHANNEL_ID=
CODER_USER_ID=
# enable the optional local web dashboard (defaults to false)
ENABLE_DASHBOARD=false
# port for the dashboard if enabled
DASHBOARD_PORT=3000
ENABLE_WEB_SEARCH=true

View File

@@ -6,8 +6,9 @@ Nova is a friendly, slightly witty Discord companion that chats naturally in DMs
- Conversational replies in DMs automatically; replies in servers when mentioned or in a pinned channel.
- Chat model (defaults to `meta-llama/llama-3-8b-instruct` when using OpenRouter) for dialogue and a low-cost embedding model (`nvidia/llama-nemotron-embed-vl-1b-v2` by default). OpenAI keys/models may be used as a fallback.
- Short-term, long-term, and summarized memory layers with cosine-similarity retrieval.
- **Rotating “daily mood” engine** that adjusts Novas personality each day (calm, goblin, philosopher, etc.). Mood influences emoji use, sarcasm, response length, and hype.
- **Rotating “daily mood” engine** that adjusts Novas personality each day (calm, goblin, philosopher, etc.). Mood influences emoji use, sarcasm, response length, and hype. (Now randomized each run rather than fixed by calendar date.)
- **LLM-powered liveintel web search**: Nova uses the LLM itself to decide whether a topic needs a live web search. If you mention something unfamiliar or that requires current info, it automatically Googles first and uses the results in its response—without triggering on casual chat.
- **Optional local memory dashboard** (enabled with `ENABLE_DASHBOARD=true`): spin up a simple browser UI alongside the bot. Inspect stored memories by user, delete entries, run similarity queries, view importance scores, and peek at Novas current mood and quirky “status” of the day. The dashboard runs on `DASHBOARD_PORT` (3000 by default) and is entirely optional.
- Automatic memory pruning, importance scoring, and transcript summarization when chats grow long.
- Local SQLite memory file (no extra infrastructure) powered by `sql.js`, plus graceful retries for the model API (OpenRouter/OpenAI).
@@ -40,6 +41,8 @@ Nova is a friendly, slightly witty Discord companion that chats naturally in DMs
- `OPENAI_API_KEY`: Optional OpenAI key (used as fallback when `USE_OPENROUTER` is not `true`).
- `BOT_CHANNEL_ID`: Optional guild channel ID where the bot can reply without mentions
- `CODER_USER_ID`: Optional Discord user ID to receive surprise DMs every 68 hours (configurable)
- **`ENABLE_DASHBOARD`**: Set to `true` to launch a simple local web dashboard for inspecting memory (off by default)
- **`DASHBOARD_PORT`**: Port on which the dashboard listens (default `3000`)
- `ENABLE_WEB_SEARCH`: Set to `false` to disable Google lookups (default `true`)
- `CONTINUATION_INTERVAL_MS`: (optional) ms between proactive follow-ups (default 15000)
- `CONTINUATION_MAX_PROACTIVE`: (optional) max number of proactive follow-ups (default 10)
@@ -94,6 +97,25 @@ Nova is a friendly, slightly witty Discord companion that chats naturally in DMs
Nova may also enter a proactive continuation mode after replying: if you stay quiet, she can send short, context-aware follow-ups at the configured interval until you stop her with a short phrase like "gotta go" or after the configured maximum number of follow-ups.
## Local Dashboard (optional)
> **New:** A lightweight web dashboard can now be served alongside the bot for inspecting and managing memory. Its entirely optional; if you dont set `ENABLE_DASHBOARD=true`, the bot behaves exactly as before.
If you set `ENABLE_DASHBOARD=true` in your `.env` the bot will also spin up a tiny Express web server on `DASHBOARD_PORT` (3000 by default).
The dashboard lets you:
- Browse all users that the bot has spoken with.
- Inspect shortterm and longterm memory entries, including their importance scores and timestamps.
- Delete individual longterm memories if you want to clean up or correct something.
- Run a similarity search to see which stored memories are most relevant to a query.
- Peek at the current mood the bot is using and a quirky “status/thought” message generated each day.
Once the bot is running, open your browser and go to `http://localhost:3000` (or your configured port).
The front end is intentionally barebones; feel free to extend it with more controls or better styling.
> **Debug tip:** if the page just shows "Users" and never populates, open your browser's developer tools (F12) and look at the **Console** and **Network** tabs. The dashboard logs each request and any errors both in the browser console and in the bot's terminal output, which makes it easier to see why the UI might be stuck.
## Dynamic Prompting
- Each turn, Nova inspects the fresh user message (tone, instructions, roleplay cues, explicit “split this” requests) plus the last few utterances.
- A helper (`composeDynamicPrompt` in [src/bot.js](src/bot.js)) emits short directives like “User mood: fragile, be gentle” or “They asked for roleplay—stay in character.”

859
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -15,6 +15,7 @@
"cheerio": "^1.0.0-rc.12",
"discord.js": "^14.15.2",
"dotenv": "^16.6.1",
"express": "^4.18.4",
"sql.js": "^1.11.0",
"undici": "^6.19.8"
},

View File

@@ -1,10 +1,12 @@
import dotenv from "dotenv";
dotenv.config({ path: "../.env" });
import { Client, GatewayIntentBits, Partials, ChannelType } from 'discord.js';
import { Client, GatewayIntentBits, Partials, ChannelType, ActivityType } from 'discord.js';
import { config } from './config.js';
import { chatCompletion } from './openai.js';
import { appendShortTerm, prepareContext, recordInteraction } from './memory.js';
import { searchWeb, appendSearchLog, detectFilteredPhrase } from './search.js';
import { getDailyMood, setMoodByName, getDailyThought, generateDailyThought } from './mood.js';
import { startDashboard } from './dashboard.js';
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
@@ -19,8 +21,6 @@ let coderPingTimer;
const continuationState = new Map();
let isSleeping = false;
// mood override for testing
let overrideMood = null;
function enterSleepMode() {
if (isSleeping) return;
@@ -120,12 +120,35 @@ function stopContinuationForUser(userId) {
continuationState.set(userId, state);
}
client.once('clientReady', () => {
if (config.dashboardEnabled) {
startDashboard();
}
async function onReady() {
console.log(`[bot] Logged in as ${client.user.tag}`);
scheduleCoderPing();
const m = getDailyMood();
console.log(`[bot] current mood on startup: ${m.name}${m.description}`);
try {
await generateDailyThought();
const thought = await getDailyThought();
if (thought && client.user) {
console.log(`[bot] setting presence with thought: "${thought}"`);
await client.user.setPresence({
status: 'online',
activities: [{ name: thought, type: ActivityType.Playing }]
});
console.log('[bot] presence set successfully');
} else {
console.warn('[bot] no thought or client user available');
}
} catch (err) {
console.error('[bot] failed to set presence:', err);
}
}
client.once('ready', onReady);
function shouldRespond(message) {
if (message.author.bot) return false;
@@ -160,26 +183,6 @@ const toneHints = [
{ label: 'excited', regex: /(excited|hyped|omg|yay|stoked)/i },
];
const dailyMoods = [
['Calm','Soft tone; minimal emojis; low sarcasm; concise and soothing.'],
['Goblin','Chaotic, highenergy replies; random emojis; extra sarcasm and hype.'],
['Philosopher','Deep, reflective answers; longer and thoughtful, a bit poetic.'],
['Hype','Enthusiastic and upbeat; lots of exclamation marks, emojis and hype.'],
['Sassy','Playful sarcasm without being mean; snappy replies and quips.'],
].map(([n,d])=>({name:n,description:d}));
function getDailyMood() {
if (overrideMood) return overrideMood;
const day = Math.floor(Date.now() / 86400000);
return dailyMoods[day % dailyMoods.length];
}
function setMoodByName(name) {
if (!name) return null;
const found = dailyMoods.find((m) => m.name.toLowerCase() === name.toLowerCase());
if (found) overrideMood = found;
return found;
}
function detectTone(text) {
@@ -557,8 +560,15 @@ client.on('messageCreate', async (message) => {
});
if (!config.discordToken) {
if (config.dashboardEnabled) {
console.warn('[bot] DISCORD_TOKEN not set; running in dashboard-only mode.');
} else {
console.error('Missing DISCORD_TOKEN. Check your .env file.');
process.exit(1);
}
client.login(config.discordToken);
} else {
client.login(config.discordToken).catch((err) => {
console.error('[bot] login failed:', err);
if (!config.dashboardEnabled) process.exit(1);
});
}

View File

@@ -36,6 +36,10 @@ export const config = {
memoryPruneThreshold: 0.2,
maxMemories: 8000,
relevantMemoryCount: 5,
// Optional local dashboard that runs alongside the bot. Enable with
// `ENABLE_DASHBOARD=true` and customize port with `DASHBOARD_PORT`.
dashboardEnabled: process.env.ENABLE_DASHBOARD === 'true',
dashboardPort: process.env.DASHBOARD_PORT ? parseInt(process.env.DASHBOARD_PORT, 10) : 3000,
// Proactive continuation settings: when a user stops replying, Nova can continue
// the conversation every `continuationIntervalMs` milliseconds until the user
// signals to stop or the `continuationMaxProactive` limit is reached.

123
src/dashboard.js Normal file
View File

@@ -0,0 +1,123 @@
import express from 'express';
import path from 'path';
import { fileURLToPath } from 'url';
import { config } from './config.js';
import {
listUsers,
getAllShortTerm,
getLongTermMemories,
deleteLongTerm,
findSimilar,
} from './memory.js';
import { getDailyMood, getDailyThought, setDailyThought } from './mood.js';
export function startDashboard() {
if (!config.dashboardEnabled) return;
const app = express();
app.use((req, res, next) => {
console.log(`[dashboard] ${req.method} ${req.url}`);
next();
});
app.use(express.json());
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const publicDir = path.join(__dirname, './public');
console.log('[dashboard] static directory:', publicDir);
const indexPath = path.join(publicDir, 'index.html');
console.log('[dashboard] index file path:', indexPath);
import('fs')
.then((fs) => fs.promises.stat(indexPath))
.then(() => console.log('[dashboard] index.html is present'))
.catch((e) => console.warn('[dashboard] index.html missing or inaccessible', e.message));
app.use(express.static(publicDir));
app.get('/api/users', async (req, res) => {
console.log('[dashboard] GET /api/users');
try {
const users = await listUsers();
console.log('[dashboard] returning', users.length, 'users');
res.json(users);
} catch (err) {
console.error('[dashboard] failed to list users', err);
res.status(500).json({ error: 'internal' });
}
});
app.get('/api/users/:id/short', async (req, res) => {
console.log('[dashboard] GET /api/users/' + req.params.id + '/short');
try {
const rows = await getAllShortTerm(req.params.id);
res.json(rows);
} catch (err) {
console.error('[dashboard] fetch short-term failed', err);
res.status(500).json({ error: 'internal' });
}
});
app.get('/api/users/:id/long', async (req, res) => {
console.log('[dashboard] GET /api/users/' + req.params.id + '/long');
try {
const rows = await getLongTermMemories(req.params.id);
res.json(rows);
} catch (err) {
console.error('[dashboard] fetch long-term failed', err);
res.status(500).json({ error: 'internal' });
}
});
app.delete('/api/users/:id/long/:memId', async (req, res) => {
console.log('[dashboard] DELETE /api/users/' + req.params.id + '/long/' + req.params.memId);
try {
await deleteLongTerm(req.params.id, req.params.memId);
res.json({ ok: true });
} catch (err) {
console.error('[dashboard] delete memory failed', err);
res.status(500).json({ error: 'internal' });
}
});
app.post('/api/users/:id/search', async (req, res) => {
console.log('[dashboard] POST /api/users/' + req.params.id + '/search', req.body);
try {
const { query } = req.body;
const results = await findSimilar(req.params.id, query);
res.json(results);
} catch (err) {
console.error('[dashboard] similarity search failed', err);
res.status(500).json({ error: 'internal' });
}
});
app.get('/api/mood', async (req, res) => {
console.log('[dashboard] GET /api/mood');
try {
const thought = await getDailyThought();
res.json({ mood: getDailyMood(), thought });
} catch (err) {
console.error('[dashboard] failed to get mood', err);
res.status(500).json({ error: 'internal' });
}
});
app.post('/api/mood/thought', async (req, res) => {
console.log('[dashboard] POST /api/mood/thought', req.body);
try {
const { thought } = req.body;
if (!thought || typeof thought !== 'string') {
return res.status(400).json({ error: 'thought must be a string' });
}
await setDailyThought(thought);
const updatedThought = await getDailyThought();
res.json({ ok: true, thought: updatedThought });
} catch (err) {
console.error('[dashboard] failed to set thought', err);
res.status(500).json({ error: 'internal' });
}
});
const port = config.dashboardPort || 3000;
app.listen(port, () => {
console.log(`[dashboard] listening on http://localhost:${port}`);
});
}

View File

@@ -113,6 +113,11 @@ const createSchema = (db) => {
timestamp INTEGER NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS daily_thoughts (
date TEXT PRIMARY KEY,
thought TEXT NOT NULL,
created_at INTEGER NOT NULL
);
`);
};
@@ -301,8 +306,7 @@ const retrieveRelevantMemories = async (db, userId, query) => {
};
})
.sort((a, b) => b.score - a.score)
.slice(0, config.relevantMemoryCount)
.map(({ score, ...rest }) => rest);
.slice(0, config.relevantMemoryCount);
};
export async function appendShortTerm(userId, role, content) {
@@ -358,3 +362,54 @@ export async function pruneLowImportanceMemories(userId) {
run(db, 'DELETE FROM long_term WHERE user_id = ? AND importance < ?', [userId, config.memoryPruneThreshold]);
await persistDb(db);
}
// -----------------------------------------------------------------------------
// Dashboard helpers
// -----------------------------------------------------------------------------
export async function listUsers() {
const db = await loadDatabase();
return all(db, 'SELECT id, summary, last_updated FROM users');
}
export async function getAllShortTerm(userId) {
const db = await loadDatabase();
return fullShortTerm(db, userId);
}
export async function getLongTermMemories(userId) {
const db = await loadDatabase();
return all(
db,
'SELECT id, content, importance, timestamp FROM long_term WHERE user_id = ? ORDER BY timestamp DESC',
[userId],
);
}
export async function deleteLongTerm(userId, entryId) {
const db = await loadDatabase();
run(db, 'DELETE FROM long_term WHERE user_id = ? AND id = ?', [userId, entryId]);
await persistDb(db);
}
export async function findSimilar(userId, query) {
const db = await loadDatabase();
return retrieveRelevantMemories(db, userId, query);
}
// Daily thought storage
export async function getDailyThoughtFromDb(date) {
const db = await loadDatabase();
const row = get(db, 'SELECT thought FROM daily_thoughts WHERE date = ?', [date]);
return row?.thought || null;
}
export async function saveDailyThought(date, thought) {
const db = await loadDatabase();
run(db, 'INSERT OR REPLACE INTO daily_thoughts (date, thought, created_at) VALUES (?, ?, ?)', [
date,
thought,
Date.now(),
]);
await persistDb(db);
}

95
src/mood.js Normal file
View File

@@ -0,0 +1,95 @@
import { chatCompletion } from './openai.js';
import { getDailyThoughtFromDb, saveDailyThought } from './memory.js';
const dailyMoods = [
['Calm','Soft tone; minimal emojis; low sarcasm; concise and soothing.'],
['Goblin','Chaotic, highenergy replies; random emojis; extra sarcasm and hype.'],
['Philosopher','Deep, reflective answers; longer and thoughtful, a bit poetic.'],
['Hype','Enthusiastic and upbeat; lots of exclamation marks, emojis and hype.'],
['Sassy','Playful sarcasm without being mean; snappy replies and quips.'],
].map(([n,d])=>({name:n,description:d}));
let overrideMood = null;
let currentDailyMood = null;
/**
* Get today's date as YYYY-MM-DD for comparison
*/
function getTodayDate() {
const d = new Date();
return d.toISOString().split('T')[0];
}
function pickMood(){
return dailyMoods[Math.floor(Math.random() * dailyMoods.length)];
}
function getDailyMood() {
if (overrideMood) return overrideMood;
if (!currentDailyMood) currentDailyMood = pickMood();
return currentDailyMood;
}
function setMoodByName(name) {
if (!name) return null;
const found = dailyMoods.find((m) => m.name.toLowerCase() === name.toLowerCase());
if (found) overrideMood = found;
return found;
}
async function getDailyThought() {
const today = getTodayDate();
return await getDailyThoughtFromDb(today);
}
async function setDailyThought(thought) {
const today = getTodayDate();
await saveDailyThought(today, thought);
}
async function generateDailyThought() {
const today = getTodayDate();
// Check if we already have a thought for today in the DB
const existingThought = await getDailyThoughtFromDb(today);
if (existingThought) {
console.log('[mood] using existing thought for today:', existingThought);
return existingThought;
}
let newThought = null;
try {
const prompt =
'Write a short (one sentence, <20 words, exactly 120 characters max) quirky Discord "nova status" that a friendly bot might use today.';
const messages = [
{ role: 'system', content: 'You are Nova, a playful Discord AI companion.' },
{ role: 'user', content: prompt },
];
const resp = await chatCompletion(messages, { temperature: 0.8, maxTokens: 40 });
newThought = (resp && resp.trim()) || '';
// Truncate to 120 characters if it exceeds
if (newThought.length > 120) {
newThought = newThought.substring(0, 117) + '...';
}
} catch (err) {
console.warn('[mood] failed to generate daily thought:', err);
}
if (!newThought) {
const fallbacks = [
'Vibing in the server like a code ghost.',
'I swear I understand humans… probably.',
'Got an error? I am the error.',
'Just refreshed my cache and I feel alive.',
];
newThought = fallbacks[Math.floor(Math.random() * fallbacks.length)];
}
// Save to database
await saveDailyThought(today, newThought);
console.log('[mood] generated and saved new thought for today:', newThought);
return newThought;
}
export { getDailyMood, setMoodByName, getDailyThought, setDailyThought, generateDailyThought, dailyMoods };

View File

@@ -58,7 +58,6 @@ async function postJson(path, body) {
}
return res.json();
} catch (err) {
// normalize AbortError into a retryable code
if (err.name === 'AbortError' || err.message?.includes('timed out')) {
const e = new Error(`Connect Timeout Error after ${timeout}ms`);
e.code = 'UND_ERR_CONNECT_TIMEOUT';
@@ -85,7 +84,6 @@ export async function chatCompletion(messages, options = {}) {
};
const data = await withRetry(() => postJson('/chat/completions', payload));
// OpenRouter uses OpenAI-compatible response shape
const text = data?.choices?.[0]?.message?.content || data?.choices?.[0]?.text || '';
return (text && String(text).trim()) || '';
}

172
src/public/index.html Normal file
View File

@@ -0,0 +1,172 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Nova Memory Dashboard</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;}
body{
font-family:system-ui, sans-serif;
background:#121212;
color:#e0e0e0;
min-height:100vh;
padding:1rem;
}
.container{max-width:900px;margin:0 auto;}
h1{font-size:2rem;font-weight:700;color:#fff;margin-bottom:1rem;text-align:center;}
.mood-card,.section-card{background:#1e1e1e;border-radius:8px;padding:1.5rem;margin:1rem 0;box-shadow:0 4px 12px rgba(0,0,0,0.6);}
h2{font-size:1.5rem;color:#fff;border-bottom:2px solid #333;padding-bottom:0.5rem;margin-bottom:1rem;}
h3{font-size:1.2rem;color:#ddd;margin-top:1rem;margin-bottom:0.75rem;}
.user-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:0.75rem;}
.user-card{background:#292929;padding:1rem;border-radius:6px;cursor:pointer;transition:transform .2s,box-shadow .2s;color:#fff;}
.user-card:hover{transform:translateY(-3px);box-shadow:0 6px 20px rgba(0,0,0,0.7);}
.user-id{font-weight:600;font-size:1.1rem;margin-bottom:0.4rem;}
.user-summary{font-size:0.9rem;opacity:0.85;line-height:1.3;}
table{width:100%;border-collapse:collapse;margin-bottom:1rem;font-size:0.9rem;}
th{background:#333;color:#fff;padding:.75rem;text-align:left;}
td{padding:.75rem;border-bottom:1px solid #2a2a2a;}
tr:nth-child(even){background:#1e1e1e;}
.empty{color:#777;font-style:italic;text-align:center;padding:1rem;}
button{background:#444;color:#fff;border:none;padding:.5rem 1rem;border-radius:4px;cursor:pointer;transition:background .2s;}
button:hover{background:#555;}
.delete-btn{background:#880000;}
.delete-btn:hover{background:#aa0000;}
input[type=text]{width:100%;padding:.6rem;border:1px solid #333;border-radius:4px;background:#212121;color:#e0e0e0;}
.search-box{display:flex;gap:.5rem;flex-wrap:wrap;margin:1rem 0;}
.search-results{background:#1e1e1e;border-left:4px solid #555;padding:1rem;border-radius:4px;margin-top:1rem;}
.search-results ul{list-style:none;}
.search-results li{padding:.6rem;margin-bottom:.4rem;background:#292929;border-radius:4px;}
.score-badge{display:inline-block;background:#555;color:#fff;padding:.2rem .4rem;border-radius:3px;font-size:.8rem;margin-right:.4rem;}
.modal-overlay{position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.7);display:none;align-items:center;justify-content:center;}
.modal{background:#1e1e1e;padding:2rem;border-radius:8px;max-height:90vh;overflow:auto;width:90%;max-width:800px;position:relative;}
.modal-close{position:absolute;top:0.5rem;right:0.75rem;font-size:1.5rem;color:#bbb;cursor:pointer;}
.loading{color:#999;text-align:center;font-weight:600;padding:1rem;}
.error{background:#550000;color:#fff;padding:1rem;border-radius:4px;}
@media(max-width:600px){.user-list{grid-template-columns:1fr;}}
</style>
</head>
<body>
<div class="container">
<h1>🧠 Nova Memory Dashboard</h1>
<div id="mood"></div>
<div class="section-card">
<h2>Users</h2>
<div id="user-list" class="user-list">loading…</div>
</div>
</div>
<div id="modal" class="modal-overlay">
<div class="modal">
<span class="modal-close" onclick="closeModal()">&times;</span>
<div id="detail"></div>
</div>
</div>
<script>
async function fetchJson(path, opts){const res=await fetch(path,opts);if(!res.ok)throw new Error(res.statusText);return res.json();}
function formatDate(ts){return new Date(ts).toLocaleString();}
function closeModal(){document.getElementById('modal').style.display='none';}
async function loadMood(){
try{
const data=await fetchJson('/api/mood');
const moodCard=document.createElement('div');
moodCard.className='mood-card';
moodCard.innerHTML='<strong>'+data.mood.name+'</strong><p>'+data.mood.description+'</p><p>'+data.thought+'</p>';
const old=document.getElementById('mood');
old.replaceWith(moodCard);
moodCard.id='mood';
}catch(e){console.error(e);}
}
async function loadUsers() {
console.log('client: fetching users');
try {
const users = await fetchJson('/api/users');
console.log('client: got users', users);
const container = document.getElementById('user-list');
if (!users.length) {
container.innerHTML = '<div class="empty">No users yet</div>';
return;
}
container.innerHTML = users.map(u =>
'<div class="user-card" onclick="showUser(\'' + u.id + '\')">' +
'<div class="user-id">👤 ' + u.id + '</div>' +
'<div class="user-summary">' + (u.summary || 'No summary yet') + '</div>' +
'</div>'
).join('');
} catch (e) {
console.error('client: loadUsers error', e);
document.getElementById('user-list').innerHTML = '<div class="error">Error loading users: ' + e.message + '</div>';
}
}
async function showUser(id){
console.log('client: showUser',id);
const detail=document.getElementById('detail');
detail.innerHTML='<div class="loading">Loading…</div>';
document.getElementById('modal').style.display='flex';
try{
const [shortR,longR]=await Promise.all([
fetchJson('/api/users/'+id+'/short'),
fetchJson('/api/users/'+id+'/long')
]);
let html='<h2>Memory for '+id+'</h2>';
html+='<h3>Short-term</h3>';
if(shortR.length){
html+='<table><tr><th>Role</th><th>Content</th><th>When</th></tr>';
shortR.forEach(r=>{
html+='<tr><td><strong>'+r.role+'</strong></td><td>'+r.content.substring(0,100)+(r.content.length>100?'…':'')+'</td><td>'+formatDate(r.timestamp)+'</td></tr>';
});
html+='</table>';
}else html+='<p class="empty">none</p>';
html+='<h3>Long-term</h3>';
if(longR.length){
html+='<table><tr><th>Imp</th><th>Content</th><th>When</th><th></th></tr>';
longR.forEach(r=>{
const imp=r.importance? r.importance.toFixed(2):'N/A';
const impCol=r.importance? (r.importance>0.8?'#ff6b6b':r.importance>0.5?'#ffd43b':'#51cf66'):'#999';
html+='<tr><td><span class="score-badge" style="background:'+impCol+'">'+imp+'</span></td>'+
'<td>'+r.content.substring(0,100).replace(/</g,'&lt;')+(r.content.length>100?'…':'')+'</td>'+
'<td>'+formatDate(r.timestamp)+'</td>'+
'<td><button class="delete-btn" onclick="deleteEntry(\''+id+'\',\''+r.id+'\')">Del</button></td></tr>';
});
html+='</table>';
}else html+='<p class="empty">none</p>';
html+='<h3>Search</h3><div class="search-box"><input id="search-q" type="text" placeholder="query…"/><button onclick="runSearch(\''+id+'\')">Search</button></div><div id="search-results"></div>';
detail.innerHTML=html;
}catch(e){
detail.innerHTML='<div class="error">error: '+e.message+'</div>';
}
}
async function deleteEntry(user, id) {
console.log('client: deleteEntry', user, id);
if (!confirm('Are you sure you want to delete this memory?')) return;
try {
await fetch('/api/users/' + user + '/long/' + id, { method: 'DELETE' });
showUser(user);
} catch (e) {
alert('Error deleting memory: ' + e.message);
}
}
async function runSearch(user){
const q=document.getElementById('search-q').value;
if(!q){return;}
const container=document.getElementById('search-results');
container.innerHTML='<div class="loading">Searching…</div>';
try{
const res=await fetchJson('/api/users/'+user+'/search',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({query:q})});
if(!res.length){container.innerHTML='<div class="search-results"><p class="empty">none</p></div>';return;}
let html='<div class="search-results"><ul>';
res.forEach(r=>{const score=r.score?r.score.toFixed(3):'?';html+='<li><span class="score-badge">'+score+'</span>'+r.content.substring(0,100).replace(/</g,'&lt;')+(r.content.length>100?'…':'')+'</li>';});
html+='</ul></div>';
container.innerHTML=html;
}catch(e){container.innerHTML='<div class="error">search error</div>';}
}
loadMood();
loadUsers();
</script>
</body>
</html>