diff options
| author | tanaka takashi <isazaka@gmail.com> | 2026-09-02 03:14:05 +0900 |
|---|---|---|
| committer | tanaka takashi <isazaka@gmail.com> | 2026-09-02 03:14:05 +0900 |
| commit | 82bb55f66071b452e3bc7adeb09eafb5003de11f (patch) | |
| tree | 01d48a68273594fc2ed480d837724562dd6fb05d /src | |
| parent | f5439335e1d18ee577e2b7c1e67f7844f9f235ac (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')
| -rw-r--r-- | src/background/index.js | 32 | ||||
| -rw-r--r-- | src/background/menu-translation.js | 28 | ||||
| -rw-r--r-- | src/background/message-router.js | 151 | ||||
| -rw-r--r-- | src/background/settings-service.js | 9 | ||||
| -rw-r--r-- | src/background/translation-service.js | 319 | ||||
| -rw-r--r-- | src/popup/popup.css | 73 | ||||
| -rw-r--r-- | src/popup/popup.html | 56 | ||||
| -rw-r--r-- | src/popup/popup.js | 184 | ||||
| -rw-r--r-- | src/shared/constants.js | 18 |
9 files changed, 744 insertions, 126 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 diff --git a/src/popup/popup.css b/src/popup/popup.css index 964454d..de287a9 100644 --- a/src/popup/popup.css +++ b/src/popup/popup.css @@ -8,9 +8,80 @@ body { background: #ffffff; } +.popup-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 10px; +} + h1 { font-size: 14px; - margin: 0 0 10px; + margin: 0; +} + +.mode-nav { + display: flex; + gap: 4px; +} + +.nav-btn { + border: 1px solid rgba(0, 0, 0, 0.25); + background: #fff; + border-radius: 4px; + padding: 2px 10px; + cursor: pointer; + font-size: 12px; +} + +.nav-btn.active { + background: #1a73e8; + border-color: #1a73e8; + color: #fff; +} + +.translation-text { + min-height: 60px; + max-height: 220px; + overflow-y: auto; + white-space: pre-wrap; + word-break: break-word; + border: 1px solid rgba(0, 0, 0, 0.15); + border-radius: 4px; + padding: 6px 8px; + margin: 6px 0; + user-select: text; +} + +.meta { + color: #5f6368; + font-size: 11px; + margin: 2px 0 0; +} + +.actions { + display: flex; + gap: 6px; + margin-top: 8px; +} + +.actions button { + flex: 1; + padding: 5px 8px; + border: 1px solid rgba(0, 0, 0, 0.25); + border-radius: 4px; + background: #fff; + cursor: pointer; +} + +.actions button:hover:not([disabled]) { + background: #f0f4ff; +} + +.actions button[disabled] { + opacity: 0.5; + cursor: default; } .row { diff --git a/src/popup/popup.html b/src/popup/popup.html index e51475f..06a4cf4 100644 --- a/src/popup/popup.html +++ b/src/popup/popup.html @@ -6,28 +6,50 @@ </head> <body> <main> - <h1>Ollama Translations</h1> + <header class="popup-header"> + <h1>Ollama Translations</h1> + <nav class="mode-nav" aria-label="表示切替"> + <button id="nav-settings" type="button" class="nav-btn active" aria-pressed="true">設定</button> + <button id="nav-translation" type="button" class="nav-btn" aria-pressed="false">翻訳</button> + </nav> + </header> - <section class="row toggle-row"> - <label class="toggle-label" for="enabled-toggle">翻訳機能</label> - <input type="checkbox" id="enabled-toggle" role="switch"> - </section> - - <section class="row"> - <label for="model-select">モデル</label> - <div class="model-row"> - <select id="model-select"></select> - <button id="reload-models" type="button" title="モデル一覧を再取得">↻</button> + <!-- Translation result mode --> + <section id="translation-mode" class="mode" hidden> + <p id="translation-status" class="status"></p> + <p id="translation-meta" class="meta" hidden></p> + <div id="translation-text" class="translation-text" aria-live="polite"></div> + <p id="translation-error" class="status error" hidden></p> + <div class="actions"> + <button id="copy-translation" type="button" disabled>コピー</button> + <button id="regenerate-translation" type="button" disabled>再生成</button> + <button id="open-options-from-translation" type="button">設定を開く</button> </div> - <p id="model-status" class="status" hidden></p> </section> - <section class="row"> - <label for="language-select">翻訳先言語</label> - <select id="language-select"></select> - </section> + <!-- Settings mode --> + <section id="settings-mode" class="mode"> + <section class="row toggle-row"> + <label class="toggle-label" for="enabled-toggle">翻訳機能</label> + <input type="checkbox" id="enabled-toggle" role="switch"> + </section> - <button id="open-options" type="button">詳細設定を開く</button> + <section class="row"> + <label for="model-select">モデル</label> + <div class="model-row"> + <select id="model-select"></select> + <button id="reload-models" type="button" title="モデル一覧を再取得">↻</button> + </div> + <p id="model-status" class="status" hidden></p> + </section> + + <section class="row"> + <label for="language-select">翻訳先言語</label> + <select id="language-select"></select> + </section> + + <button id="open-options" type="button">詳細設定を開く</button> + </section> </main> <script type="module" src="popup.js"></script> </body> diff --git a/src/popup/popup.js b/src/popup/popup.js index eb213f5..3bd0cd9 100644 --- a/src/popup/popup.js +++ b/src/popup/popup.js @@ -1,6 +1,11 @@ 'use strict'; -import { LANGUAGES, ERROR_CODES, RUNTIME_MESSAGES } from '../shared/constants.js'; +import { + LANGUAGES, + ERROR_CODES, + RUNTIME_MESSAGES, + POPUP_STATUSES +} from '../shared/constants.js'; import { defaultSettings, sanitizeSettings } from '../shared/settings-schema.js'; const $enabled = /** @type {HTMLInputElement} */ (document.getElementById('enabled-toggle')); @@ -10,9 +15,171 @@ const $modelStatus = /** @type {HTMLParagraphElement} */ (document.getElementByI const $language = /** @type {HTMLSelectElement} */ (document.getElementById('language-select')); const $openOptions = /** @type {HTMLButtonElement} */ (document.getElementById('open-options')); +// Mode navigation + translation result mode elements +const $navSettings = /** @type {HTMLButtonElement} */ (document.getElementById('nav-settings')); +const $navTranslation = /** @type {HTMLButtonElement} */ (document.getElementById('nav-translation')); +const $settingsMode = /** @type {HTMLElement} */ (document.getElementById('settings-mode')); +const $translationMode = /** @type {HTMLElement} */ (document.getElementById('translation-mode')); +const $translationStatus = /** @type {HTMLParagraphElement} */ (document.getElementById('translation-status')); +const $translationMeta = /** @type {HTMLParagraphElement} */ (document.getElementById('translation-meta')); +const $translationText = /** @type {HTMLDivElement} */ (document.getElementById('translation-text')); +const $translationError = /** @type {HTMLParagraphElement} */ (document.getElementById('translation-error')); +const $copyTranslation = /** @type {HTMLButtonElement} */ (document.getElementById('copy-translation')); +const $regenerateTranslation = /** @type {HTMLButtonElement} */ (document.getElementById('regenerate-translation')); +const $openOptionsFromTranslation = /** @type {HTMLButtonElement} */ (document.getElementById('open-options-from-translation')); + /** @type {string[]} current model list for change detection */ let knownModels = []; +// --------------------------------------------------------------------- +// Mode switching +// --------------------------------------------------------------------- + +/** @param {'settings'|'translation'} mode */ +function showMode(mode) { + const translation = mode === 'translation'; + $settingsMode.hidden = translation; + $translationMode.hidden = !translation; + $navSettings.classList.toggle('active', !translation); + $navTranslation.classList.toggle('active', translation); + $navSettings.setAttribute('aria-pressed', String(!translation)); + $navTranslation.setAttribute('aria-pressed', String(translation)); +} + +$navSettings.addEventListener('click', () => { + showMode('settings'); + void persistMode('settings'); +}); +$navTranslation.addEventListener('click', () => { + showMode('translation'); + void persistMode('translation'); + void refreshTranslationState(); +}); + +$openOptionsFromTranslation.addEventListener('click', () => { + browser.runtime.openOptionsPage(); +}); + +/** + * Remember the tab the user picked so the next popup open restores it + * instead of always jumping to the translation tab. + * @param {'settings'|'translation'} mode + */ +async function persistMode(mode) { + try { + await browser.storage.local.set({ popupMode: mode }); + } catch { + // non-fatal + } +} + +// --------------------------------------------------------------------- +// Translation result mode +// --------------------------------------------------------------------- + +const STATUS_LABELS = { + [POPUP_STATUSES.IDLE]: '待機中', + [POPUP_STATUSES.STARTING]: '翻訳を準備中…', + [POPUP_STATUSES.STREAMING]: '受信中…', + [POPUP_STATUSES.COMPLETED]: '完了', + [POPUP_STATUSES.ERROR]: 'エラー', + [POPUP_STATUSES.CANCELLED]: '中断' +}; + +function renderTranslationState(state) { + if (!state || typeof state !== 'object') return; + const status = typeof state.status === 'string' ? state.status : POPUP_STATUSES.IDLE; + const busy = status === POPUP_STATUSES.STARTING || status === POPUP_STATUSES.STREAMING; + + $translationStatus.textContent = STATUS_LABELS[status] || status; + + if (state.model) { + $translationMeta.hidden = false; + $translationMeta.textContent = `Ollama / ${state.model}`; + } else { + $translationMeta.hidden = true; + } + + // Plain-text rendering only (Security.md §7.4). + $translationText.textContent = typeof state.translatedText === 'string' ? state.translatedText : ''; + + if (state.error) { + $translationError.hidden = false; + $translationError.textContent = state.error; + } else { + $translationError.hidden = true; + } + + $copyTranslation.disabled = busy || !(state.translatedText && state.translatedText.length > 0); + $regenerateTranslation.disabled = busy || !state.sourceText; +} + +async function refreshTranslationState() { + try { + const response = await browser.runtime.sendMessage({ type: RUNTIME_MESSAGES.GET_POPUP_STATE }); + if (response && response.ok && response.state) { + renderTranslationState(response.state); + return response.state; + } + } catch { + // background unavailable; leave the current view + } + return null; +} + +// --------------------------------------------------------------------- +// Live state updates: subscribe to the shared background state so an +// open popup receives streaming chunks in real time (Design.md §7.4). +// --------------------------------------------------------------------- + +try { + const statePort = browser.runtime.connect({ name: 'popup-state' }); + statePort.onMessage.addListener((message) => { + if (message && message.type === 'popup-state' && message.state) { + renderTranslationState(message.state); + } + }); + // onDisconnect: popup is closing or the background restarted; nothing to do. +} catch { + // Port may be unavailable in non-extension test contexts; polling in + // refreshTranslationState() still covers the initial state. +} + +$copyTranslation.addEventListener('click', () => { + const text = $translationText.textContent || ''; + if (!text) return; + void (async () => { + try { + await navigator.clipboard.writeText(text); + $translationStatus.textContent = 'コピーしました'; + } catch { + // No execCommand fallback (deprecated): show failure only. + $translationStatus.textContent = 'コピーできませんでした'; + } + })(); +}); + +$regenerateTranslation.addEventListener('click', () => { + void (async () => { + try { + const response = await browser.runtime.sendMessage({ type: RUNTIME_MESSAGES.REGENERATE_LAST }); + if (response && response.ok) { + await refreshTranslationState(); + } else { + $translationError.hidden = false; + $translationError.textContent = '再生成を開始できませんでした。'; + } + } catch { + $translationError.hidden = false; + $translationError.textContent = '再生成を開始できませんでした。'; + } + })(); +}); + +// --------------------------------------------------------------------- +// Settings mode (existing behavior) +// --------------------------------------------------------------------- + function fillLanguages(selected) { $language.textContent = ''; for (const lang of LANGUAGES) { @@ -138,10 +305,23 @@ void (async () => { // If the model list loaded and no model is saved yet, persist the // auto-selected first model so the stored settings match the UI. if (knownModels.length > 0) { - const result = await browser.storage.local.get({ settings: null }); + const result = await browser.storage.local.get({ settings: null, popupMode: null }); const stored = sanitizeSettings(result.settings || defaultSettings()); if (!stored.model) { await persist({ model: $model.value }); } + // Decide the initial mode. Active/recent translation wins; otherwise + // restore the tab the user last picked (default: settings). + const state = await refreshTranslationState(); + const active = state && (state.status === 'starting' || state.status === 'streaming'); + if (active) { + showMode('translation'); + } else if (result.popupMode === 'translation' && state && state.translatedText && state.translatedText.length > 0) { + showMode('translation'); + } else { + showMode('settings'); + } + } else { + showMode('settings'); } })();
\ No newline at end of file diff --git a/src/shared/constants.js b/src/shared/constants.js index 76a679c..adae26d 100644 --- a/src/shared/constants.js +++ b/src/shared/constants.js @@ -67,5 +67,21 @@ export const BG_TO_CONTENT = { /** Message types: one-shot runtime messages (popup/options -> background). */ export const RUNTIME_MESSAGES = { - LIST_MODELS: 'listModels' + LIST_MODELS: 'listModels', + GET_POPUP_STATE: 'getPopupState', + START_CONTEXT_MENU_TRANSLATION: 'startContextMenuTranslation', + REGENERATE_LAST: 'regenerateLast' +}; + +/** Context menu item id (menus API). */ +export const MENU_ID_TRANSLATE_SELECTION = 'translate-selection'; + +/** Popup translation result statuses (Design.md §7.4). */ +export const POPUP_STATUSES = { + IDLE: 'idle', + STARTING: 'starting', + STREAMING: 'streaming', + COMPLETED: 'completed', + ERROR: 'error', + CANCELLED: 'cancelled' };
\ No newline at end of file |
