/** * 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]: '不明なエラーが発生しました。' }; /** Message shown when no model is configured at all. */ const NO_MODEL_MESSAGE = 'モデルが設定されていません。ツールバーアイコンまたは設定画面でモデルを選択してください。'; /** * 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_MESSAGE); 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} detail optional concrete reason shown to the user */ function postError(port, requestId, code, detail) { const base = ERROR_MESSAGES[code] || ERROR_MESSAGES[ERROR_CODES.UNKNOWN_ERROR]; // Append the concrete server/detail reason when provided so the popover // shows WHY it failed (e.g. the Ollama error body for HTTP 400). const message = detail && detail !== base ? `${base}\n(${detail})` : base; try { port.postMessage({ type: BG_TO_CONTENT.ERROR, requestId, code, message }); } 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;