summaryrefslogtreecommitdiff
path: root/src/background/message-router.js
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-09-02 03:14:05 +0900
committertanaka takashi <isazaka@gmail.com>2026-09-02 03:14:05 +0900
commit82bb55f66071b452e3bc7adeb09eafb5003de11f (patch)
tree01d48a68273594fc2ed480d837724562dd6fb05d /src/background/message-router.js
parentf5439335e1d18ee577e2b7c1e67f7844f9f235ac (diff)
feat: PDF Viewer translation via context menu and two-mode Action Popupv0.1.0
Add Firefox built-in PDF Viewer support using only public WebExtensions APIs: a selection context menu ("選択したテキストを翻訳") starts a translation and the toolbar Action Popup shows the streaming result. No content-script injection into the privileged PDF Viewer, no PDF.js internals, no separate windows, no custom viewer (Specifications.md §4.6). Specification alignment (Phase 1) - Specifications.md: new requirements PDF-01..04 (PDF selection translation), CTX-01..03 (context menu translation), POP-01..06 (popup two modes); SEL-06 updated to route PDF through the menus API; section 13.4 adds a 14-item manual PDF verification checklist; section 15 removes "Firefox内蔵PDF Viewerへの対応" from out-of-scope - Design.md: architecture diagram with the menus path, menu-translation component, menus permission, popup translation-result state machine (idle/starting/streaming/completed/error/cancelled), shared translation state (§9.2), context-menu integration phase, test plan and API audit checklist items (menus permission scope, no PDF Viewer internals) - Security.md: menus permission restricted to selection-context translation; PDF Viewer treated as privileged browser UI (no injection, no wrappedJSObject/cloneInto/exportFunction, no internal DOM access); menus.OnClickData.selectionText treated as untrusted input passing the same validation pipeline; popup result rendering stays textContent-only Background (Phase 2) - translation-service.js (new): the single translation pipeline shared by every entry point - selection validation (type/trim/empty/control chars/max length), prompt building, Ollama request, session/abort, and the shared translation state (requestId, sourceText, translatedText, status, model, error, startedAt, source). "source" is metadata only; a PDF-specific translation engine does not exist - menu-translation.js (new): validates menus.OnClickData.selectionText and starts a sink-less translation observed via GET_POPUP_STATE - message-router.js: routes port translations through the shared service; new runtime messages GET_POPUP_STATE and REGENERATE_LAST; popup-state port handler broadcasts live state (initial push + every chunk/status change) so an open popup shows streaming progress in real time - index.js: registers the selection context menu (contexts: ["selection"] only, no menus.onShown) and calls browser.action.openPopup() synchronously inside the click user gesture, catching failures so a popup-open problem never breaks the translation itself - manifest.json: permissions now ["storage", "menus"] - settings-service.js: __resetCacheForTests hook for test isolation Popup two-mode UI (Phase 3) - popup.html/js/css: mode nav (設定 / 翻訳). Translation mode shows status, model, streaming text (textContent only), user-safe error, and copy (Clipboard API only, no execCommand fallback), regenerate (new requestId reusing the stored source text) and a settings shortcut. Closing the popup never cancels the request; reopening shows the latest text and continues receiving chunks live. Initial mode: active (starting/streaming) translation wins, otherwise the last tab the user picked is restored (storage.local.popupMode, default settings) Fresh-install model auto-selection - translation-service.js ensureModelConfigured(): when storage.model is empty, pick the first model from the running Ollama and persist it before translating, mirroring the popup/options auto-select. A user who never opened any settings UI can translate immediately; when Ollama is unreachable a clear OLLAMA_UNAVAILABLE message is shown User-safe error details - detailForUser(): strips URLs (no internal host names echoed to the UI), caps length at 120 chars, drops full JSON response bodies, and hides meaningless low-level network errors so the popover/popup shows the base message plus at most a short, safe detail Tests: 85 passed, 0 failed - selectionText validation (untrusted input pipeline) - menu selection -> translation -> popup state streaming/completed - empty selection rejected without starting a request - streaming status transitions observed through GET_POPUP_STATE - Ollama unreachable surfaces a user-safe error into popup state - newer request replaces older state (abort semantics) - regenerateLast reuses source text with a fresh requestId - popup-state port receives live broadcasts; wrong port names ignored - fresh-install auto-select persists the first Ollama model - error detail sanitization (URL stripping, length cap, JSON drop) - popup/options smoke tests updated for the two-mode markup - setupRouter resets the shared settings cache for test isolation Docs: README documents the two-mode popup and PDF flow; Testing-Results records automated results and the manual PDF checklist (to be executed). No git-side surprises: this commit was created explicitly on request (requirement 18 of the PDF task had deferred commits until now).
Diffstat (limited to 'src/background/message-router.js')
-rw-r--r--src/background/message-router.js151
1 files changed, 47 insertions, 104 deletions
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 });
});
}