diff options
Diffstat (limited to 'src/background')
| -rw-r--r-- | src/background/index.js | 19 | ||||
| -rw-r--r-- | src/background/message-router.js | 177 | ||||
| -rw-r--r-- | src/background/ollama-client.js | 194 | ||||
| -rw-r--r-- | src/background/settings-service.js | 82 | ||||
| -rw-r--r-- | src/background/translation-session.js | 76 |
5 files changed, 548 insertions, 0 deletions
diff --git a/src/background/index.js b/src/background/index.js new file mode 100644 index 0000000..9c97c9d --- /dev/null +++ b/src/background/index.js @@ -0,0 +1,19 @@ +/** + * Background entry point (Firefox MV3 event page, ES module). + * + * All event listeners are registered synchronously at the top level so the + * event page can be woken by any of them (MDN: background scripts). + */ + +import { registerRuntimeMessageHandler, handleConnection } from './message-router.js'; +import { loadSettings, watchSettings } from './settings-service.js'; + +// One-shot messages from popup / options. +registerRuntimeMessageHandler(); + +// Streaming connections from the content script. +browser.runtime.onConnect.addListener(handleConnection); + +// Keep the validated settings cache in sync and load initial settings. +watchSettings(); +void loadSettings();
\ No newline at end of file diff --git a/src/background/message-router.js b/src/background/message-router.js new file mode 100644 index 0000000..c93fc9d --- /dev/null +++ b/src/background/message-router.js @@ -0,0 +1,177 @@ +/** + * Message router (background). + * + * - runtime.Port ('translation-stream'): translate / cancel requests from + * the content script, streaming started/chunk/completed/error back. + * - runtime.onMessage: one-shot listModels request for popup/options. + * + * All inbound messages are validated; invalid ones are discarded + * (Specifications.md SEC-06, Design.md §8.4). + */ + +import { + CONTENT_TO_BG, + BG_TO_CONTENT, + RUNTIME_MESSAGES, + MAX_SELECTION_LENGTH, + ERROR_CODES +} from '../shared/constants.js'; +import { isValidTranslateRequest, isValidCancelRequest, isTrustedSender } from '../shared/messages.js'; +import { getSettings } from './settings-service.js'; +import { TranslationSession } from './translation-session.js'; +import { chatStream, listModels } from './ollama-client.js'; +import { buildSystemPrompt } from '../shared/prompt.js'; +import { buildOptions, toApiThinking } from '../shared/settings-schema.js'; + +const session = new TranslationSession(); + +/** User-facing messages per error code (kept minimal; no stack traces). */ +const ERROR_MESSAGES = { + [ERROR_CODES.INVALID_REQUEST]: '要求が不正です。', + [ERROR_CODES.TEXT_EMPTY]: '選択文字列が空です。', + [ERROR_CODES.TEXT_TOO_LONG]: `選択文字列が長すぎます(上限 ${MAX_SELECTION_LENGTH} 文字)。`, + [ERROR_CODES.OLLAMA_UNAVAILABLE]: 'Ollamaに接続できません。Ollamaが起動しているか確認してください。', + [ERROR_CODES.MODEL_NOT_FOUND]: '指定されたモデルが見つかりません。', + [ERROR_CODES.INVALID_SETTINGS]: '設定値が不正です。設定画面を確認してください。', + [ERROR_CODES.REQUEST_ABORTED]: '翻訳を中断しました。', + [ERROR_CODES.STREAM_ERROR]: '翻訳中にエラーが発生しました。', + [ERROR_CODES.UNKNOWN_ERROR]: '不明なエラーが発生しました。' +}; + +/** + * Handle one content-script port connection. + * @param {object} port runtime.Port + */ +export function handleConnection(port) { + if (!port || port.name !== 'translation-stream') return; + + port.onMessage.addListener((message) => { + if (!isTrustedSender(port.sender)) return; + + if (isValidTranslateRequest(message)) { + void runTranslation(port, message.requestId, message.text); + return; + } + if (isValidCancelRequest(message)) { + if (session.isActive(message.requestId)) { + session.clear(message.requestId); + } + return; + } + // Invalid message: discard silently (do not echo details to the page). + }); + + port.onDisconnect.addListener(() => { + // If the tab goes away, abort the translation owned by this port. + if (session.hasActive() && session.active.port === port) { + const requestId = session.active.requestId; + session.clear(requestId); + } + }); +} + +/** + * Run one translation: settings -> prompt -> Ollama stream -> port messages. + * @param {object} port + * @param {string} requestId + * @param {string} text + */ +async function runTranslation(port, requestId, text) { + const settings = await getSettings(); + + if (!settings.enabled) { + postError(port, requestId, ERROR_CODES.INVALID_REQUEST, 'Translation is disabled'); + return; + } + if (!settings.model) { + postError(port, requestId, ERROR_CODES.INVALID_SETTINGS, 'No model configured'); + return; + } + + const controller = session.start(requestId, port); + + port.postMessage({ type: BG_TO_CONTENT.STARTED, requestId, model: settings.model }); + + try { + await chatStream({ + host: settings.ollamaHost, + model: settings.model, + messages: [ + { role: 'system', content: buildSystemPrompt(settings) }, + { role: 'user', content: text } + ], + think: toApiThinking(settings), + options: buildOptions(settings), + signal: controller.signal, + onChunk: (content) => { + // Drop chunks if a newer request took over. + if (!session.isActive(requestId)) return; + port.postMessage({ type: BG_TO_CONTENT.CHUNK, requestId, text: content }); + } + }); + if (session.isActive(requestId)) { + port.postMessage({ type: BG_TO_CONTENT.COMPLETED, requestId }); + } + } catch (error) { + const code = (error && error.code) || ERROR_CODES.UNKNOWN_ERROR; + // Aborted requests are reported by TranslationSession.abortActive(); + // avoid a duplicate error for the same requestId. + if (code !== ERROR_CODES.REQUEST_ABORTED && session.isActive(requestId)) { + postError(port, requestId, code, error && error.message ? error.message : ''); + } + } finally { + session.clear(requestId); + } +} + +/** + * @param {object} port + * @param {string} requestId + * @param {string} code + * @param {string} message + */ +function postError(port, requestId, code, message) { + try { + port.postMessage({ type: BG_TO_CONTENT.ERROR, requestId, code, message: ERROR_MESSAGES[code] || ERROR_MESSAGES[ERROR_CODES.UNKNOWN_ERROR] }); + } catch { + // port may be disconnected + } +} + +/** + * Register one-shot message handling (popup / options). + * @param {object} browserApi injectable for tests + */ +export function registerRuntimeMessageHandler(browserApi) { + const b = browserApi || globalThis.browser; + b.runtime.onMessage.addListener((message, sender) => { + if (!isTrustedSender(sender)) return Promise.resolve({ ok: false, error: ERROR_CODES.INVALID_REQUEST }); + if (message && message.type === RUNTIME_MESSAGES.LIST_MODELS) { + return (async () => { + try { + const settings = await getSettings(browserApi); + const models = await listModels(settings.ollamaHost); + return { ok: true, models }; + } catch (error) { + return { ok: false, error: (error && error.code) || ERROR_CODES.OLLAMA_UNAVAILABLE, message: (error && error.message) || '' }; + } + })(); + } + return Promise.resolve({ ok: false, error: ERROR_CODES.INVALID_REQUEST }); + }); +} + +/** + * Test-only hook: inject a browser API so the router can be exercised + * outside a real extension context. Not used at runtime. + * @param {object} browserApi + * @visibleForTesting + */ +export function __setBrowserApiForTests(browserApi) { + testBrowserApi = browserApi; + browserApi.runtime.onConnect.addListener(handleConnection); + registerRuntimeMessageHandler(browserApi); +} + +/** @type {object|null} test-injected browser api */ +let testBrowserApi = null;
\ No newline at end of file diff --git a/src/background/ollama-client.js b/src/background/ollama-client.js new file mode 100644 index 0000000..0286230 --- /dev/null +++ b/src/background/ollama-client.js @@ -0,0 +1,194 @@ +/** + * Ollama REST API client (background only). + * + * Uses the browser Fetch API directly against the Ollama REST API. + * - GET {host}/api/tags : list installed models + * - POST {host}/api/chat : streaming chat completion (NDJSON) + * + * Security rules implemented here (Security.md §4): + * - the host is re-validated immediately before each request; + * - NDJSON is parsed with a buffering line reader that tolerates chunk + * boundaries falling inside a JSON object; + * - requests can be aborted via AbortController. + */ + +import { isValidOllamaHost } from '../shared/settings-schema.js'; +import { ERROR_CODES } from '../shared/constants.js'; + +/** + * Translate a fetch/HTTP failure into a stable error code. + * @param {Response|undefined} response + * @returns {string} error code + */ +function statusCodeToError(status) { + if (status === 404) return ERROR_CODES.MODEL_NOT_FOUND; + if (status === 400 || status === 403) return ERROR_CODES.INVALID_SETTINGS; + if (status >= 500) return ERROR_CODES.STREAM_ERROR; + return ERROR_CODES.STREAM_ERROR; +} + +/** + * Assert that the host is allowed. Throws Error with code attached. + * @param {string} host + */ +function assertAllowedHost(host) { + if (!isValidOllamaHost(host)) { + const error = new Error('Invalid Ollama host'); + error.code = ERROR_CODES.INVALID_SETTINGS; + throw error; + } +} + +/** + * Normalize an error thrown by fetch into an extension error code. + * @param {*} error + * @param {{aborted?: boolean, code?: string}} [options] + * @returns {Error} + */ +export function toExtensionError(error, { aborted = false, code = ERROR_CODES.OLLAMA_UNAVAILABLE } = {}) { + if (error && error.name === 'AbortError') { + const e = new Error('Request aborted'); + e.code = ERROR_CODES.REQUEST_ABORTED; + return e; + } + if (error && error.code) return error; + // fetch() rejects on network failures (Ollama not running etc.) + const e = new Error(error instanceof Error ? error.message : 'Unknown Ollama error'); + e.code = aborted ? ERROR_CODES.REQUEST_ABORTED : code; + return e; +} + +/** + * List models installed in Ollama. + * @param {string} host validated host + * @param {AbortSignal} [signal] + * @returns {Promise<string[]>} model names + */ +export async function listModels(host, signal) { + assertAllowedHost(host); + let response; + try { + response = await fetch(`${host}/api/tags`, { method: 'GET', signal }); + } catch (error) { + throw toExtensionError(error); + } + if (!response.ok) { + const e = new Error(`Ollama /api/tags failed: ${response.status}`); + e.code = response.status === 404 ? ERROR_CODES.MODEL_NOT_FOUND : ERROR_CODES.STREAM_ERROR; + throw e; + } + const data = await response.json(); + if (!data || !Array.isArray(data.models)) return []; + return data.models.map((m) => (m && typeof m.name === 'string' ? m.name : '')).filter((name) => name.length > 0); +} + +/** + * Stream a chat completion. Calls handlers for each incremental text chunk. + * + * @param {object} params + * @param {string} params.host validated Ollama host + * @param {string} params.model + * @param {Array<{role: string, content: string}>} params.messages + * @param {boolean|string} [params.think] true | 'low' | 'medium' | 'high'; omit when off + * @param {object} params.options Ollama options object + * @param {(text: string, thinking: string) => void} params.onChunk + * @param {AbortSignal} [params.signal] + * @returns {Promise<void>} resolves when the stream completes + */ +export async function chatStream({ host, model, messages, think, options, onChunk, signal }) { + assertAllowedHost(host); + + const body = { model, messages, stream: true }; + if (think !== undefined) { + body.think = think; + } + if (options && Object.keys(options).length > 0) { + body.options = options; + } + + let response; + try { + response = await fetch(`${host}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal + }); + } catch (error) { + throw toExtensionError(error, { aborted: isAbortError(error) }); + } + + if (!response.ok) { + const e = new Error(`Ollama /api/chat failed: ${response.status}`); + e.code = + response.status === 404 + ? ERROR_CODES.MODEL_NOT_FOUND + : response.status === 400 || response.status === 403 + ? ERROR_CODES.INVALID_SETTINGS + : ERROR_CODES.STREAM_ERROR; + throw e; + } + if (!response.body) { + const e = new Error('Ollama response has no body'); + e.code = ERROR_CODES.STREAM_ERROR; + throw e; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + const processLine = (line) => { + if (!line) return; + let parsed; + try { + parsed = JSON.parse(line); + } catch { + // Malformed line: skip it rather than crashing the stream. + return; + } + if (parsed === null || typeof parsed !== 'object') return; + if (parsed.error) { + const e = new Error(String(parsed.error)); + e.code = typeof parsed.error === 'string' && /not found/i.test(parsed.error) ? ERROR_CODES.MODEL_NOT_FOUND : ERROR_CODES.STREAM_ERROR; + throw e; + } + const content = parsed.message && typeof parsed.message.content === 'string' ? parsed.message.content : ''; + const thinking = parsed.message && typeof parsed.message.thinking === 'string' ? parsed.message.thinking : ''; + if (content || thinking) { + onChunk(content, thinking); + } + }; + + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let newlineIndex; + while ((newlineIndex = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, newlineIndex).replace(/\r$/, ''); + buffer = buffer.slice(newlineIndex + 1); + processLine(line); + } + } + // Flush any remaining buffered line. + const tail = buffer.replace(/\r$/, ''); + if (tail) processLine(tail); + } catch (error) { + if (isAbortError(error)) { + const e = new Error('Request aborted'); + e.code = ERROR_CODES.REQUEST_ABORTED; + throw e; + } + throw error && error.code ? error : toExtensionError(error, { code: ERROR_CODES.STREAM_ERROR }); + } +} + +/** + * @param {*} error + * @returns {boolean} + */ +function isAbortError(error) { + return Boolean(error && (error.name === 'AbortError' || error.code === ERROR_CODES.REQUEST_ABORTED)); +}
\ No newline at end of file diff --git a/src/background/settings-service.js b/src/background/settings-service.js new file mode 100644 index 0000000..2687921 --- /dev/null +++ b/src/background/settings-service.js @@ -0,0 +1,82 @@ +/** + * Settings service (background): storage access with validation. + * + * Load flow (Design.md §17.2): + * 1. generate default settings + * 2. read storage + * 3. merge + * 4. validate + * 5. invalid values fall back to defaults + * + * The validated cache is refreshed on storage.onChanged. + */ + +import { sanitizeSettings, defaultSettings } from '../shared/settings-schema.js'; + +const STORAGE_KEY = 'settings'; + +/** @type {object|null} cached validated settings */ +let cache = null; + +/** + * @param {object} [browserApi] injectable browser API (defaults to globalThis.browser) + * @returns {object} + */ +function api(browserApi) { + return browserApi || globalThis.browser; +} + +/** + * Load settings from storage.local, sanitize and cache them. + * @param {object} [browserApi] + * @returns {Promise<object>} validated settings + */ +export async function loadSettings(browserApi) { + const b = api(browserApi); + let stored = null; + try { + const result = await b.storage.local.get({ settings: null }); + stored = result.settings; + } catch { + stored = null; + } + cache = sanitizeSettings(stored); + return cache; +} + +/** + * Save settings. Values are sanitized before writing. + * @param {object} settings raw settings object from the UI + * @param {object} [browserApi] + * @returns {Promise<object>} the validated settings actually stored + */ +export async function saveSettings(settings, browserApi) { + const b = api(browserApi); + const validated = sanitizeSettings(settings); + await b.storage.local.set({ settings: validated }); + cache = validated; + return validated; +} + +/** + * @param {object} [browserApi] + * @returns {Promise<object>} current validated settings (cached when possible) + */ +export async function getSettings(browserApi) { + if (cache) return cache; + return loadSettings(browserApi); +} + +/** + * Register the storage.onChanged listener that keeps the cache in sync. + * Must be called once from the background entry point. + * @param {object} [browserApi] + */ +export function watchSettings(browserApi) { + const b = api(browserApi); + b.storage.onChanged.addListener((changes, areaName) => { + if (areaName === 'local' && changes.settings) { + cache = sanitizeSettings(changes.settings.newValue); + } + }); +}
\ No newline at end of file diff --git a/src/background/translation-session.js b/src/background/translation-session.js new file mode 100644 index 0000000..1d02974 --- /dev/null +++ b/src/background/translation-session.js @@ -0,0 +1,76 @@ +/** + * Translation session management (background). + * + * Only one translation may be active at a time (SEC-14 / Design.md §9). + * Starting a new request aborts the previous one. Chunks belonging to a + * stale requestId are discarded. + */ + +import { ERROR_CODES } from '../shared/constants.js'; + +export class TranslationSession { + constructor() { + /** @type {{requestId: string, controller: AbortController, port: object}|null} */ + this.active = null; + } + + /** + * @returns {boolean} + */ + hasActive() { + return this.active !== null; + } + + /** + * Abort the active request, if any. Notifies its port so the UI can reset. + * @returns {void} + */ + abortActive() { + if (!this.active) return; + const { controller, port, requestId } = this.active; + this.active = null; + try { + controller.abort(); + } catch { + // ignore abort errors + } + if (port) { + try { + port.postMessage({ type: 'error', requestId, code: ERROR_CODES.REQUEST_ABORTED, message: 'Superseded by a newer request' }); + } catch { + // port may be gone + } + } + } + + /** + * Register a new active request, aborting any previous one. + * @param {string} requestId + * @param {object} port runtime.Port owning the request + * @returns {AbortController} the controller for the new request + */ + start(requestId, port) { + this.abortActive(); + const controller = new AbortController(); + this.active = { requestId, controller, port }; + return controller; + } + + /** + * @param {string} requestId + * @returns {boolean} true when the given id is the active request. + */ + isActive(requestId) { + return this.active !== null && this.active.requestId === requestId; + } + + /** + * Clear the active session when it matches the id. + * @param {string} requestId + */ + clear(requestId) { + if (this.isActive(requestId)) { + this.active = null; + } + } +}
\ No newline at end of file |
