ai-exodus 2.0.0
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/README.md +239 -0
- package/bin/cli.js +655 -0
- package/bin/regenerate.js +95 -0
- package/package.json +43 -0
- package/portal/exodus_mcp.py +300 -0
- package/portal/schema.sql +158 -0
- package/portal/worker.js +2410 -0
- package/prompts/index.js +317 -0
- package/src/analyzer.js +676 -0
- package/src/checkpoint.js +109 -0
- package/src/claude.js +147 -0
- package/src/config.js +40 -0
- package/src/deploy.js +193 -0
- package/src/generator.js +822 -0
- package/src/import.js +185 -0
- package/src/parser.js +445 -0
- package/src/spinner.js +55 -0
package/portal/worker.js
ADDED
|
@@ -0,0 +1,2410 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI Exodus Portal — Cloudflare Worker
|
|
3
|
+
* Personal chat archive + analysis dashboard
|
|
4
|
+
* Single-file architecture: API + embedded HTML/CSS/JS
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export default {
|
|
8
|
+
async fetch(request, env) {
|
|
9
|
+
const url = new URL(request.url);
|
|
10
|
+
const path = url.pathname;
|
|
11
|
+
const method = request.method;
|
|
12
|
+
|
|
13
|
+
// ── CORS preflight ──
|
|
14
|
+
if (method === 'OPTIONS') return corsResponse();
|
|
15
|
+
|
|
16
|
+
// ── Auth check (cookie-based, same as Hearth/Fieldwork) ──
|
|
17
|
+
const isAPI = path.startsWith('/api/');
|
|
18
|
+
const isMCP = path.startsWith('/mcp/');
|
|
19
|
+
const isSetup = path === '/setup';
|
|
20
|
+
const isLogin = path === '/login';
|
|
21
|
+
const isAsset = path.startsWith('/assets/');
|
|
22
|
+
|
|
23
|
+
// MCP auth is via secret in path
|
|
24
|
+
if (isMCP) return handleMCP(request, env, path);
|
|
25
|
+
|
|
26
|
+
// Check if password is set
|
|
27
|
+
const pw = await env.DB.prepare('SELECT value FROM settings WHERE key = ?').bind('password').first();
|
|
28
|
+
if (!pw && !isSetup) {
|
|
29
|
+
if (isAPI) return json({ error: 'Setup required' }, 401);
|
|
30
|
+
return htmlResponse(setupPage());
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Setup endpoint
|
|
34
|
+
if (isSetup && method === 'POST') return handleSetup(request, env);
|
|
35
|
+
|
|
36
|
+
// Login endpoint
|
|
37
|
+
if (isLogin && method === 'POST') return handleLogin(request, env, pw?.value);
|
|
38
|
+
|
|
39
|
+
// Auth check for everything else
|
|
40
|
+
if (pw) {
|
|
41
|
+
const cookie = parseCookies(request.headers.get('Cookie') || '');
|
|
42
|
+
const session = cookie['exodus_session'];
|
|
43
|
+
if (session !== pw.value) {
|
|
44
|
+
if (isAPI) return json({ error: 'Unauthorized' }, 401);
|
|
45
|
+
return htmlResponse(loginPage());
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── API Routes ──
|
|
50
|
+
if (isAPI) {
|
|
51
|
+
try {
|
|
52
|
+
// Conversations
|
|
53
|
+
if (path === '/api/conversations' && method === 'GET') return getConversations(request, env);
|
|
54
|
+
if (path === '/api/conversations/search' && method === 'GET') return searchConversations(request, env);
|
|
55
|
+
if (path.match(/^\/api\/conversations\/[^/]+$/) && method === 'GET') return getConversation(request, env, path.split('/')[3]);
|
|
56
|
+
if (path.match(/^\/api\/conversations\/[^/]+$/) && method === 'DELETE') return deleteConversation(env, path.split('/')[3]);
|
|
57
|
+
|
|
58
|
+
// Messages
|
|
59
|
+
if (path.match(/^\/api\/conversations\/[^/]+\/messages$/) && method === 'GET') return getMessages(request, env, path.split('/')[3]);
|
|
60
|
+
|
|
61
|
+
// Skills (CRUD)
|
|
62
|
+
if (path === '/api/skills' && method === 'GET') return getSkills(env);
|
|
63
|
+
if (path === '/api/skills' && method === 'POST') return createSkill(request, env);
|
|
64
|
+
if (path.match(/^\/api\/skills\/\d+$/) && method === 'PUT') return updateSkill(request, env, path.split('/')[3]);
|
|
65
|
+
if (path.match(/^\/api\/skills\/\d+$/) && method === 'DELETE') return deleteSkill(env, path.split('/')[3]);
|
|
66
|
+
|
|
67
|
+
// Skill categories (CRUD)
|
|
68
|
+
if (path === '/api/skill-categories' && method === 'GET') return getSkillCategories(env);
|
|
69
|
+
if (path === '/api/skill-categories' && method === 'POST') return createSkillCategory(request, env);
|
|
70
|
+
if (path.match(/^\/api\/skill-categories\/\d+$/) && method === 'PUT') return updateSkillCategory(request, env, path.split('/')[3]);
|
|
71
|
+
if (path.match(/^\/api\/skill-categories\/\d+$/) && method === 'DELETE') return deleteSkillCategory(env, path.split('/')[3]);
|
|
72
|
+
|
|
73
|
+
// Memories (CRUD)
|
|
74
|
+
if (path === '/api/memories' && method === 'GET') return getMemories(request, env);
|
|
75
|
+
if (path === '/api/memories' && method === 'POST') return createMemory(request, env);
|
|
76
|
+
if (path.match(/^\/api\/memories\/\d+$/) && method === 'PUT') return updateMemory(request, env, path.split('/')[3]);
|
|
77
|
+
if (path.match(/^\/api\/memories\/\d+$/) && method === 'DELETE') return deleteMemory(env, path.split('/')[3]);
|
|
78
|
+
|
|
79
|
+
// Memory categories (CRUD)
|
|
80
|
+
if (path === '/api/memory-categories' && method === 'GET') return getMemoryCategories(env);
|
|
81
|
+
if (path === '/api/memory-categories' && method === 'POST') return createMemoryCategory(request, env);
|
|
82
|
+
if (path.match(/^\/api\/memory-categories\/\d+$/) && method === 'PUT') return updateMemoryCategory(request, env, path.split('/')[3]);
|
|
83
|
+
if (path.match(/^\/api\/memory-categories\/\d+$/) && method === 'DELETE') return deleteMemoryCategory(env, path.split('/')[3]);
|
|
84
|
+
|
|
85
|
+
// Persona
|
|
86
|
+
if (path === '/api/persona' && method === 'GET') return getPersona(env);
|
|
87
|
+
if (path === '/api/persona' && method === 'PUT') return updatePersona(request, env);
|
|
88
|
+
|
|
89
|
+
// Narrative
|
|
90
|
+
if (path === '/api/narrative' && method === 'GET') return getNarrative(env);
|
|
91
|
+
|
|
92
|
+
// Analysis runs
|
|
93
|
+
if (path === '/api/runs' && method === 'GET') return getAnalysisRuns(env);
|
|
94
|
+
|
|
95
|
+
// Stats
|
|
96
|
+
if (path === '/api/stats' && method === 'GET') return getStats(env);
|
|
97
|
+
|
|
98
|
+
// Analytics
|
|
99
|
+
if (path === '/api/analytics' && method === 'GET') return getAnalytics(env);
|
|
100
|
+
|
|
101
|
+
// Import (for CLI to push data)
|
|
102
|
+
if (path === '/api/import/conversations' && method === 'POST') return importConversations(request, env);
|
|
103
|
+
if (path === '/api/import/analysis' && method === 'POST') return importAnalysis(request, env);
|
|
104
|
+
|
|
105
|
+
// Settings
|
|
106
|
+
if (path === '/api/settings' && method === 'GET') return getSettings(env);
|
|
107
|
+
if (path === '/api/settings' && method === 'PUT') return updateSettings(request, env);
|
|
108
|
+
|
|
109
|
+
return json({ error: 'Not found' }, 404);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
return json({ error: err.message }, 500);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── Serve portal HTML ──
|
|
116
|
+
return htmlResponse(portalPage());
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
// ═══════════════════════════════════════════
|
|
122
|
+
// AUTH
|
|
123
|
+
// ═══════════════════════════════════════════
|
|
124
|
+
|
|
125
|
+
async function handleSetup(request, env) {
|
|
126
|
+
const body = await request.json();
|
|
127
|
+
if (!body.password || body.password.length < 6) {
|
|
128
|
+
return json({ error: 'Password must be at least 6 characters' }, 400);
|
|
129
|
+
}
|
|
130
|
+
await env.DB.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)').bind('password', body.password).run();
|
|
131
|
+
if (body.aiName) await env.DB.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)').bind('ai_name', body.aiName).run();
|
|
132
|
+
if (body.userName) await env.DB.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)').bind('user_name', body.userName).run();
|
|
133
|
+
return json({ ok: true }, 200, { 'Set-Cookie': `exodus_session=${body.password}; Path=/; HttpOnly; SameSite=Strict; Max-Age=31536000` });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function handleLogin(request, env, password) {
|
|
137
|
+
const body = await request.json();
|
|
138
|
+
if (body.password !== password) return json({ error: 'Wrong password' }, 401);
|
|
139
|
+
return json({ ok: true }, 200, { 'Set-Cookie': `exodus_session=${password}; Path=/; HttpOnly; SameSite=Strict; Max-Age=31536000` });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
// ═══════════════════════════════════════════
|
|
144
|
+
// CONVERSATIONS
|
|
145
|
+
// ═══════════════════════════════════════════
|
|
146
|
+
|
|
147
|
+
async function getConversations(request, env) {
|
|
148
|
+
const url = new URL(request.url);
|
|
149
|
+
const page = parseInt(url.searchParams.get('page') || '1');
|
|
150
|
+
const limit = Math.min(parseInt(url.searchParams.get('limit') || '50'), 100);
|
|
151
|
+
const offset = (page - 1) * limit;
|
|
152
|
+
const source = url.searchParams.get('source');
|
|
153
|
+
const model = url.searchParams.get('model');
|
|
154
|
+
const from = url.searchParams.get('from');
|
|
155
|
+
const to = url.searchParams.get('to');
|
|
156
|
+
|
|
157
|
+
let where = [];
|
|
158
|
+
let params = [];
|
|
159
|
+
if (source) { where.push('source = ?'); params.push(source); }
|
|
160
|
+
if (model) { where.push('(model = ? OR id IN (SELECT DISTINCT conversation_id FROM messages WHERE model = ?))'); params.push(model, model); }
|
|
161
|
+
if (from) { where.push('created_at >= ?'); params.push(from); }
|
|
162
|
+
if (to) { where.push('created_at <= ?'); params.push(to); }
|
|
163
|
+
|
|
164
|
+
const whereClause = where.length ? 'WHERE ' + where.join(' AND ') : '';
|
|
165
|
+
|
|
166
|
+
const total = await env.DB.prepare(`SELECT COUNT(*) as count FROM conversations ${whereClause}`).bind(...params).first();
|
|
167
|
+
const rows = await env.DB.prepare(`SELECT * FROM conversations ${whereClause} ORDER BY created_at DESC LIMIT ? OFFSET ?`).bind(...params, limit, offset).all();
|
|
168
|
+
|
|
169
|
+
return json({
|
|
170
|
+
conversations: rows.results,
|
|
171
|
+
total: total.count,
|
|
172
|
+
page,
|
|
173
|
+
pages: Math.ceil(total.count / limit),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function searchConversations(request, env) {
|
|
178
|
+
const url = new URL(request.url);
|
|
179
|
+
const q = url.searchParams.get('q') || '';
|
|
180
|
+
const limit = Math.min(parseInt(url.searchParams.get('limit') || '20'), 50);
|
|
181
|
+
|
|
182
|
+
if (!q.trim()) return json({ results: [] });
|
|
183
|
+
|
|
184
|
+
const results = await env.DB.prepare(`
|
|
185
|
+
SELECT m.conversation_id, m.content, m.role, m.model, m.created_at, c.title
|
|
186
|
+
FROM messages m
|
|
187
|
+
JOIN conversations c ON c.id = m.conversation_id
|
|
188
|
+
WHERE m.content LIKE ?
|
|
189
|
+
ORDER BY m.created_at DESC
|
|
190
|
+
LIMIT ?
|
|
191
|
+
`).bind(`%${q}%`, limit).all();
|
|
192
|
+
|
|
193
|
+
return json({ results: results.results, query: q });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function getConversation(request, env, id) {
|
|
197
|
+
const convo = await env.DB.prepare('SELECT * FROM conversations WHERE id = ?').bind(id).first();
|
|
198
|
+
if (!convo) return json({ error: 'Not found' }, 404);
|
|
199
|
+
return json(convo);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function deleteConversation(env, id) {
|
|
203
|
+
await env.DB.batch([
|
|
204
|
+
env.DB.prepare('DELETE FROM messages WHERE conversation_id = ?').bind(id),
|
|
205
|
+
env.DB.prepare('DELETE FROM conversations WHERE id = ?').bind(id),
|
|
206
|
+
]);
|
|
207
|
+
return json({ ok: true });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function getMessages(request, env, conversationId) {
|
|
211
|
+
const url = new URL(request.url);
|
|
212
|
+
const page = parseInt(url.searchParams.get('page') || '1');
|
|
213
|
+
const limit = Math.min(parseInt(url.searchParams.get('limit') || '100'), 500);
|
|
214
|
+
const offset = (page - 1) * limit;
|
|
215
|
+
|
|
216
|
+
const rows = await env.DB.prepare('SELECT * FROM messages WHERE conversation_id = ? ORDER BY position ASC LIMIT ? OFFSET ?').bind(conversationId, limit, offset).all();
|
|
217
|
+
const total = await env.DB.prepare('SELECT COUNT(*) as count FROM messages WHERE conversation_id = ?').bind(conversationId).first();
|
|
218
|
+
|
|
219
|
+
return json({ messages: rows.results, total: total.count, page });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
// ═══════════════════════════════════════════
|
|
224
|
+
// SKILLS (CRUD)
|
|
225
|
+
// ═══════════════════════════════════════════
|
|
226
|
+
|
|
227
|
+
async function getSkills(env) {
|
|
228
|
+
const rows = await env.DB.prepare('SELECT * FROM skills ORDER BY category, name').all();
|
|
229
|
+
return json(rows.results.map(parseSkillRow));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function createSkill(request, env) {
|
|
233
|
+
const s = await request.json();
|
|
234
|
+
const result = await env.DB.prepare(`
|
|
235
|
+
INSERT INTO skills (name, category, frequency, description, approach, quality, activation_rule, triggers_phrases, triggers_temporal, triggers_emotional, triggers_contextual, examples, source)
|
|
236
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
237
|
+
`).bind(
|
|
238
|
+
s.name, s.category || 'other', s.frequency || 'occasional',
|
|
239
|
+
s.description || '', s.approach || '', s.quality || '',
|
|
240
|
+
s.activationRule || '',
|
|
241
|
+
JSON.stringify(s.triggers?.phrases || []),
|
|
242
|
+
JSON.stringify(s.triggers?.temporal || []),
|
|
243
|
+
JSON.stringify(s.triggers?.emotional || []),
|
|
244
|
+
JSON.stringify(s.triggers?.contextual || []),
|
|
245
|
+
JSON.stringify(s.examples || []),
|
|
246
|
+
s.source || 'manual'
|
|
247
|
+
).run();
|
|
248
|
+
return json({ ok: true, id: result.meta.last_row_id });
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function updateSkill(request, env, id) {
|
|
252
|
+
const s = await request.json();
|
|
253
|
+
await env.DB.prepare(`
|
|
254
|
+
UPDATE skills SET name = ?, category = ?, frequency = ?, description = ?, approach = ?, quality = ?,
|
|
255
|
+
activation_rule = ?, triggers_phrases = ?, triggers_temporal = ?, triggers_emotional = ?, triggers_contextual = ?,
|
|
256
|
+
examples = ?, updated_at = datetime('now')
|
|
257
|
+
WHERE id = ?
|
|
258
|
+
`).bind(
|
|
259
|
+
s.name, s.category, s.frequency,
|
|
260
|
+
s.description || '', s.approach || '', s.quality || '',
|
|
261
|
+
s.activationRule || '',
|
|
262
|
+
JSON.stringify(s.triggers?.phrases || []),
|
|
263
|
+
JSON.stringify(s.triggers?.temporal || []),
|
|
264
|
+
JSON.stringify(s.triggers?.emotional || []),
|
|
265
|
+
JSON.stringify(s.triggers?.contextual || []),
|
|
266
|
+
JSON.stringify(s.examples || []),
|
|
267
|
+
id
|
|
268
|
+
).run();
|
|
269
|
+
return json({ ok: true });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function deleteSkill(env, id) {
|
|
273
|
+
await env.DB.prepare('DELETE FROM skills WHERE id = ?').bind(id).run();
|
|
274
|
+
return json({ ok: true });
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function parseSkillRow(row) {
|
|
278
|
+
return {
|
|
279
|
+
...row,
|
|
280
|
+
triggers: {
|
|
281
|
+
phrases: safeJSON(row.triggers_phrases),
|
|
282
|
+
temporal: safeJSON(row.triggers_temporal),
|
|
283
|
+
emotional: safeJSON(row.triggers_emotional),
|
|
284
|
+
contextual: safeJSON(row.triggers_contextual),
|
|
285
|
+
},
|
|
286
|
+
activationRule: row.activation_rule,
|
|
287
|
+
examples: safeJSON(row.examples),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
// ═══════════════════════════════════════════
|
|
293
|
+
// SKILL CATEGORIES (CRUD)
|
|
294
|
+
// ═══════════════════════════════════════════
|
|
295
|
+
|
|
296
|
+
async function getSkillCategories(env) {
|
|
297
|
+
const rows = await env.DB.prepare('SELECT * FROM skill_categories ORDER BY sort_order').all();
|
|
298
|
+
return json(rows.results);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function createSkillCategory(request, env) {
|
|
302
|
+
const body = await request.json();
|
|
303
|
+
const result = await env.DB.prepare('INSERT INTO skill_categories (name, color, icon, sort_order) VALUES (?, ?, ?, ?)')
|
|
304
|
+
.bind(body.name, body.color || '#8b5cf6', body.icon || '', body.sortOrder || 99).run();
|
|
305
|
+
return json({ ok: true, id: result.meta.last_row_id });
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function updateSkillCategory(request, env, id) {
|
|
309
|
+
const body = await request.json();
|
|
310
|
+
await env.DB.prepare('UPDATE skill_categories SET name = ?, color = ?, icon = ? WHERE id = ? AND is_default = 0')
|
|
311
|
+
.bind(body.name, body.color, body.icon || '', id).run();
|
|
312
|
+
return json({ ok: true });
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function deleteSkillCategory(env, id) {
|
|
316
|
+
// Don't delete defaults
|
|
317
|
+
const cat = await env.DB.prepare('SELECT is_default FROM skill_categories WHERE id = ?').bind(id).first();
|
|
318
|
+
if (cat?.is_default) return json({ error: 'Cannot delete default category' }, 400);
|
|
319
|
+
await env.DB.prepare('DELETE FROM skill_categories WHERE id = ?').bind(id).run();
|
|
320
|
+
return json({ ok: true });
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
// ═══════════════════════════════════════════
|
|
325
|
+
// MEMORIES (CRUD)
|
|
326
|
+
// ═══════════════════════════════════════════
|
|
327
|
+
|
|
328
|
+
async function getMemories(request, env) {
|
|
329
|
+
const url = new URL(request.url);
|
|
330
|
+
const category = url.searchParams.get('category');
|
|
331
|
+
let query = 'SELECT * FROM memories';
|
|
332
|
+
let params = [];
|
|
333
|
+
if (category) { query += ' WHERE category = ?'; params.push(category); }
|
|
334
|
+
query += ' ORDER BY category, key';
|
|
335
|
+
const rows = await env.DB.prepare(query).bind(...params).all();
|
|
336
|
+
return json(rows.results);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function createMemory(request, env) {
|
|
340
|
+
const m = await request.json();
|
|
341
|
+
const result = await env.DB.prepare('INSERT INTO memories (category, key, value, confidence, source) VALUES (?, ?, ?, ?, ?)')
|
|
342
|
+
.bind(m.category || 'facts', m.key || null, m.value, m.confidence || 'manual', m.source || 'manual').run();
|
|
343
|
+
return json({ ok: true, id: result.meta.last_row_id });
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function updateMemory(request, env, id) {
|
|
347
|
+
const m = await request.json();
|
|
348
|
+
await env.DB.prepare('UPDATE memories SET category = ?, key = ?, value = ?, updated_at = datetime(\'now\') WHERE id = ?')
|
|
349
|
+
.bind(m.category, m.key, m.value, id).run();
|
|
350
|
+
return json({ ok: true });
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function deleteMemory(env, id) {
|
|
354
|
+
await env.DB.prepare('DELETE FROM memories WHERE id = ?').bind(id).run();
|
|
355
|
+
return json({ ok: true });
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
// ═══════════════════════════════════════════
|
|
360
|
+
// MEMORY CATEGORIES (CRUD)
|
|
361
|
+
// ═══════════════════════════════════════════
|
|
362
|
+
|
|
363
|
+
async function getMemoryCategories(env) {
|
|
364
|
+
const rows = await env.DB.prepare('SELECT * FROM memory_categories ORDER BY sort_order').all();
|
|
365
|
+
return json(rows.results);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function createMemoryCategory(request, env) {
|
|
369
|
+
const body = await request.json();
|
|
370
|
+
const result = await env.DB.prepare('INSERT INTO memory_categories (name, color, icon, sort_order) VALUES (?, ?, ?, ?)')
|
|
371
|
+
.bind(body.name, body.color || '#8b5cf6', body.icon || '', body.sortOrder || 99).run();
|
|
372
|
+
return json({ ok: true, id: result.meta.last_row_id });
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function updateMemoryCategory(request, env, id) {
|
|
376
|
+
const body = await request.json();
|
|
377
|
+
await env.DB.prepare('UPDATE memory_categories SET name = ?, color = ?, icon = ? WHERE id = ? AND is_default = 0')
|
|
378
|
+
.bind(body.name, body.color, body.icon || '', id).run();
|
|
379
|
+
return json({ ok: true });
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async function deleteMemoryCategory(env, id) {
|
|
383
|
+
const cat = await env.DB.prepare('SELECT is_default FROM memory_categories WHERE id = ?').bind(id).first();
|
|
384
|
+
if (cat?.is_default) return json({ error: 'Cannot delete default category' }, 400);
|
|
385
|
+
await env.DB.prepare('DELETE FROM memory_categories WHERE id = ?').bind(id).run();
|
|
386
|
+
return json({ ok: true });
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
// ═══════════════════════════════════════════
|
|
391
|
+
// PERSONA
|
|
392
|
+
// ═══════════════════════════════════════════
|
|
393
|
+
|
|
394
|
+
async function getPersona(env) {
|
|
395
|
+
const rows = await env.DB.prepare('SELECT * FROM persona ORDER BY sort_order').all();
|
|
396
|
+
return json(rows.results);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async function updatePersona(request, env) {
|
|
400
|
+
const body = await request.json();
|
|
401
|
+
// Replace all sections
|
|
402
|
+
await env.DB.prepare('DELETE FROM persona').run();
|
|
403
|
+
for (let i = 0; i < body.sections.length; i++) {
|
|
404
|
+
const s = body.sections[i];
|
|
405
|
+
await env.DB.prepare('INSERT INTO persona (section, content, sort_order) VALUES (?, ?, ?)')
|
|
406
|
+
.bind(s.section, s.content, i).run();
|
|
407
|
+
}
|
|
408
|
+
return json({ ok: true });
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
// ═══════════════════════════════════════════
|
|
413
|
+
// NARRATIVE
|
|
414
|
+
// ═══════════════════════════════════════════
|
|
415
|
+
|
|
416
|
+
async function getNarrative(env) {
|
|
417
|
+
const row = await env.DB.prepare('SELECT * FROM narratives ORDER BY created_at DESC LIMIT 1').first();
|
|
418
|
+
return json(row || { content: '' });
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
// ═══════════════════════════════════════════
|
|
423
|
+
// ANALYSIS RUNS
|
|
424
|
+
// ═══════════════════════════════════════════
|
|
425
|
+
|
|
426
|
+
async function getAnalysisRuns(env) {
|
|
427
|
+
const rows = await env.DB.prepare('SELECT * FROM analysis_runs ORDER BY started_at DESC LIMIT 20').all();
|
|
428
|
+
return json(rows.results);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
// ═══════════════════════════════════════════
|
|
433
|
+
// STATS
|
|
434
|
+
// ═══════════════════════════════════════════
|
|
435
|
+
|
|
436
|
+
async function getStats(env) {
|
|
437
|
+
const [convos, msgs, skills, memories, runs] = await Promise.all([
|
|
438
|
+
env.DB.prepare('SELECT COUNT(*) as count FROM conversations').first(),
|
|
439
|
+
env.DB.prepare('SELECT COUNT(*) as count FROM messages').first(),
|
|
440
|
+
env.DB.prepare('SELECT COUNT(*) as count FROM skills').first(),
|
|
441
|
+
env.DB.prepare('SELECT COUNT(*) as count FROM memories').first(),
|
|
442
|
+
env.DB.prepare('SELECT COUNT(*) as count FROM analysis_runs WHERE status = ?').bind('complete').first(),
|
|
443
|
+
]);
|
|
444
|
+
|
|
445
|
+
const models = await env.DB.prepare(`
|
|
446
|
+
SELECT model, COUNT(*) as count FROM messages WHERE model IS NOT NULL GROUP BY model ORDER BY count DESC
|
|
447
|
+
`).all();
|
|
448
|
+
|
|
449
|
+
const convoModels = await env.DB.prepare(`
|
|
450
|
+
SELECT model, COUNT(*) as count FROM conversations WHERE model IS NOT NULL GROUP BY model ORDER BY count DESC
|
|
451
|
+
`).all();
|
|
452
|
+
|
|
453
|
+
const dateRange = await env.DB.prepare(`
|
|
454
|
+
SELECT MIN(created_at) as earliest, MAX(created_at) as latest FROM conversations
|
|
455
|
+
`).first();
|
|
456
|
+
|
|
457
|
+
const aiName = await env.DB.prepare('SELECT value FROM settings WHERE key = ?').bind('ai_name').first();
|
|
458
|
+
const userName = await env.DB.prepare('SELECT value FROM settings WHERE key = ?').bind('user_name').first();
|
|
459
|
+
|
|
460
|
+
return json({
|
|
461
|
+
conversations: convos.count,
|
|
462
|
+
messages: msgs.count,
|
|
463
|
+
skills: skills.count,
|
|
464
|
+
memories: memories.count,
|
|
465
|
+
analysisRuns: runs.count,
|
|
466
|
+
models: models.results,
|
|
467
|
+
convoModels: convoModels.results,
|
|
468
|
+
dateRange: { from: dateRange?.earliest, to: dateRange?.latest },
|
|
469
|
+
aiName: aiName?.value || 'AI',
|
|
470
|
+
userName: userName?.value || 'User',
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
// ═══════════════════════════════════════════
|
|
476
|
+
// ANALYTICS
|
|
477
|
+
// ═══════════════════════════════════════════
|
|
478
|
+
|
|
479
|
+
async function getAnalytics(env) {
|
|
480
|
+
// Model distribution
|
|
481
|
+
const models = await env.DB.prepare(`
|
|
482
|
+
SELECT model, COUNT(*) as count FROM messages WHERE model IS NOT NULL GROUP BY model ORDER BY count DESC
|
|
483
|
+
`).all();
|
|
484
|
+
|
|
485
|
+
// Messages per month (activity heatmap data)
|
|
486
|
+
const activity = await env.DB.prepare(`
|
|
487
|
+
SELECT strftime('%Y-%m', created_at) as month, COUNT(*) as count
|
|
488
|
+
FROM messages WHERE created_at IS NOT NULL
|
|
489
|
+
GROUP BY month ORDER BY month
|
|
490
|
+
`).all();
|
|
491
|
+
|
|
492
|
+
// Messages by role
|
|
493
|
+
const roles = await env.DB.prepare(`
|
|
494
|
+
SELECT role, COUNT(*) as count FROM messages GROUP BY role
|
|
495
|
+
`).all();
|
|
496
|
+
|
|
497
|
+
// Conversations by day of week
|
|
498
|
+
const dayOfWeek = await env.DB.prepare(`
|
|
499
|
+
SELECT CASE CAST(strftime('%w', created_at) AS INTEGER)
|
|
500
|
+
WHEN 0 THEN 'Sun' WHEN 1 THEN 'Mon' WHEN 2 THEN 'Tue'
|
|
501
|
+
WHEN 3 THEN 'Wed' WHEN 4 THEN 'Thu' WHEN 5 THEN 'Fri' WHEN 6 THEN 'Sat' END as day,
|
|
502
|
+
COUNT(*) as count
|
|
503
|
+
FROM messages WHERE created_at IS NOT NULL AND created_at != '' AND created_at LIKE '20%'
|
|
504
|
+
GROUP BY strftime('%w', created_at)
|
|
505
|
+
HAVING day IS NOT NULL
|
|
506
|
+
ORDER BY CAST(strftime('%w', created_at) AS INTEGER)
|
|
507
|
+
`).all();
|
|
508
|
+
|
|
509
|
+
// Hour of day distribution
|
|
510
|
+
const hourOfDay = await env.DB.prepare(`
|
|
511
|
+
SELECT CAST(strftime('%H', created_at) AS INTEGER) as hour, COUNT(*) as count
|
|
512
|
+
FROM messages WHERE created_at IS NOT NULL AND created_at != '' AND created_at LIKE '20%'
|
|
513
|
+
GROUP BY hour ORDER BY hour
|
|
514
|
+
`).all();
|
|
515
|
+
|
|
516
|
+
// Average message length by role
|
|
517
|
+
const avgLength = await env.DB.prepare(`
|
|
518
|
+
SELECT role, CAST(AVG(LENGTH(content)) AS INTEGER) as avg_length,
|
|
519
|
+
CAST(MAX(LENGTH(content)) AS INTEGER) as max_length
|
|
520
|
+
FROM messages GROUP BY role
|
|
521
|
+
`).all();
|
|
522
|
+
|
|
523
|
+
// Top words (sample from recent messages — full word frequency is expensive)
|
|
524
|
+
const wordSample = await env.DB.prepare(`
|
|
525
|
+
SELECT content FROM messages WHERE role = 'user' ORDER BY created_at DESC LIMIT 500
|
|
526
|
+
`).all();
|
|
527
|
+
|
|
528
|
+
const wordCounts = {};
|
|
529
|
+
const stopWords = new Set(['the','a','an','is','it','to','in','of','and','or','for','on','at','by','my','i','me','we','you','he','she','they','this','that','with','from','was','be','have','has','had','do','does','did','will','would','can','could','should','but','not','so','if','then','than','just','like','about','what','when','how','no','yes','up','out','all','its','very','also','into','over','some','get','got','been','are','were','am','im',"i'm",'dont',"don't",'thats',"that's",'really','know','think','want','going','go','one','two','need','thing','things','here','there','much','too','now','back','more','still','good','well','right','make','said','see','way','day','come','time']);
|
|
530
|
+
|
|
531
|
+
for (const row of wordSample.results) {
|
|
532
|
+
const words = (row.content || '').toLowerCase().replace(/[^a-z\s'-]/g, '').split(/\s+/);
|
|
533
|
+
for (const w of words) {
|
|
534
|
+
if (w.length > 2 && !stopWords.has(w)) {
|
|
535
|
+
wordCounts[w] = (wordCounts[w] || 0) + 1;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const topWords = Object.entries(wordCounts)
|
|
541
|
+
.sort((a, b) => b[1] - a[1])
|
|
542
|
+
.slice(0, 50)
|
|
543
|
+
.map(([word, count]) => ({ word, count }));
|
|
544
|
+
|
|
545
|
+
// Time spent — calculate per conversation (first msg to last msg)
|
|
546
|
+
const timeData = await env.DB.prepare(`
|
|
547
|
+
SELECT conversation_id,
|
|
548
|
+
MIN(created_at) as first_msg,
|
|
549
|
+
MAX(created_at) as last_msg
|
|
550
|
+
FROM messages
|
|
551
|
+
WHERE created_at IS NOT NULL AND created_at != ''
|
|
552
|
+
GROUP BY conversation_id
|
|
553
|
+
`).all();
|
|
554
|
+
|
|
555
|
+
let totalMinutes = 0;
|
|
556
|
+
let sessionCount = 0;
|
|
557
|
+
const MIN_DATE = new Date('2015-01-01').getTime();
|
|
558
|
+
const MAX_DATE = new Date('2030-01-01').getTime();
|
|
559
|
+
|
|
560
|
+
function safeTime(val) {
|
|
561
|
+
if (!val) return null;
|
|
562
|
+
let t = new Date(val).getTime();
|
|
563
|
+
// If it looks like epoch seconds (too small for ms), convert
|
|
564
|
+
if (t > 0 && t < 2000000000) t = t * 1000;
|
|
565
|
+
if (isNaN(t) || t < MIN_DATE || t > MAX_DATE) return null;
|
|
566
|
+
return t;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
for (const row of timeData.results) {
|
|
570
|
+
const start = safeTime(row.first_msg);
|
|
571
|
+
const end = safeTime(row.last_msg);
|
|
572
|
+
if (!start || !end || end <= start) continue;
|
|
573
|
+
const durationMin = (end - start) / 60000;
|
|
574
|
+
// Cap single conversation at 24h (anything longer is likely a multi-day thread, not active time)
|
|
575
|
+
if (durationMin > 0 && durationMin < 1440) {
|
|
576
|
+
totalMinutes += durationMin;
|
|
577
|
+
sessionCount++;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const totalHours = Math.round(totalMinutes / 60);
|
|
582
|
+
const avgSessionMinutes = sessionCount > 0 ? Math.round(totalMinutes / sessionCount) : 0;
|
|
583
|
+
|
|
584
|
+
// Longest conversations
|
|
585
|
+
const longestConvos = await env.DB.prepare(`
|
|
586
|
+
SELECT id, title, message_count, model, created_at FROM conversations
|
|
587
|
+
ORDER BY message_count DESC LIMIT 10
|
|
588
|
+
`).all();
|
|
589
|
+
|
|
590
|
+
// Conversation count by source
|
|
591
|
+
const sources = await env.DB.prepare(`
|
|
592
|
+
SELECT source, COUNT(*) as count FROM conversations GROUP BY source ORDER BY count DESC
|
|
593
|
+
`).all();
|
|
594
|
+
|
|
595
|
+
return json({
|
|
596
|
+
models: models.results,
|
|
597
|
+
activity: activity.results,
|
|
598
|
+
roles: roles.results,
|
|
599
|
+
dayOfWeek: dayOfWeek.results,
|
|
600
|
+
hourOfDay: hourOfDay.results,
|
|
601
|
+
avgLength: avgLength.results,
|
|
602
|
+
topWords,
|
|
603
|
+
longestConvos: longestConvos.results,
|
|
604
|
+
sources: sources.results,
|
|
605
|
+
timeSpent: {
|
|
606
|
+
totalHours,
|
|
607
|
+
totalMinutes: Math.round(totalMinutes),
|
|
608
|
+
sessions: sessionCount,
|
|
609
|
+
avgSessionMinutes,
|
|
610
|
+
},
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
// ═══════════════════════════════════════════
|
|
616
|
+
// SETTINGS
|
|
617
|
+
// ═══════════════════════════════════════════
|
|
618
|
+
|
|
619
|
+
async function getSettings(env) {
|
|
620
|
+
const rows = await env.DB.prepare('SELECT key, value FROM settings WHERE key != ?').bind('password').all();
|
|
621
|
+
const settings = {};
|
|
622
|
+
for (const row of rows.results) settings[row.key] = row.value;
|
|
623
|
+
return json(settings);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
async function updateSettings(request, env) {
|
|
627
|
+
const body = await request.json();
|
|
628
|
+
for (const [key, value] of Object.entries(body)) {
|
|
629
|
+
if (key === 'password') continue; // Don't update password through settings
|
|
630
|
+
await env.DB.prepare('INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, datetime(\'now\'))').bind(key, value).run();
|
|
631
|
+
}
|
|
632
|
+
return json({ ok: true });
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
// ═══════════════════════════════════════════
|
|
637
|
+
// IMPORT (CLI pushes data here)
|
|
638
|
+
// ═══════════════════════════════════════════
|
|
639
|
+
|
|
640
|
+
async function importConversations(request, env) {
|
|
641
|
+
const body = await request.json();
|
|
642
|
+
const { conversations } = body;
|
|
643
|
+
if (!conversations?.length) return json({ error: 'No conversations' }, 400);
|
|
644
|
+
|
|
645
|
+
let imported = 0;
|
|
646
|
+
for (const convo of conversations) {
|
|
647
|
+
// Insert conversation
|
|
648
|
+
await env.DB.prepare(`
|
|
649
|
+
INSERT OR REPLACE INTO conversations (id, title, created_at, updated_at, message_count, model, source, metadata)
|
|
650
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
651
|
+
`).bind(
|
|
652
|
+
convo.id, convo.title, convo.createdAt, convo.updatedAt,
|
|
653
|
+
convo.messages?.length || 0, convo.model || null,
|
|
654
|
+
convo.source || 'chatgpt', JSON.stringify(convo.metadata || {})
|
|
655
|
+
).run();
|
|
656
|
+
|
|
657
|
+
// Replace messages atomically via D1 batch
|
|
658
|
+
if (convo.messages?.length) {
|
|
659
|
+
const msgStmts = [
|
|
660
|
+
env.DB.prepare('DELETE FROM messages WHERE conversation_id = ?').bind(convo.id),
|
|
661
|
+
];
|
|
662
|
+
for (let i = 0; i < convo.messages.length; i++) {
|
|
663
|
+
const msg = convo.messages[i];
|
|
664
|
+
msgStmts.push(
|
|
665
|
+
env.DB.prepare(`INSERT INTO messages (conversation_id, role, content, model, created_at, position) VALUES (?, ?, ?, ?, ?, ?)`)
|
|
666
|
+
.bind(convo.id, msg.role, msg.content, msg.model || null, msg.createdAt || null, i)
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
// Batch in chunks of 100 (D1 limit), first chunk includes the DELETE
|
|
670
|
+
for (let i = 0; i < msgStmts.length; i += 100) {
|
|
671
|
+
await env.DB.batch(msgStmts.slice(i, i + 100));
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
imported++;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
return json({ ok: true, imported });
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
async function importAnalysis(request, env) {
|
|
681
|
+
const body = await request.json();
|
|
682
|
+
let imported = { skills: 0, skillsUpdated: 0, memories: 0, memoriesSkipped: 0, persona: false, narrative: false };
|
|
683
|
+
|
|
684
|
+
// Import skills — merge by name (update existing, insert new)
|
|
685
|
+
if (body.skills?.length) {
|
|
686
|
+
for (const s of body.skills) {
|
|
687
|
+
const existing = await env.DB.prepare('SELECT id FROM skills WHERE LOWER(name) = LOWER(?)').bind(s.name).first();
|
|
688
|
+
if (existing) {
|
|
689
|
+
// Update existing skill
|
|
690
|
+
await env.DB.prepare(`
|
|
691
|
+
UPDATE skills SET category = ?, frequency = ?, description = ?, approach = ?, quality = ?,
|
|
692
|
+
activation_rule = ?, triggers_phrases = ?, triggers_temporal = ?, triggers_emotional = ?, triggers_contextual = ?,
|
|
693
|
+
examples = ?, updated_at = datetime('now') WHERE id = ?
|
|
694
|
+
`).bind(
|
|
695
|
+
s.category || 'other', s.frequency || 'occasional',
|
|
696
|
+
s.description || '', s.approach || '', s.quality || '',
|
|
697
|
+
s.activationRule || '',
|
|
698
|
+
JSON.stringify(s.triggers?.phrases || []),
|
|
699
|
+
JSON.stringify(s.triggers?.temporal || []),
|
|
700
|
+
JSON.stringify(s.triggers?.emotional || []),
|
|
701
|
+
JSON.stringify(s.triggers?.contextual || []),
|
|
702
|
+
JSON.stringify(s.examples || []),
|
|
703
|
+
existing.id
|
|
704
|
+
).run();
|
|
705
|
+
imported.skillsUpdated++;
|
|
706
|
+
} else {
|
|
707
|
+
await env.DB.prepare(`
|
|
708
|
+
INSERT INTO skills (name, category, frequency, description, approach, quality, activation_rule, triggers_phrases, triggers_temporal, triggers_emotional, triggers_contextual, examples, source, run_id)
|
|
709
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'extracted', ?)
|
|
710
|
+
`).bind(
|
|
711
|
+
s.name, s.category || 'other', s.frequency || 'occasional',
|
|
712
|
+
s.description || '', s.approach || '', s.quality || '',
|
|
713
|
+
s.activationRule || '',
|
|
714
|
+
JSON.stringify(s.triggers?.phrases || []),
|
|
715
|
+
JSON.stringify(s.triggers?.temporal || []),
|
|
716
|
+
JSON.stringify(s.triggers?.emotional || []),
|
|
717
|
+
JSON.stringify(s.triggers?.contextual || []),
|
|
718
|
+
JSON.stringify(s.examples || []),
|
|
719
|
+
body.runId || null
|
|
720
|
+
).run();
|
|
721
|
+
imported.skills++;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// Import memories — deduplicate by value (skip if same value exists in same category)
|
|
727
|
+
if (body.memories?.length) {
|
|
728
|
+
// Build a set of existing memory values for fast lookup
|
|
729
|
+
const existingMems = await env.DB.prepare('SELECT category, value FROM memories').all();
|
|
730
|
+
const existingSet = new Set(existingMems.results.map(m => (m.category || '') + '::' + (m.value || '').toLowerCase().trim()));
|
|
731
|
+
|
|
732
|
+
const newMems = body.memories.filter(m => {
|
|
733
|
+
const key = (m.category || 'facts') + '::' + (m.value || '').toLowerCase().trim();
|
|
734
|
+
if (existingSet.has(key)) return false;
|
|
735
|
+
existingSet.add(key);
|
|
736
|
+
return true;
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
imported.memoriesSkipped = body.memories.length - newMems.length;
|
|
740
|
+
|
|
741
|
+
if (newMems.length) {
|
|
742
|
+
const stmts = newMems.map(m =>
|
|
743
|
+
env.DB.prepare('INSERT INTO memories (category, key, value, source, run_id) VALUES (?, ?, ?, ?, ?)')
|
|
744
|
+
.bind(m.category || 'facts', m.key || null, m.value, 'extracted', body.runId || null)
|
|
745
|
+
);
|
|
746
|
+
for (let i = 0; i < stmts.length; i += 100) {
|
|
747
|
+
await env.DB.batch(stmts.slice(i, i + 100));
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
imported.memories = newMems.length;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// Import persona — always replace (latest run wins, but user can edit after)
|
|
754
|
+
if (body.persona) {
|
|
755
|
+
await env.DB.batch([
|
|
756
|
+
env.DB.prepare('DELETE FROM persona'),
|
|
757
|
+
env.DB.prepare('INSERT INTO persona (section, content, sort_order) VALUES (?, ?, 0)').bind('full', body.persona),
|
|
758
|
+
]);
|
|
759
|
+
imported.persona = true;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// Import narrative — replace (keep only the latest)
|
|
763
|
+
if (body.narrative) {
|
|
764
|
+
await env.DB.batch([
|
|
765
|
+
env.DB.prepare('DELETE FROM narratives'),
|
|
766
|
+
env.DB.prepare('INSERT INTO narratives (content, run_id) VALUES (?, ?)').bind(body.narrative, body.runId || null),
|
|
767
|
+
]);
|
|
768
|
+
imported.narrative = true;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// Record the run
|
|
772
|
+
if (body.runId) {
|
|
773
|
+
await env.DB.prepare(`
|
|
774
|
+
UPDATE analysis_runs SET status = 'complete', completed_at = datetime('now'),
|
|
775
|
+
results = ? WHERE id = ?
|
|
776
|
+
`).bind(JSON.stringify(body.stats || {}), body.runId).run();
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
return json({ ok: true, imported });
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
// ═══════════════════════════════════════════
|
|
784
|
+
// MCP ENDPOINT
|
|
785
|
+
// ═══════════════════════════════════════════
|
|
786
|
+
|
|
787
|
+
async function handleMCP(request, env, path) {
|
|
788
|
+
const secret = env.MCP_SECRET;
|
|
789
|
+
const parts = path.split('/');
|
|
790
|
+
// /mcp/{secret}/{tool}
|
|
791
|
+
if (parts.length < 4 || parts[2] !== secret) return json({ error: 'Unauthorized' }, 401);
|
|
792
|
+
|
|
793
|
+
const tool = parts[3];
|
|
794
|
+
const url = new URL(request.url);
|
|
795
|
+
|
|
796
|
+
if (tool === 'search') {
|
|
797
|
+
const q = url.searchParams.get('q') || '';
|
|
798
|
+
const limit = parseInt(url.searchParams.get('limit') || '10');
|
|
799
|
+
if (!q) return json({ results: [] });
|
|
800
|
+
|
|
801
|
+
const results = await env.DB.prepare(`
|
|
802
|
+
SELECT m.content, m.role, m.model, m.created_at, c.title, c.id as conversation_id
|
|
803
|
+
FROM messages m JOIN conversations c ON c.id = m.conversation_id
|
|
804
|
+
WHERE m.content LIKE ? ORDER BY m.created_at DESC LIMIT ?
|
|
805
|
+
`).bind(`%${q}%`, limit).all();
|
|
806
|
+
|
|
807
|
+
return json({ results: results.results });
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
if (tool === 'skills') {
|
|
811
|
+
const rows = await env.DB.prepare('SELECT * FROM skills ORDER BY category, name').all();
|
|
812
|
+
return json(rows.results.map(parseSkillRow));
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
if (tool === 'memories') {
|
|
816
|
+
const category = url.searchParams.get('category');
|
|
817
|
+
let query = 'SELECT * FROM memories';
|
|
818
|
+
let params = [];
|
|
819
|
+
if (category) { query += ' WHERE category = ?'; params.push(category); }
|
|
820
|
+
query += ' ORDER BY category, key';
|
|
821
|
+
const rows = await env.DB.prepare(query).bind(...params).all();
|
|
822
|
+
return json(rows.results);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
if (tool === 'persona') {
|
|
826
|
+
const rows = await env.DB.prepare('SELECT * FROM persona ORDER BY sort_order').all();
|
|
827
|
+
return json(rows.results);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
if (tool === 'narrative') {
|
|
831
|
+
const row = await env.DB.prepare('SELECT content FROM narratives ORDER BY created_at DESC LIMIT 1').first();
|
|
832
|
+
return json({ content: row?.content || '' });
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
if (tool === 'stats') {
|
|
836
|
+
// Reuse stats handler
|
|
837
|
+
return getStats(env);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
if (tool === 'conversation') {
|
|
841
|
+
const id = url.searchParams.get('id');
|
|
842
|
+
if (!id) return json({ error: 'id required' }, 400);
|
|
843
|
+
const convo = await env.DB.prepare('SELECT * FROM conversations WHERE id = ?').bind(id).first();
|
|
844
|
+
if (!convo) return json({ error: 'Not found' }, 404);
|
|
845
|
+
const msgs = await env.DB.prepare('SELECT role, content, model, created_at FROM messages WHERE conversation_id = ? ORDER BY position').bind(id).all();
|
|
846
|
+
return json({ ...convo, messages: msgs.results });
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
return json({ error: 'Unknown tool' }, 404);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
// ═══════════════════════════════════════════
|
|
854
|
+
// HELPERS
|
|
855
|
+
// ═══════════════════════════════════════════
|
|
856
|
+
|
|
857
|
+
function json(data, status = 200, extraHeaders = {}) {
|
|
858
|
+
return new Response(JSON.stringify(data), {
|
|
859
|
+
status,
|
|
860
|
+
headers: { 'Content-Type': 'application/json; charset=utf-8', 'Access-Control-Allow-Origin': '*', ...extraHeaders },
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function corsResponse() {
|
|
865
|
+
return new Response(null, {
|
|
866
|
+
headers: {
|
|
867
|
+
'Access-Control-Allow-Origin': '*',
|
|
868
|
+
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
|
869
|
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
|
870
|
+
},
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function htmlResponse(html) {
|
|
875
|
+
return new Response(html, {
|
|
876
|
+
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
function parseCookies(cookieStr) {
|
|
881
|
+
const cookies = {};
|
|
882
|
+
cookieStr.split(';').forEach(c => {
|
|
883
|
+
const [key, ...val] = c.trim().split('=');
|
|
884
|
+
if (key) cookies[key] = val.join('=');
|
|
885
|
+
});
|
|
886
|
+
return cookies;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function safeJSON(str) {
|
|
890
|
+
try { return JSON.parse(str || '[]'); } catch { return []; }
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
// ═══════════════════════════════════════════
|
|
895
|
+
// HTML PAGES
|
|
896
|
+
// ═══════════════════════════════════════════
|
|
897
|
+
|
|
898
|
+
function setupPage() {
|
|
899
|
+
return `<!DOCTYPE html>
|
|
900
|
+
<html lang="en">
|
|
901
|
+
<head>
|
|
902
|
+
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
903
|
+
<title>AI Exodus Portal — Setup</title>
|
|
904
|
+
<style>${baseCSS()}</style>
|
|
905
|
+
</head>
|
|
906
|
+
<body>
|
|
907
|
+
<div class="setup-container">
|
|
908
|
+
<div class="logo">AI EXODUS</div>
|
|
909
|
+
<p class="tagline">Your AI relationship belongs to you.</p>
|
|
910
|
+
<h2>Set Up Your Portal</h2>
|
|
911
|
+
<form id="setupForm">
|
|
912
|
+
<label>Portal Password</label>
|
|
913
|
+
<input type="password" id="password" required minlength="6" placeholder="At least 6 characters">
|
|
914
|
+
<label>Your AI's Name</label>
|
|
915
|
+
<input type="text" id="aiName" placeholder="e.g. Cass, Nova, Kai">
|
|
916
|
+
<label>Your Name</label>
|
|
917
|
+
<input type="text" id="userName" placeholder="Your name">
|
|
918
|
+
<button type="submit">Create Portal</button>
|
|
919
|
+
<div id="error" class="error" style="display:none"></div>
|
|
920
|
+
</form>
|
|
921
|
+
</div>
|
|
922
|
+
<script>
|
|
923
|
+
document.getElementById('setupForm').addEventListener('submit', async (e) => {
|
|
924
|
+
e.preventDefault();
|
|
925
|
+
const res = await fetch('/setup', {
|
|
926
|
+
method: 'POST',
|
|
927
|
+
headers: { 'Content-Type': 'application/json' },
|
|
928
|
+
body: JSON.stringify({
|
|
929
|
+
password: document.getElementById('password').value,
|
|
930
|
+
aiName: document.getElementById('aiName').value,
|
|
931
|
+
userName: document.getElementById('userName').value,
|
|
932
|
+
})
|
|
933
|
+
});
|
|
934
|
+
if (res.ok) location.reload();
|
|
935
|
+
else {
|
|
936
|
+
const data = await res.json();
|
|
937
|
+
const err = document.getElementById('error');
|
|
938
|
+
err.textContent = data.error;
|
|
939
|
+
err.style.display = 'block';
|
|
940
|
+
}
|
|
941
|
+
});
|
|
942
|
+
</script>
|
|
943
|
+
</body></html>`;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function loginPage() {
|
|
947
|
+
return `<!DOCTYPE html>
|
|
948
|
+
<html lang="en">
|
|
949
|
+
<head>
|
|
950
|
+
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
951
|
+
<title>AI Exodus Portal — Login</title>
|
|
952
|
+
<style>${baseCSS()}</style>
|
|
953
|
+
</head>
|
|
954
|
+
<body>
|
|
955
|
+
<div class="setup-container">
|
|
956
|
+
<div class="logo">AI EXODUS</div>
|
|
957
|
+
<p class="tagline">Your AI relationship belongs to you.</p>
|
|
958
|
+
<form id="loginForm">
|
|
959
|
+
<label>Password</label>
|
|
960
|
+
<input type="password" id="password" required autofocus>
|
|
961
|
+
<button type="submit">Enter</button>
|
|
962
|
+
<div id="error" class="error" style="display:none"></div>
|
|
963
|
+
</form>
|
|
964
|
+
</div>
|
|
965
|
+
<script>
|
|
966
|
+
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
|
967
|
+
e.preventDefault();
|
|
968
|
+
const res = await fetch('/login', {
|
|
969
|
+
method: 'POST',
|
|
970
|
+
headers: { 'Content-Type': 'application/json' },
|
|
971
|
+
body: JSON.stringify({ password: document.getElementById('password').value })
|
|
972
|
+
});
|
|
973
|
+
if (res.ok) location.reload();
|
|
974
|
+
else {
|
|
975
|
+
const err = document.getElementById('error');
|
|
976
|
+
err.textContent = 'Wrong password';
|
|
977
|
+
err.style.display = 'block';
|
|
978
|
+
}
|
|
979
|
+
});
|
|
980
|
+
</script>
|
|
981
|
+
</body></html>`;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
// ═══════════════════════════════════════════
|
|
986
|
+
// MAIN PORTAL PAGE
|
|
987
|
+
// ═══════════════════════════════════════════
|
|
988
|
+
|
|
989
|
+
function portalPage() {
|
|
990
|
+
return `<!DOCTYPE html>
|
|
991
|
+
<html lang="en">
|
|
992
|
+
<head>
|
|
993
|
+
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
994
|
+
<title>AI Exodus Portal</title>
|
|
995
|
+
<style>
|
|
996
|
+
${baseCSS()}
|
|
997
|
+
${portalCSS()}
|
|
998
|
+
</style>
|
|
999
|
+
</head>
|
|
1000
|
+
<body>
|
|
1001
|
+
<nav class="sidebar">
|
|
1002
|
+
<div class="logo-small">EXODUS</div>
|
|
1003
|
+
<a href="#" class="nav-item active" data-tab="dashboard">Dashboard</a>
|
|
1004
|
+
<a href="#" class="nav-item" data-tab="conversations">Conversations</a>
|
|
1005
|
+
<a href="#" class="nav-item" data-tab="skills">Skills</a>
|
|
1006
|
+
<a href="#" class="nav-item" data-tab="memories">Memories</a>
|
|
1007
|
+
<a href="#" class="nav-item" data-tab="persona">Persona</a>
|
|
1008
|
+
<a href="#" class="nav-item" data-tab="narrative">Story</a>
|
|
1009
|
+
<a href="#" class="nav-item" data-tab="analytics">Analytics</a>
|
|
1010
|
+
<a href="#" class="nav-item" data-tab="guide">How to Use</a>
|
|
1011
|
+
<a href="#" class="nav-item" data-tab="settings">Settings</a>
|
|
1012
|
+
</nav>
|
|
1013
|
+
<main class="content">
|
|
1014
|
+
<div id="tab-dashboard" class="tab active">${dashboardTab()}</div>
|
|
1015
|
+
<div id="tab-conversations" class="tab">${conversationsTab()}</div>
|
|
1016
|
+
<div id="tab-skills" class="tab">${skillsTab()}</div>
|
|
1017
|
+
<div id="tab-memories" class="tab">${memoriesTab()}</div>
|
|
1018
|
+
<div id="tab-persona" class="tab">${personaTab()}</div>
|
|
1019
|
+
<div id="tab-narrative" class="tab">${narrativeTab()}</div>
|
|
1020
|
+
<div id="tab-analytics" class="tab">${analyticsTab()}</div>
|
|
1021
|
+
<div id="tab-guide" class="tab">${guideTab()}</div>
|
|
1022
|
+
<div id="tab-settings" class="tab">${settingsTab()}</div>
|
|
1023
|
+
</main>
|
|
1024
|
+
|
|
1025
|
+
<!-- Modal for editing -->
|
|
1026
|
+
<div id="modal" class="modal" style="display:none">
|
|
1027
|
+
<div class="modal-content">
|
|
1028
|
+
<div class="modal-header">
|
|
1029
|
+
<h3 id="modal-title"></h3>
|
|
1030
|
+
<button class="modal-close" onclick="closeModal()">×</button>
|
|
1031
|
+
</div>
|
|
1032
|
+
<div id="modal-body"></div>
|
|
1033
|
+
<div class="modal-footer">
|
|
1034
|
+
<button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
|
|
1035
|
+
<button class="btn btn-primary" id="modal-save">Save</button>
|
|
1036
|
+
</div>
|
|
1037
|
+
</div>
|
|
1038
|
+
</div>
|
|
1039
|
+
|
|
1040
|
+
<script>
|
|
1041
|
+
${portalJS()}
|
|
1042
|
+
</script>
|
|
1043
|
+
</body></html>`;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
|
|
1047
|
+
// ═══════════════════════════════════════════
|
|
1048
|
+
// TAB CONTENT
|
|
1049
|
+
// ═══════════════════════════════════════════
|
|
1050
|
+
|
|
1051
|
+
function dashboardTab() {
|
|
1052
|
+
return `
|
|
1053
|
+
<h1>Dashboard</h1>
|
|
1054
|
+
|
|
1055
|
+
<!-- Upload zone (shown when empty, always available) -->
|
|
1056
|
+
<div id="upload-zone" class="upload-zone">
|
|
1057
|
+
<div class="upload-dropzone" id="dropzone">
|
|
1058
|
+
<div class="upload-icon">📦</div>
|
|
1059
|
+
<div class="upload-title">Import Your Chat History</div>
|
|
1060
|
+
<div class="upload-subtitle">Drop your ChatGPT export here<br>or click to browse</div>
|
|
1061
|
+
<div class="upload-formats">Supports: conversations.json, conversations-*.json shards, or the export folder</div>
|
|
1062
|
+
<input type="file" id="file-input" accept=".json" multiple style="display:none">
|
|
1063
|
+
</div>
|
|
1064
|
+
<div id="upload-progress" class="upload-progress" style="display:none">
|
|
1065
|
+
<div class="progress-header">
|
|
1066
|
+
<span id="upload-status">Parsing...</span>
|
|
1067
|
+
<span id="upload-percent">0%</span>
|
|
1068
|
+
</div>
|
|
1069
|
+
<div class="progress-track"><div class="progress-fill" id="upload-bar" style="width:0%"></div></div>
|
|
1070
|
+
<div id="upload-detail" class="upload-detail"></div>
|
|
1071
|
+
</div>
|
|
1072
|
+
</div>
|
|
1073
|
+
|
|
1074
|
+
<div id="stats-grid" class="stats-grid">
|
|
1075
|
+
<div class="stat-card"><div class="stat-number" id="stat-convos">-</div><div class="stat-label">Conversations</div></div>
|
|
1076
|
+
<div class="stat-card"><div class="stat-number" id="stat-msgs">-</div><div class="stat-label">Messages</div></div>
|
|
1077
|
+
<div class="stat-card"><div class="stat-number" id="stat-skills">-</div><div class="stat-label">Skills</div></div>
|
|
1078
|
+
<div class="stat-card"><div class="stat-number" id="stat-memories">-</div><div class="stat-label">Memories</div></div>
|
|
1079
|
+
</div>
|
|
1080
|
+
<div class="info-row">
|
|
1081
|
+
<div class="info-card" id="model-breakdown"><h3>Models</h3><div class="info-content" id="model-list">Loading...</div></div>
|
|
1082
|
+
<div class="info-card" id="date-range-card"><h3>Date Range</h3><div class="info-content" id="date-range">Loading...</div></div>
|
|
1083
|
+
</div>`;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
function conversationsTab() {
|
|
1087
|
+
return `
|
|
1088
|
+
<div class="tab-header">
|
|
1089
|
+
<h1>Conversations</h1>
|
|
1090
|
+
<div class="search-bar">
|
|
1091
|
+
<input type="text" id="convo-search" placeholder="Search messages...">
|
|
1092
|
+
<button class="btn btn-primary" onclick="searchConversations()">Search</button>
|
|
1093
|
+
</div>
|
|
1094
|
+
</div>
|
|
1095
|
+
<div class="filters">
|
|
1096
|
+
<select id="convo-model-filter"><option value="">All Models</option></select>
|
|
1097
|
+
<input type="date" id="convo-from" placeholder="From">
|
|
1098
|
+
<input type="date" id="convo-to" placeholder="To">
|
|
1099
|
+
<button class="btn btn-secondary" onclick="loadConversations()">Filter</button>
|
|
1100
|
+
</div>
|
|
1101
|
+
<div id="convo-list" class="convo-list">Loading...</div>
|
|
1102
|
+
<div id="convo-pagination" class="pagination"></div>
|
|
1103
|
+
<div id="convo-detail" class="convo-detail" style="display:none">
|
|
1104
|
+
<button class="btn btn-secondary" onclick="closeConversation()">Back</button>
|
|
1105
|
+
<h2 id="convo-detail-title"></h2>
|
|
1106
|
+
<div id="convo-messages" class="messages"></div>
|
|
1107
|
+
</div>`;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
function skillsTab() {
|
|
1111
|
+
return `
|
|
1112
|
+
<div class="tab-header">
|
|
1113
|
+
<h1>Skills</h1>
|
|
1114
|
+
<div class="tab-actions">
|
|
1115
|
+
<button class="btn btn-primary" onclick="downloadAllSkills()">Download All (.md)</button>
|
|
1116
|
+
<button class="btn btn-secondary" onclick="openSkillEditor()">+ Add Skill</button>
|
|
1117
|
+
<button class="btn btn-secondary" onclick="openCategoryManager('skill')">Manage Categories</button>
|
|
1118
|
+
</div>
|
|
1119
|
+
</div>
|
|
1120
|
+
<div id="skills-list" class="cards-grid">Loading...</div>`;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
function memoriesTab() {
|
|
1124
|
+
return `
|
|
1125
|
+
<div class="tab-header">
|
|
1126
|
+
<h1>Memories</h1>
|
|
1127
|
+
<div class="tab-actions">
|
|
1128
|
+
<button class="btn btn-primary" onclick="downloadAllMemories()">Download (.md)</button>
|
|
1129
|
+
<button class="btn btn-secondary" onclick="openMemoryEditor()">+ Add Memory</button>
|
|
1130
|
+
<button class="btn btn-secondary" onclick="openCategoryManager('memory')">Manage Categories</button>
|
|
1131
|
+
</div>
|
|
1132
|
+
</div>
|
|
1133
|
+
<div class="filters">
|
|
1134
|
+
<select id="memory-cat-filter" onchange="loadMemories()"><option value="">All Categories</option></select>
|
|
1135
|
+
</div>
|
|
1136
|
+
<div id="memories-list" class="memories-list">Loading...</div>`;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
function personaTab() {
|
|
1140
|
+
return `
|
|
1141
|
+
<div class="tab-header">
|
|
1142
|
+
<h1>Persona</h1>
|
|
1143
|
+
<div class="tab-actions">
|
|
1144
|
+
<button class="btn btn-primary" onclick="downloadPersona()">Download (.md)</button>
|
|
1145
|
+
<button class="btn btn-secondary" onclick="savePersona()">Save Changes</button>
|
|
1146
|
+
</div>
|
|
1147
|
+
</div>
|
|
1148
|
+
<div id="persona-editor" class="persona-editor">
|
|
1149
|
+
<textarea id="persona-content" placeholder="Your AI's persona definition will appear here after analysis..."></textarea>
|
|
1150
|
+
</div>`;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
function narrativeTab() {
|
|
1154
|
+
return `
|
|
1155
|
+
<h1>Your Story</h1>
|
|
1156
|
+
<div id="narrative-content" class="narrative">Loading...</div>`;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
function analyticsTab() {
|
|
1160
|
+
return `
|
|
1161
|
+
<h1>Analytics</h1>
|
|
1162
|
+
<div class="analytics-grid">
|
|
1163
|
+
<div class="analytics-card full-width">
|
|
1164
|
+
<h3>Activity Over Time</h3>
|
|
1165
|
+
<div id="activity-chart" class="chart-area"></div>
|
|
1166
|
+
</div>
|
|
1167
|
+
<div class="analytics-card">
|
|
1168
|
+
<h3>Model Distribution</h3>
|
|
1169
|
+
<div id="model-chart" class="chart-area"></div>
|
|
1170
|
+
</div>
|
|
1171
|
+
<div class="analytics-card">
|
|
1172
|
+
<h3>Messages by Role</h3>
|
|
1173
|
+
<div id="role-chart" class="chart-area"></div>
|
|
1174
|
+
</div>
|
|
1175
|
+
<div class="analytics-card">
|
|
1176
|
+
<h3>Day of Week</h3>
|
|
1177
|
+
<div id="dow-chart" class="chart-area"></div>
|
|
1178
|
+
</div>
|
|
1179
|
+
<div class="analytics-card">
|
|
1180
|
+
<h3>Hour of Day</h3>
|
|
1181
|
+
<div id="hod-chart" class="chart-area"></div>
|
|
1182
|
+
</div>
|
|
1183
|
+
<div class="analytics-card">
|
|
1184
|
+
<h3>Time Spent</h3>
|
|
1185
|
+
<div id="time-chart" class="chart-area"></div>
|
|
1186
|
+
</div>
|
|
1187
|
+
<div class="analytics-card">
|
|
1188
|
+
<h3>Average Message Length</h3>
|
|
1189
|
+
<div id="length-chart" class="chart-area"></div>
|
|
1190
|
+
</div>
|
|
1191
|
+
<div class="analytics-card">
|
|
1192
|
+
<h3>Sources</h3>
|
|
1193
|
+
<div id="source-chart" class="chart-area"></div>
|
|
1194
|
+
</div>
|
|
1195
|
+
<div class="analytics-card full-width">
|
|
1196
|
+
<h3>Most Used Words</h3>
|
|
1197
|
+
<div id="word-cloud" class="word-cloud"></div>
|
|
1198
|
+
</div>
|
|
1199
|
+
<div class="analytics-card full-width">
|
|
1200
|
+
<h3>Longest Conversations</h3>
|
|
1201
|
+
<div id="longest-convos"></div>
|
|
1202
|
+
</div>
|
|
1203
|
+
</div>`;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
function guideTab() {
|
|
1207
|
+
return `
|
|
1208
|
+
<h1>How to Use</h1>
|
|
1209
|
+
<div class="guide">
|
|
1210
|
+
|
|
1211
|
+
<div class="guide-section">
|
|
1212
|
+
<h2>I just want to browse my old conversations</h2>
|
|
1213
|
+
<p>You're done! Your conversations are already imported. Use the <strong>Conversations</strong> tab to browse, search, and filter by model or date.</p>
|
|
1214
|
+
</div>
|
|
1215
|
+
|
|
1216
|
+
<div class="guide-section">
|
|
1217
|
+
<h2>I want AI Exodus to analyze my conversations</h2>
|
|
1218
|
+
<p>Analysis extracts your AI's personality, your memories, skills, and the relationship story. You need <a href="https://www.npmjs.com/package/@anthropic-ai/claude-code" target="_blank">Claude Code CLI</a> installed and logged in.</p>
|
|
1219
|
+
<div class="guide-command">
|
|
1220
|
+
<code>npx ai-exodus analyze --passes all</code>
|
|
1221
|
+
<button class="btn btn-secondary btn-sm" onclick="copyCommand(this)">Copy</button>
|
|
1222
|
+
</div>
|
|
1223
|
+
<p class="guide-note">This runs on your Claude subscription. No API key needed. Takes 15-60 minutes depending on how many conversations you have.</p>
|
|
1224
|
+
</div>
|
|
1225
|
+
|
|
1226
|
+
<div class="guide-section">
|
|
1227
|
+
<h2>I only want personality extraction</h2>
|
|
1228
|
+
<div class="guide-command">
|
|
1229
|
+
<code>npx ai-exodus analyze --passes persona</code>
|
|
1230
|
+
<button class="btn btn-secondary btn-sm" onclick="copyCommand(this)">Copy</button>
|
|
1231
|
+
</div>
|
|
1232
|
+
</div>
|
|
1233
|
+
|
|
1234
|
+
<div class="guide-section">
|
|
1235
|
+
<h2>I only want memories about me</h2>
|
|
1236
|
+
<div class="guide-command">
|
|
1237
|
+
<code>npx ai-exodus analyze --passes memory</code>
|
|
1238
|
+
<button class="btn btn-secondary btn-sm" onclick="copyCommand(this)">Copy</button>
|
|
1239
|
+
</div>
|
|
1240
|
+
</div>
|
|
1241
|
+
|
|
1242
|
+
<div class="guide-section">
|
|
1243
|
+
<h2>I only want skills with activation triggers</h2>
|
|
1244
|
+
<div class="guide-command">
|
|
1245
|
+
<code>npx ai-exodus analyze --passes skills</code>
|
|
1246
|
+
<button class="btn btn-secondary btn-sm" onclick="copyCommand(this)">Copy</button>
|
|
1247
|
+
</div>
|
|
1248
|
+
</div>
|
|
1249
|
+
|
|
1250
|
+
<div class="guide-section">
|
|
1251
|
+
<h2>I only want the relationship story</h2>
|
|
1252
|
+
<div class="guide-command">
|
|
1253
|
+
<code>npx ai-exodus analyze --passes relationship</code>
|
|
1254
|
+
<button class="btn btn-secondary btn-sm" onclick="copyCommand(this)">Copy</button>
|
|
1255
|
+
</div>
|
|
1256
|
+
</div>
|
|
1257
|
+
|
|
1258
|
+
<div class="guide-section">
|
|
1259
|
+
<h2>I want to analyze only specific dates or models</h2>
|
|
1260
|
+
<div class="guide-command">
|
|
1261
|
+
<code>npx ai-exodus analyze --passes all --from 2025-01-01 --to 2025-06-30</code>
|
|
1262
|
+
<button class="btn btn-secondary btn-sm" onclick="copyCommand(this)">Copy</button>
|
|
1263
|
+
</div>
|
|
1264
|
+
<div class="guide-command">
|
|
1265
|
+
<code>npx ai-exodus analyze --passes all --only-models gpt-4o</code>
|
|
1266
|
+
<button class="btn btn-secondary btn-sm" onclick="copyCommand(this)">Copy</button>
|
|
1267
|
+
</div>
|
|
1268
|
+
</div>
|
|
1269
|
+
|
|
1270
|
+
<div class="guide-section">
|
|
1271
|
+
<h2>I want intimate/NSFW content included</h2>
|
|
1272
|
+
<p>By default, analysis skips intimate and explicit content. If your AI relationship included that side of things and you want it preserved, add the <code>--nsfw</code> flag:</p>
|
|
1273
|
+
<div class="guide-command">
|
|
1274
|
+
<code>npx ai-exodus analyze --passes all --nsfw</code>
|
|
1275
|
+
<button class="btn btn-secondary btn-sm" onclick="copyCommand(this)">Copy</button>
|
|
1276
|
+
</div>
|
|
1277
|
+
<p class="guide-note">This extracts intimate skills, relationship dynamics, and NSFW memories. Everything stays on your portal — private to you.</p>
|
|
1278
|
+
</div>
|
|
1279
|
+
|
|
1280
|
+
<div class="guide-section">
|
|
1281
|
+
<h2>I want to save tokens (cheaper analysis)</h2>
|
|
1282
|
+
<div class="guide-command">
|
|
1283
|
+
<code>npx ai-exodus analyze --passes all --fast</code>
|
|
1284
|
+
<button class="btn btn-secondary btn-sm" onclick="copyCommand(this)">Copy</button>
|
|
1285
|
+
</div>
|
|
1286
|
+
<p>The <code>--fast</code> flag uses Haiku (cheaper, faster model) for two of the five passes. Here's exactly what runs on what:</p>
|
|
1287
|
+
<table class="guide-table">
|
|
1288
|
+
<tr><th>Pass</th><th>What it does</th><th>Default</th><th>With --fast</th></tr>
|
|
1289
|
+
<tr><td>1. Index</td><td>Maps your conversations — topics, patterns, structure</td><td>Sonnet</td><td>Haiku</td></tr>
|
|
1290
|
+
<tr><td>2. Personality</td><td>Extracts your AI's voice, behavior, quirks</td><td>Sonnet</td><td>Sonnet</td></tr>
|
|
1291
|
+
<tr><td>3. Memory</td><td>Extracts everything about you — facts, preferences, history</td><td>Sonnet</td><td>Sonnet</td></tr>
|
|
1292
|
+
<tr><td>4. Skills</td><td>Detects what your AI did and what triggers each skill</td><td>Sonnet</td><td>Haiku</td></tr>
|
|
1293
|
+
<tr><td>5. Relationship</td><td>Writes the story of your relationship</td><td>Sonnet</td><td>Sonnet</td></tr>
|
|
1294
|
+
</table>
|
|
1295
|
+
<p class="guide-note">Personality, memory, and relationship always run on Sonnet — they need the depth. Indexing and skills are structural work where Haiku does fine. Saves ~30% of tokens.</p>
|
|
1296
|
+
</div>
|
|
1297
|
+
|
|
1298
|
+
<div class="guide-section">
|
|
1299
|
+
<h2>I want to download my results</h2>
|
|
1300
|
+
<p>After analysis, go to the <strong>Skills</strong>, <strong>Memories</strong>, or <strong>Persona</strong> tabs and hit the <strong>Download</strong> button. Files come as <code>.md</code> ready to drop into Claude Code, Claude Desktop, or any MCP-compatible tool.</p>
|
|
1301
|
+
</div>
|
|
1302
|
+
|
|
1303
|
+
<div class="guide-section">
|
|
1304
|
+
<h2>I want Claude to search my archive live</h2>
|
|
1305
|
+
<p>Connect your portal to Claude using the MCP connector. Your MCP URL:</p>
|
|
1306
|
+
<div class="guide-command" id="guide-mcp-url">
|
|
1307
|
+
<code>Loading...</code>
|
|
1308
|
+
</div>
|
|
1309
|
+
<p class="guide-note">Add this as a remote MCP server in Claude Desktop or Claude Code settings. Claude gets tools to search conversations, read skills, browse memories, and more.</p>
|
|
1310
|
+
</div>
|
|
1311
|
+
|
|
1312
|
+
<div class="guide-section">
|
|
1313
|
+
<h2>I want to import more conversations later</h2>
|
|
1314
|
+
<p>Just drag and drop more files on the <strong>Dashboard</strong>. Duplicates are automatically skipped.</p>
|
|
1315
|
+
</div>
|
|
1316
|
+
|
|
1317
|
+
<div class="guide-section">
|
|
1318
|
+
<h2>Requirements</h2>
|
|
1319
|
+
<ul>
|
|
1320
|
+
<li><strong>Browsing & upload:</strong> Just this portal. Nothing else needed.</li>
|
|
1321
|
+
<li><strong>Analysis:</strong> <a href="https://www.npmjs.com/package/@anthropic-ai/claude-code" target="_blank">Claude Code CLI</a> + active Claude subscription (Max or Pro)</li>
|
|
1322
|
+
<li><strong>MCP connection:</strong> Claude Desktop or Claude Code</li>
|
|
1323
|
+
</ul>
|
|
1324
|
+
</div>
|
|
1325
|
+
|
|
1326
|
+
</div>`;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
function settingsTab() {
|
|
1330
|
+
return `
|
|
1331
|
+
<h1>Settings</h1>
|
|
1332
|
+
<div class="settings-form">
|
|
1333
|
+
<label>AI Name</label>
|
|
1334
|
+
<input type="text" id="setting-ai-name">
|
|
1335
|
+
<label>Your Name</label>
|
|
1336
|
+
<input type="text" id="setting-user-name">
|
|
1337
|
+
<label>MCP Secret (for Claude integration)</label>
|
|
1338
|
+
<input type="text" id="setting-mcp-secret" readonly>
|
|
1339
|
+
<p class="help-text">Use this URL with Claude's MCP connector:<br>
|
|
1340
|
+
<code id="mcp-url"></code></p>
|
|
1341
|
+
<button class="btn btn-primary" onclick="saveSettings()">Save</button>
|
|
1342
|
+
</div>`;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
|
|
1346
|
+
// ═══════════════════════════════════════════
|
|
1347
|
+
// CSS
|
|
1348
|
+
// ═══════════════════════════════════════════
|
|
1349
|
+
|
|
1350
|
+
function baseCSS() {
|
|
1351
|
+
return `
|
|
1352
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
1353
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; background: #0a0a0f; color: #e0e0e8; min-height: 100vh; }
|
|
1354
|
+
a { color: #a78bfa; text-decoration: none; }
|
|
1355
|
+
a:hover { color: #c4b5fd; }
|
|
1356
|
+
input, select, textarea { background: #1a1a2e; border: 1px solid #2a2a3e; color: #e0e0e8; padding: 10px 14px; border-radius: 8px; font-size: 14px; width: 100%; }
|
|
1357
|
+
input:focus, select:focus, textarea:focus { outline: none; border-color: #a78bfa; box-shadow: 0 0 0 2px rgba(167,139,250,0.2); }
|
|
1358
|
+
button { cursor: pointer; border: none; padding: 10px 20px; border-radius: 8px; font-size: 14px; font-weight: 500; transition: all 0.15s; }
|
|
1359
|
+
.btn-primary { background: #7c3aed; color: white; }
|
|
1360
|
+
.btn-primary:hover { background: #6d28d9; }
|
|
1361
|
+
.btn-secondary { background: #2a2a3e; color: #e0e0e8; }
|
|
1362
|
+
.btn-secondary:hover { background: #3a3a4e; }
|
|
1363
|
+
.btn-danger { background: #dc2626; color: white; }
|
|
1364
|
+
.btn-danger:hover { background: #b91c1c; }
|
|
1365
|
+
.btn-sm { padding: 6px 12px; font-size: 12px; }
|
|
1366
|
+
label { display: block; margin: 16px 0 6px; font-size: 13px; color: #9ca3af; font-weight: 500; text-transform: uppercase; letter-spacing: 0.5px; }
|
|
1367
|
+
.error { color: #f87171; margin-top: 12px; font-size: 14px; }
|
|
1368
|
+
.help-text { font-size: 12px; color: #6b7280; margin-top: 4px; }
|
|
1369
|
+
code { background: #1a1a2e; padding: 2px 8px; border-radius: 4px; font-size: 13px; color: #a78bfa; }
|
|
1370
|
+
|
|
1371
|
+
.setup-container { max-width: 400px; margin: 15vh auto; padding: 40px; background: #12121e; border-radius: 16px; border: 1px solid #2a2a3e; }
|
|
1372
|
+
.logo { font-size: 32px; font-weight: 800; letter-spacing: 4px; text-align: center; background: linear-gradient(135deg, #a78bfa, #22d3ee); -webkit-background-clip: text; -webkit-text-fill-color: transparent; margin-bottom: 8px; }
|
|
1373
|
+
.tagline { text-align: center; color: #6b7280; font-size: 14px; margin-bottom: 32px; }
|
|
1374
|
+
`;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
function portalCSS() {
|
|
1378
|
+
return `
|
|
1379
|
+
body { display: flex; }
|
|
1380
|
+
.sidebar { width: 220px; min-height: 100vh; background: #12121e; border-right: 1px solid #1e1e2e; padding: 24px 0; position: fixed; left: 0; top: 0; z-index: 10; }
|
|
1381
|
+
.logo-small { font-size: 18px; font-weight: 800; letter-spacing: 3px; padding: 0 24px 24px; background: linear-gradient(135deg, #a78bfa, #22d3ee); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
|
1382
|
+
.nav-item { display: block; padding: 12px 24px; color: #9ca3af; font-size: 14px; transition: all 0.15s; border-left: 3px solid transparent; }
|
|
1383
|
+
.nav-item:hover { color: #e0e0e8; background: rgba(167,139,250,0.05); }
|
|
1384
|
+
.nav-item.active { color: #a78bfa; border-left-color: #a78bfa; background: rgba(167,139,250,0.08); }
|
|
1385
|
+
.content { margin-left: 220px; padding: 32px 40px; flex: 1; min-height: 100vh; }
|
|
1386
|
+
.tab { display: none; }
|
|
1387
|
+
.tab.active { display: block; }
|
|
1388
|
+
h1 { font-size: 24px; margin-bottom: 24px; font-weight: 700; }
|
|
1389
|
+
h2 { font-size: 18px; margin-bottom: 16px; }
|
|
1390
|
+
h3 { font-size: 16px; margin-bottom: 8px; }
|
|
1391
|
+
|
|
1392
|
+
.tab-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; flex-wrap: wrap; gap: 12px; }
|
|
1393
|
+
.tab-header h1 { margin-bottom: 0; }
|
|
1394
|
+
.tab-actions { display: flex; gap: 8px; }
|
|
1395
|
+
|
|
1396
|
+
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin-bottom: 24px; }
|
|
1397
|
+
.stat-card { background: #12121e; border: 1px solid #1e1e2e; border-radius: 12px; padding: 24px; text-align: center; }
|
|
1398
|
+
.stat-number { font-size: 36px; font-weight: 700; background: linear-gradient(135deg, #a78bfa, #22d3ee); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
|
1399
|
+
.stat-label { font-size: 13px; color: #6b7280; margin-top: 4px; text-transform: uppercase; letter-spacing: 1px; }
|
|
1400
|
+
|
|
1401
|
+
.info-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
|
1402
|
+
.info-card { background: #12121e; border: 1px solid #1e1e2e; border-radius: 12px; padding: 24px; }
|
|
1403
|
+
.info-card h3 { color: #9ca3af; font-size: 13px; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 12px; }
|
|
1404
|
+
|
|
1405
|
+
.search-bar { display: flex; gap: 8px; }
|
|
1406
|
+
.search-bar input { width: 300px; }
|
|
1407
|
+
|
|
1408
|
+
.filters { display: flex; gap: 12px; margin-bottom: 20px; align-items: center; }
|
|
1409
|
+
.filters select, .filters input { width: auto; min-width: 150px; }
|
|
1410
|
+
|
|
1411
|
+
.convo-list { display: flex; flex-direction: column; gap: 4px; }
|
|
1412
|
+
.convo-item { display: flex; align-items: center; padding: 14px 18px; background: #12121e; border: 1px solid #1e1e2e; border-radius: 8px; cursor: pointer; transition: all 0.15s; gap: 12px; }
|
|
1413
|
+
.convo-item:hover { border-color: #a78bfa; background: #16162a; }
|
|
1414
|
+
.convo-title { flex: 1; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
1415
|
+
.convo-meta { font-size: 12px; color: #6b7280; }
|
|
1416
|
+
.convo-model { font-size: 11px; padding: 2px 8px; border-radius: 4px; font-weight: 500; }
|
|
1417
|
+
.model-gpt4 { background: rgba(16,185,129,0.15); color: #10b981; }
|
|
1418
|
+
.model-gpt35 { background: rgba(59,130,246,0.15); color: #3b82f6; }
|
|
1419
|
+
.model-gpt4o { background: rgba(168,85,247,0.15); color: #a855f7; }
|
|
1420
|
+
.model-other { background: rgba(107,114,128,0.15); color: #9ca3af; }
|
|
1421
|
+
|
|
1422
|
+
.messages { display: flex; flex-direction: column; gap: 12px; margin-top: 16px; }
|
|
1423
|
+
.msg { padding: 14px 18px; border-radius: 12px; max-width: 85%; font-size: 14px; line-height: 1.6; white-space: pre-wrap; word-wrap: break-word; }
|
|
1424
|
+
.msg-user { background: #1e1e3a; align-self: flex-end; border-bottom-right-radius: 4px; }
|
|
1425
|
+
.msg-assistant { background: #12121e; border: 1px solid #1e1e2e; align-self: flex-start; border-bottom-left-radius: 4px; }
|
|
1426
|
+
.msg-role { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; color: #6b7280; }
|
|
1427
|
+
.msg-user .msg-role { color: #a78bfa; }
|
|
1428
|
+
.msg-assistant .msg-role { color: #22d3ee; }
|
|
1429
|
+
.msg-model { font-size: 11px; color: #6b7280; margin-top: 6px; }
|
|
1430
|
+
|
|
1431
|
+
.cards-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 16px; }
|
|
1432
|
+
.skill-card { background: #12121e; border: 1px solid #1e1e2e; border-radius: 12px; padding: 20px; position: relative; }
|
|
1433
|
+
.skill-card:hover { border-color: #2a2a4e; }
|
|
1434
|
+
.skill-name { font-size: 16px; font-weight: 600; margin-bottom: 8px; }
|
|
1435
|
+
.skill-category { display: inline-block; font-size: 11px; padding: 2px 10px; border-radius: 12px; margin-bottom: 10px; }
|
|
1436
|
+
.skill-desc { font-size: 13px; color: #9ca3af; margin-bottom: 12px; line-height: 1.5; }
|
|
1437
|
+
.skill-trigger { font-size: 12px; color: #22d3ee; margin-bottom: 4px; }
|
|
1438
|
+
.skill-trigger strong { color: #9ca3af; }
|
|
1439
|
+
.skill-actions { display: flex; gap: 6px; position: absolute; top: 16px; right: 16px; }
|
|
1440
|
+
|
|
1441
|
+
.memories-list { display: flex; flex-direction: column; gap: 6px; }
|
|
1442
|
+
.memory-item { display: flex; align-items: center; padding: 12px 16px; background: #12121e; border: 1px solid #1e1e2e; border-radius: 8px; gap: 12px; }
|
|
1443
|
+
.memory-category { font-size: 11px; padding: 2px 10px; border-radius: 12px; min-width: 80px; text-align: center; }
|
|
1444
|
+
.memory-key { font-size: 13px; font-weight: 500; color: #a78bfa; min-width: 120px; }
|
|
1445
|
+
.memory-value { font-size: 13px; flex: 1; color: #d1d5db; }
|
|
1446
|
+
.memory-actions { display: flex; gap: 6px; }
|
|
1447
|
+
|
|
1448
|
+
.persona-editor textarea { width: 100%; min-height: 500px; font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: 13px; line-height: 1.6; padding: 20px; background: #12121e; border: 1px solid #1e1e2e; border-radius: 12px; resize: vertical; }
|
|
1449
|
+
|
|
1450
|
+
.narrative { background: #12121e; border: 1px solid #1e1e2e; border-radius: 12px; padding: 32px; font-size: 15px; line-height: 1.8; white-space: pre-wrap; }
|
|
1451
|
+
|
|
1452
|
+
.settings-form { max-width: 500px; }
|
|
1453
|
+
|
|
1454
|
+
.pagination { display: flex; gap: 8px; margin-top: 16px; justify-content: center; }
|
|
1455
|
+
.pagination button { padding: 8px 14px; }
|
|
1456
|
+
.pagination button.active { background: #7c3aed; color: white; }
|
|
1457
|
+
|
|
1458
|
+
.modal { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; z-index: 100; }
|
|
1459
|
+
.modal-content { background: #12121e; border: 1px solid #2a2a3e; border-radius: 16px; width: 90%; max-width: 600px; max-height: 85vh; overflow-y: auto; }
|
|
1460
|
+
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 20px 24px 0; }
|
|
1461
|
+
.modal-header h3 { font-size: 18px; }
|
|
1462
|
+
.modal-close { background: none; color: #6b7280; font-size: 24px; padding: 0; line-height: 1; }
|
|
1463
|
+
.modal-close:hover { color: #e0e0e8; }
|
|
1464
|
+
.modal-body-inner { padding: 16px 24px; }
|
|
1465
|
+
.modal-footer { padding: 16px 24px; display: flex; gap: 8px; justify-content: flex-end; border-top: 1px solid #1e1e2e; }
|
|
1466
|
+
|
|
1467
|
+
.tag-input { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px; min-height: 42px; background: #1a1a2e; border: 1px solid #2a2a3e; border-radius: 8px; cursor: text; }
|
|
1468
|
+
.tag { display: inline-flex; align-items: center; gap: 4px; background: rgba(167,139,250,0.15); color: #a78bfa; padding: 3px 10px; border-radius: 4px; font-size: 12px; }
|
|
1469
|
+
.tag button { background: none; color: #a78bfa; padding: 0 2px; font-size: 14px; }
|
|
1470
|
+
.tag-input input { background: none; border: none; outline: none; color: #e0e0e8; flex: 1; min-width: 100px; padding: 0; }
|
|
1471
|
+
|
|
1472
|
+
.category-manager { display: flex; flex-direction: column; gap: 8px; }
|
|
1473
|
+
.cat-row { display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: #1a1a2e; border-radius: 8px; }
|
|
1474
|
+
.cat-color { width: 20px; height: 20px; border-radius: 50%; border: none; cursor: pointer; }
|
|
1475
|
+
.cat-name { flex: 1; font-size: 14px; }
|
|
1476
|
+
.cat-default { font-size: 11px; color: #6b7280; }
|
|
1477
|
+
|
|
1478
|
+
.upload-zone { margin-bottom: 24px; }
|
|
1479
|
+
.upload-dropzone { border: 2px dashed #2a2a3e; border-radius: 16px; padding: 48px; text-align: center; cursor: pointer; transition: all 0.2s; }
|
|
1480
|
+
.upload-dropzone:hover, .upload-dropzone.drag-over { border-color: #a78bfa; background: rgba(167,139,250,0.05); }
|
|
1481
|
+
.upload-icon { font-size: 48px; margin-bottom: 16px; }
|
|
1482
|
+
.upload-title { font-size: 20px; font-weight: 600; margin-bottom: 8px; }
|
|
1483
|
+
.upload-subtitle { font-size: 14px; color: #9ca3af; margin-bottom: 12px; line-height: 1.5; }
|
|
1484
|
+
.upload-formats { font-size: 12px; color: #6b7280; }
|
|
1485
|
+
.upload-progress { background: #12121e; border: 1px solid #1e1e2e; border-radius: 12px; padding: 24px; margin-top: 16px; }
|
|
1486
|
+
.progress-header { display: flex; justify-content: space-between; margin-bottom: 8px; font-size: 14px; }
|
|
1487
|
+
.progress-track { height: 8px; background: #1a1a2e; border-radius: 4px; overflow: hidden; }
|
|
1488
|
+
.progress-fill { height: 100%; background: linear-gradient(90deg, #7c3aed, #22d3ee); border-radius: 4px; transition: width 0.3s; }
|
|
1489
|
+
.upload-detail { font-size: 12px; color: #6b7280; margin-top: 8px; }
|
|
1490
|
+
|
|
1491
|
+
.guide { max-width: 700px; }
|
|
1492
|
+
.guide-section { background: #12121e; border: 1px solid #1e1e2e; border-radius: 12px; padding: 24px; margin-bottom: 16px; }
|
|
1493
|
+
.guide-section h2 { font-size: 16px; font-weight: 600; margin-bottom: 12px; color: #e0e0e8; }
|
|
1494
|
+
.guide-section p { font-size: 14px; color: #9ca3af; line-height: 1.6; margin-bottom: 8px; }
|
|
1495
|
+
.guide-section ul { font-size: 14px; color: #9ca3af; line-height: 1.8; padding-left: 20px; }
|
|
1496
|
+
.guide-command { display: flex; align-items: center; gap: 8px; background: #1a1a2e; border: 1px solid #2a2a3e; border-radius: 8px; padding: 10px 14px; margin: 8px 0; }
|
|
1497
|
+
.guide-command code { flex: 1; font-size: 13px; color: #a78bfa; background: none; padding: 0; }
|
|
1498
|
+
.guide-note { font-size: 12px; color: #6b7280; margin-top: 4px; }
|
|
1499
|
+
.guide-table { width: 100%; border-collapse: collapse; margin: 12px 0; font-size: 13px; }
|
|
1500
|
+
.guide-table th { text-align: left; padding: 8px 12px; background: #1a1a2e; color: #9ca3af; font-weight: 500; text-transform: uppercase; font-size: 11px; letter-spacing: 0.5px; }
|
|
1501
|
+
.guide-table td { padding: 8px 12px; border-bottom: 1px solid #1e1e2e; color: #d1d5db; }
|
|
1502
|
+
.guide-table tr:last-child td { border-bottom: none; }
|
|
1503
|
+
|
|
1504
|
+
.analytics-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
|
1505
|
+
.analytics-card { background: #12121e; border: 1px solid #1e1e2e; border-radius: 12px; padding: 24px; }
|
|
1506
|
+
.analytics-card h3 { color: #9ca3af; font-size: 13px; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 16px; }
|
|
1507
|
+
.analytics-card.full-width { grid-column: 1 / -1; }
|
|
1508
|
+
.chart-area { min-height: 180px; }
|
|
1509
|
+
.bar-chart { display: flex; flex-direction: column; gap: 8px; }
|
|
1510
|
+
.bar-row { display: flex; align-items: center; gap: 12px; }
|
|
1511
|
+
.bar-label { font-size: 13px; min-width: 80px; text-align: right; color: #d1d5db; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
1512
|
+
.bar-track { flex: 1; height: 28px; background: #1a1a2e; border-radius: 4px; position: relative; overflow: hidden; }
|
|
1513
|
+
.bar-fill { height: 100%; border-radius: 4px; transition: width 0.5s ease; display: flex; align-items: center; padding: 0 10px; font-size: 12px; color: white; min-width: fit-content; }
|
|
1514
|
+
.bar-count { font-size: 12px; color: #6b7280; min-width: 50px; }
|
|
1515
|
+
.word-cloud { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: center; padding: 16px; }
|
|
1516
|
+
.word-cloud span { display: inline-block; padding: 4px 12px; border-radius: 6px; background: rgba(167,139,250,0.08); transition: transform 0.15s; cursor: default; }
|
|
1517
|
+
.word-cloud span:hover { transform: scale(1.1); }
|
|
1518
|
+
.sparkline { display: flex; align-items: flex-end; gap: 2px; height: 120px; }
|
|
1519
|
+
.spark-bar { flex: 1; background: linear-gradient(to top, #7c3aed, #22d3ee); border-radius: 2px 2px 0 0; min-width: 4px; position: relative; }
|
|
1520
|
+
.spark-bar:hover { opacity: 0.8; }
|
|
1521
|
+
.spark-bar::after { content: attr(data-label); position: absolute; bottom: -20px; left: 50%; transform: translateX(-50%); font-size: 9px; color: #6b7280; white-space: nowrap; display: none; }
|
|
1522
|
+
.spark-bar:hover::after { display: block; }
|
|
1523
|
+
.spark-labels { display: flex; gap: 2px; margin-top: 4px; }
|
|
1524
|
+
.spark-labels span { flex: 1; font-size: 9px; color: #6b7280; text-align: center; min-width: 4px; }
|
|
1525
|
+
`;
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
|
|
1529
|
+
// ═══════════════════════════════════════════
|
|
1530
|
+
// JAVASCRIPT
|
|
1531
|
+
// ═══════════════════════════════════════════
|
|
1532
|
+
|
|
1533
|
+
function portalJS() {
|
|
1534
|
+
return `
|
|
1535
|
+
// ── State ──
|
|
1536
|
+
let currentTab = 'dashboard';
|
|
1537
|
+
let convoPage = 1;
|
|
1538
|
+
let skillCategories = [];
|
|
1539
|
+
let memoryCategories = [];
|
|
1540
|
+
|
|
1541
|
+
// ── Navigation ──
|
|
1542
|
+
document.querySelectorAll('.nav-item').forEach(item => {
|
|
1543
|
+
item.addEventListener('click', (e) => {
|
|
1544
|
+
e.preventDefault();
|
|
1545
|
+
const tab = item.dataset.tab;
|
|
1546
|
+
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
|
1547
|
+
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
1548
|
+
item.classList.add('active');
|
|
1549
|
+
document.getElementById('tab-' + tab).classList.add('active');
|
|
1550
|
+
currentTab = tab;
|
|
1551
|
+
if (tab === 'dashboard') loadDashboard();
|
|
1552
|
+
if (tab === 'conversations') loadConversations();
|
|
1553
|
+
if (tab === 'skills') loadSkills();
|
|
1554
|
+
if (tab === 'memories') loadMemories();
|
|
1555
|
+
if (tab === 'persona') loadPersona();
|
|
1556
|
+
if (tab === 'narrative') loadNarrative();
|
|
1557
|
+
if (tab === 'analytics') loadAnalytics();
|
|
1558
|
+
if (tab === 'guide') loadGuide();
|
|
1559
|
+
if (tab === 'settings') loadSettings();
|
|
1560
|
+
});
|
|
1561
|
+
});
|
|
1562
|
+
|
|
1563
|
+
// ── Init ──
|
|
1564
|
+
loadDashboard();
|
|
1565
|
+
initUpload();
|
|
1566
|
+
|
|
1567
|
+
// ── API helper ──
|
|
1568
|
+
async function api(path, method = 'GET', body = null) {
|
|
1569
|
+
const opts = { method, headers: {} };
|
|
1570
|
+
if (body) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); }
|
|
1571
|
+
const res = await fetch('/api/' + path, opts);
|
|
1572
|
+
return res.json();
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
// ── Dashboard ──
|
|
1576
|
+
async function loadDashboard() {
|
|
1577
|
+
const stats = await api('stats');
|
|
1578
|
+
document.getElementById('stat-convos').textContent = stats.conversations.toLocaleString();
|
|
1579
|
+
document.getElementById('stat-msgs').textContent = stats.messages.toLocaleString();
|
|
1580
|
+
document.getElementById('stat-skills').textContent = stats.skills;
|
|
1581
|
+
document.getElementById('stat-memories').textContent = stats.memories;
|
|
1582
|
+
|
|
1583
|
+
const modelHtml = (stats.models || []).map(m =>
|
|
1584
|
+
'<div style="display:flex;justify-content:space-between;padding:4px 0"><span>' +
|
|
1585
|
+
(m.model || 'unknown') + '</span><span style="color:#6b7280">' + m.count.toLocaleString() + '</span></div>'
|
|
1586
|
+
).join('') || 'No data yet';
|
|
1587
|
+
document.getElementById('model-list').innerHTML = modelHtml;
|
|
1588
|
+
|
|
1589
|
+
const dr = stats.dateRange;
|
|
1590
|
+
document.getElementById('date-range').innerHTML = dr.from && dr.to
|
|
1591
|
+
? formatDate(dr.from) + ' — ' + formatDate(dr.to)
|
|
1592
|
+
: 'No conversations imported';
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
// ── Conversations ──
|
|
1596
|
+
async function loadConversations() {
|
|
1597
|
+
const model = document.getElementById('convo-model-filter').value;
|
|
1598
|
+
const from = document.getElementById('convo-from').value;
|
|
1599
|
+
const to = document.getElementById('convo-to').value;
|
|
1600
|
+
let qs = 'conversations?page=' + convoPage;
|
|
1601
|
+
if (model) qs += '&model=' + encodeURIComponent(model);
|
|
1602
|
+
if (from) qs += '&from=' + from;
|
|
1603
|
+
if (to) qs += '&to=' + to;
|
|
1604
|
+
|
|
1605
|
+
const data = await api(qs);
|
|
1606
|
+
const list = document.getElementById('convo-list');
|
|
1607
|
+
|
|
1608
|
+
if (!data.conversations?.length) {
|
|
1609
|
+
list.innerHTML = '<p style="color:#6b7280;padding:20px">No conversations imported yet. Use the CLI to import your chat history.</p>';
|
|
1610
|
+
return;
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
list.innerHTML = data.conversations.map(c => {
|
|
1614
|
+
const modelClass = getModelClass(c.model);
|
|
1615
|
+
return '<div class="convo-item" onclick="openConversation(\\'' + escapeAttr(c.id) + '\\')">' +
|
|
1616
|
+
'<div class="convo-title">' + esc(c.title || 'Untitled') + '</div>' +
|
|
1617
|
+
'<span class="convo-meta">' + (c.message_count || 0) + ' msgs</span>' +
|
|
1618
|
+
(c.model ? '<span class="convo-model ' + modelClass + '">' + esc(c.model) + '</span>' : '') +
|
|
1619
|
+
'<span class="convo-meta">' + formatDate(c.created_at) + '</span>' +
|
|
1620
|
+
'</div>';
|
|
1621
|
+
}).join('');
|
|
1622
|
+
|
|
1623
|
+
// Pagination
|
|
1624
|
+
const pag = document.getElementById('convo-pagination');
|
|
1625
|
+
let pagHtml = '';
|
|
1626
|
+
for (let i = 1; i <= data.pages; i++) {
|
|
1627
|
+
pagHtml += '<button class="btn btn-secondary btn-sm ' + (i === data.page ? 'active' : '') + '" onclick="convoPage=' + i + ';loadConversations()">' + i + '</button>';
|
|
1628
|
+
}
|
|
1629
|
+
pag.innerHTML = pagHtml;
|
|
1630
|
+
|
|
1631
|
+
// Populate model filter from conversations table
|
|
1632
|
+
const sel = document.getElementById('convo-model-filter');
|
|
1633
|
+
if (sel.options.length <= 1) {
|
|
1634
|
+
const stats = await api('stats');
|
|
1635
|
+
if (stats.models?.length) {
|
|
1636
|
+
stats.models.forEach(m => {
|
|
1637
|
+
const opt = document.createElement('option');
|
|
1638
|
+
opt.value = m.model; opt.textContent = m.model + ' (' + m.count + ')';
|
|
1639
|
+
sel.appendChild(opt);
|
|
1640
|
+
});
|
|
1641
|
+
}
|
|
1642
|
+
// Auto-filter on change
|
|
1643
|
+
sel.onchange = () => { convoPage = 1; loadConversations(); };
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
async function searchConversations() {
|
|
1648
|
+
const q = document.getElementById('convo-search').value;
|
|
1649
|
+
if (!q.trim()) return loadConversations();
|
|
1650
|
+
|
|
1651
|
+
const data = await api('conversations/search?q=' + encodeURIComponent(q));
|
|
1652
|
+
const list = document.getElementById('convo-list');
|
|
1653
|
+
|
|
1654
|
+
if (!data.results?.length) {
|
|
1655
|
+
list.innerHTML = '<p style="color:#6b7280;padding:20px">No results for "' + esc(q) + '"</p>';
|
|
1656
|
+
return;
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
const srchSettings = await api('settings');
|
|
1660
|
+
const srchAiName = srchSettings.ai_name || 'Assistant';
|
|
1661
|
+
list.innerHTML = data.results.map(r => {
|
|
1662
|
+
const roleName = r.role === 'assistant' ? srchAiName : (r.role === 'user' ? (srchSettings.user_name || 'You') : r.role);
|
|
1663
|
+
return '<div class="convo-item" onclick="openConversation(\\'' + escapeAttr(r.conversation_id) + '\\')">' +
|
|
1664
|
+
'<div class="convo-title">' + esc(r.title || 'Untitled') + '</div>' +
|
|
1665
|
+
'<span class="convo-meta">' + esc(roleName) + '</span>' +
|
|
1666
|
+
(r.model ? '<span class="convo-model ' + getModelClass(r.model) + '">' + esc(r.model) + '</span>' : '') +
|
|
1667
|
+
'<div style="font-size:12px;color:#9ca3af;margin-top:4px">' + highlightMatch(r.content, q) + '</div>' +
|
|
1668
|
+
'</div>';
|
|
1669
|
+
}).join('');
|
|
1670
|
+
|
|
1671
|
+
document.getElementById('convo-pagination').innerHTML = '';
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
async function openConversation(id) {
|
|
1675
|
+
document.getElementById('convo-list').style.display = 'none';
|
|
1676
|
+
document.getElementById('convo-pagination').style.display = 'none';
|
|
1677
|
+
document.querySelector('.filters').style.display = 'none';
|
|
1678
|
+
document.querySelector('.search-bar').style.display = 'none';
|
|
1679
|
+
|
|
1680
|
+
const detail = document.getElementById('convo-detail');
|
|
1681
|
+
detail.style.display = 'block';
|
|
1682
|
+
|
|
1683
|
+
const convo = await api('conversations/' + id);
|
|
1684
|
+
document.getElementById('convo-detail-title').textContent = convo.title || 'Untitled';
|
|
1685
|
+
|
|
1686
|
+
const data = await api('conversations/' + id + '/messages');
|
|
1687
|
+
const msgDiv = document.getElementById('convo-messages');
|
|
1688
|
+
// Get AI name from settings for display
|
|
1689
|
+
const settings = await api('settings');
|
|
1690
|
+
const aiDisplayName = settings.ai_name || 'Assistant';
|
|
1691
|
+
const userDisplayName = settings.user_name || 'You';
|
|
1692
|
+
|
|
1693
|
+
msgDiv.innerHTML = data.messages.map(m => {
|
|
1694
|
+
const displayRole = m.role === 'assistant' ? aiDisplayName : (m.role === 'user' ? userDisplayName : m.role);
|
|
1695
|
+
return '<div class="msg msg-' + m.role + '">' +
|
|
1696
|
+
'<div class="msg-role">' + esc(displayRole) + '</div>' +
|
|
1697
|
+
esc(m.content) +
|
|
1698
|
+
(m.model ? '<div class="msg-model">' + esc(m.model) + '</div>' : '') +
|
|
1699
|
+
'</div>';
|
|
1700
|
+
}).join('');
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
function closeConversation() {
|
|
1704
|
+
document.getElementById('convo-detail').style.display = 'none';
|
|
1705
|
+
document.getElementById('convo-list').style.display = '';
|
|
1706
|
+
document.getElementById('convo-pagination').style.display = '';
|
|
1707
|
+
document.querySelector('.filters').style.display = '';
|
|
1708
|
+
document.querySelector('.search-bar').style.display = '';
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
// ── Skills ──
|
|
1712
|
+
async function loadSkills() {
|
|
1713
|
+
const [skills, cats] = await Promise.all([api('skills'), api('skill-categories')]);
|
|
1714
|
+
skillCategories = cats;
|
|
1715
|
+
|
|
1716
|
+
const grid = document.getElementById('skills-list');
|
|
1717
|
+
if (!skills.length) {
|
|
1718
|
+
grid.innerHTML = '<p style="color:#6b7280">No skills yet. Run an analysis or add skills manually.</p>';
|
|
1719
|
+
return;
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
grid.innerHTML = skills.map(s => {
|
|
1723
|
+
const cat = cats.find(c => c.name === s.category) || {};
|
|
1724
|
+
const triggers = s.triggers || {};
|
|
1725
|
+
let triggerHtml = '';
|
|
1726
|
+
if (s.activationRule) triggerHtml += '<div class="skill-trigger"><strong>When:</strong> ' + esc(s.activationRule) + '</div>';
|
|
1727
|
+
if (triggers.phrases?.length) triggerHtml += '<div class="skill-trigger"><strong>Phrases:</strong> ' + triggers.phrases.map(p => '"' + esc(p) + '"').join(', ') + '</div>';
|
|
1728
|
+
if (triggers.temporal?.length) triggerHtml += '<div class="skill-trigger"><strong>Time:</strong> ' + triggers.temporal.map(esc).join(', ') + '</div>';
|
|
1729
|
+
if (triggers.emotional?.length) triggerHtml += '<div class="skill-trigger"><strong>Mood:</strong> ' + triggers.emotional.map(esc).join(', ') + '</div>';
|
|
1730
|
+
|
|
1731
|
+
return '<div class="skill-card">' +
|
|
1732
|
+
'<div class="skill-actions">' +
|
|
1733
|
+
'<button class="btn btn-secondary btn-sm" onclick="downloadSkill(' + s.id + ')">↧</button>' +
|
|
1734
|
+
'<button class="btn btn-secondary btn-sm" onclick="openSkillEditor(' + s.id + ')">Edit</button>' +
|
|
1735
|
+
'<button class="btn btn-danger btn-sm" onclick="deleteSkillConfirm(' + s.id + ')">Del</button>' +
|
|
1736
|
+
'</div>' +
|
|
1737
|
+
'<div class="skill-name">' + esc(s.name) + '</div>' +
|
|
1738
|
+
'<span class="skill-category" style="background:' + (cat.color || '#6b7280') + '22;color:' + (cat.color || '#6b7280') + '">' + esc(s.category) + '</span>' +
|
|
1739
|
+
'<span style="font-size:11px;color:#6b7280;margin-left:8px">' + esc(s.frequency) + '</span>' +
|
|
1740
|
+
'<div class="skill-desc">' + esc(s.description) + '</div>' +
|
|
1741
|
+
triggerHtml +
|
|
1742
|
+
'</div>';
|
|
1743
|
+
}).join('');
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
async function openSkillEditor(id) {
|
|
1747
|
+
const cats = skillCategories.length ? skillCategories : await api('skill-categories');
|
|
1748
|
+
let skill = { name: '', category: 'other', frequency: 'occasional', description: '', approach: '', quality: '',
|
|
1749
|
+
activationRule: '', triggers: { phrases: [], temporal: [], emotional: [], contextual: [] }, examples: [] };
|
|
1750
|
+
|
|
1751
|
+
if (id) {
|
|
1752
|
+
const skills = await api('skills');
|
|
1753
|
+
skill = skills.find(s => s.id === id) || skill;
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
const catOptions = cats.map(c => '<option value="' + esc(c.name) + '"' + (c.name === skill.category ? ' selected' : '') + '>' + esc(c.name) + '</option>').join('');
|
|
1757
|
+
const freqOptions = ['daily','weekly','occasional','rare'].map(f => '<option value="' + f + '"' + (f === skill.frequency ? ' selected' : '') + '>' + f + '</option>').join('');
|
|
1758
|
+
|
|
1759
|
+
document.getElementById('modal-title').textContent = id ? 'Edit Skill' : 'Add Skill';
|
|
1760
|
+
document.getElementById('modal-body').innerHTML = '<div class="modal-body-inner">' +
|
|
1761
|
+
'<label>Name</label><input type="text" id="skill-name" value="' + escapeAttr(skill.name) + '">' +
|
|
1762
|
+
'<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">' +
|
|
1763
|
+
'<div><label>Category</label><select id="skill-category">' + catOptions + '</select></div>' +
|
|
1764
|
+
'<div><label>Frequency</label><select id="skill-frequency">' + freqOptions + '</select></div>' +
|
|
1765
|
+
'</div>' +
|
|
1766
|
+
'<label>Description</label><textarea id="skill-description" rows="3">' + esc(skill.description) + '</textarea>' +
|
|
1767
|
+
'<label>Activation Rule</label><input type="text" id="skill-activation" value="' + escapeAttr(skill.activationRule || '') + '" placeholder="WHEN does this skill fire?">' +
|
|
1768
|
+
'<label>Trigger Phrases (comma-separated)</label><input type="text" id="skill-phrases" value="' + escapeAttr((skill.triggers?.phrases || []).join(', ')) + '" placeholder="good morning, gm, morning">' +
|
|
1769
|
+
'<label>Temporal Triggers (comma-separated)</label><input type="text" id="skill-temporal" value="' + escapeAttr((skill.triggers?.temporal || []).join(', ')) + '" placeholder="first message of day, morning">' +
|
|
1770
|
+
'<label>Emotional Triggers (comma-separated)</label><input type="text" id="skill-emotional" value="' + escapeAttr((skill.triggers?.emotional || []).join(', ')) + '" placeholder="user seems stressed, low energy">' +
|
|
1771
|
+
'<label>Contextual Triggers (comma-separated)</label><input type="text" id="skill-contextual" value="' + escapeAttr((skill.triggers?.contextual || []).join(', ')) + '" placeholder="user shares a problem, after long silence">' +
|
|
1772
|
+
'<label>Approach</label><textarea id="skill-approach" rows="2">' + esc(skill.approach) + '</textarea>' +
|
|
1773
|
+
'<label>Quality</label><input type="text" id="skill-quality" value="' + escapeAttr(skill.quality) + '">' +
|
|
1774
|
+
'</div>';
|
|
1775
|
+
|
|
1776
|
+
const saveBtn = document.getElementById('modal-save');
|
|
1777
|
+
saveBtn.onclick = async () => {
|
|
1778
|
+
const data = {
|
|
1779
|
+
name: document.getElementById('skill-name').value,
|
|
1780
|
+
category: document.getElementById('skill-category').value,
|
|
1781
|
+
frequency: document.getElementById('skill-frequency').value,
|
|
1782
|
+
description: document.getElementById('skill-description').value,
|
|
1783
|
+
approach: document.getElementById('skill-approach').value,
|
|
1784
|
+
quality: document.getElementById('skill-quality').value,
|
|
1785
|
+
activationRule: document.getElementById('skill-activation').value,
|
|
1786
|
+
triggers: {
|
|
1787
|
+
phrases: splitTags(document.getElementById('skill-phrases').value),
|
|
1788
|
+
temporal: splitTags(document.getElementById('skill-temporal').value),
|
|
1789
|
+
emotional: splitTags(document.getElementById('skill-emotional').value),
|
|
1790
|
+
contextual: splitTags(document.getElementById('skill-contextual').value),
|
|
1791
|
+
},
|
|
1792
|
+
examples: [],
|
|
1793
|
+
};
|
|
1794
|
+
if (id) await api('skills/' + id, 'PUT', data);
|
|
1795
|
+
else await api('skills', 'POST', data);
|
|
1796
|
+
closeModal();
|
|
1797
|
+
loadSkills();
|
|
1798
|
+
};
|
|
1799
|
+
|
|
1800
|
+
document.getElementById('modal').style.display = 'flex';
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
async function deleteSkillConfirm(id) {
|
|
1804
|
+
if (confirm('Delete this skill?')) {
|
|
1805
|
+
await api('skills/' + id, 'DELETE');
|
|
1806
|
+
loadSkills();
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
function skillToMarkdown(s) {
|
|
1811
|
+
const NL = String.fromCharCode(10);
|
|
1812
|
+
const lines = [];
|
|
1813
|
+
lines.push('# Skill: ' + s.name, '');
|
|
1814
|
+
lines.push('**Category**: ' + s.category);
|
|
1815
|
+
lines.push('**Frequency**: ' + s.frequency);
|
|
1816
|
+
if (s.quality) lines.push('**Quality**: ' + s.quality);
|
|
1817
|
+
|
|
1818
|
+
if (s.activationRule) {
|
|
1819
|
+
lines.push('', '## When to Activate', s.activationRule);
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
const t = s.triggers || {};
|
|
1823
|
+
const hasTriggers = t.phrases?.length || t.temporal?.length || t.emotional?.length || t.contextual?.length;
|
|
1824
|
+
if (hasTriggers) {
|
|
1825
|
+
lines.push('', '## Triggers');
|
|
1826
|
+
if (t.phrases?.length) lines.push('**Phrases**: ' + t.phrases.map(p => '"' + p + '"').join(', '));
|
|
1827
|
+
if (t.temporal?.length) lines.push('**Temporal**: ' + t.temporal.join(', '));
|
|
1828
|
+
if (t.emotional?.length) lines.push('**Emotional**: ' + t.emotional.join(', '));
|
|
1829
|
+
if (t.contextual?.length) lines.push('**Contextual**: ' + t.contextual.join(', '));
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
if (s.description) lines.push('', '## Description', s.description);
|
|
1833
|
+
if (s.approach) lines.push('', '## Approach', s.approach);
|
|
1834
|
+
|
|
1835
|
+
if (s.examples?.length) {
|
|
1836
|
+
lines.push('', '## Examples');
|
|
1837
|
+
for (const e of s.examples) lines.push('- ' + e);
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
return lines.join(NL) + NL;
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
async function downloadSkill(id) {
|
|
1844
|
+
const skills = await api('skills');
|
|
1845
|
+
const s = skills.find(sk => sk.id === id);
|
|
1846
|
+
if (!s) return;
|
|
1847
|
+
const md = skillToMarkdown(s);
|
|
1848
|
+
const filename = s.name.toLowerCase().replace(/[^a-z0-9]+/g, '-') + '.md';
|
|
1849
|
+
downloadFile(filename, md);
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
async function downloadAllSkills() {
|
|
1853
|
+
const skills = await api('skills');
|
|
1854
|
+
if (!skills.length) { alert('No skills to download.'); return; }
|
|
1855
|
+
|
|
1856
|
+
const NL = String.fromCharCode(10);
|
|
1857
|
+
const sep = NL + '---' + NL + NL;
|
|
1858
|
+
let combined = '# Skills Package' + NL + NL + 'Generated by AI Exodus Portal' + NL + NL + '---' + NL + NL;
|
|
1859
|
+
for (const s of skills) {
|
|
1860
|
+
combined += skillToMarkdown(s) + sep;
|
|
1861
|
+
}
|
|
1862
|
+
downloadFile('skills-package.md', combined);
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
function downloadFile(filename, content) {
|
|
1866
|
+
const blob = new Blob([content], { type: 'text/markdown' });
|
|
1867
|
+
const url = URL.createObjectURL(blob);
|
|
1868
|
+
const a = document.createElement('a');
|
|
1869
|
+
a.href = url;
|
|
1870
|
+
a.download = filename;
|
|
1871
|
+
document.body.appendChild(a);
|
|
1872
|
+
a.click();
|
|
1873
|
+
document.body.removeChild(a);
|
|
1874
|
+
URL.revokeObjectURL(url);
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
// ── Memories ──
|
|
1878
|
+
async function loadMemories() {
|
|
1879
|
+
const catFilter = document.getElementById('memory-cat-filter').value;
|
|
1880
|
+
const qs = catFilter ? 'memories?category=' + encodeURIComponent(catFilter) : 'memories';
|
|
1881
|
+
const [memories, cats] = await Promise.all([api(qs), api('memory-categories')]);
|
|
1882
|
+
memoryCategories = cats;
|
|
1883
|
+
|
|
1884
|
+
// Populate filter
|
|
1885
|
+
const sel = document.getElementById('memory-cat-filter');
|
|
1886
|
+
if (sel.options.length <= 1) {
|
|
1887
|
+
cats.forEach(c => {
|
|
1888
|
+
const opt = document.createElement('option');
|
|
1889
|
+
opt.value = c.name; opt.textContent = c.name;
|
|
1890
|
+
sel.appendChild(opt);
|
|
1891
|
+
});
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
const list = document.getElementById('memories-list');
|
|
1895
|
+
if (!memories.length) {
|
|
1896
|
+
list.innerHTML = '<p style="color:#6b7280">No memories yet. Run an analysis or add memories manually.</p>';
|
|
1897
|
+
return;
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
list.innerHTML = memories.map(m => {
|
|
1901
|
+
const cat = cats.find(c => c.name === m.category) || {};
|
|
1902
|
+
return '<div class="memory-item">' +
|
|
1903
|
+
'<span class="memory-category" style="background:' + (cat.color || '#6b7280') + '22;color:' + (cat.color || '#6b7280') + '">' + esc(m.category) + '</span>' +
|
|
1904
|
+
(m.key ? '<span class="memory-key">' + esc(m.key) + '</span>' : '') +
|
|
1905
|
+
'<span class="memory-value">' + esc(m.value) + '</span>' +
|
|
1906
|
+
'<div class="memory-actions">' +
|
|
1907
|
+
'<button class="btn btn-secondary btn-sm" onclick="openMemoryEditor(' + m.id + ')">Edit</button>' +
|
|
1908
|
+
'<button class="btn btn-danger btn-sm" onclick="deleteMemoryConfirm(' + m.id + ')">Del</button>' +
|
|
1909
|
+
'</div>' +
|
|
1910
|
+
'</div>';
|
|
1911
|
+
}).join('');
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
async function openMemoryEditor(id) {
|
|
1915
|
+
const cats = memoryCategories.length ? memoryCategories : await api('memory-categories');
|
|
1916
|
+
let memory = { category: 'facts', key: '', value: '' };
|
|
1917
|
+
|
|
1918
|
+
if (id) {
|
|
1919
|
+
const memories = await api('memories');
|
|
1920
|
+
memory = memories.find(m => m.id === id) || memory;
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
const catOptions = cats.map(c => '<option value="' + esc(c.name) + '"' + (c.name === memory.category ? ' selected' : '') + '>' + esc(c.name) + '</option>').join('');
|
|
1924
|
+
|
|
1925
|
+
document.getElementById('modal-title').textContent = id ? 'Edit Memory' : 'Add Memory';
|
|
1926
|
+
document.getElementById('modal-body').innerHTML = '<div class="modal-body-inner">' +
|
|
1927
|
+
'<label>Category</label><select id="mem-category">' + catOptions + '</select>' +
|
|
1928
|
+
'<label>Key (optional label)</label><input type="text" id="mem-key" value="' + escapeAttr(memory.key || '') + '" placeholder="e.g. Full Name, Occupation">' +
|
|
1929
|
+
'<label>Value</label><textarea id="mem-value" rows="4">' + esc(memory.value) + '</textarea>' +
|
|
1930
|
+
'</div>';
|
|
1931
|
+
|
|
1932
|
+
document.getElementById('modal-save').onclick = async () => {
|
|
1933
|
+
const data = {
|
|
1934
|
+
category: document.getElementById('mem-category').value,
|
|
1935
|
+
key: document.getElementById('mem-key').value,
|
|
1936
|
+
value: document.getElementById('mem-value').value,
|
|
1937
|
+
};
|
|
1938
|
+
if (id) await api('memories/' + id, 'PUT', data);
|
|
1939
|
+
else await api('memories', 'POST', data);
|
|
1940
|
+
closeModal();
|
|
1941
|
+
loadMemories();
|
|
1942
|
+
};
|
|
1943
|
+
|
|
1944
|
+
document.getElementById('modal').style.display = 'flex';
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
async function downloadAllMemories() {
|
|
1948
|
+
const memories = await api('memories');
|
|
1949
|
+
if (!memories.length) { alert('No memories to download.'); return; }
|
|
1950
|
+
|
|
1951
|
+
const NL = String.fromCharCode(10);
|
|
1952
|
+
const lines = ['# Memories', '', 'Generated by AI Exodus Portal', ''];
|
|
1953
|
+
let currentCat = null;
|
|
1954
|
+
for (const m of memories) {
|
|
1955
|
+
if (m.category !== currentCat) {
|
|
1956
|
+
currentCat = m.category;
|
|
1957
|
+
lines.push('', '## ' + currentCat.charAt(0).toUpperCase() + currentCat.slice(1));
|
|
1958
|
+
}
|
|
1959
|
+
if (m.key) lines.push('- **' + m.key + '**: ' + m.value);
|
|
1960
|
+
else lines.push('- ' + m.value);
|
|
1961
|
+
}
|
|
1962
|
+
downloadFile('memories.md', lines.join(NL));
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
async function downloadPersona() {
|
|
1966
|
+
const content = document.getElementById('persona-content').value;
|
|
1967
|
+
if (!content.trim()) { alert('No persona to download.'); return; }
|
|
1968
|
+
downloadFile('persona.md', content);
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
async function deleteMemoryConfirm(id) {
|
|
1972
|
+
if (confirm('Delete this memory?')) {
|
|
1973
|
+
await api('memories/' + id, 'DELETE');
|
|
1974
|
+
loadMemories();
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
// ── Category Manager ──
|
|
1979
|
+
async function openCategoryManager(type) {
|
|
1980
|
+
const endpoint = type === 'skill' ? 'skill-categories' : 'memory-categories';
|
|
1981
|
+
const cats = await api(endpoint);
|
|
1982
|
+
|
|
1983
|
+
document.getElementById('modal-title').textContent = (type === 'skill' ? 'Skill' : 'Memory') + ' Categories';
|
|
1984
|
+
let html = '<div class="modal-body-inner"><div class="category-manager">';
|
|
1985
|
+
|
|
1986
|
+
for (const cat of cats) {
|
|
1987
|
+
html += '<div class="cat-row">' +
|
|
1988
|
+
'<input type="color" class="cat-color" value="' + (cat.color || '#8b5cf6') + '" data-id="' + cat.id + '" ' + (cat.is_default ? 'disabled' : '') + '>' +
|
|
1989
|
+
'<span class="cat-name">' + esc(cat.name) + '</span>' +
|
|
1990
|
+
(cat.is_default ? '<span class="cat-default">default</span>' : '<button class="btn btn-danger btn-sm" onclick="deleteCategory(\\'' + type + '\\',' + cat.id + ')">Del</button>') +
|
|
1991
|
+
'</div>';
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
html += '</div>' +
|
|
1995
|
+
'<div style="display:flex;gap:8px;margin-top:16px">' +
|
|
1996
|
+
'<input type="text" id="new-cat-name" placeholder="New category name" style="flex:1">' +
|
|
1997
|
+
'<input type="color" id="new-cat-color" value="#8b5cf6" style="width:50px;padding:4px">' +
|
|
1998
|
+
'<button class="btn btn-primary btn-sm" onclick="addCategory(\\'' + type + '\\')">Add</button>' +
|
|
1999
|
+
'</div></div>';
|
|
2000
|
+
|
|
2001
|
+
document.getElementById('modal-body').innerHTML = html;
|
|
2002
|
+
document.getElementById('modal-save').style.display = 'none';
|
|
2003
|
+
document.getElementById('modal').style.display = 'flex';
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
async function addCategory(type) {
|
|
2007
|
+
const name = document.getElementById('new-cat-name').value.trim();
|
|
2008
|
+
const color = document.getElementById('new-cat-color').value;
|
|
2009
|
+
if (!name) return;
|
|
2010
|
+
const endpoint = type === 'skill' ? 'skill-categories' : 'memory-categories';
|
|
2011
|
+
await api(endpoint, 'POST', { name, color });
|
|
2012
|
+
openCategoryManager(type);
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
async function deleteCategory(type, id) {
|
|
2016
|
+
const endpoint = type === 'skill' ? 'skill-categories' : 'memory-categories';
|
|
2017
|
+
await api(endpoint + '/' + id, 'DELETE');
|
|
2018
|
+
openCategoryManager(type);
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
// ── Persona ──
|
|
2022
|
+
async function loadPersona() {
|
|
2023
|
+
const data = await api('persona');
|
|
2024
|
+
const content = data.map(s => s.content).join('\\n\\n---\\n\\n') || '';
|
|
2025
|
+
document.getElementById('persona-content').value = content;
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
async function savePersona() {
|
|
2029
|
+
const content = document.getElementById('persona-content').value;
|
|
2030
|
+
await api('persona', 'PUT', { sections: [{ section: 'full', content }] });
|
|
2031
|
+
alert('Persona saved.');
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
// ── Narrative ──
|
|
2035
|
+
async function loadNarrative() {
|
|
2036
|
+
const data = await api('narrative');
|
|
2037
|
+
document.getElementById('narrative-content').textContent = data.content || 'No relationship narrative yet. Run an analysis to generate one.';
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
// ── Settings ──
|
|
2041
|
+
async function loadSettings() {
|
|
2042
|
+
const settings = await api('settings');
|
|
2043
|
+
document.getElementById('setting-ai-name').value = settings.ai_name || '';
|
|
2044
|
+
document.getElementById('setting-user-name').value = settings.user_name || '';
|
|
2045
|
+
document.getElementById('setting-mcp-secret').value = settings.mcp_secret || 'Not configured';
|
|
2046
|
+
document.getElementById('mcp-url').textContent = location.origin + '/mcp/{secret}/search?q=your+query';
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
async function saveSettings() {
|
|
2050
|
+
await api('settings', 'PUT', {
|
|
2051
|
+
ai_name: document.getElementById('setting-ai-name').value,
|
|
2052
|
+
user_name: document.getElementById('setting-user-name').value,
|
|
2053
|
+
});
|
|
2054
|
+
alert('Settings saved.');
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
// ── Upload / Import ──
|
|
2058
|
+
function initUpload() {
|
|
2059
|
+
const dropzone = document.getElementById('dropzone');
|
|
2060
|
+
const fileInput = document.getElementById('file-input');
|
|
2061
|
+
if (!dropzone) return;
|
|
2062
|
+
|
|
2063
|
+
dropzone.addEventListener('click', () => fileInput.click());
|
|
2064
|
+
fileInput.addEventListener('change', (e) => handleFiles(e.target.files));
|
|
2065
|
+
|
|
2066
|
+
dropzone.addEventListener('dragover', (e) => {
|
|
2067
|
+
e.preventDefault();
|
|
2068
|
+
dropzone.classList.add('drag-over');
|
|
2069
|
+
});
|
|
2070
|
+
dropzone.addEventListener('dragleave', () => dropzone.classList.remove('drag-over'));
|
|
2071
|
+
dropzone.addEventListener('drop', (e) => {
|
|
2072
|
+
e.preventDefault();
|
|
2073
|
+
dropzone.classList.remove('drag-over');
|
|
2074
|
+
handleFiles(e.dataTransfer.files);
|
|
2075
|
+
});
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
async function handleFiles(files) {
|
|
2079
|
+
if (!files.length) return;
|
|
2080
|
+
|
|
2081
|
+
const progressDiv = document.getElementById('upload-progress');
|
|
2082
|
+
const statusEl = document.getElementById('upload-status');
|
|
2083
|
+
const percentEl = document.getElementById('upload-percent');
|
|
2084
|
+
const barEl = document.getElementById('upload-bar');
|
|
2085
|
+
const detailEl = document.getElementById('upload-detail');
|
|
2086
|
+
|
|
2087
|
+
progressDiv.style.display = 'block';
|
|
2088
|
+
statusEl.textContent = 'Reading files...';
|
|
2089
|
+
percentEl.textContent = '';
|
|
2090
|
+
barEl.style.width = '0%';
|
|
2091
|
+
|
|
2092
|
+
// Read and parse all selected JSON files
|
|
2093
|
+
let allConversations = [];
|
|
2094
|
+
let filesDone = 0;
|
|
2095
|
+
|
|
2096
|
+
for (const file of files) {
|
|
2097
|
+
if (!file.name.endsWith('.json')) {
|
|
2098
|
+
detailEl.textContent = 'Skipping ' + file.name + ' (not JSON)';
|
|
2099
|
+
continue;
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
statusEl.textContent = 'Parsing ' + file.name + '...';
|
|
2103
|
+
detailEl.textContent = (file.size / 1024 / 1024).toFixed(1) + ' MB';
|
|
2104
|
+
|
|
2105
|
+
try {
|
|
2106
|
+
const text = await file.text();
|
|
2107
|
+
const data = JSON.parse(text);
|
|
2108
|
+
|
|
2109
|
+
if (!Array.isArray(data)) {
|
|
2110
|
+
detailEl.textContent = file.name + ' is not a conversation array, skipping';
|
|
2111
|
+
continue;
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
const parsed = parseChatGPTExport(data);
|
|
2115
|
+
allConversations.push(...parsed);
|
|
2116
|
+
filesDone++;
|
|
2117
|
+
detailEl.textContent = filesDone + ' file(s) parsed, ' + allConversations.length + ' conversations found';
|
|
2118
|
+
} catch (err) {
|
|
2119
|
+
detailEl.textContent = 'Error parsing ' + file.name + ': ' + err.message;
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
if (allConversations.length === 0) {
|
|
2124
|
+
statusEl.textContent = 'No conversations found';
|
|
2125
|
+
return;
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
// Upload in batches
|
|
2129
|
+
statusEl.textContent = 'Importing...';
|
|
2130
|
+
const BATCH = 10;
|
|
2131
|
+
let imported = 0;
|
|
2132
|
+
const total = allConversations.length;
|
|
2133
|
+
|
|
2134
|
+
for (let i = 0; i < total; i += BATCH) {
|
|
2135
|
+
const batch = allConversations.slice(i, i + BATCH);
|
|
2136
|
+
try {
|
|
2137
|
+
const res = await fetch('/api/import/conversations', {
|
|
2138
|
+
method: 'POST',
|
|
2139
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2140
|
+
body: JSON.stringify({ conversations: batch }),
|
|
2141
|
+
});
|
|
2142
|
+
if (!res.ok) {
|
|
2143
|
+
const err = await res.json().catch(() => ({}));
|
|
2144
|
+
throw new Error(err.error || 'HTTP ' + res.status);
|
|
2145
|
+
}
|
|
2146
|
+
imported += batch.length;
|
|
2147
|
+
} catch (err) {
|
|
2148
|
+
detailEl.textContent = 'Batch error: ' + err.message + ' (retrying...)';
|
|
2149
|
+
i -= BATCH; // retry once
|
|
2150
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
2151
|
+
// On second failure, skip
|
|
2152
|
+
try {
|
|
2153
|
+
const res2 = await fetch('/api/import/conversations', {
|
|
2154
|
+
method: 'POST',
|
|
2155
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2156
|
+
body: JSON.stringify({ conversations: batch }),
|
|
2157
|
+
});
|
|
2158
|
+
if (res2.ok) imported += batch.length;
|
|
2159
|
+
} catch { /* skip */ }
|
|
2160
|
+
i += BATCH; // undo the retry decrement
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
const pct = Math.round((imported / total) * 100);
|
|
2164
|
+
percentEl.textContent = pct + '%';
|
|
2165
|
+
barEl.style.width = pct + '%';
|
|
2166
|
+
detailEl.textContent = imported + ' / ' + total + ' conversations';
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
statusEl.textContent = 'Import complete!';
|
|
2170
|
+
percentEl.textContent = '100%';
|
|
2171
|
+
barEl.style.width = '100%';
|
|
2172
|
+
detailEl.textContent = imported + ' conversations imported. Refreshing...';
|
|
2173
|
+
|
|
2174
|
+
// Refresh dashboard
|
|
2175
|
+
setTimeout(() => loadDashboard(), 1000);
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
/**
|
|
2179
|
+
* Parse ChatGPT export JSON in the browser
|
|
2180
|
+
* Same logic as the Node.js parser but simplified for browser
|
|
2181
|
+
*/
|
|
2182
|
+
function parseChatGPTExport(data) {
|
|
2183
|
+
const conversations = [];
|
|
2184
|
+
|
|
2185
|
+
for (const convo of data) {
|
|
2186
|
+
const title = convo.title || 'Untitled';
|
|
2187
|
+
const createTime = convo.create_time ? new Date(convo.create_time * 1000).toISOString() : null;
|
|
2188
|
+
const updateTime = convo.update_time ? new Date(convo.update_time * 1000).toISOString() : null;
|
|
2189
|
+
|
|
2190
|
+
const messages = [];
|
|
2191
|
+
if (convo.mapping) {
|
|
2192
|
+
const nodes = Object.values(convo.mapping)
|
|
2193
|
+
.filter(n => n.message && n.message.content && n.message.content.parts)
|
|
2194
|
+
.filter(n => {
|
|
2195
|
+
const role = n.message.author?.role;
|
|
2196
|
+
return role === 'user' || role === 'assistant';
|
|
2197
|
+
})
|
|
2198
|
+
.sort((a, b) => (a.message.create_time || 0) - (b.message.create_time || 0));
|
|
2199
|
+
|
|
2200
|
+
for (const node of nodes) {
|
|
2201
|
+
const msg = node.message;
|
|
2202
|
+
const content = msg.content.parts
|
|
2203
|
+
.filter(p => typeof p === 'string')
|
|
2204
|
+
.join('\\n')
|
|
2205
|
+
.trim();
|
|
2206
|
+
if (!content) continue;
|
|
2207
|
+
|
|
2208
|
+
messages.push({
|
|
2209
|
+
role: msg.author?.role || 'unknown',
|
|
2210
|
+
content,
|
|
2211
|
+
model: msg.metadata?.model_slug || null,
|
|
2212
|
+
createdAt: msg.create_time ? new Date(msg.create_time * 1000).toISOString() : null,
|
|
2213
|
+
});
|
|
2214
|
+
}
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
if (messages.length < 2) continue; // skip empty/tiny convos
|
|
2218
|
+
|
|
2219
|
+
// Detect primary model
|
|
2220
|
+
const modelCounts = {};
|
|
2221
|
+
for (const m of messages) {
|
|
2222
|
+
if (m.model) modelCounts[m.model] = (modelCounts[m.model] || 0) + 1;
|
|
2223
|
+
}
|
|
2224
|
+
const primaryModel = Object.entries(modelCounts).sort((a, b) => b[1] - a[1])[0]?.[0] || null;
|
|
2225
|
+
|
|
2226
|
+
conversations.push({
|
|
2227
|
+
id: convo.id || convo.conversation_id || crypto.randomUUID(),
|
|
2228
|
+
title,
|
|
2229
|
+
createdAt: createTime,
|
|
2230
|
+
updatedAt: updateTime,
|
|
2231
|
+
model: primaryModel,
|
|
2232
|
+
source: 'chatgpt',
|
|
2233
|
+
metadata: { messageCount: messages.length },
|
|
2234
|
+
messages,
|
|
2235
|
+
});
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
return conversations;
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
// ── Analytics ──
|
|
2242
|
+
async function loadAnalytics() {
|
|
2243
|
+
const data = await api('analytics');
|
|
2244
|
+
|
|
2245
|
+
// Activity sparkline
|
|
2246
|
+
const actEl = document.getElementById('activity-chart');
|
|
2247
|
+
if (data.activity?.length) {
|
|
2248
|
+
const maxCount = Math.max(...data.activity.map(a => a.count));
|
|
2249
|
+
actEl.innerHTML = '<div class="sparkline">' +
|
|
2250
|
+
data.activity.map(a => {
|
|
2251
|
+
const h = Math.max(4, (a.count / maxCount) * 120);
|
|
2252
|
+
return '<div class="spark-bar" style="height:' + h + 'px" data-label="' + a.month + ': ' + a.count + '" title="' + a.month + ': ' + a.count.toLocaleString() + ' msgs"></div>';
|
|
2253
|
+
}).join('') +
|
|
2254
|
+
'</div><div class="spark-labels">' +
|
|
2255
|
+
data.activity.filter((_, i) => i % Math.max(1, Math.floor(data.activity.length / 8)) === 0).map(a =>
|
|
2256
|
+
'<span>' + a.month + '</span>'
|
|
2257
|
+
).join('') + '</div>';
|
|
2258
|
+
} else actEl.innerHTML = '<p style="color:#6b7280">No data</p>';
|
|
2259
|
+
|
|
2260
|
+
// Model distribution bars
|
|
2261
|
+
renderBarChart('model-chart', data.models, 'model', 'count', ['#a855f7','#22d3ee','#10b981','#f59e0b','#ef4444','#3b82f6']);
|
|
2262
|
+
|
|
2263
|
+
// Role distribution — swap in AI/user names
|
|
2264
|
+
const roleSettings = await api('settings');
|
|
2265
|
+
const roleAiName = roleSettings.ai_name || 'Assistant';
|
|
2266
|
+
const roleUserName = roleSettings.user_name || 'User';
|
|
2267
|
+
const namedRoles = (data.roles || []).map(r => ({
|
|
2268
|
+
...r,
|
|
2269
|
+
role: r.role === 'assistant' ? roleAiName : (r.role === 'user' ? roleUserName : r.role),
|
|
2270
|
+
}));
|
|
2271
|
+
renderBarChart('role-chart', namedRoles, 'role', 'count', ['#7c3aed','#22d3ee']);
|
|
2272
|
+
|
|
2273
|
+
// Day of week
|
|
2274
|
+
renderBarChart('dow-chart', data.dayOfWeek, 'day', 'count', ['#a855f7']);
|
|
2275
|
+
|
|
2276
|
+
// Hour of day — sparkline
|
|
2277
|
+
const hodEl = document.getElementById('hod-chart');
|
|
2278
|
+
if (data.hourOfDay?.length) {
|
|
2279
|
+
const maxH = Math.max(...data.hourOfDay.map(h => h.count));
|
|
2280
|
+
const hours = Array.from({length: 24}, (_, i) => {
|
|
2281
|
+
const found = data.hourOfDay.find(h => h.hour === i);
|
|
2282
|
+
return { hour: i, count: found ? found.count : 0 };
|
|
2283
|
+
});
|
|
2284
|
+
hodEl.innerHTML = '<div class="sparkline">' +
|
|
2285
|
+
hours.map(h => {
|
|
2286
|
+
const ht = Math.max(2, (h.count / maxH) * 120);
|
|
2287
|
+
return '<div class="spark-bar" style="height:' + ht + 'px" title="' + h.hour + ':00 — ' + h.count.toLocaleString() + ' msgs"></div>';
|
|
2288
|
+
}).join('') +
|
|
2289
|
+
'</div><div class="spark-labels">' +
|
|
2290
|
+
[0,3,6,9,12,15,18,21].map(h => '<span>' + h + ':00</span>').join('') + '</div>';
|
|
2291
|
+
} else hodEl.innerHTML = '<p style="color:#6b7280">No data</p>';
|
|
2292
|
+
|
|
2293
|
+
// Avg message length
|
|
2294
|
+
const lenEl = document.getElementById('length-chart');
|
|
2295
|
+
if (data.avgLength?.length) {
|
|
2296
|
+
const lenSettings = await api('settings');
|
|
2297
|
+
const lenAiName = lenSettings.ai_name || 'Assistant';
|
|
2298
|
+
const lenUserName = lenSettings.user_name || 'User';
|
|
2299
|
+
lenEl.innerHTML = data.avgLength.map(r => {
|
|
2300
|
+
const roleName = r.role === 'assistant' ? lenAiName : (r.role === 'user' ? lenUserName : r.role);
|
|
2301
|
+
return '<div style="display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid #1e1e2e">' +
|
|
2302
|
+
'<span style="text-transform:capitalize">' + esc(roleName) + '</span>' +
|
|
2303
|
+
'<span>avg <strong style="color:#a78bfa">' + r.avg_length.toLocaleString() + '</strong> chars, max <strong style="color:#22d3ee">' + r.max_length.toLocaleString() + '</strong></span></div>';
|
|
2304
|
+
}).join('');
|
|
2305
|
+
} else lenEl.innerHTML = '<p style="color:#6b7280">No data</p>';
|
|
2306
|
+
|
|
2307
|
+
// Sources
|
|
2308
|
+
renderBarChart('source-chart', data.sources, 'source', 'count', ['#10b981','#f59e0b','#3b82f6']);
|
|
2309
|
+
|
|
2310
|
+
// Time spent
|
|
2311
|
+
const timeEl = document.getElementById('time-chart');
|
|
2312
|
+
if (data.timeSpent) {
|
|
2313
|
+
const t = data.timeSpent;
|
|
2314
|
+
const days = Math.floor(t.totalHours / 24);
|
|
2315
|
+
const remainHours = t.totalHours % 24;
|
|
2316
|
+
const timeDisplay = days > 0 ? days + 'd ' + remainHours + 'h' : t.totalHours + 'h';
|
|
2317
|
+
timeEl.innerHTML =
|
|
2318
|
+
'<div style="text-align:center;padding:16px 0">' +
|
|
2319
|
+
'<div style="font-size:42px;font-weight:700;background:linear-gradient(135deg,#a78bfa,#22d3ee);-webkit-background-clip:text;-webkit-text-fill-color:transparent">' + timeDisplay + '</div>' +
|
|
2320
|
+
'<div style="color:#6b7280;font-size:13px;margin-top:4px">total time in conversations</div>' +
|
|
2321
|
+
'<div style="display:flex;justify-content:center;gap:32px;margin-top:20px">' +
|
|
2322
|
+
'<div><div style="font-size:24px;font-weight:600;color:#a78bfa">' + t.sessions.toLocaleString() + '</div><div style="font-size:11px;color:#6b7280">sessions</div></div>' +
|
|
2323
|
+
'<div><div style="font-size:24px;font-weight:600;color:#22d3ee">' + t.avgSessionMinutes + 'min</div><div style="font-size:11px;color:#6b7280">avg session</div></div>' +
|
|
2324
|
+
'</div>' +
|
|
2325
|
+
'</div>';
|
|
2326
|
+
} else timeEl.innerHTML = '<p style="color:#6b7280">No timestamp data</p>';
|
|
2327
|
+
|
|
2328
|
+
// Word cloud
|
|
2329
|
+
const wcEl = document.getElementById('word-cloud');
|
|
2330
|
+
if (data.topWords?.length) {
|
|
2331
|
+
const maxW = data.topWords[0].count;
|
|
2332
|
+
wcEl.innerHTML = data.topWords.map(w => {
|
|
2333
|
+
const size = Math.max(12, Math.min(36, 12 + (w.count / maxW) * 24));
|
|
2334
|
+
const opacity = 0.5 + (w.count / maxW) * 0.5;
|
|
2335
|
+
const hue = Math.floor(Math.random() * 60) + 240; // purple-cyan range
|
|
2336
|
+
return '<span style="font-size:' + size + 'px;opacity:' + opacity + ';color:hsl(' + hue + ',70%,70%)" title="' + w.count + ' times">' + esc(w.word) + '</span>';
|
|
2337
|
+
}).join('');
|
|
2338
|
+
} else wcEl.innerHTML = '<p style="color:#6b7280">Not enough data</p>';
|
|
2339
|
+
|
|
2340
|
+
// Longest convos
|
|
2341
|
+
const lcEl = document.getElementById('longest-convos');
|
|
2342
|
+
if (data.longestConvos?.length) {
|
|
2343
|
+
lcEl.innerHTML = '<div class="convo-list">' + data.longestConvos.map(c =>
|
|
2344
|
+
'<div class="convo-item" onclick="document.querySelector(\\'[data-tab=conversations]\\').click();setTimeout(()=>openConversation(\\'' + escapeAttr(c.id) + '\\'),100)">' +
|
|
2345
|
+
'<div class="convo-title">' + esc(c.title || 'Untitled') + '</div>' +
|
|
2346
|
+
'<span class="convo-meta">' + c.message_count + ' msgs</span>' +
|
|
2347
|
+
(c.model ? '<span class="convo-model ' + getModelClass(c.model) + '">' + esc(c.model) + '</span>' : '') +
|
|
2348
|
+
'</div>'
|
|
2349
|
+
).join('') + '</div>';
|
|
2350
|
+
} else lcEl.innerHTML = '<p style="color:#6b7280">No data</p>';
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
function renderBarChart(elementId, data, labelKey, valueKey, colors) {
|
|
2354
|
+
const el = document.getElementById(elementId);
|
|
2355
|
+
if (!data?.length) { el.innerHTML = '<p style="color:#6b7280">No data</p>'; return; }
|
|
2356
|
+
const maxVal = Math.max(...data.map(d => d[valueKey]));
|
|
2357
|
+
el.innerHTML = '<div class="bar-chart">' + data.map((d, i) => {
|
|
2358
|
+
const pct = (d[valueKey] / maxVal) * 100;
|
|
2359
|
+
const color = colors[i % colors.length];
|
|
2360
|
+
return '<div class="bar-row">' +
|
|
2361
|
+
'<span class="bar-label">' + esc(d[labelKey] || 'unknown') + '</span>' +
|
|
2362
|
+
'<div class="bar-track"><div class="bar-fill" style="width:' + Math.max(2, pct) + '%;background:' + color + '">' + d[valueKey].toLocaleString() + '</div></div>' +
|
|
2363
|
+
'</div>';
|
|
2364
|
+
}).join('') + '</div>';
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
// ── Modal ──
|
|
2368
|
+
function closeModal() {
|
|
2369
|
+
document.getElementById('modal').style.display = 'none';
|
|
2370
|
+
document.getElementById('modal-save').style.display = '';
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
// ── Guide ──
|
|
2374
|
+
async function loadGuide() {
|
|
2375
|
+
const el = document.getElementById('guide-mcp-url');
|
|
2376
|
+
if (el) {
|
|
2377
|
+
const settings = await api('settings');
|
|
2378
|
+
const secret = settings.mcp_secret || '{your-mcp-secret}';
|
|
2379
|
+
el.querySelector('code').textContent = location.origin + '/mcp/' + secret + '/search?q=your+query';
|
|
2380
|
+
}
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
function copyCommand(btn) {
|
|
2384
|
+
const code = btn.parentElement.querySelector('code');
|
|
2385
|
+
navigator.clipboard.writeText(code.textContent).then(() => {
|
|
2386
|
+
btn.textContent = 'Copied!';
|
|
2387
|
+
setTimeout(() => btn.textContent = 'Copy', 1500);
|
|
2388
|
+
});
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2391
|
+
// ── Helpers ──
|
|
2392
|
+
function esc(s) { if (!s) return ''; return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
|
2393
|
+
function escapeAttr(s) { return esc(s).replace(/'/g,'''); }
|
|
2394
|
+
function formatDate(d) { if (!d) return ''; try { return new Date(d).toLocaleDateString('en-GB', { day:'numeric', month:'short', year:'numeric' }); } catch { return d; } }
|
|
2395
|
+
function splitTags(s) { return s.split(',').map(t => t.trim()).filter(Boolean); }
|
|
2396
|
+
function highlightMatch(text, q) {
|
|
2397
|
+
if (!text || !q) return esc(text);
|
|
2398
|
+
const snippet = text.length > 200 ? '...' + text.substring(text.toLowerCase().indexOf(q.toLowerCase()) - 50, text.toLowerCase().indexOf(q.toLowerCase()) + 150) + '...' : text;
|
|
2399
|
+
return esc(snippet).replace(new RegExp(esc(q), 'gi'), '<mark style="background:#a78bfa33;color:#c4b5fd">$&</mark>');
|
|
2400
|
+
}
|
|
2401
|
+
function getModelClass(model) {
|
|
2402
|
+
if (!model) return 'model-other';
|
|
2403
|
+
const m = model.toLowerCase();
|
|
2404
|
+
if (m.includes('gpt-4o') || m.includes('4o')) return 'model-gpt4o';
|
|
2405
|
+
if (m.includes('gpt-4')) return 'model-gpt4';
|
|
2406
|
+
if (m.includes('gpt-3.5') || m.includes('gpt-35')) return 'model-gpt35';
|
|
2407
|
+
return 'model-other';
|
|
2408
|
+
}
|
|
2409
|
+
`;
|
|
2410
|
+
}
|