firefox + llama.cpp == very good prose.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(extension) configurable and persistent api addr, ttft shown in ms, better timeouts management with manual retry from the ui

eagleusb e7116590 caa34f9b

+324 -71
+6 -4
src/api.ts
··· 1 - import { API_BASE_URL, API_PARAMS, API_TIMEOUT_MS } from "./config"; 1 + import { API_PARAMS, API_TIMEOUT_MS } from "./config"; 2 2 import type { ApiErrorResponse, ApiChatCompletionStreamChunk } from "./types/api"; 3 3 4 4 /** Error thrown when the API call fails for any reason (network, HTTP, malformed response). */ ··· 21 21 * 22 22 * @param text - Validated, non-empty input text 23 23 * @param systemPrompt - System prompt to instruct the model 24 + * @param baseUrl - API server base URL (e.g. "http://localhost:8080") 24 25 * @yields Individual content tokens from the model's stream 25 26 * @throws {@link ApiError} on timeout, HTTP errors, or network failures 26 27 */ 27 28 export async function* streamCorrection( 28 29 text: string, 29 30 systemPrompt: string, 31 + baseUrl: string, 30 32 ): AsyncGenerator<string, void, undefined> { 31 33 const controller = new AbortController(); 32 34 const timeout = setTimeout(() => controller.abort(), API_TIMEOUT_MS); ··· 34 36 let response: Response; 35 37 36 38 try { 37 - response = await fetch(`${API_BASE_URL}/v1/chat/completions`, { 39 + response = await fetch(`${baseUrl}/v1/chat/completions`, { 38 40 method: "POST", 39 41 headers: { "Content-Type": "application/json" }, 40 42 body: JSON.stringify({ ··· 57 59 } 58 60 59 61 throw new ApiError( 60 - `Failed to connect to API at ${API_BASE_URL}. Is llama.cpp server running?`, 62 + `Failed to connect to API at ${baseUrl}. Is llama.cpp server running?`, 61 63 undefined, 62 64 err, 63 65 ); ··· 82 84 switch (status) { 83 85 case 404: 84 86 throw new ApiError( 85 - `API endpoint not found (404). Is llama.cpp server running at ${API_BASE_URL}?`, 87 + `API endpoint not found (404). Is llama.cpp server running at ${baseUrl}?`, 86 88 status, 87 89 ); 88 90 case 429:
+74 -10
src/background.ts
··· 1 1 import { streamCorrection, ApiError } from "./api"; 2 2 import { validateInput, ValidationError } from "./validation"; 3 - import { CORRECT_PROMPT, SUGGEST_PROMPT } from "./config"; 3 + import { CORRECT_PROMPT, SUGGEST_PROMPT, API_BASE_URL, STORAGE_KEY_API_URL } from "./config"; 4 4 5 5 /** Context menu item ID. */ 6 6 const MENU_ID = "correct-with-llamacpp"; ··· 8 8 /** Section identifiers for streaming responses. */ 9 9 type Section = "corrected" | "suggested"; 10 10 11 + /** Maps a section to its system prompt. */ 12 + const SECTION_PROMPTS: Record<Section, string> = { 13 + corrected: CORRECT_PROMPT, 14 + suggested: SUGGEST_PROMPT, 15 + }; 16 + 11 17 /** 12 18 * Pending state for a popup window that hasn't signalled "ready" yet. 13 19 */ ··· 18 24 19 25 let pending: PendingResult | null = null; 20 26 27 + // ─── Storage helpers ─────────────────────────────────────────────────────── 28 + 29 + /** Reads the configured API base URL from storage, falling back to the default. */ 30 + async function getApiBaseUrl(): Promise<string> { 31 + const result = await browser.storage.local.get(STORAGE_KEY_API_URL); 32 + const stored = result[STORAGE_KEY_API_URL]; 33 + return typeof stored === "string" && stored.length > 0 ? stored : API_BASE_URL; 34 + } 35 + 21 36 // ─── Context menu setup ──────────────────────────────────────────────────── 22 37 23 38 browser.runtime.onInstalled.addListener(() => { ··· 28 43 }); 29 44 }); 30 45 31 - // ─── Message handler (result tab readiness) ──────────────────────────────── 46 + // ─── Message handler (result tab readiness + retry) ──────────────────────── 32 47 33 - browser.runtime.onMessage.addListener((msg: { type: string }) => { 48 + browser.runtime.onMessage.addListener((msg: { type: string }, sender) => { 34 49 if (msg.type === "ready") { 35 50 if (pending) { 36 51 pending.resolve(); 37 52 } 53 + return; 54 + } 55 + 56 + if (msg.type === "retry") { 57 + const retryMsg = msg as { type: "retry"; section: Section; original: string }; 58 + const tabId = sender.tab?.id; 59 + if (!tabId) { 60 + return; 61 + } 62 + handleRetry(tabId, retryMsg.section, retryMsg.original); 38 63 } 39 64 }); 40 65 ··· 82 107 original: inputText, 83 108 }); 84 109 85 - // 5. Sequential streaming: corrected first, then suggested 110 + // 5. Sequential streaming with per-section error handling 111 + const baseUrl = await getApiBaseUrl(); 112 + 113 + const correctedOk = await attemptStreamSection(tabId, inputText, "corrected", baseUrl); 114 + 115 + if (correctedOk) { 116 + await attemptStreamSection(tabId, inputText, "suggested", baseUrl); 117 + } 118 + 119 + await browser.tabs.sendMessage(tabId, { type: "done" }); 120 + }); 121 + 122 + // ─── Stream a single section, returning success/failure ──────────────────── 123 + 124 + /** 125 + * Attempts to stream a section. On success, sends `section-done`. 126 + * On failure, sends `section-error` with the error message. 127 + * Returns `true` if the section completed successfully. 128 + */ 129 + async function attemptStreamSection( 130 + tabId: number, 131 + text: string, 132 + section: Section, 133 + baseUrl: string, 134 + ): Promise<boolean> { 86 135 try { 87 - await streamSection(tabId, inputText, CORRECT_PROMPT, "corrected"); 88 - await streamSection(tabId, inputText, SUGGEST_PROMPT, "suggested"); 89 - await browser.tabs.sendMessage(tabId, { type: "done" }); 136 + await streamSection(tabId, text, SECTION_PROMPTS[section], section, baseUrl); 137 + return true; 90 138 } catch (err: unknown) { 91 139 const msg = 92 140 err instanceof ApiError || err instanceof ValidationError 93 141 ? err.message 94 142 : "An unexpected error occurred."; 95 - await browser.tabs.sendMessage(tabId, { type: "error", message: msg }); 143 + await browser.tabs.sendMessage(tabId, { type: "section-error", section, message: msg }); 144 + return false; 96 145 } 97 - }); 146 + } 98 147 99 148 // ─── Stream a single section from the API ────────────────────────────────── 100 149 ··· 103 152 text: string, 104 153 systemPrompt: string, 105 154 section: Section, 155 + baseUrl: string, 106 156 ): Promise<void> { 107 157 await browser.tabs.sendMessage(tabId, { type: "section-start", section }); 108 158 109 - for await (const token of streamCorrection(text, systemPrompt)) { 159 + const t0 = Date.now(); 160 + let firstToken = true; 161 + 162 + for await (const token of streamCorrection(text, systemPrompt, baseUrl)) { 163 + const latencyMs = firstToken ? Date.now() - t0 : undefined; 164 + firstToken = false; 165 + 110 166 await browser.tabs.sendMessage(tabId, { 111 167 type: "stream", 112 168 section, 113 169 token, 170 + latencyMs, 114 171 }); 115 172 } 116 173 117 174 await browser.tabs.sendMessage(tabId, { type: "section-done", section }); 175 + } 176 + 177 + // ─── Retry handler ───────────────────────────────────────────────────────── 178 + 179 + async function handleRetry(tabId: number, section: Section, original: string): Promise<void> { 180 + const baseUrl = await getApiBaseUrl(); 181 + await attemptStreamSection(tabId, original, section, baseUrl); 118 182 } 119 183 120 184 // ─── Helper: open popup with an error when validation fails immediately ────
+4 -1
src/config.ts
··· 1 1 import type { ApiChatCompletionRequest } from "./types/api"; 2 2 3 - /** Base URL of the llama.cpp server (OpenAI-compatible API). */ 3 + /** Default base URL of the OpenAI-compatible API server. */ 4 4 export const API_BASE_URL = "http://localhost:8080"; 5 + 6 + /** Storage key for the configurable API base URL. */ 7 + export const STORAGE_KEY_API_URL = "apiBaseUrl"; 5 8 6 9 /** Shared tone rules appended to every prompt. */ 7 10 const TONE_RULES = `# Tone
+118 -24
src/result.html
··· 88 88 text-transform: uppercase; 89 89 letter-spacing: 0.5px; 90 90 margin-bottom: 8px; 91 + display: inline; 92 + } 93 + 94 + .retry-icon { 95 + display: none; 96 + margin-left: 6px; 97 + font-size: 13px; 98 + cursor: pointer; 99 + color: #6c63ff; 100 + vertical-align: middle; 101 + transition: color 0.15s, transform 0.15s; 102 + } 103 + 104 + .retry-icon:hover { 105 + color: #aaffaa; 106 + transform: rotate(90deg); 107 + } 108 + 109 + .retry-icon.show { 110 + display: inline; 111 + } 112 + 113 + .latency { 114 + display: none; 115 + margin-left: 6px; 116 + font-size: 10px; 117 + color: #666; 118 + font-family: monospace; 119 + vertical-align: middle; 120 + } 121 + 122 + .latency.show { 123 + display: inline; 124 + } 125 + 126 + .section-error { 127 + color: #ff8888; 128 + font-size: 13px; 129 + font-style: italic; 91 130 } 92 131 93 132 .section-content { ··· 110 149 .section-content.corrected { 111 150 color: #aaffaa; 112 151 border-color: #2a4a2a; 152 + cursor: pointer; 153 + transition: border-color 0.2s, background 0.2s; 154 + } 155 + 156 + .section-content.corrected.copied { 157 + border-color: #4caf50; 158 + background: #1a2e1a; 113 159 } 114 160 115 161 .section-content.suggested { 116 162 color: #aaddff; 117 163 border-color: #2a3a5a; 118 - } 119 - 120 - .copy-btn { 121 - display: inline-flex; 122 - align-items: center; 123 - gap: 6px; 124 - margin-top: 10px; 125 - padding: 8px 16px; 126 - background: #6c63ff; 127 - color: #fff; 128 - border: none; 129 - border-radius: 6px; 130 - font-size: 13px; 131 - font-weight: 500; 132 164 cursor: pointer; 133 - transition: background 0.15s; 165 + transition: border-color 0.2s, background 0.2s; 134 166 } 135 167 136 - .copy-btn:hover { background: #5a52e0; } 137 - .copy-btn:active { background: #4a42c0; } 138 - 139 - .copy-btn.copied { 140 - background: #4caf50; 168 + .section-content.suggested.copied { 169 + border-color: #4caf50; 170 + background: #1a2e2a; 141 171 } 142 172 143 173 /* ─── Streaming indicator (bouncing dots) ──────────────────────── */ ··· 174 204 0%, 60%, 100% { transform: translateY(0); opacity: 0.4; } 175 205 30% { transform: translateY(-6px); opacity: 1; } 176 206 } 207 + 208 + /* ─── Settings bar ─────────────────────────────────────────────── */ 209 + 210 + .settings-bar { 211 + display: flex; 212 + align-items: center; 213 + gap: 8px; 214 + padding: 8px 12px; 215 + background: #16213e; 216 + border: 1px solid #2a2a4a; 217 + border-radius: 8px; 218 + margin-bottom: 16px; 219 + } 220 + 221 + .settings-bar label { 222 + font-size: 11px; 223 + font-weight: 600; 224 + color: #6c63ff; 225 + text-transform: uppercase; 226 + letter-spacing: 0.5px; 227 + white-space: nowrap; 228 + } 229 + 230 + .settings-bar input { 231 + flex: 1; 232 + background: #1a1a2e; 233 + border: 1px solid #2a2a4a; 234 + border-radius: 4px; 235 + padding: 4px 8px; 236 + color: #e0e0e0; 237 + font-size: 12px; 238 + font-family: monospace; 239 + outline: none; 240 + transition: border-color 0.15s; 241 + } 242 + 243 + .settings-bar input:focus { 244 + border-color: #6c63ff; 245 + } 246 + 247 + .checkmark { 248 + opacity: 0; 249 + visibility: hidden; 250 + color: #4caf50; 251 + font-size: 16px; 252 + font-weight: 700; 253 + transition: opacity 0.2s, visibility 0.2s; 254 + } 255 + 256 + .checkmark.show { 257 + opacity: 1; 258 + visibility: visible; 259 + } 177 260 </style> 178 261 </head> 179 262 <body> 263 + <div class="settings-bar"> 264 + <label for="api-url">API</label> 265 + <input type="text" id="api-url" placeholder="http://localhost:8080" spellcheck="false"> 266 + <span id="api-saved" class="checkmark">&#10003;</span> 267 + </div> 180 268 <div id="loading"> 181 269 <div class="spinner"></div> 182 270 <p>Correcting text...</p> ··· 193 281 <div class="section-content original" id="original-text"></div> 194 282 </div> 195 283 <div class="section"> 196 - <div class="section-label">Corrected</div> 284 + <div class="section-header"> 285 + <span class="section-label">Corrected</span> 286 + <span class="retry-icon" id="retry-corrected">&#8635;</span> 287 + <span class="latency" id="latency-corrected"></span> 288 + </div> 197 289 <div class="section-content corrected" id="corrected-text"> 198 290 <div id="corrected-indicator" class="streaming-indicator hidden"> 199 291 <span></span><span></span><span></span> 200 292 </div> 201 293 </div> 202 - <button type="button" class="copy-btn" id="copy-corrected-btn" disabled>Copy corrected</button> 203 294 </div> 204 295 <div class="section"> 205 - <div class="section-label">Suggested</div> 296 + <div class="section-header"> 297 + <span class="section-label">Suggested</span> 298 + <span class="retry-icon" id="retry-suggested">&#8635;</span> 299 + <span class="latency" id="latency-suggested"></span> 300 + </div> 206 301 <div class="section-content suggested" id="suggested-text"> 207 302 <div id="suggested-indicator" class="streaming-indicator hidden"> 208 303 <span></span><span></span><span></span> 209 304 </div> 210 305 </div> 211 - <button type="button" class="copy-btn" id="copy-suggested-btn" disabled>Copy suggested</button> 212 306 </div> 213 307 </div> 214 308
+122 -32
src/result.ts
··· 1 + import { API_BASE_URL, STORAGE_KEY_API_URL } from "./config"; 2 + 1 3 // ─── DOM references ──────────────────────────────────────────────────────── 2 4 5 + const apiUrlInput = document.getElementById("api-url") as HTMLInputElement; 6 + const apiSavedEl = document.getElementById("api-saved")!; 3 7 const loadingEl = document.getElementById("loading")!; 4 8 const errorEl = document.getElementById("error")!; 5 9 const errorMessageEl = document.getElementById("error-message")!; ··· 9 13 const suggestedTextEl = document.getElementById("suggested-text")!; 10 14 const correctedIndicator = document.getElementById("corrected-indicator")!; 11 15 const suggestedIndicator = document.getElementById("suggested-indicator")!; 12 - const copyCorrectedBtn = document.getElementById("copy-corrected-btn") as HTMLButtonElement; 13 - const copySuggestedBtn = document.getElementById("copy-suggested-btn") as HTMLButtonElement; 16 + const retryCorrectedIcon = document.getElementById("retry-corrected")!; 17 + const retrySuggestedIcon = document.getElementById("retry-suggested")!; 18 + const latencyCorrectedEl = document.getElementById("latency-corrected")!; 19 + const latencySuggestedEl = document.getElementById("latency-suggested")!; 14 20 15 21 // ─── Section state ───────────────────────────────────────────────────────── 16 22 ··· 19 25 interface SectionState { 20 26 el: HTMLElement; 21 27 indicator: HTMLElement; 22 - copyBtn: HTMLButtonElement; 28 + retryIcon: HTMLElement; 29 + latencyEl: HTMLElement; 23 30 accumulated: string; 24 31 firstToken: boolean; 32 + done: boolean; 25 33 } 26 34 27 35 const sections: Record<Section, SectionState> = { 28 36 corrected: { 29 37 el: correctedTextEl, 30 38 indicator: correctedIndicator, 31 - copyBtn: copyCorrectedBtn, 39 + retryIcon: retryCorrectedIcon, 40 + latencyEl: latencyCorrectedEl, 32 41 accumulated: "", 33 42 firstToken: true, 43 + done: false, 34 44 }, 35 45 suggested: { 36 46 el: suggestedTextEl, 37 47 indicator: suggestedIndicator, 38 - copyBtn: copySuggestedBtn, 48 + retryIcon: retrySuggestedIcon, 49 + latencyEl: latencySuggestedEl, 39 50 accumulated: "", 40 51 firstToken: true, 52 + done: false, 41 53 }, 42 54 }; 55 + 56 + /** Stored original text for retry requests. */ 57 + let originalText = ""; 43 58 44 59 // ─── Message types from background script ────────────────────────────────── 45 60 46 61 type ResultMessage = 47 62 | { type: "start"; original: string } 48 63 | { type: "section-start"; section: Section } 49 - | { type: "stream"; section: Section; token: string } 64 + | { type: "stream"; section: Section; token: string; latencyMs?: number } 50 65 | { type: "section-done"; section: Section } 66 + | { type: "section-error"; section: Section; message: string } 51 67 | { type: "done" } 52 68 | { type: "error"; message: string }; 53 69 ··· 62 78 showSectionStart(msg.section); 63 79 break; 64 80 case "stream": 65 - appendToken(msg.section, msg.token); 81 + appendToken(msg.section, msg.token, msg.latencyMs); 66 82 break; 67 83 case "section-done": 68 84 showSectionDone(msg.section); 69 85 break; 86 + case "section-error": 87 + showSectionError(msg.section, msg.message); 88 + break; 70 89 case "done": 71 90 break; 72 91 case "error": ··· 78 97 // ─── UI helpers ──────────────────────────────────────────────────────────── 79 98 80 99 function showStart(original: string): void { 100 + originalText = original; 81 101 loadingEl.style.display = "none"; 82 102 errorEl.style.display = "none"; 83 103 resultEl.style.display = "block"; 84 104 85 105 originalTextEl.textContent = original; 86 106 87 - for (const state of Object.values(sections)) { 88 - state.el.textContent = ""; 89 - state.accumulated = ""; 90 - state.firstToken = true; 91 - state.copyBtn.disabled = true; 107 + for (const section of Object.keys(sections) as Section[]) { 108 + resetSection(section); 92 109 } 93 110 } 94 111 112 + function resetSection(section: Section): void { 113 + const state = sections[section]; 114 + state.el.textContent = ""; 115 + state.el.classList.remove("section-error"); 116 + state.accumulated = ""; 117 + state.firstToken = true; 118 + state.done = false; 119 + state.el.classList.remove("copied"); 120 + state.retryIcon.classList.remove("show"); 121 + state.latencyEl.classList.remove("show"); 122 + state.latencyEl.textContent = ""; 123 + } 124 + 95 125 function showSectionStart(section: Section): void { 96 126 const state = sections[section]; 97 127 state.el.textContent = ""; 98 128 state.el.appendChild(state.indicator); 99 129 state.indicator.classList.remove("hidden"); 130 + state.retryIcon.classList.remove("show"); 100 131 } 101 132 102 - function appendToken(section: Section, token: string): void { 133 + function appendToken(section: Section, token: string, latencyMs?: number): void { 103 134 const state = sections[section]; 104 135 105 136 if (state.firstToken) { ··· 108 139 state.firstToken = false; 109 140 } 110 141 142 + if (latencyMs !== undefined) { 143 + state.latencyEl.textContent = `${latencyMs}ms`; 144 + state.latencyEl.classList.add("show"); 145 + } 146 + 111 147 state.accumulated += token; 112 148 state.el.textContent = state.accumulated; 149 + 150 + window.scrollTo({ top: document.body.scrollHeight, behavior: "smooth" }); 113 151 } 114 152 115 153 function showSectionDone(section: Section): void { 116 - sections[section].copyBtn.disabled = false; 154 + const state = sections[section]; 155 + state.done = true; 156 + state.retryIcon.classList.remove("show"); 157 + } 158 + 159 + function showSectionError(section: Section, message: string): void { 160 + const state = sections[section]; 161 + 162 + // Hide and detach the streaming indicator 163 + state.indicator.classList.add("hidden"); 164 + state.indicator.remove(); 165 + 166 + state.done = false; 167 + state.accumulated = ""; 168 + state.el.textContent = message; 169 + state.el.classList.add("section-error"); 170 + state.retryIcon.classList.add("show"); 117 171 } 118 172 119 173 function showError(message: string): void { ··· 124 178 errorMessageEl.textContent = message; 125 179 } 126 180 127 - // ─── Copy buttons ────────────────────────────────────────────────────────── 181 + // ─── Click-to-copy on content boxes ──────────────────────────────────────── 128 182 129 - function setupCopyButton(btn: HTMLButtonElement, label: string): void { 130 - btn.addEventListener("click", async () => { 131 - const text = btn.closest(".section")?.querySelector(".section-content")?.textContent; 132 - if (!text) { 183 + function setupCopyOnDone(section: Section): void { 184 + const state = sections[section]; 185 + 186 + state.el.addEventListener("click", async () => { 187 + if (!state.done || state.accumulated.length === 0) { 133 188 return; 134 189 } 135 190 136 191 try { 137 - await navigator.clipboard.writeText(text); 138 - btn.textContent = "Copied!"; 139 - btn.classList.add("copied"); 140 - setTimeout(() => { 141 - btn.textContent = label; 142 - btn.classList.remove("copied"); 143 - }, 2_000); 192 + await navigator.clipboard.writeText(state.accumulated); 193 + state.el.classList.add("copied"); 194 + setTimeout(() => state.el.classList.remove("copied"), 2_000); 144 195 } catch { 145 - btn.textContent = "Copy failed"; 146 - setTimeout(() => { 147 - btn.textContent = label; 148 - }, 2_000); 196 + // silently ignore clipboard failures 149 197 } 150 198 }); 151 199 } 152 200 153 - setupCopyButton(copyCorrectedBtn, "Copy corrected"); 154 - setupCopyButton(copySuggestedBtn, "Copy suggested"); 201 + setupCopyOnDone("corrected"); 202 + setupCopyOnDone("suggested"); 203 + 204 + // ─── Retry icons ─────────────────────────────────────────────────────────── 205 + 206 + function setupRetryIcon(section: Section): void { 207 + const state = sections[section]; 208 + 209 + state.retryIcon.addEventListener("click", () => { 210 + state.el.classList.remove("section-error"); 211 + resetSection(section); 212 + showSectionStart(section); 213 + 214 + browser.runtime.sendMessage({ 215 + type: "retry", 216 + section, 217 + original: originalText, 218 + }); 219 + }); 220 + } 221 + 222 + setupRetryIcon("corrected"); 223 + setupRetryIcon("suggested"); 224 + 225 + // ─── API URL settings ────────────────────────────────────────────────────── 226 + 227 + /** Load stored API URL into the input field on popup open. */ 228 + browser.storage.local.get(STORAGE_KEY_API_URL).then((result) => { 229 + const stored = result[STORAGE_KEY_API_URL]; 230 + apiUrlInput.value = typeof stored === "string" && stored.length > 0 231 + ? stored 232 + : API_BASE_URL; 233 + }); 234 + 235 + /** Save API URL to storage on change. */ 236 + apiUrlInput.addEventListener("change", () => { 237 + const value = apiUrlInput.value.trim(); 238 + if (value.startsWith("http://") || value.startsWith("https://")) { 239 + browser.storage.local.set({ [STORAGE_KEY_API_URL]: value }).then(() => { 240 + apiSavedEl.classList.add("show"); 241 + setTimeout(() => apiSavedEl.classList.remove("show"), 2_000); 242 + }); 243 + } 244 + }); 155 245 156 246 // ─── Signal readiness to background script ───────────────────────────────── 157 247