Mostly works

This commit is contained in:
2026-05-03 11:13:06 -04:00
parent 6f4519894b
commit 60663b8d4a
21 changed files with 3188 additions and 1706 deletions
+433
View File
@@ -0,0 +1,433 @@
'use strict';
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const fs = require('fs');
/**
* AI Memory Database
* Stores player-specific memories and bot directives
* Singleton pattern - one instance per bot
*/
class AIMemoryDB {
constructor() {
this.db = null;
this.botName = null;
}
/**
* Initialize database connection and create tables
* @param {string} dbPath - Path to sqlite database file
* @param {string} botName - Bot name for namespacing
*/
async initialize(dbPath = './storage/ai-memory.db', botName = 'default') {
if (this.db) {
this.botName = botName;
return; // Already initialized
}
const fullPath = path.resolve(dbPath);
const dir = path.dirname(fullPath);
// Ensure directory exists
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
console.log(`AI Memory: Initializing database at ${fullPath} for bot ${botName}`);
this.db = new sqlite3.Database(fullPath);
this.botName = botName;
await this.createTables();
}
/**
* Create tables if they don't exist
*/
createTables() {
return new Promise((resolve, reject) => {
this.db.serialize(() => {
// Player memories table
this.db.run(`
CREATE TABLE IF NOT EXISTS player_memories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bot_name TEXT NOT NULL,
player_name TEXT NOT NULL,
memory_key TEXT NOT NULL,
memory_value TEXT,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(bot_name, player_name, memory_key)
)
`);
// Bot directives table - persistent instructions per bot
this.db.run(`
CREATE TABLE IF NOT EXISTS bot_directives (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bot_name TEXT NOT NULL,
directive_key TEXT NOT NULL,
directive_value TEXT,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(bot_name, directive_key)
)
`);
// General memories table - for non-player-specific info
this.db.run(`
CREATE TABLE IF NOT EXISTS general_memories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bot_name TEXT NOT NULL,
memory_key TEXT NOT NULL,
memory_value TEXT,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(bot_name, memory_key)
)
`);
// Create indexes for faster lookups
this.db.run(`CREATE INDEX IF NOT EXISTS idx_player_memories_lookup ON player_memories(bot_name, player_name)`);
this.db.run(`CREATE INDEX IF NOT EXISTS idx_bot_directives_lookup ON bot_directives(bot_name)`);
this.db.run(`CREATE INDEX IF NOT EXISTS idx_general_memories_lookup ON general_memories(bot_name)`);
resolve();
});
});
}
// ========================================
// Player Memories (Shared across all bots)
// ========================================
/**
* Set a memory about a specific player (shared across all bots)
*/
async setPlayerMemory(playerName, key, value) {
return new Promise((resolve, reject) => {
this.db.run(`
INSERT OR REPLACE INTO player_memories (bot_name, player_name, memory_key, memory_value, updated_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
`, ['global', playerName, key, value], (err) => {
if (err) reject(err);
else resolve();
});
});
}
/**
* Get a specific memory about a player (shared across all bots)
*/
async getPlayerMemory(playerName, key) {
return new Promise((resolve, reject) => {
this.db.get(`
SELECT memory_value FROM player_memories
WHERE bot_name = 'global' AND player_name = ? AND memory_key = ?
`, [playerName, key], (err, row) => {
if (err) reject(err);
else resolve(row ? row.memory_value : null);
});
});
}
/**
* Get all memories about a player (shared across all bots)
*/
async getAllPlayerMemories(playerName) {
return new Promise((resolve, reject) => {
this.db.all(`
SELECT memory_key, memory_value FROM player_memories
WHERE bot_name = 'global' AND player_name = ?
`, [playerName], (err, rows) => {
if (err) reject(err);
else {
const memories = {};
for (const row of rows) {
memories[row.memory_key] = row.memory_value;
}
resolve(memories);
}
});
});
}
/**
* Delete a specific player memory (shared across all bots)
*/
async deletePlayerMemory(playerName, key) {
return new Promise((resolve, reject) => {
this.db.run(`
DELETE FROM player_memories
WHERE bot_name = 'global' AND player_name = ? AND memory_key = ?
`, [playerName, key], (err) => {
if (err) reject(err);
else resolve();
});
});
}
/**
* List all players with stored memories (shared across all bots)
*/
async getAllKnownPlayers() {
return new Promise((resolve, reject) => {
this.db.all(`
SELECT DISTINCT player_name FROM player_memories
WHERE bot_name = 'global'
`, (err, rows) => {
if (err) reject(err);
else resolve(rows.map(r => r.player_name));
});
});
}
// ========================================
// Bot Directives
// ========================================
/**
* Set a directive for this bot
*/
async setDirective(key, value) {
return new Promise((resolve, reject) => {
this.db.run(`
INSERT OR REPLACE INTO bot_directives (bot_name, directive_key, directive_value, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
`, [this.botName, key, value], (err) => {
if (err) reject(err);
else resolve();
});
});
}
/**
* Get a specific directive
*/
async getDirective(key) {
return new Promise((resolve, reject) => {
this.db.get(`
SELECT directive_value FROM bot_directives
WHERE bot_name = ? AND directive_key = ?
`, [this.botName, key], (err, row) => {
if (err) reject(err);
else resolve(row ? row.directive_value : null);
});
});
}
/**
* Get all directives for this bot
*/
async getAllDirectives() {
return new Promise((resolve, reject) => {
this.db.all(`
SELECT directive_key, directive_value FROM bot_directives
WHERE bot_name = ?
`, [this.botName], (err, rows) => {
if (err) reject(err);
else {
const directives = {};
for (const row of rows) {
directives[row.directive_key] = row.directive_value;
}
resolve(directives);
}
});
});
}
// ========================================
// General Memories
// ========================================
/**
* Set a general memory (not player-specific)
*/
async setGeneralMemory(key, value) {
return new Promise((resolve, reject) => {
this.db.run(`
INSERT OR REPLACE INTO general_memories (bot_name, memory_key, memory_value, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
`, [this.botName, key, value], (err) => {
if (err) reject(err);
else resolve();
});
});
}
/**
* Get a general memory
*/
async getGeneralMemory(key) {
return new Promise((resolve, reject) => {
this.db.get(`
SELECT memory_value FROM general_memories
WHERE bot_name = ? AND memory_key = ?
`, [this.botName, key], (err, row) => {
if (err) reject(err);
else resolve(row ? row.memory_value : null);
});
});
}
/**
* Get all general memories
*/
async getAllGeneralMemories() {
return new Promise((resolve, reject) => {
this.db.all(`
SELECT memory_key, memory_value FROM general_memories
WHERE bot_name = ?
`, [this.botName], (err, rows) => {
if (err) reject(err);
else {
const memories = {};
for (const row of rows) {
memories[row.memory_key] = row.memory_value;
}
resolve(memories);
}
});
});
}
/**
* Get all general memories with timestamps
*/
async getAllGeneralMemoriesWithTimestamps() {
return new Promise((resolve, reject) => {
this.db.all(`
SELECT memory_key, memory_value, updated_at FROM general_memories
WHERE bot_name = ?
ORDER BY updated_at DESC
`, [this.botName], (err, rows) => {
if (err) reject(err);
else resolve(rows);
});
});
}
// ========================================
// Summary Methods (for AI context)
// ========================================
/**
* Get all directives as formatted string with timestamps
*/
async getDirectivesSummary() {
return new Promise((resolve, reject) => {
this.db.all(`
SELECT directive_key, directive_value, updated_at FROM bot_directives
WHERE bot_name = ?
ORDER BY updated_at DESC
`, [this.botName], (err, rows) => {
if (err) reject(err);
else {
if (rows.length === 0) {
resolve(null);
return;
}
let summary = 'Active Directives:\n';
for (const row of rows) {
const timeAgo = this.formatTimeAgo(row.updated_at);
summary += `- ${row.directive_key}: ${row.directive_value} (set ${timeAgo})\n`;
}
resolve(summary.trim());
}
});
});
}
/**
* Format timestamp as relative time string
*/
formatTimeAgo(timestamp) {
if (!timestamp) return 'unknown';
const now = new Date();
const past = new Date(timestamp);
const diffMs = now - past;
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffSecs < 60) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return past.toLocaleDateString();
}
/**
* Get full memory context for prompt injection
*/
async getMemoryContext() {
const directives = await this.getDirectivesSummary();
const generalMemories = await this.getAllGeneralMemoriesWithTimestamps();
let context = '';
if (directives) {
context += directives + '\n\n';
}
if (generalMemories && generalMemories.length > 0) {
context += 'General Memories:\n';
for (const mem of generalMemories) {
const timeAgo = this.formatTimeAgo(mem.updated_at);
context += `- ${mem.memory_key}: ${mem.memory_value} (stored ${timeAgo})\n`;
}
context += '\n';
}
return context || null;
}
/**
* Get memories for specific players (for prompt injection when they're online)
* @param {string[]} playerNames - Array of player names to get memories for
*/
async getPlayerMemoriesForPrompt(playerNames) {
if (!playerNames || playerNames.length === 0) return null;
let context = '';
for (const playerName of playerNames) {
const memories = await this.getAllPlayerMemories(playerName);
if (Object.keys(memories).length > 0) {
context += `Memories about ${playerName}:\n`;
for (const [key, value] of Object.entries(memories)) {
context += `- ${key}: ${value}\n`;
}
context += '\n';
}
}
return context.trim() || null;
}
// ========================================
// Utility Methods
// ========================================
/**
* Close database connection
*/
async close() {
return new Promise((resolve, reject) => {
if (this.db) {
this.db.close((err) => {
if (err) reject(err);
else {
this.db = null;
this.botName = null;
resolve();
}
});
} else {
resolve();
}
});
}
}
// Singleton instance
module.exports = new AIMemoryDB();
+34 -1
View File
@@ -6,6 +6,15 @@ class GeminiProvider {
constructor(config) {
this.config = config;
this.session = null;
this.tools = [];
}
supportsTools() {
return true;
}
setTools(tools) {
this.tools = tools;
}
async start(history) {
@@ -18,7 +27,7 @@ class GeminiProvider {
}
__settings(history) {
return {
const settings = {
generationConfig: {
temperature: this.config.temperature || 1,
topP: this.config.topP || 0.95,
@@ -55,6 +64,19 @@ class GeminiProvider {
},
],
};
// Add tools if configured
if (this.tools && this.tools.length > 0) {
settings.tools = this.tools.map(tool => ({
functionDeclarations: [{
name: tool.name,
description: tool.description,
parameters: tool.parameters
}]
}));
}
return settings;
}
async chat(message, retryCount = 0) {
@@ -65,6 +87,9 @@ class GeminiProvider {
if (retryCount > 3) {
throw new Error(`Gemini API error after ${retryCount} retries: ${error.message}`);
}
const baseDelay = Math.min(1000 * Math.pow(2, retryCount), 30000);
const jitter = Math.random() * 1000;
await new Promise(resolve => setTimeout(resolve, baseDelay + jitter));
// Recover by removing last history entry and restarting
this.session.params.history.pop();
await this.start(this.session.params.history);
@@ -80,6 +105,14 @@ class GeminiProvider {
return result.response.text();
}
getToolCalls(result) {
// Gemini returns function calls in result.response.functionCalls()
if (result.response && typeof result.response.functionCalls === 'function') {
return result.response.functionCalls();
}
return null;
}
async close() {
this.session = null;
}
+8 -1
View File
@@ -22,4 +22,11 @@ module.exports = {
ProviderFactory,
GeminiProvider,
OllamaProvider
};
};
/**
* Provider interface for tool support
* All providers must implement these methods:
* - setTools(tools): Configure available tools for function calling
* - supportsTools(): boolean - whether this provider supports tool calling
*/
+67 -22
View File
@@ -10,12 +10,20 @@ class OllamaProvider {
this.baseUrl = config.baseUrl || 'http://localhost:11434';
this.model = config.model || 'llama3.2';
this.messages = [];
this.tools = [];
}
supportsTools() {
return true;
}
setTools(tools) {
this.tools = tools;
}
async start(history) {
// Convert Gemini-style history to Ollama format if needed
this.messages = history || [];
if (this.config.prompt) {
console.log('Ollama provider initialized with model:', this.model);
}
@@ -27,13 +35,12 @@ class OllamaProvider {
top_p: this.config.topP || 0.95,
top_k: this.config.topK || 64,
num_predict: this.config.maxOutputTokens || 2048,
num_ctx: this.config.num_ctx,
};
}
__jsonFormat() {
return 'json'
/* return {
return {
type: 'array',
items: {
type: 'object',
@@ -43,12 +50,26 @@ class OllamaProvider {
},
required: ['text', 'delay']
}
};*/
};
}
/**
* Strip markdown code fences (```json ... ```) from a response string.
* Ollama models (especially smaller ones) sometimes wrap their output in fences
* even when the system prompt says not to.
*/
static stripMarkdownFences(text) {
if (!text || typeof text !== 'string') return text;
let cleaned = text.trim();
// Remove leading ```json or ``` fences
cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, '');
// Remove trailing ``` fences
cleaned = cleaned.replace(/\n?```\s*$/, '');
return cleaned.trim();
}
async chat(message, retryCount = 0) {
try {
// Build conversation from prompt + history
const messages = [
{
role: 'system',
@@ -64,33 +85,45 @@ class OllamaProvider {
}
];
// console.log('Ollama messages', messages)
const requestBody = {
model: this.model,
messages: messages,
stream: false,
think: false,
format: this.__jsonFormat(),
options: this.__settings()
};
// console.log('Ollama request:', JSON.stringify(requestBody, null, 2));
// Only set format when NO tools are configured.
// format + tools together confuses smaller models — they try to
// satisfy both constraints and produce garbage (_/empty responses).
const hasTools = this.tools && this.tools.length > 0;
if (!hasTools) {
requestBody.format = this.__jsonFormat();
}
if (hasTools) {
requestBody.tools = this.tools.map(tool => ({
type: 'function',
function: {
name: tool.name,
description: tool.description,
parameters: tool.parameters
}
}));
}
const response = await axios.post(
`${this.baseUrl}/api/chat`,
requestBody,
{
// timeout: this.config.timeout || 30000,
headers: {
'Content-Type': 'application/json'
}
}
);
// Log raw response for debugging
const rawContent = response.data.message.content;
const messageData = response.data.message;
console.log('Ollama response', rawContent)
// console.log('Ollama raw response:', JSON.stringify(rawContent));
// console.log('Ollama raw response length:', rawContent?.length);
// Update history
this.messages.push({
@@ -105,14 +138,25 @@ class OllamaProvider {
content: rawContent
});
// Return in a format compatible with the Ai class
return {
// The text() closure strips markdown code fences so consumers
// (processResponse, getToolCalls) get clean content.
const result = {
response: {
text: () => response.data.message.content
text: () => {
let content = messageData.content || rawContent;
content = OllamaProvider.stripMarkdownFences(content);
return content;
}
}
};
// Ollama may return tool calls in messageData.tool_calls
if (messageData.tool_calls) {
result.tool_calls = messageData.tool_calls;
}
return result;
} catch (error) {
// Log detailed error information
const errorDetails = {
message: error.message,
status: error.response?.status,
@@ -125,8 +169,9 @@ class OllamaProvider {
if (retryCount > 3) {
throw new Error(`Ollama API error after ${retryCount} retries: ${error.message}`);
}
// Retry after delay
await new Promise(resolve => setTimeout(resolve, 500 * (retryCount + 1)));
const baseDelay = Math.min(1000 * Math.pow(2, retryCount), 30000);
const jitter = Math.random() * 1000;
await new Promise(resolve => setTimeout(resolve, baseDelay + jitter));
return await this.chat(message, retryCount + 1);
}
}
@@ -144,4 +189,4 @@ class OllamaProvider {
}
}
module.exports = OllamaProvider;
module.exports = OllamaProvider;
+481 -8
View File
@@ -19,7 +19,7 @@ function createRouter() {
model: config.model || 'unknown',
interval: ai.intervalLength,
promptName: ai.promptName || 'unknown',
active: !!ai.intervalStop,
active: !!ai._active,
};
}
res.json({ bots: result });
@@ -29,6 +29,196 @@ function createRouter() {
}
});
// Get list of all bots (for UI)
router.get('/api/ai/bots', (req, res) => {
try {
const bots = [];
for (const [name, bot] of Object.entries(CJbot.bots)) {
const ai = bot.plunginsLoaded['Ai'];
bots.push({
name: name,
hasAI: !!ai,
hasMemory: !!(ai && ai.memoryDB)
});
}
res.json({ bots });
} catch (error) {
console.error('API Error /api/ai/bots:', error);
res.status(500).json({ error: error.message });
}
});
// Get all players with memories (shared across all bots)
router.get('/api/ai/memories/players', async (req, res) => {
try {
// Get players from any bot's memoryDB (they're shared now)
let players = [];
for (const [name, bot] of Object.entries(CJbot.bots)) {
const ai = bot.plunginsLoaded['Ai'];
if (ai && ai.memoryDB) {
players = await ai.memoryDB.getAllKnownPlayers();
break;
}
}
res.json({ players });
} catch (error) {
console.error('API Error /api/ai/memories/players:', error);
res.status(500).json({ error: error.message });
}
});
// Get memories for a specific player
router.get('/api/ai/memories/:botName/:playerName', async (req, res) => {
try {
const { botName, playerName } = req.params;
const bot = CJbot.bots[botName];
if (!bot) {
return res.status(404).json({ error: 'Bot not found' });
}
const ai = bot.plunginsLoaded['Ai'];
if (!ai || !ai.memoryDB) {
return res.status(404).json({ error: 'AI or memory DB not loaded' });
}
const memories = await ai.memoryDB.getAllPlayerMemories(playerName);
res.json({ bot: botName, player: playerName, memories });
} catch (error) {
console.error('API Error /api/ai/memories/:bot/:player:', error);
res.status(500).json({ error: error.message });
}
});
// Set or update a player memory
router.post('/api/ai/memories/:botName/:playerName', async (req, res) => {
try {
const { botName, playerName } = req.params;
const { key, value } = req.body;
if (!key || !value) {
return res.status(400).json({ error: 'key and value are required' });
}
const bot = CJbot.bots[botName];
if (!bot) {
return res.status(404).json({ error: 'Bot not found' });
}
const ai = bot.plunginsLoaded['Ai'];
if (!ai || !ai.memoryDB) {
return res.status(404).json({ error: 'AI or memory DB not loaded' });
}
await ai.memoryDB.setPlayerMemory(playerName, key, value);
res.json({ success: true, bot: botName, player: playerName, key, value });
} catch (error) {
console.error('API Error /api/ai/memories/:bot/:player POST:', error);
res.status(500).json({ error: error.message });
}
});
// Delete a player memory
router.delete('/api/ai/memories/:botName/:playerName/:key', async (req, res) => {
try {
const { botName, playerName, key } = req.params;
const bot = CJbot.bots[botName];
if (!bot) {
return res.status(404).json({ error: 'Bot not found' });
}
const ai = bot.plunginsLoaded['Ai'];
if (!ai || !ai.memoryDB) {
return res.status(404).json({ error: 'AI or memory DB not loaded' });
}
await ai.memoryDB.deletePlayerMemory(playerName, key);
res.json({ success: true, bot: botName, player: playerName, key });
} catch (error) {
console.error('API Error /api/ai/memories/:bot/:player/:key DELETE:', error);
res.status(500).json({ error: error.message });
}
});
// Get bot directives
router.get('/api/ai/directives/:botName', async (req, res) => {
try {
const { botName } = req.params;
const bot = CJbot.bots[botName];
if (!bot) {
return res.status(404).json({ error: 'Bot not found' });
}
const ai = bot.plunginsLoaded['Ai'];
if (!ai || !ai.memoryDB) {
return res.status(404).json({ error: 'AI or memory DB not loaded' });
}
const directives = await ai.memoryDB.getAllDirectives();
res.json({ bot: botName, directives });
} catch (error) {
console.error('API Error /api/ai/directives/:bot:', error);
res.status(500).json({ error: error.message });
}
});
// Set or update a directive
router.post('/api/ai/directives/:botName', async (req, res) => {
try {
const { botName } = req.params;
const { key, value } = req.body;
if (!key || !value) {
return res.status(400).json({ error: 'key and value are required' });
}
const bot = CJbot.bots[botName];
if (!bot) {
return res.status(404).json({ error: 'Bot not found' });
}
const ai = bot.plunginsLoaded['Ai'];
if (!ai || !ai.memoryDB) {
return res.status(404).json({ error: 'AI or memory DB not loaded' });
}
await ai.memoryDB.setDirective(key, value);
res.json({ success: true, bot: botName, key, value });
} catch (error) {
console.error('API Error /api/ai/directives/:bot POST:', error);
res.status(500).json({ error: error.message });
}
});
// Get general memories
router.get('/api/ai/general-memories/:botName', async (req, res) => {
try {
const { botName } = req.params;
const bot = CJbot.bots[botName];
if (!bot) {
return res.status(404).json({ error: 'Bot not found' });
}
const ai = bot.plunginsLoaded['Ai'];
if (!ai || !ai.memoryDB) {
return res.status(404).json({ error: 'AI or memory DB not loaded' });
}
const memories = await ai.memoryDB.getAllGeneralMemories();
res.json({ bot: botName, memories });
} catch (error) {
console.error('API Error /api/ai/general-memories/:bot:', error);
res.status(500).json({ error: error.message });
}
});
// Set or update a general memory
router.post('/api/ai/general-memories/:botName', async (req, res) => {
try {
const { botName } = req.params;
const { key, value } = req.body;
if (!key || !value) {
return res.status(400).json({ error: 'key and value are required' });
}
const bot = CJbot.bots[botName];
if (!bot) {
return res.status(404).json({ error: 'Bot not found' });
}
const ai = bot.plunginsLoaded['Ai'];
if (!ai || !ai.memoryDB) {
return res.status(404).json({ error: 'AI or memory DB not loaded' });
}
await ai.memoryDB.setGeneralMemory(key, value);
res.json({ success: true, bot: botName, key, value });
} catch (error) {
console.error('API Error /api/ai/general-memories/:bot POST:', error);
res.status(500).json({ error: error.message });
}
});
return router;
}
@@ -38,7 +228,16 @@ const webUI = {
tabOrder: 30,
html: `
<div id="aiArea">
<div style="padding:20px;color:#6b7280;text-align:center">Loading AI status...</div>
<div class="ai-tabs">
<button class="ai-tab-btn active" data-tab="status" onclick="switchAiTab('status')">AI Status</button>
<button class="ai-tab-btn" data-tab="memories" onclick="switchAiTab('memories')">Memories</button>
<button class="ai-tab-btn" data-tab="directives" onclick="switchAiTab('directives')">Directives</button>
</div>
<div id="ai-tab-content">
<div id="ai-status" class="ai-tab-content active"></div>
<div id="ai-memories" class="ai-tab-content"></div>
<div id="ai-directives" class="ai-tab-content"></div>
</div>
</div>
`,
css: `
@@ -53,41 +252,96 @@ const webUI = {
.ai-status-badge.active{background:#059669;color:#fff}
.ai-status-badge.inactive{background:#6b7280;color:#fff}
.ai-empty{padding:40px 20px;color:#6b7280;text-align:center;font-size:.9em}
.ai-tabs{display:flex;gap:8px;margin-bottom:16px;border-bottom:1px solid #374151;padding-bottom:8px}
.ai-tab-btn{background:transparent;border:1px solid #374151;color:#9ca3af;padding:8px 16px;border-radius:6px;cursor:pointer;transition:all .2s}
.ai-tab-btn:hover{border-color:#a78bfa;color:#e5e7eb}
.ai-tab-btn.active{background:#a78bfa;border-color:#a78bfa;color:#111827}
.ai-tab-content{display:none}
.ai-tab-content.active{display:block}
.memory-card{background:#111827;border:1px solid #374151;border-radius:8px;padding:16px;margin-bottom:12px}
.memory-card h4{color:#a78bfa;margin:0 0 12px 0;font-size:.95em}
.memory-entry{display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid #1f2937}
.memory-entry:last-child{border-bottom:none}
.memory-key{color:#e5e7eb;font-weight:500}
.memory-value{color:#9ca3af;max-width:60%;overflow:hidden;text-overflow:ellipsis}
.memory-actions{display:flex;gap:8px}
.btn-sm{padding:4px 8px;font-size:.75em;border-radius:4px;border:1px solid #374151;background:#1f2937;color:#9ca3af;cursor:pointer}
.btn-sm:hover{border-color:#a78bfa;color:#e5e7eb}
.btn-danger{border-color:#dc2626;color:#fca5a5}
.btn-danger:hover{background:#dc2626;color:#fff}
.btn-success{border-color:#059669;color:#6ee7b7}
.btn-success:hover{background:#059669;color:#fff}
.memory-form{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}
.memory-form input{flex:1;min-width:150px;padding:8px;border-radius:4px;border:1px solid #374151;background:#1f2937;color:#e5e7eb}
.memory-form input:focus{outline:none;border-color:#a78bfa}
.player-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px}
.player-card{background:#1f2937;border:1px solid #374151;border-radius:6px;padding:12px;cursor:pointer;transition:border-color .2s}
.player-card:hover{border-color:#a78bfa}
.player-card.selected{border-color:#a78bfa;background:#2d1f4e}
.form-group{margin-bottom:12px}
.form-group label{display:block;color:#6b7280;font-size:.85em;margin-bottom:4px}
.form-group input,.form-group textarea{width:100%;padding:8px;border-radius:4px;border:1px solid #374151;background:#1f2937;color:#e5e7eb;box-sizing:border-box}
.form-group textarea{min-height:80px;resize:vertical}
.bot-select{margin-bottom:16px;padding:8px;border-radius:6px;border:1px solid #374151;background:#1f2937;color:#e5e7eb;min-width:200px}
`,
onTabActive: 'onAiTabActive',
js: `
let aiInterval=null;
let currentAiTab='status';
let selectedBot=null;
let selectedPlayer=null;
function onAiTabActive() {
// Initialize tab content structure if needed
var container = document.getElementById('ai-tab-content');
if (container && !container.querySelector('#ai-status')) {
container.innerHTML = '<div id="ai-status" class="ai-tab-content active"></div><div id="ai-memories" class="ai-tab-content"></div><div id="ai-directives" class="ai-tab-content"></div>';
}
loadAiStatus();
if (!aiInterval) aiInterval = setInterval(() => { if (currentTab === 'ai') loadAiStatus(); }, 10000);
}
function switchAiTab(tab) {
currentAiTab = tab;
var btns = document.querySelectorAll('.ai-tab-btn');
for (var i = 0; i < btns.length; i++) {
btns[i].classList.remove('active');
}
var activeBtn = document.querySelector('.ai-tab-btn[data-tab="'+tab+'"]');
if (activeBtn) activeBtn.classList.add('active');
var contents = document.querySelectorAll('.ai-tab-content');
for (var i = 0; i < contents.length; i++) {
contents[i].classList.remove('active');
}
var target = document.getElementById('ai-'+tab);
if (target) target.classList.add('active');
if (tab === 'memories') loadAiPlayers();
if (tab === 'directives') loadAiDirectives();
}
async function loadAiStatus() {
try {
const r = await fetch('/api/ai/status');
if (!r.ok) { document.getElementById('aiArea').innerHTML='<div class="ai-empty">Failed to load AI status</div>'; return; }
if (!r.ok) { document.getElementById('ai-status').innerHTML='<div class="ai-empty">Failed to load AI status</div>'; return; }
const d = await r.json();
renderAiStatus(d.bots || {});
} catch(e) {
document.getElementById('aiArea').innerHTML='<div class="ai-empty">Failed to load AI status</div>';
document.getElementById('ai-status').innerHTML='<div class="ai-empty">Failed to load AI status: ' + escHtml(e.message) + '</div>';
}
}
function renderAiStatus(bots) {
const area = document.getElementById('aiArea');
const names = Object.keys(bots);
if (names.length === 0) {
area.innerHTML='<div class="ai-empty">No bots with AI loaded</div>';
document.getElementById('ai-status').innerHTML='<div class="ai-empty">No bots with AI loaded</div>';
return;
}
area.innerHTML = '<div class="ai-grid">' + names.map(name => {
let html = '<div class="ai-grid">' + names.map(name => {
const ai = bots[name];
const badge = ai.active
? '<span class="ai-status-badge active">Active</span>'
: '<span class="ai-status-badge inactive">Inactive</span>';
return '<div class="ai-card">' +
'<h3><span class="bot-status ' + (ai.connected ? 'online' : 'offline') + '"></span> ' + escHtml(name) + ' ' + badge + '</h3>' +
'<div class="ai-info"><span class="ai-label">Provider:</span> <span class="ai-value">' + escHtml(ai.provider) + '</span></div>' +
@@ -96,6 +350,225 @@ function renderAiStatus(bots) {
'<div class="ai-info"><span class="ai-label">Prompt:</span> <span class="ai-value">' + escHtml(ai.promptName) + '</span></div>' +
'</div>';
}).join('') + '</div>';
document.getElementById('ai-status').innerHTML = html;
}
async function loadAiPlayers() {
try {
// First get list of all bots (for selecting which bot to edit with)
const botsR = await fetch('/api/ai/bots');
if (!botsR.ok) throw new Error('Failed to load bots');
const botsD = await botsR.json();
// Then get players with memories (shared across all bots)
const r = await fetch('/api/ai/memories/players');
if (!r.ok) throw new Error('Failed to load players');
const d = await r.json();
renderAiPlayers(d.players || [], botsD.bots || []);
} catch(e) {
document.getElementById('ai-memories').innerHTML='<div class="ai-empty">Failed to load players: ' + escHtml(e.message) + '</div>';
}
}
function renderAiPlayers(players, allBots) {
// players is now a flat array (shared memories)
// allBots is array of {name, hasAI, hasMemory}
if (!allBots || allBots.length === 0) {
document.getElementById('ai-memories').innerHTML='<div class="ai-empty">No bots available</div>';
return;
}
let html = '<div class="form-group"><label>Select Bot (for editing):</label><select class="bot-select" onchange="onBotSelect(this.value)">';
html += '<option value="">-- Select --</option>';
allBots.forEach(bot => {
html += '<option value="'+escHtml(bot.name)+'">'+escHtml(bot.name) + (bot.hasAI ? '' : ' (no AI)')+'</option>';
});
html += '</select></div>';
// Player list (shared, no bot filtering)
if (players && players.length > 0) {
html += '<div class="player-list">' + players.map(player => {
const selected = selectedPlayer === player ? 'selected' : '';
return '<div class="player-card '+selected+'" onclick="selectPlayer(\\''+escHtml(player)+'\\')">'+escHtml(player)+'</div>';
}).join('') + '</div>';
} else {
html += '<div class="ai-empty">No players with stored memories</div>';
}
if (selectedPlayer) {
html += '<div id="playerMemoriesArea" style="margin-top:16px"></div>';
setTimeout(() => loadPlayerMemories(selectedBot, selectedPlayer), 0);
}
document.getElementById('ai-memories').innerHTML = html;
}
function onBotSelect(botName) {
selectedBot = botName;
}
function selectPlayer(playerName) {
selectedPlayer = playerName;
// Re-render to show selection highlight
loadAiPlayers();
}
async function loadPlayerMemories(botName, playerName) {
// Memories are shared, but we need a bot selected to edit
if (!botName) {
const area = document.getElementById('playerMemoriesArea');
if (area) {
area.innerHTML='<div class="ai-empty">Select a bot above to view/edit memories</div>';
}
return;
}
try {
const r = await fetch('/api/ai/memories/'+encodeURIComponent(botName)+'/'+encodeURIComponent(playerName));
if (!r.ok) throw new Error('Failed to load memories');
const d = await r.json();
renderPlayerMemories(d.memories || {}, botName, playerName);
} catch(e) {
const area = document.getElementById('playerMemoriesArea');
if (area) {
area.innerHTML='<div class="ai-empty">Failed to load memories: ' + escHtml(e.message) + '</div>';
}
}
}
function renderPlayerMemories(memories, botName, playerName) {
const area = document.getElementById('playerMemoriesArea');
if (!area) return; // Element doesn't exist yet
const keys = Object.keys(memories);
if (keys.length === 0) {
area.innerHTML='<div class="ai-empty">No memories stored for '+escHtml(playerName)+'</div>';
return;
}
let html = '<div class="memory-card"><h4>Memories for '+escHtml(playerName)+' (shared)</h4>';
keys.forEach(key => {
html += '<div class="memory-entry">'+
'<span class="memory-key">'+escHtml(key)+'</span>'+
'<span class="memory-value">'+escHtml(memories[key])+'</span>'+
'<div class="memory-actions">'+
'<button class="btn-sm btn-danger" onclick="deleteMemory(\\''+escHtml(botName)+'\\',\\''+escHtml(playerName)+'\\',\\''+escHtml(key)+'\\')"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg></button>'+
'</div></div>';
});
html += '</div>';
html += '<div class="memory-card"><h4>Add Memory</h4><div class="memory-form">'+
'<input type="text" id="memoryKey" placeholder="Key (e.g., trust_level)">'+
'<input type="text" id="memoryValue" placeholder="Value">'+
'<button class="btn-sm btn-success" onclick="addMemory(\\''+escHtml(botName)+'\\',\\''+escHtml(playerName)+'\\')">Add</button>'+
'</div></div>';
document.getElementById('playerMemoriesArea').innerHTML = html;
}
async function deleteMemory(botName, playerName, key) {
if (!confirm('Delete memory "'+key+'" for '+playerName+'?')) return;
try {
const r = await fetch('/api/ai/memories/'+encodeURIComponent(botName)+'/'+encodeURIComponent(playerName)+'/'+encodeURIComponent(key), { method: 'DELETE' });
if (!r.ok) throw new Error('Failed to delete');
loadPlayerMemories(botName, playerName);
} catch(e) {
alert('Failed to delete: ' + e.message);
}
}
async function addMemory(botName, playerName) {
const key = document.getElementById('memoryKey').value;
const value = document.getElementById('memoryValue').value;
if (!key || !value) { alert('Key and value required'); return; }
try {
const r = await fetch('/api/ai/memories/'+encodeURIComponent(botName)+'/'+encodeURIComponent(playerName), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, value })
});
if (!r.ok) throw new Error('Failed to add');
document.getElementById('memoryKey').value = '';
document.getElementById('memoryValue').value = '';
loadPlayerMemories(botName, playerName);
} catch(e) {
alert('Failed to add: ' + e.message);
}
}
async function loadAiDirectives() {
try {
const r = await fetch('/api/ai/bots');
if (!r.ok) throw new Error('Failed to load bots');
const d = await r.json();
const bots = d.bots || [];
let html = '<div class="form-group"><label>Select Bot:</label><select class="bot-select" onchange="loadBotDirectives(this.value)">';
html += '<option value="">-- Select --</option>';
bots.forEach(bot => {
html += '<option value="'+escHtml(bot.name)+'">'+escHtml(bot.name) + (bot.hasAI ? '' : ' (no AI)')+'</option>';
});
html += '</select></div><div id="directivesArea"></div>';
document.getElementById('ai-directives').innerHTML = html;
} catch(e) {
document.getElementById('ai-directives').innerHTML='<div class="ai-empty">Failed to load bots: ' + escHtml(e.message) + '</div>';
}
}
async function loadBotDirectives(botName) {
if (!botName) { document.getElementById('directivesArea').innerHTML = ''; return; }
try {
const r = await fetch('/api/ai/directives/'+encodeURIComponent(botName));
if (!r.ok) throw new Error('Failed to load directives');
const d = await r.json();
renderBotDirectives(d.directives || {}, botName);
} catch(e) {
document.getElementById('directivesArea').innerHTML='<div class="ai-empty">Failed to load: ' + escHtml(e.message) + '</div>';
}
}
function renderBotDirectives(directives, botName) {
const keys = Object.keys(directives);
let html = '<div class="memory-card"><h4>Directives for '+escHtml(botName)+'</h4>';
if (keys.length === 0) {
html += '<div class="ai-empty">No directives set</div>';
} else {
keys.forEach(key => {
html += '<div class="memory-entry">'+
'<span class="memory-key">'+escHtml(key)+'</span>'+
'<span class="memory-value">'+escHtml(directives[key])+'</span>'+
'</div>';
});
}
html += '</div>';
html += '<div class="memory-card"><h4>Add Directive</h4><div class="form-group">'+
'<label>Key</label><input type="text" id="directiveKey" placeholder="e.g., current_goal">'+
'</div><div class="form-group">'+
'<label>Value</label><textarea id="directiveValue" placeholder="Directive value"></textarea>'+
'</div><button class="btn-sm btn-success" onclick="addDirective(\\''+escHtml(botName)+'\\')">Add</button>'+
'</div>';
document.getElementById('directivesArea').innerHTML = html;
}
async function addDirective(botName) {
const key = document.getElementById('directiveKey').value;
const value = document.getElementById('directiveValue').value;
if (!key || !value) { alert('Key and value required'); return; }
try {
const r = await fetch('/api/ai/directives/'+encodeURIComponent(botName), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, value })
});
if (!r.ok) throw new Error('Failed to add');
document.getElementById('directiveKey').value = '';
document.getElementById('directiveValue').value = '';
loadBotDirectives(botName);
} catch(e) {
alert('Failed to add: ' + e.message);
}
}
`,
};