Merge branch 'SteamOS' of https://github.com/NebulaZMG/NebulaBrowser into SteamOS
This commit is contained in:
+16
-2
@@ -1,6 +1,12 @@
|
||||
// gpu-config.js - Comprehensive GPU configuration manager
|
||||
const { app } = require('electron');
|
||||
|
||||
function envTruthy(value) {
|
||||
if (value === undefined || value === null) return false;
|
||||
const normalized = String(value).trim().toLowerCase();
|
||||
return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on';
|
||||
}
|
||||
|
||||
class GPUConfig {
|
||||
constructor() {
|
||||
this.isGPUSupported = false;
|
||||
@@ -20,15 +26,23 @@ class GPUConfig {
|
||||
// Start with conservative settings that usually work
|
||||
this.applyConservativeSettings();
|
||||
|
||||
// On Linux/SteamOS, force disable GPU and sandbox to ensure webview stability
|
||||
if (platform === 'linux') {
|
||||
console.log('Linux detected: Disabling GPU and enforcing no-sandbox');
|
||||
const env = process.env;
|
||||
const profile = String(env.NEBULA_GPU_PROFILE || '').toLowerCase();
|
||||
const forcedSoftware = envTruthy(env.NEBULA_GPU_FORCE_SOFTWARE) || profile === 'software';
|
||||
const optInRequested = envTruthy(env.NEBULA_GPU_TWEAKS) || envTruthy(env.NEBULA_GPU_ALLOW_LINUX) || envTruthy(env.NEBULA_GPU_FORCE_GPU) || (profile && profile !== 'software') || Boolean(env.NEBULA_GPU_GL) || Boolean(env.NEBULA_GPU_EXTRA_ARGS);
|
||||
|
||||
if (forcedSoftware || !optInRequested) {
|
||||
console.log('Linux detected: Disabling GPU (no opt-in overrides present) and enforcing no-sandbox');
|
||||
app.commandLine.appendSwitch('disable-gpu');
|
||||
app.commandLine.appendSwitch('no-sandbox');
|
||||
this.fallbackApplied = true;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Linux GPU opt-in detected: leaving GPU acceleration enabled for this session');
|
||||
}
|
||||
|
||||
// Try to enable GPU features progressively
|
||||
this.tryEnableGPU();
|
||||
}
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
# Testing the Exponential Typing Animation Feature
|
||||
|
||||
## How to Test the Speed-Up
|
||||
|
||||
### 1. **Access Nebot Page**
|
||||
1. Open Nebula Browser (should be running now)
|
||||
2. Navigate to the Nebot page:
|
||||
- Look for a "Nebot" tab/button in the interface
|
||||
- Or try navigating to the URL manually if accessible
|
||||
|
||||
### 2. **Test the Exponential Typing Animation**
|
||||
1. Send a prompt that will generate a medium/long response (e.g. "Explain how transformers work in detail" or "Write a 400 word summary about the Solar System").
|
||||
2. Watch the assistant response render: it will begin at a natural speed then accelerate.
|
||||
3. Observe the speed progression:
|
||||
- Starts at normal typing speed
|
||||
- Gets progressively faster as the message continues
|
||||
- Reaches higher speeds near the end so long replies finish quickly
|
||||
- Much faster than constant speed for long messages
|
||||
|
||||
### 3. **Speed-Up Algorithm**
|
||||
- **Short messages (< 50 chars)**: Normal constant speed
|
||||
- **Long messages**: Exponential acceleration using formula:
|
||||
```
|
||||
speedMultiplier = 1 + 9 * (progress^2)
|
||||
delay = max(baseSpeed / speedMultiplier, 5ms)
|
||||
```
|
||||
- **Result**: 1x speed → 10x speed progression
|
||||
- **Minimum delay**: 5ms (prevents too-fast flashing)
|
||||
|
||||
### 4. **Console Debugging**
|
||||
Open DevTools (F12) and watch for:
|
||||
```
|
||||
[Nebot Page] Char 20/500, delay: 23.5ms
|
||||
[Nebot Page] Char 40/500, delay: 19.8ms
|
||||
[Nebot Page] Char 100/500, delay: 12.1ms
|
||||
[Nebot Page] Char 400/500, delay: 5.2ms
|
||||
[Nebot Page] Char 480/500, delay: 5.0ms (capped)
|
||||
```
|
||||
|
||||
## What You Should See
|
||||
|
||||
### ✅ **Working Correctly:**
|
||||
- **Short messages**: Natural constant typing speed
|
||||
- **Long messages**: Start normal, accelerate smoothly
|
||||
- **Very fast finish**: Last portion zips by quickly
|
||||
- **Console logs**: Show decreasing delay times
|
||||
- **Reasonable duration**: Even 500+ char messages finish in ~8 seconds
|
||||
|
||||
### ❌ **If Using Old Version:**
|
||||
- Long messages take forever (constant slow speed)
|
||||
- Tedious waiting for lengthy responses
|
||||
- No speed variation in console logs
|
||||
|
||||
## Benefits of Exponential Speed-Up
|
||||
|
||||
### **Before (Constant Speed):**
|
||||
- 500 characters @ 25ms = **12.5 seconds** ⏰
|
||||
- 1000 characters @ 25ms = **25 seconds** 😴
|
||||
- Very long AI responses become unbearable
|
||||
|
||||
### **After (Exponential Speed-Up):**
|
||||
- 500 characters = **~4-6 seconds** ⚡
|
||||
- 1000 characters = **~6-8 seconds** 🚀
|
||||
- Capped at 8 seconds max for any length
|
||||
- Short messages still feel natural
|
||||
|
||||
## Customization
|
||||
|
||||
1. **Settings Panel** (⚙ button):
|
||||
- **Toggle**: Enable/disable typing animation
|
||||
- **Base Speed**: 10-200 chars/sec (affects acceleration curve)
|
||||
- **Info**: Shows explanation of exponential feature
|
||||
|
||||
2. **Speed Setting Effect**:
|
||||
- Higher base speed = faster overall experience
|
||||
- Lower base speed = more dramatic for short messages
|
||||
- Exponential curve scales with base setting
|
||||
|
||||
## Real Usage Scenarios
|
||||
|
||||
### **Perfect For:**
|
||||
- 📝 **Code explanations** (often very long)
|
||||
- 📚 **Detailed tutorials** (hundreds of words)
|
||||
- 🔍 **Research summaries** (comprehensive responses)
|
||||
- 💬 **Conversational responses** (natural for short, fast for long)
|
||||
|
||||
### **Smart Behavior:**
|
||||
- **"Hello"** → Types normally (natural feel)
|
||||
- **100+ word explanation** → Starts normal, speeds up
|
||||
- **500+ word essay** → Accelerates significantly
|
||||
- **Any length** → Never takes more than ~8 seconds
|
||||
|
||||
The exponential speed-up makes long AI responses enjoyable to read instead of tedious to wait for!
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `plugins/nebot/page.js` - Includes `calculateTypingDelay()` adaptive timing logic
|
||||
- Settings UI - Provides toggle + base speed slider and explanatory hint
|
||||
- Previous temporary "Test Typing" debug button has been removed now that the feature is stable
|
||||
|
||||
You can validate behavior entirely through normal conversations; no special test button is required.
|
||||
@@ -1,298 +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) {
|
||||
console.log('[Nebot] Plugin activate called with ctx:', 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); }
|
||||
|
||||
// Register dedicated internal page (browser://nebot) served from plugin directory
|
||||
try {
|
||||
console.log('[Nebot] Registering page with path:', path.join(ctx.paths.pluginDir, 'page.html'));
|
||||
ctx.registerRendererPage?.({ id: 'nebot', html: path.join(ctx.paths.pluginDir, 'page.html') });
|
||||
} catch (e) { ctx.warn('page registration failed', e); }
|
||||
};
|
||||
@@ -1,66 +0,0 @@
|
||||
/* Markdown Bundle for Nebot Page */
|
||||
(function(){
|
||||
try {
|
||||
// Try to load libraries if available in Node context
|
||||
if (typeof require !== 'undefined') {
|
||||
const marked = require('marked');
|
||||
const createDOMPurify = require('dompurify');
|
||||
const { JSDOM } = require('jsdom');
|
||||
|
||||
// Create a DOM window for DOMPurify if needed
|
||||
let DOMPurify;
|
||||
if (typeof window !== 'undefined') {
|
||||
DOMPurify = createDOMPurify(window);
|
||||
} else {
|
||||
const window = new JSDOM('').window;
|
||||
DOMPurify = createDOMPurify(window);
|
||||
}
|
||||
|
||||
// Configure marked
|
||||
marked.setOptions({
|
||||
breaks: true,
|
||||
highlight: function(code, lang) {
|
||||
if (window.hljs && lang && window.hljs.getLanguage(lang)) {
|
||||
try {
|
||||
return window.hljs.highlight(code, { language: lang }).value;
|
||||
} catch (e) {}
|
||||
}
|
||||
return code;
|
||||
}
|
||||
});
|
||||
|
||||
// Expose to global scope
|
||||
window.marked = marked;
|
||||
window.DOMPurify = DOMPurify;
|
||||
|
||||
} else {
|
||||
console.warn('[Markdown Bundle] require() not available, libraries may not be loaded');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Markdown Bundle] Error loading libraries:', e);
|
||||
|
||||
// Fallback: simple markdown-like parsing
|
||||
window.marked = {
|
||||
parse: function(md) {
|
||||
if (!md) return '';
|
||||
|
||||
// Basic markdown parsing
|
||||
return md
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.*?)\*/g, '<em>$1</em>')
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
.replace(/^# (.*$)/gim, '<h1>$1</h1>')
|
||||
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
|
||||
.replace(/^### (.*$)/gim, '<h3>$1</h3>')
|
||||
.replace(/\n/g, '<br>');
|
||||
}
|
||||
};
|
||||
|
||||
window.DOMPurify = {
|
||||
sanitize: function(html) {
|
||||
// Basic sanitization - strip script tags
|
||||
return html.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
|
||||
}
|
||||
};
|
||||
}
|
||||
})();
|
||||
@@ -1,212 +0,0 @@
|
||||
:root {
|
||||
--bg: #12141c;
|
||||
--bg-alt: #181b25;
|
||||
--panel: #1f2430;
|
||||
--border: rgba(255,255,255,0.08);
|
||||
--accent: #7b61ff;
|
||||
--accent-glow: 180 100% 60%;
|
||||
--text: #e6e8ef;
|
||||
--muted: #9aa0b1;
|
||||
--danger: #ff4d61;
|
||||
font-family: system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,sans-serif;
|
||||
}
|
||||
|
||||
html,body { height:100%; margin:0; background:radial-gradient(circle at 20% 20%, #1c2030, #0f1116); color:var(--text); }
|
||||
body { display:flex; }
|
||||
|
||||
.app { display:flex; flex:1; width:100%; overflow:hidden; }
|
||||
.sidebar { width:280px; background:linear-gradient(180deg,#1b1f29,#161921); border-right:1px solid var(--border); display:flex; flex-direction:column; }
|
||||
.sidebar-header { padding:14px 16px 10px; display:flex; align-items:center; justify-content:space-between; gap:8px; }
|
||||
.sidebar-header h1 { font-size:18px; margin:0; letter-spacing:.5px; background:linear-gradient(90deg,#a48bff,#6cb6ff); -webkit-background-clip:text; color:transparent; }
|
||||
.sidebar-header button { background:var(--accent); color:#fff; border:0; width:34px; height:34px; border-radius:10px; cursor:pointer; font-size:18px; display:flex; align-items:center; justify-content:center; }
|
||||
.sidebar-header button:hover { filter:brightness(1.1); }
|
||||
.chat-list { list-style:none; margin:0; padding:4px 10px 10px; flex:1; overflow:auto; }
|
||||
.chat-item { padding:10px 10px; margin:4px 0; border:1px solid var(--border); border-radius:12px; cursor:pointer; background:rgba(255,255,255,0.03); display:flex; gap:8px; align-items:center; }
|
||||
.chat-item:hover { background:rgba(255,255,255,0.06); }
|
||||
.chat-item.active { border-color:var(--accent); background:linear-gradient(90deg,rgba(123,97,255,0.25),rgba(123,97,255,0.12)); }
|
||||
.chat-title { flex:1; font-size:13px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.delete-btn { background:transparent; border:0; color:var(--muted); cursor:pointer; font-size:14px; }
|
||||
.delete-btn:hover { color:var(--danger); }
|
||||
.sidebar-footer { padding:10px; border-top:1px solid var(--border); }
|
||||
.sidebar-footer button { width:100%; background:#262d3a; color:var(--text); border:1px solid var(--border); padding:8px 12px; border-radius:10px; cursor:pointer; }
|
||||
.sidebar-footer button:hover { border-color:var(--accent); color:#fff; }
|
||||
|
||||
.main { display:flex; flex:1; flex-direction:column; position:relative; }
|
||||
.messages { flex:1; overflow:auto; padding:18px 22px 20px; display:flex; flex-direction:column; gap:14px; scroll-behavior:smooth; }
|
||||
.msg { padding:12px 14px; border-radius:14px; max-width:870px; line-height:1.55; font-size:14px; white-space:pre-wrap; word-break:break-word; }
|
||||
.msg.user { align-self:flex-end; background:linear-gradient(180deg,#2d3344,#252b38); border:1px solid rgba(123,97,255,0.4); }
|
||||
.msg.assistant { align-self:flex-start; background:linear-gradient(180deg,#232836,#1d202a); border:1px solid rgba(255,255,255,0.1); }
|
||||
.msg.streaming { position:relative; }
|
||||
.msg.streaming:after { content:""; position:absolute; left:0; bottom:0; height:2px; width:100%; background:linear-gradient(90deg,rgba(123,97,255,0),rgba(123,97,255,.8),rgba(123,97,255,0)); animation:stream 1.2s linear infinite; }
|
||||
|
||||
/* 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; }
|
||||
}
|
||||
|
||||
@keyframes stream { from { background-position:0 0; } to { background-position:200% 0; } }
|
||||
|
||||
.composer { display:flex; gap:12px; padding:16px 18px; background:linear-gradient(180deg,#181c25,#14171f); border-top:1px solid var(--border); }
|
||||
.composer textarea { flex:1; resize:none; max-height:200px; min-height:46px; padding:12px 14px; font-size:14px; border-radius:12px; border:1px solid var(--border); background:#1c212d; color:var(--text); outline:none; line-height:1.5; }
|
||||
.composer textarea:focus { border-color:var(--accent); box-shadow:0 0 0 3px rgba(123,97,255,0.25); }
|
||||
.composer button { width:110px; border:0; background:var(--accent); color:#fff; font-weight:600; border-radius:12px; cursor:pointer; font-size:14px; }
|
||||
.composer button:hover { filter:brightness(1.1); }
|
||||
|
||||
.settings-modal { position:fixed; inset:0; background:rgba(0,0,0,0.55); backdrop-filter:blur(6px); display:flex; align-items:center; justify-content:center; z-index:99999; }
|
||||
.settings-card { width:460px; max-width:90%; background:linear-gradient(180deg,#222836,#1b1f29); border:1px solid var(--border); border-radius:18px; padding:20px 22px 24px; display:flex; flex-direction:column; gap:14px; }
|
||||
.settings-card h2 { margin:0 0 4px; font-size:18px; }
|
||||
.settings-card label { font-size:12px; text-transform:uppercase; letter-spacing:.05em; color:var(--muted); font-weight:600; }
|
||||
.settings-card input, .settings-card textarea { width:100%; margin-top:4px; background:#1d2430; border:1px solid var(--border); color:var(--text); border-radius:10px; padding:10px 12px; font-size:13px; resize:vertical; min-height:42px; }
|
||||
.settings-card input[type="checkbox"] { width:auto; margin:0; transform:scale(1.2); }
|
||||
.settings-card input[type="range"] { padding:0; height:6px; background:var(--border); border-radius:3px; -webkit-appearance:none; appearance:none; }
|
||||
.settings-card input[type="range"]::-webkit-slider-thumb { -webkit-appearance:none; appearance:none; width:16px; height:16px; border-radius:50%; background:var(--accent); cursor:pointer; }
|
||||
.settings-card input[type="range"]::-moz-range-thumb { width:16px; height:16px; border-radius:50%; background:var(--accent); cursor:pointer; border:none; }
|
||||
.settings-card textarea { min-height:100px; }
|
||||
.settings-card input:focus, .settings-card textarea:focus { border-color:var(--accent); outline:none; box-shadow:0 0 0 2px rgba(123,97,255,0.25); }
|
||||
.settings-actions { display:flex; justify-content:flex-end; gap:10px; margin-top:4px; }
|
||||
.settings-actions button { background:#2a3242; color:var(--text); padding:8px 14px; border:1px solid var(--border); border-radius:10px; cursor:pointer; }
|
||||
.settings-actions button.primary { background:var(--accent); color:#fff; border:0; }
|
||||
.settings-actions button:hover { border-color:var(--accent); }
|
||||
.settings-actions button.primary:hover { filter:brightness(1.1); }
|
||||
|
||||
.empty { opacity:.5; font-size:14px; text-align:center; padding:40px 0; }
|
||||
|
||||
/* Enhanced Markdown Styles */
|
||||
.markdown { color: inherit; }
|
||||
.markdown * { color: inherit; }
|
||||
|
||||
/* Typography */
|
||||
.markdown :is(h1,h2,h3,h4,h5,h6) {
|
||||
margin: 1.2em 0 0.5em;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.markdown h1 { font-size: 1.5em; border-bottom: 1px solid var(--border); padding-bottom: 0.3em; }
|
||||
.markdown h2 { font-size: 1.3em; }
|
||||
.markdown h3 { font-size: 1.15em; }
|
||||
.markdown h4 { font-size: 1.05em; }
|
||||
.markdown h5 { font-size: 1em; }
|
||||
.markdown h6 { font-size: 0.9em; color: var(--muted); }
|
||||
|
||||
.markdown p {
|
||||
margin: 0.8em 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Lists */
|
||||
.markdown :is(ul,ol) {
|
||||
margin: 0.8em 0;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
.markdown li {
|
||||
margin: 0.3em 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.markdown ul li { list-style-type: disc; }
|
||||
.markdown ol li { list-style-type: decimal; }
|
||||
|
||||
/* Code */
|
||||
.markdown code {
|
||||
background: rgba(0,0,0,0.4);
|
||||
padding: 0.15em 0.4em;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9em;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
.markdown pre {
|
||||
background: rgba(0,0,0,0.5);
|
||||
padding: 1em 1.2em;
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
border-radius: 12px;
|
||||
overflow-x: auto;
|
||||
margin: 1em 0;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.markdown pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
border: none;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* Blockquotes */
|
||||
.markdown blockquote {
|
||||
margin: 1em 0;
|
||||
padding: 0.8em 1.2em;
|
||||
border-left: 4px solid var(--accent);
|
||||
background: rgba(123,97,255,0.08);
|
||||
border-radius: 0 8px 8px 0;
|
||||
font-style: italic;
|
||||
}
|
||||
.markdown blockquote p { margin: 0.5em 0; }
|
||||
|
||||
/* Links */
|
||||
.markdown a {
|
||||
color: #6cb6ff;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.markdown a:hover {
|
||||
border-bottom-color: #6cb6ff;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.markdown table {
|
||||
border-collapse: collapse;
|
||||
margin: 1em 0;
|
||||
width: 100%;
|
||||
}
|
||||
.markdown th, .markdown td {
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.6em 0.8em;
|
||||
text-align: left;
|
||||
}
|
||||
.markdown th {
|
||||
background: rgba(255,255,255,0.05);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Horizontal rule */
|
||||
.markdown hr {
|
||||
border: none;
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 2em 0;
|
||||
}
|
||||
|
||||
/* Inline emphasis */
|
||||
.markdown strong { font-weight: 600; }
|
||||
.markdown em { font-style: italic; }
|
||||
|
||||
/* Syntax highlighting theme adjustments */
|
||||
.markdown .hljs {
|
||||
background: transparent !important;
|
||||
color: var(--text) !important;
|
||||
}
|
||||
.markdown .hljs-keyword { color: #c792ea; }
|
||||
.markdown .hljs-string { color: #ecc48d; }
|
||||
.markdown .hljs-number { color: #f78c6c; }
|
||||
.markdown .hljs-comment { color: var(--muted); }
|
||||
.markdown .hljs-function { color: #82aaff; }
|
||||
.markdown .hljs-variable { color: #ffcb6b; }
|
||||
.markdown .hljs-type { color: #c3e88d; }
|
||||
.markdown .hljs-built_in { color: #ff5370; }
|
||||
|
||||
/* Local lightweight highlight.js theme (replaces removed CDN stylesheet) */
|
||||
.markdown pre code .hljs-title { color: #82aaff; }
|
||||
.markdown pre code .hljs-attr { color: #ecc48d; }
|
||||
.markdown pre code .hljs-symbol { color: #c792ea; }
|
||||
.markdown pre code .hljs-meta { color: #7f848e; }
|
||||
.markdown pre code .hljs-params { color: #c3e88d; }
|
||||
.markdown pre code .hljs-property { color: #ffcb6b; }
|
||||
@@ -1,31 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Nebot</title>
|
||||
<link rel="stylesheet" href="page.css" />
|
||||
<!-- Removed external CDN scripts: libs provided via renderer-preload.js for self-contained plugin -->
|
||||
</head>
|
||||
<body>
|
||||
<div id="nebot-app" class="app">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h1>Nebot</h1>
|
||||
<button id="new-chat" title="New Chat">+</button>
|
||||
</div>
|
||||
<ul id="chat-list" class="chat-list"></ul>
|
||||
<div class="sidebar-footer">
|
||||
<button id="settings-btn">⚙ Settings</button>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="main">
|
||||
<div id="messages" class="messages"></div>
|
||||
<form id="composer" class="composer">
|
||||
<textarea id="input" placeholder="Ask Nebot anything..." rows="1"></textarea>
|
||||
<button id="send" type="submit">Send</button>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
<script src="page.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,622 +0,0 @@
|
||||
/* Nebot dedicated page logic */
|
||||
(function(){
|
||||
console.log('[Nebot Page] Starting initialization...');
|
||||
console.log('[Nebot Page] window.ollamaChat:', window.ollamaChat);
|
||||
console.log('[Nebot Page] window.electronAPI:', window.electronAPI);
|
||||
|
||||
// Try multiple ways to access the API
|
||||
let api = window.ollamaChat;
|
||||
|
||||
// If not available directly, try accessing through electronAPI
|
||||
if (!api && window.electronAPI) {
|
||||
console.log('[Nebot Page] Creating proxy API using electronAPI...');
|
||||
// Create a proxy API that uses IPC directly
|
||||
api = {
|
||||
listChats: () => {
|
||||
console.log('[Nebot Page] Calling listChats via IPC...');
|
||||
return window.electronAPI.invoke('ollama-chat:list-chats');
|
||||
},
|
||||
getChat: (id) => {
|
||||
console.log('[Nebot Page] Calling getChat via IPC...', id);
|
||||
return window.electronAPI.invoke('ollama-chat:get-chat', { id });
|
||||
},
|
||||
createChat: (title) => {
|
||||
console.log('[Nebot Page] Calling createChat via IPC...', title);
|
||||
return window.electronAPI.invoke('ollama-chat:create-chat', { title });
|
||||
},
|
||||
deleteChat: (id) => {
|
||||
console.log('[Nebot Page] Calling deleteChat via IPC...', id);
|
||||
return window.electronAPI.invoke('ollama-chat:delete-chat', { id });
|
||||
},
|
||||
getSettings: () => {
|
||||
console.log('[Nebot Page] Calling getSettings via IPC...');
|
||||
return window.electronAPI.invoke('ollama-chat:get-settings');
|
||||
},
|
||||
setSettings: (s) => {
|
||||
console.log('[Nebot Page] Calling setSettings via IPC...', s);
|
||||
return window.electronAPI.invoke('ollama-chat:set-settings', s);
|
||||
},
|
||||
send: (id, content) => {
|
||||
console.log('[Nebot Page] Calling send via IPC...', id, content);
|
||||
return window.electronAPI.invoke('ollama-chat:send', { id, content });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if(!api){
|
||||
document.body.innerHTML = '<div style="padding:20px;font-family:system-ui;background:#12141c;color:#e6e8ef;"><h2>Nebot Plugin API Not Available</h2><p>The Nebot plugin may be disabled or not properly loaded.</p><p>Try:</p><ul><li>Check that the plugin is enabled in settings</li><li>Restart the browser</li><li>Use the floating panel instead (Ctrl+Shift+O)</li></ul></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Nebot Page] API available, proceeding with initialization...');
|
||||
|
||||
const els = {
|
||||
chatList: document.getElementById('chat-list'),
|
||||
messages: document.getElementById('messages'),
|
||||
input: document.getElementById('input'),
|
||||
newChat: document.getElementById('new-chat'),
|
||||
form: document.getElementById('composer'),
|
||||
send: document.getElementById('send'),
|
||||
settingsBtn: document.getElementById('settings-btn')
|
||||
};
|
||||
|
||||
const state = { chats: [], currentId: null };
|
||||
|
||||
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 if(v!=null) el.setAttribute(k,v);
|
||||
}
|
||||
for(const c of children){ if(c==null) continue; el.appendChild(typeof c==='string'?document.createTextNode(c):c);} return el;
|
||||
}
|
||||
|
||||
function formatTime(ts){ try { return new Date(ts).toLocaleString(); } catch { return ''; } }
|
||||
|
||||
async function refreshList(){
|
||||
console.log('[Nebot Page] refreshList called...');
|
||||
try {
|
||||
const result = await api.listChats();
|
||||
console.log('[Nebot Page] listChats result:', result);
|
||||
state.chats = result.chats || [];
|
||||
renderChatList();
|
||||
} catch (e) {
|
||||
console.error('[Nebot Page] refreshList error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function renderChatList(){
|
||||
els.chatList.innerHTML='';
|
||||
state.chats.forEach(c => {
|
||||
const li = h('li',{class:'chat-item'+(c.id===state.currentId?' active':'')});
|
||||
li.appendChild(h('div',{class:'chat-title'}, c.title||'Untitled'));
|
||||
li.appendChild(h('button',{class:'delete-btn',title:'Delete',onclick:(e)=>{e.stopPropagation();deleteChat(c.id);}},'✕'));
|
||||
li.onclick=()=>openChat(c.id);
|
||||
els.chatList.appendChild(li);
|
||||
});
|
||||
if(!state.chats.length){
|
||||
els.chatList.appendChild(h('div',{class:'empty'},'No chats yet. Start one below.'));}
|
||||
}
|
||||
|
||||
async function openChat(id){
|
||||
console.log('[Nebot Page] openChat called with id:', id);
|
||||
state.currentId=id;
|
||||
try {
|
||||
const result = await api.getChat(id);
|
||||
console.log('[Nebot Page] getChat result:', result);
|
||||
if(result.error){
|
||||
console.error('[Nebot Page] Error getting chat:', result.error);
|
||||
return;
|
||||
}
|
||||
renderMessages(result.chat);
|
||||
renderChatList();
|
||||
subscribeStream(id);
|
||||
} catch (e) {
|
||||
console.error('[Nebot Page] openChat error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function newChat(){
|
||||
const { chat } = await api.createChat('New chat');
|
||||
await refreshList();
|
||||
await openChat(chat.id);
|
||||
}
|
||||
|
||||
async function deleteChat(id){
|
||||
await api.deleteChat(id);
|
||||
await refreshList();
|
||||
if(state.currentId===id){ state.currentId=state.chats[0]?.id||null; if(state.currentId) openChat(state.currentId); else els.messages.innerHTML=''; }
|
||||
}
|
||||
|
||||
function mdEscape(s){ return (s||'').replace(/[&<>]/g,m=>({"&":"&","<":"<",">":">"}[m])); }
|
||||
|
||||
function renderMarkdown(md){
|
||||
if(!md) return '';
|
||||
|
||||
// Check if libraries are loaded
|
||||
if(window.marked && window.DOMPurify){
|
||||
try {
|
||||
// Configure marked if not already done
|
||||
if(!window.marked.configured) {
|
||||
window.marked.setOptions({
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
headerIds: false,
|
||||
mangle: false,
|
||||
highlight: function(code, lang) {
|
||||
if (window.hljs && lang && window.hljs.getLanguage(lang)) {
|
||||
try {
|
||||
return window.hljs.highlight(code, { language: lang }).value;
|
||||
} catch (e) {
|
||||
console.warn('Highlight.js error:', e);
|
||||
}
|
||||
}
|
||||
// Try auto-detection
|
||||
if (window.hljs) {
|
||||
try {
|
||||
return window.hljs.highlightAuto(code).value;
|
||||
} catch (e) {
|
||||
console.warn('Highlight.js auto error:', e);
|
||||
}
|
||||
}
|
||||
return code;
|
||||
}
|
||||
});
|
||||
window.marked.configured = true;
|
||||
}
|
||||
|
||||
const raw = window.marked.parse(md);
|
||||
return window.DOMPurify.sanitize(raw, {
|
||||
ADD_ATTR: ['target', 'rel', 'class'],
|
||||
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'code', 'pre', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'blockquote', 'a', 'span', 'div'],
|
||||
ALLOWED_ATTR: ['href', 'target', 'rel', 'class', 'title']
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Markdown parsing error:', e);
|
||||
return mdEscape(md);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: basic markdown-like parsing
|
||||
console.warn('Markdown libraries not loaded, using fallback parsing');
|
||||
return md
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.*?)\*/g, '<em>$1</em>')
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
.replace(/^### (.*$)/gim, '<h3>$1</h3>')
|
||||
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
|
||||
.replace(/^# (.*$)/gim, '<h1>$1</h1>')
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>')
|
||||
.replace(/\n\n/g, '</p><p>')
|
||||
.replace(/\n/g, '<br>')
|
||||
.replace(/^(.*)$/, '<p>$1</p>');
|
||||
}
|
||||
|
||||
function renderMessages(chat){
|
||||
els.messages.innerHTML='';
|
||||
if(!chat){ return; }
|
||||
chat.messages.forEach(m=>{
|
||||
const div = h('div',{class:'msg '+m.role});
|
||||
const mdEl = h('div', { class: 'markdown' });
|
||||
// If libs are ready, render now; otherwise, show plain text and mark for deferred upgrade
|
||||
if (window.marked && window.DOMPurify) {
|
||||
mdEl.innerHTML = renderMarkdown(m.content);
|
||||
} else {
|
||||
mdEl.textContent = m.content || '';
|
||||
mdEl.dataset.raw = m.content || '';
|
||||
deferredMarkdown.add(mdEl);
|
||||
scheduleDeferredMarkdownCheck();
|
||||
}
|
||||
div.appendChild(mdEl);
|
||||
|
||||
// Enhance links for security (in case already rendered)
|
||||
div.querySelectorAll('a[href]').forEach(a => {
|
||||
a.setAttribute('target', '_blank');
|
||||
a.setAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
|
||||
els.messages.appendChild(div);
|
||||
});
|
||||
els.messages.scrollTop = els.messages.scrollHeight;
|
||||
}
|
||||
|
||||
// --- Deferred Markdown Enhancement Support ---
|
||||
// Some CDN scripts (marked / highlight.js) may not be ready when we first render.
|
||||
// We keep raw text and upgrade once libraries are available.
|
||||
const deferredMarkdown = new Set();
|
||||
let deferredTimer = null;
|
||||
function scheduleDeferredMarkdownCheck(){
|
||||
if(deferredTimer) return;
|
||||
deferredTimer = setInterval(()=>{
|
||||
if(window.marked && window.DOMPurify){
|
||||
deferredMarkdown.forEach(el=>{
|
||||
const raw = el.dataset.raw;
|
||||
try {
|
||||
el.innerHTML = renderMarkdown(raw);
|
||||
// Enhance links again
|
||||
el.closest('.msg')?.querySelectorAll('a[href]').forEach(a=>{ a.setAttribute('target','_blank'); a.setAttribute('rel','noopener noreferrer'); });
|
||||
el.removeAttribute('data-raw');
|
||||
deferredMarkdown.delete(el);
|
||||
} catch(e){ console.warn('[Nebot Page] Deferred markdown render failed', e); }
|
||||
});
|
||||
if(!deferredMarkdown.size){ clearInterval(deferredTimer); deferredTimer=null; }
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// Typing animation state
|
||||
let typingQueue = [];
|
||||
let isTyping = false;
|
||||
let typingSpeed = 25; // milliseconds per character (base speed)
|
||||
let typingEnabled = true; // can be toggled in settings
|
||||
let currentCharIndex = 0; // track current position for adaptive speed
|
||||
let lastComputedDelay = typingSpeed;
|
||||
|
||||
function calculateTypingDelay(charIndex, element) {
|
||||
// Dynamic words-per-second scaling based on total word count of (displayed + queued)
|
||||
const currentText = element.textContent + typingQueue.join('');
|
||||
const words = currentText.trim().length ? currentText.trim().split(/\s+/).length : 0;
|
||||
if (words === 0) return typingSpeed; // fallback
|
||||
|
||||
// Derive average chars per word (include space) for conversion
|
||||
const avgWordChars = Math.max(3.5, Math.min(8, currentText.length / Math.max(1, words)) + 0.8); // small bias for trailing spaces
|
||||
|
||||
// Base slider (typingSpeed currently ms per char) corresponds to baseWordsPerSec for small replies.
|
||||
// Convert baseSpeed (ms/char) to base words/sec using avgWordChars
|
||||
const baseWordsPerSec = 1000 / (typingSpeed * avgWordChars);
|
||||
|
||||
// Target words per second scales with total words:
|
||||
// 0 -> baseWordsPerSec
|
||||
// 1000 -> 100 wps cap (user example: 1000 words => 100 wps)
|
||||
// Linear interpolation then clamp.
|
||||
const targetWps = Math.min(100, baseWordsPerSec + (words / 1000) * (100 - baseWordsPerSec));
|
||||
|
||||
// Convert target words/sec to per-char delay.
|
||||
const delayPerChar = 1000 / (targetWps * avgWordChars);
|
||||
|
||||
// Slight smoothing to avoid jitter (EMA)
|
||||
const alpha = 0.25;
|
||||
lastComputedDelay = lastComputedDelay ? (alpha * delayPerChar + (1 - alpha) * lastComputedDelay) : delayPerChar;
|
||||
|
||||
return Math.max(2, lastComputedDelay); // minimum 2ms
|
||||
}
|
||||
|
||||
function startTypingAnimation(element) {
|
||||
console.log('[Nebot Page] startTypingAnimation called, queue length:', typingQueue.length);
|
||||
if (isTyping || typingQueue.length === 0) return;
|
||||
|
||||
isTyping = true;
|
||||
currentCharIndex = 0;
|
||||
const totalLength = typingQueue.length;
|
||||
element.classList.add('typing');
|
||||
console.log('[Nebot Page] Starting typing animation with', totalLength, 'characters (word-count adaptive speed)');
|
||||
|
||||
function typeNext() {
|
||||
if (typingQueue.length === 0) {
|
||||
isTyping = false;
|
||||
currentCharIndex = 0;
|
||||
element.classList.remove('typing');
|
||||
console.log('[Nebot Page] Typing animation completed');
|
||||
return;
|
||||
}
|
||||
|
||||
const char = typingQueue.shift();
|
||||
element.textContent += char;
|
||||
els.messages.scrollTop = els.messages.scrollHeight;
|
||||
|
||||
// Calculate dynamic delay based on live word count
|
||||
const delay = calculateTypingDelay(currentCharIndex, element);
|
||||
currentCharIndex++;
|
||||
|
||||
// Log speed changes for debugging
|
||||
if (currentCharIndex % 20 === 0) {
|
||||
console.log(`[Nebot Page] Char ${currentCharIndex}/${totalLength}, adaptive delay: ${delay.toFixed(2)}ms`);
|
||||
}
|
||||
|
||||
setTimeout(typeNext, delay);
|
||||
}
|
||||
|
||||
typeNext();
|
||||
}
|
||||
|
||||
// Keep a registry of handlers so we can remove previous listeners reliably
|
||||
const streamHandlers = new Map();
|
||||
function subscribeStream(id){
|
||||
const channel = 'ollama-chat:stream:' + id;
|
||||
console.log('[Nebot Page] Subscribing to stream channel:', channel);
|
||||
|
||||
// Reset typing state for new stream
|
||||
typingQueue = [];
|
||||
isTyping = false;
|
||||
|
||||
// Remove any existing listener registered earlier for this channel
|
||||
if (window.electronAPI && window.electronAPI.removeListener) {
|
||||
const prev = streamHandlers.get(channel);
|
||||
if (prev) {
|
||||
try { window.electronAPI.removeListener(channel, prev); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
function handleStreamPayload(...args) {
|
||||
// Handle both (event, payload) and (payload) argument patterns
|
||||
const payload = args.length > 1 ? args[1] : args[0];
|
||||
console.log('[Nebot Page] Stream payload received:', payload);
|
||||
|
||||
if(!els.messages) return;
|
||||
if(payload.type==='token'){
|
||||
let last = els.messages.querySelector('.msg.assistant.streaming');
|
||||
if(!last){
|
||||
last = h('div',{class:'msg assistant streaming'});
|
||||
els.messages.appendChild(last);
|
||||
last.innerHTML='<div class="markdown"></div>';
|
||||
console.log('[Nebot Page] Created new streaming message element');
|
||||
}
|
||||
const md = last.querySelector('.markdown');
|
||||
|
||||
if (typingEnabled) {
|
||||
// Add tokens to typing queue instead of directly appending
|
||||
console.log('[Nebot Page] Adding token to queue:', payload.token);
|
||||
for (const char of payload.token) {
|
||||
typingQueue.push(char);
|
||||
}
|
||||
console.log('[Nebot Page] Queue length now:', typingQueue.length);
|
||||
|
||||
// Start typing animation if not already running
|
||||
if (!isTyping) {
|
||||
console.log('[Nebot Page] Starting typing animation...');
|
||||
startTypingAnimation(md);
|
||||
}
|
||||
} else {
|
||||
// Direct append if typing is disabled
|
||||
console.log('[Nebot Page] Typing disabled, appending directly:', payload.token);
|
||||
md.textContent += payload.token;
|
||||
}
|
||||
} else if(payload.type==='done') {
|
||||
console.log('[Nebot Page] Stream done, finalizing message');
|
||||
const last = els.messages.querySelector('.msg.assistant.streaming');
|
||||
if(last){
|
||||
const mdEl = last.querySelector('.markdown');
|
||||
|
||||
// Wait for typing animation to complete before rendering markdown
|
||||
const waitForTyping = () => {
|
||||
if (typingEnabled && (isTyping || typingQueue.length > 0)) {
|
||||
setTimeout(waitForTyping, 50);
|
||||
return;
|
||||
}
|
||||
|
||||
// Now render the markdown (or defer if libs not ready)
|
||||
const raw = mdEl.textContent;
|
||||
if(window.marked && window.DOMPurify){
|
||||
mdEl.innerHTML = renderMarkdown(raw);
|
||||
} else {
|
||||
mdEl.dataset.raw = raw;
|
||||
deferredMarkdown.add(mdEl);
|
||||
scheduleDeferredMarkdownCheck();
|
||||
}
|
||||
|
||||
// Enhance links for security
|
||||
last.querySelectorAll('a[href]').forEach(a => {
|
||||
a.setAttribute('target', '_blank');
|
||||
a.setAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
|
||||
last.classList.remove('streaming');
|
||||
};
|
||||
|
||||
waitForTyping();
|
||||
}
|
||||
} else if(payload.type==='error') {
|
||||
console.error('[Nebot Page] Stream error:', payload.message);
|
||||
}
|
||||
els.messages.scrollTop = els.messages.scrollHeight;
|
||||
}
|
||||
|
||||
if (window.electronAPI && window.electronAPI.on) {
|
||||
console.log('[Nebot Page] Setting up stream listener via electronAPI');
|
||||
window.electronAPI.on(channel, handleStreamPayload);
|
||||
streamHandlers.set(channel, handleStreamPayload);
|
||||
} else {
|
||||
console.warn('[Nebot Page] electronAPI.on not available for stream subscription');
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage(e){
|
||||
e.preventDefault();
|
||||
console.log('[Nebot Page] sendMessage called...');
|
||||
const content = els.input.value.trim();
|
||||
if(!content) return;
|
||||
if(!state.currentId){
|
||||
console.log('[Nebot Page] Creating new chat...');
|
||||
const result = await api.createChat('New chat');
|
||||
console.log('[Nebot Page] createChat result:', result);
|
||||
await refreshList();
|
||||
state.currentId = result.chat.id;
|
||||
}
|
||||
const userDiv = h('div',{class:'msg user'}); userDiv.textContent=content; els.messages.appendChild(userDiv);
|
||||
els.input.value='';
|
||||
els.messages.scrollTop = els.messages.scrollHeight;
|
||||
|
||||
// Subscribe to stream BEFORE sending
|
||||
subscribeStream(state.currentId);
|
||||
|
||||
console.log('[Nebot Page] Sending message...', state.currentId, content);
|
||||
try {
|
||||
const result = await api.send(state.currentId, content);
|
||||
console.log('[Nebot Page] send result:', result);
|
||||
|
||||
// If no streaming response appears after 2 seconds, reload the chat to show the full response
|
||||
setTimeout(async () => {
|
||||
if (!els.messages.querySelector('.msg.assistant.streaming')) {
|
||||
console.log('[Nebot Page] No streaming response detected, reloading chat...');
|
||||
const result = await api.getChat(state.currentId);
|
||||
if (result.chat && result.chat.messages) {
|
||||
const lastMessage = result.chat.messages[result.chat.messages.length - 1];
|
||||
if (lastMessage && lastMessage.role === 'assistant') {
|
||||
console.log('[Nebot Page] Found new assistant message, simulating typing animation...');
|
||||
|
||||
// Create a streaming message element
|
||||
const assistantDiv = h('div',{class:'msg assistant streaming'});
|
||||
assistantDiv.innerHTML='<div class="markdown"></div>';
|
||||
els.messages.appendChild(assistantDiv);
|
||||
const md = assistantDiv.querySelector('.markdown');
|
||||
|
||||
// Simulate typing animation with the full response
|
||||
if (typingEnabled) {
|
||||
typingQueue = [];
|
||||
for (const char of lastMessage.content) {
|
||||
typingQueue.push(char);
|
||||
}
|
||||
console.log('[Nebot Page] Simulating typing for', typingQueue.length, 'characters with word-count adaptive speed');
|
||||
startTypingAnimation(md);
|
||||
|
||||
// Rough duration estimate using dynamic words/sec model (cap 8s)
|
||||
const msgWords = lastMessage.content.trim().split(/\s+/).length;
|
||||
const estWps = Math.min(100, 10 + (msgWords / 1000) * 90);
|
||||
const estimatedDuration = Math.min(8000, (msgWords / estWps) * 1000);
|
||||
|
||||
// Wait for typing to complete, then render markdown
|
||||
setTimeout(() => {
|
||||
assistantDiv.classList.remove('streaming');
|
||||
const raw = md.textContent;
|
||||
if(window.marked && window.DOMPurify){
|
||||
md.innerHTML = renderMarkdown(raw);
|
||||
} else {
|
||||
md.dataset.raw = raw;
|
||||
deferredMarkdown.add(md);
|
||||
scheduleDeferredMarkdownCheck();
|
||||
}
|
||||
assistantDiv.querySelectorAll('a[href]').forEach(a => {
|
||||
a.setAttribute('target', '_blank');
|
||||
a.setAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
}, estimatedDuration + 1000);
|
||||
} else {
|
||||
// No typing animation, just show the message
|
||||
md.textContent = lastMessage.content;
|
||||
assistantDiv.classList.remove('streaming');
|
||||
const raw = md.textContent;
|
||||
if(window.marked && window.DOMPurify){
|
||||
md.innerHTML = renderMarkdown(raw);
|
||||
} else {
|
||||
md.dataset.raw = raw;
|
||||
deferredMarkdown.add(md);
|
||||
scheduleDeferredMarkdownCheck();
|
||||
}
|
||||
assistantDiv.querySelectorAll('a[href]').forEach(a => {
|
||||
a.setAttribute('target', '_blank');
|
||||
a.setAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Fallback to full reload
|
||||
await openChat(state.currentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
refreshList();
|
||||
} catch (e) {
|
||||
console.error('[Nebot Page] sendMessage error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function openSettings(){
|
||||
const { settings } = await api.getSettings();
|
||||
const modal = h('div',{class:'settings-modal'},
|
||||
h('div',{class:'settings-card'},
|
||||
h('h2',{},'Nebot Settings'),
|
||||
h('div',{},
|
||||
h('label',{},'Ollama Base URL'),
|
||||
h('input',{id:'set-base',value:settings.ollamaBaseUrl||'http://localhost:11434'})
|
||||
),
|
||||
h('div',{},
|
||||
h('label',{},'System Prompt'),
|
||||
h('textarea',{id:'set-sys'}, settings.systemPrompt||'')
|
||||
),
|
||||
h('div',{},
|
||||
h('label',{},'Typing Animation'),
|
||||
h('div',{style:'display:flex;align-items:center;gap:8px;margin-top:6px;'},
|
||||
h('input',{type:'checkbox',id:'set-typing',checked:settings.typingEnabled!==false}),
|
||||
h('span',{style:'font-size:13px;'},'Enable typing animation for responses')
|
||||
)
|
||||
),
|
||||
h('div',{},
|
||||
h('label',{},'Typing Speed (characters per second)'),
|
||||
h('input',{type:'range',id:'set-speed',min:'10',max:'200',value:settings.typingSpeed||40,style:'margin-top:6px;'}),
|
||||
h('span',{id:'speed-display',style:'font-size:12px;color:var(--muted);margin-top:4px;display:block;'},(settings.typingSpeed||40)+' chars/sec'),
|
||||
h('div',{style:'font-size:11px;color:var(--muted);margin-top:4px;line-height:1.4;'},'💡 Speed scales with total words (up to 100 words/sec at ~1000 words)')
|
||||
),
|
||||
h('div',{class:'settings-actions'},
|
||||
h('button',{onclick:()=>modal.remove()},'Cancel'),
|
||||
h('button',{class:'primary',onclick:async()=>{
|
||||
const next = {
|
||||
ollamaBaseUrl: modal.querySelector('#set-base').value.trim(),
|
||||
systemPrompt: modal.querySelector('#set-sys').value,
|
||||
typingEnabled: modal.querySelector('#set-typing').checked,
|
||||
typingSpeed: parseInt(modal.querySelector('#set-speed').value)
|
||||
};
|
||||
// Update local settings
|
||||
typingEnabled = next.typingEnabled;
|
||||
typingSpeed = 1000 / next.typingSpeed; // convert chars/sec to ms per char
|
||||
|
||||
await api.setSettings(next);
|
||||
modal.remove();
|
||||
}},'Save')
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// Update speed display when slider changes
|
||||
const speedSlider = modal.querySelector('#set-speed');
|
||||
const speedDisplay = modal.querySelector('#speed-display');
|
||||
speedSlider.addEventListener('input', () => {
|
||||
speedDisplay.textContent = speedSlider.value + ' chars/sec';
|
||||
});
|
||||
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
els.newChat.addEventListener('click', newChat);
|
||||
els.form.addEventListener('submit', sendMessage);
|
||||
els.settingsBtn.addEventListener('click', openSettings);
|
||||
// Removed temporary "Test Typing" debug button now that feature is stable.
|
||||
|
||||
// Auto grow textarea
|
||||
els.input.addEventListener('input', ()=>{ els.input.style.height='auto'; els.input.style.height=Math.min(200, els.input.scrollHeight)+'px'; });
|
||||
|
||||
// Load settings and initialize
|
||||
async function initializeSettings() {
|
||||
try {
|
||||
const { settings } = await api.getSettings();
|
||||
typingEnabled = settings.typingEnabled !== false; // default to true
|
||||
typingSpeed = settings.typingSpeed ? (1000 / settings.typingSpeed) : 25; // convert chars/sec to ms per char, default 40 chars/sec
|
||||
console.log('[Nebot Page] Loaded settings - typing enabled:', typingEnabled, 'speed:', typingSpeed + 'ms per char');
|
||||
} catch (e) {
|
||||
console.warn('[Nebot Page] Could not load settings, using defaults:', e);
|
||||
}
|
||||
}
|
||||
|
||||
initializeSettings().then(() => {
|
||||
refreshList().then(()=>{ if(state.chats[0]) openChat(state.chats[0].id); });
|
||||
});
|
||||
|
||||
// Listen for title updates from main (auto-generated titles)
|
||||
try {
|
||||
if (window.electronAPI && typeof window.electronAPI.on === 'function') {
|
||||
window.electronAPI.on('ollama-chat:chat-updated', (payload) => {
|
||||
const data = payload || {};
|
||||
const { id, title } = data;
|
||||
if (!id || !title) return;
|
||||
// Update local state and rerender list
|
||||
const item = state.chats.find(c => c.id === id);
|
||||
if (item) {
|
||||
item.title = title;
|
||||
renderChatList();
|
||||
} else {
|
||||
// Fallback: refresh list from disk if we don't have it
|
||||
refreshList();
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) { console.warn('[Nebot Page] failed to attach chat-updated listener', e); }
|
||||
})();
|
||||
@@ -1,563 +0,0 @@
|
||||
/* Nebot dedicated page logic */
|
||||
(function(){
|
||||
console.log('[Nebot Page] Starting initialization...');
|
||||
console.log('[Nebot Page] window.ollamaChat:', window.ollamaChat);
|
||||
console.log('[Nebot Page] window.electronAPI:', window.electronAPI);
|
||||
|
||||
// Try multiple ways to access the API
|
||||
let api = window.ollamaChat;
|
||||
|
||||
// If not available directly, try accessing through electronAPI
|
||||
if (!api && window.electronAPI) {
|
||||
console.log('[Nebot Page] Creating proxy API using electronAPI...');
|
||||
// Create a proxy API that uses IPC directly
|
||||
api = {
|
||||
listChats: () => {
|
||||
console.log('[Nebot Page] Calling listChats via IPC...');
|
||||
return window.electronAPI.invoke('ollama-chat:list-chats');
|
||||
},
|
||||
getChat: (id) => {
|
||||
console.log('[Nebot Page] Calling getChat via IPC...', id);
|
||||
return window.electronAPI.invoke('ollama-chat:get-chat', { id });
|
||||
},
|
||||
createChat: (title) => {
|
||||
console.log('[Nebot Page] Calling createChat via IPC...', title);
|
||||
return window.electronAPI.invoke('ollama-chat:create-chat', { title });
|
||||
},
|
||||
deleteChat: (id) => {
|
||||
console.log('[Nebot Page] Calling deleteChat via IPC...', id);
|
||||
return window.electronAPI.invoke('ollama-chat:delete-chat', { id });
|
||||
},
|
||||
getSettings: () => {
|
||||
console.log('[Nebot Page] Calling getSettings via IPC...');
|
||||
return window.electronAPI.invoke('ollama-chat:get-settings');
|
||||
},
|
||||
setSettings: (s) => {
|
||||
console.log('[Nebot Page] Calling setSettings via IPC...', s);
|
||||
return window.electronAPI.invoke('ollama-chat:set-settings', s);
|
||||
},
|
||||
send: (id, content) => {
|
||||
console.log('[Nebot Page] Calling send via IPC...', id, content);
|
||||
return window.electronAPI.invoke('ollama-chat:send', { id, content });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if(!api){
|
||||
document.body.innerHTML = '<div style="padding:20px;font-family:system-ui;background:#12141c;color:#e6e8ef;"><h2>Nebot Plugin API Not Available</h2><p>The Nebot plugin may be disabled or not properly loaded.</p><p>Try:</p><ul><li>Check that the plugin is enabled in settings</li><li>Restart the browser</li><li>Use the floating panel instead (Ctrl+Shift+O)</li></ul></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Nebot Page] API available, proceeding with initialization...');
|
||||
|
||||
const els = {
|
||||
chatList: document.getElementById('chat-list'),
|
||||
messages: document.getElementById('messages'),
|
||||
input: document.getElementById('input'),
|
||||
newChat: document.getElementById('new-chat'),
|
||||
form: document.getElementById('composer'),
|
||||
send: document.getElementById('send'),
|
||||
settingsBtn: document.getElementById('settings-btn')
|
||||
};
|
||||
|
||||
const state = { chats: [], currentId: null };
|
||||
|
||||
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 if(v!=null) el.setAttribute(k,v);
|
||||
}
|
||||
for(const c of children){ if(c==null) continue; el.appendChild(typeof c==='string'?document.createTextNode(c):c);} return el;
|
||||
}
|
||||
|
||||
function formatTime(ts){ try { return new Date(ts).toLocaleString(); } catch { return ''; } }
|
||||
|
||||
async function refreshList(){
|
||||
console.log('[Nebot Page] refreshList called...');
|
||||
try {
|
||||
const result = await api.listChats();
|
||||
console.log('[Nebot Page] listChats result:', result);
|
||||
state.chats = result.chats || [];
|
||||
renderChatList();
|
||||
} catch (e) {
|
||||
console.error('[Nebot Page] refreshList error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function renderChatList(){
|
||||
els.chatList.innerHTML='';
|
||||
state.chats.forEach(c => {
|
||||
const li = h('li',{class:'chat-item'+(c.id===state.currentId?' active':'')});
|
||||
li.appendChild(h('div',{class:'chat-title'}, c.title||'Untitled'));
|
||||
li.appendChild(h('button',{class:'delete-btn',title:'Delete',onclick:(e)=>{e.stopPropagation();deleteChat(c.id);}},'✕'));
|
||||
li.onclick=()=>openChat(c.id);
|
||||
els.chatList.appendChild(li);
|
||||
});
|
||||
if(!state.chats.length){
|
||||
els.chatList.appendChild(h('div',{class:'empty'},'No chats yet. Start one below.'));}
|
||||
}
|
||||
|
||||
async function openChat(id){
|
||||
console.log('[Nebot Page] openChat called with id:', id);
|
||||
state.currentId=id;
|
||||
try {
|
||||
const result = await api.getChat(id);
|
||||
console.log('[Nebot Page] getChat result:', result);
|
||||
if(result.error){
|
||||
console.error('[Nebot Page] Error getting chat:', result.error);
|
||||
return;
|
||||
}
|
||||
renderMessages(result.chat);
|
||||
renderChatList();
|
||||
subscribeStream(id);
|
||||
} catch (e) {
|
||||
console.error('[Nebot Page] openChat error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function newChat(){
|
||||
const { chat } = await api.createChat('New chat');
|
||||
await refreshList();
|
||||
await openChat(chat.id);
|
||||
}
|
||||
|
||||
async function deleteChat(id){
|
||||
await api.deleteChat(id);
|
||||
await refreshList();
|
||||
if(state.currentId===id){ state.currentId=state.chats[0]?.id||null; if(state.currentId) openChat(state.currentId); else els.messages.innerHTML=''; }
|
||||
}
|
||||
|
||||
function mdEscape(s){ return (s||'').replace(/[&<>]/g,m=>({"&":"&","<":"<",">":">"}[m])); }
|
||||
|
||||
function renderMarkdown(md){
|
||||
if(!md) return '';
|
||||
|
||||
// Check if libraries are loaded
|
||||
if(window.marked && window.DOMPurify){
|
||||
try {
|
||||
// Configure marked if not already done
|
||||
if(!window.marked.configured) {
|
||||
window.marked.setOptions({
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
headerIds: false,
|
||||
mangle: false,
|
||||
highlight: function(code, lang) {
|
||||
if (window.hljs && lang && window.hljs.getLanguage(lang)) {
|
||||
try {
|
||||
return window.hljs.highlight(code, { language: lang }).value;
|
||||
} catch (e) {
|
||||
console.warn('Highlight.js error:', e);
|
||||
}
|
||||
}
|
||||
// Try auto-detection
|
||||
if (window.hljs) {
|
||||
try {
|
||||
return window.hljs.highlightAuto(code).value;
|
||||
} catch (e) {
|
||||
console.warn('Highlight.js auto error:', e);
|
||||
}
|
||||
}
|
||||
return code;
|
||||
}
|
||||
});
|
||||
window.marked.configured = true;
|
||||
}
|
||||
|
||||
const raw = window.marked.parse(md);
|
||||
return window.DOMPurify.sanitize(raw, {
|
||||
ADD_ATTR: ['target', 'rel', 'class'],
|
||||
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'code', 'pre', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'blockquote', 'a', 'span', 'div'],
|
||||
ALLOWED_ATTR: ['href', 'target', 'rel', 'class', 'title']
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Markdown parsing error:', e);
|
||||
return mdEscape(md);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: basic markdown-like parsing
|
||||
console.warn('Markdown libraries not loaded, using fallback parsing');
|
||||
return md
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.*?)\*/g, '<em>$1</em>')
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
.replace(/^### (.*$)/gim, '<h3>$1</h3>')
|
||||
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
|
||||
.replace(/^# (.*$)/gim, '<h1>$1</h1>')
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>')
|
||||
.replace(/\n\n/g, '</p><p>')
|
||||
.replace(/\n/g, '<br>')
|
||||
.replace(/^(.*)$/, '<p>$1</p>');
|
||||
}
|
||||
|
||||
function renderMessages(chat){
|
||||
els.messages.innerHTML='';
|
||||
if(!chat){ return; }
|
||||
chat.messages.forEach(m=>{
|
||||
const div = h('div',{class:'msg '+m.role});
|
||||
div.innerHTML = '<div class="markdown">'+renderMarkdown(m.content)+'</div>';
|
||||
|
||||
// Enhance links for security
|
||||
div.querySelectorAll('a[href]').forEach(a => {
|
||||
a.setAttribute('target', '_blank');
|
||||
a.setAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
|
||||
els.messages.appendChild(div);
|
||||
});
|
||||
els.messages.scrollTop = els.messages.scrollHeight;
|
||||
}
|
||||
|
||||
// Typing animation state
|
||||
let typingQueue = [];
|
||||
let isTyping = false;
|
||||
let typingSpeed = 25; // milliseconds per character (base speed)
|
||||
let typingEnabled = true; // can be toggled in settings
|
||||
let currentCharIndex = 0; // track position for exponential speed-up
|
||||
|
||||
function calculateTypingDelay(charIndex, totalLength, baseSpeed) {
|
||||
// For short messages (< 50 chars), use normal speed
|
||||
if (totalLength < 50) {
|
||||
return baseSpeed;
|
||||
}
|
||||
|
||||
// For longer messages, start normal and speed up exponentially
|
||||
const progress = charIndex / totalLength;
|
||||
|
||||
// Speed up factor: starts at 1x, goes to 10x at the end
|
||||
// Using exponential curve: 1 + 9 * (progress^2)
|
||||
const speedMultiplier = 1 + 9 * Math.pow(progress, 2);
|
||||
|
||||
// Calculate delay (lower = faster)
|
||||
const delay = Math.max(baseSpeed / speedMultiplier, 5); // minimum 5ms delay
|
||||
|
||||
return delay;
|
||||
}
|
||||
|
||||
function startTypingAnimation(element) {
|
||||
console.log('[Nebot Page] startTypingAnimation called, queue length:', typingQueue.length);
|
||||
if (isTyping || typingQueue.length === 0) return;
|
||||
|
||||
isTyping = true;
|
||||
currentCharIndex = 0;
|
||||
const totalLength = typingQueue.length;
|
||||
element.classList.add('typing');
|
||||
console.log('[Nebot Page] Starting typing animation with', totalLength, 'characters (exponential speed-up enabled)');
|
||||
|
||||
function typeNext() {
|
||||
if (typingQueue.length === 0) {
|
||||
isTyping = false;
|
||||
currentCharIndex = 0;
|
||||
element.classList.remove('typing');
|
||||
console.log('[Nebot Page] Typing animation completed');
|
||||
return;
|
||||
}
|
||||
|
||||
const char = typingQueue.shift();
|
||||
element.textContent += char;
|
||||
els.messages.scrollTop = els.messages.scrollHeight;
|
||||
|
||||
// Calculate dynamic delay based on position
|
||||
const delay = calculateTypingDelay(currentCharIndex, totalLength, typingSpeed);
|
||||
currentCharIndex++;
|
||||
|
||||
// Log speed changes for debugging
|
||||
if (currentCharIndex % 20 === 0) {
|
||||
console.log(`[Nebot Page] Char ${currentCharIndex}/${totalLength}, delay: ${delay.toFixed(1)}ms`);
|
||||
}
|
||||
|
||||
setTimeout(typeNext, delay);
|
||||
}
|
||||
|
||||
typeNext();
|
||||
}
|
||||
|
||||
function subscribeStream(id){
|
||||
const channel = 'ollama-chat:stream:' + id;
|
||||
console.log('[Nebot Page] Subscribing to stream channel:', channel);
|
||||
|
||||
// Reset typing state for new stream
|
||||
typingQueue = [];
|
||||
isTyping = false;
|
||||
|
||||
// Remove any existing listeners for this channel
|
||||
if (window.electronAPI && window.electronAPI.removeListener) {
|
||||
window.electronAPI.removeListener(channel, handleStreamPayload);
|
||||
}
|
||||
|
||||
function handleStreamPayload(...args) {
|
||||
// Handle both (event, payload) and (payload) argument patterns
|
||||
const payload = args.length > 1 ? args[1] : args[0];
|
||||
console.log('[Nebot Page] Stream payload received:', payload);
|
||||
|
||||
if(!els.messages) return;
|
||||
if(payload.type==='token'){
|
||||
let last = els.messages.querySelector('.msg.assistant.streaming');
|
||||
if(!last){
|
||||
last = h('div',{class:'msg assistant streaming'});
|
||||
els.messages.appendChild(last);
|
||||
last.innerHTML='<div class="markdown"></div>';
|
||||
console.log('[Nebot Page] Created new streaming message element');
|
||||
}
|
||||
const md = last.querySelector('.markdown');
|
||||
|
||||
if (typingEnabled) {
|
||||
// Add tokens to typing queue instead of directly appending
|
||||
console.log('[Nebot Page] Adding token to queue:', payload.token);
|
||||
for (const char of payload.token) {
|
||||
typingQueue.push(char);
|
||||
}
|
||||
console.log('[Nebot Page] Queue length now:', typingQueue.length);
|
||||
|
||||
// Start typing animation if not already running
|
||||
if (!isTyping) {
|
||||
console.log('[Nebot Page] Starting typing animation...');
|
||||
startTypingAnimation(md);
|
||||
}
|
||||
} else {
|
||||
// Direct append if typing is disabled
|
||||
console.log('[Nebot Page] Typing disabled, appending directly:', payload.token);
|
||||
md.textContent += payload.token;
|
||||
}
|
||||
} else if(payload.type==='done') {
|
||||
console.log('[Nebot Page] Stream done, finalizing message');
|
||||
const last = els.messages.querySelector('.msg.assistant.streaming');
|
||||
if(last){
|
||||
const mdEl = last.querySelector('.markdown');
|
||||
|
||||
// Wait for typing animation to complete before rendering markdown
|
||||
const waitForTyping = () => {
|
||||
if (typingEnabled && (isTyping || typingQueue.length > 0)) {
|
||||
setTimeout(waitForTyping, 50);
|
||||
return;
|
||||
}
|
||||
|
||||
// Now render the markdown
|
||||
mdEl.innerHTML = renderMarkdown(mdEl.textContent);
|
||||
|
||||
// Enhance links for security
|
||||
last.querySelectorAll('a[href]').forEach(a => {
|
||||
a.setAttribute('target', '_blank');
|
||||
a.setAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
|
||||
last.classList.remove('streaming');
|
||||
};
|
||||
|
||||
waitForTyping();
|
||||
}
|
||||
} else if(payload.type==='error') {
|
||||
console.error('[Nebot Page] Stream error:', payload.message);
|
||||
}
|
||||
els.messages.scrollTop = els.messages.scrollHeight;
|
||||
}
|
||||
|
||||
if (window.electronAPI && window.electronAPI.on) {
|
||||
console.log('[Nebot Page] Setting up stream listener via electronAPI');
|
||||
window.electronAPI.on(channel, handleStreamPayload);
|
||||
} else {
|
||||
console.warn('[Nebot Page] electronAPI.on not available for stream subscription');
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage(e){
|
||||
e.preventDefault();
|
||||
console.log('[Nebot Page] sendMessage called...');
|
||||
const content = els.input.value.trim();
|
||||
if(!content) return;
|
||||
if(!state.currentId){
|
||||
console.log('[Nebot Page] Creating new chat...');
|
||||
const result = await api.createChat('New chat');
|
||||
console.log('[Nebot Page] createChat result:', result);
|
||||
await refreshList();
|
||||
state.currentId = result.chat.id;
|
||||
}
|
||||
const userDiv = h('div',{class:'msg user'}); userDiv.textContent=content; els.messages.appendChild(userDiv);
|
||||
els.input.value='';
|
||||
els.messages.scrollTop = els.messages.scrollHeight;
|
||||
|
||||
// Subscribe to stream BEFORE sending
|
||||
subscribeStream(state.currentId);
|
||||
|
||||
console.log('[Nebot Page] Sending message...', state.currentId, content);
|
||||
try {
|
||||
const result = await api.send(state.currentId, content);
|
||||
console.log('[Nebot Page] send result:', result);
|
||||
|
||||
// If no streaming response appears after 2 seconds, reload the chat to show the full response
|
||||
setTimeout(async () => {
|
||||
if (!els.messages.querySelector('.msg.assistant.streaming')) {
|
||||
console.log('[Nebot Page] No streaming response detected, reloading chat...');
|
||||
const result = await api.getChat(state.currentId);
|
||||
if (result.chat && result.chat.messages) {
|
||||
const lastMessage = result.chat.messages[result.chat.messages.length - 1];
|
||||
if (lastMessage && lastMessage.role === 'assistant') {
|
||||
console.log('[Nebot Page] Found new assistant message, simulating typing animation...');
|
||||
|
||||
// Create a streaming message element
|
||||
const assistantDiv = h('div',{class:'msg assistant streaming'});
|
||||
assistantDiv.innerHTML='<div class="markdown"></div>';
|
||||
els.messages.appendChild(assistantDiv);
|
||||
const md = assistantDiv.querySelector('.markdown');
|
||||
|
||||
// Simulate typing animation with the full response
|
||||
if (typingEnabled) {
|
||||
typingQueue = [];
|
||||
for (const char of lastMessage.content) {
|
||||
typingQueue.push(char);
|
||||
}
|
||||
console.log('[Nebot Page] Simulating typing for', typingQueue.length, 'characters with exponential speed-up');
|
||||
startTypingAnimation(md);
|
||||
|
||||
// Calculate estimated duration with exponential speed-up
|
||||
const estimatedDuration = lastMessage.content.length < 50
|
||||
? lastMessage.content.length * typingSpeed
|
||||
: Math.min(lastMessage.content.length * typingSpeed * 0.3, 8000); // Cap at 8 seconds
|
||||
|
||||
// Wait for typing to complete, then render markdown
|
||||
setTimeout(() => {
|
||||
assistantDiv.classList.remove('streaming');
|
||||
md.innerHTML = renderMarkdown(md.textContent);
|
||||
assistantDiv.querySelectorAll('a[href]').forEach(a => {
|
||||
a.setAttribute('target', '_blank');
|
||||
a.setAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
}, estimatedDuration + 1000);
|
||||
} else {
|
||||
// No typing animation, just show the message
|
||||
md.textContent = lastMessage.content;
|
||||
assistantDiv.classList.remove('streaming');
|
||||
md.innerHTML = renderMarkdown(md.textContent);
|
||||
assistantDiv.querySelectorAll('a[href]').forEach(a => {
|
||||
a.setAttribute('target', '_blank');
|
||||
a.setAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Fallback to full reload
|
||||
await openChat(state.currentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
refreshList();
|
||||
} catch (e) {
|
||||
console.error('[Nebot Page] sendMessage error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function openSettings(){
|
||||
const { settings } = await api.getSettings();
|
||||
const modal = h('div',{class:'settings-modal'},
|
||||
h('div',{class:'settings-card'},
|
||||
h('h2',{},'Nebot Settings'),
|
||||
h('div',{},
|
||||
h('label',{},'Ollama Base URL'),
|
||||
h('input',{id:'set-base',value:settings.ollamaBaseUrl||'http://localhost:11434'})
|
||||
),
|
||||
h('div',{},
|
||||
h('label',{},'System Prompt'),
|
||||
h('textarea',{id:'set-sys'}, settings.systemPrompt||'')
|
||||
),
|
||||
h('div',{},
|
||||
h('label',{},'Typing Animation'),
|
||||
h('div',{style:'display:flex;align-items:center;gap:8px;margin-top:6px;'},
|
||||
h('input',{type:'checkbox',id:'set-typing',checked:settings.typingEnabled!==false}),
|
||||
h('span',{style:'font-size:13px;'},'Enable typing animation for responses')
|
||||
)
|
||||
),
|
||||
h('div',{},
|
||||
h('label',{},'Typing Speed (characters per second)'),
|
||||
h('input',{type:'range',id:'set-speed',min:'10',max:'200',value:settings.typingSpeed||40,style:'margin-top:6px;'}),
|
||||
h('span',{id:'speed-display',style:'font-size:12px;color:var(--muted);margin-top:4px;display:block;'},(settings.typingSpeed||40)+' chars/sec'),
|
||||
h('div',{style:'font-size:11px;color:var(--muted);margin-top:4px;line-height:1.4;'},'💡 Long messages automatically speed up exponentially (1x → 10x) to prevent tedious waiting')
|
||||
),
|
||||
h('div',{class:'settings-actions'},
|
||||
h('button',{onclick:()=>modal.remove()},'Cancel'),
|
||||
h('button',{class:'primary',onclick:async()=>{
|
||||
const next = {
|
||||
ollamaBaseUrl: modal.querySelector('#set-base').value.trim(),
|
||||
systemPrompt: modal.querySelector('#set-sys').value,
|
||||
typingEnabled: modal.querySelector('#set-typing').checked,
|
||||
typingSpeed: parseInt(modal.querySelector('#set-speed').value)
|
||||
};
|
||||
// Update local settings
|
||||
typingEnabled = next.typingEnabled;
|
||||
typingSpeed = 1000 / next.typingSpeed; // convert chars/sec to ms per char
|
||||
|
||||
await api.setSettings(next);
|
||||
modal.remove();
|
||||
}},'Save')
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// Update speed display when slider changes
|
||||
const speedSlider = modal.querySelector('#set-speed');
|
||||
const speedDisplay = modal.querySelector('#speed-display');
|
||||
speedSlider.addEventListener('input', () => {
|
||||
speedDisplay.textContent = speedSlider.value + ' chars/sec';
|
||||
});
|
||||
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
els.newChat.addEventListener('click', newChat);
|
||||
els.form.addEventListener('submit', sendMessage);
|
||||
els.settingsBtn.addEventListener('click', openSettings);
|
||||
|
||||
// Add a test button for debugging typing animation
|
||||
const createTestButton = () => {
|
||||
const testBtn = h('button', {
|
||||
style: 'position:fixed;top:10px;right:10px;z-index:9999;background:#7b61ff;color:white;padding:8px 12px;border:none;border-radius:8px;cursor:pointer;font-size:12px;',
|
||||
onclick: () => {
|
||||
console.log('[Nebot Page] Test button clicked');
|
||||
const testDiv = h('div',{class:'msg assistant streaming'});
|
||||
testDiv.innerHTML='<div class="markdown"></div>';
|
||||
els.messages.appendChild(testDiv);
|
||||
const md = testDiv.querySelector('.markdown');
|
||||
|
||||
// Simulate a longer response to test exponential speed-up
|
||||
const testResponse = "🤖 Hello! This is a test response to verify the typing animation is working correctly with exponential speed-up. It should start at normal speed but get progressively faster as the message gets longer. This is especially useful for very long responses that would otherwise take forever to type out. The speed increase follows an exponential curve, starting at 1x normal speed and reaching up to 10x speed by the end of the message. This ensures that short messages still feel natural while long messages don't become tedious to wait for. You can adjust the base speed in settings, and the acceleration will scale accordingly!";
|
||||
typingQueue = [];
|
||||
for (const char of testResponse) {
|
||||
typingQueue.push(char);
|
||||
}
|
||||
console.log('[Nebot Page] Test queue populated with', typingQueue.length, 'characters (will demonstrate speed-up)');
|
||||
startTypingAnimation(md);
|
||||
|
||||
setTimeout(() => {
|
||||
if (testDiv.classList.contains('streaming')) {
|
||||
testDiv.classList.remove('streaming');
|
||||
md.innerHTML = renderMarkdown(md.textContent);
|
||||
}
|
||||
}, 8000); // Longer timeout since we don't know exact duration with variable speed
|
||||
}
|
||||
}, 'Test Typing ⚡');
|
||||
document.body.appendChild(testBtn);
|
||||
};
|
||||
|
||||
// Always show test button for now to verify typing animation
|
||||
createTestButton();
|
||||
|
||||
// Auto grow textarea
|
||||
els.input.addEventListener('input', ()=>{ els.input.style.height='auto'; els.input.style.height=Math.min(200, els.input.scrollHeight)+'px'; });
|
||||
|
||||
// Load settings and initialize
|
||||
async function initializeSettings() {
|
||||
try {
|
||||
const { settings } = await api.getSettings();
|
||||
typingEnabled = settings.typingEnabled !== false; // default to true
|
||||
typingSpeed = settings.typingSpeed ? (1000 / settings.typingSpeed) : 25; // convert chars/sec to ms per char, default 40 chars/sec
|
||||
console.log('[Nebot Page] Loaded settings - typing enabled:', typingEnabled, 'speed:', typingSpeed + 'ms per char');
|
||||
} catch (e) {
|
||||
console.warn('[Nebot Page] Could not load settings, using defaults:', e);
|
||||
}
|
||||
}
|
||||
|
||||
initializeSettings().then(() => {
|
||||
refreshList().then(()=>{ if(state.chats[0]) openChat(state.chats[0].id); });
|
||||
});
|
||||
})();
|
||||
@@ -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,577 +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');
|
||||
// Defer DOMPurify creation until DOM is ready to avoid early failures in some contexts
|
||||
try {
|
||||
DOMPurify = createDOMPurify(window);
|
||||
} catch {}
|
||||
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; }
|
||||
}
|
||||
});
|
||||
// Expose to page context so page.html no longer needs CDN scripts
|
||||
try {
|
||||
if (typeof window !== 'undefined') {
|
||||
// Note: with contextIsolation enabled, assigning to window does not expose to main world.
|
||||
// Keep assignments for same-world consumers, but also expose explicitly via contextBridge below.
|
||||
window.marked = marked;
|
||||
window.DOMPurify = DOMPurify;
|
||||
window.hljs = hljs;
|
||||
}
|
||||
} catch {}
|
||||
// Explicitly expose to main world so internal pages (browser://nebot) can use these libs
|
||||
try {
|
||||
if (marked) contextBridge.exposeInMainWorld('marked', marked);
|
||||
if (hljs) contextBridge.exposeInMainWorld('hljs', hljs);
|
||||
if (DOMPurify) contextBridge.exposeInMainWorld('DOMPurify', DOMPurify);
|
||||
} catch {}
|
||||
} catch (e) {
|
||||
// If libs aren't available yet, we'll gracefully render as plain text.
|
||||
}
|
||||
|
||||
// If DOMPurify wasn't ready, create and expose it after DOM is ready
|
||||
try {
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
try {
|
||||
if (!DOMPurify && createDOMPurify) {
|
||||
DOMPurify = createDOMPurify(window);
|
||||
}
|
||||
if (DOMPurify) {
|
||||
try { contextBridge.exposeInMainWorld('DOMPurify', DOMPurify); } catch {}
|
||||
try { window.DOMPurify = DOMPurify; } catch {}
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
} catch {}
|
||||
|
||||
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.6em 0; line-height: 1.6; }
|
||||
.${pluginId}-msg h1, .${pluginId}-msg h2, .${pluginId}-msg h3, .${pluginId}-msg h4, .${pluginId}-msg h5, .${pluginId}-msg h6 { margin: 0.8em 0 0.4em; font-weight: 600; line-height: 1.25; }
|
||||
.${pluginId}-msg h1 { font-size: 1.4em; border-bottom: 1px solid rgba(255,255,255,0.15); padding-bottom: 0.3em; }
|
||||
.${pluginId}-msg h2 { font-size: 1.2em; }
|
||||
.${pluginId}-msg h3 { font-size: 1.1em; }
|
||||
.${pluginId}-msg ul, .${pluginId}-msg ol { padding-left: 1.2em; margin: 0.6em 0; }
|
||||
.${pluginId}-msg li { margin: 0.25em 0; line-height: 1.5; }
|
||||
.${pluginId}-msg blockquote { margin: 0.8em 0; padding: 0.6em 1em; border-left: 4px solid rgba(123,97,255,0.6); background: rgba(123,97,255,0.08); border-radius: 0 8px 8px 0; font-style: italic; }
|
||||
.${pluginId}-msg code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; background: rgba(0,0,0,0.4); padding: 0.15em 0.35em; border-radius: 6px; font-size: 0.9em; border: 1px solid rgba(255,255,255,0.1); }
|
||||
.${pluginId}-msg pre { background: rgba(0,0,0,0.5); padding: 12px 14px; border-radius: 10px; overflow: auto; border: 1px solid rgba(255,255,255,0.12); margin: 0.8em 0; line-height: 1.45; }
|
||||
.${pluginId}-msg pre code { background: transparent; padding: 0; border: none; font-size: 0.85em; }
|
||||
.${pluginId}-msg a { color: #6cb6ff; text-decoration: none; border-bottom: 1px solid transparent; transition: border-color 0.2s; }
|
||||
.${pluginId}-msg a:hover { border-bottom-color: #6cb6ff; }
|
||||
.${pluginId}-msg table { border-collapse: collapse; margin: 0.8em 0; width: 100%; font-size: 0.9em; }
|
||||
.${pluginId}-msg th, .${pluginId}-msg td { border: 1px solid rgba(255,255,255,0.15); padding: 0.5em 0.7em; text-align: left; }
|
||||
.${pluginId}-msg th { background: rgba(255,255,255,0.05); font-weight: 600; }
|
||||
.${pluginId}-msg hr { border: none; height: 1px; background: rgba(255,255,255,0.15); margin: 1.5em 0; }
|
||||
.${pluginId}-msg strong { font-weight: 600; }
|
||||
.${pluginId}-msg em { font-style: italic; }
|
||||
/* Enhanced highlight colors aligned to theme */
|
||||
.${pluginId}-msg .hljs { color: var(--text, #e8e8f0); background: transparent !important; }
|
||||
.${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}-msg .hljs-function { color: #82aaff; }
|
||||
.${pluginId}-msg .hljs-variable { color: #ffcb6b; }
|
||||
.${pluginId}-msg .hljs-type { color: #c3e88d; }
|
||||
.${pluginId}-msg .hljs-built_in { color: #ff5370; }
|
||||
.${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();
|
||||
function openFullPage() {
|
||||
console.log('[Nebot] Open Page button clicked');
|
||||
try {
|
||||
const target = 'browser://nebot';
|
||||
let opened = false;
|
||||
// 0) Try window.postMessage bridge (works across contextIsolation)
|
||||
try {
|
||||
window.postMessage({ type: 'open-internal-page', url: target }, '*');
|
||||
opened = true;
|
||||
console.log('[Nebot] Posted message to open internal page', target);
|
||||
} catch {}
|
||||
// 1) Preferred path: ask host (tab manager) via sendToHost so this works inside any webview
|
||||
try {
|
||||
if (!opened && ipcRenderer && typeof ipcRenderer.sendToHost === 'function') {
|
||||
ipcRenderer.sendToHost('navigate', target, { newTab: true });
|
||||
opened = true;
|
||||
console.log('[Nebot] Requested host to open new tab for', target);
|
||||
}
|
||||
} catch {}
|
||||
// 2) If we're actually in the top-level renderer (not a webview) window.createTab will exist
|
||||
if (!opened && typeof window.createTab === 'function') {
|
||||
window.createTab(target);
|
||||
opened = true;
|
||||
console.log('[Nebot] Used window.createTab fallback for', target);
|
||||
}
|
||||
// 3) Last resort: manipulate URL bar + navigate (top-level renderer only)
|
||||
if (!opened && typeof window.navigate === 'function') {
|
||||
const urlBox = document.getElementById('url');
|
||||
if (urlBox) { urlBox.value = target; window.navigate(); opened = true; }
|
||||
console.log('[Nebot] Used window.navigate fallback for', target);
|
||||
}
|
||||
if (!opened) console.warn('[Nebot] Failed to find a method to open full page Nebot');
|
||||
} catch (e) {
|
||||
console.warn('Failed to open full Nebot page', e);
|
||||
} finally {
|
||||
closePanel(document.getElementById(`${pluginId}-panel`));
|
||||
}
|
||||
}
|
||||
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`, title: 'Open full-page Nebot (browser://nebot)', onclick: openFullPage }, 'Open Page'),
|
||||
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 deepseek-r1:8b');
|
||||
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 {}
|
||||
})();
|
||||
@@ -1,57 +0,0 @@
|
||||
# Markdown Test Message
|
||||
|
||||
Here's a demonstration of the **rich text formatting** capabilities:
|
||||
|
||||
## Text Formatting
|
||||
- **Bold text** with double asterisks
|
||||
- *Italic text* with single asterisks
|
||||
- `Inline code` with backticks
|
||||
|
||||
## Code Blocks
|
||||
```javascript
|
||||
function greetUser(name) {
|
||||
console.log(`Hello, ${name}!`);
|
||||
return `Welcome to Nebot!`;
|
||||
}
|
||||
```
|
||||
|
||||
```python
|
||||
def calculate_fibonacci(n):
|
||||
if n <= 1:
|
||||
return n
|
||||
return calculate_fibonacci(n-1) + calculate_fibonacci(n-2)
|
||||
```
|
||||
|
||||
## Lists and Structure
|
||||
|
||||
### Ordered List
|
||||
1. First item
|
||||
2. Second item
|
||||
3. Third item
|
||||
|
||||
### Unordered List
|
||||
- Feature A
|
||||
- Feature B
|
||||
- Feature C
|
||||
|
||||
## Blockquotes
|
||||
|
||||
> This is a blockquote example.
|
||||
> It can span multiple lines and provides
|
||||
> emphasis for important information.
|
||||
|
||||
## Links and More
|
||||
|
||||
Check out [this link](https://github.com) for more information.
|
||||
|
||||
---
|
||||
|
||||
## Tables
|
||||
|
||||
| Feature | Status | Notes |
|
||||
|---------|--------|-------|
|
||||
| Bold text | ✅ Working | Looks great |
|
||||
| Code blocks | ✅ Working | Syntax highlighted |
|
||||
| Links | ✅ Working | Open in new tab |
|
||||
|
||||
That's it! The markdown rendering should now work beautifully in both the popup panel and the dedicated page.
|
||||
@@ -1,85 +0,0 @@
|
||||
// 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');
|
||||
};
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -1,286 +0,0 @@
|
||||
// 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