summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-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
-rw-r--r--src/popup/popup.css73
-rw-r--r--src/popup/popup.html56
-rw-r--r--src/popup/popup.js184
-rw-r--r--src/shared/constants.js18
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