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

17
.gitignore vendored Normal file
View File

@@ -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

BIN
bin/gitea-mcp Normal file

Binary file not shown.

238
readme.md Normal file
View File

@@ -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

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,
},
});
}