diff --git a/CHANGELOG.md b/CHANGELOG.md index efb6df45..1b1291f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 0.3.0 (2026-05-30) + +### Features + +- Keyboard shortcuts to control the chat overlay: **Alt+G** toggles open/closed from anywhere on the page, **Escape** closes it +- Shortcuts are configurable from the settings panel — click a shortcut field, press the key combination to rebind, or reset to default + +### Fixes + +- Shortcuts now work while the chat input is focused (the input previously swallowed the key events), so Alt+G and Escape reliably toggle/close the chat from inside the overlay + ## 0.2.0 (2026-04-06) ### Features diff --git a/README.md b/README.md index 085ff3ea..77bba318 100644 --- a/README.md +++ b/README.md @@ -55,9 +55,19 @@ Click the gear icon in the chat header: - **Model**: Switch between Gemma 4 E2B (~500MB) and E4B (~1.5GB). Selection persists across sessions. - **Thinking**: Toggle native Gemma 4 thinking - **Max iterations**: Cap on tool call loops per request +- **Shortcuts**: Rebind the keyboard shortcuts that toggle and close the chat. Click a shortcut field, press the key combination you want, and it's saved. The `↺` button resets a shortcut to its default. Defaults: **Alt+G** toggles the chat, **Escape** closes it. Bindings persist across sessions. - **Clear context**: Reset conversation history for the current page - **Disable on this site**: Disable the extension per-hostname (persisted) +## Keyboard Shortcuts + +| Shortcut | Action | Default | +|----------|--------|---------| +| Toggle chat | Open/close the chat overlay from anywhere on the page | `Alt+G` | +| Close chat | Close the overlay when it's open | `Escape` | + +Both are rebindable from the gear-icon settings panel (see **Shortcuts** above). + ## Development ```bash diff --git a/content/chat-overlay.ts b/content/chat-overlay.ts index bae5dff6..d2495fae 100644 --- a/content/chat-overlay.ts +++ b/content/chat-overlay.ts @@ -1,5 +1,12 @@ import { marked } from 'marked' import { MODELS, DEFAULT_MODEL_ID, type ModelId } from '@/shared/models' +import { + DEFAULT_SHORTCUTS, + formatShortcut, + shortcutFromEvent, + type Shortcut, + type ShortcutsConfig, +} from '@/shared/shortcuts' marked.setOptions({ breaks: true }) @@ -124,6 +131,30 @@ const STYLES = ` } .setting-disable:hover { background: rgba(239, 68, 68, 0.25); } + /* Shortcut rebinding */ + .settings-divider { + height: 1px; background: rgba(139, 92, 246, 0.15); margin: 2px 0; + } + .settings-section-label { + font-size: 11px; color: #64748b; text-transform: uppercase; letter-spacing: 0.04em; + } + .shortcut-controls { display: flex; align-items: center; gap: 6px; } + .shortcut-key { + min-width: 76px; background: rgba(30, 30, 50, 0.6); border: 1px solid rgba(139, 92, 246, 0.2); + border-radius: 4px; padding: 3px 8px; color: #e2e8f0; font-size: 12px; + font-family: 'SF Mono', Menlo, Consolas, monospace; text-align: center; cursor: pointer; + transition: border-color 0.2s, color 0.2s; + } + .shortcut-key:hover { border-color: rgba(139, 92, 246, 0.5); } + .shortcut-key.recording { + border-color: rgba(165, 180, 252, 0.8); color: #a5b4fc; + } + .shortcut-reset { + background: none; border: none; color: #64748b; cursor: pointer; + font-size: 13px; padding: 0 2px; line-height: 1; transition: color 0.2s; + } + .shortcut-reset:hover { color: #e2e8f0; } + /* Messages */ .chat-messages { flex: 1; overflow-y: auto; padding: 12px; @@ -250,6 +281,36 @@ const STYLES = ` .chat-stop { background: rgba(239, 68, 68, 0.5); } .chat-stop:hover { background: rgba(239, 68, 68, 0.7); } .chat-send svg, .chat-stop svg { width: 18px; height: 18px; } + + /* Model picker */ + .model-picker { + display: none; + flex-direction: column; + align-items: center; + justify-content: center; + flex: 1; + padding: 24px; + gap: 14px; + } + .model-picker.visible { display: flex; } + .model-picker-title { font-size: 15px; font-weight: 600; color: #c4b5fd; text-align: center; } + .model-picker-subtitle { font-size: 11px; color: #64748b; text-align: center; line-height: 1.4; } + .model-options { display: flex; flex-direction: column; gap: 10px; width: 100%; } + .model-option { + background: rgba(30, 30, 50, 0.8); + border: 1px solid rgba(139, 92, 246, 0.25); + border-radius: 10px; + padding: 14px 16px; + cursor: pointer; + transition: background 0.2s, border-color 0.2s; + text-align: center; + user-select: none; + } + .model-option:hover { background: rgba(139, 92, 246, 0.15); border-color: rgba(139, 92, 246, 0.5); } + .model-option:active { background: rgba(139, 92, 246, 0.2); } + .model-option-name { font-size: 14px; font-weight: 600; color: #e2e8f0; } + .model-option-size { font-size: 11px; color: #a5b4fc; margin-top: 3px; } + .model-option-desc { font-size: 11px; color: #64748b; margin-top: 2px; } ` export interface ChatOverlayCallbacks { @@ -259,6 +320,7 @@ export interface ChatOverlayCallbacks { onClearContext: () => void onDisableSite: () => void onModelSwitch: (modelId: ModelId) => void + onShortcutsChange: (shortcuts: ShortcutsConfig) => void } export class ChatOverlay { @@ -275,6 +337,8 @@ export class ChatOverlay { private iterationsTag: HTMLElement private modelTag: HTMLElement private modelSelect: HTMLSelectElement + private modelPickerEl: HTMLElement + private inputAreaEl: HTMLElement private typingEl: HTMLElement | null = null private streamEl: HTMLElement | null = null private streamText = '' @@ -282,6 +346,12 @@ export class ChatOverlay { private thinkingStreamText = '' private visible = false settings: ChatSettings = { ...DEFAULT_SETTINGS } + private shortcuts: ShortcutsConfig = { + toggle: { ...DEFAULT_SHORTCUTS.toggle }, + close: { ...DEFAULT_SHORTCUTS.close }, + } + private shortcutKeyEls: Partial> = {} + private stopRecording: (() => void) | null = null constructor(callbacks: ChatOverlayCallbacks) { this.host = document.createElement('div') @@ -354,6 +424,18 @@ export class ChatOverlay { ` this.modelSelect = this.settingsPanel.querySelector('[data-setting="modelId"]') as HTMLSelectElement + + // Shortcuts section + const divider = document.createElement('div') + divider.className = 'settings-divider' + const shortcutsLabel = document.createElement('span') + shortcutsLabel.className = 'settings-section-label' + shortcutsLabel.textContent = 'Shortcuts' + this.settingsPanel.appendChild(divider) + this.settingsPanel.appendChild(shortcutsLabel) + this.settingsPanel.appendChild(this.createShortcutRow('toggle', 'Toggle chat', callbacks)) + this.settingsPanel.appendChild(this.createShortcutRow('close', 'Close chat', callbacks)) + const disableBtn = document.createElement('button') disableBtn.className = 'setting-disable' disableBtn.textContent = 'Disable on this site' @@ -405,13 +487,30 @@ export class ChatOverlay { statusBar.appendChild(tags) statusBar.appendChild(clearBtn) + // Model picker + this.modelPickerEl = document.createElement('div') + this.modelPickerEl.className = 'model-picker' + this.modelPickerEl.innerHTML = ` +
Choose a model
+
Select which Gemma 4 model to download and run locally via WebGPU.
+
+ ${Object.values(MODELS).map(m => ` +
+
${m.label}
+
${m.downloadSize}
+
${m.id === 'gemma-4-e2b' ? 'Faster, less RAM' : 'Smarter, more RAM'}
+
+ `).join('')} +
+ ` + // Messages this.messagesEl = document.createElement('div') this.messagesEl.className = 'chat-messages' // Input area - const inputArea = document.createElement('div') - inputArea.className = 'chat-input-area' + this.inputAreaEl = document.createElement('div') + this.inputAreaEl.className = 'chat-input-area' this.inputEl = document.createElement('textarea') this.inputEl.className = 'chat-input' this.inputEl.placeholder = 'Ask about this page...' @@ -425,17 +524,31 @@ export class ChatOverlay { this.stopBtn.style.display = 'none' this.stopBtn.innerHTML = '' - inputArea.appendChild(this.inputEl) - inputArea.appendChild(this.sendBtn) - inputArea.appendChild(this.stopBtn) + this.inputAreaEl.appendChild(this.inputEl) + this.inputAreaEl.appendChild(this.sendBtn) + this.inputAreaEl.appendChild(this.stopBtn) this.container.appendChild(header) this.container.appendChild(this.settingsPanel) this.container.appendChild(statusBar) + this.container.appendChild(this.modelPickerEl) this.container.appendChild(this.messagesEl) - this.container.appendChild(inputArea) + this.container.appendChild(this.inputAreaEl) + this.shadow.appendChild(this.container) + // Model picker click handlers + const pickerOptions = this.modelPickerEl.querySelectorAll('.model-option') + for (const opt of pickerOptions) { + opt.addEventListener('click', () => { + const modelId = (opt as HTMLElement).dataset.model as ModelId + if (modelId) { + this.hideModelPicker() + callbacks.onModelSwitch(modelId) + } + }) + } + this.sendBtn.addEventListener('click', () => this.handleSend(callbacks.onSend)) this.stopBtn.addEventListener('click', () => callbacks.onStop()) @@ -457,6 +570,99 @@ export class ChatOverlay { this.iterationsTag.textContent = `\u{1F504} ${this.settings.maxIterations} iters` } + private createShortcutRow( + action: keyof ShortcutsConfig, + label: string, + callbacks: ChatOverlayCallbacks, + ): HTMLElement { + const row = document.createElement('div') + row.className = 'setting-row' + + const labelEl = document.createElement('span') + labelEl.className = 'setting-label' + labelEl.textContent = label + + const controls = document.createElement('div') + controls.className = 'shortcut-controls' + + const keyEl = document.createElement('button') + keyEl.className = 'shortcut-key' + keyEl.title = 'Click, then press the keys to bind' + keyEl.addEventListener('click', () => this.beginRecording(action, callbacks)) + + const resetEl = document.createElement('button') + resetEl.className = 'shortcut-reset' + resetEl.textContent = '↺' + resetEl.title = 'Reset to default' + resetEl.addEventListener('click', () => { + this.cancelRecording() + this.shortcuts[action] = { ...DEFAULT_SHORTCUTS[action] } + this.renderShortcut(action) + callbacks.onShortcutsChange(this.cloneShortcuts()) + }) + + controls.appendChild(keyEl) + controls.appendChild(resetEl) + row.appendChild(labelEl) + row.appendChild(controls) + + this.shortcutKeyEls[action] = keyEl + this.renderShortcut(action) + return row + } + + private renderShortcut(action: keyof ShortcutsConfig): void { + const keyEl = this.shortcutKeyEls[action] + if (!keyEl) return + keyEl.classList.remove('recording') + keyEl.textContent = formatShortcut(this.shortcuts[action]) + } + + private cloneShortcuts(): ShortcutsConfig { + return { toggle: { ...this.shortcuts.toggle }, close: { ...this.shortcuts.close } } + } + + private cancelRecording(): void { + if (this.stopRecording) this.stopRecording() + } + + isRecording(): boolean { + return this.stopRecording !== null + } + + private beginRecording(action: keyof ShortcutsConfig, callbacks: ChatOverlayCallbacks): void { + // A second click on the same field cancels recording. + if (this.stopRecording) { + this.stopRecording() + return + } + + const keyEl = this.shortcutKeyEls[action] + if (!keyEl) return + keyEl.classList.add('recording') + keyEl.textContent = 'Press keys…' + + const onKeyDown = (e: KeyboardEvent) => { + const shortcut = shortcutFromEvent(e) + // Modifier-only press: keep waiting for the real key. + if (!shortcut) return + // Capture phase + stop both: keeps the bound keys (e.g. Escape) from also + // firing the page-level toggle/close handler while we record. + e.preventDefault() + e.stopPropagation() + this.shortcuts[action] = shortcut + this.stopRecording?.() + callbacks.onShortcutsChange(this.cloneShortcuts()) + } + + document.addEventListener('keydown', onKeyDown, true) + this.stopRecording = () => { + document.removeEventListener('keydown', onKeyDown, true) + this.stopRecording = null + this.renderShortcut(action) + } + } + private handleSend(onSend: (text: string) => void): void { const text = this.inputEl.value.trim() if (!text) return @@ -476,6 +682,10 @@ export class ChatOverlay { this.container.style.display = 'none' } + isVisible(): boolean { + return this.visible + } + appendStream(text: string): void { this.hideTyping() this.streamText += text @@ -637,6 +847,12 @@ export class ChatOverlay { this.modelTag.textContent = MODELS[modelId].label } + setShortcuts(shortcuts: ShortcutsConfig): void { + this.shortcuts = { toggle: { ...shortcuts.toggle }, close: { ...shortcuts.close } } + this.renderShortcut('toggle') + this.renderShortcut('close') + } + updateStatus(status: string): void { this.statusEl.textContent = status } @@ -664,6 +880,18 @@ export class ChatOverlay { } } + showModelPicker(): void { + this.modelPickerEl.classList.add('visible') + this.messagesEl.style.display = 'none' + this.inputAreaEl.style.display = 'none' + } + + hideModelPicker(): void { + this.modelPickerEl.classList.remove('visible') + this.messagesEl.style.display = 'flex' + this.inputAreaEl.style.display = 'flex' + } + getElement(): HTMLElement { return this.host } diff --git a/entrypoints/content.ts b/entrypoints/content.ts index 88b8f408..a60026b3 100644 --- a/entrypoints/content.ts +++ b/entrypoints/content.ts @@ -5,8 +5,15 @@ import { executeContentTool } from '@/content/tool-executors' import type { Message } from '@/shared/messages' import type { ToolCall } from '@kessler/gemma-agent' import { MODELS, STORAGE_KEY_MODEL, DEFAULT_MODEL_ID, type ModelId } from '@/shared/models' +import { + DEFAULT_SHORTCUTS, + STORAGE_KEY_SHORTCUTS, + matchesShortcut, + type ShortcutsConfig, +} from '@/shared/shortcuts' const STORAGE_KEY = 'gemma_disabled_sites' +const STORAGE_KEY_CHOSEN = 'gemma_model_chosen' const PAGE_SNAPSHOT_MAX_LENGTH = 8000 function capturePageSnapshot(): string { @@ -45,6 +52,16 @@ async function setDisabledForSite(disabled: boolean): Promise { await browser.storage.local.set({ [STORAGE_KEY]: sites }) } +async function loadShortcuts(): Promise { + const data = await browser.storage.local.get(STORAGE_KEY_SHORTCUTS) + // Merge over defaults so a stored partial/legacy value can't drop a binding. + return { ...DEFAULT_SHORTCUTS, ...(data[STORAGE_KEY_SHORTCUTS] ?? {}) } +} + +async function saveShortcuts(shortcuts: ShortcutsConfig): Promise { + await browser.storage.local.set({ [STORAGE_KEY_SHORTCUTS]: shortcuts }) +} + export default defineContentScript({ matches: [''], async main() { @@ -53,6 +70,8 @@ export default defineContentScript({ const modelData = await browser.storage.local.get(STORAGE_KEY_MODEL) const initialModelId: ModelId = modelData[STORAGE_KEY_MODEL] ?? DEFAULT_MODEL_ID + let shortcuts = await loadShortcuts() + function safeSend(message: Message): void { try { browser.runtime.sendMessage(message).catch(() => { @@ -100,17 +119,27 @@ export default defineContentScript({ chat.addMessage(`Switching to ${MODELS[modelId].label}...`, 'agent') modelReady = false shownLoadingMessage = false + modelEverChosen = true + browser.storage.local.set({ [STORAGE_KEY_CHOSEN]: true }) safeSend({ type: 'model:switch', modelId }) }, + onShortcutsChange(next: ShortcutsConfig) { + shortcuts = next + saveShortcuts(next) + }, }) chat.setSelectedModel(initialModelId) + chat.setShortcuts(shortcuts) + + const modelChosenData = await browser.storage.local.get(STORAGE_KEY_CHOSEN) + let modelEverChosen = modelChosenData[STORAGE_KEY_CHOSEN] === true let modelReady = false let shownLoadingMessage = false let stopped = false - const icon = createGemIcon(() => { + const icon = createGemIcon(async () => { if (siteDisabled) { if (confirm('Re-enable Gemma Gem on this site?')) { siteDisabled = false @@ -120,7 +149,12 @@ export default defineContentScript({ return } chat.toggle() - safeSend({ type: 'chat:open' }) + if (modelEverChosen) { + safeSend({ type: 'chat:open' }) + } else { + chat.updateStatus('Select a model') + chat.showModelPicker() + } }) document.body.appendChild(icon) @@ -130,6 +164,30 @@ export default defineContentScript({ setGemDisabled(true) } + // Capture phase so the shortcut wins even when focus is in the chat input + // (the input stops propagation of key events, which would otherwise swallow + // the combo in the bubble phase). When recording a rebind, bail out and let + // the overlay's own capture listener consume the keystroke. + document.addEventListener('keydown', (e) => { + if (chat.isRecording()) return + + // Toggle chat overlay (default Alt+G) + if (matchesShortcut(e, shortcuts.toggle)) { + if (siteDisabled) return + e.preventDefault() + e.stopPropagation() + chat.toggle() + if (chat.isVisible()) safeSend({ type: 'chat:open' }) + return + } + // Close chat overlay (default Escape) + if (matchesShortcut(e, shortcuts.close) && chat.isVisible()) { + e.preventDefault() + e.stopPropagation() + chat.hide() + } + }, true) + browser.runtime.onMessage.addListener((message: Message) => { switch (message.type) { case 'agent:response': @@ -189,6 +247,9 @@ export default defineContentScript({ updateGemProgress(-1) chat.updateStatus(`Error: ${message.error}`) chat.setModelSwitchEnabled(true) + } else if (message.status === 'unloaded') { + modelReady = false + chat.updateStatus('Unloaded (inactive)') } break } diff --git a/entrypoints/offscreen/main.ts b/entrypoints/offscreen/main.ts index 98e675fb..636b6972 100644 --- a/entrypoints/offscreen/main.ts +++ b/entrypoints/offscreen/main.ts @@ -87,12 +87,38 @@ const modelHost = new GemmaModelHost((status, progress, error) => { chrome.runtime.sendMessage({ type: 'model:status', status, - modelId: modelHost.getCurrentModelId() ?? undefined, + modelId: modelHost.getCurrentModelId() ?? modelHost.getLastModelId() ?? undefined, progress, error, + device: modelHost.getDevice(), } satisfies Message) }) +// Inactivity timer — unloads model after 15 minutes of no activity +const INACTIVITY_TIMEOUT = 30 * 60 * 1000 +let inactivityTimer: ReturnType | null = null + +function resetInactivityTimer(): void { + if (inactivityTimer) clearTimeout(inactivityTimer) + inactivityTimer = setTimeout(() => { + log.info('Inactivity timeout reached — unloading model') + modelHost.unload() + chrome.runtime.sendMessage({ + type: 'model:status', + status: 'unloaded', + modelId: modelHost.getLastModelId() ?? undefined, + device: modelHost.getDevice(), + } satisfies Message) + }, INACTIVITY_TIMEOUT) +} + +function clearInactivityTimer(): void { + if (inactivityTimer) { + clearTimeout(inactivityTimer) + inactivityTimer = null + } +} + // Pending tool results keyed by requestId const pendingToolResults = new Map void, timeoutId: number }>() let requestIdCounter = 0 @@ -172,12 +198,14 @@ chrome.runtime.onMessage.addListener(async (message: Message) => { switch (message.type) { case 'model:load': { const modelId = message.modelId ?? modelHost.getCurrentModelId() ?? DEFAULT_MODEL_ID + const device = message.device as 'webgpu' | 'wasm' | undefined try { const warning = await checkGPUCompatibility() - if (warning) { + if (warning && (!device || device === 'webgpu')) { chrome.runtime.sendMessage({ type: 'gpu:warning', text: warning } satisfies Message) } - await modelHost.load(modelId) + await modelHost.load(modelId, device) + resetInactivityTimer() } catch (e) { log.error('Model load failed:', e) } @@ -186,14 +214,16 @@ chrome.runtime.onMessage.addListener(async (message: Message) => { case 'model:switch': { const { modelId } = message - log.info('Switching model to:', modelId) + const device = message.device as 'webgpu' | 'wasm' | undefined + log.info('Switching model to:', modelId, device ? `device: ${device}` : '') + resetInactivityTimer() if (currentAgent) { currentAgent.clearHistory() } currentAgent = null currentTabId = null // Storage persistence is handled by the background service worker - modelHost.load(modelId).catch(e => log.error('Model switch failed:', e)) + modelHost.load(modelId, device).catch(e => log.error('Model switch failed:', e)) break } @@ -207,6 +237,7 @@ chrome.runtime.onMessage.addListener(async (message: Message) => { case 'settings:update': { const { settings } = message log.info('Settings updated:', settings) + resetInactivityTimer() if (currentAgent) { currentAgent.updateOptions({ thinking: settings.thinking, @@ -218,6 +249,7 @@ chrome.runtime.onMessage.addListener(async (message: Message) => { case 'chat:stop': { log.info('Generation stopped by user') + resetInactivityTimer() modelHost.abort() if (currentAgent) { currentAgent.abort() @@ -227,6 +259,7 @@ chrome.runtime.onMessage.addListener(async (message: Message) => { case 'context:clear': { log.info('Context cleared') + resetInactivityTimer() if (currentAgent) { currentAgent.clearHistory() } @@ -235,13 +268,27 @@ chrome.runtime.onMessage.addListener(async (message: Message) => { case 'agent:run': { if (!modelHost.isLoaded()) { + const modelId = modelHost.getLastModelId() ?? DEFAULT_MODEL_ID + log.info('Model not loaded — auto-loading before agent run') chrome.runtime.sendMessage({ - type: 'agent:response', - tabId: message.tabId, - text: 'Model is still loading. Please wait...', + type: 'model:status', + status: 'loading', + modelId, + device: modelHost.getDevice(), } satisfies Message) - return + try { + await modelHost.load(modelId) + } catch (e) { + log.error('Auto-load failed:', e) + chrome.runtime.sendMessage({ + type: 'agent:response', + tabId: message.tabId, + text: 'Failed to reload model. Please open the chat again.', + } satisfies Message) + return + } } + resetInactivityTimer() const { tabId, userMessage, settings, pageContext } = message log.info('Agent run, tab:', tabId, 'message:', userMessage.slice(0, 80)) @@ -285,29 +332,89 @@ chrome.runtime.onMessage.addListener(async (message: Message) => { currentTabId = tabId } - currentAgent.run(userMessage).then((result) => { - log.info('Agent done. Iterations:', result.iterations, 'Tool calls:', result.toolCallCount) - log.debug('Response:', result.response.slice(0, 200)) - chrome.runtime.sendMessage({ - type: 'agent:response', - tabId, - text: result.response, - } satisfies Message) - }).catch((err) => { - log.error('Agent error:', err) - const message = err instanceof Error ? err.message : String(err) - chrome.runtime.sendMessage({ - type: 'agent:response', - tabId, - text: `Something went wrong: ${message}`, - } satisfies Message) - }) + const runAgent = async (retryOnUnaligned: boolean): Promise => { + try { + const result = await currentAgent!.run(userMessage) + log.info('Agent done. Iterations:', result.iterations, 'Tool calls:', result.toolCallCount) + log.debug('Response:', result.response.slice(0, 200)) + chrome.runtime.sendMessage({ + type: 'agent:response', + tabId, + text: result.response, + } satisfies Message) + } catch (err) { + log.error('Agent run error:', err) + const message = err instanceof Error ? err.message : String(err) + + if (retryOnUnaligned && message.startsWith('unaligned:') && modelHost.getDevice() === 'webgpu') { + log.warn('Unaligned access on WebGPU — reloading with WASM and retrying...') + chrome.runtime.sendMessage({ + type: 'agent:chunk', + tabId, + text: '\n\n_⚠️ Errore di allineamento WebGPU. Passo al backend WASM e riprovo..._\n\n', + } satisfies Message) + + currentAgent = null + currentTabId = null + clearInactivityTimer() + + const modelId = modelHost.getCurrentModelId() ?? modelHost.getLastModelId() ?? DEFAULT_MODEL_ID + await modelHost.unload() + await modelHost.load(modelId, 'wasm') + + currentAgent = new Agent({ + model: modelHost, + tools: createTools(tabId), + systemPrompt: buildSystemPrompt(pageContext), + historyStrategy: maxTurns(1000), + maxIterations, + thinking: enableThinking, + logger: log, + onThinkingChunk(text) { + chrome.runtime.sendMessage({ + type: 'agent:chunk', + tabId, + text: `[Thinking] ${text}`, + } satisfies Message) + }, + onToolCall(call) { + log.info('Tool call:', call.name, JSON.stringify(call.arguments)) + chrome.runtime.sendMessage({ + type: 'agent:chunk', + tabId, + text: `[Tool] ${call.name}(${JSON.stringify(call.arguments)})`, + } satisfies Message) + }, + onChunk(text) { + chrome.runtime.sendMessage({ + type: 'agent:chunk', + tabId, + text, + } satisfies Message) + }, + }) + currentTabId = tabId + + await runAgent(false) + return + } + + chrome.runtime.sendMessage({ + type: 'agent:response', + tabId, + text: `Something went wrong: ${message}`, + } satisfies Message) + } + } + + runAgent(true).catch(e => log.error('Unhandled in runAgent:', e)) break } case 'tool:result': { log.debug('Tool result received:', message.requestId) + resetInactivityTimer() const entry = pendingToolResults.get(message.requestId) if (entry) { clearTimeout(entry.timeoutId) diff --git a/offscreen/model-host.ts b/offscreen/model-host.ts index ef55c141..1cc5fdc8 100644 --- a/offscreen/model-host.ts +++ b/offscreen/model-host.ts @@ -35,27 +35,35 @@ function stripSpecialTokens(text: string): string { env.backends.onnx.wasm.wasmPaths = chrome.runtime.getURL('ort/') type StatusCallback = (status: 'loading' | 'ready' | 'error', progress?: number, error?: string) => void +type DeviceType = 'webgpu' | 'wasm' export class GemmaModelHost implements ModelBackend { private model: InstanceType | null = null private processor: Awaited> | null = null private loading = false private currentModelId: ModelId | null = null + private lastModelId: ModelId | null = null private loadingModelId: ModelId | null = null private onStatus: StatusCallback private abortController: AbortController | null = null + private device: DeviceType = 'webgpu' constructor(onStatus: StatusCallback) { this.onStatus = onStatus } - async load(modelId: ModelId = DEFAULT_MODEL_ID): Promise { - log.info('load() called:', modelId, '| current:', this.currentModelId, '| hasModel:', !!this.model, '| loading:', this.loading) - if (this.model && this.currentModelId === modelId) { + getDevice(): DeviceType { + return this.device + } + + async load(modelId: ModelId = DEFAULT_MODEL_ID, deviceOverride?: DeviceType): Promise { + const targetDevice = deviceOverride ?? this.device + log.info('load() called:', modelId, 'device:', targetDevice, '| current:', this.currentModelId, '| hasModel:', !!this.model, '| loading:', this.loading) + if (this.model && this.currentModelId === modelId && (deviceOverride === undefined || deviceOverride === this.device)) { this.onStatus('ready') return } - if (this.model && this.currentModelId !== modelId) { + if (this.model) { log.info('Unloading current model before switching') await this.unload() log.info('Unload complete') @@ -93,7 +101,7 @@ export class GemmaModelHost implements ModelBackend { const [model, processor] = await Promise.all([ Gemma4ForConditionalGeneration.from_pretrained(config.hfModelId, { dtype: 'q4f16', - device: 'webgpu', + device: targetDevice, progress_callback, }), AutoProcessor.from_pretrained(config.hfModelId), @@ -102,6 +110,8 @@ export class GemmaModelHost implements ModelBackend { this.model = model as InstanceType this.processor = processor this.currentModelId = modelId + this.lastModelId = modelId + this.device = targetDevice this.loadingModelId = null this.contextLimit = config.contextLimit this.loading = false @@ -109,6 +119,25 @@ export class GemmaModelHost implements ModelBackend { } catch (e) { this.loading = false this.loadingModelId = null + + if (targetDevice === 'webgpu') { + const errMsg = e instanceof Error ? e.message : String(e) + const isAlignmentError = errMsg.includes('unaligned') + log.warn(`WebGPU load failed${isAlignmentError ? ' (unaligned access)' : ''}, falling back to WASM:`, errMsg) + this.onStatus('loading', undefined) + + try { + await this.load(modelId, 'wasm') + this.onStatus('ready') + log.info('WASM fallback succeeded') + return + } catch (fallbackErr) { + log.error('WASM fallback also failed:', fallbackErr) + this.onStatus('error', undefined, String(fallbackErr)) + throw fallbackErr + } + } + this.onStatus('error', undefined, String(e)) throw e } @@ -131,6 +160,10 @@ export class GemmaModelHost implements ModelBackend { return this.currentModelId ?? this.loadingModelId } + getLastModelId(): ModelId | null { + return this.lastModelId + } + abort(): void { if (this.abortController) { this.abortController.abort() @@ -237,6 +270,12 @@ export class GemmaModelHost implements ModelBackend { log.info('Generation aborted by user') return rawResult } + const errMsg = e instanceof Error ? e.message : String(e) + if (errMsg.includes('unaligned')) { + log.warn('Unaligned access error during generation:', errMsg) + // Re-throw so the caller (main.ts) can initiate a WASM fallback + throw new Error(`unaligned:${errMsg}`) + } log.error('FAILED at model.generate():', e) throw e } finally { diff --git a/public/ort/ort-wasm-simd-threaded.mjs b/public/ort/ort-wasm-simd-threaded.mjs new file mode 100644 index 00000000..0d7d62ff --- /dev/null +++ b/public/ort/ort-wasm-simd-threaded.mjs @@ -0,0 +1,59 @@ +async function ortWasmThreaded(moduleArg={}){var moduleRtn;var h=moduleArg,aa=!!globalThis.window,k=!!globalThis.WorkerGlobalScope,m=globalThis.process?.versions?.node&&"renderer"!=globalThis.process?.type,n=k&&self.name?.startsWith("em-pthread");if(m){const {createRequire:a}=await import("module");var require=a(import.meta.url),ba=require("worker_threads");global.Worker=ba.Worker;n=(k=!ba.hc)&&"em-pthread"==ba.workerData}h.mountExternalData=(a,b)=>{a.startsWith("./")&&(a=a.substring(2));(h.Rb||(h.Rb=new Map)).set(a,b)}; +h.unmountExternalData=()=>{delete h.Rb};var SharedArrayBuffer=globalThis.SharedArrayBuffer??(new WebAssembly.Memory({initial:0,maximum:0,shared:!0})).buffer.constructor,ca="./this.program",da=(a,b)=>{throw b;},ea=import.meta.url,fa="",ha,ia; +if(m){var fs=require("fs");ea.startsWith("file:")&&(fa=require("path").dirname(require("url").fileURLToPath(ea))+"/");ia=a=>{a=ja(a)?new URL(a):a;return fs.readFileSync(a)};ha=async a=>{a=ja(a)?new URL(a):a;return fs.readFileSync(a,void 0)};1{process.exitCode=a;throw b;}}else if(aa||k){try{fa=(new URL(".",ea)).href}catch{}m||(k&&(ia=a=>{var b=new XMLHttpRequest;b.open("GET",a,!1);b.responseType="arraybuffer"; +b.send(null);return new Uint8Array(b.response)}),ha=async a=>{if(ja(a))return new Promise((d,c)=>{var e=new XMLHttpRequest;e.open("GET",a,!0);e.responseType="arraybuffer";e.onload=()=>{200==e.status||0==e.status&&e.response?d(e.response):c(e.status)};e.onerror=c;e.send(null)});var b=await fetch(a,{credentials:"same-origin"});if(b.ok)return b.arrayBuffer();throw Error(b.status+" : "+b.url);})}var ka=console.log.bind(console),la=console.error.bind(console); +if(m){var ma=require("util"),na=a=>"object"==typeof a?ma.inspect(a):a;ka=(...a)=>fs.writeSync(1,a.map(na).join(" ")+"\n");la=(...a)=>fs.writeSync(2,a.map(na).join(" ")+"\n")}var oa=ka,p=la,q,r,pa=!1,t,ja=a=>a.startsWith("file://");function v(){x.buffer!=z.buffer&&qa()}var ra,sa; +if(m&&n){var ta=ba.parentPort;ta.on("message",a=>global.onmessage?.({data:a}));Object.assign(globalThis,{self:global,postMessage:a=>ta.postMessage(a)});process.on("uncaughtException",a=>{postMessage({Pb:"uncaughtException",error:a});process.exit(1)})}var ua; +if(n){var va=!1;self.onunhandledrejection=b=>{throw b.reason||b;};function a(b){try{var d=b.data,c=d.Pb;if("load"===c){let e=[];self.onmessage=f=>e.push(f);ua=()=>{postMessage({Pb:"loaded"});for(let f of e)a(f);self.onmessage=a};for(const f of d.Zb)if(!h[f]||h[f].proxy)h[f]=(...g)=>{postMessage({Pb:"callHandler",Yb:f,args:g})},"print"==f&&(oa=h[f]),"printErr"==f&&(p=h[f]);x=d.dc;qa();r=d.ec;wa();xa()}else if("run"===c){ya(d.Ob);za(d.Ob,0,0,1,0,0);Aa();Ba(d.Ob);va||=!0;try{Ca(d.bc,d.Tb)}catch(e){if("unwind"!= +e)throw e;}}else"setimmediate"!==d.target&&("checkMailbox"===c?va&&Da():c&&(p(`worker: received unknown command ${c}`),p(d)))}catch(e){throw Ea(),e;}}self.onmessage=a}var z,A,Fa,C,D,Ga,G,H,Ha=!1;function qa(){var a=x.buffer;h.HEAP8=z=new Int8Array(a);Fa=new Int16Array(a);h.HEAPU8=A=new Uint8Array(a);new Uint16Array(a);h.HEAP32=C=new Int32Array(a);h.HEAPU32=D=new Uint32Array(a);Ga=new Float32Array(a);G=new Float64Array(a);H=new BigInt64Array(a);new BigUint64Array(a)} +function Ia(){Ha=!0;n?ua():I.Ta()}function J(a){a="Aborted("+a+")";p(a);pa=!0;a=new WebAssembly.RuntimeError(a+". Build with -sASSERTIONS for more info.");sa?.(a);throw a;}var Ja;async function Ka(a){if(!q)try{var b=await ha(a);return new Uint8Array(b)}catch{}if(a==Ja&&q)a=new Uint8Array(q);else if(ia)a=ia(a);else throw"both async and sync fetching of the wasm failed";return a} +async function La(a,b){try{var d=await Ka(a);return await WebAssembly.instantiate(d,b)}catch(c){p(`failed to asynchronously prepare wasm: ${c}`),J(c)}}async function Na(a){var b=Ja;if(!q&&!ja(b)&&!m)try{var d=fetch(b,{credentials:"same-origin"});return await WebAssembly.instantiateStreaming(d,a)}catch(c){p(`wasm streaming compile failed: ${c}`),p("falling back to ArrayBuffer instantiation")}return La(b,a)} +function Oa(){Pa={R:Qa,f:Ra,w:Sa,e:Ta,l:Ua,g:Va,S:Wa,b:Xa,F:Ya,ta:Za,j:$a,K:ab,Ja:bb,pa:cb,ra:db,Ka:eb,Ha:fb,Aa:gb,Ga:hb,Y:ib,qa:jb,na:kb,Ia:lb,oa:mb,Pa:nb,Da:ob,la:pb,ua:qb,ia:rb,T:sb,Ca:Ba,Ma:tb,xa:ub,ya:vb,za:wb,va:xb,wa:yb,ja:zb,Ra:Ab,Oa:Bb,V:Cb,U:Db,Na:Eb,E:Fb,La:Gb,ma:Hb,u:Ib,G:Jb,Q:Kb,ka:Lb,aa:Mb,Sa:Nb,Ea:Ob,Fa:Pb,sa:Qb,H:Rb,X:Sb,Ba:Tb,W:Ub,_:Vb,L:Wb,$:Xb,M:Yb,v:Zb,d:$b,m:ac,n:bc,r:cc,ba:dc,x:ec,o:fc,N:gc,C:hc,I:ic,ca:jc,da:kc,P:lc,O:mc,ea:nc,z:oc,D:pc,c:qc,q:rc,i:sc,Z:tc,k:uc,p:vc,s:wc,t:xc, +y:yc,fa:zc,A:Ac,J:Bc,B:Cc,ga:Dc,ha:Ec,h:Fc,a:x,Qa:Gc};return{a:Pa}} +async function wa(){function a(c,e){I=c.exports;I=Hc();Ic.push(I.vb);c=I;h._OrtInit=c.Ua;h._OrtGetLastError=c.Va;h._OrtCreateSessionOptions=c.Wa;h._OrtAppendExecutionProvider=c.Xa;h._OrtAddFreeDimensionOverride=c.Ya;h._OrtAddSessionConfigEntry=c.Za;h._OrtReleaseSessionOptions=c._a;h._OrtCreateSession=c.$a;h._OrtReleaseSession=c.ab;h._OrtGetInputOutputCount=c.bb;h._OrtGetInputOutputMetadata=c.cb;h._OrtFree=c.db;h._OrtCreateTensor=c.eb;h._OrtGetTensorData=c.fb;h._OrtReleaseTensor=c.gb;h._OrtCreateRunOptions= +c.hb;h._OrtAddRunConfigEntry=c.ib;h._OrtReleaseRunOptions=c.jb;h._OrtCreateBinding=c.kb;h._OrtBindInput=c.lb;h._OrtBindOutput=c.mb;h._OrtClearBoundOutputs=c.nb;h._OrtReleaseBinding=c.ob;h._OrtRunWithBinding=c.pb;h._OrtRun=c.qb;h._OrtEndProfiling=c.rb;Jc=c.sb;Kc=h._free=c.tb;Lc=h._malloc=c.ub;za=c.xb;Ea=c.yb;Mc=c.zb;Nc=c.Ab;Oc=c.Bb;Pc=c.Cb;Qc=c.Db;K=c.Eb;L=c.Fb;Rc=c.Gb;M=c.Hb;Sc=c.Ib;N=c.Jb;Tc=c.Kb;Uc=c.Lb;Vc=c.Mb;Wc=c.Nb;Xc=c.wb;r=e;return I}var b=Oa();if(h.instantiateWasm)return new Promise(c=>{h.instantiateWasm(b, +(e,f)=>{c(a(e,f))})});if(n){var d=new WebAssembly.Instance(r,Oa());return a(d,r)}Ja??=h.locateFile?h.locateFile?h.locateFile("ort-wasm-simd-threaded.wasm",fa):fa+"ort-wasm-simd-threaded.wasm":(new URL("ort-wasm-simd-threaded.wasm",import.meta.url)).href;return function(c){return a(c.instance,c.module)}(await Na(b))}class Yc{name="ExitStatus";constructor(a){this.message=`Program terminated with exit(${a})`;this.status=a}} +var Zc=a=>{a.terminate();a.onmessage=()=>{}},$c=[],O=0,P=null,cd=a=>{0==Q.length&&(ad(),bd(Q[0]));var b=Q.pop();if(!b)return 6;R.push(b);S[a.Ob]=b;b.Ob=a.Ob;var d={Pb:"run",bc:a.ac,Tb:a.Tb,Ob:a.Ob};m&&b.unref();b.postMessage(d,a.Xb);return 0},T=0,U=(a,b,...d)=>{var c=16*d.length,e=N(),f=Sc(c),g=f>>>3,l;for(l of d)"bigint"==typeof l?((v(),H)[g++>>>0]=1n,(v(),H)[g++>>>0]=l):((v(),H)[g++>>>0]=0n,(v(),G)[g++>>>0]=l);a=Mc(a,0,c,f,b);M(e);return a}; +function Gc(a){if(n)return U(0,1,a);t=a;if(!(0{t=a;if(n)throw dd(a),"unwind";Gc(a)},Q=[],R=[],Ic=[],S={};function ed(){for(var a=h.numThreads-1;a--;)ad();$c.push(async()=>{var b=fd();O++;await b;O--;0==O&&P&&(b=P,P=null,b())})}var gd=a=>{var b=a.Ob;delete S[b];Q.push(a);R.splice(R.indexOf(a),1);a.Ob=0;Nc(b)};function Aa(){Ic.forEach(a=>a())} +var bd=a=>new Promise(b=>{a.onmessage=f=>{var g=f.data;f=g.Pb;if(g.Sb&&g.Sb!=Jc()){var l=S[g.Sb];l?l.postMessage(g,g.Xb):p(`Internal error! Worker sent a message "${f}" to target pthread ${g.Sb}, but that thread no longer exists!`)}else if("checkMailbox"===f)Da();else if("spawnThread"===f)cd(g);else if("cleanupThread"===f)hd(()=>{gd(S[g.cc])});else if("loaded"===f)a.loaded=!0,m&&!a.Ob&&a.unref(),b(a);else if("setimmediate"===g.target)a.postMessage(g);else if("uncaughtException"===f)a.onerror(g.error); +else if("callHandler"===f)h[g.Yb](...g.args);else f&&p(`worker sent an unknown command ${f}`)};a.onerror=f=>{p(`${"worker sent an error!"} ${f.filename}:${f.lineno}: ${f.message}`);throw f;};m&&(a.on("message",f=>a.onmessage({data:f})),a.on("error",f=>a.onerror(f)));var d=[],c=[],e;for(e of c)h.propertyIsEnumerable(e)&&d.push(e);a.postMessage({Pb:"load",Zb:d,dc:x,ec:r})});async function fd(){if(!n)return Promise.all(Q.map(bd))} +function ad(){var a=new Worker(new URL(import.meta.url),{type:"module",workerData:"em-pthread",name:"em-pthread"});Q.push(a)}function ya(a){var b=(v(),D)[a+52>>>2>>>0];a=(v(),D)[a+56>>>2>>>0];Rc(b,b-a);M(b)}var jd=[],V=a=>{var b=jd[a];b||(jd[a]=b=Xc.get(a));return b},Ca=(a,b)=>{T=0;a=V(a)(b);0>>=0;var b=new md(a);0==(v(),z)[b.Qb+12>>>0]&&(nd(b,!0),ld--);od(b,!1);kd.push(b);return Wc(a)} +var W=0,Sa=()=>{K(0,0);var a=kd.pop();Tc(a.Ub);W=0};function nd(a,b){b=b?1:0;(v(),z)[a.Qb+12>>>0]=b}function od(a,b){b=b?1:0;(v(),z)[a.Qb+13>>>0]=b}class md{constructor(a){this.Ub=a;this.Qb=a-24}}var pd=a=>{var b=W;if(!b)return L(0),0;var d=new md(b);(v(),D)[d.Qb+16>>>2>>>0]=b;var c=(v(),D)[d.Qb+4>>>2>>>0];if(!c)return L(0),b;for(var e of a){if(0===e||e===c)break;if(Vc(e,c,d.Qb+16))return L(e),b}L(c);return b};function Ta(){return pd([])}function Ua(a){return pd([a>>>0])} +function Va(a,b,d,c){return pd([a>>>0,b>>>0,d>>>0,c>>>0])}var Wa=()=>{var a=kd.pop();a||J("no exception to throw");var b=a.Ub;0==(v(),z)[a.Qb+13>>>0]&&(kd.push(a),od(a,!0),nd(a,!1),ld++);Uc(b);W=b;throw W;};function Xa(a,b,d){a>>>=0;var c=new md(a);b>>>=0;d>>>=0;(v(),D)[c.Qb+16>>>2>>>0]=0;(v(),D)[c.Qb+4>>>2>>>0]=b;(v(),D)[c.Qb+8>>>2>>>0]=d;Uc(a);W=a;ld++;throw W;}var Ya=()=>ld;function qd(a,b,d,c){return n?U(2,1,a,b,d,c):Za(a,b,d,c)} +function Za(a,b,d,c){a>>>=0;b>>>=0;d>>>=0;c>>>=0;if(!globalThis.SharedArrayBuffer)return 6;var e=[];if(n&&0===e.length)return qd(a,b,d,c);a={ac:d,Ob:a,Tb:c,Xb:e};return n?(a.Pb="spawnThread",postMessage(a,e),0):cd(a)}function $a(a){W||=a>>>0;throw W;} +var rd=globalThis.TextDecoder&&new TextDecoder,sd=(a,b=0,d,c)=>{b>>>=0;var e=b;d=e+d;if(c)c=d;else{for(;a[e]&&!(e>=d);)++e;c=e}if(16d?e+=String.fromCharCode(d):(d-=65536,e+=String.fromCharCode(55296|d>>10,56320| +d&1023))}}else e+=String.fromCharCode(d);return e},td=(a,b,d)=>(a>>>=0)?sd((v(),A),a,b,d):"";function ab(a,b,d){return n?U(3,1,a,b,d):0}function bb(a,b){if(n)return U(4,1,a,b)}function cb(a,b){if(n)return U(5,1,a,b)}function db(a,b,d){if(n)return U(6,1,a,b,d)}function eb(a,b,d){return n?U(7,1,a,b,d):0}function fb(a,b){if(n)return U(8,1,a,b)}function gb(a,b,d){if(n)return U(9,1,a,b,d)}function hb(a,b,d,c){if(n)return U(10,1,a,b,d,c)}function ib(a,b,d,c){if(n)return U(11,1,a,b,d,c)} +function jb(a,b,d,c){if(n)return U(12,1,a,b,d,c)}function kb(a){if(n)return U(13,1,a)}function lb(a,b){if(n)return U(14,1,a,b)}function mb(a,b,d){if(n)return U(15,1,a,b,d)}var nb=()=>J("");function ob(a){za(a>>>0,!k,1,!aa,131072,!1);Aa()} +var hd=a=>{if(!pa)try{if(a(),!(0Number((navigator.userAgent.match(/Chrom(e|ium)\/([0-9]+)\./)||[])[2]);function Ba(a){a>>>=0;ud||(Atomics.waitAsync((v(),C),a>>>2,a).value.then(Da),a+=128,Atomics.store((v(),C),a>>>2,1))}var Da=()=>hd(()=>{var a=Jc();a&&(Ba(a),Qc())}); +function pb(a,b){a>>>=0;a==b>>>0?setTimeout(Da):n?postMessage({Sb:a,Pb:"checkMailbox"}):(a=S[a])&&a.postMessage({Pb:"checkMailbox"})}var vd=[];function qb(a,b,d,c,e){b>>>=0;e>>>=0;vd.length=0;d=e>>>3;for(c=e+c>>>3;d>>0]?f=(v(),H)[d++>>>0]:f=(v(),G)[d++>>>0];vd.push(f)}return(b?wd[b]:xd[a])(...vd)}var rb=()=>{T=0};function sb(a){a>>>=0;n?postMessage({Pb:"cleanupThread",cc:a}):gd(S[a])}function tb(a){m&&S[a>>>0].ref()} +function ub(a,b){a=-9007199254740992>a||9007199254740992>>=0;a=new Date(1E3*a);(v(),C)[b>>>2>>>0]=a.getUTCSeconds();(v(),C)[b+4>>>2>>>0]=a.getUTCMinutes();(v(),C)[b+8>>>2>>>0]=a.getUTCHours();(v(),C)[b+12>>>2>>>0]=a.getUTCDate();(v(),C)[b+16>>>2>>>0]=a.getUTCMonth();(v(),C)[b+20>>>2>>>0]=a.getUTCFullYear()-1900;(v(),C)[b+24>>>2>>>0]=a.getUTCDay();a=(a.getTime()-Date.UTC(a.getUTCFullYear(),0,1,0,0,0,0))/864E5|0;(v(),C)[b+28>>>2>>>0]=a} +var yd=a=>0===a%4&&(0!==a%100||0===a%400),zd=[0,31,60,91,121,152,182,213,244,274,305,335],Ad=[0,31,59,90,120,151,181,212,243,273,304,334]; +function vb(a,b){a=-9007199254740992>a||9007199254740992>>=0;a=new Date(1E3*a);(v(),C)[b>>>2>>>0]=a.getSeconds();(v(),C)[b+4>>>2>>>0]=a.getMinutes();(v(),C)[b+8>>>2>>>0]=a.getHours();(v(),C)[b+12>>>2>>>0]=a.getDate();(v(),C)[b+16>>>2>>>0]=a.getMonth();(v(),C)[b+20>>>2>>>0]=a.getFullYear()-1900;(v(),C)[b+24>>>2>>>0]=a.getDay();var d=(yd(a.getFullYear())?zd:Ad)[a.getMonth()]+a.getDate()-1|0;(v(),C)[b+28>>>2>>>0]=d;(v(),C)[b+36>>>2>>>0]=-(60*a.getTimezoneOffset());d=(new Date(a.getFullYear(), +6,1)).getTimezoneOffset();var c=(new Date(a.getFullYear(),0,1)).getTimezoneOffset();a=(d!=c&&a.getTimezoneOffset()==Math.min(c,d))|0;(v(),C)[b+32>>>2>>>0]=a} +function wb(a){a>>>=0;var b=new Date((v(),C)[a+20>>>2>>>0]+1900,(v(),C)[a+16>>>2>>>0],(v(),C)[a+12>>>2>>>0],(v(),C)[a+8>>>2>>>0],(v(),C)[a+4>>>2>>>0],(v(),C)[a>>>2>>>0],0),d=(v(),C)[a+32>>>2>>>0],c=b.getTimezoneOffset(),e=(new Date(b.getFullYear(),6,1)).getTimezoneOffset(),f=(new Date(b.getFullYear(),0,1)).getTimezoneOffset(),g=Math.min(f,e);0>d?(v(),C)[a+32>>>2>>>0]=Number(e!=f&&g==c):0>>2>>>0]=b.getDay();d=(yd(b.getFullYear())? +zd:Ad)[b.getMonth()]+b.getDate()-1|0;(v(),C)[a+28>>>2>>>0]=d;(v(),C)[a>>>2>>>0]=b.getSeconds();(v(),C)[a+4>>>2>>>0]=b.getMinutes();(v(),C)[a+8>>>2>>>0]=b.getHours();(v(),C)[a+12>>>2>>>0]=b.getDate();(v(),C)[a+16>>>2>>>0]=b.getMonth();(v(),C)[a+20>>>2>>>0]=b.getYear();a=b.getTime();return BigInt(isNaN(a)?-1:a/1E3)}function xb(a,b,d,c,e,f,g){return n?U(16,1,a,b,d,c,e,f,g):-52}function yb(a,b,d,c,e,f){if(n)return U(17,1,a,b,d,c,e,f)}var X={},Ib=()=>performance.timeOrigin+performance.now(); +function zb(a,b){if(n)return U(18,1,a,b);X[a]&&(clearTimeout(X[a].id),delete X[a]);if(!b)return 0;var d=setTimeout(()=>{delete X[a];hd(()=>Pc(a,performance.timeOrigin+performance.now()))},b);X[a]={id:d,jc:b};return 0} +var Y=(a,b,d)=>{var c=(v(),A);b>>>=0;if(0=g){if(b>=d)break;c[b++>>>0]=g}else if(2047>=g){if(b+1>=d)break;c[b++>>>0]=192|g>>6;c[b++>>>0]=128|g&63}else if(65535>=g){if(b+2>=d)break;c[b++>>>0]=224|g>>12;c[b++>>>0]=128|g>>6&63;c[b++>>>0]=128|g&63}else{if(b+3>=d)break;c[b++>>>0]=240|g>>18;c[b++>>>0]=128|g>>12&63;c[b++>>>0]=128|g>>6&63;c[b++>>>0]=128|g&63;f++}}c[b>>>0]=0;a=b-e}else a=0;return a}; +function Ab(a,b,d,c){a>>>=0;b>>>=0;d>>>=0;c>>>=0;var e=(new Date).getFullYear(),f=(new Date(e,0,1)).getTimezoneOffset();e=(new Date(e,6,1)).getTimezoneOffset();var g=Math.max(f,e);(v(),D)[a>>>2>>>0]=60*g;(v(),C)[b>>>2>>>0]=Number(f!=e);b=l=>{var u=Math.abs(l);return`UTC${0<=l?"-":"+"}${String(Math.floor(u/60)).padStart(2,"0")}${String(u%60).padStart(2,"0")}`};a=b(f);b=b(e);eDate.now(),Bd=1; +function Bb(a,b,d){d>>>=0;if(!(0<=a&&3>=a))return 28;if(0===a)a=Date.now();else if(Bd)a=performance.timeOrigin+performance.now();else return 52;a=Math.round(1E6*a);(v(),H)[d>>>3>>>0]=BigInt(a);return 0}var Cd=[];function Cb(a,b,d){a>>>=0;b>>>=0;d>>>=0;Cd.length=0;for(var c;c=(v(),A)[b++>>>0];){var e=105!=c;e&=112!=c;d+=e&&d%8?4:0;Cd.push(112==c?(v(),D)[d>>>2>>>0]:106==c?(v(),H)[d>>>3>>>0]:105==c?(v(),C)[d>>>2>>>0]:(v(),G)[d>>>3>>>0]);d+=e?8:4}return wd[a](...Cd)}var Db=()=>{}; +function Fb(a,b){return p(td(a>>>0,b>>>0))}var Gb=()=>{T+=1;throw"unwind";};function Hb(){return 4294901760}var Jb=()=>m?require("os").cpus().length:navigator.hardwareConcurrency,Z={},Dd=a=>{for(var b=0,d=0;d=c?b++:2047>=c?b+=2:55296<=c&&57343>=c?(b+=4,++d):b+=3}return b},Ed=a=>{var b;return(b=/\bwasm-function\[\d+\]:(0x[0-9a-f]+)/.exec(a))?+b[1]:(b=/:(\d+):\d+(?:\)|$)/.exec(a))?2147483648|+b[1]:0},Fd=a=>{for(var b of a)(a=Ed(b))&&(Z[a]=b)}; +function Mb(){var a=Error().stack.toString().split("\n");"Error"==a[0]&&a.shift();Fd(a);Z.Vb=Ed(a[3]);Z.$b=a;return Z.Vb}function Kb(a){a=Z[a>>>0];if(!a)return 0;var b;if(b=/^\s+at .*\.wasm\.(.*) \(.*\)$/.exec(a))a=b[1];else if(b=/^\s+at (.*) \(.*\)$/.exec(a))a=b[1];else if(b=/^(.+?)@/.exec(a))a=b[1];else return 0;Kc(Kb.Wb??0);b=Dd(a)+1;var d=Lc(b);d&&Y(a,d,b);Kb.Wb=d;return Kb.Wb} +function Lb(a){a>>>=0;var b=(v(),A).length;if(a<=b||4294901760=d;d*=2){var c=b*(1+.2/d);c=Math.min(c,a+100663296);a:{c=(Math.min(4294901760,65536*Math.ceil(Math.max(a,c)/65536))-x.buffer.byteLength+65535)/65536|0;try{x.grow(c);qa();var e=1;break a}catch(f){}e=void 0}if(e)return!0}return!1} +function Nb(a,b,d){a>>>=0;b>>>=0;if(Z.Vb==a)var c=Z.$b;else c=Error().stack.toString().split("\n"),"Error"==c[0]&&c.shift(),Fd(c);for(var e=3;c[e]&&Ed(c[e])!=a;)++e;for(a=0;a>>2>>>0]=Ed(c[a+e]);return a} +var Gd={},Id=()=>{if(!Hd){var a={USER:"web_user",LOGNAME:"web_user",PATH:"/",PWD:"/",HOME:"/home/web_user",LANG:(globalThis.navigator?.language??"C").replace("-","_")+".UTF-8",_:ca||"./this.program"},b;for(b in Gd)void 0===Gd[b]?delete a[b]:a[b]=Gd[b];var d=[];for(b in a)d.push(`${b}=${a[b]}`);Hd=d}return Hd},Hd;function Ob(a,b){if(n)return U(19,1,a,b);a>>>=0;b>>>=0;var d=0,c=0,e;for(e of Id()){var f=b+d;(v(),D)[a+c>>>2>>>0]=f;d+=Y(e,f,Infinity)+1;c+=4}return 0} +function Pb(a,b){if(n)return U(20,1,a,b);a>>>=0;b>>>=0;var d=Id();(v(),D)[a>>>2>>>0]=d.length;a=0;for(var c of d)a+=Dd(c)+1;(v(),D)[b>>>2>>>0]=a;return 0}function Rb(a){return n?U(21,1,a):52}function Sb(a,b,d,c){return n?U(22,1,a,b,d,c):52}function Tb(a,b,d,c){return n?U(23,1,a,b,d,c):70}var Jd=[null,[],[]]; +function Ub(a,b,d,c){if(n)return U(24,1,a,b,d,c);b>>>=0;d>>>=0;c>>>=0;for(var e=0,f=0;f>>2>>>0],l=(v(),D)[b+4>>>2>>>0];b+=8;for(var u=0;u>>0],B=Jd[w];0===y||10===y?((1===w?oa:p)(sd(B)),B.length=0):B.push(y)}e+=l}(v(),D)[c>>>2>>>0]=e;return 0}function Fc(a){return a>>>0}n||ed();n||(x=new WebAssembly.Memory({initial:256,maximum:65536,shared:!0}),qa());h.wasmBinary&&(q=h.wasmBinary);h.stackSave=()=>N();h.stackRestore=a=>M(a);h.stackAlloc=a=>Sc(a); +h.setValue=function(a,b,d="i8"){d.endsWith("*")&&(d="*");switch(d){case "i1":(v(),z)[a>>>0]=b;break;case "i8":(v(),z)[a>>>0]=b;break;case "i16":(v(),Fa)[a>>>1>>>0]=b;break;case "i32":(v(),C)[a>>>2>>>0]=b;break;case "i64":(v(),H)[a>>>3>>>0]=BigInt(b);break;case "float":(v(),Ga)[a>>>2>>>0]=b;break;case "double":(v(),G)[a>>>3>>>0]=b;break;case "*":(v(),D)[a>>>2>>>0]=b;break;default:J(`invalid type for setValue: ${d}`)}}; +h.getValue=function(a,b="i8"){b.endsWith("*")&&(b="*");switch(b){case "i1":return(v(),z)[a>>>0];case "i8":return(v(),z)[a>>>0];case "i16":return(v(),Fa)[a>>>1>>>0];case "i32":return(v(),C)[a>>>2>>>0];case "i64":return(v(),H)[a>>>3>>>0];case "float":return(v(),Ga)[a>>>2>>>0];case "double":return(v(),G)[a>>>3>>>0];case "*":return(v(),D)[a>>>2>>>0];default:J(`invalid type for getValue: ${b}`)}};h.UTF8ToString=td;h.stringToUTF8=Y;h.lengthBytesUTF8=Dd; +var xd=[Gc,dd,qd,ab,bb,cb,db,eb,fb,gb,hb,ib,jb,kb,lb,mb,xb,yb,zb,Ob,Pb,Rb,Sb,Tb,Ub],wd={924268:(a,b,d,c,e)=>{if("undefined"==typeof h||!h.Rb)return 1;a=td(Number(a>>>0));a.startsWith("./")&&(a=a.substring(2));a=h.Rb.get(a);if(!a)return 2;b=Number(b>>>0);d=Number(d>>>0);c=Number(c>>>0);if(b+d>a.byteLength)return 3;try{const f=a.subarray(b,b+d);switch(e){case 0:(v(),A).set(f,c>>>0);break;case 1:h.fc?h.fc(c,f):h.ic(c,f);break;default:return 4}return 0}catch{return 4}},925092:()=>"undefined"!==typeof wasmOffsetConverter}; +function Qa(){return"undefined"!==typeof wasmOffsetConverter}var Jc,Kc,Lc,za,Ea,Mc,Nc,Oc,Pc,Qc,K,L,Rc,M,Sc,N,Tc,Uc,Vc,Wc,Xc,Pa;function bc(a,b,d,c){var e=N();try{return V(a)(b,d,c)}catch(f){M(e);if(f!==f+0)throw f;K(1,0)}}function ac(a,b,d){var c=N();try{return V(a)(b,d)}catch(e){M(c);if(e!==e+0)throw e;K(1,0)}}function qc(a){var b=N();try{V(a)()}catch(d){M(b);if(d!==d+0)throw d;K(1,0)}}function $b(a,b){var d=N();try{return V(a)(b)}catch(c){M(d);if(c!==c+0)throw c;K(1,0)}} +function sc(a,b,d){var c=N();try{V(a)(b,d)}catch(e){M(c);if(e!==e+0)throw e;K(1,0)}}function rc(a,b){var d=N();try{V(a)(b)}catch(c){M(d);if(c!==c+0)throw c;K(1,0)}}function fc(a,b,d,c,e,f,g){var l=N();try{return V(a)(b,d,c,e,f,g)}catch(u){M(l);if(u!==u+0)throw u;K(1,0)}}function wc(a,b,d,c,e,f){var g=N();try{V(a)(b,d,c,e,f)}catch(l){M(g);if(l!==l+0)throw l;K(1,0)}}function uc(a,b,d,c){var e=N();try{V(a)(b,d,c)}catch(f){M(e);if(f!==f+0)throw f;K(1,0)}} +function vc(a,b,d,c,e){var f=N();try{V(a)(b,d,c,e)}catch(g){M(f);if(g!==g+0)throw g;K(1,0)}}function xc(a,b,d,c,e,f,g){var l=N();try{V(a)(b,d,c,e,f,g)}catch(u){M(l);if(u!==u+0)throw u;K(1,0)}}function Ec(a,b,d,c,e,f,g){var l=N();try{V(a)(b,d,c,e,f,g)}catch(u){M(l);if(u!==u+0)throw u;K(1,0)}}function Dc(a,b,d,c,e,f,g,l){var u=N();try{V(a)(b,d,c,e,f,g,l)}catch(w){M(u);if(w!==w+0)throw w;K(1,0)}}function cc(a,b,d,c,e){var f=N();try{return V(a)(b,d,c,e)}catch(g){M(f);if(g!==g+0)throw g;K(1,0)}} +function yc(a,b,d,c,e,f,g,l){var u=N();try{V(a)(b,d,c,e,f,g,l)}catch(w){M(u);if(w!==w+0)throw w;K(1,0)}}function Bc(a,b,d,c,e,f,g,l,u,w,y,B){var E=N();try{V(a)(b,d,c,e,f,g,l,u,w,y,B)}catch(F){M(E);if(F!==F+0)throw F;K(1,0)}}function ec(a,b,d,c,e,f){var g=N();try{return V(a)(b,d,c,e,f)}catch(l){M(g);if(l!==l+0)throw l;K(1,0)}}function oc(a,b,d){var c=N();try{return V(a)(b,d)}catch(e){M(c);if(e!==e+0)throw e;K(1,0);return 0n}} +function zc(a,b,d,c,e,f,g,l,u){var w=N();try{V(a)(b,d,c,e,f,g,l,u)}catch(y){M(w);if(y!==y+0)throw y;K(1,0)}}function Zb(a){var b=N();try{return V(a)()}catch(d){M(b);if(d!==d+0)throw d;K(1,0)}}function lc(a,b,d){var c=N();try{return V(a)(b,d)}catch(e){M(c);if(e!==e+0)throw e;K(1,0)}}function nc(a,b){var d=N();try{return V(a)(b)}catch(c){M(d);if(c!==c+0)throw c;K(1,0);return 0n}}function mc(a){var b=N();try{return V(a)()}catch(d){M(b);if(d!==d+0)throw d;K(1,0);return 0n}} +function kc(a,b,d,c){var e=N();try{return V(a)(b,d,c)}catch(f){M(e);if(f!==f+0)throw f;K(1,0)}}function jc(a,b,d,c,e){var f=N();try{return V(a)(b,d,c,e)}catch(g){M(f);if(g!==g+0)throw g;K(1,0)}}function ic(a,b,d,c,e,f){var g=N();try{return V(a)(b,d,c,e,f)}catch(l){M(g);if(l!==l+0)throw l;K(1,0)}}function dc(a,b,d,c,e,f){var g=N();try{return V(a)(b,d,c,e,f)}catch(l){M(g);if(l!==l+0)throw l;K(1,0)}} +function gc(a,b,d,c,e,f,g,l){var u=N();try{return V(a)(b,d,c,e,f,g,l)}catch(w){M(u);if(w!==w+0)throw w;K(1,0)}}function pc(a,b,d,c,e){var f=N();try{return V(a)(b,d,c,e)}catch(g){M(f);if(g!==g+0)throw g;K(1,0);return 0n}}function Yb(a,b,d,c){var e=N();try{return V(a)(b,d,c)}catch(f){M(e);if(f!==f+0)throw f;K(1,0)}}function Wb(a,b,d,c){var e=N();try{return V(a)(b,d,c)}catch(f){M(e);if(f!==f+0)throw f;K(1,0)}} +function hc(a,b,d,c,e,f,g,l,u,w,y,B){var E=N();try{return V(a)(b,d,c,e,f,g,l,u,w,y,B)}catch(F){M(E);if(F!==F+0)throw F;K(1,0)}}function Ac(a,b,d,c,e,f,g,l,u,w,y){var B=N();try{V(a)(b,d,c,e,f,g,l,u,w,y)}catch(E){M(B);if(E!==E+0)throw E;K(1,0)}}function Cc(a,b,d,c,e,f,g,l,u,w,y,B,E,F,Kd,Ld){var Md=N();try{V(a)(b,d,c,e,f,g,l,u,w,y,B,E,F,Kd,Ld)}catch(Ma){M(Md);if(Ma!==Ma+0)throw Ma;K(1,0)}}function Xb(a,b,d){var c=N();try{return V(a)(b,d)}catch(e){M(c);if(e!==e+0)throw e;K(1,0)}} +function Vb(a,b,d){var c=N();try{return V(a)(b,d)}catch(e){M(c);if(e!==e+0)throw e;K(1,0)}}function tc(a,b,d,c){var e=N();try{V(a)(b,d,c)}catch(f){M(e);if(f!==f+0)throw f;K(1,0)}}function Hc(){var a=I;a=Object.assign({},a);var b=c=>()=>c()>>>0,d=c=>e=>c(e)>>>0;a.sb=b(a.sb);a.ub=d(a.ub);a.Ib=d(a.Ib);a.Jb=b(a.Jb);a.Nb=d(a.Nb);return a}function xa(){if(0{ra=a;sa=b}); +;return moduleRtn}export default ortWasmThreaded;var isPthread=globalThis.self?.name?.startsWith("em-pthread");var isNode=globalThis.process?.versions?.node&&globalThis.process?.type!="renderer";if(isNode)isPthread=(await import("worker_threads")).workerData==="em-pthread";isPthread&&ortWasmThreaded(); diff --git a/public/ort/ort-wasm-simd-threaded.wasm b/public/ort/ort-wasm-simd-threaded.wasm new file mode 100644 index 00000000..532e65e5 Binary files /dev/null and b/public/ort/ort-wasm-simd-threaded.wasm differ diff --git a/shared/messages.ts b/shared/messages.ts index 44a3a82e..b36a4c1a 100644 --- a/shared/messages.ts +++ b/shared/messages.ts @@ -61,15 +61,17 @@ export type AgentToolCallMessage = { export type ModelStatusMessage = { type: 'model:status' - status: 'loading' | 'ready' | 'error' + status: 'loading' | 'ready' | 'error' | 'unloaded' modelId?: ModelId progress?: number error?: string + device?: string } export type ModelSwitchMessage = { type: 'model:switch' modelId: ModelId + device?: string } // Service Worker -> Offscreen Document @@ -84,6 +86,7 @@ export type AgentRunMessage = { export type ModelLoadMessage = { type: 'model:load' modelId?: ModelId + device?: string } // Offscreen Document -> Service Worker @@ -108,10 +111,11 @@ export type OffscreenAgentChunkMessage = { export type OffscreenModelStatusMessage = { type: 'model:status' - status: 'loading' | 'ready' | 'error' + status: 'loading' | 'ready' | 'error' | 'unloaded' modelId?: ModelId progress?: number error?: string + device?: string } export type GPUWarningMessage = { diff --git a/shared/models.ts b/shared/models.ts index 3d8b839d..e50d1b61 100644 --- a/shared/models.ts +++ b/shared/models.ts @@ -13,14 +13,14 @@ export const MODELS: Record = { id: 'gemma-4-e2b', hfModelId: 'onnx-community/gemma-4-E2B-it-ONNX', label: 'Gemma 4 E2B', - downloadSize: '~500MB', + downloadSize: '~3.4GB', contextLimit: 128_000, }, 'gemma-4-e4b': { id: 'gemma-4-e4b', hfModelId: 'onnx-community/gemma-4-E4B-it-ONNX', label: 'Gemma 4 E4B', - downloadSize: '~1.5GB', + downloadSize: '~5.2GB', contextLimit: 128_000, }, } diff --git a/shared/shortcuts.ts b/shared/shortcuts.ts new file mode 100644 index 00000000..e939c130 --- /dev/null +++ b/shared/shortcuts.ts @@ -0,0 +1,68 @@ +// A keyboard shortcut is keyed off KeyboardEvent.code (the physical key, e.g. +// 'KeyG'), not .key — because holding a modifier like Alt mutates .key into a +// layout-dependent character (on macOS, Alt+G yields '©'), whereas .code is stable. +export interface Shortcut { + code: string + alt: boolean + ctrl: boolean + meta: boolean + shift: boolean +} + +export interface ShortcutsConfig { + toggle: Shortcut + close: Shortcut +} + +export const DEFAULT_SHORTCUTS: ShortcutsConfig = { + toggle: { code: 'KeyG', alt: true, ctrl: false, meta: false, shift: false }, + close: { code: 'Escape', alt: false, ctrl: false, meta: false, shift: false }, +} + +export const STORAGE_KEY_SHORTCUTS = 'gemma_shortcuts' + +const MODIFIER_CODES = new Set([ + 'AltLeft', 'AltRight', + 'ControlLeft', 'ControlRight', + 'MetaLeft', 'MetaRight', + 'ShiftLeft', 'ShiftRight', +]) + +export function matchesShortcut(e: KeyboardEvent, shortcut: Shortcut): boolean { + return e.code === shortcut.code && + e.altKey === shortcut.alt && + e.ctrlKey === shortcut.ctrl && + e.metaKey === shortcut.meta && + e.shiftKey === shortcut.shift +} + +// Build a Shortcut from a keydown event, or null when only a modifier was pressed +// (so the rebind UI keeps waiting for a real key). +export function shortcutFromEvent(e: KeyboardEvent): Shortcut | null { + if (MODIFIER_CODES.has(e.code)) return null + return { + code: e.code, + alt: e.altKey, + ctrl: e.ctrlKey, + meta: e.metaKey, + shift: e.shiftKey, + } +} + +function codeLabel(code: string): string { + if (code.startsWith('Key')) return code.slice(3) + if (code.startsWith('Digit')) return code.slice(5) + if (code.startsWith('Numpad')) return `Num ${code.slice(6)}` + if (code.startsWith('Arrow')) return code.slice(5) + return code +} + +export function formatShortcut(shortcut: Shortcut): string { + const parts: string[] = [] + if (shortcut.ctrl) parts.push('Ctrl') + if (shortcut.alt) parts.push('Alt') + if (shortcut.shift) parts.push('Shift') + if (shortcut.meta) parts.push('Cmd') + parts.push(codeLabel(shortcut.code)) + return parts.join('+') +} diff --git a/wxt.config.ts b/wxt.config.ts index 2359b75a..2cba17b4 100644 --- a/wxt.config.ts +++ b/wxt.config.ts @@ -14,6 +14,8 @@ function copyOrtFiles() { const files = [ 'ort-wasm-simd-threaded.asyncify.mjs', 'ort-wasm-simd-threaded.asyncify.wasm', + 'ort-wasm-simd-threaded.mjs', + 'ort-wasm-simd-threaded.wasm', ] for (const file of files) {