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; +}