Files
NebulaBrowser/plugins/nebot/main.js
T
andrew 7cd6048778 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.
2025-09-08 21:42:09 +12:00

292 lines
12 KiB
JavaScript

// 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); }
};