Initial commit

This commit is contained in:
2026-03-11 20:04:26 +08:00
commit c1e2cde127
4 changed files with 656 additions and 0 deletions

401
worker.js Normal file
View File

@@ -0,0 +1,401 @@
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,
},
});
}