Files
ai-translate/worker.js
2026-06-24 22:10:21 +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;
// API 的 CORS 预检请求
if (request.method === "OPTIONS" && pathname.startsWith("/api/")) {
return new Response(null, { headers: corsHeaders() });
}
// --- API返回语言列表常用 ---
if (pathname === "/api/languages") {
return jsonResponse({ languages: COMMON_LANGUAGES }, 200, corsHeaders());
}
// --- API翻译 ---
if (pathname === "/api/translate") {
if (request.method !== "POST") {
return jsonResponse(
{ error: "Use POST /api/translate with JSON body." },
405,
corsHeaders()
);
}
// 可选的 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(); // 可选
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) {
// 使用批量请求Workers AI 在该模型上支持批量 schema
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 });
// 某些绑定会返回类似数组的结果;这里做防御性归一化处理:
const translated_pages = Array.isArray(out)
? out.map((x) => x?.translated_text ?? "")
: (out?.responses ?? out?.results ?? out?.translated_pages ?? null);
// 兜底处理:如果返回结构不符合预期,就退回到单页翻译
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()
);
}
}
// 默认:仅翻译单页
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 界面 ---
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 });
},
};
// ---------------- 辅助函数 ----------------
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) {
// 文档里写的是语言代码,例如 'en'/'es' 等。
// 但有些示例会使用 english/french这里也允许原样透传。
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];
// 优先按段落拆分
const parts = t.split(/\n{2,}/);
const chunks = [];
let buf = "";
for (const p of parts) {
const piece = (p + "\n\n");
if (piece.length > maxChars) {
// 对超长段落进行硬切分
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,
},
});
}