commit c1e2cde1277a20532f250ec0f21113f745403c60 Author: shumengya Date: Wed Mar 11 20:04:26 2026 +0800 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a1565e4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +.DS_Store +Thumbs.db + +# Node / tooling +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Env / secrets +.env +.env.* + +# Misc +*.tmp +*.swp diff --git a/bin/gitea-mcp b/bin/gitea-mcp new file mode 100644 index 0000000..68f84c9 Binary files /dev/null and b/bin/gitea-mcp differ diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..93d12d0 --- /dev/null +++ b/readme.md @@ -0,0 +1,238 @@ + + +------ + +# Cloudflare Workers AI 翻译服务 + +一个基于 **Cloudflare Workers + Workers AI** 的轻量级 AI 翻译服务,支持: + +- 🌐 **网页访问翻译**(默认:英语 → 中文) +- 📄 **长文本自动分页 / 翻页** +- 🔌 **HTTP API 调用** +- 🌎 **多语言互译**(基于 `m2m100-1.2b` 模型) +- ⚡ **全球边缘节点低延迟部署** + +--- + +## ✨ 功能特性 + +### 1. 网页翻译(Web UI) + +- 默认语言:**en → zh** +- 支持选择源语言 / 目标语言 +- 自动按字符数分页 +- 翻页只翻译当前页,响应更快 +- 适合: + - 阅读外文文章 + - 翻译长文档 + - 快速人工校对 + +访问根路径即可使用:https://your-worker-domain/ + + +--- + +### 2. HTTP API 翻译 + +#### 接口一览 + +| 方法 | 路径 | 说明 | +|----|----|----| +| GET | `/api/languages` | 获取常用语言列表 | +| POST | `/api/translate` | 翻译文本 | + +--- + +## 🚀 API 使用说明 + +### 2.1 单页 / 分页翻译 + +**POST** `/api/translate` + +```json +{ + "text": "Hello world, this is a long text...", + "source_lang": "en", + "target_lang": "zh", + "page": 1, + "page_size": 1800 +} +``` + +#### 返回示例 + +```json +{ + "page": 1, + "page_size": 1800, + "total_pages": 3, + "source_lang": "en", + "target_lang": "zh", + "translated_text": "你好,世界,这是一段很长的文本……" +} +``` + +------ + +### 2.2 一次翻译全文(自动分段) + +```json +{ + "text": "Very long text...", + "source_lang": "en", + "target_lang": "zh", + "translate_all": true, + "page_size": 1800 +} +``` + +> ⚠️ 默认最多批量翻译 **50 段**(防止超时 / 过载) + +------ + +### 2.3 curl 示例 + +```bash +curl -X POST https://your-worker-domain/api/translate \ + -H "Content-Type: application/json" \ + -d '{ + "text": "Hello World", + "source_lang": "en", + "target_lang": "zh" + }' +``` + +------ + +## 🌐 支持语言(示例) + +基于 **Meta M2M100 多语言模型**,支持数十种语言互译,例如: + +- en – English +- zh – 中文 +- ja – 日本語 +- ko – 한국어 +- fr – Français +- de – Deutsch +- es – Español +- ru – Русский +- ar – العربية +- hi – हिन्दी +- vi – Tiếng Việt + 等…… + +> 实际支持语言以模型能力为准 + +------ + +## 🧠 使用的 AI 模型 + +- **模型**:`@cf/meta/m2m100-1.2b` +- **类型**:多语言 → 多语言翻译 +- **提供方**:Cloudflare Workers AI + +官方文档: +https://developers.cloudflare.com/workers-ai/models/m2m100-1.2b/ + +------ + +## 📦 项目结构 + +```text +. +├── index.js # Cloudflare Worker 主逻辑 +├── wrangler.toml # Wrangler 配置 +└── README.md +``` + +------ + +## ⚙️ 部署方式 + +### 1️⃣ 安装 Wrangler + +```bash +npm install -g wrangler +``` + +### 2️⃣ 登录 Cloudflare + +```bash +wrangler login +``` + +### 3️⃣ 配置 `wrangler.toml` + +```toml +name = "ai-translate-worker" +main = "index.js" +compatibility_date = "2026-01-27" + +[ai] +binding = "AI" +``` + +------ + +### 4️⃣ 发布 Worker + +```bash +wrangler deploy +``` + +------ + +## 🔐 (可选)API Key 保护 + +### 设置密钥 + +```bash +wrangler secret put API_KEY +``` + +### 调用方式 + +```http +X-API-Key: your_api_key +``` + +或: + +```text +/api/translate?key=your_api_key +``` + +------ + +## 🧩 适用场景 + +- 个人翻译工具 +- 博客 / 内容平台翻译接口 +- 海外资讯聚合 +- AI 工具链中的翻译节点 +- Cloudflare 边缘 AI Demo + +------ + +## 📌 TODO / 可扩展方向 + +- 自动语言识别 +- 翻译历史记录 +- 左右对照 UI +- Markdown / HTML 翻译 +- 翻译缓存(KV / Cache API) +- 流式翻译(Streaming) + +------ + +## 📄 License + +MIT License + +------ + +## ❤️ 致谢 + +- Cloudflare Workers +- Cloudflare Workers AI +- Meta M2M100 Multilingual Model \ No newline at end of file diff --git a/worker.js b/worker.js new file mode 100644 index 0000000..7a30100 --- /dev/null +++ b/worker.js @@ -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) => + `` + ).join(""); + + const hasResult = Boolean(text); + + return ` + + + + + Workers AI 翻译 + + + +
+

Workers AI 翻译(m2m100-1.2b)

+
+ 默认网页访问:英译中(en → zh)。语言参数遵循语言代码(如 en/zh/es…) :contentReference[oaicite:4]{index=4} +
+ + ${error ? `

${escapeHtml(error)}

` : ""} + +
+
+
+ + + + + + + + + + + +
+ +
+ +
+ +
提示:长文本会自动分页;翻页时只翻译当前页,响应更快。
+
+
+ + ${ + hasResult + ? `
+
+
第 ${page} / ${total_pages} 页
+
+ ${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) : ""} +
+
+ +

原文(本页)

+
${escapeHtml(input_chunk || "")}
+ +

译文

+
${escapeHtml(translated_text || "")}
+ +
+ API:POST /api/translate(JSON)|GET /api/languages +
+
` + : "" + } +
+ +`; +} + +function navForm(label, page, text, source_lang, target_lang, page_size) { + return `
+ + + + + + +
`; +} + +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, + }, + }); +}