/** * Translation service (background): shared translation pipeline. * * Both entry points use this module so there is exactly one request * generation / streaming / state implementation: * - content script (runtime.Port streaming, Design.md §8) * - context menu / popup (translation state, Design.md §9.2) * * No PDF-specific translation engine exists; `source` is metadata only. */ import { getSettings, saveSettings } 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, sanitizeSettings, defaultSettings } from '../shared/settings-schema.js'; import { BG_TO_CONTENT, MAX_SELECTION_LENGTH, ERROR_CODES } from '../shared/constants.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]: '不明なエラーが発生しました。' }; const NO_MODEL_MESSAGE = 'モデルが設定されていません。ツールバーアイコンまたは設定画面でモデルを選択してください。'; /** * Background translation state (Design.md §9.2). Shared by popup and * menu-controller; the popup reads it via GET_POPUP_STATE. * @type {{requestId: string|null, sourceText: string, translatedText: string, * status: string, model: string, error: string|null, startedAt: number|null, * source: string}} */ const state = { requestId: null, sourceText: '', translatedText: '', status: 'idle', model: '', error: null, startedAt: null, source: '' }; /** * @returns {object} a copy of the current translation state. */ export function getTranslationState() { return { ...state, translatedText: state.translatedText }; } /** * Reset the shared state for a new request. * @param {string} requestId * @param {string} text validated source text * @param {string} source 'content' | 'context-menu' */ function beginState(requestId, text, source) { state.requestId = requestId; state.sourceText = text; state.translatedText = ''; state.status = 'starting'; state.model = ''; state.error = null; state.startedAt = Date.now(); state.source = source; } /** * Validate an untrusted selection string (content script or * menus.selectionText - both are untrusted, Security.md §7.4). * @param {*} text * @returns {{ok: true, text: string}|{ok: false, code: string}} */ export function validateSelectionText(text) { if (typeof text !== 'string') { return { ok: false, code: ERROR_CODES.INVALID_REQUEST }; } const trimmed = text.trim(); if (trimmed.length === 0) { return { ok: false, code: ERROR_CODES.TEXT_EMPTY }; } // Strip control characters except tab/newline/CR. const cleaned = trimmed.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, ''); if (cleaned.length === 0) { return { ok: false, code: ERROR_CODES.TEXT_EMPTY }; } if (cleaned.length > MAX_SELECTION_LENGTH) { return { ok: false, code: ERROR_CODES.TEXT_TOO_LONG }; } return { ok: true, text: cleaned }; } /** * @param {string} code * @returns {string} user-facing message for the code */ export function messageForCode(code) { return ERROR_MESSAGES[code] || ERROR_MESSAGES[ERROR_CODES.UNKNOWN_ERROR]; } /** * Ensure a model is configured before translating. On a fresh install the * user may translate straight from a context menu without ever opening the * settings UI, so storage.model would still be ''. In that case pick the * first model reported by the running Ollama (best-effort, one attempt per * runTranslation call) and persist it so the choice is visible in the UI. * This mirrors the popup/options auto-select behavior so every path agrees. * @param {object} browserApi injectable for tests * @returns {Promise<{settings: object, error: string|null}>} */ async function ensureModelConfigured(browserApi) { let settings = await getSettings(browserApi); if (settings.model) { return { settings, error: null }; } try { const models = await listModels(settings.ollamaHost); if (models.length > 0) { const merged = sanitizeSettings({ ...settings, model: models[0] }); await saveSettings(merged, browserApi); settings = merged; return { settings, error: null }; } return { settings, error: NO_MODEL_MESSAGE }; } catch (error) { // Ollama unreachable while listing models: report a clear error. const code = (error && error.code) || ERROR_CODES.OLLAMA_UNAVAILABLE; return { settings, error: messageForCode(code) }; } } /** * Popup state subscribers. The popup connects a 'popup-state' port and * receives the shared state after every change, so an open popup shows * live streaming progress even for sink-less (context-menu) requests. * @type {Set} */ const popupPorts = new Set(); /** * Register a popup port for live state broadcasts. * @param {object} port runtime.Port (name 'popup-state') */ export function addPopupPort(port) { popupPorts.add(port); try { port.postMessage({ type: 'popup-state', state: getTranslationState() }); } catch { // ignore } } /** * Unregister a popup port. * @param {object} port */ export function removePopupPort(port) { popupPorts.delete(port); } /** * Broadcast the current state to every connected popup port. */ function broadcastState() { for (const port of popupPorts) { try { port.postMessage({ type: 'popup-state', state: getTranslationState() }); } catch { popupPorts.delete(port); } } } /** * Run one translation and notify a sink with started/chunk/completed/error. * * The sink is an object with an optional postMessage(message) method. The * content-script port is one sink; the popup state is updated internally and * broadcast to connected popups so they can render live progress. * * @param {object} params * @param {string} params.text validated source text * @param {string} params.requestId * @param {string} params.source 'content' | 'context-menu' * @param {object|null} [params.sink] optional message sink (port-like) * @returns {Promise} */ export async function runTranslation({ text, requestId, source, sink = null, browserApi = null }) { let settings = await getSettings(browserApi); if (!settings.enabled) { state.status = 'error'; state.error = messageForCode(ERROR_CODES.INVALID_REQUEST); broadcastState(); postToSink(sink, { type: BG_TO_CONTENT.ERROR, requestId, code: ERROR_CODES.INVALID_REQUEST, message: state.error }); return; } // Fresh-install path: pick the first model from the running Ollama so a // user who never opened the settings UI can still translate. const ensured = await ensureModelConfigured(browserApi); if (ensured.error) { state.status = 'error'; state.error = ensured.error; broadcastState(); postToSink(sink, { type: BG_TO_CONTENT.ERROR, requestId, code: ERROR_CODES.INVALID_SETTINGS, message: ensured.error }); return; } settings = ensured.settings; // Registering the new request aborts any previous one. const controller = session.start(requestId, sink); beginState(requestId, text, source); state.model = settings.model; broadcastState(); postToSink(sink, { 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; if (content) { state.translatedText += content; state.status = 'streaming'; broadcastState(); } postToSink(sink, { type: BG_TO_CONTENT.CHUNK, requestId, text: content }); } }); if (session.isActive(requestId)) { state.status = 'completed'; broadcastState(); postToSink(sink, { type: BG_TO_CONTENT.COMPLETED, requestId }); } } catch (error) { const code = (error && error.code) || ERROR_CODES.UNKNOWN_ERROR; if (code === ERROR_CODES.REQUEST_ABORTED) { // Reported by TranslationSession.abortActive() to the old sink; the // shared state marks it cancelled so the popup can show it. state.status = 'cancelled'; state.error = messageForCode(code); broadcastState(); } else if (session.isActive(requestId)) { state.status = 'error'; // Fall back to the code's base message when the detail is empty or // judged non-user-facing by detailForUser(). const detail = detailForUser(error); state.error = detail ? `${messageForCode(code)}\n(${detail})` : messageForCode(code); broadcastState(); postToSink(sink, { type: BG_TO_CONTENT.ERROR, requestId, code, detail: state.error }); } } finally { session.clear(requestId); } } /** * Build a short, user-safe detail string from an error. Low-level exception * text and full server responses are not shown unbounded: keep the message * short and drop anything that looks like an internal URL or a huge blob. * @param {*} error * @returns {string} '' when there is nothing worth showing */ function detailForUser(error) { const base = messageForCode(ERROR_CODES.UNKNOWN_ERROR); const raw = error && error.message ? String(error.message) : ''; if (!raw) return ''; // Skip raw network errors that leak no useful info beyond the code. if (/failed to fetch|networkerror|load failed/i.test(raw)) return ''; // Strip URLs (internal host names should not be echoed to the UI). const cleaned = raw.replace(/\bhttps?:\/\/\S+/gi, '').trim(); if (!cleaned) return ''; // Drop obviously non-user-facing payloads (full JSON responses etc.). if (cleaned.startsWith('{') || cleaned.startsWith('[')) return ''; const short = cleaned.length > 120 ? `${cleaned.slice(0, 120)}…` : cleaned; return `${short}`; } /** * @param {object|null} sink * @param {object} message */ function postToSink(sink, message) { if (!sink) return; try { sink.postMessage(message); } catch { // sink may be disconnected (e.g. popup closed) } } /** @returns {TranslationSession} the shared session (for tests / cancel). */ export function getSession() { return session; } export { ERROR_MESSAGES, NO_MODEL_MESSAGE };