2ndbrain 2026.1.30 → 2026.1.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +16 -0
- package/LICENSE +21 -0
- package/README.md +1 -1
- package/db/migrations/001_initial_schema.sql +91 -0
- package/doc/SPEC.md +896 -0
- package/hooks/auto-capture.sh +4 -0
- package/hooks/validate-command.sh +374 -0
- package/package.json +34 -20
- package/skills/journal/SKILL.md +112 -0
- package/skills/knowledge/SKILL.md +165 -0
- package/skills/project-manage/SKILL.md +216 -0
- package/skills/recall/SKILL.md +182 -0
- package/skills/system-ops/SKILL.md +161 -0
- package/src/attachments/store.js +167 -0
- package/src/claude/bridge.js +291 -0
- package/src/claude/conversation.js +219 -0
- package/src/config.js +90 -0
- package/src/db/migrate.js +94 -0
- package/src/db/pool.js +33 -0
- package/src/embeddings/engine.js +281 -0
- package/src/embeddings/worker.js +221 -0
- package/src/hooks/lifecycle.js +448 -0
- package/src/index.js +560 -0
- package/src/logging.js +91 -0
- package/src/mcp/config.js +75 -0
- package/src/mcp/embed-server.js +242 -0
- package/src/rate-limiter.js +114 -0
- package/src/telegram/bot.js +546 -0
- package/src/telegram/commands.js +440 -0
- package/src/web/server.js +880 -0
|
@@ -0,0 +1,880 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
// Resolve project root (two directories up from src/web/)
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
9
|
+
const __dirname = path.dirname(__filename);
|
|
10
|
+
const DEFAULT_ENV_PATH = path.resolve(__dirname, '..', '..', '.env');
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Settings field definitions -- drives both the form UI and save logic
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
const SETTINGS_FIELDS = [
|
|
17
|
+
{
|
|
18
|
+
section: 'Telegram',
|
|
19
|
+
fields: [
|
|
20
|
+
{ key: 'TELEGRAM_BOT_TOKEN', label: 'Bot Token', required: true, secret: true, hint: 'Telegram Bot API token from @BotFather' },
|
|
21
|
+
{ key: 'TELEGRAM_ALLOWED_USERS', label: 'Allowed Users', required: true, hint: 'Comma-separated Telegram user IDs' },
|
|
22
|
+
],
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
section: 'Database',
|
|
26
|
+
fields: [
|
|
27
|
+
{ key: 'DATABASE_URL', label: 'Database URL', required: true, secret: true, hint: 'PostgreSQL connection string (e.g. postgresql://user:pass@localhost/2ndbrain)' },
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
section: 'Claude',
|
|
32
|
+
fields: [
|
|
33
|
+
{ key: 'CLAUDE_MODEL', label: 'Model', hint: 'Default: claude-sonnet-4-20250514' },
|
|
34
|
+
{ key: 'CLAUDE_THINKING', label: 'Thinking', hint: 'Enable extended thinking (true/false)' },
|
|
35
|
+
{ key: 'CLAUDE_TIMEOUT', label: 'Timeout (ms)', hint: 'Default: 120000' },
|
|
36
|
+
{ key: 'CLAUDE_MAX_BUDGET', label: 'Max Budget (USD)', hint: 'Max cost per invocation (e.g. 0.50)' },
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
section: 'Storage',
|
|
41
|
+
fields: [
|
|
42
|
+
{ key: 'DATA_DIR', label: 'Data Directory', hint: 'Default: ~/data' },
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
section: 'Security',
|
|
47
|
+
fields: [
|
|
48
|
+
{ key: 'COMMANDS_WHITELIST', label: 'Commands Whitelist', hint: 'Allowed shell command patterns (comma-separated)' },
|
|
49
|
+
{ key: 'MCP_TOOLS_WHITELIST', label: 'MCP Tools Whitelist', hint: 'Allowed MCP tool names (* = all)' },
|
|
50
|
+
{ key: 'FILE_EDIT_PATHS', label: 'File Edit Paths', hint: 'Additional writable directories (comma-separated absolute paths)' },
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
section: 'Rate Limits',
|
|
55
|
+
fields: [
|
|
56
|
+
{ key: 'RATE_LIMIT_CLAUDE', label: 'Claude Rate Limit', hint: 'Max Claude calls per minute (default: 10)' },
|
|
57
|
+
{ key: 'RATE_LIMIT_TELEGRAM', label: 'Telegram Rate Limit', hint: 'Max Telegram sends per minute (default: 30)' },
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
section: 'Conversation',
|
|
62
|
+
fields: [
|
|
63
|
+
{ key: 'HISTORY_COMPACT_THRESHOLD', label: 'Compact Threshold', hint: 'Message count before auto-compaction (default: 100)' },
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
section: 'Logging',
|
|
68
|
+
fields: [
|
|
69
|
+
{ key: 'LOG_LEVEL', label: 'Log Level', hint: 'debug, info, warn, error (default: info)' },
|
|
70
|
+
],
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
section: 'Web Admin',
|
|
74
|
+
fields: [
|
|
75
|
+
{ key: 'WEB_PORT', label: 'Port', hint: 'Default: 3000' },
|
|
76
|
+
{ key: 'WEB_BIND', label: 'Bind Address', hint: 'Default: 127.0.0.1' },
|
|
77
|
+
{ key: 'AUTO_OPEN_BROWSER', label: 'Auto Open Browser', hint: 'true/false (default: true)' },
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
section: 'Embeddings',
|
|
82
|
+
fields: [
|
|
83
|
+
{ key: 'EMBEDDING_PROVIDER', label: 'Provider', hint: '"openai" or empty to disable' },
|
|
84
|
+
{ key: 'EMBEDDING_API_KEY', label: 'API Key', secret: true, hint: 'API key for the embedding provider' },
|
|
85
|
+
{ key: 'EMBEDDING_MODEL', label: 'Model', hint: 'Default: text-embedding-3-small' },
|
|
86
|
+
{ key: 'EMBEDDING_DIMENSIONS', label: 'Dimensions', hint: 'Override output dimensions (empty = model default)' },
|
|
87
|
+
{ key: 'EMBEDDING_BASE_URL', label: 'Base URL', hint: 'Override API base URL (empty = provider default)' },
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// WebServer
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
class WebServer {
|
|
97
|
+
/**
|
|
98
|
+
* @param {object} opts
|
|
99
|
+
* @param {object} opts.config - Application configuration object
|
|
100
|
+
* @param {object} opts.db - Database interface with query(text, params)
|
|
101
|
+
* @param {object} opts.logger - Logger instance with info/warn/error methods
|
|
102
|
+
*/
|
|
103
|
+
constructor({ config, db, logger }) {
|
|
104
|
+
this._config = config;
|
|
105
|
+
this._db = db;
|
|
106
|
+
this._logger = logger;
|
|
107
|
+
this._server = null;
|
|
108
|
+
this._app = null;
|
|
109
|
+
this._envPath = config.ENV_PATH || DEFAULT_ENV_PATH;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Create the Express app, bind routes, and start listening.
|
|
114
|
+
* @returns {Promise<import('node:http').Server>} The HTTP server instance
|
|
115
|
+
*/
|
|
116
|
+
async start() {
|
|
117
|
+
const app = express();
|
|
118
|
+
this._app = app;
|
|
119
|
+
|
|
120
|
+
// Body parsing
|
|
121
|
+
app.use(express.urlencoded({ extended: true }));
|
|
122
|
+
app.use(express.json());
|
|
123
|
+
|
|
124
|
+
// Routes
|
|
125
|
+
app.get('/', (req, res) => this._handleDashboard(req, res));
|
|
126
|
+
app.get('/settings', (req, res) => this._handleSettings(req, res));
|
|
127
|
+
app.post('/settings', (req, res) => this._handleSaveSettings(req, res));
|
|
128
|
+
app.get('/logs', (req, res) => this._handleLogs(req, res));
|
|
129
|
+
app.get('/health', (req, res) => this._handleHealth(req, res));
|
|
130
|
+
|
|
131
|
+
// Start listening
|
|
132
|
+
const server = createServer(app);
|
|
133
|
+
this._server = server;
|
|
134
|
+
|
|
135
|
+
return new Promise((resolve, reject) => {
|
|
136
|
+
server.once('error', reject);
|
|
137
|
+
server.listen(this._config.WEB_PORT, this._config.WEB_BIND, () => {
|
|
138
|
+
server.removeListener('error', reject);
|
|
139
|
+
this._logger.info(
|
|
140
|
+
'web',
|
|
141
|
+
`Admin server listening on http://${this._config.WEB_BIND}:${this._config.WEB_PORT}`,
|
|
142
|
+
);
|
|
143
|
+
resolve(server);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Gracefully close the HTTP server.
|
|
150
|
+
*/
|
|
151
|
+
async stop() {
|
|
152
|
+
if (!this._server) return;
|
|
153
|
+
return new Promise((resolve, reject) => {
|
|
154
|
+
this._server.close((err) => {
|
|
155
|
+
this._server = null;
|
|
156
|
+
if (err) {
|
|
157
|
+
reject(err);
|
|
158
|
+
} else {
|
|
159
|
+
this._logger.info('web', 'Admin server stopped');
|
|
160
|
+
resolve();
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// -----------------------------------------------------------------------
|
|
167
|
+
// Route handlers
|
|
168
|
+
// -----------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
async _handleDashboard(_req, res) {
|
|
171
|
+
const data = {
|
|
172
|
+
uptime: process.uptime(),
|
|
173
|
+
memory: process.memoryUsage(),
|
|
174
|
+
messageCount: 0,
|
|
175
|
+
recentMessages: [],
|
|
176
|
+
activeSessionId: null,
|
|
177
|
+
embeddingStatus: this._config.EMBEDDING_PROVIDER ? 'enabled' : 'disabled',
|
|
178
|
+
recentErrors: [],
|
|
179
|
+
dbAvailable: true,
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
const countRes = await this._db.query(
|
|
184
|
+
'SELECT COUNT(*)::int AS count FROM conversation_messages',
|
|
185
|
+
);
|
|
186
|
+
data.messageCount = countRes.rows[0]?.count ?? 0;
|
|
187
|
+
} catch {
|
|
188
|
+
data.dbAvailable = false;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (data.dbAvailable) {
|
|
192
|
+
try {
|
|
193
|
+
const recent = await this._db.query(
|
|
194
|
+
`SELECT id, created_at, session_id, role,
|
|
195
|
+
LEFT(content, 200) AS content
|
|
196
|
+
FROM conversation_messages
|
|
197
|
+
ORDER BY created_at DESC
|
|
198
|
+
LIMIT 10`,
|
|
199
|
+
);
|
|
200
|
+
data.recentMessages = recent.rows;
|
|
201
|
+
} catch { /* query failed, leave empty */ }
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
const session = await this._db.query(
|
|
205
|
+
`SELECT session_id FROM conversation_messages
|
|
206
|
+
WHERE session_id IS NOT NULL
|
|
207
|
+
ORDER BY created_at DESC LIMIT 1`,
|
|
208
|
+
);
|
|
209
|
+
data.activeSessionId = session.rows[0]?.session_id ?? null;
|
|
210
|
+
} catch { /* leave null */ }
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
const errors = await this._db.query(
|
|
214
|
+
`SELECT id, created_at, level, source, content
|
|
215
|
+
FROM system_logs
|
|
216
|
+
WHERE level = 'error'
|
|
217
|
+
ORDER BY created_at DESC
|
|
218
|
+
LIMIT 5`,
|
|
219
|
+
);
|
|
220
|
+
data.recentErrors = errors.rows;
|
|
221
|
+
} catch { /* leave empty */ }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
res.send(dashboardHTML(data));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async _handleSettings(req, res) {
|
|
228
|
+
const message = req.query.saved === '1'
|
|
229
|
+
? { type: 'success', text: 'Settings saved. Restart the service for changes to take effect.' }
|
|
230
|
+
: null;
|
|
231
|
+
|
|
232
|
+
res.send(settingsHTML(this._config, message));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async _handleSaveSettings(req, res) {
|
|
236
|
+
try {
|
|
237
|
+
const body = req.body;
|
|
238
|
+
const values = {};
|
|
239
|
+
|
|
240
|
+
for (const section of SETTINGS_FIELDS) {
|
|
241
|
+
for (const field of section.fields) {
|
|
242
|
+
const formValue = body[field.key];
|
|
243
|
+
if (formValue === undefined) continue;
|
|
244
|
+
|
|
245
|
+
// For secret fields, empty submission means "keep existing value"
|
|
246
|
+
if (field.secret && formValue === '') continue;
|
|
247
|
+
|
|
248
|
+
values[field.key] = formValue;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
this._writeEnvFile(values);
|
|
253
|
+
this._logger.info('web', 'Settings updated via web admin');
|
|
254
|
+
res.redirect('/settings?saved=1');
|
|
255
|
+
} catch (err) {
|
|
256
|
+
this._logger.error('web', `Failed to save settings: ${err.message}`);
|
|
257
|
+
res.status(500).send(
|
|
258
|
+
layoutHTML('Error', `<h1>Failed to Save Settings</h1><p>${esc(err.message)}</p>`),
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async _handleLogs(req, res) {
|
|
264
|
+
const level = req.query.level || '';
|
|
265
|
+
let logs = [];
|
|
266
|
+
|
|
267
|
+
try {
|
|
268
|
+
const validLevels = ['debug', 'info', 'warn', 'error'];
|
|
269
|
+
let sql = 'SELECT id, created_at, level, source, content FROM system_logs';
|
|
270
|
+
const params = [];
|
|
271
|
+
|
|
272
|
+
if (level && validLevels.includes(level)) {
|
|
273
|
+
sql += ' WHERE level = $1';
|
|
274
|
+
params.push(level);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
sql += ' ORDER BY created_at DESC LIMIT 100';
|
|
278
|
+
const result = await this._db.query(sql, params);
|
|
279
|
+
logs = result.rows;
|
|
280
|
+
} catch { /* database unavailable, show empty */ }
|
|
281
|
+
|
|
282
|
+
res.send(logsHTML(logs, level));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async _handleHealth(_req, res) {
|
|
286
|
+
const health = {
|
|
287
|
+
status: 'ok',
|
|
288
|
+
components: {
|
|
289
|
+
database: 'ok',
|
|
290
|
+
telegram: 'unknown',
|
|
291
|
+
claude: 'unknown',
|
|
292
|
+
},
|
|
293
|
+
uptime: process.uptime(),
|
|
294
|
+
memory: process.memoryUsage(),
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
try {
|
|
298
|
+
await this._db.query('SELECT 1');
|
|
299
|
+
} catch {
|
|
300
|
+
health.components.database = 'error';
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Derive overall status from component states
|
|
304
|
+
const states = Object.values(health.components);
|
|
305
|
+
if (states.includes('error')) {
|
|
306
|
+
health.status = states.every((s) => s === 'error') ? 'error' : 'degraded';
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const httpStatus = health.status === 'error' ? 503 : 200;
|
|
310
|
+
res.status(httpStatus).json(health);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// -----------------------------------------------------------------------
|
|
314
|
+
// .env file helpers
|
|
315
|
+
// -----------------------------------------------------------------------
|
|
316
|
+
|
|
317
|
+
_readEnvFile() {
|
|
318
|
+
try {
|
|
319
|
+
return fs.readFileSync(this._envPath, 'utf-8');
|
|
320
|
+
} catch {
|
|
321
|
+
return '';
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
_writeEnvFile(values) {
|
|
326
|
+
const content = this._readEnvFile();
|
|
327
|
+
const lines = content.split('\n');
|
|
328
|
+
const written = new Set();
|
|
329
|
+
|
|
330
|
+
// Pass 1: update existing key=value lines in place
|
|
331
|
+
const updated = lines.map((line) => {
|
|
332
|
+
const match = line.match(/^([A-Z_][A-Z0-9_]*)\s*=/);
|
|
333
|
+
if (match && match[1] in values) {
|
|
334
|
+
written.add(match[1]);
|
|
335
|
+
return fmtEnvLine(match[1], values[match[1]]);
|
|
336
|
+
}
|
|
337
|
+
return line;
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// Pass 2: append any new keys that were not already in the file
|
|
341
|
+
for (const [key, value] of Object.entries(values)) {
|
|
342
|
+
if (!written.has(key)) {
|
|
343
|
+
updated.push(fmtEnvLine(key, value));
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
let result = updated.join('\n');
|
|
348
|
+
if (!result.endsWith('\n')) result += '\n';
|
|
349
|
+
|
|
350
|
+
fs.writeFileSync(this._envPath, result, 'utf-8');
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
// Utility helpers
|
|
356
|
+
// ---------------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
/** HTML-escape a string to prevent XSS. */
|
|
359
|
+
function esc(str) {
|
|
360
|
+
return String(str)
|
|
361
|
+
.replace(/&/g, '&')
|
|
362
|
+
.replace(/</g, '<')
|
|
363
|
+
.replace(/>/g, '>')
|
|
364
|
+
.replace(/"/g, '"')
|
|
365
|
+
.replace(/'/g, ''');
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Format a .env line, quoting the value when needed. */
|
|
369
|
+
function fmtEnvLine(key, value) {
|
|
370
|
+
if (value === '' || value === undefined || value === null) {
|
|
371
|
+
return `${key}=`;
|
|
372
|
+
}
|
|
373
|
+
// Quote values containing whitespace, quotes, hashes, or dollar signs
|
|
374
|
+
if (/[\s"'#$\\]/.test(value)) {
|
|
375
|
+
return `${key}="${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
376
|
+
}
|
|
377
|
+
return `${key}=${value}`;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Format seconds into a human-readable uptime string. */
|
|
381
|
+
function fmtUptime(seconds) {
|
|
382
|
+
const d = Math.floor(seconds / 86400);
|
|
383
|
+
const h = Math.floor((seconds % 86400) / 3600);
|
|
384
|
+
const m = Math.floor((seconds % 3600) / 60);
|
|
385
|
+
const s = Math.floor(seconds % 60);
|
|
386
|
+
const parts = [];
|
|
387
|
+
if (d > 0) parts.push(`${d}d`);
|
|
388
|
+
parts.push(`${h}h`, `${m}m`, `${s}s`);
|
|
389
|
+
return parts.join(' ');
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Format bytes into a human-readable size string. */
|
|
393
|
+
function fmtBytes(bytes) {
|
|
394
|
+
if (bytes === 0) return '0 B';
|
|
395
|
+
const units = ['B', 'KB', 'MB', 'GB'];
|
|
396
|
+
let idx = 0;
|
|
397
|
+
let value = bytes;
|
|
398
|
+
while (value >= 1024 && idx < units.length - 1) {
|
|
399
|
+
value /= 1024;
|
|
400
|
+
idx++;
|
|
401
|
+
}
|
|
402
|
+
return `${value.toFixed(1)} ${units[idx]}`;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Format a timestamp for display (UTC, no milliseconds). */
|
|
406
|
+
function fmtTime(ts) {
|
|
407
|
+
if (!ts) return '';
|
|
408
|
+
return new Date(ts).toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, ' UTC');
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Mask a secret value for display, showing only partial bookends. */
|
|
412
|
+
function maskValue(value) {
|
|
413
|
+
if (!value) return '(not set)';
|
|
414
|
+
const s = String(value);
|
|
415
|
+
if (s.length <= 8) return '*'.repeat(s.length);
|
|
416
|
+
return s.slice(0, 4) + '*'.repeat(Math.min(s.length - 8, 20)) + s.slice(-4);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ---------------------------------------------------------------------------
|
|
420
|
+
// HTML template functions
|
|
421
|
+
// ---------------------------------------------------------------------------
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Base page layout. Wraps content in a full HTML document with navigation,
|
|
425
|
+
* dark-theme CSS, and responsive structure.
|
|
426
|
+
*/
|
|
427
|
+
function layoutHTML(title, content) {
|
|
428
|
+
return `<!DOCTYPE html>
|
|
429
|
+
<html lang="en">
|
|
430
|
+
<head>
|
|
431
|
+
<meta charset="UTF-8">
|
|
432
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
433
|
+
<title>${esc(title)} - 2ndbrain</title>
|
|
434
|
+
<style>
|
|
435
|
+
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
|
436
|
+
|
|
437
|
+
body {
|
|
438
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, sans-serif;
|
|
439
|
+
background: #0d1117;
|
|
440
|
+
color: #c9d1d9;
|
|
441
|
+
line-height: 1.6;
|
|
442
|
+
min-height: 100vh;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/* --- Navigation --- */
|
|
446
|
+
nav {
|
|
447
|
+
background: #161b22;
|
|
448
|
+
border-bottom: 1px solid #30363d;
|
|
449
|
+
padding: 0.75rem 1.5rem;
|
|
450
|
+
display: flex;
|
|
451
|
+
align-items: center;
|
|
452
|
+
gap: 1.5rem;
|
|
453
|
+
}
|
|
454
|
+
nav .brand {
|
|
455
|
+
font-weight: 700;
|
|
456
|
+
font-size: 1.1rem;
|
|
457
|
+
color: #f0f6fc;
|
|
458
|
+
text-decoration: none;
|
|
459
|
+
}
|
|
460
|
+
nav a { color: #58a6ff; text-decoration: none; font-size: 0.9rem; }
|
|
461
|
+
nav a:hover { text-decoration: underline; }
|
|
462
|
+
|
|
463
|
+
/* --- Layout --- */
|
|
464
|
+
.container { max-width: 1100px; margin: 0 auto; padding: 1.5rem; }
|
|
465
|
+
h1 { color: #f0f6fc; margin-bottom: 1rem; font-size: 1.5rem; }
|
|
466
|
+
h2 { color: #f0f6fc; margin: 1.5rem 0 0.75rem; font-size: 1.2rem; }
|
|
467
|
+
|
|
468
|
+
/* --- Cards & grid --- */
|
|
469
|
+
.card {
|
|
470
|
+
background: #161b22;
|
|
471
|
+
border: 1px solid #30363d;
|
|
472
|
+
border-radius: 6px;
|
|
473
|
+
padding: 1rem 1.25rem;
|
|
474
|
+
margin-bottom: 1rem;
|
|
475
|
+
}
|
|
476
|
+
.grid {
|
|
477
|
+
display: grid;
|
|
478
|
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
479
|
+
gap: 1rem;
|
|
480
|
+
margin-bottom: 1.5rem;
|
|
481
|
+
}
|
|
482
|
+
.stat-label {
|
|
483
|
+
font-size: 0.78rem;
|
|
484
|
+
color: #8b949e;
|
|
485
|
+
text-transform: uppercase;
|
|
486
|
+
letter-spacing: 0.05em;
|
|
487
|
+
}
|
|
488
|
+
.stat-value {
|
|
489
|
+
font-size: 1.35rem;
|
|
490
|
+
font-weight: 600;
|
|
491
|
+
color: #f0f6fc;
|
|
492
|
+
word-break: break-all;
|
|
493
|
+
}
|
|
494
|
+
.stat-value.small { font-size: 0.9rem; }
|
|
495
|
+
|
|
496
|
+
/* --- Table --- */
|
|
497
|
+
table { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
|
|
498
|
+
th {
|
|
499
|
+
text-align: left;
|
|
500
|
+
padding: 0.5rem 0.75rem;
|
|
501
|
+
border-bottom: 2px solid #30363d;
|
|
502
|
+
color: #8b949e;
|
|
503
|
+
font-weight: 600;
|
|
504
|
+
font-size: 0.78rem;
|
|
505
|
+
text-transform: uppercase;
|
|
506
|
+
}
|
|
507
|
+
td {
|
|
508
|
+
padding: 0.5rem 0.75rem;
|
|
509
|
+
border-bottom: 1px solid #21262d;
|
|
510
|
+
vertical-align: top;
|
|
511
|
+
}
|
|
512
|
+
tr:hover td { background: rgba(88,166,255,0.04); }
|
|
513
|
+
|
|
514
|
+
/* --- Role colours --- */
|
|
515
|
+
.role-user { color: #58a6ff; }
|
|
516
|
+
.role-assistant { color: #3fb950; }
|
|
517
|
+
.role-system { color: #d29922; }
|
|
518
|
+
.role-summary { color: #8b949e; }
|
|
519
|
+
|
|
520
|
+
/* --- Level colours --- */
|
|
521
|
+
.level-debug { color: #8b949e; }
|
|
522
|
+
.level-info { color: #58a6ff; }
|
|
523
|
+
.level-warn { color: #d29922; }
|
|
524
|
+
.level-error { color: #f85149; }
|
|
525
|
+
|
|
526
|
+
/* --- Badges --- */
|
|
527
|
+
.badge {
|
|
528
|
+
display: inline-block;
|
|
529
|
+
padding: 0.15rem 0.5rem;
|
|
530
|
+
border-radius: 3px;
|
|
531
|
+
font-size: 0.75rem;
|
|
532
|
+
font-weight: 600;
|
|
533
|
+
text-transform: uppercase;
|
|
534
|
+
}
|
|
535
|
+
.badge-ok { background: rgba(63,185,80,0.15); color: #3fb950; }
|
|
536
|
+
.badge-error { background: rgba(248,81,73,0.15); color: #f85149; }
|
|
537
|
+
.badge-warn { background: rgba(210,153,34,0.15); color: #d29922; }
|
|
538
|
+
.badge-disabled { background: rgba(139,148,158,0.15);color: #8b949e; }
|
|
539
|
+
|
|
540
|
+
/* --- Settings form --- */
|
|
541
|
+
.form-section { margin-bottom: 1.5rem; }
|
|
542
|
+
.form-section h3 {
|
|
543
|
+
color: #f0f6fc;
|
|
544
|
+
font-size: 1rem;
|
|
545
|
+
margin-bottom: 0.75rem;
|
|
546
|
+
padding-bottom: 0.5rem;
|
|
547
|
+
border-bottom: 1px solid #21262d;
|
|
548
|
+
}
|
|
549
|
+
.form-group {
|
|
550
|
+
margin-bottom: 0.75rem;
|
|
551
|
+
display: grid;
|
|
552
|
+
grid-template-columns: 220px 1fr;
|
|
553
|
+
align-items: start;
|
|
554
|
+
gap: 0.75rem;
|
|
555
|
+
}
|
|
556
|
+
label {
|
|
557
|
+
font-size: 0.88rem;
|
|
558
|
+
color: #c9d1d9;
|
|
559
|
+
padding-top: 0.4rem;
|
|
560
|
+
}
|
|
561
|
+
label .required { color: #f85149; }
|
|
562
|
+
label .hint {
|
|
563
|
+
display: block;
|
|
564
|
+
font-size: 0.74rem;
|
|
565
|
+
color: #8b949e;
|
|
566
|
+
margin-top: 0.15rem;
|
|
567
|
+
}
|
|
568
|
+
input[type="text"],
|
|
569
|
+
input[type="password"] {
|
|
570
|
+
width: 100%;
|
|
571
|
+
padding: 0.4rem 0.6rem;
|
|
572
|
+
background: #0d1117;
|
|
573
|
+
border: 1px solid #30363d;
|
|
574
|
+
border-radius: 4px;
|
|
575
|
+
color: #c9d1d9;
|
|
576
|
+
font-size: 0.88rem;
|
|
577
|
+
font-family: inherit;
|
|
578
|
+
}
|
|
579
|
+
input:focus {
|
|
580
|
+
outline: none;
|
|
581
|
+
border-color: #58a6ff;
|
|
582
|
+
box-shadow: 0 0 0 2px rgba(88,166,255,0.2);
|
|
583
|
+
}
|
|
584
|
+
.secret-current {
|
|
585
|
+
font-size: 0.78rem;
|
|
586
|
+
color: #8b949e;
|
|
587
|
+
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
|
588
|
+
margin-bottom: 0.25rem;
|
|
589
|
+
}
|
|
590
|
+
button[type="submit"] {
|
|
591
|
+
background: #238636;
|
|
592
|
+
color: #fff;
|
|
593
|
+
border: none;
|
|
594
|
+
padding: 0.6rem 1.5rem;
|
|
595
|
+
border-radius: 6px;
|
|
596
|
+
font-size: 0.9rem;
|
|
597
|
+
font-weight: 600;
|
|
598
|
+
cursor: pointer;
|
|
599
|
+
margin-top: 1rem;
|
|
600
|
+
}
|
|
601
|
+
button[type="submit"]:hover { background: #2ea043; }
|
|
602
|
+
|
|
603
|
+
/* --- Alerts --- */
|
|
604
|
+
.alert {
|
|
605
|
+
padding: 0.75rem 1rem;
|
|
606
|
+
border-radius: 6px;
|
|
607
|
+
margin-bottom: 1rem;
|
|
608
|
+
font-size: 0.88rem;
|
|
609
|
+
}
|
|
610
|
+
.alert-success {
|
|
611
|
+
background: rgba(63,185,80,0.1);
|
|
612
|
+
border: 1px solid rgba(63,185,80,0.3);
|
|
613
|
+
color: #3fb950;
|
|
614
|
+
}
|
|
615
|
+
.alert-error {
|
|
616
|
+
background: rgba(248,81,73,0.1);
|
|
617
|
+
border: 1px solid rgba(248,81,73,0.3);
|
|
618
|
+
color: #f85149;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/* --- Log viewer --- */
|
|
622
|
+
.log-filters {
|
|
623
|
+
display: flex;
|
|
624
|
+
gap: 0.5rem;
|
|
625
|
+
flex-wrap: wrap;
|
|
626
|
+
margin-bottom: 1rem;
|
|
627
|
+
}
|
|
628
|
+
.log-filters a {
|
|
629
|
+
padding: 0.3rem 0.75rem;
|
|
630
|
+
border-radius: 4px;
|
|
631
|
+
font-size: 0.84rem;
|
|
632
|
+
text-decoration: none;
|
|
633
|
+
color: #c9d1d9;
|
|
634
|
+
background: #21262d;
|
|
635
|
+
border: 1px solid #30363d;
|
|
636
|
+
}
|
|
637
|
+
.log-filters a.active {
|
|
638
|
+
background: #388bfd;
|
|
639
|
+
color: #fff;
|
|
640
|
+
border-color: #388bfd;
|
|
641
|
+
}
|
|
642
|
+
.log-filters a:hover { border-color: #58a6ff; }
|
|
643
|
+
.log-entry {
|
|
644
|
+
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
|
645
|
+
font-size: 0.8rem;
|
|
646
|
+
padding: 0.35rem 0;
|
|
647
|
+
border-bottom: 1px solid #21262d;
|
|
648
|
+
display: grid;
|
|
649
|
+
grid-template-columns: 175px 52px 110px 1fr;
|
|
650
|
+
gap: 0.5rem;
|
|
651
|
+
align-items: start;
|
|
652
|
+
}
|
|
653
|
+
.log-time { color: #8b949e; }
|
|
654
|
+
.log-source { color: #d2a8ff; }
|
|
655
|
+
.log-content { color: #c9d1d9; word-break: break-word; white-space: pre-wrap; }
|
|
656
|
+
|
|
657
|
+
/* --- Misc --- */
|
|
658
|
+
.muted { color: #8b949e; }
|
|
659
|
+
.empty { color: #8b949e; text-align: center; padding: 2rem; }
|
|
660
|
+
.db-warn {
|
|
661
|
+
background: rgba(210,153,34,0.1);
|
|
662
|
+
border: 1px solid rgba(210,153,34,0.25);
|
|
663
|
+
color: #d29922;
|
|
664
|
+
padding: 0.6rem 1rem;
|
|
665
|
+
border-radius: 6px;
|
|
666
|
+
margin-bottom: 1rem;
|
|
667
|
+
font-size: 0.88rem;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
@media (max-width: 768px) {
|
|
671
|
+
.form-group { grid-template-columns: 1fr; gap: 0.25rem; }
|
|
672
|
+
.log-entry { grid-template-columns: 1fr; gap: 0.15rem; }
|
|
673
|
+
.grid { grid-template-columns: 1fr 1fr; }
|
|
674
|
+
}
|
|
675
|
+
</style>
|
|
676
|
+
</head>
|
|
677
|
+
<body>
|
|
678
|
+
<nav>
|
|
679
|
+
<a class="brand" href="/">2ndbrain</a>
|
|
680
|
+
<a href="/">Dashboard</a>
|
|
681
|
+
<a href="/settings">Settings</a>
|
|
682
|
+
<a href="/logs">Logs</a>
|
|
683
|
+
</nav>
|
|
684
|
+
<div class="container">
|
|
685
|
+
${content}
|
|
686
|
+
</div>
|
|
687
|
+
</body>
|
|
688
|
+
</html>`;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Dashboard page showing system status, recent messages, and errors.
|
|
693
|
+
*/
|
|
694
|
+
function dashboardHTML(data) {
|
|
695
|
+
const mem = data.memory;
|
|
696
|
+
|
|
697
|
+
// -- Stats grid ----------------------------------------------------------
|
|
698
|
+
const stats = `
|
|
699
|
+
<div class="grid">
|
|
700
|
+
<div class="card">
|
|
701
|
+
<div class="stat-label">Uptime</div>
|
|
702
|
+
<div class="stat-value">${esc(fmtUptime(data.uptime))}</div>
|
|
703
|
+
</div>
|
|
704
|
+
<div class="card">
|
|
705
|
+
<div class="stat-label">Memory (RSS)</div>
|
|
706
|
+
<div class="stat-value">${fmtBytes(mem.rss)}</div>
|
|
707
|
+
</div>
|
|
708
|
+
<div class="card">
|
|
709
|
+
<div class="stat-label">Heap Used</div>
|
|
710
|
+
<div class="stat-value">${fmtBytes(mem.heapUsed)} / ${fmtBytes(mem.heapTotal)}</div>
|
|
711
|
+
</div>
|
|
712
|
+
<div class="card">
|
|
713
|
+
<div class="stat-label">Messages</div>
|
|
714
|
+
<div class="stat-value">${data.messageCount}</div>
|
|
715
|
+
</div>
|
|
716
|
+
<div class="card">
|
|
717
|
+
<div class="stat-label">Active Session</div>
|
|
718
|
+
<div class="stat-value small">${data.activeSessionId ? esc(data.activeSessionId) : '<span class="muted">none</span>'}</div>
|
|
719
|
+
</div>
|
|
720
|
+
<div class="card">
|
|
721
|
+
<div class="stat-label">Embeddings</div>
|
|
722
|
+
<div class="stat-value">
|
|
723
|
+
<span class="badge ${data.embeddingStatus === 'enabled' ? 'badge-ok' : 'badge-disabled'}">${data.embeddingStatus}</span>
|
|
724
|
+
</div>
|
|
725
|
+
</div>
|
|
726
|
+
</div>`;
|
|
727
|
+
|
|
728
|
+
// -- DB warning ----------------------------------------------------------
|
|
729
|
+
const dbWarn = data.dbAvailable
|
|
730
|
+
? ''
|
|
731
|
+
: '<div class="db-warn">Database is unavailable. Dashboard data may be incomplete.</div>';
|
|
732
|
+
|
|
733
|
+
// -- Recent messages -----------------------------------------------------
|
|
734
|
+
let messagesSection;
|
|
735
|
+
if (data.recentMessages.length > 0) {
|
|
736
|
+
const rows = data.recentMessages.map((msg) => `
|
|
737
|
+
<tr>
|
|
738
|
+
<td style="white-space:nowrap;">${fmtTime(msg.created_at)}</td>
|
|
739
|
+
<td><span class="role-${esc(msg.role)}">${esc(msg.role)}</span></td>
|
|
740
|
+
<td>${esc(msg.content || '')}</td>
|
|
741
|
+
</tr>`).join('');
|
|
742
|
+
|
|
743
|
+
messagesSection = `
|
|
744
|
+
<h2>Recent Messages</h2>
|
|
745
|
+
<div class="card" style="overflow-x:auto;">
|
|
746
|
+
<table>
|
|
747
|
+
<thead><tr><th>Time</th><th>Role</th><th>Content</th></tr></thead>
|
|
748
|
+
<tbody>${rows}</tbody>
|
|
749
|
+
</table>
|
|
750
|
+
</div>`;
|
|
751
|
+
} else {
|
|
752
|
+
messagesSection = `
|
|
753
|
+
<h2>Recent Messages</h2>
|
|
754
|
+
<div class="card"><p class="empty">No messages yet.</p></div>`;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// -- Recent errors -------------------------------------------------------
|
|
758
|
+
let errorsSection = '';
|
|
759
|
+
if (data.recentErrors.length > 0) {
|
|
760
|
+
const rows = data.recentErrors.map((log) => `
|
|
761
|
+
<tr>
|
|
762
|
+
<td style="white-space:nowrap;">${fmtTime(log.created_at)}</td>
|
|
763
|
+
<td>${esc(log.source || '')}</td>
|
|
764
|
+
<td>${esc(log.content)}</td>
|
|
765
|
+
</tr>`).join('');
|
|
766
|
+
|
|
767
|
+
errorsSection = `
|
|
768
|
+
<h2>Recent Errors</h2>
|
|
769
|
+
<div class="card" style="overflow-x:auto;">
|
|
770
|
+
<table>
|
|
771
|
+
<thead><tr><th>Time</th><th>Source</th><th>Content</th></tr></thead>
|
|
772
|
+
<tbody>${rows}</tbody>
|
|
773
|
+
</table>
|
|
774
|
+
</div>`;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
return layoutHTML('Dashboard', `
|
|
778
|
+
<h1>Dashboard</h1>
|
|
779
|
+
${dbWarn}
|
|
780
|
+
${stats}
|
|
781
|
+
${messagesSection}
|
|
782
|
+
${errorsSection}
|
|
783
|
+
`);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Settings page with a form for every configurable env var.
|
|
788
|
+
* Secret fields are shown as password inputs with a masked current-value hint.
|
|
789
|
+
*/
|
|
790
|
+
function settingsHTML(config, message) {
|
|
791
|
+
const alert = message
|
|
792
|
+
? `<div class="alert alert-${esc(message.type)}">${esc(message.text)}</div>`
|
|
793
|
+
: '';
|
|
794
|
+
|
|
795
|
+
const sections = SETTINGS_FIELDS.map((section) => {
|
|
796
|
+
const fields = section.fields.map((field) => {
|
|
797
|
+
const value = config[field.key] ?? '';
|
|
798
|
+
let inputArea;
|
|
799
|
+
|
|
800
|
+
if (field.secret) {
|
|
801
|
+
const status = value
|
|
802
|
+
? `Current: ${esc(maskValue(String(value)))}`
|
|
803
|
+
: '(not set)';
|
|
804
|
+
inputArea = `
|
|
805
|
+
<div>
|
|
806
|
+
<div class="secret-current">${status}</div>
|
|
807
|
+
<input type="password" name="${esc(field.key)}"
|
|
808
|
+
value="" placeholder="Enter new value to change"
|
|
809
|
+
autocomplete="off" />
|
|
810
|
+
</div>`;
|
|
811
|
+
} else {
|
|
812
|
+
inputArea = `<input type="text" name="${esc(field.key)}"
|
|
813
|
+
value="${esc(String(value))}" />`;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
return `
|
|
817
|
+
<div class="form-group">
|
|
818
|
+
<label>
|
|
819
|
+
${esc(field.label)}${field.required ? ' <span class="required">*</span>' : ''}
|
|
820
|
+
${field.hint ? `<span class="hint">${esc(field.hint)}</span>` : ''}
|
|
821
|
+
</label>
|
|
822
|
+
${inputArea}
|
|
823
|
+
</div>`;
|
|
824
|
+
}).join('');
|
|
825
|
+
|
|
826
|
+
return `
|
|
827
|
+
<div class="form-section">
|
|
828
|
+
<h3>${esc(section.section)}</h3>
|
|
829
|
+
${fields}
|
|
830
|
+
</div>`;
|
|
831
|
+
}).join('');
|
|
832
|
+
|
|
833
|
+
return layoutHTML('Settings', `
|
|
834
|
+
<h1>Settings</h1>
|
|
835
|
+
${alert}
|
|
836
|
+
<form method="POST" action="/settings">
|
|
837
|
+
${sections}
|
|
838
|
+
<button type="submit">Save Settings</button>
|
|
839
|
+
</form>
|
|
840
|
+
`);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* Log viewer page with level filtering.
|
|
845
|
+
*/
|
|
846
|
+
function logsHTML(logs, activeLevel) {
|
|
847
|
+
const levels = ['', 'debug', 'info', 'warn', 'error'];
|
|
848
|
+
const filters = levels.map((lvl) => {
|
|
849
|
+
const label = lvl ? lvl.charAt(0).toUpperCase() + lvl.slice(1) : 'All';
|
|
850
|
+
const href = lvl ? `/logs?level=${lvl}` : '/logs';
|
|
851
|
+
const cls = activeLevel === lvl ? ' active' : '';
|
|
852
|
+
return `<a href="${href}" class="${cls}">${label}</a>`;
|
|
853
|
+
}).join('');
|
|
854
|
+
|
|
855
|
+
let body;
|
|
856
|
+
if (logs.length > 0) {
|
|
857
|
+
body = logs.map((log) => `
|
|
858
|
+
<div class="log-entry">
|
|
859
|
+
<span class="log-time">${fmtTime(log.created_at)}</span>
|
|
860
|
+
<span class="level-${esc(log.level)}">${esc(log.level)}</span>
|
|
861
|
+
<span class="log-source">${esc(log.source || '')}</span>
|
|
862
|
+
<span class="log-content">${esc(log.content)}</span>
|
|
863
|
+
</div>`).join('');
|
|
864
|
+
} else {
|
|
865
|
+
body = '<p class="empty">No log entries found.</p>';
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
return layoutHTML('Logs', `
|
|
869
|
+
<h1>Logs</h1>
|
|
870
|
+
<div class="log-filters">${filters}</div>
|
|
871
|
+
<div class="card">${body}</div>
|
|
872
|
+
`);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// ---------------------------------------------------------------------------
|
|
876
|
+
// Export
|
|
877
|
+
// ---------------------------------------------------------------------------
|
|
878
|
+
|
|
879
|
+
export { WebServer };
|
|
880
|
+
export default WebServer;
|