Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 7 additions & 10 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,19 +63,16 @@ Limits: ~120 req/hour/subject; anonymous IP ceiling still applies (~300/hour).

Same-origin proxy for card-editor «Перевести» (CSP-safe).

**Upstream (prod):** Cloudflare Workers AI —
1. Llama (`@cf/meta/llama-3.1-8b-instruct`) for meaning (avoids transliteration like onion → «Онеон»)
2. m2m100 with language **names** `english`/`russian` (ISO `en`/`ru` often transliterates on CF)
3. MyMemory only as last fallback / local `npm run dev` without `AI` binding
**Upstream (prod):**

**Body (JSON):**
1. Workers AI Llama (if `AI` binding is set in Pages)
2. Workers AI m2m100 with language names `english`/`russian`
3. **Google Translate gtx** — reliable from Cloudflare edge without a key (MyMemory often returns 502 from CF IPs)
4. MyMemory last

| Field | Required | Description |
| ------ | -------- | ---------------------------------------- |
| `text` | yes | Word/phrase to translate (max 500 chars) |
| `dir` | no | `ru-en` (default) or `en-ru` |
**Success:** `{ text, dir, provider?: "workers-ai-llm" \| "workers-ai-m2m" \| "gtx" \| "mymemory" }`

**Success:** `{ text: string, dir: string, provider?: "workers-ai-llm" \| "workers-ai-m2m" \| "mymemory" }`
If translate keeps returning 502 after deploy, add Pages → Settings → Bindings → Workers AI (`AI`), then redeploy.

Limits: ~120 req/hour/subject.

Expand Down
100 changes: 80 additions & 20 deletions functions/api/translate.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
//
// Клиент → same-origin /api/translate (CSP).
//
// Прод (Workers AI binding):
// 1) Llama — смысловой перевод для карточек (не транслит)
// 2) m2m100 с именами языков english/russian (ISO-коды en/ru
// у m2m100 на CF часто дают транслит вроде onion → «Онеон»)
// Fallback без AI / при сбое: MyMemory (локальный npm run dev).
// Порядок провайдеров:
// 1) Workers AI Llama — смысловой перевод (нужен биндинг AI)
// 2) Workers AI m2m100 с именами english/russian
// 3) Google Translate gtx — работает с edge CF без ключа/биндинга
// (MyMemory с IP Cloudflare часто 502)
// 4) MyMemory — последний шанс / локальный dev

const MYMEMORY = "https://api.mymemory.translated.net/get"
const GTX = "https://translate.googleapis.com/translate_a/single"
const LLM_MODEL = "@cf/meta/llama-3.1-8b-instruct"
const MT_MODEL = "@cf/meta/m2m100-1.2b"
const UPSTREAM_TIMEOUT_MS = 12_000
Expand Down Expand Up @@ -197,6 +199,41 @@ async function translateWithMyMemory(text, from, to) {
return out
}

/** Неофициальный Google Translate endpoint — обычно доступен с Workers. */
async function translateWithGtx(text, from, to) {
const url = new URL(GTX)
url.searchParams.set("client", "gtx")
url.searchParams.set("sl", from)
url.searchParams.set("tl", to)
url.searchParams.set("dt", "t")
url.searchParams.set("q", text)
const res = await fetch(url.toString(), {
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
headers: { accept: "application/json" }
})
if (!res.ok) {
const err = new Error(`gtx status ${res.status}`)
err.code = "upstream"
throw err
}
const data = await res.json()
const chunks = Array.isArray(data?.[0]) ? data[0] : []
const out = chunks
.map((part) => (Array.isArray(part) ? String(part[0] || "") : ""))
.join("")
.trim()
if (!out) {
const err = new Error("empty")
err.code = "upstream"
throw err
}
return out
}

function acceptTranslation(source, out, dir) {
return !!out && !looksLikeTransliteration(source, out, dir)
}

async function handler(req, env = {}, subject = "") {
if (req.method !== "POST") return json({ error: "bad-request", message: "Ожидается POST" }, 405)

Expand All @@ -214,31 +251,47 @@ async function handler(req, env = {}, subject = "") {

const dir = payload?.dir === "en-ru" ? "en-ru" : "ru-en"
const { from, to } = parseDir(dir)
const hasAi = !!(env?.AI && typeof env.AI.run === "function")
if (!hasAi) {
console.warn("[translate] AI binding missing — using HTTP fallbacks", { subject })
}

try {
const viaLlm = await translateWithLlm(env, text, from, to)
if (viaLlm && !looksLikeTransliteration(text, viaLlm, dir)) {
return json({ text: viaLlm, dir, provider: "workers-ai-llm" })
if (hasAi) {
try {
const viaLlm = await translateWithLlm(env, text, from, to)
if (acceptTranslation(text, viaLlm, dir)) {
return json({ text: viaLlm, dir, provider: "workers-ai-llm" })
}
} catch (e) {
console.error("[translate] llm failed", String(e?.message || e), { subject })
}

try {
const viaMt = await translateWithM2m(env, text, from, to)
if (acceptTranslation(text, viaMt, dir)) {
return json({ text: viaMt, dir, provider: "workers-ai-m2m" })
}
if (viaMt) {
console.warn("[translate] m2m transliteration rejected", { text, viaMt, dir, subject })
}
} catch (e) {
console.error("[translate] m2m failed", String(e?.message || e), { subject })
}
} catch (e) {
console.error("[translate] llm failed", String(e?.message || e), { subject })
}

try {
const viaMt = await translateWithM2m(env, text, from, to)
if (viaMt && !looksLikeTransliteration(text, viaMt, dir)) {
return json({ text: viaMt, dir, provider: "workers-ai-m2m" })
}
if (viaMt) {
console.warn("[translate] m2m transliteration rejected", { text, viaMt, dir, subject })
const viaGtx = await translateWithGtx(text, from, to)
if (acceptTranslation(text, viaGtx, dir)) {
return json({ text: viaGtx, dir, provider: "gtx" })
}
console.warn("[translate] gtx transliteration rejected", { text, viaGtx, dir, subject })
} catch (e) {
console.error("[translate] m2m failed", String(e?.message || e), { subject })
console.error("[translate] gtx failed", String(e?.message || e), { subject })
}

try {
const viaMm = await translateWithMyMemory(text, from, to)
if (looksLikeTransliteration(text, viaMm, dir)) {
if (!acceptTranslation(text, viaMm, dir)) {
return json(
{
error: "bad-translation",
Expand All @@ -253,7 +306,14 @@ async function handler(req, env = {}, subject = "") {
if (e?.code === "quota") {
return json({ error: "quota", message: "Лимит перевода исчерпан, попробуйте позже" }, 429)
}
return json({ error: "upstream", message: "Сервис перевода недоступен — попробуй позже" }, 502)
return json(
{
error: "upstream",
message: "Сервис перевода недоступен — попробуй позже",
detail: hasAi ? "ai+gtx+mymemory-failed" : "no-ai+gtx+mymemory-failed"
},
502
)
}
}

Expand Down
4 changes: 2 additions & 2 deletions js/core/version.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/** Версия сборки. Dev SW (`sw.js`) = APP_VERSION; prod SW (`dist/sw.js`) = APP_VERSION + "-bundle". */
export const APP_VERSION = "kar-v16.4"
export const APP_VERSION_SHORT = "16.4"
export const APP_VERSION = "kar-v16.5"
export const APP_VERSION_SHORT = "16.5"

/** Публичный репозиторий проекта. */
export const APP_GITHUB_URL = "https://github.com/PaperPit/kar-dots"
2 changes: 1 addition & 1 deletion sw.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const VERSION = 'kar-v16.4';
const VERSION = 'kar-v16.5';

/** AUTO-GENERATED CORE_FILES — node scripts/generate-sw-files.js */
const CORE_FILES = [
Expand Down
51 changes: 41 additions & 10 deletions tests/api-translate.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ describe("api/translate", () => {
expect(fetchSpy).not.toHaveBeenCalled()
})

it("отбрасывает транслит m2m и берёт нормальный ответ MyMemory", async () => {
it("отбрасывает транслит m2m и берёт gtx", async () => {
const env = {
AI: {
run: vi.fn(async (model, input) => {
Expand All @@ -61,13 +61,13 @@ describe("api/translate", () => {
}
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
ok: true,
json: async () => ({
responseStatus: 200,
responseData: { translatedText: "лук" }
})
}))
vi.fn(async (url) => {
expect(String(url)).toContain("translate.googleapis.com")
return {
ok: true,
json: async () => [[["лук", "onion", null, null, 10]]]
}
})
)

const req = new Request("http://localhost/api/translate", {
Expand All @@ -80,14 +80,45 @@ describe("api/translate", () => {
expect(await res.json()).toEqual({
text: "лук",
dir: "en-ru",
provider: "mymemory"
provider: "gtx"
})
})

it("без AI берёт Google gtx", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async (url) => {
expect(String(url)).toContain("translate.googleapis.com")
expect(String(url)).toContain("sl=en")
expect(String(url)).toContain("tl=ru")
return {
ok: true,
json: async () => [[["лук", "onion", null, null, 10]]]
}
})
)

const req = new Request("http://localhost/api/translate", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text: "onion", dir: "en-ru" })
})
const res = await handler(req, {}, "test")
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
text: "лук",
dir: "en-ru",
provider: "gtx"
})
})

it("без AI откатывается на MyMemory", async () => {
it("без AI откатывается на MyMemory если gtx упал", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async (url) => {
if (String(url).includes("translate.googleapis.com")) {
return { ok: false, status: 503, json: async () => ({}) }
}
expect(String(url)).toContain("langpair=ru|en")
expect(String(url)).toContain(encodeURIComponent("икра"))
return {
Expand Down
Loading