diff --git a/.gitignore b/.gitignore index ef20e1f..50ba5b2 100644 --- a/.gitignore +++ b/.gitignore @@ -82,6 +82,5 @@ typings/ # IDE config files .vscode/ .idea/ -site-history.json -site-history.json + site-history.json diff --git a/package-lock.json b/package-lock.json index 7d5282f..7830f81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,11 @@ "name": "nebula", "version": "1.0.0", "license": "ISC", + "dependencies": { + "dompurify": "^3.1.6", + "highlight.js": "^11.9.0", + "marked": "^12.0.2" + }, "devDependencies": { "electron": "^37.3.1", "electron-builder": "^23.0.0", @@ -335,6 +340,13 @@ "@types/node": "*" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/verror": { "version": "1.10.11", "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", @@ -1338,6 +1350,15 @@ "node": ">=8" } }, + "node_modules/dompurify": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz", + "integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dotenv": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-9.0.2.tgz", @@ -2126,6 +2147,15 @@ "node": ">= 0.4" } }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/hosted-git-info": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", @@ -2428,6 +2458,18 @@ "node": ">=10" } }, + "node_modules/marked": { + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-12.0.2.tgz", + "integrity": "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/matcher": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", diff --git a/package.json b/package.json index 84d9836..0b6523b 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,11 @@ "author": "", "license": "ISC", "description": "", + "dependencies": { + "dompurify": "^3.1.6", + "highlight.js": "^11.9.0", + "marked": "^12.0.2" + }, "devDependencies": { "electron": "^37.3.1", "electron-builder": "^23.0.0", diff --git a/plugins/nebot/main.js b/plugins/nebot/main.js new file mode 100644 index 0000000..dfc0347 --- /dev/null +++ b/plugins/nebot/main.js @@ -0,0 +1,291 @@ +// Nebot plugin - main process side +// Responsibilities: +// - Persist chat sessions under the plugin directory (JSON files) +// - IPC handlers for CRUD + streaming chat completions via Ollama HTTP API +// - Add a Help menu item to toggle the chat panel in the renderer + +const fs = require('fs'); +const path = require('path'); + +/** + * A tiny JSON store stored in pluginDir/chats + */ +function ensureDirSync(p) { + try { fs.mkdirSync(p, { recursive: true }); } catch {} +} + +function readJSONSafe(p, fallback) { + try { return JSON.parse(fs.readFileSync(p, 'utf-8')); } catch { return fallback; } +} + +function writeJSONSafe(p, data) { + fs.writeFileSync(p, JSON.stringify(data, null, 2), 'utf-8'); +} + +module.exports.activate = function(ctx) { + const pluginId = 'ollama-chat'; + const pluginDir = ctx.paths?.pluginDir || ctx.paths?.appPath || process.cwd(); + const userPlugins = path.join(ctx.paths?.userData || pluginDir, 'plugins'); + // Prefer saving under userData/plugins/ 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); } +}; diff --git a/plugins/nebot/plugin.json b/plugins/nebot/plugin.json new file mode 100644 index 0000000..2dbe9ec --- /dev/null +++ b/plugins/nebot/plugin.json @@ -0,0 +1,9 @@ +{ + "id": "ollama-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", + "enabled": true +} \ No newline at end of file diff --git a/plugins/nebot/renderer-preload.js b/plugins/nebot/renderer-preload.js new file mode 100644 index 0000000..7eaa869 --- /dev/null +++ b/plugins/nebot/renderer-preload.js @@ -0,0 +1,488 @@ +// Renderer preload for Nebot plugin +const { contextBridge, ipcRenderer } = require('electron'); +// Markdown rendering & sanitization +let marked, hljs, createDOMPurify, DOMPurify; +try { + // These will be available after adding dependencies to package.json + marked = require('marked'); + hljs = require('highlight.js'); + createDOMPurify = require('dompurify'); + DOMPurify = createDOMPurify(window); + marked.setOptions({ + breaks: true, + highlight(code, lang) { + try { + if (lang && hljs.getLanguage(lang)) { + return hljs.highlight(code, { language: lang }).value; + } + } catch {} + try { + return hljs.highlightAuto(code).value; + } catch { return code; } + } + }); +} catch (e) { + // If libs aren't available yet, we'll gracefully render as plain text. +} + +const pluginId = 'ollama-chat'; + +// Expose minimal API for page scripts (optional) +contextBridge.exposeInMainWorld('ollamaChat', { + toggle: () => ipcRenderer.send(`${pluginId}:toggle`), + listChats: () => ipcRenderer.invoke(`${pluginId}:list-chats`), + getChat: (id) => ipcRenderer.invoke(`${pluginId}:get-chat`, { id }), + createChat: (title) => ipcRenderer.invoke(`${pluginId}:create-chat`, { title }), + deleteChat: (id) => ipcRenderer.invoke(`${pluginId}:delete-chat`, { id }), + getSettings: () => ipcRenderer.invoke(`${pluginId}:get-settings`), + setSettings: (s) => ipcRenderer.invoke(`${pluginId}:set-settings`, s), + send: (id, content) => ipcRenderer.invoke(`${pluginId}:send`, { id, content }), +}); + +// UI Injection: floating panel +function ensureStyles() { + if (document.getElementById(`${pluginId}-styles`)) return; + const style = document.createElement('style'); + style.id = `${pluginId}-styles`; + style.textContent = ` + .${pluginId}-panel { position: fixed; background: + linear-gradient(180deg, rgba(22,25,37,0.8), rgba(16,18,26,0.82)) padding-box, + linear-gradient(135deg, rgba(140,86,255,0.22), rgba(62,149,255,0.18)) border-box; + color: var(--text, #e8e8f0); border: 1px solid transparent; display: flex; flex-direction: column; overflow: hidden; z-index: 999999; position: fixed; overscroll-behavior: contain; + -webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px); box-shadow: var(--shadow-1, 0 6px 20px rgba(0,0,0,.35)); } + .${pluginId}-panel.floating { right: 16px; bottom: 16px; width: var(--ollama-chat-width, 460px); height: 70vh; max-height: 92vh; border-radius: var(--radius-lg, 16px); } + .${pluginId}-panel.docked { right: 0; top: var(--nebula-header-height, 0px); bottom: 0; width: var(--ollama-chat-width, 460px); height: calc(100vh - var(--nebula-header-height, 0px)); border-left: 1px solid rgba(255,255,255,0.06); border-radius: 0; box-shadow: none; } + .${pluginId}-resizer { position: absolute; left: 0; top: 0; bottom: 0; width: 8px; cursor: ew-resize; background: linear-gradient(90deg, rgba(255,255,255,0.06), rgba(255,255,255,0)); opacity: 0.25; } + .${pluginId}-resizer:hover { opacity: 0.5; } + .${pluginId}-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; background: + linear-gradient(180deg, rgba(24,26,36,0.7), rgba(24,26,36,0.62)); border-bottom: 1px solid rgba(255,255,255,0.06); font-weight: 600; } + .${pluginId}-btn { background: var(--accent, #7b61ff); color: #fff; border: 1px solid transparent; padding: 6px 10px; border-radius: 8px; cursor: pointer; box-shadow: inset 0 1px 0 rgba(255,255,255,0.05); } + .${pluginId}-btn:hover { filter: brightness(1.05); } + .${pluginId}-btn:active { transform: translateY(1px); } + .${pluginId}-btn.secondary { background: rgba(255,255,255,0.12); border-color: rgba(255,255,255,0.14); color: var(--text, #e8e8f0); } + .${pluginId}-body { display: grid; grid-template-columns: 260px 1fr; flex: 1 1 auto; min-height: 0; height: auto; } + .${pluginId}-sidebar { border-right: 1px solid rgba(255,255,255,0.06); overflow: auto; background: rgba(0,0,0,0.08); min-height: 0; } + .${pluginId}-chatlist { list-style: none; margin: 0; padding: 8px; } + .${pluginId}-chatlist li { display: flex; align-items: center; gap: 8px; padding: 10px 10px; cursor: pointer; border: 1px solid rgba(255,255,255,0.06); border-radius: 12px; margin-bottom: 8px; background: rgba(255,255,255,0.03); } + .${pluginId}-chatlist li:hover { background: rgba(255,255,255,0.06); } + .${pluginId}-chatlist li.active { background: rgba(123,97,255,0.16); border-color: rgba(123,97,255,0.38); } + .${pluginId}-chat-item-main { display: flex; flex-direction: column; gap: 2px; flex: 1 1 auto; min-width: 0; } + .${pluginId}-chat-title { font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .${pluginId}-chat-meta { font-size: 11px; color: var(--muted, #a4a7b3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .${pluginId}-chat-actions { display: flex; align-items: center; gap: 4px; } + .${pluginId}-icon-btn { background: transparent; color: var(--text, #e8e8f0); border: 1px solid rgba(255,255,255,0.14); width: 28px; height: 28px; border-radius: 8px; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; } + .${pluginId}-icon-btn:hover { background: rgba(255,255,255,0.12); } + .${pluginId}-main { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; } + .${pluginId}-msgs { flex: 1 1 auto; overflow: auto; padding: 14px 12px; scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.25) transparent; min-height: 0; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; touch-action: pan-y; } + .${pluginId}-msgs::-webkit-scrollbar { width: 10px; } + .${pluginId}-msgs::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.22); border-radius: 10px; } + .${pluginId}-msgs::-webkit-scrollbar-track { background: transparent; } + .${pluginId}-msg { margin: 8px 0; padding: 10px 12px; border-radius: 12px; max-width: 88%; line-height: 1.5; } + .${pluginId}-msg.user { background: + linear-gradient(180deg, rgba(36,40,66,0.8), rgba(28,32,52,0.78)); border: 1px solid rgba(123,97,255,0.28); align-self: flex-end; } + .${pluginId}-msg.assistant { background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.08); align-self: flex-start; } + /* Rich content styles */ + .${pluginId}-msg * { color: inherit; } + .${pluginId}-msg p { margin: 0.4em 0; } + .${pluginId}-msg h1, .${pluginId}-msg h2, .${pluginId}-msg h3 { margin: 0.6em 0 0.3em; font-weight: 700; } + .${pluginId}-msg ul, .${pluginId}-msg ol { padding-left: 1.2em; margin: 0.4em 0; } + .${pluginId}-msg blockquote { margin: 0.6em 0; padding: 0.4em 0.8em; border-left: 3px solid rgba(255,255,255,0.25); background: rgba(255,255,255,0.05); border-radius: 8px; } + .${pluginId}-msg code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; background: rgba(0,0,0,0.35); padding: 0.15em 0.35em; border-radius: 6px; } + .${pluginId}-msg pre { background: rgba(0,0,0,0.4); padding: 10px; border-radius: 10px; overflow: auto; border: 1px solid rgba(255,255,255,0.08); } + .${pluginId}-msg pre code { background: transparent; padding: 0; } + /* Minimal highlight colors aligned to theme */ + .${pluginId}-msg .hljs { color: var(--text, #e8e8f0); } + .${pluginId}-msg .hljs-keyword, .${pluginId}-msg .hljs-selector-tag { color: #c792ea; } + .${pluginId}-msg .hljs-string, .${pluginId}-msg .hljs-attr { color: #ecc48d; } + .${pluginId}-msg .hljs-number, .${pluginId}-msg .hljs-literal { color: #f78c6c; } + .${pluginId}-msg .hljs-comment { color: #7f848e; } + .${pluginId}-composer { display: flex; gap: 8px; padding: 10px; border-top: 1px solid rgba(255,255,255,0.06); background: rgba(0,0,0,0.06); } + .${pluginId}-composer textarea { flex: 1; resize: vertical; min-height: 44px; max-height: 140px; background: rgba(0,0,0,0.28); color: var(--text, #e8e8f0); border: 1px solid rgba(255,255,255,0.12); border-radius: 10px; padding: 10px 12px; outline: none; } + .${pluginId}-composer textarea:focus { border-color: rgba(123,97,255,0.45); box-shadow: 0 0 0 3px rgba(123,97,255,0.18); } + .${pluginId}-footer { display: flex; gap: 6px; align-items: center; padding: 8px 10px; background: rgba(0,0,0,0.08); border-top: 1px solid rgba(255,255,255,0.06); color: var(--muted, #a4a7b3); font-size: 12px; } + /* Shrink main page content when docked panel is open */ + #webviews { width: calc(100% - var(--ollama-right-offset, 0px)) !important; } + #home-container { width: calc(100% - var(--ollama-right-offset, 0px)) !important; } + `; + document.head.appendChild(style); +} + +function h(tag, attrs = {}, ...children) { + const el = document.createElement(tag); + for (const [k, v] of Object.entries(attrs)) { + if (k === 'class') el.className = v; + else if (k === 'onclick') el.addEventListener('click', v); + else el.setAttribute(k, v); + } + for (const c of children) { + if (c == null) continue; + el.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); + } + return el; +} + +let state = { chats: [], currentId: null, streaming: false, docked: true, width: 0 }; +let els = {}; + +function getSavedWidth() { + const v = Number(localStorage.getItem(`${pluginId}:width`) || '0'); + return Number.isFinite(v) && v >= 300 ? v : 460; +} + +function saveWidth(w) { + try { localStorage.setItem(`${pluginId}:width`, String(w)); } catch {} +} + +function applyWidth(root, w) { + const min = 320, max = 1024; + const clamped = Math.max(min, Math.min(max, Math.round(w))); + state.width = clamped; + root.style.setProperty('--ollama-chat-width', `${clamped}px`); + setPageOffset(root); +} + +function initResizer(root) { + const handle = h('div', { class: `${pluginId}-resizer` }); + root.appendChild(handle); + let startX = 0, startW = 0, moving = false; + const onMove = (e) => { + if (!moving) return; + const clientX = e.touches ? e.touches[0].clientX : e.clientX; + const deltaX = clientX - startX; + const next = startW - deltaX; // anchored to right, dragging left increases width + applyWidth(root, next); + }; + const onUp = () => { + if (!moving) return; + moving = false; + document.body.style.userSelect = ''; + saveWidth(state.width); + window.removeEventListener('mousemove', onMove); + window.removeEventListener('mouseup', onUp); + window.removeEventListener('touchmove', onMove); + window.removeEventListener('touchend', onUp); + }; + const onDown = (e) => { + e.preventDefault(); + const rect = root.getBoundingClientRect(); + startW = rect.width; + startX = e.touches ? e.touches[0].clientX : e.clientX; + moving = true; + document.body.style.userSelect = 'none'; + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + window.addEventListener('touchmove', onMove, { passive: false }); + window.addEventListener('touchend', onUp); + }; + handle.addEventListener('mousedown', onDown); + handle.addEventListener('touchstart', onDown, { passive: false }); +} + +function setPageOffset(root) { + try { + // Only offset when docked so the page remains fully visible behind the panel + const px = (state.docked && root && document.body.contains(root)) ? state.width : 0; + document.documentElement.style.setProperty('--ollama-right-offset', `${px}px`); + // Force a reflow so and layout pick up the width change immediately + // by reading offsetWidth of an affected element. + const target = document.getElementById('webviews') || document.getElementById('home-container'); + if (target) void target.offsetWidth; // reflow hint + } catch {} +} + +function closePanel(root) { + setTimeout(() => { + try { document.documentElement.style.setProperty('--ollama-right-offset', '0px'); } catch {} + }, 0); + root.remove(); +} + +function mdToHtml(md) { + // Fall back to simple escape if libs not present + if (!marked || !DOMPurify) { + const div = document.createElement('div'); + div.textContent = md; + return div.innerHTML; + } + const raw = marked.parse(md || ''); + const clean = DOMPurify.sanitize(raw, { ADD_ATTR: ['target', 'rel', 'class'] }); + return clean; +} + +function setRichContent(el, md) { + el.innerHTML = mdToHtml(md); + // Enhance links to open in new tab and be safe + el.querySelectorAll('a[href]').forEach(a => { + a.setAttribute('target', '_blank'); + a.setAttribute('rel', 'noopener noreferrer'); + }); +} + +async function refreshList() { + const { chats } = await ipcRenderer.invoke(`${pluginId}:list-chats`); + state.chats = chats || []; + renderList(); +} + +async function openChat(id) { + state.currentId = id; + const { chat, error } = await ipcRenderer.invoke(`${pluginId}:get-chat`, { id }); + if (error) return; + renderMessages(chat); + renderList(); + subscribeStream(id); +} + +async function newChat() { + const { chat } = await ipcRenderer.invoke(`${pluginId}:create-chat`, { title: 'Chat ' + new Date().toLocaleTimeString() }); + await refreshList(); + await openChat(chat.id); +} + +async function deleteChat(id) { + await ipcRenderer.invoke(`${pluginId}:delete-chat`, { id }); + await refreshList(); + if (state.currentId === id) { + state.currentId = state.chats[0]?.id || null; + if (state.currentId) openChat(state.currentId); else renderMessages(null); + } +} + +function subscribeStream(id) { + // Remove previous + ipcRenderer.removeAllListeners(`${pluginId}:stream:${id}`); + let buffer = ''; + let scheduled = null; + const scheduleRender = () => { + if (scheduled) return; + scheduled = requestAnimationFrame(() => { + const last = els.msgs && els.msgs.querySelector('.streaming'); + if (last) setRichContent(last, buffer); + scheduled = null; + }); + }; + ipcRenderer.on(`${pluginId}:stream:${id}`, (_e, payload) => { + if (!els.msgs) return; + if (payload.type === 'token') { + let last = els.msgs.querySelector('.streaming'); + if (!last) { + last = h('div', { class: `${pluginId}-msg assistant streaming` }); + els.msgs.appendChild(last); + buffer = ''; + } + buffer += payload.token || ''; + scheduleRender(); + els.msgs.scrollTop = els.msgs.scrollHeight; + } else if (payload.type === 'done') { + const last = els.msgs.querySelector('.streaming'); + if (last) { + setRichContent(last, buffer); + last.classList.remove('streaming'); + } + buffer = ''; + } + }); +} + +function renderList() { + if (!els.chatlist) return; + els.chatlist.innerHTML = ''; + for (const c of state.chats) { + const li = h('li', { class: state.currentId === c.id ? 'active' : '', onclick: () => openChat(c.id) }); + const updated = new Date(c.updatedAt || Date.now()).toLocaleString(); + const main = h('div', { class: `${pluginId}-chat-item-main` }, + h('div', { class: `${pluginId}-chat-title` }, c.title || 'Untitled Chat'), + h('div', { class: `${pluginId}-chat-meta` }, updated) + ); + const actions = h('div', { class: `${pluginId}-chat-actions` }); + const del = h('button', { class: `${pluginId}-icon-btn`, title: 'Delete chat', onclick: (e) => { e.stopPropagation(); deleteChat(c.id); } }, '🗑'); + actions.appendChild(del); + li.appendChild(main); + li.appendChild(actions); + els.chatlist.appendChild(li); + } +} + +function renderMessages(chat) { + if (!els.msgs) return; + els.msgs.innerHTML = ''; + if (!chat) return; + for (const m of chat.messages) { + const div = h('div', { class: `${pluginId}-msg ${m.role}` }); + setRichContent(div, m.content); + els.msgs.appendChild(div); + } + els.msgs.scrollTop = els.msgs.scrollHeight; +} + +async function sendCurrent() { + const content = els.input.value.trim(); + if (!content) return; + // If no chat selected, create one on first send + if (!state.currentId) { + const { chat } = await ipcRenderer.invoke(`${pluginId}:create-chat`, { title: 'New chat' }); + await refreshList(); + state.currentId = chat.id; + await openChat(state.currentId); + } + els.input.value = ''; + // echo user message into UI immediately + const userDiv = h('div', { class: `${pluginId}-msg user` }); + // Render user content as plain text to avoid accidental HTML + userDiv.textContent = content; + els.msgs.appendChild(userDiv); + els.msgs.scrollTop = els.msgs.scrollHeight; + await ipcRenderer.invoke(`${pluginId}:send`, { id: state.currentId, content }); +} + +function setDockClass(root) { + root.classList.remove('floating', 'docked'); + root.classList.add(state.docked ? 'docked' : 'floating'); +} + +function toggleDock(root) { + state.docked = !state.docked; + setDockClass(root); + if (els.dockBtn) els.dockBtn.textContent = state.docked ? 'Undock' : 'Dock'; + setPageOffset(root); + applyHeaderOffset(); +} + +function panelEl() { + ensureStyles(); + applyHeaderOffset(); + let root = document.getElementById(`${pluginId}-panel`); + if (root) return root; + state.width = getSavedWidth(); + root = h('div', { id: `${pluginId}-panel`, class: `${pluginId}-panel ${state.docked ? 'docked' : 'floating'}` }, + h('div', { class: `${pluginId}-header` }, + h('span', {}, 'Nebot'), + h('div', {}, + h('button', { class: `${pluginId}-btn secondary`, onclick: () => closePanel(root) }, 'Close') + ) + ), + h('div', { class: `${pluginId}-body` }, + h('div', { class: `${pluginId}-sidebar` }, + h('div', { style: 'padding:6px;' }, + h('button', { class: `${pluginId}-btn`, onclick: newChat }, 'New chat') + ), + els.chatlist = h('ul', { class: `${pluginId}-chatlist` }) + ), + h('div', { class: `${pluginId}-main` }, + els.msgs = h('div', { class: `${pluginId}-msgs` }), + h('div', { class: `${pluginId}-composer` }, + els.input = h('textarea', { placeholder: 'Type a message to start a new chat…' }), + h('button', { class: `${pluginId}-btn`, onclick: sendCurrent }, 'Send') + ), + h('div', { class: `${pluginId}-footer` }, + h('small', {}, 'Messages are stored locally in the plugin folder.') + ) + ) + ) + ); + document.body.appendChild(root); + // Route assistant links to open in a new browser tab via host + const routeToNewTab = (url) => { + try { + // Prefer direct sendToHost when available + ipcRenderer.sendToHost('navigate', url, { newTab: true }); + } catch { + try { + if (window.parent && typeof window.parent.postMessage === 'function') { + window.parent.postMessage({ type: 'navigate', url, newTab: true }, '*'); + } else { + window.open(url, '_blank', 'noopener'); + } + } catch { + window.open(url, '_blank', 'noopener'); + } + } + }; + // Delegate clicks from within messages area + els.msgs.addEventListener('click', (e) => { + const a = e.target && e.target.closest ? e.target.closest('a[href]') : null; + if (!a) return; + const href = a.href || a.getAttribute('href'); + if (!href) return; + // Only intercept http(s) links for in-browser tabs + if (/^https?:\/\//i.test(href)) { + e.preventDefault(); + routeToNewTab(href); + } + }); + // Middle-click support (auxclick) + els.msgs.addEventListener('auxclick', (e) => { + if (e.button !== 1) return; + const a = e.target && e.target.closest ? e.target.closest('a[href]') : null; + if (!a) return; + const href = a.href || a.getAttribute('href'); + if (!href) return; + if (/^https?:\/\//i.test(href)) { + e.preventDefault(); + routeToNewTab(href); + } + }); + applyWidth(root, state.width); + initResizer(root); + refreshList().then(() => state.chats[0] && openChat(state.chats[0].id)); + return root; +} + +async function openSettings() { + const { settings } = await ipcRenderer.invoke(`${pluginId}:get-settings`); + const base = prompt('Ollama base URL', settings.ollamaBaseUrl || 'http://192.168.1.132:11434'); + if (base == null) return; + // Model is fixed; show message for clarity + alert('Model is fixed to gpt-oss:20b'); + const systemPrompt = prompt('System prompt', settings.systemPrompt || 'You are a helpful assistant inside the Nebula browser.'); + await ipcRenderer.invoke(`${pluginId}:set-settings`, { ollamaBaseUrl: base, systemPrompt }); +} + +// Listen for toggle from main menu +ipcRenderer.on(`${pluginId}:toggle`, () => { + const existing = document.getElementById(`${pluginId}-panel`); + if (existing) closePanel(existing); else panelEl(); +}); + +// When main updates a chat (e.g., after auto-title), refresh the list and keep selection +ipcRenderer.on('ollama-chat:chat-updated', (_e, { id, title }) => { + if (!state.chats.length) return; + const item = state.chats.find(c => c.id === id); + if (item) item.title = title; + renderList(); +}); + +// Also expose a global keyboard shortcut inside renderer (optional, light) +window.addEventListener('keydown', (e) => { + if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'o') { + e.preventDefault(); + const existing = document.getElementById(`${pluginId}-panel`); + if (existing) existing.remove(); else panelEl(); + } +}); + +// Compute header offset so docked panel doesn't overlap top UI +function applyHeaderOffset() { + try { + const tab = document.getElementById('tab-bar'); + const nav = document.getElementById('nav'); + let h = 0; + if (tab) h += Math.max(0, tab.getBoundingClientRect().height || 0); + if (nav) h += Math.max(0, nav.getBoundingClientRect().height || 0); + document.documentElement.style.setProperty('--nebula-header-height', `${Math.round(h)}px`); + } catch {} +} + +window.addEventListener('resize', applyHeaderOffset); +window.addEventListener('resize', () => setPageOffset(document.getElementById(`${pluginId}-panel`))); +document.addEventListener('DOMContentLoaded', applyHeaderOffset); +// Watch for dynamic header size changes +(() => { + try { + const ro = new ResizeObserver(() => applyHeaderOffset()); + const tab = document.getElementById('tab-bar'); + const nav = document.getElementById('nav'); + if (tab) ro.observe(tab); + if (nav) ro.observe(nav); + } catch {} +})(); diff --git a/plugins/sample-hello/main.js b/plugins/sample-hello/main.js deleted file mode 100644 index 65da9b8..0000000 --- a/plugins/sample-hello/main.js +++ /dev/null @@ -1,43 +0,0 @@ -// Sample main-process side of a plugin -module.exports.activate = function(ctx) { - ctx.log('activating'); - - // Add a simple menu item under Help - 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: 'Say Hello (Sample Plugin)', - click: () => { - const win = ctx.BrowserWindow.getFocusedWindow(); - if (win) win.webContents.send('sample-hello', { msg: 'Hello from plugin!' }); - } - })); - ctx.Menu.setApplicationMenu(ctx.Menu.getApplicationMenu()); - } - } - } catch (e) { ctx.warn('menu injection skipped', e); } - - // Simple IPC example - ctx.registerIPC('sample-hello:ping', async () => ({ pong: true })); - - // Optional: intercept a request (no-op demo) - ctx.registerWebRequest({ urls: ['*://*/*'] }, (details) => { - // Could cancel or redirect here, but we let it pass through - return { cancel: false }; - }); - - // Context menu contribution example - ctx.contributeContextMenu?.((template, params, sender) => { - template.push({ type: 'separator' }); - template.push({ - label: 'Sample: Greet Console', - click: () => { - try { (sender.hostWebContents || sender).executeJavaScript("console.log('[Sample Plugin] Hello from context menu')"); } catch {} - } - }); - }); -}; diff --git a/plugins/sample-hello/plugin.json b/plugins/sample-hello/plugin.json deleted file mode 100644 index 0498d3e..0000000 --- a/plugins/sample-hello/plugin.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "id": "sample-hello", - "name": "Sample Hello Plugin", - "version": "0.1.0", - "description": "Demonstrates Nebula plugin basics: add menu item and renderer API.", - "main": "main.js", - "rendererPreload": "renderer-preload.js", - "enabled": false -} \ No newline at end of file diff --git a/plugins/sample-hello/renderer-preload.js b/plugins/sample-hello/renderer-preload.js deleted file mode 100644 index 36f2811..0000000 --- a/plugins/sample-hello/renderer-preload.js +++ /dev/null @@ -1,8 +0,0 @@ -// Renderer preload for sample plugin -// You can expose new APIs to the page -const { contextBridge, ipcRenderer } = require('electron'); - -contextBridge.exposeInMainWorld('sampleHello', { - ping: () => ipcRenderer.invoke('sample-hello:ping'), - onHello: (handler) => ipcRenderer.on('sample-hello', (_e, payload) => handler(payload)) -}); diff --git a/site-history.json b/site-history.json deleted file mode 100644 index a37f34d..0000000 --- a/site-history.json +++ /dev/null @@ -1,29 +0,0 @@ -[ - "https://www.google.com/search?q=Andrew%20Zambazos", - "https://nebula.zambazosmedia.group/", - "https://www.google.com/search?q=transparent%20imges&sei=Vxa-aKjEILaVg8UPxfWfuA0", - "https://pngtree.com/free-png", - "https://www.rawpixel.com/search/transparent%20png?page=1&sort=curated", - "https://www.rawpixel.com/image/6329319/png-sticker-public-domain", - "https://www.rawpixel.com/image/6329319/png-sticker-public-domain#eyJrZXlzIjoidHJhbnNwYXJlbnQgcG5nIiwic29ydGVkS2V5cyI6InBuZyB0cmFuc3BhcmVudCJ9", - "https://www.rawpixel.com/search/transparent%20png", - "https://www.google.com/search?q=transparent%20imges", - "https://www.youtube.com/", - "https://www.youtube.com/?themeRefresh=1", - "https://github.com/sessions/two-factor/webauthn", - "https://github.com/sessions/social/google/confirm", - "https://github.com/sessions/social/google/initiate?return_to=", - "https://github.com/login", - "https://github.com/", - "https://accounts.google.com/signin/oauth/id?authuser=0&part=AJi8hANsZCAVV0qiARVlRDOPJq2iA0sKnOjsfyAyfsCCaFtz40396topRrJ_kribwlDRt09x0FsnCgAoNsMpF86iespql6pWiRKquVqWzDTLpYPf4k00TTQHc2oeVDKs3TjJnW1YhfSCs33GbadOh3esuALJOiO9qF70vsfqS2w71DZr_1Xiqpe_rIQbT2SBpKpEfbg-PuWvP9MPROGy3S_VwjjDe9g3Z0cq_HJQmwgd-Q-eMGi4zdn-213XolV7mY2DpFkGVKKrS-VC6sF1oPg7gvV3DCGwtfmcGtBO--U72WgSk9RLr-qK2nxoVIGbe2YGKC6ms1jc0yCTns8-ZkuxLvCEFdEN6Dfb3xh3Ql1jWM7rhT-4i4eJt371JUPUP55VjQ5hjRifUcxnRlmIa0lv8eW0ITYc1w41fTUL2KvHz-klfLp7EtPbPZAv-se4IoEP8vTe-vnBAdUQOcZaxG1Q_FotZLmIMrZpnqW-9xIKnhH5yrUKhpCKciz8bcQToc0IrwNPs5ss7hFfrFs8xIz2iLDIWIVZcvC-W0glyk_v2Fznr0wAQI7aXWqtr5WG95gpNxKEwhV6czCHc0PKy1kfqqW_rUlnkaB45Nn46AcXEtMmphjh2FV39XkrPnc9_66Zgnks8XqyPT-g2RqtPtszboV4FdFHXy0HE04vN0FXDJwCpYBMOXhcn4l_SjqXbLmXQedjXpJRe5C0ClJxnwvltQ1YN5Qymgxs5Ms6gYUAqrenL7T_KHqUSM4uuNrqQszAb6PAf_SmIXEx829UYUA_gXxWOA3wdyCU0DUyoeGXBrjiiK97ICJ2fwch0McBpfFeMCkOBrF6jCEciRXYVUjH0EBD5TlWIw&flowName=GeneralOAuthFlow&as=S2063921138%3A1756253045404292&client_id=1078992815106-brpsupgvhheqg35tupphbh0qk9c32nq8.apps.googleusercontent.com&rapt=AEjHL4OVSpGthcXIZ7SFjXPErM4eV4qOo1ja7xGGlJqxtPaUwD68ZqZenb5RWZfR83Kucg-u4loriooc5Dj69kjLrPMI6dc_1w#", - "https://accounts.google.com/o/oauth2/v2/auth/oauthchooseaccount?client_id=1078992815106-brpsupgvhheqg35tupphbh0qk9c32nq8.apps.googleusercontent.com&response_type=code&redirect_uri=https%3A%2F%2Fgithub.com%2Fsessions%2Fsocial%2Fgoogle%2Fcallback&scope=openid%20email%20profile&state=cab0565714cb155c7fcf8972e3897cac&nonce=342bbf0571704ddc5883dcd11e356dc7&code_challenge=n9iUWC-AoLe6HzVPYfPx1FJDxd7ykmUEvBtUdvgBLK4&code_challenge_method=S256&service=lso&o2v=2&flowName=GeneralOAuthFlow", - "https://accounts.google.com/o/oauth2/v2/auth?client_id=1078992815106-brpsupgvhheqg35tupphbh0qk9c32nq8.apps.googleusercontent.com&response_type=code&redirect_uri=https%3A%2F%2Fgithub.com%2Fsessions%2Fsocial%2Fgoogle%2Fcallback&scope=openid%20email%20profile&state=cab0565714cb155c7fcf8972e3897cac&nonce=342bbf0571704ddc5883dcd11e356dc7&code_challenge=n9iUWC-AoLe6HzVPYfPx1FJDxd7ykmUEvBtUdvgBLK4&code_challenge_method=S256&service=lso&o2v=2", - "https://accounts.google.com/o/oauth2/v2/auth?client_id=1078992815106-brpsupgvhheqg35tupphbh0qk9c32nq8.apps.googleusercontent.com&response_type=code&redirect_uri=https%3A%2F%2Fgithub.com%2Fsessions%2Fsocial%2Fgoogle%2Fcallback&scope=openid+email+profile&state=cab0565714cb155c7fcf8972e3897cac&nonce=342bbf0571704ddc5883dcd11e356dc7&code_challenge=n9iUWC-AoLe6HzVPYfPx1FJDxd7ykmUEvBtUdvgBLK4&code_challenge_method=S256", - "https://www.google.com/search?sca_esv=8740a35e22b44344&q=google&source=lnms&fbs=AIIjpHzThbnmQ2WmOKxM311CRlKFRYIYkDZQGNzKWZOtgUvrU8IkX2D8ZljVyZBwLc67VO5Vh9BmSq-tJTelkfgGaeLOhsWoMcpPaMobUKT2lDeoy4baWd3FunAQvdgUkx-O2UqTNd3rElthg_q_RDuOd63_-9VEOzcZa8DOthTdfufpgCAS8atIRQu6ndbQbff19E3EbkrdgATyQCGbkaHPxI6YLnT-EcRkSXiWTOApoudKAVtFgl4&sa=X&ved=2ahUKEwiP8MW4nIyPAxXwr1YBHfDEE0wQ0pQJegQICRAB&biw=2544&bih=1251&dpr=1.5", - "https://www.google.com/search?sca_esv=8740a35e22b44344&udm=2&fbs=AIIjpHxU7SXXniUZfeShr2fp4giZ1Y6MJ25_tmWITc7uy4KIeuYzzFkfneXafNx6OMdA4MQRJc_t_TQjwHYrzlkIauOKj9nSuujpEIbB1x32lFLEvBmmX-p1UI3WlSFH86-EF1CpFR0tZjCgi5bM20K3xHOK3droXh0yMXraJ5han3x4rkl9Co5S6JKPNx1fHkXHoy-qehbRF1XGgIa6fKwyF5LNOJ-3xQ&q=google&sa=X&ved=2ahUKEwij2L2QnIyPAxVjsVYBHf8oASsQtKgLegQIHhAB&cshid=1755240501153677&biw=2544&bih=1251&dpr=1.5", - "https://www.google.com/search?q=google#cobssid=s", - "https://www.google.com/", - "https://www.google.com/search?q=google", - "https://www.whatismybrowser.com/", - "https://www.google.com/search?q=what%20is%20my%20browser" -] \ No newline at end of file