Add Nebot plugin with Ollama chat integration
Introduces the Nebot plugin, providing a floating chat panel that communicates with a local or remote Ollama server and persists chat sessions. Adds dependencies for markdown rendering and code highlighting (dompurify, highlight.js, marked). Removes the sample-hello plugin. Updates site-history.json with recent browsing history.
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
// Nebot plugin - main process side
|
||||
// Responsibilities:
|
||||
// - Persist chat sessions under the plugin directory (JSON files)
|
||||
// - IPC handlers for CRUD + streaming chat completions via Ollama HTTP API
|
||||
// - Add a Help menu item to toggle the chat panel in the renderer
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* A tiny JSON store stored in pluginDir/chats
|
||||
*/
|
||||
function ensureDirSync(p) {
|
||||
try { fs.mkdirSync(p, { recursive: true }); } catch {}
|
||||
}
|
||||
|
||||
function readJSONSafe(p, fallback) {
|
||||
try { return JSON.parse(fs.readFileSync(p, 'utf-8')); } catch { return fallback; }
|
||||
}
|
||||
|
||||
function writeJSONSafe(p, data) {
|
||||
fs.writeFileSync(p, JSON.stringify(data, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
module.exports.activate = function(ctx) {
|
||||
const pluginId = 'ollama-chat';
|
||||
const pluginDir = ctx.paths?.pluginDir || ctx.paths?.appPath || process.cwd();
|
||||
const userPlugins = path.join(ctx.paths?.userData || pluginDir, 'plugins');
|
||||
// Prefer saving under userData/plugins/<id> to ensure write access
|
||||
const dataRoot = pluginDir.startsWith(userPlugins)
|
||||
? pluginDir
|
||||
: path.join(userPlugins, pluginId);
|
||||
ensureDirSync(dataRoot);
|
||||
const chatsDir = path.join(dataRoot, 'chats');
|
||||
ensureDirSync(chatsDir);
|
||||
|
||||
// Simple settings (host/model) stored alongside chats
|
||||
const settingsPath = path.join(dataRoot, 'settings.json');
|
||||
ensureDirSync(path.dirname(settingsPath));
|
||||
const defaultSettings = {
|
||||
ollamaBaseUrl: 'http://localhost:11434',
|
||||
model: 'gpt-oss:20b',
|
||||
systemPrompt: 'You are Nebot, the embedded chat assistant inside the Nebula browser. Be friendly, confident, and a bit playful. Prefer clear, descriptive answers with brief reasoning when helpful, and include short examples when it aids understanding. Keep responses concise by default; expand only if asked. Stay safe and do not claim capabilities you lack.'
|
||||
};
|
||||
const loadSettings = () => readJSONSafe(settingsPath, defaultSettings);
|
||||
const saveSettings = (s) => writeJSONSafe(settingsPath, { ...defaultSettings, ...s });
|
||||
|
||||
async function generateTitleIfNeeded(senderWebContents, chatPath) {
|
||||
try {
|
||||
const chat = readJSONSafe(chatPath, null);
|
||||
if (!chat) return;
|
||||
const needsTitle = !chat.title || /^new chat/i.test(chat.title) || /^chat \d|^chat \d{1,2}:\d{2}/i.test(chat.title);
|
||||
if (!needsTitle) return;
|
||||
if (!Array.isArray(chat.messages) || chat.messages.length < 2) return; // need at least user+assistant
|
||||
const firstUser = chat.messages.find(m => m.role === 'user');
|
||||
const firstAssistant = chat.messages.find(m => m.role === 'assistant');
|
||||
if (!firstUser || !firstAssistant) return;
|
||||
|
||||
const userText = String(firstUser.content || '').slice(0, 400);
|
||||
const asstText = String(firstAssistant.content || '').slice(0, 400);
|
||||
const { ollamaBaseUrl } = loadSettings();
|
||||
const model = 'gpt-oss:20b';
|
||||
const prompt = `Create a concise, descriptive chat title (4-8 words) for this conversation. Use Title Case. No quotes. No trailing punctuation.\n\nUser: ${userText}\nAssistant: ${asstText}\n\nTitle:`;
|
||||
const url = `${ollamaBaseUrl.replace(/\/$/, '')}/api/generate`;
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model, prompt, stream: false })
|
||||
});
|
||||
} catch {
|
||||
return; // no network/title
|
||||
}
|
||||
if (!resp.ok) return;
|
||||
let data;
|
||||
try { data = await resp.json(); } catch { return; }
|
||||
let title = (data && typeof data.response === 'string') ? data.response.trim() : '';
|
||||
if (!title) return;
|
||||
// Sanitize: single line, strip quotes
|
||||
title = title.split('\n')[0].replace(/^"|"$/g, '').replace(/^'|'$/g, '').trim();
|
||||
// Clamp length
|
||||
if (title.length > 80) title = title.slice(0, 77) + '…';
|
||||
if (!title) return;
|
||||
|
||||
const latest = readJSONSafe(chatPath, null);
|
||||
if (!latest) return;
|
||||
latest.title = title;
|
||||
latest.updatedAt = Date.now();
|
||||
writeJSONSafe(chatPath, latest);
|
||||
try { senderWebContents?.send('ollama-chat:chat-updated', { id: latest.id, title }); } catch {}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// IPC: list chats
|
||||
ctx.registerIPC(`${pluginId}:list-chats`, async () => {
|
||||
const files = fs.readdirSync(chatsDir).filter(f => f.endsWith('.json'));
|
||||
const chats = files.map(f => {
|
||||
const p = path.join(chatsDir, f);
|
||||
const j = readJSONSafe(p, null);
|
||||
return j ? { id: j.id, title: j.title, updatedAt: j.updatedAt } : null;
|
||||
}).filter(Boolean).sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
|
||||
return { chats };
|
||||
});
|
||||
|
||||
// IPC: get chat
|
||||
ctx.registerIPC(`${pluginId}:get-chat`, async (_e, { id }) => {
|
||||
const p = path.join(chatsDir, `${id}.json`);
|
||||
const chat = readJSONSafe(p, null);
|
||||
if (!chat) return { error: 'not_found' };
|
||||
return { chat };
|
||||
});
|
||||
|
||||
// IPC: create chat
|
||||
ctx.registerIPC(`${pluginId}:create-chat`, async (_e, { title }) => {
|
||||
const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const now = Date.now();
|
||||
const chat = { id, title: title || 'New chat', createdAt: now, updatedAt: now, messages: [] };
|
||||
writeJSONSafe(path.join(chatsDir, `${id}.json`), chat);
|
||||
return { chat };
|
||||
});
|
||||
|
||||
// IPC: delete chat
|
||||
ctx.registerIPC(`${pluginId}:delete-chat`, async (_e, { id }) => {
|
||||
try { fs.unlinkSync(path.join(chatsDir, `${id}.json`)); return { ok: true }; } catch (e) { return { ok: false, error: String(e) }; }
|
||||
});
|
||||
|
||||
// IPC: update settings
|
||||
ctx.registerIPC(`${pluginId}:get-settings`, async () => ({ settings: loadSettings() }));
|
||||
ctx.registerIPC(`${pluginId}:set-settings`, async (_e, s) => {
|
||||
// Enforce fixed model regardless of input
|
||||
const next = { ...s, model: 'gpt-oss:20b' };
|
||||
saveSettings(next);
|
||||
return { settings: loadSettings() };
|
||||
});
|
||||
|
||||
// IPC: append user message and request model completion (streamed)
|
||||
// Renderer will send: { id, content }
|
||||
// We append the user message to the chat file, then call Ollama chat API with full history.
|
||||
ctx.registerIPC(`${pluginId}:send`, async (event, { id, content }) => {
|
||||
const p = path.join(chatsDir, `${id}.json`);
|
||||
const chat = readJSONSafe(p, null);
|
||||
if (!chat) return { error: 'not_found' };
|
||||
|
||||
chat.messages.push({ role: 'user', content, timestamp: Date.now() });
|
||||
chat.updatedAt = Date.now();
|
||||
writeJSONSafe(p, chat);
|
||||
|
||||
// Build payload for Ollama
|
||||
const { ollamaBaseUrl, systemPrompt } = loadSettings();
|
||||
const model = 'gpt-oss:20b';
|
||||
const fixedIdentity = 'System: You are Nebot, a plugin running inside the Nebula browser. Adopt a helpful, engaging tone. Describe your answers clearly and briefly explain your reasoning when useful. Use concise formatting and small examples. Avoid unsafe content and be honest about limitations.';
|
||||
const messages = [ { role: 'system', content: fixedIdentity } ];
|
||||
if (systemPrompt) messages.push({ role: 'system', content: systemPrompt });
|
||||
for (const m of chat.messages) messages.push({ role: m.role, content: m.content });
|
||||
|
||||
// Stream back tokens to the same renderer that invoked this call
|
||||
const senderWebContents = (event && (event.sender?.hostWebContents || event.sender)) || ctx.BrowserWindow.getFocusedWindow()?.webContents;
|
||||
const channel = `${pluginId}:stream:${id}`;
|
||||
|
||||
// Use global fetch available in recent Electron or node:http as fallback
|
||||
const url = `${ollamaBaseUrl.replace(/\/$/, '')}/api/chat`;
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model, messages, stream: true })
|
||||
});
|
||||
} catch (e) {
|
||||
ctx.error('Failed to reach Ollama', e);
|
||||
try { senderWebContents?.send(channel, { type: 'error', message: 'Failed to reach Ollama server' }); } catch {}
|
||||
return { error: 'network' };
|
||||
}
|
||||
|
||||
if (!resp.ok || !resp.body) {
|
||||
try { senderWebContents?.send(channel, { type: 'error', message: `Bad response: ${resp.status}` }); } catch {}
|
||||
return { error: `bad_response:${resp.status}` };
|
||||
}
|
||||
|
||||
// Stream NDJSON lines with proper boundary handling; treat message.content/response as delta tokens
|
||||
const reader = resp.body.getReader();
|
||||
let assistant = '';
|
||||
let buf = '';
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += Buffer.from(value).toString('utf8');
|
||||
while (true) {
|
||||
const idx = buf.indexOf('\n');
|
||||
if (idx === -1) break;
|
||||
const line = buf.slice(0, idx).trim();
|
||||
buf = buf.slice(idx + 1);
|
||||
if (!line) continue;
|
||||
try {
|
||||
const j = JSON.parse(line);
|
||||
if (j.done) {
|
||||
// Some servers send a final done object
|
||||
try { senderWebContents?.send(channel, { type: 'done' }); } catch {}
|
||||
continue;
|
||||
}
|
||||
let delta = '';
|
||||
if (j && j.message && typeof j.message.content === 'string') {
|
||||
delta = j.message.content; // chat endpoint streams deltas
|
||||
assistant += delta;
|
||||
} else if (typeof j.response === 'string') {
|
||||
delta = j.response; // generate endpoint style
|
||||
assistant += delta;
|
||||
}
|
||||
if (delta) {
|
||||
try { senderWebContents?.send(channel, { type: 'token', token: delta }); } catch {}
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore malformed partials
|
||||
}
|
||||
}
|
||||
}
|
||||
// flush leftover (may be a final JSON object without trailing newline)
|
||||
const line = buf.trim();
|
||||
if (line) {
|
||||
try {
|
||||
const j = JSON.parse(line);
|
||||
if (!j.done) {
|
||||
let delta = '';
|
||||
if (j && j.message && typeof j.message.content === 'string') {
|
||||
delta = j.message.content; assistant += delta;
|
||||
} else if (typeof j.response === 'string') {
|
||||
delta = j.response; assistant += delta;
|
||||
}
|
||||
if (delta) {
|
||||
try { senderWebContents?.send(channel, { type: 'token', token: delta }); } catch {}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
} catch (e) {
|
||||
ctx.warn('stream interrupted', e);
|
||||
}
|
||||
|
||||
// Persist assistant message
|
||||
const persisted = readJSONSafe(p, chat);
|
||||
persisted.messages.push({ role: 'assistant', content: assistant, timestamp: Date.now() });
|
||||
persisted.updatedAt = Date.now();
|
||||
writeJSONSafe(p, persisted);
|
||||
|
||||
try { senderWebContents?.send(channel, { type: 'done' }); } catch {}
|
||||
// Fire-and-forget title generation if this is the first assistant response
|
||||
try { generateTitleIfNeeded(senderWebContents, p); } catch {}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// Add Help menu toggle
|
||||
try {
|
||||
const template = ctx.Menu.getApplicationMenu()?.items?.map(mi => mi);
|
||||
if (template) {
|
||||
const help = template.find(i => /help/i.test(i.label || ''));
|
||||
const insertInto = help || template[template.length - 1];
|
||||
if (insertInto && insertInto.submenu) {
|
||||
insertInto.submenu.append(new ctx.Menu.MenuItem({
|
||||
label: 'Toggle Nebot',
|
||||
click: () => {
|
||||
const win = ctx.BrowserWindow.getFocusedWindow();
|
||||
if (win) win.webContents.send(`${pluginId}:toggle`);
|
||||
}
|
||||
}));
|
||||
ctx.Menu.setApplicationMenu(ctx.Menu.getApplicationMenu());
|
||||
}
|
||||
}
|
||||
} catch (e) { ctx.warn('menu injection skipped', e); }
|
||||
|
||||
// Bounce renderer-triggered toggles back to the same sender
|
||||
try {
|
||||
ctx.ipcMain.on(`${pluginId}:toggle`, (e) => {
|
||||
try { (e.sender.hostWebContents || e.sender).send(`${pluginId}:toggle`); } catch {}
|
||||
});
|
||||
} catch {}
|
||||
|
||||
// Contribute to right-click context menu
|
||||
try {
|
||||
ctx.contributeContextMenu?.((template, params, sender) => {
|
||||
try { template.push({ type: 'separator' }); } catch {}
|
||||
template.push({
|
||||
label: 'Toggle Nebot',
|
||||
click: () => {
|
||||
try { (sender.hostWebContents || sender).send(`${pluginId}:toggle`); } catch {}
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (e) { ctx.warn('context menu contrib failed', e); }
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "ollama-chat",
|
||||
"name": "Nebot",
|
||||
"version": "0.1.0",
|
||||
"description": "Nebot: a floating chat panel that talks to a local/remote Ollama server and saves chats in the plugin folder.",
|
||||
"main": "main.js",
|
||||
"rendererPreload": "renderer-preload.js",
|
||||
"enabled": true
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
// Renderer preload for Nebot plugin
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
// Markdown rendering & sanitization
|
||||
let marked, hljs, createDOMPurify, DOMPurify;
|
||||
try {
|
||||
// These will be available after adding dependencies to package.json
|
||||
marked = require('marked');
|
||||
hljs = require('highlight.js');
|
||||
createDOMPurify = require('dompurify');
|
||||
DOMPurify = createDOMPurify(window);
|
||||
marked.setOptions({
|
||||
breaks: true,
|
||||
highlight(code, lang) {
|
||||
try {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
return hljs.highlight(code, { language: lang }).value;
|
||||
}
|
||||
} catch {}
|
||||
try {
|
||||
return hljs.highlightAuto(code).value;
|
||||
} catch { return code; }
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
// If libs aren't available yet, we'll gracefully render as plain text.
|
||||
}
|
||||
|
||||
const pluginId = 'ollama-chat';
|
||||
|
||||
// Expose minimal API for page scripts (optional)
|
||||
contextBridge.exposeInMainWorld('ollamaChat', {
|
||||
toggle: () => ipcRenderer.send(`${pluginId}:toggle`),
|
||||
listChats: () => ipcRenderer.invoke(`${pluginId}:list-chats`),
|
||||
getChat: (id) => ipcRenderer.invoke(`${pluginId}:get-chat`, { id }),
|
||||
createChat: (title) => ipcRenderer.invoke(`${pluginId}:create-chat`, { title }),
|
||||
deleteChat: (id) => ipcRenderer.invoke(`${pluginId}:delete-chat`, { id }),
|
||||
getSettings: () => ipcRenderer.invoke(`${pluginId}:get-settings`),
|
||||
setSettings: (s) => ipcRenderer.invoke(`${pluginId}:set-settings`, s),
|
||||
send: (id, content) => ipcRenderer.invoke(`${pluginId}:send`, { id, content }),
|
||||
});
|
||||
|
||||
// UI Injection: floating panel
|
||||
function ensureStyles() {
|
||||
if (document.getElementById(`${pluginId}-styles`)) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = `${pluginId}-styles`;
|
||||
style.textContent = `
|
||||
.${pluginId}-panel { position: fixed; background:
|
||||
linear-gradient(180deg, rgba(22,25,37,0.8), rgba(16,18,26,0.82)) padding-box,
|
||||
linear-gradient(135deg, rgba(140,86,255,0.22), rgba(62,149,255,0.18)) border-box;
|
||||
color: var(--text, #e8e8f0); border: 1px solid transparent; display: flex; flex-direction: column; overflow: hidden; z-index: 999999; position: fixed; overscroll-behavior: contain;
|
||||
-webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px); box-shadow: var(--shadow-1, 0 6px 20px rgba(0,0,0,.35)); }
|
||||
.${pluginId}-panel.floating { right: 16px; bottom: 16px; width: var(--ollama-chat-width, 460px); height: 70vh; max-height: 92vh; border-radius: var(--radius-lg, 16px); }
|
||||
.${pluginId}-panel.docked { right: 0; top: var(--nebula-header-height, 0px); bottom: 0; width: var(--ollama-chat-width, 460px); height: calc(100vh - var(--nebula-header-height, 0px)); border-left: 1px solid rgba(255,255,255,0.06); border-radius: 0; box-shadow: none; }
|
||||
.${pluginId}-resizer { position: absolute; left: 0; top: 0; bottom: 0; width: 8px; cursor: ew-resize; background: linear-gradient(90deg, rgba(255,255,255,0.06), rgba(255,255,255,0)); opacity: 0.25; }
|
||||
.${pluginId}-resizer:hover { opacity: 0.5; }
|
||||
.${pluginId}-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; background:
|
||||
linear-gradient(180deg, rgba(24,26,36,0.7), rgba(24,26,36,0.62)); border-bottom: 1px solid rgba(255,255,255,0.06); font-weight: 600; }
|
||||
.${pluginId}-btn { background: var(--accent, #7b61ff); color: #fff; border: 1px solid transparent; padding: 6px 10px; border-radius: 8px; cursor: pointer; box-shadow: inset 0 1px 0 rgba(255,255,255,0.05); }
|
||||
.${pluginId}-btn:hover { filter: brightness(1.05); }
|
||||
.${pluginId}-btn:active { transform: translateY(1px); }
|
||||
.${pluginId}-btn.secondary { background: rgba(255,255,255,0.12); border-color: rgba(255,255,255,0.14); color: var(--text, #e8e8f0); }
|
||||
.${pluginId}-body { display: grid; grid-template-columns: 260px 1fr; flex: 1 1 auto; min-height: 0; height: auto; }
|
||||
.${pluginId}-sidebar { border-right: 1px solid rgba(255,255,255,0.06); overflow: auto; background: rgba(0,0,0,0.08); min-height: 0; }
|
||||
.${pluginId}-chatlist { list-style: none; margin: 0; padding: 8px; }
|
||||
.${pluginId}-chatlist li { display: flex; align-items: center; gap: 8px; padding: 10px 10px; cursor: pointer; border: 1px solid rgba(255,255,255,0.06); border-radius: 12px; margin-bottom: 8px; background: rgba(255,255,255,0.03); }
|
||||
.${pluginId}-chatlist li:hover { background: rgba(255,255,255,0.06); }
|
||||
.${pluginId}-chatlist li.active { background: rgba(123,97,255,0.16); border-color: rgba(123,97,255,0.38); }
|
||||
.${pluginId}-chat-item-main { display: flex; flex-direction: column; gap: 2px; flex: 1 1 auto; min-width: 0; }
|
||||
.${pluginId}-chat-title { font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.${pluginId}-chat-meta { font-size: 11px; color: var(--muted, #a4a7b3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.${pluginId}-chat-actions { display: flex; align-items: center; gap: 4px; }
|
||||
.${pluginId}-icon-btn { background: transparent; color: var(--text, #e8e8f0); border: 1px solid rgba(255,255,255,0.14); width: 28px; height: 28px; border-radius: 8px; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; }
|
||||
.${pluginId}-icon-btn:hover { background: rgba(255,255,255,0.12); }
|
||||
.${pluginId}-main { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; }
|
||||
.${pluginId}-msgs { flex: 1 1 auto; overflow: auto; padding: 14px 12px; scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.25) transparent; min-height: 0; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; touch-action: pan-y; }
|
||||
.${pluginId}-msgs::-webkit-scrollbar { width: 10px; }
|
||||
.${pluginId}-msgs::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.22); border-radius: 10px; }
|
||||
.${pluginId}-msgs::-webkit-scrollbar-track { background: transparent; }
|
||||
.${pluginId}-msg { margin: 8px 0; padding: 10px 12px; border-radius: 12px; max-width: 88%; line-height: 1.5; }
|
||||
.${pluginId}-msg.user { background:
|
||||
linear-gradient(180deg, rgba(36,40,66,0.8), rgba(28,32,52,0.78)); border: 1px solid rgba(123,97,255,0.28); align-self: flex-end; }
|
||||
.${pluginId}-msg.assistant { background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.08); align-self: flex-start; }
|
||||
/* Rich content styles */
|
||||
.${pluginId}-msg * { color: inherit; }
|
||||
.${pluginId}-msg p { margin: 0.4em 0; }
|
||||
.${pluginId}-msg h1, .${pluginId}-msg h2, .${pluginId}-msg h3 { margin: 0.6em 0 0.3em; font-weight: 700; }
|
||||
.${pluginId}-msg ul, .${pluginId}-msg ol { padding-left: 1.2em; margin: 0.4em 0; }
|
||||
.${pluginId}-msg blockquote { margin: 0.6em 0; padding: 0.4em 0.8em; border-left: 3px solid rgba(255,255,255,0.25); background: rgba(255,255,255,0.05); border-radius: 8px; }
|
||||
.${pluginId}-msg code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; background: rgba(0,0,0,0.35); padding: 0.15em 0.35em; border-radius: 6px; }
|
||||
.${pluginId}-msg pre { background: rgba(0,0,0,0.4); padding: 10px; border-radius: 10px; overflow: auto; border: 1px solid rgba(255,255,255,0.08); }
|
||||
.${pluginId}-msg pre code { background: transparent; padding: 0; }
|
||||
/* Minimal highlight colors aligned to theme */
|
||||
.${pluginId}-msg .hljs { color: var(--text, #e8e8f0); }
|
||||
.${pluginId}-msg .hljs-keyword, .${pluginId}-msg .hljs-selector-tag { color: #c792ea; }
|
||||
.${pluginId}-msg .hljs-string, .${pluginId}-msg .hljs-attr { color: #ecc48d; }
|
||||
.${pluginId}-msg .hljs-number, .${pluginId}-msg .hljs-literal { color: #f78c6c; }
|
||||
.${pluginId}-msg .hljs-comment { color: #7f848e; }
|
||||
.${pluginId}-composer { display: flex; gap: 8px; padding: 10px; border-top: 1px solid rgba(255,255,255,0.06); background: rgba(0,0,0,0.06); }
|
||||
.${pluginId}-composer textarea { flex: 1; resize: vertical; min-height: 44px; max-height: 140px; background: rgba(0,0,0,0.28); color: var(--text, #e8e8f0); border: 1px solid rgba(255,255,255,0.12); border-radius: 10px; padding: 10px 12px; outline: none; }
|
||||
.${pluginId}-composer textarea:focus { border-color: rgba(123,97,255,0.45); box-shadow: 0 0 0 3px rgba(123,97,255,0.18); }
|
||||
.${pluginId}-footer { display: flex; gap: 6px; align-items: center; padding: 8px 10px; background: rgba(0,0,0,0.08); border-top: 1px solid rgba(255,255,255,0.06); color: var(--muted, #a4a7b3); font-size: 12px; }
|
||||
/* Shrink main page content when docked panel is open */
|
||||
#webviews { width: calc(100% - var(--ollama-right-offset, 0px)) !important; }
|
||||
#home-container { width: calc(100% - var(--ollama-right-offset, 0px)) !important; }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function h(tag, attrs = {}, ...children) {
|
||||
const el = document.createElement(tag);
|
||||
for (const [k, v] of Object.entries(attrs)) {
|
||||
if (k === 'class') el.className = v;
|
||||
else if (k === 'onclick') el.addEventListener('click', v);
|
||||
else el.setAttribute(k, v);
|
||||
}
|
||||
for (const c of children) {
|
||||
if (c == null) continue;
|
||||
el.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
let state = { chats: [], currentId: null, streaming: false, docked: true, width: 0 };
|
||||
let els = {};
|
||||
|
||||
function getSavedWidth() {
|
||||
const v = Number(localStorage.getItem(`${pluginId}:width`) || '0');
|
||||
return Number.isFinite(v) && v >= 300 ? v : 460;
|
||||
}
|
||||
|
||||
function saveWidth(w) {
|
||||
try { localStorage.setItem(`${pluginId}:width`, String(w)); } catch {}
|
||||
}
|
||||
|
||||
function applyWidth(root, w) {
|
||||
const min = 320, max = 1024;
|
||||
const clamped = Math.max(min, Math.min(max, Math.round(w)));
|
||||
state.width = clamped;
|
||||
root.style.setProperty('--ollama-chat-width', `${clamped}px`);
|
||||
setPageOffset(root);
|
||||
}
|
||||
|
||||
function initResizer(root) {
|
||||
const handle = h('div', { class: `${pluginId}-resizer` });
|
||||
root.appendChild(handle);
|
||||
let startX = 0, startW = 0, moving = false;
|
||||
const onMove = (e) => {
|
||||
if (!moving) return;
|
||||
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
|
||||
const deltaX = clientX - startX;
|
||||
const next = startW - deltaX; // anchored to right, dragging left increases width
|
||||
applyWidth(root, next);
|
||||
};
|
||||
const onUp = () => {
|
||||
if (!moving) return;
|
||||
moving = false;
|
||||
document.body.style.userSelect = '';
|
||||
saveWidth(state.width);
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
window.removeEventListener('touchmove', onMove);
|
||||
window.removeEventListener('touchend', onUp);
|
||||
};
|
||||
const onDown = (e) => {
|
||||
e.preventDefault();
|
||||
const rect = root.getBoundingClientRect();
|
||||
startW = rect.width;
|
||||
startX = e.touches ? e.touches[0].clientX : e.clientX;
|
||||
moving = true;
|
||||
document.body.style.userSelect = 'none';
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
window.addEventListener('touchmove', onMove, { passive: false });
|
||||
window.addEventListener('touchend', onUp);
|
||||
};
|
||||
handle.addEventListener('mousedown', onDown);
|
||||
handle.addEventListener('touchstart', onDown, { passive: false });
|
||||
}
|
||||
|
||||
function setPageOffset(root) {
|
||||
try {
|
||||
// Only offset when docked so the page remains fully visible behind the panel
|
||||
const px = (state.docked && root && document.body.contains(root)) ? state.width : 0;
|
||||
document.documentElement.style.setProperty('--ollama-right-offset', `${px}px`);
|
||||
// Force a reflow so <webview> and layout pick up the width change immediately
|
||||
// by reading offsetWidth of an affected element.
|
||||
const target = document.getElementById('webviews') || document.getElementById('home-container');
|
||||
if (target) void target.offsetWidth; // reflow hint
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function closePanel(root) {
|
||||
setTimeout(() => {
|
||||
try { document.documentElement.style.setProperty('--ollama-right-offset', '0px'); } catch {}
|
||||
}, 0);
|
||||
root.remove();
|
||||
}
|
||||
|
||||
function mdToHtml(md) {
|
||||
// Fall back to simple escape if libs not present
|
||||
if (!marked || !DOMPurify) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = md;
|
||||
return div.innerHTML;
|
||||
}
|
||||
const raw = marked.parse(md || '');
|
||||
const clean = DOMPurify.sanitize(raw, { ADD_ATTR: ['target', 'rel', 'class'] });
|
||||
return clean;
|
||||
}
|
||||
|
||||
function setRichContent(el, md) {
|
||||
el.innerHTML = mdToHtml(md);
|
||||
// Enhance links to open in new tab and be safe
|
||||
el.querySelectorAll('a[href]').forEach(a => {
|
||||
a.setAttribute('target', '_blank');
|
||||
a.setAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshList() {
|
||||
const { chats } = await ipcRenderer.invoke(`${pluginId}:list-chats`);
|
||||
state.chats = chats || [];
|
||||
renderList();
|
||||
}
|
||||
|
||||
async function openChat(id) {
|
||||
state.currentId = id;
|
||||
const { chat, error } = await ipcRenderer.invoke(`${pluginId}:get-chat`, { id });
|
||||
if (error) return;
|
||||
renderMessages(chat);
|
||||
renderList();
|
||||
subscribeStream(id);
|
||||
}
|
||||
|
||||
async function newChat() {
|
||||
const { chat } = await ipcRenderer.invoke(`${pluginId}:create-chat`, { title: 'Chat ' + new Date().toLocaleTimeString() });
|
||||
await refreshList();
|
||||
await openChat(chat.id);
|
||||
}
|
||||
|
||||
async function deleteChat(id) {
|
||||
await ipcRenderer.invoke(`${pluginId}:delete-chat`, { id });
|
||||
await refreshList();
|
||||
if (state.currentId === id) {
|
||||
state.currentId = state.chats[0]?.id || null;
|
||||
if (state.currentId) openChat(state.currentId); else renderMessages(null);
|
||||
}
|
||||
}
|
||||
|
||||
function subscribeStream(id) {
|
||||
// Remove previous
|
||||
ipcRenderer.removeAllListeners(`${pluginId}:stream:${id}`);
|
||||
let buffer = '';
|
||||
let scheduled = null;
|
||||
const scheduleRender = () => {
|
||||
if (scheduled) return;
|
||||
scheduled = requestAnimationFrame(() => {
|
||||
const last = els.msgs && els.msgs.querySelector('.streaming');
|
||||
if (last) setRichContent(last, buffer);
|
||||
scheduled = null;
|
||||
});
|
||||
};
|
||||
ipcRenderer.on(`${pluginId}:stream:${id}`, (_e, payload) => {
|
||||
if (!els.msgs) return;
|
||||
if (payload.type === 'token') {
|
||||
let last = els.msgs.querySelector('.streaming');
|
||||
if (!last) {
|
||||
last = h('div', { class: `${pluginId}-msg assistant streaming` });
|
||||
els.msgs.appendChild(last);
|
||||
buffer = '';
|
||||
}
|
||||
buffer += payload.token || '';
|
||||
scheduleRender();
|
||||
els.msgs.scrollTop = els.msgs.scrollHeight;
|
||||
} else if (payload.type === 'done') {
|
||||
const last = els.msgs.querySelector('.streaming');
|
||||
if (last) {
|
||||
setRichContent(last, buffer);
|
||||
last.classList.remove('streaming');
|
||||
}
|
||||
buffer = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
if (!els.chatlist) return;
|
||||
els.chatlist.innerHTML = '';
|
||||
for (const c of state.chats) {
|
||||
const li = h('li', { class: state.currentId === c.id ? 'active' : '', onclick: () => openChat(c.id) });
|
||||
const updated = new Date(c.updatedAt || Date.now()).toLocaleString();
|
||||
const main = h('div', { class: `${pluginId}-chat-item-main` },
|
||||
h('div', { class: `${pluginId}-chat-title` }, c.title || 'Untitled Chat'),
|
||||
h('div', { class: `${pluginId}-chat-meta` }, updated)
|
||||
);
|
||||
const actions = h('div', { class: `${pluginId}-chat-actions` });
|
||||
const del = h('button', { class: `${pluginId}-icon-btn`, title: 'Delete chat', onclick: (e) => { e.stopPropagation(); deleteChat(c.id); } }, '🗑');
|
||||
actions.appendChild(del);
|
||||
li.appendChild(main);
|
||||
li.appendChild(actions);
|
||||
els.chatlist.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
function renderMessages(chat) {
|
||||
if (!els.msgs) return;
|
||||
els.msgs.innerHTML = '';
|
||||
if (!chat) return;
|
||||
for (const m of chat.messages) {
|
||||
const div = h('div', { class: `${pluginId}-msg ${m.role}` });
|
||||
setRichContent(div, m.content);
|
||||
els.msgs.appendChild(div);
|
||||
}
|
||||
els.msgs.scrollTop = els.msgs.scrollHeight;
|
||||
}
|
||||
|
||||
async function sendCurrent() {
|
||||
const content = els.input.value.trim();
|
||||
if (!content) return;
|
||||
// If no chat selected, create one on first send
|
||||
if (!state.currentId) {
|
||||
const { chat } = await ipcRenderer.invoke(`${pluginId}:create-chat`, { title: 'New chat' });
|
||||
await refreshList();
|
||||
state.currentId = chat.id;
|
||||
await openChat(state.currentId);
|
||||
}
|
||||
els.input.value = '';
|
||||
// echo user message into UI immediately
|
||||
const userDiv = h('div', { class: `${pluginId}-msg user` });
|
||||
// Render user content as plain text to avoid accidental HTML
|
||||
userDiv.textContent = content;
|
||||
els.msgs.appendChild(userDiv);
|
||||
els.msgs.scrollTop = els.msgs.scrollHeight;
|
||||
await ipcRenderer.invoke(`${pluginId}:send`, { id: state.currentId, content });
|
||||
}
|
||||
|
||||
function setDockClass(root) {
|
||||
root.classList.remove('floating', 'docked');
|
||||
root.classList.add(state.docked ? 'docked' : 'floating');
|
||||
}
|
||||
|
||||
function toggleDock(root) {
|
||||
state.docked = !state.docked;
|
||||
setDockClass(root);
|
||||
if (els.dockBtn) els.dockBtn.textContent = state.docked ? 'Undock' : 'Dock';
|
||||
setPageOffset(root);
|
||||
applyHeaderOffset();
|
||||
}
|
||||
|
||||
function panelEl() {
|
||||
ensureStyles();
|
||||
applyHeaderOffset();
|
||||
let root = document.getElementById(`${pluginId}-panel`);
|
||||
if (root) return root;
|
||||
state.width = getSavedWidth();
|
||||
root = h('div', { id: `${pluginId}-panel`, class: `${pluginId}-panel ${state.docked ? 'docked' : 'floating'}` },
|
||||
h('div', { class: `${pluginId}-header` },
|
||||
h('span', {}, 'Nebot'),
|
||||
h('div', {},
|
||||
h('button', { class: `${pluginId}-btn secondary`, onclick: () => closePanel(root) }, 'Close')
|
||||
)
|
||||
),
|
||||
h('div', { class: `${pluginId}-body` },
|
||||
h('div', { class: `${pluginId}-sidebar` },
|
||||
h('div', { style: 'padding:6px;' },
|
||||
h('button', { class: `${pluginId}-btn`, onclick: newChat }, 'New chat')
|
||||
),
|
||||
els.chatlist = h('ul', { class: `${pluginId}-chatlist` })
|
||||
),
|
||||
h('div', { class: `${pluginId}-main` },
|
||||
els.msgs = h('div', { class: `${pluginId}-msgs` }),
|
||||
h('div', { class: `${pluginId}-composer` },
|
||||
els.input = h('textarea', { placeholder: 'Type a message to start a new chat…' }),
|
||||
h('button', { class: `${pluginId}-btn`, onclick: sendCurrent }, 'Send')
|
||||
),
|
||||
h('div', { class: `${pluginId}-footer` },
|
||||
h('small', {}, 'Messages are stored locally in the plugin folder.')
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
document.body.appendChild(root);
|
||||
// Route assistant links to open in a new browser tab via host
|
||||
const routeToNewTab = (url) => {
|
||||
try {
|
||||
// Prefer direct sendToHost when available
|
||||
ipcRenderer.sendToHost('navigate', url, { newTab: true });
|
||||
} catch {
|
||||
try {
|
||||
if (window.parent && typeof window.parent.postMessage === 'function') {
|
||||
window.parent.postMessage({ type: 'navigate', url, newTab: true }, '*');
|
||||
} else {
|
||||
window.open(url, '_blank', 'noopener');
|
||||
}
|
||||
} catch {
|
||||
window.open(url, '_blank', 'noopener');
|
||||
}
|
||||
}
|
||||
};
|
||||
// Delegate clicks from within messages area
|
||||
els.msgs.addEventListener('click', (e) => {
|
||||
const a = e.target && e.target.closest ? e.target.closest('a[href]') : null;
|
||||
if (!a) return;
|
||||
const href = a.href || a.getAttribute('href');
|
||||
if (!href) return;
|
||||
// Only intercept http(s) links for in-browser tabs
|
||||
if (/^https?:\/\//i.test(href)) {
|
||||
e.preventDefault();
|
||||
routeToNewTab(href);
|
||||
}
|
||||
});
|
||||
// Middle-click support (auxclick)
|
||||
els.msgs.addEventListener('auxclick', (e) => {
|
||||
if (e.button !== 1) return;
|
||||
const a = e.target && e.target.closest ? e.target.closest('a[href]') : null;
|
||||
if (!a) return;
|
||||
const href = a.href || a.getAttribute('href');
|
||||
if (!href) return;
|
||||
if (/^https?:\/\//i.test(href)) {
|
||||
e.preventDefault();
|
||||
routeToNewTab(href);
|
||||
}
|
||||
});
|
||||
applyWidth(root, state.width);
|
||||
initResizer(root);
|
||||
refreshList().then(() => state.chats[0] && openChat(state.chats[0].id));
|
||||
return root;
|
||||
}
|
||||
|
||||
async function openSettings() {
|
||||
const { settings } = await ipcRenderer.invoke(`${pluginId}:get-settings`);
|
||||
const base = prompt('Ollama base URL', settings.ollamaBaseUrl || 'http://192.168.1.132:11434');
|
||||
if (base == null) return;
|
||||
// Model is fixed; show message for clarity
|
||||
alert('Model is fixed to gpt-oss:20b');
|
||||
const systemPrompt = prompt('System prompt', settings.systemPrompt || 'You are a helpful assistant inside the Nebula browser.');
|
||||
await ipcRenderer.invoke(`${pluginId}:set-settings`, { ollamaBaseUrl: base, systemPrompt });
|
||||
}
|
||||
|
||||
// Listen for toggle from main menu
|
||||
ipcRenderer.on(`${pluginId}:toggle`, () => {
|
||||
const existing = document.getElementById(`${pluginId}-panel`);
|
||||
if (existing) closePanel(existing); else panelEl();
|
||||
});
|
||||
|
||||
// When main updates a chat (e.g., after auto-title), refresh the list and keep selection
|
||||
ipcRenderer.on('ollama-chat:chat-updated', (_e, { id, title }) => {
|
||||
if (!state.chats.length) return;
|
||||
const item = state.chats.find(c => c.id === id);
|
||||
if (item) item.title = title;
|
||||
renderList();
|
||||
});
|
||||
|
||||
// Also expose a global keyboard shortcut inside renderer (optional, light)
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'o') {
|
||||
e.preventDefault();
|
||||
const existing = document.getElementById(`${pluginId}-panel`);
|
||||
if (existing) existing.remove(); else panelEl();
|
||||
}
|
||||
});
|
||||
|
||||
// Compute header offset so docked panel doesn't overlap top UI
|
||||
function applyHeaderOffset() {
|
||||
try {
|
||||
const tab = document.getElementById('tab-bar');
|
||||
const nav = document.getElementById('nav');
|
||||
let h = 0;
|
||||
if (tab) h += Math.max(0, tab.getBoundingClientRect().height || 0);
|
||||
if (nav) h += Math.max(0, nav.getBoundingClientRect().height || 0);
|
||||
document.documentElement.style.setProperty('--nebula-header-height', `${Math.round(h)}px`);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
window.addEventListener('resize', applyHeaderOffset);
|
||||
window.addEventListener('resize', () => setPageOffset(document.getElementById(`${pluginId}-panel`)));
|
||||
document.addEventListener('DOMContentLoaded', applyHeaderOffset);
|
||||
// Watch for dynamic header size changes
|
||||
(() => {
|
||||
try {
|
||||
const ro = new ResizeObserver(() => applyHeaderOffset());
|
||||
const tab = document.getElementById('tab-bar');
|
||||
const nav = document.getElementById('nav');
|
||||
if (tab) ro.observe(tab);
|
||||
if (nav) ro.observe(nav);
|
||||
} catch {}
|
||||
})();
|
||||
Reference in New Issue
Block a user