@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/LICENSE +674 -0
- package/README.md +512 -0
- package/app.js +1593 -0
- package/index.html +576 -0
- package/package.json +31 -0
- package/vendor/highlight-dark.min.css +10 -0
- package/vendor/highlight-light.min.css +10 -0
- package/vendor/highlight.min.js +1213 -0
- package/vendor/marked.min.js +6 -0
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,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
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
|
+
});
|