Changes
diff --git a/tavern-translate/app.js b/tavern-translate/app.js
index 68b12e2..624a885 100644
--- a/tavern-translate/app.js
+++ b/tavern-translate/app.js
@@ -72,11 +72,14 @@ const setStorage = (key, val) => localStorage.setItem(STORAGE_PREFIX + key, val)
const config = signal({
api_url: getStorage('api_url') || 'https://api.openai.com/v1/chat/completions',
api_key: getStorage('api_key') || '',
+ model: getStorage('model') || 'gpt-3.5-turbo',
system_prompt: getStorage('system_prompt') || '作为一名资深翻译家,把用户提供的文本从英文翻译成中文,保持原文的格式和语气。注意 `{{user}}` 和 `{{char}}` 为特殊标记,不要翻译。',
ignored_keys: getStorage('ignored_keys') || 'spec,spec_version,avatar,url,id,keys,secondary_keys,creator,version,chub,fav,talkativeness',
concurrency: parseInt(getStorage('concurrency') || '3', 10)
});
+const MAX_RETRIES = 3;
+
const task_queue = new TaskQueue(config.value.concurrency);
// --- Helper Functions ---
@@ -141,6 +144,81 @@ function flattenTree(node)
return node;
}
+// Try to pull a human-readable error message out of an OpenAI-style
+// error body. Returns null if the body is not JSON or has no message.
+function extractApiError(raw)
+{
+ try
+ {
+ const parsed = JSON.parse(raw);
+ if(typeof parsed.error === 'string') return parsed.error;
+ return parsed.error?.message || parsed.message || null;
+ }
+ catch(e)
+ {
+ return null;
+ }
+}
+
+// Perform a single translation request. Reads the response as text
+// first so that a non-JSON body (an HTML error page from a proxy, an
+// empty body, etc.) produces a meaningful error instead of a cryptic
+// "JSON.parse: unexpected character" failure.
+async function requestTranslation(text)
+{
+ const response = await fetch(config.value.api_url, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${config.value.api_key}`
+ },
+ body: JSON.stringify({
+ model: config.value.model,
+ messages: [
+ { role: 'system', content: config.value.system_prompt },
+ { role: 'user', content: text }
+ ],
+ temperature: 0.3
+ })
+ });
+
+ const raw = await response.text();
+
+ if(!response.ok)
+ {
+ const message = extractApiError(raw) ||
+ `HTTP ${response.status} ${response.statusText}`;
+ const err = new Error(message);
+ // Rate limiting and server errors are usually transient.
+ err.retriable = response.status === 429 || response.status >= 500;
+ throw err;
+ }
+
+ let data;
+ try
+ {
+ data = JSON.parse(raw);
+ }
+ catch(e)
+ {
+ // A 200 response with a non-JSON body. This is what produced the
+ // original "unexpected character at line 1 column 1" error, and
+ // it is typically a transient proxy/gateway hiccup.
+ const snippet = raw.trim().slice(0, 200) || '(empty body)';
+ const err = new Error(`API returned a non-JSON response: ${snippet}`);
+ err.retriable = true;
+ throw err;
+ }
+
+ const content = data.choices?.[0]?.message?.content;
+ if(content == null)
+ {
+ throw new Error(extractApiError(raw) ||
+ 'API response did not contain a translation.');
+ }
+ return content;
+}
+
async function translateNode(node, force = false)
{
if(node.status.value === 'translating') return;
@@ -153,30 +231,27 @@ async function translateNode(node, force = false)
{
const result = await task_queue.add(async () =>
{
- const response = await fetch(config.value.api_url, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'Authorization': `Bearer ${config.value.api_key}`
- },
- body: JSON.stringify({
- model: 'gpt-3.5-turbo',
- messages: [
- { role: 'system', content: config.value.system_prompt },
- { role: 'user', content: node.original_value }
- ],
- temperature: 0.3
- })
- });
-
- if(!response.ok)
+ let last_error = null;
+ for(let attempt = 0; attempt <= MAX_RETRIES; attempt++)
{
- const error_data = await response.json();
- throw new Error(error_data.error?.message || 'API Error');
- }
+ if(attempt > 0)
+ {
+ // Exponential backoff: 0.5s, 1s, 2s, ...
+ const delay = 500 * Math.pow(2, attempt - 1);
+ await new Promise(r => setTimeout(r, delay));
+ }
- const data = await response.json();
- return data.choices[0].message.content;
+ try
+ {
+ return await requestTranslation(node.original_value);
+ }
+ catch(err)
+ {
+ last_error = err;
+ if(!err.retriable) throw err;
+ }
+ }
+ throw last_error || new Error('Translation failed.');
});
node.translated_value.value = result;
@@ -266,6 +341,10 @@ function OptionsDialog({ close })
<label>API Key</label>
<input type="password" value=${local_config.api_key} oninput=${(e) => set_local_config({ ...local_config, api_key: e.target.value })} />
</div>
+ <div class="form-group">
+ <label>Model</label>
+ <input type="text" value=${local_config.model} oninput=${(e) => set_local_config({ ...local_config, model: e.target.value })} />
+ </div>
<div class="form-group">
<label>Concurrency (Max active requests)</label>
<input type="number" min="1" max="20" value=${local_config.concurrency} oninput=${(e) => set_local_config({ ...local_config, concurrency: parseInt(e.target.value, 10) })} />