summaryrefslogtreecommitdiff
path: root/src/background
diff options
context:
space:
mode:
Diffstat (limited to 'src/background')
-rw-r--r--src/background/index.js32
-rw-r--r--src/background/menu-translation.js28
-rw-r--r--src/background/message-router.js151
-rw-r--r--src/background/settings-service.js9
-rw-r--r--src/background/translation-service.js319
5 files changed, 434 insertions, 105 deletions
diff --git a/src/background/index.js b/src/background/index.js
index 9c97c9d..8e3c5f1 100644
--- a/src/background/index.js
+++ b/src/background/index.js
@@ -5,8 +5,9 @@
* event page can be woken by any of them (MDN: background scripts).
*/
-import { registerRuntimeMessageHandler, handleConnection } from './message-router.js';
+import { registerRuntimeMessageHandler, handleConnection, handlePopupStateConnection } from './message-router.js';
import { loadSettings, watchSettings } from './settings-service.js';
+import { startFromSelection } from './menu-translation.js';
// One-shot messages from popup / options.
registerRuntimeMessageHandler();
@@ -14,6 +15,35 @@ registerRuntimeMessageHandler();
// Streaming connections from the content script.
browser.runtime.onConnect.addListener(handleConnection);
+// Live translation state updates for the popup (translation result mode).
+browser.runtime.onConnect.addListener(handlePopupStateConnection);
+
+// Selection context menu (works on normal pages and the PDF Viewer).
+if (browser.menus && typeof browser.menus.create === 'function') {
+ browser.menus.create({
+ id: 'translate-selection',
+ title: '選択したテキストを翻訳',
+ contexts: ['selection']
+ });
+ browser.menus.onClicked.addListener((info) => {
+ if (!info || info.menuItemId !== 'translate-selection') return;
+ const result = startFromSelection(info.selectionText);
+ if (result.ok) {
+ // Open the toolbar popup as the result UI. openPopup() must be called
+ // synchronously within the click user gesture. It can reject when the
+ // popup is already open or on unsupported platforms - ignore safely.
+ try {
+ const p = browser.action.openPopup();
+ if (p && typeof p.catch === 'function') {
+ p.catch(() => {});
+ }
+ } catch {
+ // openPopup unavailable; the user can open the popup manually
+ }
+ }
+ });
+}
+
// 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/menu-translation.js b/src/background/menu-translation.js
new file mode 100644
index 0000000..1465c53
--- /dev/null
+++ b/src/background/menu-translation.js
@@ -0,0 +1,28 @@
+/**
+ * Context-menu translation entry (background).
+ *
+ * Receives the raw `menus.OnClickData.selectionText`, validates it through
+ * the shared pipeline and starts a translation without a sink. The popup
+ * observes results via the shared translation state (GET_POPUP_STATE).
+ */
+
+import { validateSelectionText, runTranslation, messageForCode } from './translation-service.js';
+import { createRequestId } from '../shared/messages.js';
+import { ERROR_CODES } from '../shared/constants.js';
+
+/**
+ * Start a translation from a context-menu selection.
+ * @param {*} selectionText raw value from menus.OnClickData
+ * @returns {{ok: boolean, requestId?: string, error?: string, message?: string}}
+ */
+export function startFromSelection(selectionText) {
+ const validated = validateSelectionText(selectionText);
+ if (!validated.ok) {
+ return { ok: false, error: validated.code, message: messageForCode(validated.code) };
+ }
+ const requestId = createRequestId();
+ // No sink: the popup polls GET_POPUP_STATE and receives streaming updates
+ // through the shared translation state.
+ void runTranslation({ text: validated.text, requestId, source: 'context-menu', sink: null });
+ return { ok: true, requestId };
+} \ No newline at end of file
diff --git a/src/background/message-router.js b/src/background/message-router.js
index 9428312..cb9b69c 100644
--- a/src/background/message-router.js
+++ b/src/background/message-router.js
@@ -3,43 +3,25 @@
*
* - 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.
+ * - runtime.onMessage: one-shot requests for popup/options:
+ * - listModels
+ * - getPopupState (popup translation result mode)
+ * - regenerateLast
*
- * All inbound messages are validated; invalid ones are discarded
- * (Specifications.md SEC-06, Design.md §8.4).
+ * The actual translation pipeline (validation, prompt, Ollama request,
+ * session/abort, shared state) lives in translation-service.js and is used
+ * by BOTH the content-script port flow and the context-menu flow, so there
+ * is a single request-generation implementation (Design.md §9.2).
*/
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 { isValidTranslateRequest, isValidCancelRequest, isTrustedSender, createRequestId } 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 = 'モデルが設定されていません。ツールバーアイコンまたは設定画面でモデルを選択してください。';
+import { listModels } from './ollama-client.js';
+import { runTranslation, getSession, getTranslationState, addPopupPort, removePopupPort } from './translation-service.js';
/**
* Handle one content-script port connection.
@@ -52,12 +34,12 @@ export function handleConnection(port) {
if (!isTrustedSender(port.sender)) return;
if (isValidTranslateRequest(message)) {
- void runTranslation(port, message.requestId, message.text);
+ void runTranslation({ text: message.text, requestId: message.requestId, source: 'content', sink: port });
return;
}
if (isValidCancelRequest(message)) {
- if (session.isActive(message.requestId)) {
- session.clear(message.requestId);
+ if (getSession().isActive(message.requestId)) {
+ getSession().clear(message.requestId);
}
return;
}
@@ -65,84 +47,27 @@ export function handleConnection(port) {
});
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);
+ // If the tab goes away, clear the session entry owned by this port.
+ // The popup-facing translation state keeps whatever was accumulated so
+ // reopening the popup still shows the latest text.
+ if (getSession().hasActive() && getSession().active.port === port) {
+ const requestId = getSession().active.requestId;
+ getSession().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
+ * Handle one popup-state port connection. The popup subscribes to live
+ * translation state updates (Design.md §7.4, §9.2).
+ * @param {object} port runtime.Port
*/
-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
- }
+export function handlePopupStateConnection(port) {
+ if (!port || port.name !== 'popup-state') return;
+ addPopupPort(port);
+ port.onDisconnect.addListener(() => {
+ removePopupPort(port);
+ });
}
/**
@@ -164,6 +89,24 @@ export function registerRuntimeMessageHandler(browserApi) {
}
})();
}
+ if (message && message.type === RUNTIME_MESSAGES.GET_POPUP_STATE) {
+ return Promise.resolve({ ok: true, state: getTranslationState() });
+ }
+ if (message && message.type === RUNTIME_MESSAGES.REGENERATE_LAST) {
+ return (async () => {
+ try {
+ const state = getTranslationState();
+ if (!state.sourceText) {
+ return { ok: false, error: ERROR_CODES.TEXT_EMPTY };
+ }
+ const requestId = createRequestId();
+ void runTranslation({ text: state.sourceText, requestId, source: state.source || 'context-menu', sink: null });
+ return { ok: true, requestId };
+ } catch (error) {
+ return { ok: false, error: (error && error.code) || ERROR_CODES.UNKNOWN_ERROR };
+ }
+ })();
+ }
return Promise.resolve({ ok: false, error: ERROR_CODES.INVALID_REQUEST });
});
}
diff --git a/src/background/settings-service.js b/src/background/settings-service.js
index 2687921..55d2a8f 100644
--- a/src/background/settings-service.js
+++ b/src/background/settings-service.js
@@ -79,4 +79,13 @@ export function watchSettings(browserApi) {
cache = sanitizeSettings(changes.settings.newValue);
}
});
+}
+
+/**
+ * Test-only: clear the module-level settings cache so each test starts from
+ * storage state instead of inheriting the previous test's cache.
+ * @visibleForTesting
+ */
+export function __resetCacheForTests() {
+ cache = null;
} \ No newline at end of file
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