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:
2025-09-11 20:42:43 +12:00
parent 0a26ecccd5
commit 71462d83de
11 changed files with 731 additions and 845 deletions
-291
View File
@@ -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); }
};
-14
View File
@@ -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
}
-488
View File
@@ -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 {}
})();
+85
View File
@@ -0,0 +1,85 @@
// Return YouTube Dislike - main process side
// Provides an IPC endpoint to fetch dislike data, bypassing page CSP.
// Also injects the renderer script into YouTube pages in webviews.
const fs = require('fs');
const path = require('path');
module.exports.activate = function(ctx) {
const CACHE_TTL_MS = 3 * 60 * 1000; // 3 minutes
const cache = new Map(); // key: videoId -> { t, data }
async function fetchVotes(videoId) {
const url = `https://returnyoutubedislikeapi.com/votes?videoId=${encodeURIComponent(videoId)}`;
let resp;
try {
resp = await fetch(url, { method: 'GET', headers: { 'Accept': 'application/json' } });
} catch (e) {
ctx.warn('[RYD] fetch failed', e);
return null;
}
if (!resp.ok) return null;
try { return await resp.json(); } catch { return null; }
}
ctx.registerIPC('return-youtube-dislike:get', async (_e, { videoId }) => {
if (!videoId || typeof videoId !== 'string') return { ok: false, error: 'bad_args' };
const now = Date.now();
const ent = cache.get(videoId);
if (ent && (now - ent.t) < CACHE_TTL_MS) {
return { ok: true, data: ent.data, cached: true };
}
const data = await fetchVotes(videoId);
if (!data) return { ok: false, error: 'fetch_failed' };
cache.set(videoId, { t: now, data });
return { ok: true, data };
});
// Load the renderer script
const rendererScriptPath = path.join(ctx.paths.pluginDir, 'renderer-preload.js');
let rendererScript = '';
try {
rendererScript = fs.readFileSync(rendererScriptPath, 'utf8');
} catch (e) {
ctx.error('[RYD] Failed to load renderer script:', e);
return;
}
// Listen for web contents creation to inject into YouTube pages
ctx.on('web-contents-created', (contents) => {
// Only inject into webviews (guest pages), not the main window
if (!contents.hostWebContents) return;
// Handle IPC messages from the injected script
contents.on('ipc-message', async (event, message) => {
if (message && message.data && message.data.channel === 'return-youtube-dislike:get') {
const { videoId, id } = message.data.args[0];
try {
const data = await fetchVotes(videoId);
if (data) {
event.reply('return-youtube-dislike:get', { ok: true, data, id });
} else {
event.reply('return-youtube-dislike:get', { ok: false, error: 'fetch_failed', id });
}
} catch (e) {
event.reply('return-youtube-dislike:get', { ok: false, error: e.message, id });
}
}
});
contents.on('dom-ready', () => {
const url = contents.getURL();
if (!url || !/^(?:.*\.)?youtube\.com$/.test(new URL(url).hostname)) return;
// Inject the script into the guest page
try {
contents.executeJavaScript(rendererScript);
ctx.log('[RYD] Injected script into YouTube page');
} catch (e) {
ctx.warn('[RYD] Failed to inject script:', e);
}
});
});
ctx.log('Return YouTube Dislike plugin activated');
};
@@ -0,0 +1,17 @@
{
"id": "return-youtube-dislike",
"name": "Return YouTube Dislike",
"version": "0.1.0",
"description": "Shows estimated dislike counts on YouTube using the Return YouTube Dislike API.",
"main": "main.js",
"categories": [
"Media",
"Utilities"
],
"authors": [
{
"name": "Nebula Team"
}
],
"enabled": true
}
@@ -0,0 +1,286 @@
// Return YouTube Dislike - injected into YouTube pages
// Injects a compact dislike counter into YouTube watch/shorts pages.
try { console.info('[RYD] script injected into', location.hostname, 'url=', location.href); } catch {}
// Minimal CSS injected once
function injectStyles() {
if (document.getElementById('ryd-styles')) return;
const style = document.createElement('style');
style.id = 'ryd-styles';
style.textContent = `
.ryd-badge { display:inline-flex; align-items:center; gap:6px; padding:4px 8px; border-radius:999px; font: 12px/1.2 -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica, Arial, sans-serif; color:#e8e8f0; background: rgba(255,255,255,0.08); border:1px solid rgba(255,255,255,0.14); }
.ryd-badge .icon { width:14px; height:14px; display:inline-block; }
.ryd-badge .count { font-weight:600; }
.ryd-muted { opacity: .65 }
.ryd-floating-wrap { pointer-events: none; }
.ryd-floating-wrap .ryd-badge { pointer-events: auto; backdrop-filter: blur(6px); background: rgba(0,0,0,0.45); border-color: rgba(255,255,255,0.18); }
.ryd-fixed-wrap { position: fixed; left: 12px; bottom: 12px; z-index: 2147483647; pointer-events: none; }
.ryd-fixed-wrap .ryd-badge { pointer-events: auto; backdrop-filter: blur(6px); background: rgba(0,0,0,0.45); border-color: rgba(255,255,255,0.18); }
`;
if (document.head) document.head.appendChild(style); else document.documentElement.appendChild(style);
}
function nfmt(n) {
try { return new Intl.NumberFormat(undefined, { notation: 'compact' }).format(n); } catch { return String(n); }
}
function invokeIPC(channel, args) {
return new Promise((resolve, reject) => {
const id = Math.random().toString(36).substr(2, 9);
const message = { type: 'message', data: { channel, args: [args], id } };
const handler = (event) => {
if (event.data && event.data.type === 'message' && event.data.data && event.data.data.id === id) {
window.removeEventListener('message', handler);
const response = event.data.data.args[0];
if (response && response.ok) {
resolve(response);
} else {
reject(new Error(response ? response.error : 'IPC failed'));
}
}
};
window.addEventListener('message', handler);
window.postMessage(message, '*');
// Timeout after 10 seconds
setTimeout(() => {
window.removeEventListener('message', handler);
reject(new Error('IPC timeout'));
}, 10000);
});
}
async function fetchRyd(videoId) {
// Try IPC first to bypass CSP
try {
const res = await invokeIPC('return-youtube-dislike:get', { videoId });
if (res && res.ok) return res.data;
} catch (e) {
console.debug('[RYD] IPC failed, falling back to fetch:', e.message);
}
// Fallback to direct fetch
try {
const url = `https://returnyoutubedislikeapi.com/votes?videoId=${encodeURIComponent(videoId)}`;
const r = await fetch(url, { cache: 'no-store' });
if (!r.ok) return null;
return await r.json();
} catch (e) {
console.debug('[RYD] Fetch failed:', e);
return null;
}
}
function getVideoIdFromUrl(u) {
try {
const url = new URL(u);
if (url.hostname.includes('youtube.com') || url.hostname === 'youtu.be') {
// watch?v=ID
if (url.pathname === '/watch') return url.searchParams.get('v');
// shorts/ID
if (url.pathname.startsWith('/shorts/')) return url.pathname.split('/')[2] || null;
// youtu.be/ID
if (url.hostname === 'youtu.be') return url.pathname.slice(1) || null;
}
} catch {}
return null;
}
function findBadgeHost() {
// Primary: watch page actions container that holds the like/share buttons
const primarySelectors = [
'ytd-watch-metadata ytd-menu-renderer #top-level-buttons-computed',
'ytd-video-primary-info-renderer #top-level-buttons-computed',
'ytd-watch-metadata #top-row #actions',
'ytd-watch-metadata #actions',
'#actions-inner'
];
for (const sel of primarySelectors) {
const n = document.querySelector(sel);
if (n) return n;
}
// Fallback: if we can find the segmented like/dislike component, place next to it
const seg = document.querySelector('ytd-segmented-like-dislike-button-renderer');
if (seg && seg.parentElement) return seg.parentElement;
// Shorts: different overlay structure
const shortsSelectors = [
'ytd-reel-player-overlay-renderer #actions',
'ytd-reel-video-renderer #actions'
];
for (const sel of shortsSelectors) {
const n = document.querySelector(sel);
if (n) return n;
}
// Shadow DOM targeted probes (open shadow roots only)
const probeShadow = (tag, innerSel) => {
try {
const nodes = document.querySelectorAll(tag);
for (const el of nodes) {
if (el && el.shadowRoot) {
const found = el.shadowRoot.querySelector(innerSel);
if (found) return found;
}
}
} catch {}
return null;
};
// Actions under menu renderer
let deep = probeShadow('ytd-menu-renderer', '#top-level-buttons-computed');
if (deep) return deep;
// Watch metadata containers
deep = probeShadow('ytd-watch-metadata', '#top-row #actions');
if (deep) return deep;
deep = probeShadow('ytd-watch-metadata', '#actions');
if (deep) return deep;
// Shorts overlay
deep = probeShadow('ytd-reel-player-overlay-renderer', '#actions');
if (deep) return deep;
return null;
}
function ensureBadge(host) {
if (!host) return null;
let slot = host.querySelector('.ryd-badge');
if (!slot) {
slot = document.createElement('span');
slot.className = 'ryd-badge ryd-muted';
slot.innerHTML = `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 15l-3.5 3.5a2 2 0 0 1-3.5-1.5V13a2 2 0 0 1 2-2h5V5a3 3 0 0 1 3-3l3 9h3a2 2 0 0 1 2 2v1a8 8 0 0 1-8 8h-3"/></svg><span class="count">—</span>`;
try { host.appendChild(slot); } catch {}
}
return slot;
}
function findPlayerOverlayHost() {
// As a fallback, attach to the player container and absolutely position the badge.
const containers = [
'#player',
'#movie_player',
'ytd-player',
'ytd-watch-flexy #player-container',
'ytd-watch-flexy #player'
];
for (const sel of containers) {
const n = document.querySelector(sel);
if (n) return n;
}
return null;
}
let lastVideoId = null;
let pending = 0;
let hostRetryTimer = null;
async function updateForCurrentUrl() {
const vid = getVideoIdFromUrl(location.href);
if (!vid) return;
if (vid !== lastVideoId) {
lastVideoId = vid;
pending++; // invalidate prior fetches
}
injectStyles();
const tryAttach = async () => {
let host = findBadgeHost();
if (!host) {
// Fallback: player overlay attachment
const player = findPlayerOverlayHost();
if (player) {
// Ensure the player can position children over video
try {
const st = player.style;
if (getComputedStyle(player).position === 'static') st.position = 'relative';
} catch {}
// Create a container to hold the floating badge in the player
let wrap = player.querySelector('.ryd-floating-wrap');
if (!wrap) {
wrap = document.createElement('div');
wrap.className = 'ryd-floating-wrap';
wrap.style.position = 'absolute';
wrap.style.left = '12px';
wrap.style.bottom = '12px';
wrap.style.zIndex = '2147483647';
player.appendChild(wrap);
}
host = wrap;
}
}
if (!host) { return false; }
try { console.debug('[RYD] attaching badge to', host.tagName || host.className || host.id || host); } catch {}
const badge = ensureBadge(host);
if (!badge) return false;
badge.classList.add('ryd-muted');
const ticket = ++pending;
const data = await fetchRyd(vid);
if (ticket !== pending || lastVideoId !== vid) return true; // outdated
if (!data) { const cnt = badge.querySelector('.count'); if (cnt) cnt.textContent = 'n/a'; return true; }
const dislikes = Number(data.dislikes || data.dislikeCount || 0);
const likes = Number(data.likes || data.likeCount || 0);
const ratio = likes + dislikes > 0 ? Math.round((dislikes / (likes + dislikes)) * 100) : 0;
const cnt = badge.querySelector('.count');
if (cnt) cnt.textContent = `${nfmt(dislikes)} 👎 (${ratio}%)`;
badge.title = `${dislikes.toLocaleString()} dislikes\n${likes.toLocaleString()} likes`;
badge.classList.remove('ryd-muted');
return true;
};
// Immediate attempt, then retry a few seconds while YouTube lays out
const okNow = await tryAttach();
if (okNow) return;
let tries = 0;
clearInterval(hostRetryTimer);
hostRetryTimer = setInterval(async () => {
tries++;
const done = await tryAttach();
if (done || tries > 60) { // up to ~30s for very slow layouts
clearInterval(hostRetryTimer);
hostRetryTimer = null;
if (!done) {
// Final fallback: fixed overlay attached to body
try {
let fixed = document.querySelector('.ryd-fixed-wrap');
if (!fixed) {
fixed = document.createElement('div');
fixed.className = 'ryd-fixed-wrap';
document.body.appendChild(fixed);
}
const badge = ensureBadge(fixed);
if (badge) {
badge.classList.add('ryd-muted');
const ticket = ++pending;
const data = await fetchRyd(vid);
if (ticket === pending && lastVideoId === vid) {
const dislikes = Number((data && (data.dislikes || data.dislikeCount)) || 0);
const likes = Number((data && (data.likes || data.likeCount)) || 0);
const ratio = likes + dislikes > 0 ? Math.round((dislikes / (likes + dislikes)) * 100) : 0;
const cnt = badge.querySelector('.count');
if (cnt) cnt.textContent = `${nfmt(dislikes)} 👎 (${ratio}%)`;
badge.title = `${dislikes.toLocaleString()} dislikes\n${likes.toLocaleString()} likes`;
badge.classList.remove('ryd-muted');
}
}
} catch {}
}
}
}, 500);
}
function observeUrlChanges() {
// Single-page app navigations
let last = location.href;
const mo = new MutationObserver(() => {
if (location.href !== last) { last = location.href; updateForCurrentUrl(); }
});
mo.observe(document, { subtree: true, childList: true });
window.addEventListener('yt-navigate-finish', updateForCurrentUrl, true);
window.addEventListener('popstate', updateForCurrentUrl, true);
window.addEventListener('yt-page-data-updated', updateForCurrentUrl, true);
}
document.addEventListener('readystatechange', () => { if (document.readyState === 'interactive') updateForCurrentUrl(); });
document.addEventListener('DOMContentLoaded', () => {
// Only act on YouTube
if (!/^(?:.*\.)?youtube\.com$/.test(location.hostname) && location.hostname !== 'youtu.be') return;
updateForCurrentUrl();
observeUrlChanges();
// Also schedule a couple of follow-up attempts after page scripts settle
setTimeout(updateForCurrentUrl, 1500);
setTimeout(updateForCurrentUrl, 3500);
});