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 {}
|
||||
})();
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user