402 lines
13 KiB
JavaScript
402 lines
13 KiB
JavaScript
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("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """)
|
||
.replaceAll("'", "'");
|
||
}
|
||
|
||
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">
|
||
API:POST /api/translate(JSON)|GET /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,
|
||
},
|
||
});
|
||
}
|