Initial commit
This commit is contained in:
401
worker.js
Normal file
401
worker.js
Normal 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("&", "&")
|
||||
.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,
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user