Files
ai-translate/worker.js
2026-03-11 20:04:26 +08:00

402 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
const { pathname } = url;
// CORS preflight for API
if (request.method === "OPTIONS" && pathname.startsWith("/api/")) {
return new Response(null, { headers: corsHeaders() });
}
// --- API: list languages (common) ---
if (pathname === "/api/languages") {
return jsonResponse({ languages: COMMON_LANGUAGES }, 200, corsHeaders());
}
// --- API: translate ---
if (pathname === "/api/translate") {
if (request.method !== "POST") {
return jsonResponse(
{ error: "Use POST /api/translate with JSON body." },
405,
corsHeaders()
);
}
// Optional API key
const apiKey = env.API_KEY;
if (apiKey) {
const got =
request.headers.get("x-api-key") ||
url.searchParams.get("key") ||
"";
if (got !== apiKey) {
return jsonResponse({ error: "Unauthorized" }, 401, corsHeaders());
}
}
let body;
try {
body = await request.json();
} catch {
return jsonResponse({ error: "Invalid JSON body." }, 400, corsHeaders());
}
const text = (body.text ?? "").toString();
const target_lang = (body.target_lang ?? "").toString().trim();
const source_lang_raw = (body.source_lang ?? "").toString().trim(); // optional
if (!text) return jsonResponse({ error: "text is required" }, 400, corsHeaders());
if (!target_lang) return jsonResponse({ error: "target_lang is required" }, 400, corsHeaders());
const page_size = clampInt(body.page_size ?? 1800, 200, 5000);
const page = clampInt(body.page ?? 1, 1, 999999);
const translate_all = Boolean(body.translate_all);
const chunks = splitText(text, page_size);
const total_pages = chunks.length;
const safePage = clampInt(page, 1, total_pages);
try {
if (translate_all) {
// Use batch requests (Workers AI supports batch schema on this model) :contentReference[oaicite:2]{index=2}
const requests = chunks.slice(0, 50).map((t) => ({
text: t,
...(source_lang_raw ? { source_lang: normalizeLang(source_lang_raw) } : {}),
target_lang: normalizeLang(target_lang),
}));
const out = await env.AI.run("@cf/meta/m2m100-1.2b", { requests });
// Some bindings return array-like results; normalize defensively:
const translated_pages = Array.isArray(out)
? out.map((x) => x?.translated_text ?? "")
: (out?.responses ?? out?.results ?? out?.translated_pages ?? null);
// Fallback: if unknown, just translate one-by-one
if (Array.isArray(translated_pages)) {
return jsonResponse(
{
page: 1,
page_size,
total_pages,
translated_pages,
translated_text: translated_pages.join(""),
truncated: chunks.length > 50,
},
200,
corsHeaders()
);
}
}
// Default: translate one page
const chunk = chunks[safePage - 1] ?? "";
const out = await env.AI.run("@cf/meta/m2m100-1.2b", {
text: chunk,
...(source_lang_raw ? { source_lang: normalizeLang(source_lang_raw) } : {}),
target_lang: normalizeLang(target_lang),
});
return jsonResponse(
{
page: safePage,
page_size,
total_pages,
source_lang: source_lang_raw || "(default en)",
target_lang,
input_text: chunk,
translated_text: out?.translated_text ?? "",
},
200,
corsHeaders()
);
} catch (e) {
return jsonResponse(
{ error: "AI run failed", detail: String(e?.message || e) },
500,
corsHeaders()
);
}
}
// --- Web UI ---
if (pathname === "/" || pathname === "/index.html") {
if (request.method === "GET") {
return new Response(renderPage({}), {
headers: { "content-type": "text/html; charset=utf-8" },
});
}
if (request.method === "POST") {
const form = await request.formData();
const text = (form.get("text") ?? "").toString();
const source_lang = (form.get("source_lang") ?? "en").toString().trim();
const target_lang = (form.get("target_lang") ?? "zh").toString().trim();
const page_size = clampInt(form.get("page_size") ?? 1800, 200, 5000);
const page = clampInt(form.get("page") ?? 1, 1, 999999);
if (!text) {
return new Response(renderPage({ error: "请输入要翻译的文本。", text, source_lang, target_lang, page_size, page }), {
headers: { "content-type": "text/html; charset=utf-8" },
});
}
const chunks = splitText(text, page_size);
const total_pages = chunks.length;
const safePage = clampInt(page, 1, total_pages);
const chunk = chunks[safePage - 1] ?? "";
let translated = "";
let err = "";
try {
const out = await env.AI.run("@cf/meta/m2m100-1.2b", {
text: chunk,
source_lang: normalizeLang(source_lang),
target_lang: normalizeLang(target_lang),
});
translated = out?.translated_text ?? "";
} catch (e) {
err = `翻译失败:${String(e?.message || e)}`;
}
return new Response(
renderPage({
text,
source_lang,
target_lang,
page_size,
page: safePage,
total_pages,
input_chunk: chunk,
translated_text: translated,
error: err,
}),
{ headers: { "content-type": "text/html; charset=utf-8" } }
);
}
return new Response("Method Not Allowed", { status: 405 });
}
return new Response("Not Found", { status: 404 });
},
};
// ---------------- helpers ----------------
const COMMON_LANGUAGES = [
{ code: "en", name: "English" },
{ code: "zh", name: "中文" },
{ code: "ja", name: "日本語" },
{ code: "ko", name: "한국어" },
{ code: "fr", name: "Français" },
{ code: "de", name: "Deutsch" },
{ code: "es", name: "Español" },
{ code: "pt", name: "Português" },
{ code: "it", name: "Italiano" },
{ code: "ru", name: "Русский" },
{ code: "ar", name: "العربية" },
{ code: "hi", name: "हिन्दी" },
{ code: "id", name: "Bahasa Indonesia" },
{ code: "th", name: "ไทย" },
{ code: "vi", name: "Tiếng Việt" },
{ code: "tr", name: "Türkçe" },
{ code: "nl", name: "Nederlands" },
{ code: "sv", name: "Svenska" },
{ code: "pl", name: "Polski" },
{ code: "uk", name: "Українська" },
];
function normalizeLang(lang) {
// Docs say language code like 'en'/'es' etc. :contentReference[oaicite:3]{index=3}
// But examples sometimes use english/french; we accept both by passing through.
return String(lang || "").trim().toLowerCase();
}
function clampInt(v, min, max) {
const n = Number.parseInt(String(v), 10);
if (!Number.isFinite(n)) return min;
return Math.min(max, Math.max(min, n));
}
function splitText(text, maxChars) {
const t = String(text || "");
if (t.length <= maxChars) return [t];
// Prefer paragraph-based splitting
const parts = t.split(/\n{2,}/);
const chunks = [];
let buf = "";
for (const p of parts) {
const piece = (p + "\n\n");
if (piece.length > maxChars) {
// Hard split long paragraph
if (buf) {
chunks.push(buf);
buf = "";
}
for (let i = 0; i < piece.length; i += maxChars) {
chunks.push(piece.slice(i, i + maxChars));
}
continue;
}
if (buf.length + piece.length > maxChars) {
chunks.push(buf);
buf = piece;
} else {
buf += piece;
}
}
if (buf) chunks.push(buf);
return chunks;
}
function escapeHtml(s) {
return String(s || "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function renderPage(state) {
const {
text = "",
source_lang = "en",
target_lang = "zh",
page_size = 1800,
page = 1,
total_pages = 1,
input_chunk = "",
translated_text = "",
error = "",
} = state || {};
const langOptions = (selected) =>
COMMON_LANGUAGES.map(
(l) =>
`<option value="${escapeHtml(l.code)}" ${
l.code === selected ? "selected" : ""
}>${escapeHtml(l.name)} (${escapeHtml(l.code)})</option>`
).join("");
const hasResult = Boolean(text);
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Workers AI 翻译</title>
<style>
body { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; margin: 24px; }
.wrap { max-width: 1100px; margin: 0 auto; }
textarea { width: 100%; min-height: 220px; padding: 12px; font-size: 14px; }
pre { white-space: pre-wrap; word-break: break-word; background: #f6f7f9; padding: 12px; border-radius: 10px; }
.row { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
.card { border: 1px solid #e6e7ea; border-radius: 14px; padding: 14px; margin-top: 14px; }
.btn { padding: 10px 14px; border-radius: 10px; border: 1px solid #d0d3d9; background: #fff; cursor: pointer; }
.btn-primary { border-color: #111; }
.muted { color: #666; font-size: 12px; }
.err { color: #b00020; }
input[type="number"], input[type="text"], select { padding: 8px 10px; border-radius: 10px; border: 1px solid #d0d3d9; }
</style>
</head>
<body>
<div class="wrap">
<h2>Workers AI 翻译m2m100-1.2b</h2>
<div class="muted">
默认网页访问英译中en → zh。语言参数遵循语言代码如 en/zh/es… :contentReference[oaicite:4]{index=4}
</div>
${error ? `<p class="err">${escapeHtml(error)}</p>` : ""}
<form method="POST" action="/">
<div class="card">
<div class="row">
<label>源语言:</label>
<select name="source_lang">
${langOptions(source_lang)}
</select>
<label>目标语言:</label>
<select name="target_lang">
${langOptions(target_lang)}
</select>
<label>每页字符数:</label>
<input name="page_size" type="number" min="200" max="5000" value="${escapeHtml(page_size)}" />
<input type="hidden" name="page" value="1" />
<button class="btn btn-primary" type="submit">翻译(从第 1 页开始)</button>
</div>
<div style="margin-top: 10px;">
<textarea name="text" placeholder="粘贴要翻译的文本…">${escapeHtml(text)}</textarea>
</div>
<div class="muted">提示:长文本会自动分页;翻页时只翻译当前页,响应更快。</div>
</div>
</form>
${
hasResult
? `<div class="card">
<div class="row" style="justify-content: space-between;">
<div><b>第 ${page} / ${total_pages} 页</b></div>
<div class="row">
${page > 1 ? navForm("上一页", page - 1, text, source_lang, target_lang, page_size) : ""}
${page < total_pages ? navForm("下一页", page + 1, text, source_lang, target_lang, page_size) : ""}
</div>
</div>
<h3>原文(本页)</h3>
<pre>${escapeHtml(input_chunk || "")}</pre>
<h3>译文</h3>
<pre>${escapeHtml(translated_text || "")}</pre>
<div class="muted">
APIPOST /api/translateJSONGET /api/languages
</div>
</div>`
: ""
}
</div>
</body>
</html>`;
}
function navForm(label, page, text, source_lang, target_lang, page_size) {
return `<form method="POST" action="/" style="display:inline;">
<input type="hidden" name="page" value="${escapeHtml(page)}" />
<input type="hidden" name="source_lang" value="${escapeHtml(source_lang)}" />
<input type="hidden" name="target_lang" value="${escapeHtml(target_lang)}" />
<input type="hidden" name="page_size" value="${escapeHtml(page_size)}" />
<input type="hidden" name="text" value="${escapeHtml(text)}" />
<button class="btn" type="submit">${escapeHtml(label)}</button>
</form>`;
}
function corsHeaders() {
return {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, X-API-Key",
"Access-Control-Max-Age": "86400",
};
}
function jsonResponse(obj, status = 200, extraHeaders = {}) {
return new Response(JSON.stringify(obj, null, 2), {
status,
headers: {
"content-type": "application/json; charset=utf-8",
...extraHeaders,
},
});
}