diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e401cc4..05417b6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [main] + branches: [main, develop] pull_request: - branches: [main] + branches: [main, develop] workflow_dispatch: env: diff --git a/backend/app/api/v1/entries.py b/backend/app/api/v1/entries.py index 308e8bf..a58ed96 100644 --- a/backend/app/api/v1/entries.py +++ b/backend/app/api/v1/entries.py @@ -9,7 +9,7 @@ from sqlalchemy import select from app.api.deps import DbSession, CurrentUser -from app.models.entry import EntryStatus +from app.models.entry import EntryStatus, TaskStatus from app.models.user_config import UserConfig from app.schemas.entry import ( EntryResponse, @@ -47,15 +47,15 @@ def entry_to_response(entry) -> EntryResponse: is_read=entry.is_read, marked_at=entry.marked_at, ai_summary=entry.ai_summary, - ai_content_type=entry.ai_content_type, ai_processed_at=entry.ai_processed_at, + task_interpret_status=getattr(entry, 'task_interpret_status', None), + task_translation_status=getattr(entry, 'task_translation_status', None), user_notes=entry.user_notes, exported_to_zotero=entry.exported_to_zotero, fetched_at=entry.fetched_at, created_at=entry.created_at, translated_abstract=getattr(entry, 'translated_abstract', None), brief_summary=getattr(entry, 'brief_summary', None), - translation_status=getattr(entry, 'translation_status', None), rss_source_name=source_name, ) @@ -69,6 +69,8 @@ async def list_entries( category: Optional[str] = None, period: Optional[Literal["today", "past"]] = None, is_read: Optional[bool] = None, + task_translation_status: Optional[str] = None, + task_interpret_status: Optional[str] = None, page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), ): @@ -81,6 +83,8 @@ async def list_entries( category=category, period=period, is_read=is_read, + task_translation_status=task_translation_status, + task_interpret_status=task_interpret_status, skip=skip, limit=page_size, ) @@ -220,11 +224,11 @@ async def update_entry_status( # 检查是否需要解读:未解读或解读失败都需要重新解读 needs_interpretation = ( not entry.ai_summary or - entry.ai_content_type == "error" or - entry.ai_content_type is None + entry.task_interpret_status == TaskStatus.FAILED.value or + entry.task_interpret_status is None ) # 排除正在解读中的 - if needs_interpretation and entry.ai_content_type != "interpreting": + if needs_interpretation and entry.task_interpret_status != TaskStatus.RUNNING.value: from app.tasks.fetch_rss import interpret_arxiv_entry asyncio.create_task(interpret_arxiv_entry(entry.id)) @@ -267,10 +271,10 @@ async def batch_update_status( if is_arxiv_entry(entry): needs_interpretation = ( not entry.ai_summary or - entry.ai_content_type == "error" or - entry.ai_content_type is None + entry.task_interpret_status == TaskStatus.FAILED.value or + entry.task_interpret_status is None ) - if needs_interpretation and entry.ai_content_type != "interpreting": + if needs_interpretation and entry.task_interpret_status != TaskStatus.RUNNING.value: asyncio.create_task(interpret_arxiv_entry(entry.id)) return EntryBatchResponse(updated_count=updated_count) @@ -341,7 +345,7 @@ async def reinterpret_entry( # 重置解读状态 entry.ai_summary = None - entry.ai_content_type = None + entry.task_interpret_status = None entry.ai_processed_at = None await db.commit() diff --git a/backend/app/api/v1/share.py b/backend/app/api/v1/share.py index ffe6a39..2675f19 100644 --- a/backend/app/api/v1/share.py +++ b/backend/app/api/v1/share.py @@ -139,12 +139,15 @@ async def get_share(share_code: str): is_read=entry.is_read, marked_at=entry.marked_at, ai_summary=entry.ai_summary, - ai_content_type=entry.ai_content_type, ai_processed_at=entry.ai_processed_at, + task_interpret_status=entry.task_interpret_status, + task_translation_status=entry.task_translation_status, user_notes=entry.user_notes, exported_to_zotero=entry.exported_to_zotero, fetched_at=entry.fetched_at, created_at=entry.created_at, + translated_abstract=entry.translated_abstract, + brief_summary=entry.brief_summary, rss_source_name=entry.rss_source_name or (entry.rss_source.name if entry.rss_source else None), ) ) diff --git a/backend/app/database.py b/backend/app/database.py index da5f5d6..a9162a1 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -84,6 +84,9 @@ async def run_migrations() -> None: ("user_configs", "ai_models_translation", "TEXT", "TEXT"), ("user_configs", "ai_models_interpret", "TEXT", "TEXT"), ("user_configs", "ai_models", "TEXT", "TEXT"), + # 统一任务状态新列 + ("entries", "task_translation_status", "VARCHAR(20)", "VARCHAR(20)"), + ("entries", "task_interpret_status", "VARCHAR(50)", "VARCHAR(50)"), ] async with engine.begin() as conn: @@ -119,6 +122,9 @@ async def run_migrations() -> None: # 运行任务结构迁移(flat list -> {model_ids, enabled}) await migrate_tasks_to_abstract_format() + # 迁移旧的任务状态列到新的统一列 + await migrate_to_task_status_columns() + async def migrate_ai_configs() -> None: """ @@ -385,6 +391,48 @@ async def migrate_tasks_to_abstract_format() -> None: print(f"Migration: migrated {migrated_count} user(s) to abstract task format") +async def migrate_to_task_status_columns() -> None: + """ + 将旧的 translation_status 和 ai_content_type 列数据迁移到新的统一任务状态列 + + 旧列 → 新列: + translation_status → task_translation_status (translating → running,其余不变) + ai_content_type → task_interpret_status (interpreting → running, arxiv_interpretation → completed, + error → failed, no_html → skipped) + + 迁移是幂等的:只更新新列为 NULL 且旧列不为 NULL 的行 + """ + from sqlalchemy import text + + async with async_session_maker() as db: + # 迁移 translation_status → task_translation_status + result = await db.execute(text(""" + UPDATE entries SET task_translation_status = CASE + WHEN translation_status = 'translating' THEN 'running' + ELSE translation_status + END + WHERE task_translation_status IS NULL AND translation_status IS NOT NULL + """)) + if result.rowcount > 0: + print(f"Migration: migrated {result.rowcount} entries translation_status -> task_translation_status") + + # 迁移 ai_content_type → task_interpret_status + result = await db.execute(text(""" + UPDATE entries SET task_interpret_status = CASE + WHEN ai_content_type = 'interpreting' THEN 'running' + WHEN ai_content_type = 'arxiv_interpretation' THEN 'completed' + WHEN ai_content_type = 'error' THEN 'failed' + WHEN ai_content_type = 'no_html' THEN 'skipped' + ELSE ai_content_type + END + WHERE task_interpret_status IS NULL AND ai_content_type IS NOT NULL + """)) + if result.rowcount > 0: + print(f"Migration: migrated {result.rowcount} entries ai_content_type -> task_interpret_status") + + await db.commit() + + async def close_db() -> None: """关闭数据库连接""" await engine.dispose() diff --git a/backend/app/integrations/zotero.py b/backend/app/integrations/zotero.py index 38fdb64..ebccb8c 100644 --- a/backend/app/integrations/zotero.py +++ b/backend/app/integrations/zotero.py @@ -119,7 +119,7 @@ def create_item(self, entry: Entry, collection_name: Optional[str] = None) -> Op """ try: # 根据 AI 内容类型和链接选择 Zotero item type - item_type = self._get_zotero_item_type(entry.ai_content_type, entry.link) + item_type = self._get_zotero_item_type(entry.task_interpret_status, entry.link) template = self.client.item_template(item_type) # 通用字段 @@ -184,7 +184,7 @@ def create_item(self, entry: Entry, collection_name: Optional[str] = None) -> Op extra_parts = [] # ArXiv 论文:优先使用深度解读,其次是翻译摘要 - if entry.ai_content_type == "arxiv_interpretation" and entry.ai_summary: + if entry.task_interpret_status == "completed" and entry.ai_summary: extra_parts.append(f"【AI 深度解读】\n{entry.ai_summary}") elif entry.translated_abstract: # 有翻译摘要 @@ -200,8 +200,8 @@ def create_item(self, entry: Entry, collection_name: Optional[str] = None) -> Op # 添加标签 tags = [] - if entry.ai_content_type: - tags.append({"tag": entry.ai_content_type}) + if entry.task_interpret_status: + tags.append({"tag": entry.task_interpret_status}) if entry.rss_source: tags.append({"tag": entry.rss_source.name}) tags.append({"tag": "Focus"}) diff --git a/backend/app/models/entry.py b/backend/app/models/entry.py index 1cefa04..f65d7a9 100644 --- a/backend/app/models/entry.py +++ b/backend/app/models/entry.py @@ -22,12 +22,17 @@ class EntryStatus(str, Enum): ALL = "all" # 全部状态(不过滤) -class TranslationStatus(str, Enum): - """ArXiv 翻译状态""" - PENDING = "pending" # 待翻译 - TRANSLATING = "translating" # 翻译中 - COMPLETED = "completed" # 已完成 - FAILED = "failed" # 翻译失败 +class TaskStatus(str, Enum): + """统一任务处理状态""" + PENDING = "pending" # 待处理 + RUNNING = "running" # 处理中(原 translating / interpreting) + COMPLETED = "completed" # 已完成(原 completed / arxiv_interpretation) + FAILED = "failed" # 处理失败(原 failed / error) + SKIPPED = "skipped" # 跳过(原 no_html,无法处理) + + +# 向后兼容别名 +TranslationStatus = TaskStatus class Entry(Base): @@ -63,13 +68,19 @@ class Entry(Base): # AI 生成内容 ai_summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True) - ai_content_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) ai_processed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) - # ArXiv 翻译 + # 旧列(已弃用,由迁移脚本复制到新列后不再使用) + ai_content_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) + translation_status: Mapped[Optional[str]] = mapped_column(String(20), nullable=True, index=True) + + # 统一任务状态(新列,取代 ai_content_type 和 translation_status) + task_interpret_status: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) # 解读任务状态 + task_translation_status: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) # 翻译任务状态 + + # ArXiv 翻译结果 translated_abstract: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # 翻译后的摘要 brief_summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # 简要总结(帮助快速判断) - translation_status: Mapped[Optional[str]] = mapped_column(String(20), nullable=True, index=True) # 翻译状态 # 用户笔记 user_notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True) @@ -103,8 +114,9 @@ class Entry(Base): Index("ix_entry_source_name", "rss_source_name"), # 源名称索引,用于筛选孤立条目 # Focus 页面优化:按状态、源、显示顺序查询 Index("ix_entry_unread_source_order", "status", "rss_source_id", "display_order"), - # AI 解读状态查询 - Index("ix_entry_ai_status", "ai_content_type", "ai_processed_at"), + # AI 解读状态查询(新列) + Index("ix_entry_task_interpret_status", "task_interpret_status", "ai_processed_at"), + Index("ix_entry_task_translation_status", "task_translation_status"), # 唯一约束:同一源下同一内容只能有一条 # 注意:当 rss_source_id 为 NULL 时(源已删除),此约束不生效 # SQLite 和 PostgreSQL 都将 NULL 视为不相等,所以孤立条目不受约束影响 diff --git a/backend/app/schemas/entry.py b/backend/app/schemas/entry.py index 9a2b4fd..43e35ba 100644 --- a/backend/app/schemas/entry.py +++ b/backend/app/schemas/entry.py @@ -32,17 +32,17 @@ class EntryResponse(BaseModel): is_read: bool marked_at: Optional[datetime] ai_summary: Optional[str] - ai_content_type: Optional[str] ai_processed_at: Optional[datetime] + task_interpret_status: Optional[str] = None # 统一任务状态:pending, running, completed, failed, skipped + task_translation_status: Optional[str] = None # 统一任务状态:pending, running, completed, failed user_notes: Optional[str] exported_to_zotero: bool fetched_at: datetime created_at: datetime - # ArXiv 翻译 + # ArXiv 翻译结果 translated_abstract: Optional[str] = None brief_summary: Optional[str] = None # 简要总结(帮助快速判断) - translation_status: Optional[str] = None # pending, translating, completed, failed # 关联信息(即使源被删除,仍保留源名称) rss_source_name: Optional[str] = None diff --git a/backend/app/services/ai_executor.py b/backend/app/services/ai_executor.py index 04aef89..9acfd80 100644 --- a/backend/app/services/ai_executor.py +++ b/backend/app/services/ai_executor.py @@ -183,7 +183,7 @@ def __init__(self, primary: AIModelConfig, fallbacks: Optional[List[AIModelConfi """ self.models = [primary] + (fallbacks or []) self.current_index = 0 - model_names = [f"{m.name}({m.model})" for m in self.models] + model_names = [f"{m.name}({m.model}) via {m.provider}" for m in self.models] logger.info(f"AIModelExecutor initialized with models: {' → '.join(model_names)}") def _create_client(self, model_config: AIModelConfig) -> AsyncOpenAI: @@ -241,7 +241,7 @@ async def execute( for i, model_config in enumerate(self.models): try: client = self._create_client(model_config) - logger.info(f"[{task_name}] Using model: {model_config.name} ({model_config.model})") + logger.info(f"[{task_name}] Using model: {model_config.name} ({model_config.model}) via {model_config.provider}") result = await task_func(client, model_config.model) return result except Exception as e: @@ -252,7 +252,7 @@ async def execute( # 检查是否应该切换模型 if self._should_switch(e) and i < len(self.models) - 1: next_model = self.models[i + 1] - logger.info(f"[{task_name}] Switching from '{model_config.name}' to '{next_model.name}'") + logger.info(f"[{task_name}] Switching from '{model_config.name}' ({model_config.provider}) to '{next_model.name}' ({next_model.provider})") continue # 不应切换或已是最后一个模型,直接抛出 diff --git a/backend/app/services/entry_service.py b/backend/app/services/entry_service.py index 329ecb5..4232fea 100644 --- a/backend/app/services/entry_service.py +++ b/backend/app/services/entry_service.py @@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.models.entry import Entry, EntryStatus +from app.models.entry import Entry, EntryStatus, TaskStatus from app.models.rss import RssSource from app.utils.logger import logger from app.utils.datetime_utils import get_today_start_utc @@ -40,6 +40,8 @@ async def get_entries( exclude_untranslated_arxiv: bool = False, user_id: Optional[int] = None, only_subscribed: bool = False, + task_translation_status: Optional[str] = None, + task_interpret_status: Optional[str] = None, ) -> Tuple[List[Entry], int]: """Get entry list. @@ -48,7 +50,6 @@ async def get_entries( user_id: User ID for filtering subscribed sources. only_subscribed: If True, only return entries from user's subscribed sources. """ - from app.models.entry import TranslationStatus from app.models.subscription import UserRssSubscription query = select(Entry).options(selectinload(Entry.rss_source)) @@ -86,10 +87,24 @@ async def get_entries( query = query.where( or_( ~Entry.link.contains('arxiv.org'), - Entry.translation_status == TranslationStatus.COMPLETED.value, + Entry.task_translation_status == TaskStatus.COMPLETED.value, ) ) + # Filter by task translation status + if task_translation_status: + if task_translation_status == "none": + query = query.where(Entry.task_translation_status.is_(None)) + else: + query = query.where(Entry.task_translation_status == task_translation_status) + + # Filter by task interpretation status + if task_interpret_status: + if task_interpret_status == "none": + query = query.where(Entry.task_interpret_status.is_(None)) + else: + query = query.where(Entry.task_interpret_status == task_interpret_status) + count_query = select(func.count()).select_from(query.subquery()) total = (await db.execute(count_query)).scalar() or 0 diff --git a/backend/app/tasks/fetch_rss.py b/backend/app/tasks/fetch_rss.py index 69ece30..16ea22a 100644 --- a/backend/app/tasks/fetch_rss.py +++ b/backend/app/tasks/fetch_rss.py @@ -10,7 +10,7 @@ from app.database import async_session_maker from app.models.rss import RssSource -from app.models.entry import Entry +from app.models.entry import Entry, TaskStatus from app.models.user import User from app.services.rss_service import fetch_rss_entries from app.services.arxiv_service import is_arxiv_entry, ArxivInterpreter @@ -135,16 +135,15 @@ async def trigger_arxiv_translation(db, source_id: int): # 查找该源下待翻译的 ArXiv 文章(pending 或未设置状态且未翻译) from sqlalchemy import or_ - from app.models.entry import TranslationStatus result = await db.execute( select(Entry).where( Entry.rss_source_id == source_id, Entry.translated_abstract.is_(None), # 未翻译 or_( - Entry.translation_status.is_(None), - Entry.translation_status == TranslationStatus.PENDING.value, - Entry.translation_status == TranslationStatus.FAILED.value, # 重试失败的 + Entry.task_translation_status.is_(None), + Entry.task_translation_status == TaskStatus.PENDING.value, + Entry.task_translation_status == TaskStatus.FAILED.value, # 重试失败的 ) ) ) @@ -157,8 +156,8 @@ async def trigger_arxiv_translation(db, source_id: int): # 标记为待翻译状态 for entry in arxiv_entries: - if not entry.translation_status: - entry.translation_status = TranslationStatus.PENDING.value + if not entry.task_translation_status: + entry.task_translation_status = TaskStatus.PENDING.value await db.commit() logger.info(f"Found {len(arxiv_entries)} ArXiv entries to translate for source {source_id}") @@ -199,7 +198,6 @@ async def translate_abstract(entry_id: int, config=None): config: 用户配置(可选,如果不传则从数据库获取) """ from app.services.arxiv_service import ArxivTranslator - from app.models.entry import TranslationStatus async with async_session_maker() as db: result = await db.execute(select(Entry).where(Entry.id == entry_id)) @@ -228,7 +226,7 @@ async def translate_abstract(entry_id: int, config=None): return try: - entry.translation_status = TranslationStatus.TRANSLATING.value + entry.task_translation_status = TaskStatus.RUNNING.value await db.commit() logger.info(f"Translating entry {entry_id} using model '{config.ai_model}': '{entry.title[:50]}...'") @@ -241,14 +239,14 @@ async def translate_abstract(entry_id: int, config=None): entry.translated_abstract = translated entry.brief_summary = brief_summary - entry.translation_status = TranslationStatus.COMPLETED.value + entry.task_translation_status = TaskStatus.COMPLETED.value await db.commit() logger.info(f"Completed translation for entry {entry_id}") except Exception as e: logger.error(f"Failed to translate entry {entry_id}: {e}") - entry.translation_status = TranslationStatus.FAILED.value + entry.task_translation_status = TaskStatus.FAILED.value await db.commit() @@ -257,11 +255,11 @@ async def scan_pending_arxiv_tasks(): 启动时扫描未完成的 ArXiv 翻译和解读任务 扫描并处理: - 1. 未翻译的 ArXiv 文章(translation_status 为 pending/failed/translating) + 1. 未翻译的 ArXiv 文章(task_translation_status 为 pending/failed/running) 2. 已翻译但缺少简要总结的 ArXiv 文章 - 3. 已保存但未解读的 ArXiv 文章(status=interested 且 ai_content_type 为空或 interpreting) + 3. 已保存但未解读的 ArXiv 文章(status=interested 且 task_interpret_status 为空或 running) """ - from app.models.entry import EntryStatus, TranslationStatus + from app.models.entry import EntryStatus from app.services.arxiv_service import is_arxiv_entry, validate_ai_api_key from app.services.ai_executor import is_task_enabled @@ -303,11 +301,11 @@ async def scan_pending_arxiv_tasks(): .options(selectinload(Entry.rss_source)) .where( or_( - Entry.translation_status.is_(None), - Entry.translation_status == TranslationStatus.PENDING.value, - Entry.translation_status == TranslationStatus.FAILED.value, - Entry.translation_status == TranslationStatus.TRANSLATING.value, # 可能因重启而中断 - Entry.translation_status == TranslationStatus.COMPLETED.value, # 已完成但可能内容为空 + Entry.task_translation_status.is_(None), + Entry.task_translation_status == TaskStatus.PENDING.value, + Entry.task_translation_status == TaskStatus.FAILED.value, + Entry.task_translation_status == TaskStatus.RUNNING.value, # 可能因重启而中断 + Entry.task_translation_status == TaskStatus.COMPLETED.value, # 已完成但可能内容为空 ) ) ) @@ -335,7 +333,7 @@ def needs_translation(entry: Entry) -> bool: logger.info(f"Found {len(untranslated)} untranslated ArXiv entries, starting translation...") # 标记为待翻译 for entry in untranslated: - entry.translation_status = TranslationStatus.PENDING.value + entry.task_translation_status = TaskStatus.PENDING.value await db.commit() # 启动后台翻译任务 asyncio.create_task(batch_translate_abstracts(untranslated, config)) @@ -348,19 +346,19 @@ def needs_translation(entry: Entry) -> bool: logger.info("Auto ArXiv interpretation disabled, skipping interpretation scan") uninterpreted = [] else: - # 注意:no_html 状态不在此列表中,因为没有 HTML 版本的论文无法解读 + # 注意:skipped 状态不在此列表中,因为没有 HTML 版本的论文无法解读 interpretation_result = await db.execute( select(Entry) .options(selectinload(Entry.rss_source)) .where( Entry.status == EntryStatus.INTERESTED, or_( - # 未解读:ai_content_type 为空 - Entry.ai_content_type.is_(None), - # 解读中断:因重启而停在 interpreting 状态 - Entry.ai_content_type == "interpreting", - # 解读失败:error 状态,需要重试(不包括 no_html) - Entry.ai_content_type == "error", + # 未解读:task_interpret_status 为空 + Entry.task_interpret_status.is_(None), + # 解读中断:因重启而停在 running 状态 + Entry.task_interpret_status == TaskStatus.RUNNING.value, + # 解读失败:failed 状态,需要重试(不包括 skipped) + Entry.task_interpret_status == TaskStatus.FAILED.value, ) ) ) @@ -373,7 +371,7 @@ def needs_translation(entry: Entry) -> bool: for entry in uninterpreted: # 重置解读相关字段,确保重新开始 entry.ai_summary = None - entry.ai_content_type = None + entry.task_interpret_status = None entry.ai_processed_at = None await db.commit() @@ -408,12 +406,12 @@ async def interpret_arxiv_entry(entry_id: int): return # 跳过已成功解读的文章 - if entry.ai_content_type == "arxiv_interpretation" and entry.ai_summary: + if entry.task_interpret_status == TaskStatus.COMPLETED.value and entry.ai_summary: logger.info(f"Entry {entry_id} already interpreted, skipping") return - # 跳过正在解读中的文章(但如果是从启动扫描来的 interpreting 状态,需要继续) - # 这里不跳过 interpreting 状态,因为可能是重启后恢复的任务 + # 跳过正在解读中的文章(但如果是从启动扫描来的 running 状态,需要继续) + # 这里不跳过 running 状态,因为可能是重启后恢复的任务 # 获取用户配置(使用默认用户) user_result = await db.execute(select(User).limit(1)) @@ -432,7 +430,7 @@ async def interpret_arxiv_entry(entry_id: int): try: # 更新状态:解读中 - entry.ai_content_type = "interpreting" + entry.task_interpret_status = TaskStatus.RUNNING.value await db.commit() logger.info(f"Starting interpretation for entry {entry_id}: '{entry.title[:50]}...'") @@ -443,7 +441,7 @@ async def interpret_arxiv_entry(entry_id: int): # 保存结果到数据库 entry.ai_summary = interpretation - entry.ai_content_type = "arxiv_interpretation" + entry.task_interpret_status = TaskStatus.COMPLETED.value entry.ai_processed_at = datetime.utcnow() await db.commit() @@ -451,7 +449,7 @@ async def interpret_arxiv_entry(entry_id: int): except NoHtmlAvailableError as e: logger.info(f"Entry {entry_id} has no HTML version: {e}") - entry.ai_content_type = "no_html" + entry.task_interpret_status = TaskStatus.SKIPPED.value entry.ai_summary = None # 不保存错误信息,前端显示翻译内容 await db.commit() @@ -471,5 +469,5 @@ async def interpret_arxiv_entry(entry_id: int): else: logger.error(f"Failed to interpret entry {entry_id}: {e}") entry.ai_summary = f"解读失败: {error_msg}" - entry.ai_content_type = "error" + entry.task_interpret_status = TaskStatus.FAILED.value await db.commit() diff --git a/frontend/src/api/entries.ts b/frontend/src/api/entries.ts index fce5edf..805cb5f 100644 --- a/frontend/src/api/entries.ts +++ b/frontend/src/api/entries.ts @@ -7,6 +7,8 @@ interface ListEntriesParams { category?: string; period?: 'today' | 'past'; is_read?: boolean; + task_translation_status?: string; + task_interpret_status?: string; page?: number; page_size?: number; } diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 7f1af37..c43cc28 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -253,6 +253,15 @@ "translated": "Translated", "pendingTranslation": "Pending", "allStatuses": "All", + "translationStatus": "Translation", + "interpretStatus": "Interpretation", + "taskNone": "Not Started", + "taskPending": "Pending", + "taskRunning": "Processing", + "taskCompleted": "Completed", + "taskFailed": "Failed", + "taskSkipped": "Skipped", + "refreshed": "Refreshed", "loadMore": "Load More", "loadingMore": "Loading..." }, diff --git a/frontend/src/i18n/locales/zh.json b/frontend/src/i18n/locales/zh.json index b6b9d5b..6460d1d 100644 --- a/frontend/src/i18n/locales/zh.json +++ b/frontend/src/i18n/locales/zh.json @@ -253,6 +253,15 @@ "translated": "已翻译", "pendingTranslation": "待翻译", "allStatuses": "全部", + "translationStatus": "翻译状态", + "interpretStatus": "解读状态", + "taskNone": "未开始", + "taskPending": "等待中", + "taskRunning": "处理中", + "taskCompleted": "已完成", + "taskFailed": "失败", + "taskSkipped": "已跳过", + "refreshed": "已刷新", "loadMore": "加载更多", "loadingMore": "加载中..." }, diff --git a/frontend/src/types/entry.ts b/frontend/src/types/entry.ts index fdfe6d6..bfac551 100644 --- a/frontend/src/types/entry.ts +++ b/frontend/src/types/entry.ts @@ -1,8 +1,11 @@ // Backend entry status enum values export type EntryStatus = 'unread' | 'interested' | 'trash' | 'favorite' | 'archived'; -// Translation status for ArXiv articles -export type TranslationStatus = 'pending' | 'translating' | 'completed' | 'failed'; +// Unified task processing status +export type TaskStatus = 'pending' | 'running' | 'completed' | 'failed' | 'skipped'; + +// Backward-compatible alias +export type TranslationStatus = TaskStatus; // Backend entry response type export interface Entry { @@ -18,8 +21,9 @@ export interface Entry { is_read: boolean; marked_at: string | null; ai_summary: string | null; - ai_content_type: string | null; ai_processed_at: string | null; + task_interpret_status: TaskStatus | null; + task_translation_status: TaskStatus | null; user_notes: string | null; exported_to_zotero: boolean; fetched_at: string; @@ -27,7 +31,6 @@ export interface Entry { rss_source_name: string | null; translated_abstract: string | null; brief_summary: string | null; - translation_status: TranslationStatus | null; } export interface EntryListResponse { diff --git a/frontend/src/views/HomeView/index.tsx b/frontend/src/views/HomeView/index.tsx index 896e997..060acda 100644 --- a/frontend/src/views/HomeView/index.tsx +++ b/frontend/src/views/HomeView/index.tsx @@ -604,10 +604,10 @@ export function HomeView({ darkMode, isActive = true }: HomeViewProps) { const isArxiv = isArxivArticle(current); const hasInterpretation = current._entry?.ai_summary && - current._entry?.ai_content_type === "arxiv_interpretation"; - const isInterpreting = current._entry?.ai_content_type === "interpreting"; - const isInterpretFailed = current._entry?.ai_content_type === "error"; - const isNoHtml = current._entry?.ai_content_type === "no_html"; + current._entry?.task_interpret_status === "completed"; + const isInterpreting = current._entry?.task_interpret_status === "running"; + const isInterpretFailed = current._entry?.task_interpret_status === "failed"; + const isNoHtml = current._entry?.task_interpret_status === "skipped"; const translatedAbstract = current._entry?.translated_abstract; const briefSummary = current._entry?.brief_summary; diff --git a/frontend/src/views/LibraryView/index.tsx b/frontend/src/views/LibraryView/index.tsx index 64637c9..3a9636c 100644 --- a/frontend/src/views/LibraryView/index.tsx +++ b/frontend/src/views/LibraryView/index.tsx @@ -39,6 +39,8 @@ export function LibraryView({ darkMode, onOpenArticle, refreshKey = 0 }: Library const [appliedYearFilter, setAppliedYearFilter] = useState('all'); const [appliedLetterFilter, setAppliedLetterFilter] = useState('all'); const [appliedSearch, setAppliedSearch] = useState(''); + const [appliedTranslationFilter, setAppliedTranslationFilter] = useState('all'); + const [appliedInterpretFilter, setAppliedInterpretFilter] = useState('all'); // Temporary filter states - these are used for UI interaction before applying const [tempStatusFilters, setTempStatusFilters] = useState>(new Set(['interested'])); @@ -46,6 +48,8 @@ export function LibraryView({ darkMode, onOpenArticle, refreshKey = 0 }: Library const [tempYearFilter, setTempYearFilter] = useState('all'); const [tempLetterFilter, setTempLetterFilter] = useState('all'); const [tempSearch, setTempSearch] = useState(''); + const [tempTranslationFilter, setTempTranslationFilter] = useState('all'); + const [tempInterpretFilter, setTempInterpretFilter] = useState('all'); // Sort states const [sortField, setSortField] = useState('date'); @@ -82,10 +86,19 @@ export function LibraryView({ darkMode, onOpenArticle, refreshKey = 0 }: Library } try { + // Build task status filter params + const taskFilterParams: Record = {}; + if (appliedTranslationFilter !== 'all') { + taskFilterParams.task_translation_status = appliedTranslationFilter; + } + if (appliedInterpretFilter !== 'all') { + taskFilterParams.task_interpret_status = appliedInterpretFilter; + } + // Phase 2: Handle 'all' status - call single endpoint instead of parallel requests if (appliedStatusFilters.has('all')) { // Call single 'all' endpoint (backend handles fetching all statuses) - const response = await entriesApi.list({ status: 'all' as any, page: currentPage, page_size: pageSize }); + const response = await entriesApi.list({ status: 'all' as any, page: currentPage, page_size: pageSize, ...taskFilterParams }); const mappedArticles = response.items.map(mapEntryToArticle); if (append) { @@ -104,7 +117,7 @@ export function LibraryView({ darkMode, onOpenArticle, refreshKey = 0 }: Library const responses = await Promise.all( statuses.map(status => - entriesApi.list({ status, page: currentPage, page_size: pageSize }) + entriesApi.list({ status, page: currentPage, page_size: pageSize, ...taskFilterParams }) ) ); @@ -127,7 +140,7 @@ export function LibraryView({ darkMode, onOpenArticle, refreshKey = 0 }: Library setLoading(false); setLoadingMore(false); } - }, [page, appliedStatusFilters]); + }, [page, appliedStatusFilters, appliedTranslationFilter, appliedInterpretFilter]); // Initial load - only fetch once useEffect(() => { @@ -157,7 +170,7 @@ export function LibraryView({ darkMode, onOpenArticle, refreshKey = 0 }: Library if (hasLoaded.current) { fetchEntries(); } - }, [appliedStatusFilters]); // eslint-disable-line react-hooks/exhaustive-deps + }, [appliedStatusFilters, appliedTranslationFilter, appliedInterpretFilter]); // eslint-disable-line react-hooks/exhaustive-deps // Get unique categories and years for filters const categories = useMemo(() => { @@ -288,7 +301,9 @@ export function LibraryView({ darkMode, onOpenArticle, refreshKey = 0 }: Library tempCategoryFilter !== appliedCategoryFilter || tempYearFilter !== appliedYearFilter || tempLetterFilter !== appliedLetterFilter || - tempSearch !== appliedSearch + tempSearch !== appliedSearch || + tempTranslationFilter !== appliedTranslationFilter || + tempInterpretFilter !== appliedInterpretFilter ); }; @@ -303,6 +318,8 @@ export function LibraryView({ darkMode, onOpenArticle, refreshKey = 0 }: Library setAppliedYearFilter(tempYearFilter); setAppliedLetterFilter(tempLetterFilter); setAppliedSearch(tempSearch); + setAppliedTranslationFilter(tempTranslationFilter); + setAppliedInterpretFilter(tempInterpretFilter); // Reset pagination setPage(1); @@ -322,12 +339,16 @@ export function LibraryView({ darkMode, onOpenArticle, refreshKey = 0 }: Library setTempYearFilter('all'); setTempLetterFilter('all'); setTempSearch(''); + setTempTranslationFilter('all'); + setTempInterpretFilter('all'); setAppliedStatusFilters(defaultStatusFilters); setAppliedCategoryFilter('all'); setAppliedYearFilter('all'); setAppliedLetterFilter('all'); setAppliedSearch(''); + setAppliedTranslationFilter('all'); + setAppliedInterpretFilter('all'); setPage(1); setCurrentPage(1); @@ -341,6 +362,8 @@ export function LibraryView({ darkMode, onOpenArticle, refreshKey = 0 }: Library setTempYearFilter(appliedYearFilter); setTempLetterFilter(appliedLetterFilter); setTempSearch(appliedSearch); + setTempTranslationFilter(appliedTranslationFilter); + setTempInterpretFilter(appliedInterpretFilter); setShowFilters(false); }; @@ -384,9 +407,11 @@ export function LibraryView({ darkMode, onOpenArticle, refreshKey = 0 }: Library if (appliedYearFilter !== 'all') count++; if (appliedLetterFilter !== 'all') count++; if (appliedSearch.trim()) count++; + if (appliedTranslationFilter !== 'all') count++; + if (appliedInterpretFilter !== 'all') count++; return count; - }, [appliedStatusFilters, appliedCategoryFilter, appliedYearFilter, appliedLetterFilter, appliedSearch]); + }, [appliedStatusFilters, appliedCategoryFilter, appliedYearFilter, appliedLetterFilter, appliedSearch, appliedTranslationFilter, appliedInterpretFilter]); const toggleSelect = (id: string, e: React.MouseEvent) => { e.stopPropagation(); @@ -475,14 +500,14 @@ export function LibraryView({ darkMode, onOpenArticle, refreshKey = 0 }: Library e.stopPropagation(); if (!article._entry?.id) return; - const isReinterpret = article._entry?.ai_content_type === 'error'; + const isReinterpret = article._entry?.task_interpret_status === 'failed'; try { await entriesApi.reinterpret(article._entry.id); // Update local state to show interpreting status setArticles(prev => prev.map(a => a.id === article.id - ? { ...a, _entry: { ...a._entry!, ai_content_type: 'interpreting', ai_summary: null } } + ? { ...a, _entry: { ...a._entry!, task_interpret_status: 'running', ai_summary: null } } : a )); showToast(isReinterpret ? t('home.reinterpretStarted') : t('home.interpretStarted'), 'success'); @@ -554,6 +579,24 @@ export function LibraryView({ darkMode, onOpenArticle, refreshKey = 0 }: Library + + diff --git a/frontend/src/views/ReadingModal/index.tsx b/frontend/src/views/ReadingModal/index.tsx index ccf897b..89491fe 100644 --- a/frontend/src/views/ReadingModal/index.tsx +++ b/frontend/src/views/ReadingModal/index.tsx @@ -36,13 +36,13 @@ export function ReadingModal({ article, onClose, darkMode, onDiscard, onFavorite const isArxiv = isArxivArticle(article); // ArXiv status checks - const hasInterpretation = article._entry?.ai_summary && article._entry?.ai_content_type === 'arxiv_interpretation'; - const isInterpreting = article._entry?.ai_content_type === 'interpreting'; - const isInterpretFailed = article._entry?.ai_content_type === 'error'; - const isNoHtml = article._entry?.ai_content_type === 'no_html'; + const hasInterpretation = article._entry?.ai_summary && article._entry?.task_interpret_status === 'completed'; + const isInterpreting = article._entry?.task_interpret_status === 'running'; + const isInterpretFailed = article._entry?.task_interpret_status === 'failed'; + const isNoHtml = article._entry?.task_interpret_status === 'skipped'; const translatedAbstract = article._entry?.translated_abstract; - const translationStatus = article._entry?.translation_status; - const isTranslating = translationStatus === 'translating'; + const translationStatus = article._entry?.task_translation_status; + const isTranslating = translationStatus === 'running'; const isTranslationFailed = translationStatus === 'failed'; const isTranslationCompleted = translationStatus === 'completed'; const briefSummary = article._entry?.brief_summary; diff --git a/frontend/src/views/ShareView/index.tsx b/frontend/src/views/ShareView/index.tsx index db69851..7b8e0b1 100644 --- a/frontend/src/views/ShareView/index.tsx +++ b/frontend/src/views/ShareView/index.tsx @@ -41,10 +41,10 @@ export function ShareView({ code, darkMode, fontClass = 'font-sans' }: ShareView // Render entry content based on ArXiv status const renderEntryContent = (entry: Entry) => { const isArxiv = isArxivEntry(entry); - const hasInterpretation = entry.ai_summary && entry.ai_content_type === 'arxiv_interpretation'; + const hasInterpretation = entry.ai_summary && entry.task_interpret_status === 'completed'; const translatedAbstract = entry.translated_abstract; const briefSummary = entry.brief_summary; - const isTranslationCompleted = entry.translation_status === 'completed'; + const isTranslationCompleted = entry.task_translation_status === 'completed'; if (!isArxiv) { return ; @@ -228,7 +228,7 @@ export function ShareView({ code, darkMode, fontClass = 'font-sans' }: ShareView if (share_type === 'entries' && entries && entries.length > 0) { const current = entries[currentIndex]; const isArxiv = isArxivEntry(current); - const hasInterpretation = current.ai_summary && current.ai_content_type === 'arxiv_interpretation'; + const hasInterpretation = current.ai_summary && current.task_interpret_status === 'completed'; return (
@@ -267,13 +267,13 @@ export function ShareView({ code, darkMode, fontClass = 'font-sans' }: ShareView {hasInterpretation ? t("home.interpreted") - : current.translation_status === 'completed' + : current.task_translation_status === 'completed' ? t("library.translated") : "ArXiv"} diff --git a/frontend/src/views/SourcesView/index.tsx b/frontend/src/views/SourcesView/index.tsx index bbfc424..d3f16b6 100644 --- a/frontend/src/views/SourcesView/index.tsx +++ b/frontend/src/views/SourcesView/index.tsx @@ -389,8 +389,10 @@ export function SourcesView({ darkMode }: SourcesViewProps) { isOpen={addModalOpen} onClose={() => setAddModalOpen(false)} onSuccess={() => { + // Invalidate both tabs cache and refresh current tab hasLoadedTab.current.my = false; - fetchFeeds('my'); + hasLoadedTab.current.market = false; + fetchFeeds(); }} />