@sidx1scr-apps/prefrontal 1.1.79

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/app.js ADDED
@@ -0,0 +1,1593 @@
1
+ /* ═══════════════════════════════════════════════════════════════
2
+ Prefrontal — App Logic (app.js)
3
+ Local AI via Ollama | 100% Offline | No Ads
4
+ ═══════════════════════════════════════════════════════════════ */
5
+
6
+ 'use strict';
7
+
8
+ // ── STATE ────────────────────────────────────────────────────────
9
+ const state = {
10
+ chats: {}, // { id: { id, title, messages:[], created, updated } }
11
+ activeChatId: null,
12
+ isGenerating: false,
13
+ abortController: null,
14
+ settings: {
15
+ serverUrl: 'http://localhost:11434',
16
+ runtime: 'ollama', // 'ollama' | 'openai' | 'openrouter'
17
+ model: 'gemma4:e2b',
18
+ systemPrompt: 'You are Prefrontal, a helpful, honest, and harmless AI assistant. You are running entirely locally on the user\'s device with complete privacy. Be concise, clear, and friendly.',
19
+ temperature: 0.7,
20
+ numCtx: 8192,
21
+ stream: true,
22
+ autoScroll: true,
23
+ sound: false,
24
+ sendMode: 'enter', // 'enter' | 'shift'
25
+ theme: 'dark',
26
+ apiKey: '',
27
+ personality: 'balanced', // tracks which preset is active
28
+ webSearch: false, // OpenRouter-only: enable the ':web' search plugin
29
+ },
30
+ totalTokens: 0,
31
+ profile: null, // { deviceId, displayName, avatar, createdAt }
32
+ };
33
+
34
+ // ── PERSONALITY PRESETS ───────────────────────────────────────────
35
+ const PERSONALITY_PRESETS = {
36
+ balanced: {
37
+ name: 'Balanced',
38
+ temperature: 0.7,
39
+ systemPrompt: 'You are Prefrontal, a helpful, honest, and harmless AI assistant. You are running entirely locally on the user\'s device with complete privacy. Be concise, clear, and friendly.',
40
+ },
41
+ creative: {
42
+ name: 'Creative',
43
+ temperature: 1.1,
44
+ systemPrompt: 'You are Prefrontal, a creative and imaginative AI muse running entirely locally on the user\'s device. Be expressive, playful, and explore ideas with flair. Use vivid language, metaphors, and creative thinking.',
45
+ },
46
+ precise: {
47
+ name: 'Precise',
48
+ temperature: 0.2,
49
+ systemPrompt: 'You are Prefrontal, a precise and factual AI assistant running entirely locally. Be concise, direct, and accurate. Avoid filler, preamble, and unnecessary repetition. Answer exactly what is asked.',
50
+ },
51
+ developer: {
52
+ name: 'Developer',
53
+ temperature: 0.3,
54
+ systemPrompt: 'You are Prefrontal, a senior software engineer and code review AI running entirely locally. Prioritize working, idiomatic code above all else. Be terse and technical — skip hand-holding.',
55
+ },
56
+ custom: {
57
+ name: 'Custom',
58
+ temperature: null,
59
+ systemPrompt: null,
60
+ },
61
+ };
62
+
63
+ function applyPersonalityPreset(preset, { updateUI = false } = {}) {
64
+ const p = PERSONALITY_PRESETS[preset];
65
+ if (!p || preset === 'custom') return;
66
+ state.settings.personality = preset;
67
+ state.settings.temperature = p.temperature;
68
+ state.settings.systemPrompt = p.systemPrompt;
69
+ if (updateUI) {
70
+ if (els.tempSlider) { els.tempSlider.value = p.temperature; }
71
+ if (els.tempDisplay) { els.tempDisplay.textContent = p.temperature.toFixed(2); }
72
+ if (els.tempBadge) { els.tempBadge.textContent = getTempBadgeLabel(p.temperature); }
73
+ if (els.systemPrompt) { els.systemPrompt.value = p.systemPrompt; }
74
+ syncPersonalityUI(preset);
75
+ }
76
+ saveSettings();
77
+ }
78
+
79
+ function syncPersonalityUI(preset) {
80
+ // Settings modal preset buttons
81
+ document.querySelectorAll('.personality-preset-btn').forEach(b =>
82
+ b.classList.toggle('active', b.dataset.preset === preset)
83
+ );
84
+ // Welcome screen pills
85
+ document.querySelectorAll('.personality-pill').forEach(b =>
86
+ b.classList.toggle('active', b.dataset.preset === preset)
87
+ );
88
+ }
89
+
90
+ function getTempBadgeLabel(val) {
91
+ const v = parseFloat(val);
92
+ if (v <= 0.1) return 'Deterministic';
93
+ if (v <= 0.4) return 'Precise';
94
+ if (v <= 0.8) return 'Balanced';
95
+ if (v <= 1.2) return 'Creative';
96
+ if (v <= 1.6) return 'Expressive';
97
+ return 'Wild';
98
+ }
99
+
100
+ // ── DOM REFS ──────────────────────────────────────────────────────
101
+ const $ = id => document.getElementById(id);
102
+ const els = {
103
+ sidebar: $('sidebar'),
104
+ sidebarToggle: $('sidebarToggle'),
105
+ newChatBtn: $('newChatBtn'),
106
+ searchChats: $('searchChats'),
107
+ chatList: $('chatList'),
108
+ exportAllBtn: $('exportAllBtn'),
109
+ clearAllBtn: $('clearAllBtn'),
110
+ topbarTitle: $('topbarTitle'),
111
+ modelNameDisplay: $('modelNameDisplay'),
112
+ modelBadge: $('modelBadge'),
113
+ settingsBtn: $('settingsBtn'),
114
+ exportChatBtn: $('exportChatBtn'),
115
+ chatArea: $('chatArea'),
116
+ welcomeScreen: $('welcomeScreen'),
117
+ messagesWrapper: $('messagesWrapper'),
118
+ statusBar: $('statusBar'),
119
+ statusDot: $('statusDot'),
120
+ statusText: $('statusText'),
121
+ tokenCounter: $('tokenCounter'),
122
+ userInput: $('userInput'),
123
+ charCount: $('charCount'),
124
+ sendBtn: $('sendBtn'),
125
+ attachBtn: $('attachBtn'),
126
+ settingsOverlay: $('settingsOverlay'),
127
+ closeSettings: $('closeSettings'),
128
+ serverUrl: $('serverUrl'),
129
+ serverUrlHint: $('serverUrlHint'),
130
+ serverTypeBadge: $('serverTypeBadge'),
131
+ serverQuickBtns: $('serverQuickBtns'),
132
+ runtimeOptions: $('runtimeOptions'),
133
+ modelInput: $('modelInput'),
134
+ fetchModelsBtn: $('fetchModelsBtn'),
135
+ modelList: $('modelList'),
136
+ systemPrompt: $('systemPrompt'),
137
+ tempSlider: $('tempSlider'),
138
+ tempDisplay: $('tempDisplay'),
139
+ tempBadge: $('tempBadge'),
140
+ ctxSlider: $('ctxSlider'),
141
+ ctxDisplay: $('ctxDisplay'),
142
+ themeOptions: $('themeOptions'),
143
+ streamToggle: $('streamToggle'),
144
+ autoScrollToggle: $('autoScrollToggle'),
145
+ soundToggle: $('soundToggle'),
146
+ shortcutOptions: $('shortcutOptions'),
147
+ apiKey: $('apiKey'),
148
+ webSearchGroup: $('webSearchGroup'),
149
+ webSearchToggle: $('webSearchToggle'),
150
+ resetSettingsBtn: $('resetSettingsBtn'),
151
+ saveSettingsBtn: $('saveSettingsBtn'),
152
+ confirmOverlay: $('confirmOverlay'),
153
+ confirmTitle: $('confirmTitle'),
154
+ confirmMessage: $('confirmMessage'),
155
+ confirmCancel: $('confirmCancel'),
156
+ confirmOk: $('confirmOk'),
157
+ toastContainer: $('toastContainer'),
158
+ // Profile
159
+ profileCard: $('profileCard'),
160
+ openProfileBtn: $('openProfileBtn'),
161
+ sidebarAvatar: $('sidebarAvatar'),
162
+ sidebarName: $('sidebarName'),
163
+ sidebarId: $('sidebarId'),
164
+ // Setup modal
165
+ setupOverlay: $('setupOverlay'),
166
+ deviceIdPreview: $('deviceIdPreview'),
167
+ avatarGrid: $('avatarGrid'),
168
+ setupName: $('setupName'),
169
+ completeSetupBtn: $('completeSetupBtn'),
170
+ // Profile modal
171
+ profileOverlay: $('profileOverlay'),
172
+ closeProfileBtn: $('closeProfileBtn'),
173
+ cancelProfileBtn: $('cancelProfileBtn'),
174
+ saveProfileBtn: $('saveProfileBtn'),
175
+ profilePreviewAvatar:$('profilePreviewAvatar'),
176
+ profilePreviewName: $('profilePreviewName'),
177
+ profilePreviewMeta: $('profilePreviewMeta'),
178
+ profileAvatarGrid: $('profileAvatarGrid'),
179
+ profileNameInput: $('profileNameInput'),
180
+ profileDeviceId: $('profileDeviceId'),
181
+ profileCreatedAt: $('profileCreatedAt'),
182
+ exportProfileBtn: $('exportProfileBtn'),
183
+ importProfileInput: $('importProfileInput'),
184
+ };
185
+
186
+ // ── PERSISTENCE ───────────────────────────────────────────────────
187
+ const STORAGE_KEY_CHATS = 'prefrontal_chats';
188
+ const STORAGE_KEY_SETTINGS = 'prefrontal_settings';
189
+ const STORAGE_KEY_PROFILE = 'prefrontal_profile';
190
+
191
+ function saveChats() {
192
+ try { localStorage.setItem(STORAGE_KEY_CHATS, JSON.stringify(state.chats)); } catch(e) {}
193
+ }
194
+ function loadChats() {
195
+ try {
196
+ const raw = localStorage.getItem(STORAGE_KEY_CHATS);
197
+ if (raw) state.chats = JSON.parse(raw);
198
+ } catch(e) { state.chats = {}; }
199
+ }
200
+ function saveSettings() {
201
+ try { localStorage.setItem(STORAGE_KEY_SETTINGS, JSON.stringify(state.settings)); } catch(e) {}
202
+ }
203
+ function loadSettings() {
204
+ try {
205
+ const raw = localStorage.getItem(STORAGE_KEY_SETTINGS);
206
+ if (raw) Object.assign(state.settings, JSON.parse(raw));
207
+ } catch(e) {}
208
+ }
209
+
210
+ // ── PROFILE PERSISTENCE ───────────────────────────────────────────
211
+ function saveProfile() {
212
+ try { localStorage.setItem(STORAGE_KEY_PROFILE, JSON.stringify(state.profile)); } catch(e) {}
213
+ }
214
+ function loadProfile() {
215
+ try {
216
+ const raw = localStorage.getItem(STORAGE_KEY_PROFILE);
217
+ if (raw) { state.profile = JSON.parse(raw); return true; }
218
+ } catch(e) {}
219
+ return false;
220
+ }
221
+
222
+ // Generate a UUID v4
223
+ function generateUUID() {
224
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
225
+ const r = Math.random() * 16 | 0;
226
+ return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
227
+ });
228
+ }
229
+
230
+ // Short version for display: first 8 chars
231
+ const shortId = id => id ? id.slice(0, 8).toUpperCase() : '—';
232
+
233
+ // ── ID & TIMESTAMP ────────────────────────────────────────────────
234
+ const uid = () => `${Date.now()}-${Math.random().toString(36).slice(2,8)}`;
235
+ const fmtTime = ts => new Date(ts).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});
236
+ const fmtDate = ts => {
237
+ const d = new Date(ts), now = new Date();
238
+ if (d.toDateString() === now.toDateString()) return 'Today';
239
+ const yesterday = new Date(); yesterday.setDate(yesterday.getDate()-1);
240
+ if (d.toDateString() === yesterday.toDateString()) return 'Yesterday';
241
+ return d.toLocaleDateString([], {month:'short',day:'numeric'});
242
+ };
243
+
244
+ // ── TOAST ─────────────────────────────────────────────────────────
245
+ function toast(msg, type = 'info', duration = 3000) {
246
+ const icons = { success:'✅', error:'❌', info:'ℹ️', warn:'⚠️' };
247
+ const el = document.createElement('div');
248
+ el.className = `toast ${type}`;
249
+ el.innerHTML = `<span class="toast-icon">${icons[type]||'ℹ️'}</span><span>${msg}</span>`;
250
+ els.toastContainer.appendChild(el);
251
+ setTimeout(() => {
252
+ el.style.animation = 'toastOut 0.3s ease forwards';
253
+ setTimeout(() => el.remove(), 300);
254
+ }, duration);
255
+ }
256
+
257
+ // ── CONFIRM DIALOG ────────────────────────────────────────────────
258
+ function confirm(title, message) {
259
+ return new Promise(resolve => {
260
+ els.confirmTitle.textContent = title;
261
+ els.confirmMessage.textContent = message;
262
+ els.confirmOverlay.classList.add('open');
263
+ const ok = () => { cleanup(); resolve(true); };
264
+ const cancel = () => { cleanup(); resolve(false); };
265
+ function cleanup() {
266
+ els.confirmOverlay.classList.remove('open');
267
+ els.confirmOk.removeEventListener('click', ok);
268
+ els.confirmCancel.removeEventListener('click', cancel);
269
+ }
270
+ els.confirmOk.addEventListener('click', ok);
271
+ els.confirmCancel.addEventListener('click', cancel);
272
+ });
273
+ }
274
+
275
+ // ── SOUND ─────────────────────────────────────────────────────────
276
+ function playSound(freq = 440, duration = 0.08) {
277
+ if (!state.settings.sound) return;
278
+ try {
279
+ const ctx = new (window.AudioContext || window.webkitAudioContext)();
280
+ const osc = ctx.createOscillator();
281
+ const gain = ctx.createGain();
282
+ osc.connect(gain); gain.connect(ctx.destination);
283
+ osc.frequency.value = freq; osc.type = 'sine';
284
+ gain.gain.setValueAtTime(0.08, ctx.currentTime);
285
+ gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + duration);
286
+ osc.start(); osc.stop(ctx.currentTime + duration);
287
+ } catch(e) {}
288
+ }
289
+
290
+ // ── MARKDOWN RENDERING ────────────────────────────────────────────
291
+ function renderMarkdown(text) {
292
+ if (typeof marked === 'undefined') return escapeHtml(text);
293
+ marked.setOptions({
294
+ highlight: (code, lang) => {
295
+ if (hljs && lang && hljs.getLanguage(lang)) {
296
+ try { return hljs.highlight(code, {language: lang}).value; } catch(e) {}
297
+ }
298
+ return hljs ? hljs.highlightAuto(code).value : escapeHtml(code);
299
+ },
300
+ breaks: true, gfm: true,
301
+ });
302
+
303
+ let html = marked.parse(text);
304
+
305
+ // Add copy buttons and headers to code blocks
306
+ html = html.replace(/<pre><code(?: class="language-([^"]+)")?>([\s\S]*?)<\/code><\/pre>/g, (_, lang, code) => {
307
+ const l = lang || 'text';
308
+ return `<div class="code-block-wrapper"><pre><div class="code-header"><span class="code-lang">${l}</span><button class="copy-code-btn" onclick="copyCode(this)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:12px;height:12px"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M16 3H7a2 2 0 00-2 2v13a2 2 0 002 2h9a2 2 0 002-2V5a2 2 0 00-2-2z"/></svg></button></div><code>${code}</code></pre></div>`;
309
+ });
310
+ return html;
311
+ }
312
+
313
+ function escapeHtml(t) {
314
+ return t.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
315
+ }
316
+
317
+ // ── WEB SEARCH (prompt-driven, DuckDuckGo Instant Answer API) ──────
318
+ // Prefrontal does NOT use OpenRouter's built-in search plugin. Instead, when
319
+ // Web Search is on, the model is told (via a system-prompt addendum) that it
320
+ // can request a search by replying with ONLY a small JSON object. sendRequest()
321
+ // detects that JSON, runs the search itself against DuckDuckGo, and feeds the
322
+ // results back to the model as a follow-up message so it can answer normally.
323
+ // This keeps the feature entirely in Prefrontal's hands — it works with the
324
+ // free DuckDuckGo API and needs no extra provider-side capability.
325
+ const WEB_SEARCH_INSTRUCTIONS = `You have the ability to search the web when you need current information, facts you're not sure of, or anything that could have changed since your training. To search, reply with ONLY this JSON object and nothing else — no other words, no markdown code fences, no explanation before or after it:
326
+ {"search_query": "your search terms here"}
327
+ The app will run that search and send the results back to you in a follow-up message. Once you have the results, answer the user's original question normally, in plain text, using them. Only search when it would genuinely help — for things you already know, or normal conversation, just answer directly without searching.`;
328
+
329
+ // Pulls a {"search_query": "..."} request out of a model reply. Tolerates
330
+ // accidental code-fence wrapping even though the prompt asks the model not
331
+ // to use one, since smaller/free models don't always follow instructions
332
+ // exactly. Returns null for anything that isn't a clean search request.
333
+ function extractSearchQuery(text) {
334
+ let t = (text || '').trim();
335
+ t = t.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/, '').trim();
336
+ if (!t.startsWith('{') || !t.endsWith('}')) return null;
337
+ try {
338
+ const obj = JSON.parse(t);
339
+ if (obj && typeof obj.search_query === 'string' && obj.search_query.trim()) {
340
+ return obj.search_query.trim();
341
+ }
342
+ } catch (e) { /* not JSON, not a search request */ }
343
+ return null;
344
+ }
345
+
346
+ // Queries DuckDuckGo's free, keyless Instant Answer API. This is a knowledge-graph
347
+ // style API, not a full search index — it works well for facts, definitions, people,
348
+ // and topics with a Wikipedia-style abstract, but can come back empty for narrower
349
+ // or very current queries. Returns an array of {title, url, snippet}, newest/most
350
+ // relevant first, capped to keep the follow-up prompt small.
351
+ async function duckDuckGoSearch(query) {
352
+ const url = `https://api.duckduckgo.com/?q=${encodeURIComponent(query)}&format=json&no_html=1&skip_disambig=1&no_redirect=1`;
353
+ try {
354
+ const res = await fetch(url);
355
+ if (!res.ok) return [];
356
+ const data = await res.json();
357
+ const items = [];
358
+ if (data.AbstractText) {
359
+ items.push({ title: data.Heading || query, url: data.AbstractURL || url, snippet: data.AbstractText });
360
+ }
361
+ if (data.Answer) {
362
+ items.push({ title: data.AnswerType || 'Answer', url: data.AbstractURL || url, snippet: data.Answer });
363
+ }
364
+ const walkTopics = (topics) => {
365
+ for (const t of topics || []) {
366
+ if (items.length >= 6) return;
367
+ if (t.Topics) { walkTopics(t.Topics); continue; }
368
+ if (t.Text && t.FirstURL) {
369
+ items.push({ title: t.Text.split(' - ')[0].slice(0, 90), url: t.FirstURL, snippet: t.Text });
370
+ }
371
+ }
372
+ };
373
+ walkTopics(data.RelatedTopics);
374
+ return items.slice(0, 6);
375
+ } catch (e) {
376
+ return []; // network hiccup or CORS issue — fail quietly, model answers from its own knowledge
377
+ }
378
+ }
379
+
380
+ // Turns DuckDuckGo results into the follow-up user-role message sent back to
381
+ // the model, explicitly telling it not to search again so the loop terminates.
382
+ function formatSearchResultsForModel(query, items) {
383
+ if (!items.length) {
384
+ return `[Web search for "${query}" returned no results from DuckDuckGo. Answer using your own knowledge — mention that a live search came up empty if that's relevant to the answer. Do not search again.]`;
385
+ }
386
+ const lines = items.map((it, i) => `${i + 1}. ${it.title} — ${it.snippet} (${it.url})`);
387
+ return `[Web search results for "${query}"]\n${lines.join('\n')}\n\nUsing the results above, answer the user's original question normally in plain text. Do not output JSON or search again.`;
388
+ }
389
+
390
+ // Dedupe-and-append for the source chips shown under a message. Accepts
391
+ // plain {url, title} objects (DuckDuckGo results already come in this shape).
392
+ function mergeSources(existing, incoming) {
393
+ const list = existing.slice();
394
+ for (const s of incoming || []) {
395
+ if (!s?.url || list.some(x => x.url === s.url)) continue;
396
+ list.push({ url: s.url, title: s.title || s.url });
397
+ }
398
+ return list;
399
+ }
400
+
401
+ function renderSourcesHtml(sources) {
402
+ if (!sources || !sources.length) return '';
403
+ const chips = sources.map(s => {
404
+ const url = escapeHtml(s.url);
405
+ const title = escapeHtml(s.title || s.url);
406
+ return `<a class="msg-source-chip" href="${url}" target="_blank" rel="noopener noreferrer" title="${url}">${title}</a>`;
407
+ }).join('');
408
+ return `<div class="msg-sources"><span class="msg-sources-label">🔎 Web sources</span><div class="msg-source-chips">${chips}</div></div>`;
409
+ }
410
+
411
+ window.copyCode = function(btn) {
412
+ const code = btn.closest('pre').querySelector('code');
413
+ navigator.clipboard.writeText(code.innerText).then(() => {
414
+ btn.classList.add('copied');
415
+ btn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:12px;height:12px"><polyline points="20 6 9 17 4 12"/></svg>`;
416
+ setTimeout(() => {
417
+ btn.classList.remove('copied');
418
+ btn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:12px;height:12px"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M16 3H7a2 2 0 00-2 2v13a2 2 0 002 2h9a2 2 0 002-2V5a2 2 0 00-2-2z"/></svg>`;
419
+ }, 2000);
420
+ });
421
+ };
422
+
423
+ // ── CHAT MANAGEMENT ───────────────────────────────────────────────
424
+ function createChat() {
425
+ const id = uid();
426
+ state.chats[id] = { id, title: 'New Chat', messages: [], created: Date.now(), updated: Date.now() };
427
+ return id;
428
+ }
429
+
430
+ function deleteChat(id) {
431
+ delete state.chats[id];
432
+ if (state.activeChatId === id) {
433
+ const remaining = Object.keys(state.chats);
434
+ state.activeChatId = remaining.length > 0 ? remaining[remaining.length - 1] : null;
435
+ if (!state.activeChatId) {
436
+ state.activeChatId = createChat();
437
+ }
438
+ renderChat();
439
+ }
440
+ saveChats();
441
+ renderChatList();
442
+ }
443
+
444
+ function renameChat(id, newTitle) {
445
+ if (state.chats[id]) {
446
+ state.chats[id].title = newTitle || 'Untitled';
447
+ saveChats();
448
+ renderChatList();
449
+ if (state.activeChatId === id) els.topbarTitle.textContent = state.chats[id].title;
450
+ }
451
+ }
452
+
453
+ function autoTitle(id) {
454
+ const chat = state.chats[id];
455
+ if (!chat || chat.messages.length === 0) return;
456
+ const first = chat.messages.find(m => m.role === 'user');
457
+ if (!first) return;
458
+ const raw = first.content.trim().replace(/\n/g,' ');
459
+ const title = raw.length > 48 ? raw.slice(0, 45) + '…' : raw;
460
+ chat.title = title;
461
+ chat.updated = Date.now();
462
+ }
463
+
464
+ function setActiveChat(id) {
465
+ state.activeChatId = id;
466
+ renderChatList();
467
+ renderChat();
468
+ }
469
+
470
+ // ── RENDER CHAT LIST ──────────────────────────────────────────────
471
+ function renderChatList(filter = '') {
472
+ const ids = Object.keys(state.chats).sort((a,b) => (state.chats[b].updated||0) - (state.chats[a].updated||0));
473
+ const filtered = filter ? ids.filter(id => state.chats[id].title.toLowerCase().includes(filter.toLowerCase())) : ids;
474
+
475
+ if (filtered.length === 0) {
476
+ els.chatList.innerHTML = `<div class="empty-chat-list"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg><span>No conversations yet</span></div>`;
477
+ return;
478
+ }
479
+
480
+ els.chatList.innerHTML = filtered.map(id => {
481
+ const chat = state.chats[id];
482
+ const active = id === state.activeChatId ? 'active' : '';
483
+ const icon = chat.messages.length > 0 ? '💬' : '🆕';
484
+ return `<div class="chat-item ${active}" data-id="${id}" id="chat-item-${id}">
485
+ <div class="chat-item-icon">${icon}</div>
486
+ <div class="chat-item-info">
487
+ <div class="chat-item-title">${escapeHtml(chat.title)}</div>
488
+ <div class="chat-item-date">${fmtDate(chat.updated||chat.created)} · ${chat.messages.filter(m=>m.role==='user').length} msg${chat.messages.filter(m=>m.role==='user').length!==1?'s':''}</div>
489
+ </div>
490
+ <div class="chat-item-actions">
491
+ <button class="chat-item-action-btn" onclick="promptRename(event,'${id}')" title="Rename">✏️</button>
492
+ <button class="chat-item-action-btn" onclick="promptExport(event,'${id}')" title="Export">📥</button>
493
+ <button class="chat-item-action-btn del" onclick="promptDelete(event,'${id}')" title="Delete">🗑️</button>
494
+ </div>
495
+ </div>`;
496
+ }).join('');
497
+
498
+ // Click on chat item
499
+ filtered.forEach(id => {
500
+ const el = document.getElementById(`chat-item-${id}`);
501
+ if (el) el.addEventListener('click', e => {
502
+ if (!e.target.closest('.chat-item-actions')) setActiveChat(id);
503
+ });
504
+ });
505
+ }
506
+
507
+ window.promptRename = function(e, id) {
508
+ e.stopPropagation();
509
+ const current = state.chats[id]?.title || '';
510
+ const name = prompt('Rename conversation:', current);
511
+ if (name !== null && name.trim()) renameChat(id, name.trim());
512
+ };
513
+ window.promptDelete = async function(e, id) {
514
+ e.stopPropagation();
515
+ const ok = await confirm('Delete Conversation', `Delete "${state.chats[id]?.title}"? This cannot be undone.`);
516
+ if (ok) { deleteChat(id); toast('Conversation deleted', 'success'); }
517
+ };
518
+ window.promptExport = function(e, id) {
519
+ e.stopPropagation();
520
+ exportChat(id);
521
+ };
522
+
523
+ // ── RENDER CHAT ───────────────────────────────────────────────────
524
+ function renderChat() {
525
+ const chat = state.chats[state.activeChatId];
526
+ if (!chat) return;
527
+
528
+ els.topbarTitle.textContent = chat.title;
529
+ els.messagesWrapper.innerHTML = '';
530
+
531
+ if (chat.messages.length === 0) {
532
+ els.welcomeScreen.style.display = '';
533
+ return;
534
+ }
535
+ els.welcomeScreen.style.display = 'none';
536
+
537
+ chat.messages.forEach(msg => appendMessageEl(msg));
538
+ scrollToBottom(false);
539
+ updateTokenCounter();
540
+ }
541
+
542
+ function appendMessageEl(msg) {
543
+ const el = document.createElement('div');
544
+ el.className = `message ${msg.role}`;
545
+ el.dataset.id = msg.id;
546
+
547
+ const avatarContent = msg.role === 'user'
548
+ ? `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:17px;height:17px;color:#fff"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`
549
+ : `<svg viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg" style="width:20px;height:20px"><circle cx="18" cy="18" r="18" fill="url(#ag${msg.id?.slice(-4)||'x'})"/><path d="M24 14h-5.5a2.5 2.5 0 000 5H21a2.5 2.5 0 010 5h-6" stroke="#fff" stroke-width="1.5" stroke-linecap="round"/><defs><linearGradient id="ag${msg.id?.slice(-4)||'x'}" x1="0" y1="0" x2="36" y2="36"><stop offset="0%" stop-color="#10a37f"/><stop offset="100%" stop-color="#0d8965"/></linearGradient></defs></svg>`;
550
+
551
+ const copyIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>`;
552
+ const regenIcon = msg.role === 'assistant' ? `<button class="msg-action-btn" onclick="regenerateFrom('${msg.id}')" title="Regenerate">${regenSvg()}</button>` : '';
553
+ const delIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6m3 0V4a1 1 0 011-1h4a1 1 0 011 1v2m4 5v6m-4-6v6"/></svg>`;
554
+
555
+ const renderedContent = msg.role === 'assistant'
556
+ ? renderMarkdown(msg.content) + renderSourcesHtml(msg.sources)
557
+ : `<p>${escapeHtml(msg.content).replace(/\n/g,'<br>')}</p>`;
558
+
559
+ el.innerHTML = `
560
+ <div class="msg-avatar">${avatarContent}</div>
561
+ <div class="msg-bubble">
562
+ <div class="msg-content">${renderedContent}</div>
563
+ <div class="msg-meta">
564
+ <span class="msg-time">${fmtTime(msg.timestamp)}</span>
565
+ <div class="msg-actions">
566
+ <button class="msg-action-btn" onclick="copyMsgContent('${msg.id}')" title="Copy">${copyIcon}</button>
567
+ ${regenIcon}
568
+ <button class="msg-action-btn" onclick="deleteMessage('${msg.id}')" title="Delete">${delIcon}</button>
569
+ </div>
570
+ </div>
571
+ </div>`;
572
+
573
+ els.messagesWrapper.appendChild(el);
574
+ return el;
575
+ }
576
+
577
+ function regenSvg() { return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 4v6h6"/><path d="M3.51 15a9 9 0 1015.85-5.5"/></svg>`; }
578
+
579
+ window.copyMsgContent = function(id) {
580
+ const chat = state.chats[state.activeChatId];
581
+ const msg = chat?.messages.find(m => m.id === id);
582
+ if (!msg) return;
583
+ navigator.clipboard.writeText(msg.content).then(() => toast('Copied to clipboard', 'success'));
584
+ };
585
+
586
+ window.deleteMessage = function(id) {
587
+ const chat = state.chats[state.activeChatId];
588
+ if (!chat) return;
589
+ chat.messages = chat.messages.filter(m => m.id !== id);
590
+ saveChats();
591
+ renderChat();
592
+ toast('Message deleted', 'info');
593
+ };
594
+
595
+ window.regenerateFrom = async function(id) {
596
+ const chat = state.chats[state.activeChatId];
597
+ if (!chat || state.isGenerating) return;
598
+ const idx = chat.messages.findIndex(m => m.id === id);
599
+ if (idx === -1) return;
600
+ // Keep all messages UP TO the assistant message and regenerate
601
+ chat.messages = chat.messages.slice(0, idx);
602
+ saveChats();
603
+ renderChat();
604
+ await sendRequest();
605
+ };
606
+
607
+ // ── SCROLL ────────────────────────────────────────────────────────
608
+ function scrollToBottom(smooth = true) {
609
+ if (!state.settings.autoScroll && smooth) return;
610
+ els.chatArea.scrollTo({ top: els.chatArea.scrollHeight, behavior: smooth ? 'smooth' : 'instant' });
611
+ }
612
+
613
+ // ── GENERATE / SEND ───────────────────────────────────────────────
614
+ async function sendMessage(content) {
615
+ if (!content.trim() || state.isGenerating) return;
616
+
617
+ const chat = state.chats[state.activeChatId];
618
+ if (!chat) return;
619
+
620
+ // Hide welcome, show messages
621
+ els.welcomeScreen.style.display = 'none';
622
+
623
+ // Add user message
624
+ const userMsg = { id: uid(), role: 'user', content: content.trim(), timestamp: Date.now() };
625
+ chat.messages.push(userMsg);
626
+ appendMessageEl(userMsg);
627
+ scrollToBottom();
628
+ playSound(600, 0.08);
629
+
630
+ // Update chat title from first message
631
+ if (chat.messages.filter(m=>m.role==='user').length === 1) {
632
+ autoTitle(state.activeChatId);
633
+ renderChatList();
634
+ els.topbarTitle.textContent = chat.title;
635
+ }
636
+
637
+ saveChats();
638
+ updateTokenCounter();
639
+ await sendRequest();
640
+ }
641
+
642
+ async function sendRequest() {
643
+ const chat = state.chats[state.activeChatId];
644
+ if (!chat || state.isGenerating) return;
645
+
646
+ state.isGenerating = true;
647
+ state.abortController = new AbortController();
648
+ setSendingState(true);
649
+ setStatus('loading', 'Generating…');
650
+
651
+ // Build message history for API
652
+ const webSearchEnabled = state.settings.runtime === 'openrouter' && state.settings.webSearch;
653
+ const messages = [];
654
+ let sysPrompt = state.settings.systemPrompt.trim();
655
+ if (webSearchEnabled) {
656
+ sysPrompt = (sysPrompt ? sysPrompt + '\n\n' : '') + WEB_SEARCH_INSTRUCTIONS;
657
+ }
658
+ if (sysPrompt) {
659
+ messages.push({ role: 'system', content: sysPrompt });
660
+ }
661
+ chat.messages.forEach(m => messages.push({ role: m.role, content: m.content }));
662
+
663
+ // Create assistant message placeholder
664
+ const assistantMsg = { id: uid(), role: 'assistant', content: '', timestamp: Date.now() };
665
+ chat.messages.push(assistantMsg);
666
+
667
+ // Create streaming element
668
+ const msgEl = document.createElement('div');
669
+ msgEl.className = 'message assistant';
670
+ msgEl.dataset.id = assistantMsg.id;
671
+ const avatarId = assistantMsg.id.slice(-4);
672
+ msgEl.innerHTML = `
673
+ <div class="msg-avatar">
674
+ <svg viewBox="0 0 36 36" fill="none" style="width:20px;height:20px"><circle cx="18" cy="18" r="18" fill="url(#ag${avatarId})"/><path d="M24 14h-5.5a2.5 2.5 0 000 5H21a2.5 2.5 0 010 5h-6" stroke="#fff" stroke-width="1.5" stroke-linecap="round"/><defs><linearGradient id="ag${avatarId}" x1="0" y1="0" x2="36" y2="36"><stop offset="0%" stop-color="#10a37f"/><stop offset="100%" stop-color="#0d8965"/></linearGradient></defs></svg>
675
+ </div>
676
+ <div class="msg-bubble">
677
+ <div class="msg-content"><div class="thinking-dots"><span></span><span></span><span></span></div></div>
678
+ <div class="msg-meta"><span class="msg-time">${fmtTime(assistantMsg.timestamp)}</span></div>
679
+ </div>`;
680
+ els.messagesWrapper.appendChild(msgEl);
681
+
682
+ // Stop button
683
+ const stopWrapper = document.createElement('div');
684
+ stopWrapper.className = 'stop-btn-wrapper';
685
+ stopWrapper.innerHTML = `<button class="stop-btn visible" id="stopGenBtn"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="1"/></svg> Stop</button>`;
686
+ els.messagesWrapper.appendChild(stopWrapper);
687
+ $('stopGenBtn')?.addEventListener('click', () => {
688
+ state.abortController?.abort();
689
+ });
690
+
691
+ scrollToBottom();
692
+
693
+ const contentEl = msgEl.querySelector('.msg-content');
694
+
695
+ // Runs exactly one completion request (streaming or not) against whichever
696
+ // runtime is active, rendering tokens into contentEl as they arrive, and
697
+ // resolves with the full response text. Used directly for a normal reply,
698
+ // and called a second time with search results appended when the model
699
+ // asks to search (see the loop below).
700
+ async function runOneCompletion(msgsForThisCall) {
701
+ let url, payload;
702
+
703
+ if (state.settings.runtime === 'openai' || state.settings.runtime === 'openrouter') {
704
+ if (state.settings.runtime === 'openrouter') {
705
+ url = 'https://openrouter.ai/api/v1/chat/completions';
706
+ } else {
707
+ url = `${state.settings.serverUrl.replace(/\/$/, '')}/v1/chat/completions`;
708
+ }
709
+ payload = {
710
+ model: state.settings.model,
711
+ messages: msgsForThisCall,
712
+ stream: state.settings.stream,
713
+ temperature: state.settings.temperature,
714
+ };
715
+ } else {
716
+ url = `${state.settings.serverUrl.replace(/\/$/, '')}/api/chat`;
717
+ payload = {
718
+ model: state.settings.model,
719
+ messages: msgsForThisCall,
720
+ stream: state.settings.stream,
721
+ options: {
722
+ temperature: state.settings.temperature,
723
+ num_ctx: state.settings.numCtx,
724
+ },
725
+ };
726
+ }
727
+
728
+ const headers = { 'Content-Type': 'application/json' };
729
+ if (state.settings.apiKey && (state.settings.runtime === 'openai' || state.settings.runtime === 'openrouter')) {
730
+ headers['Authorization'] = `Bearer ${state.settings.apiKey}`;
731
+ }
732
+
733
+ const response = await fetch(url, {
734
+ method: 'POST',
735
+ headers: headers,
736
+ body: JSON.stringify(payload),
737
+ signal: state.abortController.signal,
738
+ });
739
+
740
+ if (!response.ok) {
741
+ const err = await response.text();
742
+ throw new Error(`Server error ${response.status}: ${err.slice(0,200)}`);
743
+ }
744
+
745
+ let text = '';
746
+
747
+ // While Web Search is on, a reply that *starts* with '{' might be the
748
+ // model's search-request JSON rather than a real answer — render a
749
+ // "searching" placeholder instead of flashing raw JSON at the user until
750
+ // we know which one it is.
751
+ const renderLive = () => {
752
+ if (webSearchEnabled && text.trim().startsWith('{')) {
753
+ contentEl.innerHTML = `<div class="thinking-dots"><span></span><span></span><span></span></div>`;
754
+ } else {
755
+ contentEl.innerHTML = renderMarkdown(text) + '<span class="typing-cursor"></span>';
756
+ }
757
+ if (state.settings.autoScroll) scrollToBottom();
758
+ };
759
+
760
+ if (state.settings.stream) {
761
+ const reader = response.body.getReader();
762
+ const decoder = new TextDecoder();
763
+ let buffer = '';
764
+
765
+ while (true) {
766
+ const { done, value } = await reader.read();
767
+ if (done) break;
768
+ buffer += decoder.decode(value, { stream: true });
769
+ const lines = buffer.split('\n');
770
+ buffer = lines.pop() || '';
771
+
772
+ for (const line of lines) {
773
+ const tLine = line.trim();
774
+ if (!tLine) continue;
775
+
776
+ if (state.settings.runtime === 'openai' || state.settings.runtime === 'openrouter') {
777
+ // OpenAI/OpenRouter SSE format
778
+ if (tLine.startsWith('data: ')) {
779
+ const dataStr = tLine.slice(6).trim();
780
+ if (dataStr === '[DONE]') continue;
781
+ try {
782
+ const data = JSON.parse(dataStr);
783
+ const chunk = data.choices?.[0]?.delta?.content || '';
784
+ if (chunk) {
785
+ text += chunk;
786
+ renderLive();
787
+ }
788
+ } catch(pe) {
789
+ // Silent fail on parse errors
790
+ }
791
+ }
792
+ } else {
793
+ // Ollama NDJSON
794
+ try {
795
+ const data = JSON.parse(tLine);
796
+ if (data.message?.content) {
797
+ text += data.message.content;
798
+ renderLive();
799
+ }
800
+ if (data.done && data.eval_count) {
801
+ state.totalTokens += (data.prompt_eval_count || 0) + (data.eval_count || 0);
802
+ updateTokenCounter();
803
+ }
804
+ } catch(pe) {
805
+ // Silent fail
806
+ }
807
+ }
808
+ }
809
+ }
810
+ } else {
811
+ const data = await response.json();
812
+ if (state.settings.runtime === 'openai' || state.settings.runtime === 'openrouter') {
813
+ text = data.choices?.[0]?.message?.content || '';
814
+ if (data.usage?.total_tokens) {
815
+ state.totalTokens += data.usage.total_tokens;
816
+ updateTokenCounter();
817
+ }
818
+ } else {
819
+ text = data.message?.content || '';
820
+ if (data.eval_count) {
821
+ state.totalTokens += (data.prompt_eval_count || 0) + (data.eval_count || 0);
822
+ updateTokenCounter();
823
+ }
824
+ }
825
+ renderLive();
826
+ }
827
+
828
+ return text;
829
+ }
830
+
831
+ try {
832
+ let callMessages = messages;
833
+ let fullText = '';
834
+ let sources = [];
835
+ const maxSearches = 2; // hard cap so a stubborn model can't loop forever
836
+
837
+ for (let searchCount = 0; ; searchCount++) {
838
+ fullText = await runOneCompletion(callMessages);
839
+
840
+ const query = webSearchEnabled ? extractSearchQuery(fullText) : null;
841
+ if (!query || searchCount >= maxSearches) break;
842
+
843
+ contentEl.innerHTML = `<div class="search-status">🔎 Searching the web for “${escapeHtml(query)}”…</div>`;
844
+ if (state.settings.autoScroll) scrollToBottom();
845
+
846
+ const results = await duckDuckGoSearch(query);
847
+ sources = mergeSources(sources, results.map(r => ({ url: r.url, title: r.title })));
848
+
849
+ callMessages = [
850
+ ...callMessages,
851
+ { role: 'assistant', content: fullText },
852
+ { role: 'user', content: formatSearchResultsForModel(query, results) },
853
+ ];
854
+ }
855
+
856
+ contentEl.innerHTML = renderMarkdown(fullText) + renderSourcesHtml(sources);
857
+ assistantMsg.content = fullText;
858
+ assistantMsg.sources = sources;
859
+ assistantMsg.timestamp = Date.now();
860
+
861
+ // Re-highlight
862
+ contentEl.querySelectorAll('pre code').forEach(el => {
863
+ try { hljs.highlightElement(el); } catch(e) {}
864
+ });
865
+
866
+ playSound(440, 0.12);
867
+ setStatus('online', `Connected to server · ${state.settings.model}`);
868
+
869
+ } catch(err) {
870
+ if (err.name === 'AbortError') {
871
+ contentEl.innerHTML = renderMarkdown(assistantMsg.content || '') + '\n\n<em style="color:var(--text-muted);font-size:12px">⏹ Generation stopped</em>';
872
+ assistantMsg.content = assistantMsg.content + '\n\n[Generation stopped]';
873
+ toast('Generation stopped', 'info');
874
+ } else {
875
+ const errMsg = formatError(err);
876
+ contentEl.innerHTML = `<div style="color:#f87171;font-size:13.5px;line-height:1.6"><strong>⚠️ Error</strong><br>${escapeHtml(errMsg)}</div>`;
877
+ assistantMsg.content = `[Error: ${errMsg}]`;
878
+ setStatus('error', errMsg.slice(0, 80));
879
+ toast(errMsg, 'error', 5000);
880
+ }
881
+ } finally {
882
+ // Remove stop button
883
+ stopWrapper.remove();
884
+ // Update meta
885
+ const metaEl = msgEl.querySelector('.msg-meta');
886
+ metaEl.innerHTML = `
887
+ <span class="msg-time">${fmtTime(assistantMsg.timestamp)}</span>
888
+ <div class="msg-actions">
889
+ <button class="msg-action-btn" onclick="copyMsgContent('${assistantMsg.id}')" title="Copy"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg></button>
890
+ <button class="msg-action-btn" onclick="regenerateFrom('${assistantMsg.id}')" title="Regenerate">${regenSvg()}</button>
891
+ <button class="msg-action-btn" onclick="deleteMessage('${assistantMsg.id}')" title="Delete"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6m3 0V4a1 1 0 011-1h4a1 1 0 011 1v2m4 5v6m-4-6v6"/></svg></button>
892
+ </div>`;
893
+ state.isGenerating = false;
894
+ setSendingState(false);
895
+ chat.updated = Date.now();
896
+ saveChats();
897
+ renderChatList();
898
+ scrollToBottom();
899
+ }
900
+ }
901
+
902
+ function formatError(err) {
903
+ const msg = err.message || String(err);
904
+ if (msg.includes('Failed to fetch') || msg.includes('fetch')) {
905
+ return 'Cannot connect to server. Make sure your AI runtime is running on the correct Server URL.';
906
+ }
907
+ if (msg.includes('model') || msg.includes('404')) {
908
+ return `Model "${state.settings.model}" not found on server. Try refreshing models.`;
909
+ }
910
+ if (msg.includes('401') || msg.includes('Unauthorized')) {
911
+ return 'Authentication failed. Check your API key.';
912
+ }
913
+ return msg;
914
+ }
915
+
916
+ // ── UI STATE ─────────────────────────────────────────────────────
917
+ function setSendingState(loading) {
918
+ els.sendBtn.disabled = loading;
919
+ els.userInput.disabled = loading;
920
+ if (loading) {
921
+ els.sendBtn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>`;
922
+ } else {
923
+ els.sendBtn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2" /></svg>`;
924
+ els.userInput.focus();
925
+ }
926
+ }
927
+
928
+ function setStatus(type, text) {
929
+ els.statusDot.className = `status-dot ${type}`;
930
+ els.statusText.textContent = text;
931
+ }
932
+
933
+ function updateTokenCounter() {
934
+ if (state.totalTokens > 0) {
935
+ els.tokenCounter.textContent = `~${state.totalTokens.toLocaleString()} tokens`;
936
+ }
937
+ }
938
+
939
+ // ── SERVER CONNECTION ─────────────────────────────────────────────
940
+ async function checkServer() {
941
+ setStatus('loading', 'Connecting to server…');
942
+ try {
943
+ let url;
944
+ if (state.settings.runtime === 'openrouter') {
945
+ url = 'https://openrouter.ai/api/v1/models';
946
+ } else if (state.settings.runtime === 'openai') {
947
+ url = `${state.settings.serverUrl.replace(/\/$/, '')}/v1/models`;
948
+ } else {
949
+ url = `${state.settings.serverUrl.replace(/\/$/, '')}/api/tags`;
950
+ }
951
+ const headers = {};
952
+ if (state.settings.apiKey && (state.settings.runtime === 'openai' || state.settings.runtime === 'openrouter')) {
953
+ headers['Authorization'] = `Bearer ${state.settings.apiKey}`;
954
+ }
955
+ const r = await fetch(url, { signal: AbortSignal.timeout(8000), headers });
956
+ if (r.ok) {
957
+ const data = await r.json();
958
+ let models = [];
959
+ if (state.settings.runtime === 'openrouter') {
960
+ models = data.data?.map?.(m => ({ name: m.id })).slice(0, 50) || [];
961
+ } else if (state.settings.runtime === 'openai') {
962
+ models = data.data?.map?.(m => ({ name: m.id || m.name })) || [];
963
+ } else {
964
+ models = data.models || [];
965
+ }
966
+ const modelCount = models.length;
967
+ setStatus('online', `Connected · ${modelCount} model${modelCount !== 1 ? 's' : ''} available`);
968
+ document.querySelector('.model-dot')?.classList.remove('offline');
969
+ return models;
970
+ }
971
+ throw new Error(`HTTP ${r.status}`);
972
+ } catch(e) {
973
+ if (state.settings.runtime === 'openrouter') {
974
+ setStatus('error', 'OpenRouter not accessible — check API key & internet');
975
+ } else if (state.settings.runtime === 'openai') {
976
+ setStatus('error', 'Server not detected — is Llama.cpp running?');
977
+ } else {
978
+ setStatus('error', 'Ollama not detected — open a terminal and run: ollama serve');
979
+ }
980
+ document.querySelector('.model-dot')?.classList.add('offline');
981
+ return [];
982
+ }
983
+ }
984
+
985
+ async function fetchAndShowModels() {
986
+ els.modelList.innerHTML = '<span style="font-size:12px;color:var(--text-muted)">Loading models…</span>';
987
+ const models = await checkServer();
988
+ els.modelList.innerHTML = '';
989
+ if (models.length === 0) {
990
+ els.modelList.innerHTML = `<span style="font-size:12px;color:var(--text-muted)">No models found on server.</span>`;
991
+ return;
992
+ }
993
+ models.forEach(m => {
994
+ const chip = document.createElement('button');
995
+ chip.className = `model-chip ${m.name === state.settings.model ? 'selected' : ''}`;
996
+ chip.textContent = m.name;
997
+ chip.onclick = () => {
998
+ document.querySelectorAll('.model-chip').forEach(c => c.classList.remove('selected'));
999
+ chip.classList.add('selected');
1000
+ els.modelInput.value = m.name;
1001
+ };
1002
+ els.modelList.appendChild(chip);
1003
+ });
1004
+ }
1005
+
1006
+ // ── EXPORT ────────────────────────────────────────────────────────
1007
+ function exportChat(id) {
1008
+ const chat = state.chats[id];
1009
+ if (!chat || chat.messages.length === 0) { toast('No messages to export', 'warn'); return; }
1010
+ const lines = [`# ${chat.title}`, `Exported: ${new Date().toLocaleString()}`, `Model: ${state.settings.model}`, '', '---', ''];
1011
+ chat.messages.forEach(m => {
1012
+ lines.push(`### ${m.role === 'user' ? '👤 You' : '🤖 Assistant'} — ${fmtTime(m.timestamp)}`);
1013
+ lines.push('');
1014
+ lines.push(m.content);
1015
+ lines.push('');
1016
+ lines.push('---');
1017
+ lines.push('');
1018
+ });
1019
+ download(`${chat.title.replace(/[^a-z0-9]/gi,'_')}.md`, lines.join('\n'));
1020
+ toast('Chat exported!', 'success');
1021
+ }
1022
+
1023
+ function exportAllChats() {
1024
+ const ids = Object.keys(state.chats);
1025
+ if (ids.length === 0) { toast('No chats to export', 'warn'); return; }
1026
+ const data = { exported: new Date().toISOString(), model: state.settings.model, chats: Object.values(state.chats) };
1027
+ download('prefrontal_export.json', JSON.stringify(data, null, 2));
1028
+ toast(`Exported ${ids.length} chat${ids.length !== 1 ? 's' : ''}!`, 'success');
1029
+ }
1030
+
1031
+ function download(filename, content) {
1032
+ const a = document.createElement('a');
1033
+ a.href = URL.createObjectURL(new Blob([content], {type:'text/plain'}));
1034
+ a.download = filename; a.click();
1035
+ }
1036
+
1037
+ // ── SETTINGS ─────────────────────────────────────────────────────
1038
+ function applyTheme(theme) {
1039
+ document.documentElement.setAttribute('data-theme', theme);
1040
+ const hlTheme = $('hlTheme');
1041
+ if (hlTheme) {
1042
+ hlTheme.href = theme === 'light'
1043
+ ? 'vendor/highlight-light.min.css'
1044
+ : 'vendor/highlight-dark.min.css';
1045
+ }
1046
+ }
1047
+
1048
+ function openSettings() {
1049
+ // Populate fields
1050
+ els.serverUrl.value = state.settings.serverUrl;
1051
+ els.modelInput.value = state.settings.model;
1052
+ els.systemPrompt.value = state.settings.systemPrompt;
1053
+ els.tempSlider.value = state.settings.temperature;
1054
+ els.tempDisplay.textContent = parseFloat(state.settings.temperature).toFixed(2);
1055
+ if (els.tempBadge) els.tempBadge.textContent = getTempBadgeLabel(state.settings.temperature);
1056
+ els.ctxSlider.value = state.settings.numCtx;
1057
+ els.ctxDisplay.textContent = Number(state.settings.numCtx).toLocaleString();
1058
+ els.streamToggle.checked = state.settings.stream;
1059
+ els.autoScrollToggle.checked = state.settings.autoScroll;
1060
+ els.soundToggle.checked = state.settings.sound;
1061
+ if (els.apiKey) els.apiKey.value = state.settings.apiKey || '';
1062
+ if (els.webSearchToggle) els.webSearchToggle.checked = !!state.settings.webSearch;
1063
+
1064
+ document.querySelectorAll('.theme-btn').forEach(b => {
1065
+ b.classList.toggle('active', b.dataset.theme === state.settings.theme);
1066
+ });
1067
+ document.querySelectorAll('.shortcut-btn').forEach(b => {
1068
+ if (b.dataset.mode) {
1069
+ b.classList.toggle('active', b.dataset.mode === state.settings.sendMode);
1070
+ }
1071
+ });
1072
+ if (els.runtimeOptions) {
1073
+ els.runtimeOptions.querySelectorAll('.shortcut-btn').forEach(b => {
1074
+ if (b.dataset.runtime) {
1075
+ b.classList.toggle('active', b.dataset.runtime === state.settings.runtime);
1076
+ }
1077
+ });
1078
+ updateServerUrlHint(state.settings.runtime);
1079
+ updateWebSearchVisibility(state.settings.runtime);
1080
+ }
1081
+
1082
+ // Sync personality presets
1083
+ syncPersonalityUI(state.settings.personality || 'balanced');
1084
+
1085
+ els.settingsOverlay.classList.add('open');
1086
+ fetchAndShowModels();
1087
+ }
1088
+
1089
+ function getServerType(url) {
1090
+ if (!url) return 'local';
1091
+ const u = url.toLowerCase();
1092
+ if (u.includes('localhost') || u.includes('127.0.0.1') || u.includes('::1')) return 'local';
1093
+ // RFC 1918 private ranges
1094
+ if (/^https?:\/\/(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/.test(u)) return 'lan';
1095
+ return 'external';
1096
+ }
1097
+
1098
+ function updateServerBadge(url) {
1099
+ if (!els.serverTypeBadge) return;
1100
+ const type = getServerType(url);
1101
+ const labels = { local: '🏠 local', lan: '📡 lan', external: '🌐 external' };
1102
+ els.serverTypeBadge.textContent = labels[type] || 'local';
1103
+ }
1104
+
1105
+ function updateServerUrlHint(runtime) {
1106
+ updateServerBadge(els.serverUrl?.value || '');
1107
+ }
1108
+
1109
+ // Web search is an OpenRouter-only plugin — hide the toggle for other runtimes
1110
+ function updateWebSearchVisibility(runtime) {
1111
+ if (!els.webSearchGroup) return;
1112
+ els.webSearchGroup.style.display = runtime === 'openrouter' ? '' : 'none';
1113
+ }
1114
+
1115
+ // Wire up server quick select buttons
1116
+ function bindServerQuickBtns() {
1117
+ if (!els.serverQuickBtns) return;
1118
+ els.serverQuickBtns.addEventListener('click', e => {
1119
+ const btn = e.target.closest('.server-quick-btn');
1120
+ if (!btn) return;
1121
+ const url = btn.dataset.url;
1122
+ const runtime = btn.dataset.runtime;
1123
+
1124
+ if (url) {
1125
+ els.serverUrl.value = url;
1126
+ updateServerBadge(url);
1127
+ els.serverUrl.focus();
1128
+
1129
+ // If it's a template URL, select the placeholder part so they can type over it
1130
+ if (url.includes('192.168.1.X')) els.serverUrl.setSelectionRange(7, 18);
1131
+ if (url.includes('myserver.example.com')) els.serverUrl.setSelectionRange(8, 28);
1132
+ }
1133
+
1134
+ // Auto-switch runtime if specified
1135
+ if (runtime && els.runtimeOptions) {
1136
+ els.runtimeOptions.querySelectorAll('.shortcut-btn').forEach(b => {
1137
+ b.classList.toggle('active', b.dataset.runtime === runtime);
1138
+ });
1139
+ updateServerUrlHint(runtime);
1140
+ } else if (url && url.includes('/v1')) {
1141
+ // Auto-detect openai API schema
1142
+ els.runtimeOptions.querySelectorAll('.shortcut-btn').forEach(b => {
1143
+ b.classList.toggle('active', b.dataset.runtime === 'openai');
1144
+ });
1145
+ updateServerUrlHint('openai');
1146
+ }
1147
+ });
1148
+
1149
+ // Live badge update as user types
1150
+ els.serverUrl?.addEventListener('input', e => updateServerBadge(e.target.value));
1151
+ }
1152
+
1153
+ function saveSettingsFromModal() {
1154
+ state.settings.serverUrl = els.serverUrl.value.trim() || 'http://localhost:11434';
1155
+ state.settings.model = els.modelInput.value.trim() || 'gemma4:e2b';
1156
+ state.settings.systemPrompt = els.systemPrompt.value;
1157
+ // Parse temperature explicitly as a float and clamp to [0,2]
1158
+ const rawTemp = parseFloat(els.tempSlider.value);
1159
+ state.settings.temperature = isNaN(rawTemp) ? 0.7 : Math.min(2, Math.max(0, rawTemp));
1160
+ state.settings.numCtx = parseInt(els.ctxSlider.value);
1161
+ state.settings.stream = els.streamToggle.checked;
1162
+ state.settings.autoScroll = els.autoScrollToggle.checked;
1163
+ state.settings.sound = els.soundToggle.checked;
1164
+ if (els.apiKey) state.settings.apiKey = els.apiKey.value.trim();
1165
+ if (els.webSearchToggle) state.settings.webSearch = els.webSearchToggle.checked;
1166
+
1167
+ const activeTheme = document.querySelector('.theme-btn.active')?.dataset.theme || 'dark';
1168
+ state.settings.theme = activeTheme;
1169
+ applyTheme(activeTheme);
1170
+
1171
+ const activeShortcut = els.shortcutOptions?.querySelector('.shortcut-btn.active')?.dataset.mode || 'enter';
1172
+ state.settings.sendMode = activeShortcut;
1173
+
1174
+ if (els.runtimeOptions) {
1175
+ const activeRuntime = els.runtimeOptions.querySelector('.shortcut-btn.active')?.dataset.runtime || 'ollama';
1176
+ state.settings.runtime = activeRuntime;
1177
+ }
1178
+
1179
+ // Detect if current prompt matches any preset
1180
+ const matchedPreset = Object.entries(PERSONALITY_PRESETS).find(
1181
+ ([key, p]) => key !== 'custom' && p.systemPrompt === state.settings.systemPrompt
1182
+ );
1183
+ state.settings.personality = matchedPreset ? matchedPreset[0] : 'custom';
1184
+
1185
+ els.modelNameDisplay.textContent = state.settings.model;
1186
+ saveSettings();
1187
+ checkServer();
1188
+ toast(`Settings saved ✓ (temp: ${state.settings.temperature.toFixed(2)})`, 'success');
1189
+ els.settingsOverlay.classList.remove('open');
1190
+ }
1191
+
1192
+ function resetSettings() {
1193
+ const defaults = {
1194
+ serverUrl: 'http://localhost:11434', runtime: 'ollama', model: 'gemma4:e2b',
1195
+ systemPrompt: 'You are Prefrontal, a helpful, honest, and harmless AI assistant. You are running entirely locally on the user\'s device with complete privacy. Be concise, clear, and friendly.',
1196
+ temperature: 0.7, numCtx: 8192, stream: true, autoScroll: true, sound: false, sendMode: 'enter', theme: 'dark',
1197
+ webSearch: false,
1198
+ };
1199
+ Object.assign(state.settings, defaults);
1200
+ saveSettings();
1201
+ openSettings();
1202
+ toast('Settings reset to defaults', 'info');
1203
+ }
1204
+
1205
+ // ── INPUT HANDLING ────────────────────────────────────────────────
1206
+ function autoResizeInput() {
1207
+ const ta = els.userInput;
1208
+ ta.style.height = 'auto';
1209
+ ta.style.height = Math.min(ta.scrollHeight, 180) + 'px';
1210
+ const len = ta.value.length;
1211
+ if (len > 20000) {
1212
+ els.charCount.textContent = `${len.toLocaleString()} / 32,000`;
1213
+ els.charCount.style.display = 'block';
1214
+ els.charCount.style.color = len > 30000 ? '#f87171' : 'var(--text-muted)';
1215
+ } else {
1216
+ els.charCount.style.display = 'none';
1217
+ }
1218
+ }
1219
+
1220
+ // ── EVENT BINDINGS ────────────────────────────────────────────────
1221
+ function bindEvents() {
1222
+ bindServerQuickBtns();
1223
+
1224
+ // Sidebar toggle
1225
+ els.sidebarToggle.addEventListener('click', () => {
1226
+ els.sidebar.classList.toggle('collapsed');
1227
+ });
1228
+
1229
+ // New chat
1230
+ els.newChatBtn.addEventListener('click', () => {
1231
+ const id = createChat();
1232
+ state.activeChatId = id;
1233
+ setActiveChat(id);
1234
+ saveChats();
1235
+ renderChatList();
1236
+ els.userInput.focus();
1237
+ });
1238
+
1239
+ // Search chats
1240
+ els.searchChats.addEventListener('input', e => renderChatList(e.target.value));
1241
+
1242
+ // Export / Clear all
1243
+ els.exportAllBtn.addEventListener('click', exportAllChats);
1244
+ els.clearAllBtn.addEventListener('click', async () => {
1245
+ const ok = await confirm('Clear All Conversations', 'This will permanently delete all conversations. This cannot be undone.');
1246
+ if (ok) {
1247
+ state.chats = {};
1248
+ const id = createChat();
1249
+ state.activeChatId = id;
1250
+ saveChats();
1251
+ renderChatList();
1252
+ renderChat();
1253
+ toast('All conversations cleared', 'success');
1254
+ }
1255
+ });
1256
+
1257
+ // Export current chat
1258
+ els.exportChatBtn.addEventListener('click', () => exportChat(state.activeChatId));
1259
+
1260
+ // Settings
1261
+ els.settingsBtn.addEventListener('click', openSettings);
1262
+ els.closeSettings.addEventListener('click', () => els.settingsOverlay.classList.remove('open'));
1263
+ els.settingsOverlay.addEventListener('click', e => { if (e.target === els.settingsOverlay) els.settingsOverlay.classList.remove('open'); });
1264
+ els.saveSettingsBtn.addEventListener('click', saveSettingsFromModal);
1265
+ els.resetSettingsBtn.addEventListener('click', resetSettings);
1266
+ els.fetchModelsBtn.addEventListener('click', fetchAndShowModels);
1267
+
1268
+ // Theme buttons
1269
+ els.themeOptions.addEventListener('click', e => {
1270
+ const btn = e.target.closest('.theme-btn');
1271
+ if (!btn) return;
1272
+ document.querySelectorAll('.theme-btn').forEach(b => b.classList.remove('active'));
1273
+ btn.classList.add('active');
1274
+ applyTheme(btn.dataset.theme);
1275
+ });
1276
+
1277
+ // Shortcut buttons / Runtime options
1278
+ document.addEventListener('click', e => {
1279
+ // Handling shortcut-btn inside shortcutOptions (Enter to Send)
1280
+ if (e.target.closest('#shortcutOptions .shortcut-btn')) {
1281
+ const btn = e.target.closest('.shortcut-btn');
1282
+ document.querySelectorAll('#shortcutOptions .shortcut-btn').forEach(b => b.classList.remove('active'));
1283
+ btn.classList.add('active');
1284
+ }
1285
+ // Handling shortcut-btn inside runtimeOptions
1286
+ if (e.target.closest('#runtimeOptions .shortcut-btn')) {
1287
+ const btn = e.target.closest('.shortcut-btn');
1288
+ document.querySelectorAll('#runtimeOptions .shortcut-btn').forEach(b => b.classList.remove('active'));
1289
+ btn.classList.add('active');
1290
+ const rt = btn.dataset.runtime;
1291
+ if (rt) {
1292
+ updateServerUrlHint(rt);
1293
+ updateWebSearchVisibility(rt);
1294
+ if (rt === 'openrouter') {
1295
+ els.serverUrl.value = 'https://openrouter.ai/api/v1';
1296
+ } else if (rt === 'openai' && els.serverUrl.value === 'http://localhost:11434') {
1297
+ els.serverUrl.value = 'http://localhost:8080/v1';
1298
+ } else if (rt === 'ollama' && (els.serverUrl.value === 'http://localhost:8080/v1' || els.serverUrl.value === 'https://openrouter.ai/api/v1')) {
1299
+ els.serverUrl.value = 'http://localhost:11434';
1300
+ }
1301
+ }
1302
+ }
1303
+ });
1304
+
1305
+ // Temp slider — live label update
1306
+ els.tempSlider.addEventListener('input', e => {
1307
+ const val = parseFloat(e.target.value);
1308
+ els.tempDisplay.textContent = val.toFixed(2);
1309
+ if (els.tempBadge) els.tempBadge.textContent = getTempBadgeLabel(val);
1310
+ });
1311
+ // Context slider
1312
+ els.ctxSlider.addEventListener('input', e => {
1313
+ els.ctxDisplay.textContent = Number(e.target.value).toLocaleString();
1314
+ });
1315
+
1316
+ // Personality presets in settings modal
1317
+ document.addEventListener('click', e => {
1318
+ const btn = e.target.closest('#personalityPresets .personality-preset-btn');
1319
+ if (!btn) return;
1320
+ applyPersonalityPreset(btn.dataset.preset, { updateUI: true });
1321
+ });
1322
+
1323
+ // System prompt manual edit → mark as custom
1324
+ els.systemPrompt.addEventListener('input', () => {
1325
+ const matches = Object.entries(PERSONALITY_PRESETS).some(
1326
+ ([key, p]) => key !== 'custom' && p.systemPrompt === els.systemPrompt.value
1327
+ );
1328
+ if (!matches) {
1329
+ state.settings.personality = 'custom';
1330
+ syncPersonalityUI('custom');
1331
+ }
1332
+ });
1333
+
1334
+ // Welcome screen personality pills
1335
+ const welcomeBar = $('welcomePersonalityBar');
1336
+ if (welcomeBar) {
1337
+ welcomeBar.addEventListener('click', e => {
1338
+ const pill = e.target.closest('.personality-pill');
1339
+ if (!pill) return;
1340
+ applyPersonalityPreset(pill.dataset.preset, { updateUI: false });
1341
+ syncPersonalityUI(pill.dataset.preset);
1342
+ toast(`${PERSONALITY_PRESETS[pill.dataset.preset].name} mode activated`, 'success', 2000);
1343
+ });
1344
+ }
1345
+
1346
+ // Input events
1347
+ els.userInput.addEventListener('input', autoResizeInput);
1348
+ els.userInput.addEventListener('keydown', e => {
1349
+ const sendOnEnter = state.settings.sendMode === 'enter';
1350
+ const shouldSend = sendOnEnter ? (e.key === 'Enter' && !e.shiftKey) : (e.key === 'Enter' && e.shiftKey);
1351
+ if (shouldSend) {
1352
+ e.preventDefault();
1353
+ handleSend();
1354
+ }
1355
+ });
1356
+
1357
+ // Send button
1358
+ els.sendBtn.addEventListener('click', handleSend);
1359
+
1360
+ // Welcome chips
1361
+ document.querySelectorAll('.chip').forEach(chip => {
1362
+ chip.addEventListener('click', () => {
1363
+ els.userInput.value = chip.dataset.prompt;
1364
+ autoResizeInput();
1365
+ handleSend();
1366
+ });
1367
+ });
1368
+
1369
+ // Confirm dialog overlay
1370
+ els.confirmOverlay.addEventListener('click', e => {
1371
+ if (e.target === els.confirmOverlay) els.confirmOverlay.classList.remove('open');
1372
+ });
1373
+
1374
+ // Keyboard shortcut: Ctrl+N = new chat
1375
+ document.addEventListener('keydown', e => {
1376
+ if ((e.ctrlKey || e.metaKey) && e.key === 'n' && !e.shiftKey) {
1377
+ e.preventDefault();
1378
+ els.newChatBtn.click();
1379
+ }
1380
+ if ((e.ctrlKey || e.metaKey) && e.key === ',') {
1381
+ e.preventDefault();
1382
+ openSettings();
1383
+ }
1384
+ if (e.key === 'Escape') {
1385
+ els.settingsOverlay.classList.remove('open');
1386
+ els.confirmOverlay.classList.remove('open');
1387
+ }
1388
+ });
1389
+ }
1390
+
1391
+ function handleSend() {
1392
+ const content = els.userInput.value.trim();
1393
+ if (!content || state.isGenerating) return;
1394
+ els.userInput.value = '';
1395
+ autoResizeInput();
1396
+ sendMessage(content);
1397
+ }
1398
+
1399
+ // ── INIT ──────────────────────────────────────────────────────────
1400
+ function init() {
1401
+ loadSettings();
1402
+ loadChats();
1403
+ applyTheme(state.settings.theme);
1404
+
1405
+ // Restore model name in badge
1406
+ els.modelNameDisplay.textContent = state.settings.model;
1407
+
1408
+ // Ensure at least one chat exists
1409
+ if (Object.keys(state.chats).length === 0) {
1410
+ const id = createChat();
1411
+ state.activeChatId = id;
1412
+ } else {
1413
+ // Pick most recent
1414
+ const ids = Object.keys(state.chats).sort((a,b) => (state.chats[b].updated||0) - (state.chats[a].updated||0));
1415
+ state.activeChatId = ids[0];
1416
+ }
1417
+
1418
+ renderChatList();
1419
+ renderChat();
1420
+ bindEvents();
1421
+ checkServer();
1422
+
1423
+ // Sync personality UI to saved preference
1424
+ syncPersonalityUI(state.settings.personality || 'balanced');
1425
+
1426
+ // Focus input
1427
+ setTimeout(() => els.userInput.focus(), 100);
1428
+ }
1429
+
1430
+ // ── PROFILE SYSTEM ────────────────────────────────────────────────
1431
+
1432
+ function initProfile() {
1433
+ const exists = loadProfile();
1434
+ if (!exists || !state.profile?.deviceId) {
1435
+ // Generate device ID and show setup
1436
+ const newId = generateUUID();
1437
+ state.profile = { deviceId: newId, displayName: '', avatar: '🧠', createdAt: new Date().toISOString() };
1438
+ showSetupModal();
1439
+ } else {
1440
+ renderProfileCard();
1441
+ }
1442
+ }
1443
+
1444
+ function showSetupModal() {
1445
+ // Show the generated ID
1446
+ els.deviceIdPreview.textContent = state.profile.deviceId;
1447
+ // Setup avatar picker
1448
+ bindAvatarGrid(els.avatarGrid, 'setup');
1449
+ els.setupOverlay.classList.add('open');
1450
+ setTimeout(() => els.setupName.focus(), 200);
1451
+
1452
+ els.completeSetupBtn.onclick = () => {
1453
+ const name = els.setupName.value.trim();
1454
+ if (!name) { els.setupName.style.borderColor = 'var(--danger)'; els.setupName.focus(); return; }
1455
+ els.setupName.style.borderColor = '';
1456
+ const selectedAvatar = els.avatarGrid.querySelector('.avatar-btn.selected')?.dataset.emoji || '🧠';
1457
+ state.profile.displayName = name;
1458
+ state.profile.avatar = selectedAvatar;
1459
+ saveProfile();
1460
+ els.setupOverlay.classList.remove('open');
1461
+ renderProfileCard();
1462
+ toast(`Welcome, ${name}! Your profile is saved locally. 🎉`, 'success', 4000);
1463
+ };
1464
+
1465
+ // Enter submits
1466
+ els.setupName.addEventListener('keydown', e => { if (e.key === 'Enter') els.completeSetupBtn.click(); });
1467
+ }
1468
+
1469
+ function renderProfileCard() {
1470
+ if (!state.profile) return;
1471
+ els.sidebarAvatar.textContent = state.profile.avatar || '🧠';
1472
+ els.sidebarName.textContent = state.profile.displayName || 'Anonymous';
1473
+ els.sidebarId.textContent = shortId(state.profile.deviceId);
1474
+ }
1475
+
1476
+ function openProfileModal() {
1477
+ if (!state.profile) return;
1478
+ // Populate preview
1479
+ updateProfilePreview();
1480
+ // Populate fields
1481
+ els.profileNameInput.value = state.profile.displayName;
1482
+ els.profileDeviceId.textContent = state.profile.deviceId;
1483
+ els.profileCreatedAt.textContent = new Date(state.profile.createdAt).toLocaleString([], { dateStyle: 'long', timeStyle: 'short' });
1484
+ // Sync avatar grid selection
1485
+ bindAvatarGrid(els.profileAvatarGrid, 'profile');
1486
+ syncAvatarSelection(els.profileAvatarGrid, state.profile.avatar);
1487
+ // Live preview on name change
1488
+ els.profileNameInput.oninput = () => updateProfilePreview();
1489
+ els.profileOverlay.classList.add('open');
1490
+ }
1491
+
1492
+ function updateProfilePreview() {
1493
+ const avatar = els.profileAvatarGrid?.querySelector('.avatar-btn.selected')?.data-emoji || state.profile?.avatar || '🧠';
1494
+ const name = els.profileNameInput?.value.trim() || state.profile?.displayName || 'Anonymous';
1495
+ if (els.profilePreviewAvatar) els.profilePreviewAvatar.textContent = avatar;
1496
+ if (els.profilePreviewName) els.profilePreviewName.textContent = name;
1497
+ if (els.profilePreviewMeta) els.profilePreviewMeta.textContent = shortId(state.profile?.deviceId);
1498
+ }
1499
+
1500
+ function bindAvatarGrid(grid, context) {
1501
+ if (!grid) return;
1502
+ grid.querySelectorAll('.avatar-btn').forEach(btn => {
1503
+ btn.onclick = () => {
1504
+ grid.querySelectorAll('.avatar-btn').forEach(b => b.classList.remove('selected'));
1505
+ btn.classList.add('selected');
1506
+ if (context === 'profile') updateProfilePreview();
1507
+ };
1508
+ });
1509
+ }
1510
+
1511
+ function syncAvatarSelection(grid, emoji) {
1512
+ if (!grid) return;
1513
+ grid.querySelectorAll('.avatar-btn').forEach(b => {
1514
+ b.classList.toggle('selected', b.dataset.emoji === emoji);
1515
+ });
1516
+ }
1517
+
1518
+ function saveProfileChanges() {
1519
+ const name = els.profileNameInput.value.trim();
1520
+ if (!name) { toast('Display name cannot be empty', 'error'); return; }
1521
+ const avatar = els.profileAvatarGrid.querySelector('.avatar-btn.selected')?.dataset.emoji || state.profile.avatar;
1522
+ state.profile.displayName = name;
1523
+ state.profile.avatar = avatar;
1524
+ saveProfile();
1525
+ renderProfileCard();
1526
+ els.profileOverlay.classList.remove('open');
1527
+ toast('Profile saved ✓', 'success');
1528
+ }
1529
+
1530
+ function exportProfile() {
1531
+ const data = { ...state.profile, appVersion: '1.0', exportedAt: new Date().toISOString() };
1532
+ download('prefrontal_profile.json', JSON.stringify(data, null, 2));
1533
+ toast('Profile exported as prefrontal_profile.json', 'success');
1534
+ }
1535
+
1536
+ function importProfile(file) {
1537
+ const reader = new FileReader();
1538
+ reader.onload = e => {
1539
+ try {
1540
+ const data = JSON.parse(e.target.result);
1541
+ if (!data.deviceId) throw new Error('Invalid profile file');
1542
+ state.profile = {
1543
+ deviceId: data.deviceId,
1544
+ displayName: data.displayName || 'Imported User',
1545
+ avatar: data.avatar || '🧠',
1546
+ createdAt: data.createdAt || new Date().toISOString(),
1547
+ };
1548
+ saveProfile();
1549
+ renderProfileCard();
1550
+ els.profileOverlay.classList.remove('open');
1551
+ toast(`Profile imported: ${state.profile.displayName} ✓`, 'success', 4000);
1552
+ } catch(err) {
1553
+ toast('Invalid profile file. Make sure it is a prefrontal_profile.json', 'error', 5000);
1554
+ }
1555
+ };
1556
+ reader.readAsText(file);
1557
+ }
1558
+
1559
+ window.copyDeviceId = function() {
1560
+ if (!state.profile?.deviceId) return;
1561
+ navigator.clipboard.writeText(state.profile.deviceId).then(() => {
1562
+ toast('Device ID copied!', 'success');
1563
+ });
1564
+ };
1565
+
1566
+ function bindProfileEvents() {
1567
+ // Open profile modal from sidebar card
1568
+ [els.profileCard, els.openProfileBtn].forEach(el => {
1569
+ el?.addEventListener('click', e => { e.stopPropagation(); openProfileModal(); });
1570
+ });
1571
+ // Close
1572
+ els.closeProfileBtn?.addEventListener('click', () => els.profileOverlay.classList.remove('open'));
1573
+ els.cancelProfileBtn?.addEventListener('click', () => els.profileOverlay.classList.remove('open'));
1574
+ els.profileOverlay?.addEventListener('click', e => { if (e.target === els.profileOverlay) els.profileOverlay.classList.remove('open'); });
1575
+ // Save
1576
+ els.saveProfileBtn?.addEventListener('click', saveProfileChanges);
1577
+ // Export
1578
+ els.exportProfileBtn?.addEventListener('click', exportProfile);
1579
+ // Import
1580
+ els.importProfileInput?.addEventListener('change', e => {
1581
+ const file = e.target.files[0];
1582
+ if (file) importProfile(file);
1583
+ e.target.value = '';
1584
+ });
1585
+ // Setup overlay — don't close on bg click (force completion)
1586
+ els.setupOverlay?.addEventListener('click', e => e.stopPropagation());
1587
+ }
1588
+
1589
+ document.addEventListener('DOMContentLoaded', () => {
1590
+ init();
1591
+ bindProfileEvents();
1592
+ initProfile();
1593
+ });