summaryrefslogtreecommitdiff
path: root/src/background/message-router.js
blob: 94283128470fe7c28542e6d01498172d2467dd05 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
/**
 * Message router (background).
 *
 * - 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.
 *
 * All inbound messages are validated; invalid ones are discarded
 * (Specifications.md SEC-06, Design.md §8.4).
 */

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 { 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 = 'モデルが設定されていません。ツールバーアイコンまたは設定画面でモデルを選択してください。';

/**
 * Handle one content-script port connection.
 * @param {object} port runtime.Port
 */
export function handleConnection(port) {
  if (!port || port.name !== 'translation-stream') return;

  port.onMessage.addListener((message) => {
    if (!isTrustedSender(port.sender)) return;

    if (isValidTranslateRequest(message)) {
      void runTranslation(port, message.requestId, message.text);
      return;
    }
    if (isValidCancelRequest(message)) {
      if (session.isActive(message.requestId)) {
        session.clear(message.requestId);
      }
      return;
    }
    // Invalid message: discard silently (do not echo details to the page).
  });

  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);
    }
  });
}

/**
 * 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
 */
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
  }
}

/**
 * Register one-shot message handling (popup / options).
 * @param {object} browserApi injectable for tests
 */
export function registerRuntimeMessageHandler(browserApi) {
  const b = browserApi || globalThis.browser;
  b.runtime.onMessage.addListener((message, sender) => {
    if (!isTrustedSender(sender)) return Promise.resolve({ ok: false, error: ERROR_CODES.INVALID_REQUEST });
    if (message && message.type === RUNTIME_MESSAGES.LIST_MODELS) {
      return (async () => {
        try {
          const settings = await getSettings(browserApi);
          const models = await listModels(settings.ollamaHost);
          return { ok: true, models };
        } catch (error) {
          return { ok: false, error: (error && error.code) || ERROR_CODES.OLLAMA_UNAVAILABLE, message: (error && error.message) || '' };
        }
      })();
    }
    return Promise.resolve({ ok: false, error: ERROR_CODES.INVALID_REQUEST });
  });
}

/**
 * Test-only hook: inject a browser API so the router can be exercised
 * outside a real extension context. Not used at runtime.
 * @param {object} browserApi
 * @visibleForTesting
 */
export function __setBrowserApiForTests(browserApi) {
  testBrowserApi = browserApi;
  browserApi.runtime.onConnect.addListener(handleConnection);
  registerRuntimeMessageHandler(browserApi);
}

/** @type {object|null} test-injected browser api */
let testBrowserApi = null;