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
+133
View File
@@ -0,0 +1,133 @@
# Nebot Typing Animation Feature
## Overview
Added a realistic typing animation to the Nebot chat interface that makes AI responses appear character by character, similar to ChatGPT and other modern AI chat interfaces.
## Features Added
### 1. **Typing Animation**
- Characters appear one by one instead of instantly
- Smooth, natural typing rhythm
- Configurable typing speed
- Blinking cursor indicator during typing
### 2. **Settings Integration**
- **Enable/Disable Toggle**: Users can turn typing animation on/off
- **Speed Control**: Adjustable from 10-200 characters per second
- **Live Preview**: Speed indicator updates in real-time
- **Persistent Settings**: Preferences are saved and restored
### 3. **Smart Behavior**
- **Queue Management**: Handles fast token streams efficiently
- **Graceful Fallback**: Falls back to instant display if disabled
- **Markdown Rendering**: Waits for typing to complete before rendering markdown
- **Auto-scroll**: Maintains scroll position during animation
## Technical Implementation
### Code Changes Made:
#### 1. **page.js** - Main Logic
```javascript
// Typing animation state
let typingQueue = [];
let isTyping = false;
let typingSpeed = 25; // milliseconds per character
let typingEnabled = true; // can be toggled in settings
function startTypingAnimation(element) {
if (isTyping || typingQueue.length === 0) return;
isTyping = true;
element.classList.add('typing');
function typeNext() {
if (typingQueue.length === 0) {
isTyping = false;
element.classList.remove('typing');
return;
}
const char = typingQueue.shift();
element.textContent += char;
els.messages.scrollTop = els.messages.scrollHeight;
setTimeout(typeNext, typingSpeed);
}
typeNext();
}
```
#### 2. **page.css** - Visual Effects
```css
/* Typing animation cursor */
.markdown.typing:after {
content: "▋";
color: var(--accent);
animation: blink 1s infinite;
margin-left: 1px;
}
@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
```
#### 3. **Settings UI** - User Controls
- Checkbox to enable/disable typing animation
- Range slider for speed control (10-200 chars/sec)
- Real-time speed display
- Proper styling for form elements
## User Experience
### Before:
- Text appeared instantly when AI responded
- No visual feedback during response generation
- Less engaging interaction
### After:
- Smooth character-by-character reveal
- Blinking cursor shows active typing
- Configurable speed for user preference
- More engaging, human-like interaction
## Usage Instructions
1. **Open Nebot**: Navigate to the Nebot page in Nebula Browser
2. **Start a Chat**: Send a message to begin conversation
3. **Watch the Animation**: AI responses will type out naturally
4. **Customize Settings**:
- Click the ⚙ Settings button
- Toggle "Enable typing animation"
- Adjust typing speed with the slider
- Save changes
## Performance Considerations
- **Efficient Queuing**: Uses character queue to handle fast token streams
- **Memory Friendly**: Minimal memory overhead
- **Responsive**: Maintains smooth UI during animation
- **Interruptible**: Can be disabled without restart
## Future Enhancements
Potential improvements could include:
- Variable speed based on punctuation (pause at periods)
- Sound effects for typing
- Different animation styles
- Per-conversation speed settings
- Typing speed based on message length
## Testing
To test the feature:
1. Start Nebula Browser (`npm start`)
2. Navigate to Nebot page
3. Send a message and observe the typing animation
4. Try different speed settings in the settings panel
5. Toggle the feature on/off to compare experiences
The typing animation enhances the user experience by making AI interactions feel more natural and engaging, similar to popular chat interfaces like ChatGPT.
+8
View File
@@ -787,6 +787,11 @@ ipcMain.handle('plugins-get-renderer-preloads', () => {
try { return pluginManager.getRendererPreloads(); } catch { return []; } try { return pluginManager.getRendererPreloads(); } catch { return []; }
}); });
// Plugins: expose registered internal pages (browser://<id>)
ipcMain.handle('plugins-get-pages', () => {
try { return pluginManager.getRendererPages(); } catch { return []; }
});
// Plugins: management IPC for settings UI // Plugins: management IPC for settings UI
ipcMain.handle('plugins-list', () => pluginManager.discoverPlugins()); ipcMain.handle('plugins-list', () => pluginManager.discoverPlugins());
ipcMain.handle('plugins-set-enabled', async (_e, { id, enabled }) => { ipcMain.handle('plugins-set-enabled', async (_e, { id, enabled }) => {
@@ -806,6 +811,9 @@ app.on('web-contents-created', (event, contents) => {
buildAndShowContextMenu(contents, params); buildAndShowContextMenu(contents, params);
}); });
// Emit to plugins
try { pluginManager.emit('web-contents-created', contents); } catch {}
// On macOS, when a page (or a <webview>) enters HTML fullscreen (e.g., YouTube video), // On macOS, when a page (or a <webview>) enters HTML fullscreen (e.g., YouTube video),
// also toggle the BrowserWindow into simple fullscreen so the content uses the whole // also toggle the BrowserWindow into simple fullscreen so the content uses the whole
// screen and macOS traffic lights/titlebar are hidden. Revert when HTML fullscreen exits. // screen and macOS traffic lights/titlebar are hidden. Revert when HTML fullscreen exits.
+19
View File
@@ -1,11 +1,13 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { pathToFileURL } = require('url');
const { app, session, Menu, ipcMain, BrowserWindow, dialog, shell } = require('electron'); const { app, session, Menu, ipcMain, BrowserWindow, dialog, shell } = require('electron');
class PluginManager { class PluginManager {
constructor() { constructor() {
this.plugins = []; // { id, dir, manifest, mod, enabled } this.plugins = []; // { id, dir, manifest, mod, enabled }
this.rendererPreloads = []; // absolute file paths this.rendererPreloads = []; // absolute file paths
this.rendererPages = []; // { id, file, pluginId }
this._listeners = { this._listeners = {
'app-ready': [], 'app-ready': [],
'window-created': [], 'window-created': [],
@@ -33,6 +35,7 @@ class PluginManager {
loadAll() { loadAll() {
this.plugins = []; this.plugins = [];
this.rendererPreloads = []; this.rendererPreloads = [];
this.rendererPages = [];
const dirs = this.getPluginDirs(); const dirs = this.getPluginDirs();
for (const root of dirs) { for (const root of dirs) {
let entries = []; let entries = [];
@@ -127,6 +130,17 @@ class PluginManager {
contributeContextMenu: (contribFn) => { contributeContextMenu: (contribFn) => {
try { manager._contextMenuContribs.push(contribFn); } catch (e) { console.error(logPrefix, 'contributeContextMenu failed', e); } try { manager._contextMenuContribs.push(contribFn); } catch (e) { console.error(logPrefix, 'contributeContextMenu failed', e); }
}, },
// Register a dedicated internal page (shown via browser://<id>)
registerRendererPage: ({ id, html }) => {
try {
if (!id || !html) return;
let fileUrl = null;
try { fileUrl = pathToFileURL(html).href; } catch {}
manager.rendererPages.push({ id, file: html, fileUrl, pluginId: plugin.id });
console.log('[Plugins] Registered page:', id, '->', html, 'fileUrl:', fileUrl);
manager.log('registered page:', id, '->', html);
} catch (e) { manager.error('registerRendererPage failed', e); }
}
}; };
} }
@@ -134,6 +148,11 @@ class PluginManager {
return Array.from(new Set(this.rendererPreloads)); return Array.from(new Set(this.rendererPreloads));
} }
getRendererPages() {
// Return a shallow copy so callers can't mutate internal array
return this.rendererPages.map(p => ({ ...p }));
}
on(evt, cb) { on(evt, cb) {
if (!this._listeners[evt]) this._listeners[evt] = []; if (!this._listeners[evt]) this._listeners[evt] = [];
this._listeners[evt].push(cb); this._listeners[evt].push(cb);
-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);
});
+59
View File
@@ -0,0 +1,59 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Nebot</title>
<link rel="stylesheet" href="../plugins/nebot/page.css" onerror="this.remove()"/>
<style>
body { margin:0; font-family: system-ui,-apple-system,Segoe UI,Roboto,sans-serif; background:#12141c; color:#e6e8ef; }
.fallback { max-width:620px; margin:60px auto; padding:32px 36px; background:#1d222e; border:1px solid rgba(255,255,255,0.08); border-radius:18px; }
.fallback h1 { margin:0 0 12px; font-size:28px; background:linear-gradient(90deg,#a48bff,#6cb6ff); -webkit-background-clip:text; color:transparent; }
.fallback p { line-height:1.55; }
.tip { background:#232b38; padding:10px 14px; border-radius:10px; font-size:13px; margin-top:18px; border:1px solid rgba(255,255,255,0.08); }
.err { color:#ff6d7d; font-weight:600; }
#mount { min-height:400px; }
</style>
</head>
<body>
<div id="mount"></div>
<script>
(async function(){
// Wait a tick so plugin preload (renderer-preload) runs and exposes window.ollamaChat
const mount = document.getElementById('mount');
function showFallback(reason){
mount.innerHTML = `<div class="fallback">`+
`<h1>Nebot</h1>`+
`<p>The Nebot plugin page could not load automatically.</p>`+
(reason?`<p class='err'>${reason}</p>`:'')+
`<div class="tip"><strong>How to fix</strong><br/>1. Ensure the Nebot plugin folder exists at plugins/nebot.<br/>2. Confirm plugin is enabled (manifest enabled: true).<br/>3. Restart the app so the plugin manager registers pages.</div>`+
`</div>`;
}
try {
// Try to fetch plugin page HTML directly
const res = await fetch('../plugins/nebot/page.html');
if(!res.ok){ showFallback('Missing page.html (status '+res.status+').'); return; }
const html = await res.text();
// Simple sandboxed injection
mount.innerHTML = html;
// The injected page expects its CSS & JS relative to itself; adjust asset paths
const fixLinks = mount.querySelectorAll('link[rel="stylesheet"], script[src]');
fixLinks.forEach(el=>{
const attr = el.tagName==='SCRIPT'?'src':'href';
if(el.getAttribute(attr) && !/plugins\/nebot\//.test(el.getAttribute(attr))){
el.setAttribute(attr,'../plugins/nebot/'+el.getAttribute(attr));
}
});
// Inject JS if not already present
if(!mount.querySelector('script[data-nebot-page]')){
const s=document.createElement('script'); s.dataset.nebotPage='1';
// Pass the current URL hash to the page script for debug mode
s.src='../plugins/nebot/page.js' + window.location.hash;
mount.appendChild(s);
}
} catch(e){
showFallback(e.message||'Unknown error');
}
})();
</script>
</body>
</html>
+124 -52
View File
@@ -52,7 +52,52 @@ urlBox.addEventListener('keydown', (e) => {
let tabs = []; let tabs = [];
let activeTabId = null; let activeTabId = null;
const allowedInternalPages = ['settings', 'home', 'downloads']; const allowedInternalPages = ['settings', 'home', 'downloads', 'nebot'];
let pluginPages = []; // { id, file, fileUrl, pluginId }
let pluginPagesReady = false;
const pendingInternalNavigations = [];
// Allow isolated worlds / plugin preloads (contextIsolation) to request opening an internal page
window.addEventListener('message', (e) => {
try {
const data = e.data;
if (!data || typeof data !== 'object') return;
if (data.type === 'open-internal-page' && typeof data.url === 'string') {
console.log('[DEBUG] Message request to open internal page:', data.url);
createTab(data.url);
}
} catch (err) {
console.warn('[DEBUG] open-internal-page handler error', err);
}
});
// Fetch plugin-provided pages (browser://<id>) once on startup
(async () => {
try {
console.log('[DEBUG] About to request plugin pages from main process...');
pluginPages = await ipcRenderer.invoke('plugins-get-pages');
console.log('[DEBUG] Loaded pluginPages:', pluginPages);
console.log('[DEBUG] allowedInternalPages before:', allowedInternalPages);
for (const p of pluginPages) {
if (p && p.id && !allowedInternalPages.includes(p.id)) {
console.log('[DEBUG] Adding plugin page to allowed list:', p.id);
allowedInternalPages.push(p.id);
}
}
console.log('[DEBUG] allowedInternalPages after:', allowedInternalPages);
} catch (e) {
console.warn('Failed to load plugin pages', e);
}
finally {
pluginPagesReady = true;
console.log('[DEBUG] Plugin pages ready, flushing', pendingInternalNavigations.length, 'pending navigations');
// Flush any queued internal navigations that occurred before readiness
while (pendingInternalNavigations.length) {
const fn = pendingInternalNavigations.shift();
try { fn(); } catch {}
}
}
})();
let bookmarks = []; let bookmarks = [];
// Efficient render scheduling to avoid redundant DOM work // Efficient render scheduling to avoid redundant DOM work
@@ -134,8 +179,14 @@ ipcRenderer.on('record-site-history', (event, url) => {
function createTab(inputUrl) { function createTab(inputUrl) {
inputUrl = inputUrl || 'browser://home'; inputUrl = inputUrl || 'browser://home';
debug('[DEBUG] createTab() inputUrl =', inputUrl); console.log('[DEBUG] createTab() inputUrl =', inputUrl);
const id = crypto.randomUUID(); const id = crypto.randomUUID();
if (inputUrl.startsWith('browser://') && !pluginPagesReady) {
// Defer creation until plugin pages known to avoid 404 race
console.log('[DEBUG] Deferring createTab until pluginPagesReady');
pendingInternalNavigations.push(() => createTab(inputUrl));
return id;
}
// Handle home page specially // Handle home page specially
if (inputUrl === 'browser://home') { if (inputUrl === 'browser://home') {
@@ -162,6 +213,7 @@ function createTab(inputUrl) {
// For all other URLs, use webview // For all other URLs, use webview
let resolvedUrl = resolveInternalUrl(inputUrl); let resolvedUrl = resolveInternalUrl(inputUrl);
console.log('[DEBUG] createTab resolvedUrl:', resolvedUrl, 'from inputUrl:', inputUrl);
// If it's a raw data: URL (image) keep as is; blob: will only resolve within its origin context (may fail) // If it's a raw data: URL (image) keep as is; blob: will only resolve within its origin context (may fail)
// For very long data URLs we could embed them in a minimal viewer page for cleaner rendering. // For very long data URLs we could embed them in a minimal viewer page for cleaner rendering.
if (resolvedUrl.startsWith('data:') && resolvedUrl.length > 4096) { if (resolvedUrl.startsWith('data:') && resolvedUrl.length > 4096) {
@@ -278,13 +330,45 @@ function createTab(inputUrl) {
scheduleRenderTabs(); scheduleRenderTabs();
} }
// Expose for plugin usage (e.g., Nebot panel "Open Page")
try { window.createTab = createTab; } catch {}
function resolveInternalUrl(url) { function resolveInternalUrl(url) {
console.log('[DEBUG] resolveInternalUrl called with:', url);
if (url.startsWith('browser://')) { if (url.startsWith('browser://')) {
const page = url.replace('browser://', ''); const page = url.replace('browser://', '');
if (allowedInternalPages.includes(page)) return `${page}.html`; console.log('[DEBUG] Extracted page:', page);
else return '404.html'; // Fast path: if user typed browser://nebot and plugin page exists, return immediately
if (page === 'nebot') {
const nebotPage = pluginPages.find(p => p.id === 'nebot');
console.log('[DEBUG] Fast path for nebot, pluginPages:', pluginPages, 'nebotPage:', nebotPage);
if (nebotPage && (nebotPage.fileUrl || nebotPage.file)) {
const resolvedFast = nebotPage.fileUrl || (nebotPage.file.startsWith('file://') ? nebotPage.file : 'file://' + nebotPage.file.replace(/\\/g,'/'));
console.log('[DEBUG] Fast path nebot resolve ->', resolvedFast);
return resolvedFast;
}
console.log('[DEBUG] No plugin page found for nebot, falling back to nebot.html');
}
console.log('[DEBUG] Checking if page in allowedInternalPages:', page, 'list:', allowedInternalPages);
if (allowedInternalPages.includes(page)) {
// Check if this page is provided by a plugin (absolute file path)
const plug = pluginPages.find(p => p.id === page);
console.log('[DEBUG] Resolving browser://' + page, 'plug:', plug);
if (plug && (plug.fileUrl || plug.file)) {
// Prefer pre-built fileUrl for correctness across platforms
const resolved = plug.fileUrl ? plug.fileUrl : (plug.file.startsWith('file://') ? plug.file : 'file://' + plug.file.replace(/\\/g,'/'));
console.log('[DEBUG] Resolved plugin page', page, '->', resolved);
return resolved;
}
// Fallback: built-in renderer copy (e.g., renderer/nebot.html)
console.log('[DEBUG] Using fallback for page:', page);
if (page === 'nebot') return 'nebot.html';
return `${page}.html`;
}
console.log('[DEBUG] Page not in allowedInternalPages, returning 404');
return '404.html';
} }
// Allow direct loading of common schemes without forcing https:// // Allow direct loading of common schemes without forcing https://
if (/^(https?:|file:|data:|blob:)/i.test(url)) return url; if (/^(https?:|file:|data:|blob:)/i.test(url)) return url;
@@ -309,21 +393,9 @@ function updateTabMetadata(id, key, value) {
} }
} }
function navigate() { function performNavigation(input, originalInputForHistory) {
const rawInput = urlBox.value.trim();
// Strip surrounding single or double quotes (common when copying paths)
let input = rawInput;
if ((input.startsWith('"') && input.endsWith('"')) || (input.startsWith("'") && input.endsWith("'"))) {
input = input.slice(1, -1);
}
// If we modified input (removed quotes), reflect it back in the UI for clarity
if (input !== rawInput) {
urlBox.value = input;
}
const tab = tabs.find(t => t.id === activeTabId); const tab = tabs.find(t => t.id === activeTabId);
if (!tab) return; if (!tab) return;
// decide if this is a search query or a URL/internal page
const hasProtocol = /^https?:\/\//i.test(input); const hasProtocol = /^https?:\/\//i.test(input);
const isFileProtocol = /^file:\/\//i.test(input); const isFileProtocol = /^file:\/\//i.test(input);
const looksLikeLocalPath = /^(?:[A-Za-z]:\\|\\\\|\/?)[^?]*\.(?:x?html?)$/i.test(input); const looksLikeLocalPath = /^(?:[A-Za-z]:\\|\\\\|\/?)[^?]*\.(?:x?html?)$/i.test(input);
@@ -331,51 +403,58 @@ function navigate() {
const isLikelyUrl = hasProtocol || input.includes('.'); const isLikelyUrl = hasProtocol || input.includes('.');
let resolved; let resolved;
if (isFileProtocol) { if (isFileProtocol) {
resolved = input; // Electron will load file:// directly in <webview> resolved = input;
} else if (looksLikeLocalPath) { } else if (looksLikeLocalPath) {
// Convert Windows or *nix style path to file:// URL let p = input.replace(/\\/g,'/');
let p = input; if (/^[A-Za-z]:\//.test(p)) resolved = 'file:///' + encodeURI(p); else if (p.startsWith('/')) resolved = 'file://' + encodeURI(p); else resolved = 'file://' + encodeURI(p);
// Expand backslashes
p = p.replace(/\\/g, '/');
// If it starts with a drive letter like C:/ ensure single leading slash
if (/^[A-Za-z]:\//.test(p)) {
resolved = 'file:///' + encodeURI(p);
} else if (p.startsWith('/')) {
resolved = 'file://' + encodeURI(p); // already absolute
} else {
// relative path relative to app root (renderer directory)
resolved = 'file://' + encodeURI(p); // fallback; treat as relative from working dir
}
} else if (!isInternal && !isLikelyUrl) { } else if (!isInternal && !isLikelyUrl) {
resolved = `https://www.google.com/search?q=${encodeURIComponent(input)}`; resolved = `https://www.google.com/search?q=${encodeURIComponent(input)}`;
} else { } else {
resolved = resolveInternalUrl(input); resolved = resolveInternalUrl(input);
} }
// If current tab is a home tab and we're navigating to a website, console.log('[DEBUG] performNavigation input:', input, 'resolved:', resolved, 'tab.isHome:', tab.isHome, 'isInternal:', isInternal);
// we need to convert it to a webview tab or create a new one
if (tab.isHome && !input.startsWith('browser://')) { if (tab.isHome && !isInternal) {
// Convert home tab to webview tab convertHomeTabToWebview(tab.id, originalInputForHistory, resolved);
convertHomeTabToWebview(tab.id, input, resolved); return;
}
// If this is a home tab and we're navigating to an internal page, convert to webview
if (tab.isHome && isInternal) {
convertHomeTabToWebview(tab.id, originalInputForHistory, resolved);
return; return;
} }
// For regular webview tabs, just navigate
const webview = document.getElementById(`tab-${activeTabId}`); const webview = document.getElementById(`tab-${activeTabId}`);
if (!webview) return; if (!webview) {
console.log('[DEBUG] No webview found for tab', activeTabId, 'creating new tab instead');
// Push to history using the original input createTab(input);
return;
}
tab.history = tab.history.slice(0, tab.historyIndex + 1); tab.history = tab.history.slice(0, tab.historyIndex + 1);
tab.history.push(input); tab.history.push(originalInputForHistory);
tab.historyIndex++; tab.historyIndex++;
tab.url = originalInputForHistory;
tab.url = input;
webview.src = resolved; webview.src = resolved;
scheduleRenderTabs(); scheduleRenderTabs();
scheduleUpdateNavButtons(); scheduleUpdateNavButtons();
} }
function navigate() {
const rawInput = urlBox.value.trim();
let input = rawInput;
if ((input.startsWith('"') && input.endsWith('"')) || (input.startsWith("'") && input.endsWith("'"))) input = input.slice(1, -1);
if (input !== rawInput) urlBox.value = input;
const isInternal = input.startsWith('browser://');
if (isInternal && !pluginPagesReady) {
const captured = input; // preserve original
pendingInternalNavigations.push(() => performNavigation(captured, captured));
return;
}
performNavigation(input, input);
}
// Keyboard shortcut: Ctrl+O (Cmd+O on mac) to open a local file // Keyboard shortcut: Ctrl+O (Cmd+O on mac) to open a local file
document.addEventListener('keydown', async (e) => { document.addEventListener('keydown', async (e) => {
const isAccel = (navigator.platform.includes('Mac') ? e.metaKey : e.ctrlKey); const isAccel = (navigator.platform.includes('Mac') ? e.metaKey : e.ctrlKey);
@@ -1126,14 +1205,7 @@ function attachCloseMenuOnInteract(el) {
el.addEventListener('focus', closeIfOpen, true); el.addEventListener('focus', closeIfOpen, true);
} }
// Attempt to load Node modules if available for context-menu actions // Use electronAPI from preload - already defined at top of file
let fs, remote;
try {
fs = require('fs');
remote = require('electron').remote;
} catch (err) {
console.warn('fs or remote modules unavailable in renderer:', err);
}
// Native context menu: delegate to main via preload API // Native context menu: delegate to main via preload API
document.addEventListener('contextmenu', (e) => { document.addEventListener('contextmenu', (e) => {