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

Configure Feed

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

feat(settings) multi lang support with settings modal

eagleusb 7a68f2bb b787b059

+556 -238
+50 -2
src/api.ts
··· 1 - import { API_PARAMS, API_TIMEOUT_MS } from "./config"; 2 - import type { ApiErrorResponse, ApiChatCompletionStreamChunk } from "./types/api"; 1 + import { API_PARAMS, API_TIMEOUT_MS, DEBUG } from "./config"; 2 + import type { ApiErrorResponse, ApiChatCompletionStreamChunk, ApiHealthResponse } from "./types/api"; 3 + 4 + /** metadata returned by the api after streaming completes. */ 5 + export interface StreamResult { 6 + completionTokens?: number; 7 + } 3 8 4 9 /** error thrown when the api call fails for any reason (network, http, malformed response). */ 5 10 export class ApiError extends Error { ··· 22 27 * @param text - validated, non-empty input text 23 28 * @param systemPrompt - system prompt to instruct the model 24 29 * @param baseUrl - api server base url (e.g. "http://localhost:8080") 30 + * @param result - optional object populated with metadata after streaming completes 25 31 * @yields individual content tokens from the model's stream 26 32 * @throws {@link ApiError} on timeout, http errors, or network failures 27 33 */ ··· 29 35 text: string, 30 36 systemPrompt: string, 31 37 baseUrl: string, 38 + result?: StreamResult, 32 39 ): AsyncGenerator<string, void, undefined> { 33 40 const controller = new AbortController(); 34 41 const timeout = setTimeout(() => controller.abort(), API_TIMEOUT_MS); ··· 42 49 body: JSON.stringify({ 43 50 ...API_PARAMS, 44 51 stream: true, 52 + stream_options: { include_usage: true }, 45 53 messages: [ 46 54 { role: "system", content: systemPrompt }, 47 55 { role: "user", content: text }, ··· 142 150 continue; 143 151 } 144 152 153 + if (DEBUG) { 154 + // eslint-disable-next-line no-console 155 + console.log("[shakespeare]", chunk); 156 + } 157 + 145 158 const token = chunk.choices?.[0]?.delta?.content; 146 159 if (token) { 147 160 yield token; 148 161 } 162 + 163 + if (chunk.usage && result) { 164 + result.completionTokens = chunk.usage.completion_tokens; 165 + } 149 166 } 150 167 } 151 168 } finally { 152 169 reader.releaseLock(); 153 170 } 154 171 } 172 + 173 + /** 174 + * checks whether the llama.cpp server is reachable and healthy. 175 + * 176 + * queries the `/v1/health` endpoint and returns `true` if the server 177 + * responds with `{ "status": "ok" }`. returns `false` on any network 178 + * error, non-200 status, or unexpected response body. 179 + * 180 + * @param baseUrl - api server base url (e.g. "http://localhost:8080") 181 + */ 182 + export async function checkHealth(baseUrl: string): Promise<boolean> { 183 + try { 184 + const controller = new AbortController(); 185 + const timeout = setTimeout(() => controller.abort(), 5_000); 186 + 187 + const response = await fetch(`${baseUrl}/v1/health`, { 188 + signal: controller.signal, 189 + }); 190 + 191 + clearTimeout(timeout); 192 + 193 + if (!response.ok) { 194 + return false; 195 + } 196 + 197 + const body = (await response.json()) as ApiHealthResponse; 198 + return body.status === "ok"; 199 + } catch { 200 + return false; 201 + } 202 + }
+59 -17
src/background.ts
··· 1 - import { streamCorrection, ApiError } from "./api"; 1 + import { streamCorrection, ApiError, checkHealth } from "./api"; 2 + import type { StreamResult } from "./api"; 2 3 import { validateInput, ValidationError } from "./validation"; 3 - import { CORRECT_PROMPT, SUGGEST_PROMPT, API_BASE_URL, STORAGE_KEY_API_URL } from "./config"; 4 + import { 5 + PROMPTS, 6 + API_BASE_URL, 7 + STORAGE_KEY_API_URL, 8 + STORAGE_KEY_LANGUAGE, 9 + DEFAULT_LANGUAGE, 10 + } from "./config"; 11 + import type { Language } from "./config"; 4 12 5 13 /** context menu item id */ 6 - const MENU_ID = "correct-with-llamacpp"; 14 + const MENU_ID = "shakespeare-selection"; 15 + const SETTINGS_MENU_ID = "shakespeare-settings"; 7 16 8 17 /** section identifiers for streaming responses */ 9 18 type Section = "corrected" | "suggested"; 10 19 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 - 17 20 /** pending state for a popup window that hasn't signalled "ready" yet */ 18 21 interface PendingResult { 19 22 tabId: number; ··· 31 34 return typeof stored === "string" && stored.length > 0 ? stored : API_BASE_URL; 32 35 } 33 36 37 + /** reads the selected prompt language from storage, falling back to the default */ 38 + async function getLanguage(): Promise<Language> { 39 + const result = await browser.storage.local.get(STORAGE_KEY_LANGUAGE); 40 + const stored = result[STORAGE_KEY_LANGUAGE]; 41 + return stored === "en" || stored === "fr" ? stored : DEFAULT_LANGUAGE; 42 + } 43 + 34 44 /* context menu setup */ 35 45 36 46 browser.runtime.onInstalled.addListener(() => { 37 47 browser.contextMenus.create({ 38 48 id: MENU_ID, 39 - title: "shakespeare edit (selection)", 49 + title: "shakespeare correction (selection)", 40 50 contexts: ["selection"], 41 51 }); 52 + 53 + browser.contextMenus.create({ 54 + id: SETTINGS_MENU_ID, 55 + title: "shakespeare settings", 56 + contexts: ["all"], 57 + }); 42 58 }); 43 59 44 60 /* message handler (result tab readiness + retry) */ 45 61 46 - browser.runtime.onMessage.addListener((msg: { type: string }, sender) => { 62 + browser.runtime.onMessage.addListener((msg: { type: string }, sender, sendResponse) => { 47 63 if (msg.type === "ready") { 48 64 if (pending) { 49 65 pending.resolve(); ··· 58 74 return; 59 75 } 60 76 handleRetry(tabId, retryMsg.section, retryMsg.original); 77 + return; 78 + } 79 + 80 + if (msg.type === "check-health") { 81 + const healthMsg = msg as { type: "check-health"; url: string }; 82 + checkHealth(healthMsg.url).then(sendResponse); 83 + return true; 61 84 } 62 85 }); 63 86 64 87 /* context menu click handler */ 65 88 66 89 browser.contextMenus.onClicked.addListener(async (info) => { 90 + if (info.menuItemId === SETTINGS_MENU_ID) { 91 + await browser.windows.create({ 92 + type: "popup", 93 + url: browser.runtime.getURL("result.html?mode=settings"), 94 + width: 600, 95 + height: 200, 96 + }); 97 + return; 98 + } 99 + 67 100 if (info.menuItemId !== MENU_ID) { 68 101 return; 69 102 } ··· 107 140 108 141 /* 5. sequential streaming with per-section error handling */ 109 142 const baseUrl = await getApiBaseUrl(); 143 + const language = await getLanguage(); 110 144 111 - const correctedOk = await attemptStreamSection(tabId, inputText, "corrected", baseUrl); 145 + const correctedOk = await attemptStreamSection(tabId, inputText, "corrected", baseUrl, language); 112 146 113 147 if (correctedOk) { 114 - await attemptStreamSection(tabId, inputText, "suggested", baseUrl); 148 + await attemptStreamSection(tabId, inputText, "suggested", baseUrl, language); 115 149 } 116 150 117 151 await browser.tabs.sendMessage(tabId, { type: "done" }); ··· 127 161 text: string, 128 162 section: Section, 129 163 baseUrl: string, 164 + language: Language, 130 165 ): Promise<boolean> { 131 166 try { 132 - await streamSection(tabId, text, SECTION_PROMPTS[section], section, baseUrl); 167 + const systemPrompt = PROMPTS[language][section === "corrected" ? "correct" : "suggest"]; 168 + await streamSection(tabId, text, systemPrompt, section, baseUrl); 133 169 return true; 134 170 } catch (err: unknown) { 135 171 const msg = ··· 153 189 154 190 const t0 = Date.now(); 155 191 let firstToken = true; 192 + const result: StreamResult = {}; 156 193 157 - for await (const token of streamCorrection(text, systemPrompt, baseUrl)) { 194 + for await (const token of streamCorrection(text, systemPrompt, baseUrl, result)) { 158 195 const latencyMs = firstToken ? Date.now() - t0 : undefined; 159 196 firstToken = false; 160 197 ··· 166 203 }); 167 204 } 168 205 169 - await browser.tabs.sendMessage(tabId, { type: "section-done", section }); 206 + await browser.tabs.sendMessage(tabId, { 207 + type: "section-done", 208 + section, 209 + completionTokens: result.completionTokens, 210 + }); 170 211 } 171 212 172 213 /* retry handler */ 173 214 174 215 async function handleRetry(tabId: number, section: Section, original: string): Promise<void> { 175 216 const baseUrl = await getApiBaseUrl(); 176 - await attemptStreamSection(tabId, original, section, baseUrl); 217 + const language = await getLanguage(); 218 + await attemptStreamSection(tabId, original, section, baseUrl, language); 177 219 } 178 220 179 221 /** open popup with an error when validation fails immediately */
+59 -20
src/config.ts
··· 6 6 /** Storage key for the configurable API base URL. */ 7 7 export const STORAGE_KEY_API_URL = "apiBaseUrl"; 8 8 9 - /** Shared tone rules appended to every prompt. */ 10 - const TONE_RULES = `# Tone 11 - When responding, you must follow these rules: 12 - - follow the original tone and style 13 - - answer directly from your knowledge when you can 14 - - be concise, prioritize clarity, brevity and don't repeat yourself 15 - - admit when you're unsure rather than making things up`; 9 + /** Supported prompt languages. */ 10 + export type Language = "en" | "fr"; 16 11 17 - /** 18 - * System prompt for grammar/spelling correction. 19 - * Instructs the model to return only the corrected text. 20 - */ 21 - export const CORRECT_PROMPT = `# Agent Guidelines 12 + /** Storage key for the selected prompt language. */ 13 + export const STORAGE_KEY_LANGUAGE = "language"; 14 + 15 + /** Default prompt language. */ 16 + export const DEFAULT_LANGUAGE: Language = "en"; 17 + 18 + /** Per-language prompts for correction and suggestion. */ 19 + export const PROMPTS: Record<Language, { correct: string; suggest: string }> = { 20 + en: { 21 + correct: `# Agent Guidelines 22 22 You are an agent specialized in english and french grammar correction. 23 23 Correct the grammar, spelling, and punctuation of the submitted text in its original language. 24 24 25 25 # Output 26 26 Return ONLY the corrected text. No headings, no explanations, no markdown formatting. 27 27 28 - ${TONE_RULES}`; 28 + # Tone 29 + When responding, you must follow these rules: 30 + - follow the original tone and style 31 + - answer directly from your knowledge when you can 32 + - be concise, prioritize clarity, brevity and don't repeat yourself 33 + - admit when you're unsure rather than making things up`, 29 34 30 - /** 31 - * System prompt for wording improvement. 32 - * Instructs the model to return a better-worded version of the text. 33 - */ 34 - export const SUGGEST_PROMPT = `# Agent Guidelines 35 + suggest: `# Agent Guidelines 35 36 You are an agent specialized in english and french writing improvement. 36 37 Rewrite the submitted text with better wording and phrasing. 37 38 Keep the original language if no translation is asked (english/english or french/french). 38 - Keep the same tone as the original text. 39 39 40 40 # Output 41 41 Return ONLY the improved text. Try to keep the same format and return lines. No headings, no explanations, no markdown formatting. 42 42 43 - ${TONE_RULES}`; 43 + # Tone 44 + When responding, you must follow these rules: 45 + - answer directly from your knowledge when you can 46 + - be concise, prioritize clarity, brevity and don't repeat yourself 47 + - admit when you're unsure rather than making things up`, 48 + }, 49 + 50 + fr: { 51 + correct: `# Directives de l'agent 52 + Tu es un agent spécialisé dans la correction grammaticale française. 53 + Corrige la grammaire, l'orthographe et la ponctuation du texte soumis dans sa langue d'origine. 54 + 55 + # Sortie 56 + Retourne UNIQUEMENT le texte corrigé. Pas de titres, pas d'explications, pas de formatage markdown. 57 + 58 + # Ton 59 + Lors de ta réponse, tu dois suivre ces règles : 60 + - respecter le ton et le style d'origine 61 + - répondre directement à partir de tes connaissances quand tu le peux 62 + - être concis, privilégier la clarté et la brièveté, ne pas te répéter 63 + - avouer quand tu n'es pas sûr plutôt qu'inventer`, 64 + 65 + suggest: `# Directives de l'agent 66 + Tu es un agent spécialisé dans l'amélioration rédactionnelle française. 67 + Réécris le texte soumis avec un meilleur choix de mots et de tournures. 68 + Conserve la langue d'origine si aucune traduction n'est demandée (anglais/anglais ou français/français). 69 + 70 + # Sortie 71 + Retourne UNIQUEMENT le texte amélioré. Essaie de conserver le même format et les mêmes retours à la ligne. Pas de titres, pas d'explications, pas de formatage markdown. 72 + 73 + # Ton 74 + Lors de ta réponse, tu dois suivre ces règles : 75 + - répondre directement à partir de tes connaissances quand tu le peux 76 + - être concis, privilégier la clarté et la brièveté, ne pas te répéter 77 + - avouer quand tu n'es pas sûr plutôt qu'inventer`, 78 + }, 79 + }; 44 80 45 81 /** Parameters sent to the chat completions endpoint (generation/sampling subset). */ 46 82 export const API_PARAMS: Pick<ApiChatCompletionRequest, ··· 64 100 presence_penalty: 0.0, 65 101 stream: true, 66 102 }; 103 + 104 + /** Enable debug logging of llama.cpp SSE chunks in the service worker console. */ 105 + export const DEBUG = false; 67 106 68 107 /** HTTP request timeout in milliseconds. */ 69 108 export const API_TIMEOUT_MS = 30_000;
+71
src/result.html
··· 123 123 display: inline; 124 124 } 125 125 126 + .tokens { 127 + display: none; 128 + margin-left: 6px; 129 + font-size: 10px; 130 + color: #666; 131 + font-family: monospace; 132 + vertical-align: middle; 133 + } 134 + 135 + .tokens.show { 136 + display: inline; 137 + } 138 + 126 139 .section-error { 127 140 color: #ff8888; 128 141 font-size: 13px; ··· 257 270 opacity: 1; 258 271 visibility: visible; 259 272 } 273 + 274 + .health-dot { 275 + display: none; 276 + width: 8px; 277 + height: 8px; 278 + border-radius: 50%; 279 + vertical-align: middle; 280 + } 281 + 282 + .health-dot.ok { 283 + display: inline-block; 284 + background: #4caf50; 285 + } 286 + 287 + .health-dot.fail { 288 + display: inline-block; 289 + background: #ff5555; 290 + } 291 + 292 + .lang-toggle { 293 + display: flex; 294 + margin-left: auto; 295 + border: 1px solid #2a2a4a; 296 + border-radius: 4px; 297 + overflow: hidden; 298 + } 299 + 300 + .lang-btn { 301 + background: #1a1a2e; 302 + border: none; 303 + padding: 3px 8px; 304 + font-size: 11px; 305 + font-weight: 600; 306 + color: #888; 307 + cursor: pointer; 308 + transition: background 0.15s, color 0.15s; 309 + } 310 + 311 + .lang-btn + .lang-btn { 312 + border-left: 1px solid #2a2a4a; 313 + } 314 + 315 + .lang-btn.active { 316 + background: #6c63ff; 317 + color: #fff; 318 + } 319 + 320 + .lang-btn:hover:not(.active) { 321 + background: #2a2a4a; 322 + color: #ccc; 323 + } 260 324 </style> 261 325 </head> 262 326 <body> ··· 264 328 <label for="api-url">API</label> 265 329 <input type="text" id="api-url" placeholder="http://localhost:8080" spellcheck="false"> 266 330 <span id="api-saved" class="checkmark">&#10003;</span> 331 + <span id="health-dot" class="health-dot"></span> 332 + <div class="lang-toggle"> 333 + <button id="lang-en" class="lang-btn active" type="button">EN</button> 334 + <button id="lang-fr" class="lang-btn" type="button">FR</button> 335 + </div> 267 336 </div> 268 337 <div id="loading"> 269 338 <div class="spinner"></div> ··· 285 354 <span class="section-label">Corrected</span> 286 355 <span class="retry-icon" id="retry-corrected">&#8635;</span> 287 356 <span class="latency" id="latency-corrected"></span> 357 + <span class="tokens" id="tokens-corrected"></span> 288 358 </div> 289 359 <div class="section-content corrected" id="corrected-text"> 290 360 <div id="corrected-indicator" class="streaming-indicator hidden"> ··· 297 367 <span class="section-label">Suggested</span> 298 368 <span class="retry-icon" id="retry-suggested">&#8635;</span> 299 369 <span class="latency" id="latency-suggested"></span> 370 + <span class="tokens" id="tokens-suggested"></span> 300 371 </div> 301 372 <div class="section-content suggested" id="suggested-text"> 302 373 <div id="suggested-indicator" class="streaming-indicator hidden">
+22 -199
src/result.ts
··· 1 - import { API_BASE_URL, STORAGE_KEY_API_URL } from "./config"; 1 + import { 2 + initSections, 3 + showStart, 4 + showSectionStart, 5 + appendToken, 6 + showSectionDone, 7 + showSectionError, 8 + showError, 9 + } from "./sections"; 10 + import { initSettings } from "./settings"; 11 + import type { Section } from "./sections"; 2 12 3 - /* dom references */ 13 + /* top-level dom references */ 4 14 5 - const apiUrlInput = document.getElementById("api-url") as HTMLInputElement; 6 - const apiSavedEl = document.getElementById("api-saved")!; 7 15 const loadingEl = document.getElementById("loading")!; 8 16 const errorEl = document.getElementById("error")!; 9 17 const errorMessageEl = document.getElementById("error-message")!; 10 18 const resultEl = document.getElementById("result")!; 11 - const originalTextEl = document.getElementById("original-text")!; 12 - const correctedTextEl = document.getElementById("corrected-text")!; 13 - const suggestedTextEl = document.getElementById("suggested-text")!; 14 - const correctedIndicator = document.getElementById("corrected-indicator")!; 15 - const suggestedIndicator = document.getElementById("suggested-indicator")!; 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")!; 20 - 21 - /* section state */ 22 - 23 - type Section = "corrected" | "suggested"; 24 - 25 - interface SectionState { 26 - el: HTMLElement; 27 - indicator: HTMLElement; 28 - retryIcon: HTMLElement; 29 - latencyEl: HTMLElement; 30 - accumulated: string; 31 - firstToken: boolean; 32 - done: boolean; 33 - } 34 - 35 - const sections: Record<Section, SectionState> = { 36 - corrected: { 37 - el: correctedTextEl, 38 - indicator: correctedIndicator, 39 - retryIcon: retryCorrectedIcon, 40 - latencyEl: latencyCorrectedEl, 41 - accumulated: "", 42 - firstToken: true, 43 - done: false, 44 - }, 45 - suggested: { 46 - el: suggestedTextEl, 47 - indicator: suggestedIndicator, 48 - retryIcon: retrySuggestedIcon, 49 - latencyEl: latencySuggestedEl, 50 - accumulated: "", 51 - firstToken: true, 52 - done: false, 53 - }, 54 - }; 55 - 56 - /** stored original text for retry requests */ 57 - let originalText = ""; 58 19 59 20 /* message types from background script */ 60 21 ··· 62 23 | { type: "start"; original: string } 63 24 | { type: "section-start"; section: Section } 64 25 | { type: "stream"; section: Section; token: string; latencyMs?: number } 65 - | { type: "section-done"; section: Section } 26 + | { type: "section-done"; section: Section; completionTokens?: number } 66 27 | { type: "section-error"; section: Section; message: string } 67 28 | { type: "done" } 68 29 | { type: "error"; message: string }; ··· 81 42 appendToken(msg.section, msg.token, msg.latencyMs); 82 43 break; 83 44 case "section-done": 84 - showSectionDone(msg.section); 45 + showSectionDone(msg.section, msg.completionTokens); 85 46 break; 86 47 case "section-error": 87 48 showSectionError(msg.section, msg.message); ··· 94 55 } 95 56 }); 96 57 97 - /* ui helpers */ 58 + /* init */ 98 59 99 - function showStart(original: string): void { 100 - originalText = original; 101 - loadingEl.style.display = "none"; 102 - errorEl.style.display = "none"; 103 - resultEl.style.display = "block"; 60 + initSections({ loading: loadingEl, error: errorEl, result: resultEl, errorMessage: errorMessageEl }); 61 + initSettings(); 104 62 105 - originalTextEl.textContent = original; 63 + /* settings-only mode: hide loading, skip streaming handshake */ 106 64 107 - for (const section of Object.keys(sections) as Section[]) { 108 - resetSection(section); 109 - } 110 - } 65 + const settingsMode = new URLSearchParams(location.search).get("mode") === "settings"; 111 66 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 - 125 - function showSectionStart(section: Section): void { 126 - const state = sections[section]; 127 - state.el.textContent = ""; 128 - state.el.appendChild(state.indicator); 129 - state.indicator.classList.remove("hidden"); 130 - state.retryIcon.classList.remove("show"); 131 - } 132 - 133 - function appendToken(section: Section, token: string, latencyMs?: number): void { 134 - const state = sections[section]; 135 - 136 - if (state.firstToken) { 137 - state.indicator.classList.add("hidden"); 138 - state.el.textContent = ""; 139 - state.firstToken = false; 140 - } 141 - 142 - if (latencyMs !== undefined) { 143 - state.latencyEl.textContent = `${latencyMs}ms`; 144 - state.latencyEl.classList.add("show"); 145 - } 146 - 147 - state.accumulated += token; 148 - state.el.textContent = state.accumulated; 149 - 150 - window.scrollTo({ top: document.body.scrollHeight, behavior: "smooth" }); 151 - } 152 - 153 - function showSectionDone(section: Section): void { 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"); 171 - } 172 - 173 - function showError(message: string): void { 67 + if (settingsMode) { 174 68 loadingEl.style.display = "none"; 175 - resultEl.style.display = "none"; 176 - errorEl.style.display = "block"; 177 - 178 - errorMessageEl.textContent = message; 179 - } 180 - 181 - /* click-to-copy on content boxes */ 182 - 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) { 188 - return; 189 - } 190 - 191 - try { 192 - await navigator.clipboard.writeText(state.accumulated); 193 - state.el.classList.add("copied"); 194 - setTimeout(() => state.el.classList.remove("copied"), 2_000); 195 - } catch { 196 - /* silently ignore clipboard failures */ 197 - } 198 - }); 199 - } 200 - 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 - }); 69 + } else { 70 + browser.runtime.sendMessage({ type: "ready" }); 220 71 } 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 - }); 245 - 246 - /* signal readiness to background script */ 247 - 248 - browser.runtime.sendMessage({ type: "ready" });
+214
src/sections.ts
··· 1 + /** section identifiers for streaming responses */ 2 + export type Section = "corrected" | "suggested"; 3 + 4 + export interface SectionState { 5 + el: HTMLElement; 6 + indicator: HTMLElement; 7 + retryIcon: HTMLElement; 8 + latencyEl: HTMLElement; 9 + tokensEl: HTMLElement; 10 + accumulated: string; 11 + firstToken: boolean; 12 + done: boolean; 13 + } 14 + 15 + /* section dom references */ 16 + 17 + const correctedTextEl = document.getElementById("corrected-text")!; 18 + const suggestedTextEl = document.getElementById("suggested-text")!; 19 + const correctedIndicator = document.getElementById("corrected-indicator")!; 20 + const suggestedIndicator = document.getElementById("suggested-indicator")!; 21 + const retryCorrectedIcon = document.getElementById("retry-corrected")!; 22 + const retrySuggestedIcon = document.getElementById("retry-suggested")!; 23 + const latencyCorrectedEl = document.getElementById("latency-corrected")!; 24 + const latencySuggestedEl = document.getElementById("latency-suggested")!; 25 + const tokensCorrectedEl = document.getElementById("tokens-corrected")!; 26 + const tokensSuggestedEl = document.getElementById("tokens-suggested")!; 27 + 28 + export const sections: Record<Section, SectionState> = { 29 + corrected: { 30 + el: correctedTextEl, 31 + indicator: correctedIndicator, 32 + retryIcon: retryCorrectedIcon, 33 + latencyEl: latencyCorrectedEl, 34 + tokensEl: tokensCorrectedEl, 35 + accumulated: "", 36 + firstToken: true, 37 + done: false, 38 + }, 39 + suggested: { 40 + el: suggestedTextEl, 41 + indicator: suggestedIndicator, 42 + retryIcon: retrySuggestedIcon, 43 + latencyEl: latencySuggestedEl, 44 + tokensEl: tokensSuggestedEl, 45 + accumulated: "", 46 + firstToken: true, 47 + done: false, 48 + }, 49 + }; 50 + 51 + /* injected layout refs (set by initSections) */ 52 + 53 + let loadingEl!: HTMLElement; 54 + let errorEl!: HTMLElement; 55 + let resultEl!: HTMLElement; 56 + let errorMessageEl!: HTMLElement; 57 + 58 + /** stored original text for retry requests */ 59 + let originalText = ""; 60 + 61 + export function getOriginalText(): string { 62 + return originalText; 63 + } 64 + 65 + /* init */ 66 + 67 + export interface RootRefs { 68 + loading: HTMLElement; 69 + error: HTMLElement; 70 + result: HTMLElement; 71 + errorMessage: HTMLElement; 72 + } 73 + 74 + export function initSections(refs: RootRefs): void { 75 + loadingEl = refs.loading; 76 + errorEl = refs.error; 77 + resultEl = refs.result; 78 + errorMessageEl = refs.errorMessage; 79 + 80 + setupCopyOnDone("corrected"); 81 + setupCopyOnDone("suggested"); 82 + setupRetryIcon("corrected"); 83 + setupRetryIcon("suggested"); 84 + } 85 + 86 + /* section ui helpers */ 87 + 88 + export function showStart(original: string): void { 89 + originalText = original; 90 + loadingEl.style.display = "none"; 91 + errorEl.style.display = "none"; 92 + resultEl.style.display = "block"; 93 + 94 + const originalTextEl = document.getElementById("original-text")!; 95 + originalTextEl.textContent = original; 96 + 97 + for (const section of Object.keys(sections) as Section[]) { 98 + resetSection(section); 99 + } 100 + } 101 + 102 + function resetSection(section: Section): void { 103 + const state = sections[section]; 104 + state.el.textContent = ""; 105 + state.el.classList.remove("section-error"); 106 + state.accumulated = ""; 107 + state.firstToken = true; 108 + state.done = false; 109 + state.el.classList.remove("copied"); 110 + state.retryIcon.classList.remove("show"); 111 + state.latencyEl.classList.remove("show"); 112 + state.latencyEl.textContent = ""; 113 + state.tokensEl.classList.remove("show"); 114 + state.tokensEl.textContent = ""; 115 + } 116 + 117 + export function showSectionStart(section: Section): void { 118 + const state = sections[section]; 119 + state.el.textContent = ""; 120 + state.el.appendChild(state.indicator); 121 + state.indicator.classList.remove("hidden"); 122 + state.retryIcon.classList.remove("show"); 123 + } 124 + 125 + export function appendToken(section: Section, token: string, latencyMs?: number): void { 126 + const state = sections[section]; 127 + 128 + if (state.firstToken) { 129 + state.indicator.classList.add("hidden"); 130 + state.el.textContent = ""; 131 + state.firstToken = false; 132 + } 133 + 134 + if (latencyMs !== undefined) { 135 + state.latencyEl.textContent = `(TTFT: ${latencyMs}ms)`; 136 + state.latencyEl.classList.add("show"); 137 + } 138 + 139 + state.accumulated += token; 140 + state.el.textContent = state.accumulated; 141 + 142 + window.scrollTo({ top: document.body.scrollHeight, behavior: "smooth" }); 143 + } 144 + 145 + export function showSectionDone(section: Section, completionTokens?: number): void { 146 + const state = sections[section]; 147 + state.done = true; 148 + state.retryIcon.classList.remove("show"); 149 + 150 + if (completionTokens !== undefined) { 151 + state.tokensEl.textContent = `(Tokens: ${completionTokens})`; 152 + state.tokensEl.classList.add("show"); 153 + } 154 + } 155 + 156 + export function showSectionError(section: Section, message: string): void { 157 + const state = sections[section]; 158 + 159 + /* hide and detach the streaming indicator */ 160 + state.indicator.classList.add("hidden"); 161 + state.indicator.remove(); 162 + 163 + state.done = false; 164 + state.accumulated = ""; 165 + state.el.textContent = message; 166 + state.el.classList.add("section-error"); 167 + state.retryIcon.classList.add("show"); 168 + } 169 + 170 + export function showError(message: string): void { 171 + loadingEl.style.display = "none"; 172 + resultEl.style.display = "none"; 173 + errorEl.style.display = "block"; 174 + 175 + errorMessageEl.textContent = message; 176 + } 177 + 178 + /* click-to-copy on content boxes */ 179 + 180 + function setupCopyOnDone(section: Section): void { 181 + const state = sections[section]; 182 + 183 + state.el.addEventListener("click", async () => { 184 + if (!state.done || state.accumulated.length === 0) { 185 + return; 186 + } 187 + 188 + try { 189 + await navigator.clipboard.writeText(state.accumulated); 190 + state.el.classList.add("copied"); 191 + setTimeout(() => state.el.classList.remove("copied"), 2_000); 192 + } catch { 193 + /* silently ignore clipboard failures */ 194 + } 195 + }); 196 + } 197 + 198 + /* retry icons */ 199 + 200 + function setupRetryIcon(section: Section): void { 201 + const state = sections[section]; 202 + 203 + state.retryIcon.addEventListener("click", () => { 204 + state.el.classList.remove("section-error"); 205 + resetSection(section); 206 + showSectionStart(section); 207 + 208 + browser.runtime.sendMessage({ 209 + type: "retry", 210 + section, 211 + original: originalText, 212 + }); 213 + }); 214 + }
+71
src/settings.ts
··· 1 + import { 2 + API_BASE_URL, 3 + STORAGE_KEY_API_URL, 4 + STORAGE_KEY_LANGUAGE, 5 + DEFAULT_LANGUAGE, 6 + } from "./config"; 7 + import type { Language } from "./config"; 8 + 9 + /* dom references */ 10 + 11 + const apiUrlInput = document.getElementById("api-url") as HTMLInputElement; 12 + const apiSavedEl = document.getElementById("api-saved")!; 13 + const healthDotEl = document.getElementById("health-dot")!; 14 + const langEnBtn = document.getElementById("lang-en")!; 15 + const langFrBtn = document.getElementById("lang-fr")!; 16 + 17 + const LANG_BTNS: Record<Language, HTMLElement> = { en: langEnBtn, fr: langFrBtn }; 18 + 19 + /* health check */ 20 + 21 + /** check api health and update the health dot indicator */ 22 + async function checkApiHealth(url: string): Promise<void> { 23 + const ok = await browser.runtime.sendMessage({ type: "check-health", url }); 24 + healthDotEl.className = "health-dot"; 25 + healthDotEl.classList.add(ok ? "ok" : "fail"); 26 + } 27 + 28 + /* language toggle */ 29 + 30 + function setActiveLang(lang: Language): void { 31 + for (const [key, btn] of Object.entries(LANG_BTNS)) { 32 + btn.classList.toggle("active", key === lang); 33 + } 34 + } 35 + 36 + /* init */ 37 + 38 + export function initSettings(): void { 39 + /* load stored settings on popup open */ 40 + browser.storage.local.get([STORAGE_KEY_API_URL, STORAGE_KEY_LANGUAGE]).then((result) => { 41 + const stored = result[STORAGE_KEY_API_URL]; 42 + const url = typeof stored === "string" && stored.length > 0 43 + ? stored 44 + : API_BASE_URL; 45 + apiUrlInput.value = url; 46 + checkApiHealth(url); 47 + 48 + const lang = (result[STORAGE_KEY_LANGUAGE] as Language) ?? DEFAULT_LANGUAGE; 49 + setActiveLang(lang); 50 + }); 51 + 52 + /* save api url to storage on change */ 53 + apiUrlInput.addEventListener("change", () => { 54 + const value = apiUrlInput.value.trim(); 55 + if (value.startsWith("http://") || value.startsWith("https://")) { 56 + browser.storage.local.set({ [STORAGE_KEY_API_URL]: value }).then(() => { 57 + apiSavedEl.classList.add("show"); 58 + setTimeout(() => apiSavedEl.classList.remove("show"), 2_000); 59 + }); 60 + checkApiHealth(value); 61 + } 62 + }); 63 + 64 + /* language toggle */ 65 + for (const lang of Object.keys(LANG_BTNS) as Language[]) { 66 + LANG_BTNS[lang].addEventListener("click", () => { 67 + setActiveLang(lang); 68 + browser.storage.local.set({ [STORAGE_KEY_LANGUAGE]: lang }); 69 + }); 70 + } 71 + }
+10
src/types/api.d.ts
··· 49 49 tool_call_id?: string; 50 50 }>; 51 51 stream?: boolean; 52 + stream_options?: { include_usage: boolean }; 52 53 model?: string; 53 54 return_progress?: boolean; 54 55 tools?: ApiChatCompletionTool[]; ··· 114 115 }; 115 116 finish_reason?: string | null; 116 117 }>; 118 + usage?: { 119 + prompt_tokens: number; 120 + completion_tokens: number; 121 + total_tokens: number; 122 + }; 123 + } 124 + 125 + export interface ApiHealthResponse { 126 + status: "ok"; 117 127 } 118 128 119 129 export interface ApiErrorResponse {