summaryrefslogtreecommitdiff
path: root/src/background/translation-service.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/background/translation-service.js')
-rw-r--r--src/background/translation-service.js319
1 files changed, 319 insertions, 0 deletions
diff --git a/src/background/translation-service.js b/src/background/translation-service.js
new file mode 100644
index 0000000..f443237
--- /dev/null
+++ b/src/background/translation-service.js
@@ -0,0 +1,319 @@
+/**
+ * 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<object>}
+ */
+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<void>}
+ */
+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 }; \ No newline at end of file