Replace Nebot plugin with Return YouTube Dislike
Removed the Nebot chat plugin and its files, and added the Return YouTube Dislike plugin with main process logic, renderer preload, and manifest. Updated plugin manager and main process to support internal plugin pages and improved plugin event handling. Minor updates to renderer and documentation.
This commit is contained in:
@@ -1,291 +0,0 @@
|
||||
// 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); }
|
||||
};
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"id": "nebot-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",
|
||||
"categories": ["AI", "Chat", "Utilities"],
|
||||
"authors": [
|
||||
{ "name": "Nebula Team", "email": "andrewzambazos@gmail.com" },
|
||||
"Bobbybear007"
|
||||
],
|
||||
"enabled": true
|
||||
}
|
||||
@@ -1,488 +0,0 @@
|
||||
// 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://homelab.andrewzambazos.com: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