diff --git a/backend/agent_translation/agent.py b/backend/agent_translation/agent.py
index cfe6476..57609ca 100644
--- a/backend/agent_translation/agent.py
+++ b/backend/agent_translation/agent.py
@@ -2,6 +2,8 @@
import asyncio
import re
+from collections.abc import AsyncIterator
+from dataclasses import dataclass
from llm_providers import (
ChatMessage,
@@ -55,6 +57,12 @@ def chunk_by_headings(text: str, max_chars: int = 4000) -> list[str]:
return chunks if chunks else [text]
+@dataclass(slots=True)
+class BilingualSegment:
+ kind: str
+ source_text: str
+
+
class TranslationAgent:
"""
Agent for translating article content to a target language.
@@ -164,58 +172,20 @@ async def translate_bilingual(
Returns:
Dictionary with translated_text (bilingual format) and usage
"""
- # 按段落分段
- paragraphs = [p.strip() for p in content.split('\n\n') if p.strip()]
-
- # 合并短段落,避免太碎片化
- merged_paragraphs = []
- current = ""
- for para in paragraphs:
- if len(current) + len(para) < 500:
- current = current + "\n\n" + para if current else para
- else:
- if current:
- merged_paragraphs.append(current)
- current = para
- if current:
- merged_paragraphs.append(current)
-
total_prompt_tokens = 0
total_completion_tokens = 0
bilingual_parts = []
failed_chunks = 0
- for para in merged_paragraphs:
- # 检查是否是标题
- if para.startswith('#'):
- # 标题直接翻译,不保留原文;失败则退化为原标题
- try:
- result = await self.translate_chunk(para, target_lang, temperature)
- bilingual_parts.append(result["text"])
- total_prompt_tokens += result["prompt_tokens"]
- total_completion_tokens += result["completion_tokens"]
- except LLMProviderError:
- failed_chunks += 1
- bilingual_parts.append(para)
- else:
- # 普通段落:先原文,再翻译。单段失败不影响整篇,保留原文占位。
- original = f'
{para}
'
- try:
- result = await self.translate_chunk(para, target_lang, temperature)
- translated = result["text"]
- total_prompt_tokens += result["prompt_tokens"]
- total_completion_tokens += result["completion_tokens"]
- except LLMProviderError:
- failed_chunks += 1
- translated = para
- translation = f'{translated}
'
- bilingual_parts.append(original)
- bilingual_parts.append("")
- bilingual_parts.append(translation)
- bilingual_parts.append("")
+ async for event in self.stream_translate_bilingual(content, target_lang, temperature):
+ bilingual_parts.append(event["html"])
+ total_prompt_tokens += event["prompt_tokens"]
+ total_completion_tokens += event["completion_tokens"]
+ failed_chunks += 1 if event["failed"] else 0
# 全部片段都失败时,视为整体失败,交由 service 标记 failure
- if merged_paragraphs and failed_chunks == len(merged_paragraphs):
+ segments = self.prepare_bilingual_segments(content)
+ if segments and failed_chunks == len(segments):
raise LLMProviderError("All translation chunks failed")
# 合并结果
@@ -231,6 +201,83 @@ async def translate_bilingual(
},
}
+ def prepare_bilingual_segments(self, content: str) -> list[BilingualSegment]:
+ paragraphs = [p.strip() for p in content.split('\n\n') if p.strip()]
+
+ merged_paragraphs = []
+ current = ""
+ for para in paragraphs:
+ if len(current) + len(para) < 500:
+ current = current + "\n\n" + para if current else para
+ else:
+ if current:
+ merged_paragraphs.append(current)
+ current = para
+ if current:
+ merged_paragraphs.append(current)
+
+ segments = []
+ for para in merged_paragraphs:
+ kind = "heading" if para.startswith('#') else "paragraph"
+ segments.append(BilingualSegment(kind=kind, source_text=para))
+ return segments
+
+ async def stream_translate_bilingual(
+ self,
+ content: str,
+ target_lang: str,
+ temperature: float = 0.3,
+ ) -> AsyncIterator[dict]:
+ segments = self.prepare_bilingual_segments(content)
+
+ for index, segment in enumerate(segments):
+ if segment.kind == "heading":
+ try:
+ result = await self.translate_chunk(
+ segment.source_text,
+ target_lang,
+ temperature,
+ )
+ translated = result["text"]
+ failed = False
+ prompt_tokens = result["prompt_tokens"]
+ completion_tokens = result["completion_tokens"]
+ except LLMProviderError:
+ translated = segment.source_text
+ failed = True
+ prompt_tokens = 0
+ completion_tokens = 0
+ yield {
+ "chunk_index": index,
+ "html": translated,
+ "failed": failed,
+ "prompt_tokens": prompt_tokens,
+ "completion_tokens": completion_tokens,
+ }
+ continue
+
+ original = f'{segment.source_text}
'
+ try:
+ result = await self.translate_chunk(segment.source_text, target_lang, temperature)
+ translated = result["text"]
+ failed = False
+ prompt_tokens = result["prompt_tokens"]
+ completion_tokens = result["completion_tokens"]
+ except LLMProviderError:
+ translated = segment.source_text
+ failed = True
+ prompt_tokens = 0
+ completion_tokens = 0
+
+ translation = f'{translated}
'
+ yield {
+ "chunk_index": index,
+ "html": f"{original}\n\n{translation}",
+ "failed": failed,
+ "prompt_tokens": prompt_tokens,
+ "completion_tokens": completion_tokens,
+ }
+
async def translate(
self,
content: str,
diff --git a/backend/agent_translation/http/router.py b/backend/agent_translation/http/router.py
index 6788140..f364029 100644
--- a/backend/agent_translation/http/router.py
+++ b/backend/agent_translation/http/router.py
@@ -8,9 +8,11 @@
When translation fails, a 200 response with FAILURE status is returned instead.
"""
+import json
import logging
from fastapi import APIRouter, HTTPException
+from fastapi.responses import StreamingResponse
from app.schemas.agent import TranslationRequest, TranslationResult
@@ -92,3 +94,40 @@ async def translate_article(request: TranslationRequest) -> TranslationResult:
except ArticleContentUnavailableError as exc:
logger.warning(f"Translation request for article without content: {request.entry_id}")
raise HTTPException(status_code=409, detail="Entry has no content to translate") from exc
+
+
+@router.post("/stream")
+async def translate_article_stream(request: TranslationRequest) -> StreamingResponse:
+ service = get_translation_service()
+
+ try:
+ stream = service.stream_generate(request)
+ first_event = await stream.__anext__()
+
+ async def event_stream():
+ first_payload = json.dumps(first_event, ensure_ascii=False)
+ yield f"event: {first_event.get('type', 'message')}\ndata: {first_payload}\n\n"
+ async for event in stream:
+ event_type = event.get("type", "message")
+ payload = json.dumps(event, ensure_ascii=False)
+ yield f"event: {event_type}\ndata: {payload}\n\n"
+
+ return StreamingResponse(
+ event_stream(),
+ media_type="text/event-stream",
+ headers={
+ "Cache-Control": "no-cache",
+ "Connection": "keep-alive",
+ },
+ )
+
+ except ArticleNotFoundError as exc:
+ logger.warning(f"Streaming translation request for missing article: {request.entry_id}")
+ raise HTTPException(status_code=404, detail="Entry not found") from exc
+
+ except ArticleContentUnavailableError as exc:
+ logger.warning(
+ "Streaming translation request for article without content: %s",
+ request.entry_id,
+ )
+ raise HTTPException(status_code=409, detail="Entry has no content to translate") from exc
diff --git a/backend/agent_translation/service.py b/backend/agent_translation/service.py
index 712112f..d3ddbbb 100644
--- a/backend/agent_translation/service.py
+++ b/backend/agent_translation/service.py
@@ -8,6 +8,7 @@
"""
import re
+from collections.abc import AsyncIterator
from dataclasses import dataclass
from datetime import UTC
@@ -90,6 +91,24 @@ async def generate(self, request: TranslationRequest) -> TranslationResult:
return result
+ async def stream_generate(self, request: TranslationRequest) -> AsyncIterator[dict]:
+ article = get_article(request.entry_id)
+ if article is None:
+ raise ArticleNotFoundError(request.entry_id)
+
+ content = self._resolve_content(request.entry_id, article.reader_html)
+ if not content:
+ raise ArticleContentUnavailableError(request.entry_id)
+
+ async for event in self._execute_translation_stream(
+ entry_id=request.entry_id,
+ target_lang=request.target_lang,
+ content=content,
+ provider_name=request.provider,
+ model_name=request.model,
+ ):
+ yield event
+
async def _execute_translation(
self,
entry_id: str,
@@ -185,6 +204,138 @@ async def _execute_translation(
model=model_name or "unknown",
)
+ async def _execute_translation_stream(
+ self,
+ entry_id: str,
+ target_lang: str,
+ content: str,
+ provider_name: str | None,
+ model_name: str | None,
+ ) -> AsyncIterator[dict]:
+ try:
+ provider = get_provider(name=provider_name)
+ agent = TranslationAgent(provider=provider)
+ segments = agent.prepare_bilingual_segments(content)
+
+ accumulated_html = ""
+ total_prompt_tokens = 0
+ total_completion_tokens = 0
+ failed_chunks = 0
+ total_chunks = 0
+ resolved_segments: dict[int, str] = {}
+
+ yield {
+ "type": "start",
+ "entry_id": entry_id,
+ "target_lang": target_lang,
+ "provider": provider.name,
+ "model": provider.model,
+ }
+
+ async for chunk in agent.stream_translate_bilingual(
+ content=content,
+ target_lang=target_lang,
+ temperature=0.3,
+ ):
+ total_chunks += 1
+ failed_chunks += 1 if chunk["failed"] else 0
+ total_prompt_tokens += chunk["prompt_tokens"]
+ total_completion_tokens += chunk["completion_tokens"]
+ accumulated_html = (
+ f"{accumulated_html}\n\n{chunk['html']}" if accumulated_html else chunk["html"]
+ )
+ resolved_segments[chunk["chunk_index"]] = chunk["html"]
+ yield {
+ "type": "chunk",
+ "chunk_index": chunk["chunk_index"],
+ "delta_html": chunk["html"],
+ "translation_html": self._build_stream_preview_html(
+ segments,
+ resolved_segments,
+ ),
+ "failed": chunk["failed"],
+ }
+
+ if total_chunks and failed_chunks == total_chunks:
+ raise LLMProviderError("All translation chunks failed")
+
+ from datetime import datetime
+
+ today = datetime.now(UTC).strftime("%Y-%m-%d")
+ record_usage(
+ day=today,
+ provider=provider.name,
+ model=provider.model,
+ agent="translation",
+ prompt_tokens=total_prompt_tokens,
+ completion_tokens=total_completion_tokens,
+ )
+
+ result = TranslationResult(
+ entry_id=entry_id,
+ target_lang=target_lang,
+ translation_html=accumulated_html,
+ status="success",
+ provider=provider.name,
+ model=provider.model,
+ )
+ save_agent_result(result)
+ yield {
+ "type": "complete",
+ "result": result.model_dump(),
+ }
+
+ except ProviderNotFoundError:
+ result = TranslationResult(
+ entry_id=entry_id,
+ target_lang=target_lang,
+ translation_html="",
+ status="failure",
+ provider=provider_name or "unknown",
+ model=model_name or "unknown",
+ )
+ save_agent_result(result)
+ yield {"type": "complete", "result": result.model_dump()}
+
+ except LLMProviderError:
+ result = TranslationResult(
+ entry_id=entry_id,
+ target_lang=target_lang,
+ translation_html="",
+ status="failure",
+ provider=provider_name or "unknown",
+ model=model_name or "unknown",
+ )
+ save_agent_result(result)
+ yield {"type": "complete", "result": result.model_dump()}
+
+ except Exception:
+ result = TranslationResult(
+ entry_id=entry_id,
+ target_lang=target_lang,
+ translation_html="",
+ status="failure",
+ provider=provider_name or "unknown",
+ model=model_name or "unknown",
+ )
+ save_agent_result(result)
+ yield {"type": "complete", "result": result.model_dump()}
+
+ @staticmethod
+ def _build_stream_preview_html(segments, resolved_segments: dict[int, str]) -> str:
+ parts: list[str] = []
+ for index, segment in enumerate(segments):
+ if index in resolved_segments:
+ parts.append(resolved_segments[index])
+ continue
+
+ if segment.kind == "heading":
+ parts.append(segment.source_text)
+ else:
+ parts.append(f'{segment.source_text}
')
+
+ return "\n\n".join(parts)
+
def _resolve_content(self, entry_id: str, reader_html: str) -> str:
"""
Resolve article content, preferring cleaned content over raw HTML.
diff --git a/backend/tests/test_http_contract.py b/backend/tests/test_http_contract.py
index 398745f..f00b2a3 100644
--- a/backend/tests/test_http_contract.py
+++ b/backend/tests/test_http_contract.py
@@ -1,5 +1,7 @@
from __future__ import annotations
+import importlib
+import json
from collections.abc import Iterator
import pytest
@@ -245,6 +247,63 @@ async def summarize(self, entry_id: str, content: str) -> dict:
assert response.json() == {"detail": "LLM provider rejected the request: 无效的令牌"}
+def test_stream_translation_emits_incremental_events(api_client: TestClient, monkeypatch) -> None:
+ translation_router_module = importlib.import_module("agent_translation.http.router")
+ from agent_translation.service import TranslationService
+
+ chunk_html = (
+ 'Hello
\n\n'
+ '你好
'
+ )
+
+ class FakeTranslationService(TranslationService):
+ async def stream_generate(self, request):
+ yield {
+ "type": "start",
+ "entry_id": request.entry_id,
+ "target_lang": request.target_lang,
+ "provider": "mock",
+ "model": "mock-model",
+ }
+ yield {
+ "type": "chunk",
+ "chunk_index": 0,
+ "delta_html": chunk_html,
+ "translation_html": chunk_html,
+ "failed": False,
+ }
+ yield {
+ "type": "complete",
+ "result": {
+ "entry_id": request.entry_id,
+ "target_lang": request.target_lang,
+ "translation_html": chunk_html,
+ "status": "success",
+ "provider": "mock",
+ "model": "mock-model",
+ },
+ }
+
+ monkeypatch.setattr(
+ translation_router_module,
+ "get_translation_service",
+ lambda: FakeTranslationService(),
+ )
+
+ response = api_client.post(
+ "/agents/translation/stream",
+ json={"entry_id": "article-1", "target_lang": "中文"},
+ )
+
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("text/event-stream")
+ body = response.text
+ assert 'event: start\ndata: {"type": "start"' in body
+ assert 'event: chunk\ndata: {"type": "chunk"' in body
+ assert json.dumps(chunk_html, ensure_ascii=False)[1:-1] in body
+ assert 'event: complete\ndata: {"type": "complete"' in body
+
+
def _article(article_id: str, reader_html: str) -> Entry:
return Entry(
id=article_id,
diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx
index 348f3b8..4f60062 100644
--- a/packages/ui/src/App.tsx
+++ b/packages/ui/src/App.tsx
@@ -55,7 +55,7 @@ import type {
import { translate } from "./i18n/messages";
import {
getApiErrorMessage,
- requestTranslation
+ requestTranslationStream
} from "./services/api";
import { openExternalUrl } from "./services/desktop";
import { readStoredBoolean, readStoredNumber, writeStoredBoolean, writeStoredNumber } from "./services/storage";
@@ -1052,6 +1052,49 @@ function ReaderDetail(props: {
return;
}
+ async function startStreamingTranslation(entryId: string, targetLang: string) {
+ setIsTranslating(true);
+ props.onUpdateEntry(entryId, (current) => ({
+ ...current,
+ translationStatus: "running",
+ translationHtml: ""
+ }));
+
+ try {
+ let finalStatus: string | null = null;
+ let finalHtml = "";
+ await requestTranslationStream(entryId, targetLang, {
+ onChunk: (event) => {
+ props.onUpdateEntry(entryId, (current) => ({
+ ...current,
+ translationStatus: "running",
+ translationHtml: event.translation_html
+ }));
+ },
+ onComplete: (result) => {
+ finalStatus = result.status;
+ finalHtml = result.translation_html;
+ props.onUpdateEntry(entryId, (current) => ({
+ ...current,
+ translationStatus: result.status,
+ translationHtml: result.translation_html
+ }));
+ }
+ });
+
+ if (finalStatus !== "success" || !finalHtml.trim()) {
+ props.onNotice(t("translationFailed"));
+ setTranslationMode("original");
+ }
+ } catch (error) {
+ props.onUpdateEntry(entryId, (current) => ({ ...current, translationStatus: "failure" }));
+ props.onNotice(getApiErrorMessage(error));
+ setTranslationMode("original");
+ } finally {
+ setIsTranslating(false);
+ }
+ }
+
// Reader mode: real backend translation with side-by-side original/translation.
if (state.readerMode === "reader") {
if (translationMode === "translation") {
@@ -1067,54 +1110,21 @@ function ReaderDetail(props: {
}
const entryId = entry.id;
const targetLang = detectTargetLang(entry.readerHtml);
- setIsTranslating(true);
- props.onUpdateEntry(entryId, (current) => ({ ...current, translationStatus: "running" }));
- try {
- const result = await requestTranslation(entryId, targetLang);
- if (result.status === "success" && result.translation_html.trim()) {
- props.onUpdateEntry(entryId, (current) => ({
- ...current,
- translationStatus: "success",
- translationHtml: result.translation_html
- }));
- } else {
- props.onUpdateEntry(entryId, (current) => ({ ...current, translationStatus: "failure" }));
- props.onNotice(t("translationFailed"));
- setTranslationMode("original");
- }
- } catch (error) {
- props.onUpdateEntry(entryId, (current) => ({ ...current, translationStatus: "failure" }));
- props.onNotice(getApiErrorMessage(error));
- setTranslationMode("original");
- } finally {
- setIsTranslating(false);
- }
+ await startStreamingTranslation(entryId, targetLang);
return;
}
- // Dual mode: preserve existing mock preview behavior unchanged.
+ // Dual mode: stream translated chunks into the secondary pane as they arrive.
if (translationMode === "original") {
setTranslationMode("translation");
- // 如果已有翻译,直接显示
if (entry.translationHtml) {
props.onUpdateEntry(entry.id, (current) => ({ ...current, translationStatus: "success" }));
return;
}
- // 否则调用翻译 API
- props.onUpdateEntry(entry.id, (current) => ({ ...current, translationStatus: "running" }));
- import("./services/api").then(({ translateArticle }) => {
- translateArticle(entry.id, detectTargetLang(entry.readerHtml))
- .then((result) => {
- props.onUpdateEntry(entry.id, (current) => ({
- ...current,
- translationHtml: result.translation_html,
- translationStatus: result.status === "success" ? "success" : "failure"
- }));
- })
- .catch(() => {
- props.onUpdateEntry(entry.id, (current) => ({ ...current, translationStatus: "failure" }));
- });
- });
+ if (isTranslating) {
+ return;
+ }
+ await startStreamingTranslation(entry.id, detectTargetLang(entry.readerHtml));
return;
}
setTranslationMode("original");
diff --git a/packages/ui/src/services/api/client.ts b/packages/ui/src/services/api/client.ts
index cba4a32..e3377e7 100644
--- a/packages/ui/src/services/api/client.ts
+++ b/packages/ui/src/services/api/client.ts
@@ -2,6 +2,8 @@ import { createClient } from "@mercury/ipc-client";
import { resolveBackendBaseUrl } from "./base-url";
+export const backendBaseUrl = resolveBackendBaseUrl();
+
export const mercuryClient = createClient({
- baseUrl: resolveBackendBaseUrl()
+ baseUrl: backendBaseUrl
});
diff --git a/packages/ui/src/services/api/index.ts b/packages/ui/src/services/api/index.ts
index c94e24c..18dff33 100644
--- a/packages/ui/src/services/api/index.ts
+++ b/packages/ui/src/services/api/index.ts
@@ -24,7 +24,7 @@ import {
import type { components } from "@mercury/shared-types";
import type { Entry, Feed, Tag } from "../../domain/types";
-import { mercuryClient } from "./client";
+import { backendBaseUrl, mercuryClient } from "./client";
import { toUiEntry, toUiFeed, toUiTag } from "./mappers";
export interface AppDataPayload {
@@ -77,6 +77,100 @@ export async function requestTranslation(
>("POST", "/agents/translation", { body: { entry_id: entryId, target_lang: targetLang } });
}
+interface TranslationStreamStartEvent {
+ type: "start";
+ entry_id: string;
+ target_lang: string;
+ provider: string;
+ model: string;
+}
+
+interface TranslationStreamChunkEvent {
+ type: "chunk";
+ chunk_index: number;
+ delta_html: string;
+ translation_html: string;
+ failed: boolean;
+}
+
+interface TranslationStreamCompleteEvent {
+ type: "complete";
+ result: components["schemas"]["TranslationResult"];
+}
+
+type TranslationStreamEvent =
+ | TranslationStreamStartEvent
+ | TranslationStreamChunkEvent
+ | TranslationStreamCompleteEvent;
+
+export async function requestTranslationStream(
+ entryId: string,
+ targetLang: string,
+ handlers: {
+ onStart?: (event: TranslationStreamStartEvent) => void;
+ onChunk: (event: TranslationStreamChunkEvent) => void;
+ onComplete: (result: components["schemas"]["TranslationResult"]) => void;
+ }
+): Promise {
+ const response = await fetch(`${backendBaseUrl}/agents/translation/stream`, {
+ method: "POST",
+ headers: {
+ "content-type": "application/json"
+ },
+ body: JSON.stringify({ entry_id: entryId, target_lang: targetLang })
+ });
+
+ if (!response.ok) {
+ const text = await response.text();
+ const parsed = text.length > 0 ? JSON.parse(text) : undefined;
+ throw new IpcError(response.status, response.url, parsed);
+ }
+
+ if (!response.body) {
+ throw new Error("Streaming response body is unavailable");
+ }
+
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = "";
+
+ while (true) {
+ const { value, done } = await reader.read();
+ buffer += decoder.decode(value, { stream: !done });
+
+ const events = buffer.split("\n\n");
+ buffer = events.pop() ?? "";
+
+ for (const rawEvent of events) {
+ const event = parseSseEvent(rawEvent);
+ if (!event) {
+ continue;
+ }
+ if (event.type === "start") {
+ handlers.onStart?.(event);
+ } else if (event.type === "chunk") {
+ handlers.onChunk(event);
+ } else if (event.type === "complete") {
+ handlers.onComplete(event.result);
+ }
+ }
+
+ if (done) {
+ if (buffer.trim()) {
+ const event = parseSseEvent(buffer);
+ if (event?.type === "start") {
+ handlers.onStart?.(event);
+ } else if (event?.type === "chunk") {
+ handlers.onChunk(event);
+ } else if (event?.type === "complete") {
+ handlers.onComplete(event.result);
+ }
+ }
+ break;
+ }
+ }
+}
+
export async function subscribeToFeed(url: string, sync = true): Promise {
return toUiFeed(await createFeed(mercuryClient, { url, sync }));
}
@@ -146,3 +240,30 @@ export function getApiErrorMessage(error: unknown): string {
function hasDetail(value: unknown): value is { detail?: unknown } {
return typeof value === "object" && value !== null && "detail" in value;
}
+
+function parseSseEvent(rawEvent: string): TranslationStreamEvent | null {
+ const lines = rawEvent
+ .split(/\r?\n/)
+ .map((line) => line.trimEnd())
+ .filter(Boolean);
+
+ let eventType = "message";
+ const dataLines: string[] = [];
+ for (const line of lines) {
+ if (line.startsWith("event:")) {
+ eventType = line.slice("event:".length).trim();
+ } else if (line.startsWith("data:")) {
+ dataLines.push(line.slice("data:".length).trimStart());
+ }
+ }
+
+ if (dataLines.length === 0) {
+ return null;
+ }
+
+ const parsed = JSON.parse(dataLines.join("\n")) as TranslationStreamEvent;
+ if (parsed.type !== eventType && parsed.type) {
+ return parsed;
+ }
+ return parsed;
+}