1536 lines
55 KiB
JavaScript
1536 lines
55 KiB
JavaScript
(function () {
|
|
'use strict';
|
|
|
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
|
|
var DEF_PARAMS = {
|
|
temperature: 0.7,
|
|
top_p: 0.9,
|
|
top_k: 40,
|
|
repeat_penalty: 1.1,
|
|
seed: -1,
|
|
num_ctx: 4096,
|
|
num_predict: -1
|
|
};
|
|
|
|
var DEFAULT_SYSTEM_PROMPT = 'You are a helpful AI assistant.';
|
|
|
|
var PARAM_META = [
|
|
{ key: 'temperature', label: 'Temperature', min: 0, max: 2, step: 0.05 },
|
|
{ key: 'top_p', label: 'Top-P', min: 0, max: 1, step: 0.05 },
|
|
{ key: 'top_k', label: 'Top-K', min: 1, max: 200, step: 1 },
|
|
{ key: 'repeat_penalty', label: 'Repeat penalty',min: 0.5, max: 2, step: 0.05 },
|
|
{ key: 'seed', label: 'Seed (-1=rand)',min: -1, max: 999999,step: 1 },
|
|
{ key: 'num_ctx', label: 'Context (tokens)',min:512,max:131072, step: 512 },
|
|
{ key: 'num_predict', label: 'Max tokens (-1=∞)',min:-1,max:8192, step: 1 }
|
|
];
|
|
|
|
// ── State ─────────────────────────────────────────────────────────────────────
|
|
|
|
var S = {
|
|
settings: { url: 'http://localhost:11434', token: '' },
|
|
theme: 'dark',
|
|
models: [], // [{ name, paramSize, sizeLabel }]
|
|
modelParams: {}, // { [modelName]: { ...params } }
|
|
templates: [], // [{ id, name, content }]
|
|
sessions: [], // [{ id, name, model, updatedAt }] index only
|
|
activeSession: null, // full session object
|
|
pageContext: null,
|
|
usePageContext: false,
|
|
attachments: [], // [{ name, content }]
|
|
isStreaming: false,
|
|
currentStream: null // { requestId, bubble, thinkBlock, thinkContent,
|
|
// mainContent, metrics, sources, msgIndex }
|
|
};
|
|
|
|
// ── DOM refs ──────────────────────────────────────────────────────────────────
|
|
|
|
var app = document.getElementById('app');
|
|
var statusDot = document.getElementById('status-dot');
|
|
var btnSessions = document.getElementById('btn-sessions');
|
|
var sessionsPanel = document.getElementById('sessions-panel');
|
|
var sessionsList = document.getElementById('sessions-list');
|
|
var btnNewSession = document.getElementById('btn-new-session');
|
|
var btnSettings = document.getElementById('btn-settings');
|
|
var settingsPanel = document.getElementById('settings-panel');
|
|
var inputUrl = document.getElementById('input-url');
|
|
var inputToken = document.getElementById('input-token');
|
|
var btnShowToken = document.getElementById('btn-show-token');
|
|
var btnSave = document.getElementById('btn-save');
|
|
var settingsStatus = document.getElementById('settings-status');
|
|
var btnTheme = document.getElementById('btn-theme');
|
|
var sessionNameEl = document.getElementById('session-name-display');
|
|
var btnRenameSession = document.getElementById('btn-rename-session');
|
|
var btnExportSession = document.getElementById('btn-export-session');
|
|
var btnDeleteSession = document.getElementById('btn-delete-session');
|
|
var chatArea = document.getElementById('chat-area');
|
|
var messagesEl = document.getElementById('messages');
|
|
var systemPanel = document.getElementById('system-prompt-panel');
|
|
var inputSysPrompt = document.getElementById('input-system-prompt');
|
|
var btnToggleSystem = document.getElementById('btn-toggle-system');
|
|
var btnCloseSystem = document.getElementById('btn-close-system');
|
|
var paramsPanel = document.getElementById('params-panel');
|
|
var paramsGrid = document.getElementById('params-grid');
|
|
var btnToggleParams = document.getElementById('btn-toggle-params');
|
|
var btnCloseParams = document.getElementById('btn-close-params');
|
|
var fileChipsArea = document.getElementById('file-chips-area');
|
|
var fileChipsEl = document.getElementById('file-chips');
|
|
var btnPageContext = document.getElementById('btn-page-context');
|
|
var pageCtxLbl = document.getElementById('page-ctx-lbl');
|
|
var btnAttachFile = document.getElementById('btn-attach-file');
|
|
var btnTemplatesOpen = document.getElementById('btn-templates-open');
|
|
var btnClear = document.getElementById('btn-clear');
|
|
var inputMessage = document.getElementById('input-message');
|
|
var templateDropdown = document.getElementById('template-dropdown');
|
|
var tmplDdList = document.getElementById('tmpl-dd-list');
|
|
var btnTmplManageInline = document.getElementById('btn-tmpl-manage-inline');
|
|
var selectModel = document.getElementById('select-model');
|
|
var btnStop = document.getElementById('btn-stop');
|
|
var btnSend = document.getElementById('btn-send');
|
|
var tmplOverlay = document.getElementById('tmpl-overlay');
|
|
var tmplList = document.getElementById('tmpl-list');
|
|
var tmplNewName = document.getElementById('tmpl-new-name');
|
|
var tmplNewContent = document.getElementById('tmpl-new-content');
|
|
var btnTmplAdd = document.getElementById('btn-tmpl-add');
|
|
var btnTmplOverlayClose = document.getElementById('btn-tmpl-overlay-close');
|
|
var fileInput = document.getElementById('file-input');
|
|
|
|
// ── Utilities ─────────────────────────────────────────────────────────────────
|
|
|
|
function genId() {
|
|
return Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
return String(s)
|
|
.replace(/&/g,'&').replace(/</g,'<')
|
|
.replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
|
|
function formatBytes(b) {
|
|
if (b >= 1e9) return (b / 1e9).toFixed(1) + ' GB';
|
|
if (b >= 1e6) return (b / 1e6).toFixed(0) + ' MB';
|
|
return b + ' B';
|
|
}
|
|
|
|
function formatMetrics(m) {
|
|
if (!m || !m.eval_count) return '';
|
|
var tps = m.eval_duration ? (m.eval_count / (m.eval_duration / 1e9)).toFixed(1) : '?';
|
|
var secs = m.total_duration ? (m.total_duration / 1e9).toFixed(2) : '?';
|
|
return m.eval_count + ' tokens · ' + tps + ' tok/s · ' + secs + 's';
|
|
}
|
|
|
|
function humanizeError(raw) {
|
|
if (typeof raw !== 'string') return 'Unknown error';
|
|
if (raw.indexOf('HTTP 401') !== -1) return 'Unauthorized — bearer token required or incorrect';
|
|
if (raw.indexOf('HTTP 403') !== -1) return 'Access denied — check your bearer token';
|
|
if (raw.indexOf('HTTP 405') !== -1) return 'Method not allowed — check server CORS configuration';
|
|
if (raw.indexOf('HTTP 5') !== -1) return 'Server error — ' + raw;
|
|
if (/Failed to fetch|NetworkError|Load failed|ECONNREFUSED|network/i.test(raw))
|
|
return 'Cannot reach Ollama — check the URL and that Ollama is running';
|
|
return raw;
|
|
}
|
|
|
|
function getHeaders() {
|
|
var h = { 'Content-Type': 'application/json' };
|
|
var tok = S.settings.token && S.settings.token.trim();
|
|
if (tok) h['Authorization'] = 'Bearer ' + tok;
|
|
return h;
|
|
}
|
|
|
|
function setStatus(state, title) {
|
|
statusDot.className = 'status-dot' + (state ? ' ' + state : '');
|
|
statusDot.title = title || state || 'Unknown';
|
|
}
|
|
|
|
function setSettingsStatus(msg, type) {
|
|
settingsStatus.textContent = msg;
|
|
settingsStatus.className = 'status-line' + (type ? ' ' + type : '');
|
|
}
|
|
|
|
function setSendEnabled(on) {
|
|
btnSend.disabled = !on;
|
|
inputMessage.disabled = !on;
|
|
if (on) {
|
|
btnStop.classList.add('hidden');
|
|
btnSend.classList.remove('hidden');
|
|
} else {
|
|
btnStop.classList.remove('hidden');
|
|
btnSend.classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
// ── Markdown renderer ─────────────────────────────────────────────────────────
|
|
|
|
// Import marked.js for proper markdown rendering
|
|
var marked;
|
|
function loadMarked() {
|
|
if (!marked) {
|
|
marked = new (window.marked || window.require('marked'))();
|
|
// Use GitHub-style markdown preset
|
|
marked.setOptions({
|
|
gfm: true,
|
|
breaks: true,
|
|
sanitize: false
|
|
});
|
|
}
|
|
}
|
|
|
|
function renderInline(text) {
|
|
var h = escapeHtml(text);
|
|
h = h.replace(/`([^`\n]+)`/g, '<code>$1</code>');
|
|
h = h.replace(/\*\*([^*\n]+?)\*\*/g, '<strong>$1</strong>');
|
|
h = h.replace(/\*([^*\n]+?)\*/g, '<em>$1</em>');
|
|
h = h.replace(/\n/g, '<br>');
|
|
return h;
|
|
}
|
|
|
|
function renderMarkdown(raw) {
|
|
if (!raw) return '';
|
|
loadMarked();
|
|
try {
|
|
var html = marked.parse(raw, { breaks: true });
|
|
// Add syntax highlighting
|
|
html = html.replace(/<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/g, function(match, attr, code) {
|
|
var lang = attr.match(/[^\s]*=/)?.replace('=', '').trim() || '';
|
|
var hl = '';
|
|
if (lang && hl) hl = ' data-lang="' + escapeHtml(lang) + '"';
|
|
return '<pre' + hl + '><code' + (hl ? hl : '') + '>' + escapeHtml(code) + '</code></pre>';
|
|
});
|
|
return html;
|
|
} catch (e) {
|
|
// Fallback to simple rendering if marked fails
|
|
return renderInline(raw);
|
|
}
|
|
}
|
|
|
|
function renderMarkdown(raw) {
|
|
if (!raw) return '';
|
|
var parts = raw.split(/(```[^\n]*\n[\s\S]*?```)/g);
|
|
return parts.map(function (part, i) {
|
|
if (i % 2 === 1) {
|
|
var m = part.match(/```([^\n]*)\n([\s\S]*?)```/);
|
|
if (m) {
|
|
var la = m[1].trim() ? ' data-lang="' + escapeHtml(m[1].trim()) + '"' : '';
|
|
return '<pre' + la + '><code>' + escapeHtml(m[2]) + '</code></pre>';
|
|
}
|
|
return escapeHtml(part);
|
|
}
|
|
return renderInline(part);
|
|
}).join('');
|
|
}
|
|
|
|
// ── RAG helpers ───────────────────────────────────────────────────────────────
|
|
|
|
var STOP_WORDS = new Set(
|
|
'the a an is are was were be been have has had do does did will would could should may might can this that these those i you he she it we they and or but in on at to for of with by from as'.split(' ')
|
|
);
|
|
|
|
function chunkText(text, size) {
|
|
size = size || 250;
|
|
var words = text.split(/\s+/);
|
|
var chunks = [];
|
|
for (var i = 0; i < words.length; i += size) {
|
|
chunks.push(words.slice(i, i + size).join(' '));
|
|
}
|
|
return chunks;
|
|
}
|
|
|
|
function scoreChunk(chunk, queryTerms) {
|
|
if (!queryTerms.length) return 1; // no terms = include everything (short text)
|
|
var lower = chunk.toLowerCase();
|
|
var score = 0;
|
|
queryTerms.forEach(function (t) {
|
|
var re = new RegExp('\\b' + t.replace(/[.*+?^${}()|[\]\\]/g,'\\$&') + '\\b', 'gi');
|
|
var m = lower.match(re);
|
|
if (m) score += m.length;
|
|
});
|
|
return score;
|
|
}
|
|
|
|
function getTopChunks(text, query, maxChunks, maxChars) {
|
|
maxChunks = maxChunks || 4;
|
|
maxChars = maxChars || 3000;
|
|
if (!text || !text.trim()) return [];
|
|
var terms = (query || '').toLowerCase().split(/\W+/)
|
|
.filter(function (w) { return w.length > 2 && !STOP_WORDS.has(w); });
|
|
var chunks = chunkText(text);
|
|
var scored = chunks.map(function (c, idx) {
|
|
return { c: c, s: scoreChunk(c, terms), idx: idx };
|
|
});
|
|
// If no query terms, include first N chunks
|
|
if (!terms.length) {
|
|
scored.sort(function (a, b) { return a.idx - b.idx; });
|
|
} else {
|
|
scored = scored.filter(function (x) { return x.s > 0; });
|
|
scored.sort(function (a, b) { return b.s - a.s; });
|
|
}
|
|
var out = []; var chars = 0;
|
|
for (var i = 0; i < scored.length && out.length < maxChunks; i++) {
|
|
if (chars + scored[i].c.length > maxChars) break;
|
|
out.push(scored[i].c);
|
|
chars += scored[i].c.length;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Build context block from active page context + attachments, using RAG scoring
|
|
function buildContextBlock(userQuery) {
|
|
var sources = []; var names = [];
|
|
|
|
if (S.usePageContext && S.pageContext && S.pageContext.content) {
|
|
var chunks = getTopChunks(S.pageContext.content, userQuery);
|
|
if (chunks.length) {
|
|
sources.push('=== ' + (S.pageContext.title || S.pageContext.url || 'Page') + ' ===\n' + chunks.join('\n\n'));
|
|
names.push(S.pageContext.title || 'Page');
|
|
}
|
|
}
|
|
|
|
S.attachments.forEach(function (att) {
|
|
var chunks = getTopChunks(att.content, userQuery);
|
|
if (chunks.length) {
|
|
sources.push('=== ' + att.name + ' ===\n' + chunks.join('\n\n'));
|
|
names.push(att.name);
|
|
}
|
|
});
|
|
|
|
if (!sources.length) return null;
|
|
return { text: '[Context]\n' + sources.join('\n\n'), names: names };
|
|
}
|
|
|
|
// ── Storage ───────────────────────────────────────────────────────────────────
|
|
|
|
function loadAllData(cb) {
|
|
chrome.storage.local.get(
|
|
['ollama_s','ollama_t','ollama_si','ollama_sa','ollama_mp','ollama_tp'],
|
|
function (r) {
|
|
// settings
|
|
var saved = r.ollama_s || {};
|
|
S.settings.url = saved.url || 'http://localhost:11434';
|
|
S.settings.token = saved.token || '';
|
|
inputUrl.value = S.settings.url;
|
|
inputToken.value = S.settings.token;
|
|
|
|
// theme
|
|
S.theme = r.ollama_t || 'dark';
|
|
applyTheme(S.theme);
|
|
|
|
// default system prompt
|
|
loadDefaultSystemPrompt();
|
|
|
|
// sessions index
|
|
S.sessions = r.ollama_si || [];
|
|
|
|
// model params
|
|
S.modelParams = r.ollama_mp || {};
|
|
|
|
// templates
|
|
S.templates = r.ollama_tp || [];
|
|
|
|
// load active session
|
|
var activeId = r.ollama_sa;
|
|
if (!activeId && S.sessions.length) activeId = S.sessions[0].id;
|
|
|
|
if (activeId) {
|
|
chrome.storage.local.get('ollama_ss_' + activeId, function (r2) {
|
|
var sess = r2['ollama_ss_' + activeId];
|
|
if (sess) {
|
|
S.activeSession = sess;
|
|
} else {
|
|
S.activeSession = createSessionObj('New Chat');
|
|
saveSession(S.activeSession);
|
|
}
|
|
if (cb) cb();
|
|
});
|
|
} else {
|
|
S.activeSession = createSessionObj('New Chat');
|
|
saveSession(S.activeSession);
|
|
if (cb) cb();
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
function saveSettings() {
|
|
S.settings.url = inputUrl.value.trim().replace(/\/+$/, '') || 'http://localhost:11434';
|
|
S.settings.token = inputToken.value;
|
|
inputUrl.value = S.settings.url;
|
|
chrome.storage.local.set({ ollama_s: { url: S.settings.url, token: S.settings.token } });
|
|
// Save default system prompt if changed
|
|
if (inputSysPrompt.value !== DEFAULT_SYSTEM_PROMPT) {
|
|
chrome.storage.local.set({ ollama_s_default_sys: inputSysPrompt.value });
|
|
}
|
|
}
|
|
|
|
function loadDefaultSystemPrompt() {
|
|
chrome.storage.local.get('ollama_s_default_sys', function(r) {
|
|
var saved = r['ollama_s_default_sys'];
|
|
if (saved && typeof saved === 'string') {
|
|
inputSysPrompt.value = saved;
|
|
if (S.activeSession) S.activeSession.systemPrompt = saved;
|
|
}
|
|
});
|
|
}
|
|
|
|
function saveSession(sess) {
|
|
sess.updatedAt = Date.now();
|
|
// Upsert in index
|
|
var idx = -1;
|
|
for (var i = 0; i < S.sessions.length; i++) {
|
|
if (S.sessions[i].id === sess.id) { idx = i; break; }
|
|
}
|
|
var meta = { id: sess.id, name: sess.name, model: sess.model, updatedAt: sess.updatedAt };
|
|
if (idx === -1) S.sessions.unshift(meta);
|
|
else S.sessions[idx] = meta;
|
|
var store = {};
|
|
store['ollama_ss_' + sess.id] = sess;
|
|
store['ollama_si'] = S.sessions;
|
|
store['ollama_sa'] = sess.id;
|
|
chrome.storage.local.set(store);
|
|
}
|
|
|
|
var _saveTimer = null;
|
|
function debouncedSaveSession() {
|
|
clearTimeout(_saveTimer);
|
|
_saveTimer = setTimeout(function () {
|
|
if (S.activeSession) saveSession(S.activeSession);
|
|
}, 600);
|
|
}
|
|
|
|
function deleteSessionFromStorage(id) {
|
|
S.sessions = S.sessions.filter(function (s) { return s.id !== id; });
|
|
chrome.storage.local.remove('ollama_ss_' + id);
|
|
chrome.storage.local.set({ ollama_si: S.sessions });
|
|
}
|
|
|
|
function saveModelParams() {
|
|
chrome.storage.local.set({ ollama_mp: S.modelParams });
|
|
}
|
|
|
|
function saveTemplates() {
|
|
chrome.storage.local.set({ ollama_tp: S.templates });
|
|
}
|
|
|
|
// ── Session management ────────────────────────────────────────────────────────
|
|
|
|
function createSessionObj(name) {
|
|
var model = (selectModel && selectModel.value) || '';
|
|
// Auto-generate title from first message if no name provided
|
|
var generatedName = '';
|
|
if (!name && S.attachments.length === 0 && S.pageContext) {
|
|
var ctx = S.pageContext.title || S.pageContext.url || 'page';
|
|
generatedName = '[From ' + ctx + ']';
|
|
}
|
|
return {
|
|
id: genId(),
|
|
name: name || generatedName || 'New Chat',
|
|
model: model,
|
|
systemPrompt: '',
|
|
messages: [],
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now()
|
|
};
|
|
}
|
|
|
|
function newSession() {
|
|
if (S.activeSession) saveSession(S.activeSession);
|
|
var sess = createSessionObj('New Chat');
|
|
S.activeSession = sess;
|
|
saveSession(sess);
|
|
renderSession();
|
|
updateSessionsPanel();
|
|
closePanels();
|
|
}
|
|
|
|
function switchSession(id) {
|
|
if (S.activeSession && S.activeSession.id === id) { closePanels(); return; }
|
|
if (S.activeSession) saveSession(S.activeSession);
|
|
chrome.storage.local.get('ollama_ss_' + id, function (r) {
|
|
var sess = r['ollama_ss_' + id];
|
|
if (!sess) return;
|
|
S.activeSession = sess;
|
|
chrome.storage.local.set({ ollama_sa: id });
|
|
renderSession();
|
|
updateSessionsPanel();
|
|
closePanels();
|
|
});
|
|
}
|
|
|
|
function deleteSession(id) {
|
|
if (!confirm('Delete this conversation?')) return;
|
|
deleteSessionFromStorage(id);
|
|
if (S.activeSession && S.activeSession.id === id) {
|
|
if (S.sessions.length) {
|
|
switchSession(S.sessions[0].id);
|
|
} else {
|
|
S.activeSession = createSessionObj('New Chat');
|
|
saveSession(S.activeSession);
|
|
renderSession();
|
|
}
|
|
}
|
|
updateSessionsPanel();
|
|
}
|
|
|
|
function renameSession(id) {
|
|
var meta = S.sessions.find(function (s) { return s.id === id; });
|
|
if (!meta) return;
|
|
var name = prompt('Rename conversation:', meta.name);
|
|
if (!name || !name.trim()) return;
|
|
name = name.trim().slice(0, 80);
|
|
meta.name = name;
|
|
if (S.activeSession && S.activeSession.id === id) {
|
|
S.activeSession.name = name;
|
|
saveSession(S.activeSession);
|
|
sessionNameEl.textContent = name;
|
|
} else {
|
|
chrome.storage.local.get('ollama_ss_' + id, function (r) {
|
|
var sess = r['ollama_ss_' + id];
|
|
if (sess) { sess.name = name; saveSession(sess); }
|
|
else { chrome.storage.local.set({ ollama_si: S.sessions }); }
|
|
});
|
|
}
|
|
updateSessionsPanel();
|
|
}
|
|
|
|
function exportSession(sess) {
|
|
if (!sess) return;
|
|
var lines = ['# ' + sess.name, '',
|
|
'> **Model:** ' + (sess.model || 'unknown') + ' ',
|
|
'> **Created:** ' + new Date(sess.createdAt).toLocaleString(),
|
|
''];
|
|
if (sess.systemPrompt) {
|
|
lines.push('## System Prompt', '', sess.systemPrompt, '');
|
|
}
|
|
lines.push('---', '');
|
|
(sess.messages || []).forEach(function (m) {
|
|
lines.push('**' + (m.role === 'user' ? 'You' : 'Assistant') + ':**', '', m.content, '', '---', '');
|
|
});
|
|
var blob = new Blob([lines.join('\n')], { type: 'text/markdown' });
|
|
var url = URL.createObjectURL(blob);
|
|
var a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = (sess.name || 'chat').replace(/[^a-z0-9\-_ ]/gi, '-').slice(0,60) + '.md';
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
// ── Model management ──────────────────────────────────────────────────────────
|
|
|
|
function fetchModels() {
|
|
setStatus('', 'Connecting…');
|
|
return new Promise(function (resolve) {
|
|
chrome.runtime.sendMessage(
|
|
{ action: 'OLLAMA_GET', url: S.settings.url + '/api/tags', headers: getHeaders() },
|
|
function (response) {
|
|
if (chrome.runtime.lastError || !response) {
|
|
var e = chrome.runtime.lastError ? chrome.runtime.lastError.message : 'No response';
|
|
setStatus('error', humanizeError(e));
|
|
setSettingsStatus(humanizeError(e), 'err');
|
|
resolve(); return;
|
|
}
|
|
if (!response.ok) {
|
|
var msg = humanizeError(response.error || ('HTTP ' + response.status));
|
|
setStatus('error', msg);
|
|
setSettingsStatus(msg, 'err');
|
|
resolve(); return;
|
|
}
|
|
var raw = response.data.models || [];
|
|
S.models = raw.map(function (m) {
|
|
return {
|
|
name: m.name,
|
|
paramSize: (m.details && m.details.parameter_size) || '',
|
|
sizeLabel: m.size ? formatBytes(m.size) : ''
|
|
};
|
|
});
|
|
populateModels();
|
|
setStatus('connected', 'Connected');
|
|
setSettingsStatus('Connected — ' + S.models.length + ' model(s)', 'ok');
|
|
resolve();
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
function populateModels() {
|
|
var current = (S.activeSession && S.activeSession.model) || '';
|
|
selectModel.innerHTML = '<option value="">-- select model --</option>';
|
|
S.models.forEach(function (m) {
|
|
var opt = document.createElement('option');
|
|
opt.value = m.name;
|
|
var lbl = m.name;
|
|
if (m.paramSize) lbl += ' (' + m.paramSize + ')';
|
|
if (m.sizeLabel) lbl += ' · ' + m.sizeLabel;
|
|
opt.textContent = lbl;
|
|
if (m.name === current) opt.selected = true;
|
|
selectModel.appendChild(opt);
|
|
});
|
|
if (selectModel.value && S.activeSession) S.activeSession.model = selectModel.value;
|
|
}
|
|
|
|
function getModelOptions() {
|
|
var model = (S.activeSession && S.activeSession.model) || selectModel.value || '';
|
|
return Object.assign({}, DEF_PARAMS, S.modelParams[model] || {});
|
|
}
|
|
|
|
// ── Page context ──────────────────────────────────────────────────────────────
|
|
|
|
function getPageContent() {
|
|
return new Promise(function (resolve, reject) {
|
|
chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) {
|
|
if (!tabs || !tabs[0]) { reject(new Error('No active tab')); return; }
|
|
chrome.runtime.sendMessage(
|
|
{ action: 'GET_PAGE_CONTENT', tabId: tabs[0].id },
|
|
function (response) {
|
|
if (chrome.runtime.lastError) { reject(new Error(chrome.runtime.lastError.message)); return; }
|
|
if (!response) { reject(new Error('No response from background')); return; }
|
|
if (response.error) { reject(new Error(response.error)); return; }
|
|
resolve(response);
|
|
}
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
async function togglePageContext() {
|
|
if (S.usePageContext) {
|
|
S.usePageContext = false; S.pageContext = null;
|
|
btnPageContext.classList.remove('active');
|
|
pageCtxLbl.textContent = 'Page';
|
|
return;
|
|
}
|
|
btnPageContext.disabled = true;
|
|
pageCtxLbl.textContent = 'Loading…';
|
|
try {
|
|
var ctx = await getPageContent();
|
|
S.pageContext = ctx; S.usePageContext = true;
|
|
btnPageContext.classList.add('active');
|
|
var title = (ctx.title || ctx.url || 'page').trim();
|
|
pageCtxLbl.textContent = title.length > 20 ? title.slice(0, 18) + '…' : title;
|
|
} catch (e) {
|
|
pageCtxLbl.textContent = 'Page';
|
|
appendSystemMsg('Could not read page: ' + e.message);
|
|
} finally {
|
|
btnPageContext.disabled = false;
|
|
}
|
|
}
|
|
|
|
// ── File attachments ──────────────────────────────────────────────────────────
|
|
|
|
function extractPdfText(buffer) {
|
|
var bytes = new Uint8Array(buffer);
|
|
var runs = []; var cur = '';
|
|
for (var i = 0; i < bytes.length; i++) {
|
|
var b = bytes[i];
|
|
if (b >= 32 && b <= 126) { cur += String.fromCharCode(b); }
|
|
else if (b === 10 || b === 13) { if (cur.length > 3) runs.push(cur); cur = ''; }
|
|
else { if (cur.length > 3) runs.push(cur); cur = ''; }
|
|
}
|
|
if (cur.length > 3) runs.push(cur);
|
|
return runs.filter(function (s) { return !/^[\/\-\(\)\.]{2,}$/.test(s.trim()); })
|
|
.join(' ').replace(/\s+/g, ' ').trim().slice(0, 12000);
|
|
}
|
|
|
|
function handleFiles(files) {
|
|
Array.prototype.forEach.call(files, function (file) {
|
|
var name = file.name;
|
|
var ext = name.split('.').pop().toLowerCase();
|
|
if (ext === 'pdf') {
|
|
var reader = new FileReader();
|
|
reader.onload = function (e) {
|
|
var text = extractPdfText(e.target.result);
|
|
S.attachments.push({ name: name, content: text });
|
|
renderFileChips();
|
|
};
|
|
reader.readAsArrayBuffer(file);
|
|
} else {
|
|
var reader2 = new FileReader();
|
|
reader2.onload = function (e) {
|
|
S.attachments.push({ name: name, content: (e.target.result || '').slice(0, 12000) });
|
|
renderFileChips();
|
|
};
|
|
reader2.readAsText(file);
|
|
}
|
|
});
|
|
fileInput.value = '';
|
|
}
|
|
|
|
function removeAttachment(idx) {
|
|
S.attachments.splice(idx, 1);
|
|
renderFileChips();
|
|
}
|
|
|
|
function renderFileChips() {
|
|
if (!S.attachments.length) {
|
|
fileChipsArea.classList.add('hidden'); return;
|
|
}
|
|
fileChipsArea.classList.remove('hidden');
|
|
fileChipsEl.innerHTML = '';
|
|
S.attachments.forEach(function (att, i) {
|
|
var chip = document.createElement('div');
|
|
chip.className = 'file-chip';
|
|
chip.innerHTML =
|
|
'<span class="file-chip-name" title="' + escapeHtml(att.name) + '">' + escapeHtml(att.name) + '</span>' +
|
|
'<button class="file-chip-del" data-idx="' + i + '" title="Remove">✕</button>';
|
|
fileChipsEl.appendChild(chip);
|
|
});
|
|
}
|
|
|
|
// ── Template management ───────────────────────────────────────────────────────
|
|
|
|
function renderTmplOverlay() {
|
|
tmplList.innerHTML = '';
|
|
if (!S.templates.length) {
|
|
tmplList.innerHTML = '<div style="padding:12px;color:var(--fg3);font-size:12px;">No templates yet.</div>';
|
|
return;
|
|
}
|
|
S.templates.forEach(function (t) {
|
|
var el = document.createElement('div');
|
|
el.className = 'tmpl-item';
|
|
el.innerHTML =
|
|
'<div class="tmpl-item-body">' +
|
|
'<div class="tmpl-item-name">' + escapeHtml(t.name) + '</div>' +
|
|
'<div class="tmpl-item-text">' + escapeHtml((t.content || '').slice(0, 80)) + '</div>' +
|
|
'</div>' +
|
|
'<button class="tmpl-item-del" data-id="' + t.id + '" title="Delete">✕</button>';
|
|
tmplList.appendChild(el);
|
|
});
|
|
}
|
|
|
|
function renderTmplDropdown(filter) {
|
|
var filtered = S.templates.filter(function (t) {
|
|
if (!filter) return true;
|
|
return t.name.toLowerCase().includes(filter) || t.content.toLowerCase().includes(filter);
|
|
});
|
|
if (!filtered.length) { templateDropdown.classList.add('hidden'); return; }
|
|
tmplDdList.innerHTML = '';
|
|
filtered.slice(0, 8).forEach(function (t) {
|
|
var el = document.createElement('div');
|
|
el.className = 'tmpl-dd-item';
|
|
el.innerHTML =
|
|
'<div class="tmpl-dd-name">' + escapeHtml(t.name) + '</div>' +
|
|
'<div class="tmpl-dd-preview">' + escapeHtml((t.content || '').slice(0, 60)) + '</div>';
|
|
el.addEventListener('click', function () {
|
|
inputMessage.value = t.content;
|
|
inputMessage.style.height = 'auto';
|
|
inputMessage.style.height = Math.min(inputMessage.scrollHeight, 160) + 'px';
|
|
templateDropdown.classList.add('hidden');
|
|
inputMessage.focus();
|
|
});
|
|
tmplDdList.appendChild(el);
|
|
});
|
|
templateDropdown.classList.remove('hidden');
|
|
}
|
|
|
|
// ── UI helpers ────────────────────────────────────────────────────────────────
|
|
|
|
function applyTheme(t) {
|
|
S.theme = t;
|
|
app.setAttribute('data-theme', t);
|
|
chrome.storage.local.set({ ollama_t: t });
|
|
}
|
|
|
|
function closePanels() {
|
|
settingsPanel.classList.add('hidden');
|
|
btnSettings.classList.remove('active');
|
|
sessionsPanel.classList.add('hidden');
|
|
btnSessions.classList.remove('active');
|
|
}
|
|
|
|
function updateSessionsPanel() {
|
|
if (!S.sessions.length) {
|
|
sessionsList.innerHTML = '<div style="padding:8px;color:var(--fg3);font-size:12px;">No saved sessions.</div>';
|
|
return;
|
|
}
|
|
sessionsList.innerHTML = '';
|
|
S.sessions.forEach(function (s) {
|
|
var el = document.createElement('div');
|
|
el.className = 'session-item' + (S.activeSession && S.activeSession.id === s.id ? ' active' : '');
|
|
var ts = s.updatedAt ? new Date(s.updatedAt).toLocaleDateString() : '';
|
|
el.innerHTML =
|
|
'<div class="session-item-name" title="' + escapeHtml(s.name) + '">' + escapeHtml(s.name) + '</div>' +
|
|
'<div class="session-item-meta">' + escapeHtml(ts) + '</div>' +
|
|
'<div class="session-item-actions">' +
|
|
'<button class="icon-btn sm" data-action="rename" data-id="' + s.id + '" title="Rename">✎</button>' +
|
|
'<button class="icon-btn sm danger" data-action="delete" data-id="' + s.id + '" title="Delete">✕</button>' +
|
|
'</div>';
|
|
el.addEventListener('click', function (e) {
|
|
if (e.target.closest('[data-action]')) return;
|
|
switchSession(s.id);
|
|
});
|
|
sessionsList.appendChild(el);
|
|
});
|
|
}
|
|
|
|
function updateSessionBar() {
|
|
if (!S.activeSession) return;
|
|
sessionNameEl.textContent = S.activeSession.name;
|
|
}
|
|
|
|
function updateModelDisplay() {
|
|
var model = S.activeSession && S.activeSession.model || selectModel.value || '';
|
|
var display = model || '(no model)';
|
|
document.getElementById('model-display').textContent = display;
|
|
}
|
|
|
|
function renderParamsPanel() {
|
|
var opts = getModelOptions();
|
|
paramsGrid.innerHTML = '';
|
|
PARAM_META.forEach(function (pm) {
|
|
var val = opts[pm.key] !== undefined ? opts[pm.key] : DEF_PARAMS[pm.key];
|
|
var lbl = document.createElement('label');
|
|
lbl.className = 'param-lbl';
|
|
lbl.htmlFor = 'param_' + pm.key;
|
|
lbl.textContent = pm.label;
|
|
|
|
var inp = document.createElement('input');
|
|
inp.id = 'param_' + pm.key;
|
|
inp.type = 'number';
|
|
inp.className = 'param-input';
|
|
inp.value = val;
|
|
inp.min = pm.min;
|
|
inp.max = pm.max;
|
|
inp.step = pm.step;
|
|
inp.addEventListener('change', function () {
|
|
var model = (S.activeSession && S.activeSession.model) || selectModel.value || '';
|
|
if (!S.modelParams[model]) S.modelParams[model] = {};
|
|
S.modelParams[model][pm.key] = parseFloat(this.value);
|
|
saveModelParams();
|
|
});
|
|
|
|
var valEl = document.createElement('span');
|
|
valEl.className = 'param-val';
|
|
valEl.textContent = val;
|
|
inp.addEventListener('input', function () { valEl.textContent = this.value; });
|
|
|
|
paramsGrid.appendChild(lbl);
|
|
paramsGrid.appendChild(inp);
|
|
paramsGrid.appendChild(valEl);
|
|
});
|
|
}
|
|
|
|
function renderSession() {
|
|
if (!S.activeSession) return;
|
|
updateSessionBar();
|
|
// Sync system prompt field
|
|
inputSysPrompt.value = S.activeSession.systemPrompt || '';
|
|
// Sync model selector
|
|
if (S.activeSession.model && selectModel) {
|
|
selectModel.value = S.activeSession.model;
|
|
if (!selectModel.value && S.activeSession.model) {
|
|
// Model not in list yet, will be set after fetchModels
|
|
}
|
|
}
|
|
renderAllMessages();
|
|
}
|
|
|
|
// ── Message rendering ─────────────────────────────────────────────────────────
|
|
|
|
function scrollToBottom() { chatArea.scrollTop = chatArea.scrollHeight; }
|
|
|
|
function showEmptyState() {
|
|
if (messagesEl.children.length) return;
|
|
var el = document.createElement('div');
|
|
el.id = 'empty-state'; el.className = 'empty-state';
|
|
el.innerHTML =
|
|
'<div class="ei">🦙</div>' +
|
|
'<p>Ask anything.<br>Use <strong>Page</strong> or <strong>Attach</strong> to include context.</p>';
|
|
messagesEl.appendChild(el);
|
|
}
|
|
|
|
function removeEmptyState() {
|
|
var el = document.getElementById('empty-state');
|
|
if (el) el.remove();
|
|
}
|
|
|
|
function appendSystemMsg(text) {
|
|
var w = document.createElement('div');
|
|
w.className = 'message error';
|
|
var b = document.createElement('div');
|
|
b.className = 'msg-bubble'; b.textContent = humanizeError(text);
|
|
var body = document.createElement('div');
|
|
body.className = 'msg-body'; body.appendChild(b);
|
|
w.appendChild(body); messagesEl.appendChild(w); scrollToBottom();
|
|
}
|
|
|
|
function buildMessageEl(msg, index) {
|
|
var w = document.createElement('div');
|
|
w.className = 'message ' + msg.role;
|
|
w.dataset.index = index;
|
|
|
|
var roleEl = document.createElement('div');
|
|
roleEl.className = 'msg-role';
|
|
roleEl.textContent = msg.role === 'user' ? 'You' : 'Assistant';
|
|
|
|
var body = document.createElement('div');
|
|
body.className = 'msg-body';
|
|
|
|
if (msg.role === 'assistant') {
|
|
// Thinking block
|
|
if (msg.thinking) {
|
|
var det = document.createElement('details');
|
|
det.className = 'think-block';
|
|
var sum = document.createElement('summary');
|
|
sum.textContent = 'Thinking';
|
|
var tc = document.createElement('div');
|
|
tc.className = 'think-content';
|
|
tc.innerHTML = renderMarkdown(msg.thinking);
|
|
det.appendChild(sum); det.appendChild(tc);
|
|
body.appendChild(det);
|
|
}
|
|
// Bubble
|
|
var bubble = document.createElement('div');
|
|
bubble.className = 'msg-bubble';
|
|
bubble.innerHTML = renderMarkdown(msg.content || '');
|
|
body.appendChild(bubble);
|
|
// Meta (source + metrics)
|
|
var meta = document.createElement('div');
|
|
meta.className = 'msg-meta';
|
|
if (msg.sources && msg.sources.length) {
|
|
var sc = document.createElement('div');
|
|
sc.className = 'source-chip';
|
|
sc.textContent = '📎 ' + msg.sources.join(', ');
|
|
meta.appendChild(sc);
|
|
}
|
|
if (msg.metrics) {
|
|
var ml = document.createElement('div');
|
|
ml.className = 'metrics-line';
|
|
ml.textContent = formatMetrics(msg.metrics);
|
|
if (ml.textContent) meta.appendChild(ml);
|
|
}
|
|
if (meta.children.length) body.appendChild(meta);
|
|
// Actions
|
|
var acts = document.createElement('div');
|
|
acts.className = 'msg-actions';
|
|
acts.innerHTML =
|
|
'<button class="act-btn" data-action="copy" title="Copy">⧉ Copy</button>';
|
|
body.appendChild(acts);
|
|
} else {
|
|
// User bubble
|
|
var ub = document.createElement('div');
|
|
ub.className = 'msg-bubble';
|
|
ub.textContent = msg.content;
|
|
body.appendChild(ub);
|
|
var ua = document.createElement('div');
|
|
ua.className = 'msg-actions';
|
|
ua.innerHTML = '<button class="act-btn" data-action="edit" title="Edit">✎ Edit</button>';
|
|
body.appendChild(ua);
|
|
}
|
|
|
|
w.appendChild(roleEl); w.appendChild(body);
|
|
return w;
|
|
}
|
|
|
|
function renderAllMessages() {
|
|
messagesEl.innerHTML = '';
|
|
if (!S.activeSession || !S.activeSession.messages.length) { showEmptyState(); return; }
|
|
S.activeSession.messages.forEach(function (msg, i) {
|
|
messagesEl.appendChild(buildMessageEl(msg, i));
|
|
});
|
|
// Add regen button to last assistant message
|
|
updateRegenButton();
|
|
scrollToBottom();
|
|
}
|
|
|
|
function updateRegenButton() {
|
|
// Remove any existing regen buttons
|
|
messagesEl.querySelectorAll('[data-action="regen"]').forEach(function (b) { b.remove(); });
|
|
// Find last assistant message
|
|
var msgs = S.activeSession && S.activeSession.messages;
|
|
if (!msgs || !msgs.length) return;
|
|
for (var i = msgs.length - 1; i >= 0; i--) {
|
|
if (msgs[i].role === 'assistant') {
|
|
var el = messagesEl.querySelector('.message[data-index="' + i + '"] .msg-actions');
|
|
if (el) {
|
|
var rb = document.createElement('button');
|
|
rb.className = 'act-btn'; rb.dataset.action = 'regen'; rb.title = 'Regenerate';
|
|
rb.innerHTML = '↻ Regen';
|
|
el.appendChild(rb);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Chat ──────────────────────────────────────────────────────────────────────
|
|
|
|
function buildApiMessages() {
|
|
var out = [];
|
|
if (S.activeSession && S.activeSession.systemPrompt) {
|
|
out.push({ role: 'system', content: S.activeSession.systemPrompt });
|
|
}
|
|
(S.activeSession && S.activeSession.messages || []).forEach(function (m) {
|
|
out.push({ role: m.role, content: m.content });
|
|
});
|
|
return out;
|
|
}
|
|
|
|
function doSendChat(text) {
|
|
if (S.isStreaming) return;
|
|
if (!text || !text.trim()) return;
|
|
if (!S.activeSession) return;
|
|
|
|
var model = selectModel.value;
|
|
if (!model) { appendSystemMsg('Please select a model first.'); return; }
|
|
S.activeSession.model = model;
|
|
|
|
// Collect context
|
|
var ctx = buildContextBlock(text);
|
|
|
|
// Build actual user content (with context prefix)
|
|
var apiContent = text;
|
|
if (ctx) apiContent = ctx.text + '\n\n' + text;
|
|
|
|
removeEmptyState();
|
|
inputMessage.value = '';
|
|
inputMessage.style.height = '';
|
|
setSendEnabled(false);
|
|
S.isStreaming = true;
|
|
|
|
// User message (display only the typed text)
|
|
var userMsg = { id: genId(), role: 'user', content: text, ts: Date.now() };
|
|
S.activeSession.messages.push(userMsg);
|
|
var userIdx = S.activeSession.messages.length - 1;
|
|
messagesEl.appendChild(buildMessageEl(userMsg, userIdx));
|
|
scrollToBottom();
|
|
|
|
// Generate title from first message if this is the first message
|
|
if (S.activeSession.messages.length === 2 && !S.activeSession.name) {
|
|
// First message just sent - generate a short title
|
|
var shortTitle = text.slice(0, 40).trim().replace(/[^a-zA-Z0-9 ]/g, '');
|
|
S.activeSession.name = shortTitle || 'New Chat';
|
|
saveSession(S.activeSession);
|
|
}
|
|
|
|
// Build API history with context-injected version of the last message
|
|
var apiMsgs = buildApiMessages();
|
|
apiMsgs[apiMsgs.length - 1] = { role: 'user', content: apiContent };
|
|
|
|
// Assistant bubble
|
|
var asstIdx = S.activeSession.messages.length; // will be pushed on DONE
|
|
var asstMsgEl = document.createElement('div');
|
|
asstMsgEl.className = 'message assistant';
|
|
asstMsgEl.dataset.index = asstIdx;
|
|
|
|
var roleEl = document.createElement('div');
|
|
roleEl.className = 'msg-role'; roleEl.textContent = 'Assistant';
|
|
|
|
var body = document.createElement('div');
|
|
body.className = 'msg-body';
|
|
|
|
// Thinking block (hidden until content arrives)
|
|
var thinkBlock = document.createElement('details');
|
|
thinkBlock.className = 'think-block hidden';
|
|
var thinkSum = document.createElement('summary');
|
|
thinkSum.textContent = 'Thinking';
|
|
var thinkContent = document.createElement('div');
|
|
thinkContent.className = 'think-content';
|
|
thinkBlock.appendChild(thinkSum); thinkBlock.appendChild(thinkContent);
|
|
body.appendChild(thinkBlock);
|
|
|
|
var bubble = document.createElement('div');
|
|
bubble.className = 'msg-bubble streaming';
|
|
body.appendChild(bubble);
|
|
|
|
asstMsgEl.appendChild(roleEl); asstMsgEl.appendChild(body);
|
|
messagesEl.appendChild(asstMsgEl);
|
|
scrollToBottom();
|
|
|
|
var requestId = genId();
|
|
S.currentStream = {
|
|
requestId: requestId,
|
|
bubble: bubble,
|
|
thinkBlock: thinkBlock,
|
|
thinkContent: thinkContent,
|
|
thinkText: '',
|
|
mainText: '',
|
|
inThinkTag: false,
|
|
metrics: null,
|
|
sources: ctx ? ctx.names : null,
|
|
msgIndex: asstIdx,
|
|
msgEl: asstMsgEl,
|
|
bodyEl: body
|
|
};
|
|
|
|
chrome.runtime.sendMessage({
|
|
action: 'OLLAMA_FETCH',
|
|
url: S.settings.url + '/api/chat',
|
|
headers: getHeaders(),
|
|
body: JSON.stringify({
|
|
model: model,
|
|
messages: apiMsgs,
|
|
stream: true,
|
|
options: getModelOptions()
|
|
}),
|
|
requestId: requestId
|
|
});
|
|
}
|
|
|
|
function sendChat() {
|
|
var text = inputMessage.value.trim();
|
|
if (text) doSendChat(text);
|
|
}
|
|
|
|
function stopStream() {
|
|
if (!S.currentStream) return;
|
|
chrome.runtime.sendMessage({ action: 'CANCEL_STREAM', requestId: S.currentStream.requestId });
|
|
}
|
|
|
|
function regenerateLastResponse() {
|
|
if (S.isStreaming || !S.activeSession) return;
|
|
var msgs = S.activeSession.messages;
|
|
// Remove trailing assistant messages
|
|
while (msgs.length && msgs[msgs.length - 1].role === 'assistant') msgs.pop();
|
|
if (!msgs.length || msgs[msgs.length - 1].role !== 'user') return;
|
|
var lastUserText = msgs[msgs.length - 1].content;
|
|
msgs.pop(); // will be re-added by doSendChat
|
|
saveSession(S.activeSession);
|
|
renderAllMessages();
|
|
doSendChat(lastUserText);
|
|
}
|
|
|
|
function startEditMessage(msgEl, index) {
|
|
if (S.isStreaming) return;
|
|
var msgs = S.activeSession && S.activeSession.messages;
|
|
if (!msgs || index >= msgs.length) return;
|
|
var original = msgs[index].content;
|
|
|
|
var bubble = msgEl.querySelector('.msg-bubble');
|
|
bubble.innerHTML = '';
|
|
var ta = document.createElement('textarea');
|
|
ta.className = 'edit-textarea'; ta.value = original;
|
|
bubble.appendChild(ta);
|
|
|
|
var acts = msgEl.querySelector('.msg-actions');
|
|
acts.innerHTML = '';
|
|
var saveBtn = document.createElement('button');
|
|
saveBtn.className = 'primary-btn sm'; saveBtn.textContent = 'Save & Send';
|
|
var cancelBtn = document.createElement('button');
|
|
cancelBtn.className = 'secondary-btn'; cancelBtn.textContent = 'Cancel';
|
|
|
|
saveBtn.addEventListener('click', function () {
|
|
var newText = ta.value.trim();
|
|
if (!newText) return;
|
|
// Truncate history to before this message
|
|
S.activeSession.messages = msgs.slice(0, index);
|
|
saveSession(S.activeSession);
|
|
renderAllMessages();
|
|
doSendChat(newText);
|
|
});
|
|
cancelBtn.addEventListener('click', function () {
|
|
bubble.innerHTML = escapeHtml(original).replace(/\n/g,'<br>');
|
|
acts.innerHTML = '<button class="act-btn" data-action="edit" title="Edit">✎ Edit</button>';
|
|
});
|
|
|
|
var editActs = document.createElement('div');
|
|
editActs.className = 'edit-actions';
|
|
editActs.appendChild(saveBtn); editActs.appendChild(cancelBtn);
|
|
msgEl.querySelector('.msg-body').appendChild(editActs);
|
|
ta.focus(); ta.select();
|
|
}
|
|
|
|
// ── Stream message listener ───────────────────────────────────────────────────
|
|
|
|
chrome.runtime.onMessage.addListener(function (msg) {
|
|
if (!S.currentStream || msg.requestId !== S.currentStream.requestId) return;
|
|
var cs = S.currentStream;
|
|
|
|
if (msg.action === 'STREAM_THINKING') {
|
|
cs.thinkText += msg.text;
|
|
cs.thinkContent.innerHTML = renderMarkdown(cs.thinkText);
|
|
cs.thinkBlock.classList.remove('hidden');
|
|
cs.thinkBlock.open = true;
|
|
scrollToBottom();
|
|
return;
|
|
}
|
|
|
|
if (msg.action === 'STREAM_CHUNK') {
|
|
var delta = msg.text;
|
|
|
|
// Parse inline <think>...</think> tags from content
|
|
if (!cs.inThinkTag && !cs.mainText && delta.trimStart().startsWith('<think>')) {
|
|
cs.inThinkTag = true;
|
|
delta = delta.replace(/^[\s\S]*?<think>/, '');
|
|
}
|
|
if (cs.inThinkTag) {
|
|
var closeIdx = delta.indexOf('</think>');
|
|
if (closeIdx !== -1) {
|
|
cs.thinkText += delta.slice(0, closeIdx);
|
|
cs.inThinkTag = false;
|
|
delta = delta.slice(closeIdx + 8).replace(/^\s+/, '');
|
|
} else {
|
|
cs.thinkText += delta;
|
|
delta = '';
|
|
}
|
|
}
|
|
if (cs.thinkText) {
|
|
cs.thinkContent.innerHTML = renderMarkdown(cs.thinkText);
|
|
cs.thinkBlock.classList.remove('hidden');
|
|
if (cs.inThinkTag) cs.thinkBlock.open = true;
|
|
}
|
|
if (delta) {
|
|
cs.mainText += delta;
|
|
cs.bubble.innerHTML = renderMarkdown(cs.mainText);
|
|
}
|
|
scrollToBottom();
|
|
return;
|
|
}
|
|
|
|
if (msg.action === 'STREAM_METRICS') {
|
|
cs.metrics = msg.metrics;
|
|
return;
|
|
}
|
|
|
|
if (msg.action === 'STREAM_DONE' || msg.action === 'STREAM_ERROR') {
|
|
var isErr = msg.action === 'STREAM_ERROR';
|
|
var content = cs.mainText;
|
|
|
|
cs.bubble.classList.remove('streaming');
|
|
if (cs.thinkBlock && cs.thinkText) cs.thinkBlock.open = false; // collapse when done
|
|
|
|
if (isErr && !content) {
|
|
// No partial content — show error in bubble
|
|
if (S.activeSession && S.activeSession.messages.length) {
|
|
S.activeSession.messages.pop(); // roll back user message
|
|
}
|
|
cs.bubble.parentElement.parentElement.className = 'message error';
|
|
cs.bubble.textContent = humanizeError(msg.error || 'Unknown error');
|
|
} else {
|
|
if (isErr) {
|
|
// Partial content + error note
|
|
var note = document.createElement('div');
|
|
note.className = 'stream-err-note';
|
|
note.textContent = humanizeError(msg.error || 'Stream interrupted');
|
|
cs.bubble.parentElement.appendChild(note);
|
|
}
|
|
|
|
// Add source chip if context was used
|
|
if (cs.sources && cs.sources.length) {
|
|
var sc = document.createElement('div');
|
|
sc.className = 'source-chip';
|
|
sc.textContent = '📎 ' + cs.sources.join(', ');
|
|
var metaEl = document.createElement('div');
|
|
metaEl.className = 'msg-meta';
|
|
metaEl.appendChild(sc);
|
|
if (cs.metrics) {
|
|
var ml = document.createElement('div');
|
|
ml.className = 'metrics-line';
|
|
ml.textContent = formatMetrics(cs.metrics);
|
|
if (ml.textContent) metaEl.appendChild(ml);
|
|
}
|
|
cs.bodyEl.insertBefore(metaEl, cs.bubble.nextSibling);
|
|
} else if (cs.metrics) {
|
|
var mEl = document.createElement('div');
|
|
mEl.className = 'msg-meta';
|
|
var ml2 = document.createElement('div');
|
|
ml2.className = 'metrics-line';
|
|
ml2.textContent = formatMetrics(cs.metrics);
|
|
if (ml2.textContent) { mEl.appendChild(ml2); cs.bodyEl.insertBefore(mEl, cs.bubble.nextSibling); }
|
|
}
|
|
|
|
// Add actions row
|
|
var actsEl = document.createElement('div');
|
|
actsEl.className = 'msg-actions';
|
|
actsEl.innerHTML = '<button class="act-btn" data-action="copy" title="Copy">⧉ Copy</button>';
|
|
cs.bodyEl.appendChild(actsEl);
|
|
|
|
// Push to session
|
|
if (S.activeSession) {
|
|
var asstMsg = {
|
|
id: genId(),
|
|
role: 'assistant',
|
|
content: content || '',
|
|
thinking: cs.thinkText || null,
|
|
metrics: cs.metrics || null,
|
|
sources: cs.sources || null,
|
|
ts: Date.now()
|
|
};
|
|
S.activeSession.messages.push(asstMsg);
|
|
cs.msgEl.dataset.index = S.activeSession.messages.length - 1;
|
|
saveSession(S.activeSession);
|
|
updateRegenButton();
|
|
}
|
|
}
|
|
|
|
S.currentStream = null;
|
|
S.isStreaming = false;
|
|
setSendEnabled(true);
|
|
scrollToBottom();
|
|
inputMessage.focus();
|
|
}
|
|
});
|
|
|
|
// ── Event handlers ────────────────────────────────────────────────────────────
|
|
|
|
function initEventHandlers() {
|
|
|
|
// Header
|
|
btnSettings.addEventListener('click', function () {
|
|
var wasOpen = !settingsPanel.classList.contains('hidden');
|
|
closePanels();
|
|
if (!wasOpen) { settingsPanel.classList.remove('hidden'); btnSettings.classList.add('active'); }
|
|
});
|
|
|
|
btnSessions.addEventListener('click', function () {
|
|
var wasOpen = !sessionsPanel.classList.contains('hidden');
|
|
closePanels();
|
|
if (!wasOpen) {
|
|
updateSessionsPanel();
|
|
sessionsPanel.classList.remove('hidden');
|
|
btnSessions.classList.add('active');
|
|
}
|
|
});
|
|
|
|
btnTheme.addEventListener('click', function () {
|
|
applyTheme(S.theme === 'dark' ? 'light' : 'dark');
|
|
});
|
|
|
|
// Settings
|
|
btnShowToken.addEventListener('click', function () {
|
|
inputToken.type = inputToken.type === 'password' ? 'text' : 'password';
|
|
});
|
|
|
|
var urlTimer = null;
|
|
inputUrl.addEventListener('input', function () {
|
|
clearTimeout(urlTimer);
|
|
var val = this.value.trim().replace(/\/+$/, '');
|
|
if (!val || !/^https?:\/\//.test(val)) return;
|
|
urlTimer = setTimeout(function () {
|
|
setStatus('', 'Checking…');
|
|
var lh = { 'Content-Type': 'application/json' };
|
|
var tok = inputToken.value.trim();
|
|
if (tok) lh['Authorization'] = 'Bearer ' + tok;
|
|
chrome.runtime.sendMessage(
|
|
{ action: 'OLLAMA_GET', url: val + '/api/tags', headers: lh },
|
|
function (r) {
|
|
if (!r || !r.ok) { setStatus('error', 'Cannot connect'); return; }
|
|
setStatus('connected', 'Connected');
|
|
var models = (r.data.models || []).map(function (m) {
|
|
return { name: m.name, paramSize: (m.details && m.details.parameter_size) || '', sizeLabel: m.size ? formatBytes(m.size) : '' };
|
|
});
|
|
S.models = models;
|
|
populateModels();
|
|
}
|
|
);
|
|
}, 800);
|
|
});
|
|
|
|
btnSave.addEventListener('click', async function () {
|
|
saveSettings();
|
|
setSettingsStatus('Connecting…', '');
|
|
await fetchModels();
|
|
});
|
|
|
|
// Sessions panel
|
|
btnNewSession.addEventListener('click', newSession);
|
|
|
|
sessionsList.addEventListener('click', function (e) {
|
|
var btn = e.target.closest('[data-action]');
|
|
if (!btn) return;
|
|
var id = btn.dataset.id;
|
|
if (btn.dataset.action === 'rename') renameSession(id);
|
|
if (btn.dataset.action === 'delete') deleteSession(id);
|
|
});
|
|
|
|
// Session bar
|
|
btnRenameSession.addEventListener('click', function () {
|
|
if (S.activeSession) renameSession(S.activeSession.id);
|
|
});
|
|
btnExportSession.addEventListener('click', function () { exportSession(S.activeSession); });
|
|
btnDeleteSession.addEventListener('click', function () {
|
|
if (S.activeSession) deleteSession(S.activeSession.id);
|
|
});
|
|
sessionNameEl.addEventListener('dblclick', function () {
|
|
if (S.activeSession) renameSession(S.activeSession.id);
|
|
});
|
|
|
|
// Model selector (in settings panel)
|
|
selectModel.addEventListener('change', function () {
|
|
if (!S.activeSession) return;
|
|
S.activeSession.model = this.value;
|
|
updateModelDisplay();
|
|
debouncedSaveSession();
|
|
renderParamsPanel();
|
|
});
|
|
|
|
// System prompt (now in settings panel, but also accessible from input area as reminder)
|
|
btnToggleSystem.addEventListener('click', function () {
|
|
// Toggle visibility in input area (for quick editing)
|
|
var open = systemPanel.classList.toggle('hidden') === false;
|
|
btnToggleSystem.classList.toggle('active', !systemPanel.classList.contains('hidden'));
|
|
if (open) inputSysPrompt.focus();
|
|
});
|
|
btnCloseSystem.addEventListener('click', function () {
|
|
systemPanel.classList.add('hidden');
|
|
btnToggleSystem.classList.remove('active');
|
|
});
|
|
inputSysPrompt.addEventListener('input', function () {
|
|
if (!S.activeSession) return;
|
|
S.activeSession.systemPrompt = this.value;
|
|
debouncedSaveSession();
|
|
});
|
|
|
|
// Settings panel system prompt
|
|
var settingsSysPanel = document.getElementById('system-prompt-panel');
|
|
if (settingsSysPanel) {
|
|
var settingsBtnCloseSystem = settingsSysPanel.querySelector('#btn-close-system');
|
|
if (settingsBtnCloseSystem) {
|
|
settingsBtnCloseSystem.addEventListener('click', function () {
|
|
settingsSysPanel.classList.add('collapsed');
|
|
});
|
|
}
|
|
var settingsInputSysPrompt = settingsSysPanel.querySelector('#input-system-prompt');
|
|
if (settingsInputSysPrompt) {
|
|
settingsInputSysPrompt.addEventListener('input', function () {
|
|
if (!S.activeSession) return;
|
|
S.activeSession.systemPrompt = this.value;
|
|
debouncedSaveSession();
|
|
});
|
|
}
|
|
}
|
|
|
|
// Params panel
|
|
btnToggleParams.addEventListener('click', function () {
|
|
var open = !paramsPanel.classList.contains('hidden');
|
|
paramsPanel.classList.toggle('hidden', open);
|
|
btnToggleParams.classList.toggle('active', !open);
|
|
if (!open) renderParamsPanel();
|
|
});
|
|
btnCloseParams.addEventListener('click', function () {
|
|
paramsPanel.classList.add('hidden');
|
|
btnToggleParams.classList.remove('active');
|
|
});
|
|
|
|
// File chips (delegation)
|
|
fileChipsEl.addEventListener('click', function (e) {
|
|
var btn = e.target.closest('.file-chip-del');
|
|
if (!btn) return;
|
|
var idx = parseInt(btn.dataset.idx);
|
|
removeAttachment(idx);
|
|
});
|
|
|
|
// File attach
|
|
btnAttachFile.addEventListener('click', function () { fileInput.click(); });
|
|
fileInput.addEventListener('change', function () { if (this.files.length) handleFiles(this.files); });
|
|
|
|
// Page context
|
|
btnPageContext.addEventListener('click', togglePageContext);
|
|
|
|
// Clear
|
|
btnClear.addEventListener('click', function () {
|
|
if (S.isStreaming) return;
|
|
if (!confirm('Clear this conversation?')) return;
|
|
if (S.activeSession) { S.activeSession.messages = []; saveSession(S.activeSession); }
|
|
renderAllMessages();
|
|
});
|
|
|
|
// Textarea input & template dropdown
|
|
inputMessage.addEventListener('input', function () {
|
|
this.style.height = 'auto';
|
|
this.style.height = Math.min(this.scrollHeight, 160) + 'px';
|
|
var val = this.value;
|
|
if (val.startsWith('/')) {
|
|
renderTmplDropdown(val.slice(1).toLowerCase());
|
|
} else {
|
|
templateDropdown.classList.add('hidden');
|
|
}
|
|
});
|
|
|
|
inputMessage.addEventListener('keydown', function (e) {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
sendChat();
|
|
}
|
|
if (e.key === 'Escape') templateDropdown.classList.add('hidden');
|
|
});
|
|
|
|
// Templates button (open manager)
|
|
btnTemplatesOpen.addEventListener('click', function () {
|
|
renderTmplOverlay();
|
|
tmplOverlay.classList.remove('hidden');
|
|
});
|
|
btnTmplManageInline.addEventListener('click', function () {
|
|
templateDropdown.classList.add('hidden');
|
|
renderTmplOverlay();
|
|
tmplOverlay.classList.remove('hidden');
|
|
});
|
|
|
|
// Template overlay
|
|
btnTmplOverlayClose.addEventListener('click', function () {
|
|
tmplOverlay.classList.add('hidden');
|
|
});
|
|
tmplOverlay.addEventListener('click', function (e) {
|
|
if (e.target === tmplOverlay) tmplOverlay.classList.add('hidden');
|
|
});
|
|
btnTmplAdd.addEventListener('click', function () {
|
|
var name = tmplNewName.value.trim();
|
|
var content = tmplNewContent.value.trim();
|
|
if (!name || !content) return;
|
|
S.templates.push({ id: genId(), name: name, content: content });
|
|
saveTemplates();
|
|
tmplNewName.value = ''; tmplNewContent.value = '';
|
|
renderTmplOverlay();
|
|
});
|
|
tmplList.addEventListener('click', function (e) {
|
|
var btn = e.target.closest('.tmpl-item-del');
|
|
if (!btn) return;
|
|
var id = btn.dataset.id;
|
|
S.templates = S.templates.filter(function (t) { return t.id !== id; });
|
|
saveTemplates();
|
|
renderTmplOverlay();
|
|
});
|
|
|
|
// Send / Stop
|
|
btnSend.addEventListener('click', sendChat);
|
|
btnStop.addEventListener('click', stopStream);
|
|
|
|
// Message actions (delegation)
|
|
messagesEl.addEventListener('click', function (e) {
|
|
var btn = e.target.closest('.act-btn[data-action]');
|
|
if (!btn) return;
|
|
var action = btn.dataset.action;
|
|
var msgEl = btn.closest('.message');
|
|
var index = msgEl ? parseInt(msgEl.dataset.index) : -1;
|
|
|
|
if (action === 'copy') {
|
|
var bubble = msgEl && msgEl.querySelector('.msg-bubble');
|
|
var text = bubble ? (bubble.innerText || bubble.textContent || '') : '';
|
|
navigator.clipboard.writeText(text).catch(function () {
|
|
// fallback: select text
|
|
var r = document.createRange(); r.selectNode(bubble);
|
|
window.getSelection().removeAllRanges();
|
|
window.getSelection().addRange(r);
|
|
document.execCommand('copy');
|
|
window.getSelection().removeAllRanges();
|
|
});
|
|
btn.textContent = '✓ Copied';
|
|
setTimeout(function () { btn.innerHTML = '⧉ Copy'; }, 1500);
|
|
}
|
|
if (action === 'edit') { startEditMessage(msgEl, index); }
|
|
if (action === 'regen') { regenerateLastResponse(); }
|
|
});
|
|
}
|
|
|
|
// ── Init ──────────────────────────────────────────────────────────────────────
|
|
|
|
loadAllData(async function () {
|
|
renderSession();
|
|
initEventHandlers();
|
|
renderParamsPanel();
|
|
await fetchModels();
|
|
// After models load, sync session model to selector
|
|
if (S.activeSession && S.activeSession.model) {
|
|
selectModel.value = S.activeSession.model;
|
|
if (!selectModel.value) {
|
|
// model stored in session not in list; pick first
|
|
if (selectModel.options.length > 1) {
|
|
selectModel.selectedIndex = 1;
|
|
S.activeSession.model = selectModel.value;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
})();
|