summaryrefslogtreecommitdiff
path: root/src/background/translation-service.js
blob: f443237b5261f0173032b7cbfd04b062992f1ffe (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
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
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 };