diff --git a/dataflow_agent/toolkits/multimodaltool/req_img.py b/dataflow_agent/toolkits/multimodaltool/req_img.py index 5b209e85..beeb2298 100644 --- a/dataflow_agent/toolkits/multimodaltool/req_img.py +++ b/dataflow_agent/toolkits/multimodaltool/req_img.py @@ -30,7 +30,10 @@ async def _post_stream_and_accumulate( log.info(f"POST STREAM {url}") - async with httpx.AsyncClient(timeout=httpx.Timeout(timeout), http2=False) as client: + async with httpx.AsyncClient( + timeout=httpx.Timeout(timeout, connect=30.0, read=timeout), + http2=False, + ) as client: try: full_content = [] async with client.stream("POST", url, headers=headers, json=payload) as response: diff --git a/fastapi_app/main.py b/fastapi_app/main.py index 37b5b8bb..0b196e99 100644 --- a/fastapi_app/main.py +++ b/fastapi_app/main.py @@ -40,6 +40,7 @@ def _configure_runtime_tempdir() -> None: from fastapi_app.routers import image_playground from fastapi_app.routers import mindmap from fastapi_app.routers import paper2drawio +from fastapi_app.routers import paper2ppt_code from fastapi_app.routers import paper2rebuttal from fastapi_app.middleware.api_key import APIKeyMiddleware from dataflow_agent.utils import get_project_root @@ -91,6 +92,7 @@ def create_app() -> FastAPI: app.include_router(account.router, prefix="/api/v1", tags=["account"]) # Paper2PPT app.include_router(paper2ppt.router, prefix="/api/v1", tags=["paper2ppt"]) + app.include_router(paper2ppt_code.router, prefix="/api/v1", tags=["paper2ppt"]) # Paper2Citation app.include_router(paper2citation.router, prefix="/api/v1", tags=["paper2citation"]) # paper2video diff --git a/fastapi_app/middleware/api_key.py b/fastapi_app/middleware/api_key.py index df51092c..42c25761 100644 --- a/fastapi_app/middleware/api_key.py +++ b/fastapi_app/middleware/api_key.py @@ -95,6 +95,10 @@ class RateLimitRule: "/api/v1/paper2ppt/generate-task": RateLimitRule(limit=12, window_seconds=300, bucket="paper2ppt-generate-task"), "/api/v1/paper2ppt/outline-refine": RateLimitRule(limit=20, window_seconds=300, bucket="paper2ppt-outline-refine"), "/api/v1/paper2ppt/frontend/generate": RateLimitRule(limit=16, window_seconds=300, bucket="paper2ppt-frontend-generate"), + "/api/v1/paper2ppt/code/generate": RateLimitRule(limit=12, window_seconds=300, bucket="paper2ppt-code-generate"), + "/api/v1/paper2ppt/code/generate-task": RateLimitRule(limit=12, window_seconds=300, bucket="paper2ppt-code-generate-task"), + "/api/v1/paper2ppt/code/assemble-task": RateLimitRule(limit=12, window_seconds=300, bucket="paper2ppt-code-assemble-task"), + "/api/v1/paper2ppt/code/patch-slide-task": RateLimitRule(limit=20, window_seconds=300, bucket="paper2ppt-code-patch-slide"), "/api/v1/paper2ppt/frontend/review": RateLimitRule(limit=24, window_seconds=300, bucket="paper2ppt-frontend-review"), "/api/v1/kb/chat": RateLimitRule(limit=30, window_seconds=300, bucket="kb-chat"), "/api/v1/kb/search": RateLimitRule(limit=40, window_seconds=300, bucket="kb-search"), @@ -129,6 +133,10 @@ class RateLimitRule: "/api/v1/paper2ppt/generate": WorkflowGuard("paper2ppt"), "/api/v1/paper2ppt/generate-task": WorkflowGuard("paper2ppt"), "/api/v1/paper2ppt/frontend/generate": WorkflowGuard("paper2ppt"), + "/api/v1/paper2ppt/code/generate": WorkflowGuard("paper2ppt"), + "/api/v1/paper2ppt/code/generate-task": WorkflowGuard("paper2ppt"), + "/api/v1/paper2ppt/code/assemble-task": WorkflowGuard("paper2ppt"), + "/api/v1/paper2ppt/code/patch-slide-task": WorkflowGuard("paper2ppt"), "/api/v1/kb/chat": WorkflowGuard("kb_chat"), "/api/v1/kb/search": WorkflowGuard("kb_search"), "/api/v1/kb/generate-ppt": WorkflowGuard("kb_ppt"), @@ -281,6 +289,10 @@ async def _resolve_workflow_charge_decision( "/api/v1/paper2ppt/generate", "/api/v1/paper2ppt/generate-task", "/api/v1/paper2ppt/frontend/generate", + "/api/v1/paper2ppt/code/generate", + "/api/v1/paper2ppt/code/generate-task", + "/api/v1/paper2ppt/code/assemble-task", + "/api/v1/paper2ppt/code/patch-slide-task", }: return None diff --git a/fastapi_app/routers/paper2ppt_code.py b/fastapi_app/routers/paper2ppt_code.py new file mode 100644 index 00000000..0dd9812d --- /dev/null +++ b/fastapi_app/routers/paper2ppt_code.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +from typing import Any, Dict, Optional + +from fastapi import APIRouter, Depends, Form, Request + +from fastapi_app.config import settings +from fastapi_app.schemas import AssembleEditablePPTRequest, EditablePPTGenerationRequest, ErrorResponse, PatchSlideRequest +from fastapi_app.services.editable_ppt_service import EditablePPTService +from fastapi_app.services.managed_api_service import resolve_model_name +from fastapi_app.services.paper2ppt_code_task_service import Paper2PPTCodeTaskService + +router = APIRouter(tags=["paper2ppt"]) + + +def get_editable_ppt_service() -> EditablePPTService: + return EditablePPTService() + + +def get_code_task_service() -> Paper2PPTCodeTaskService: + return Paper2PPTCodeTaskService() + + +@router.post( + "/paper2ppt/code/generate", + response_model=Dict[str, Any], + responses={400: {"model": ErrorResponse}, 500: {"model": ErrorResponse}}, +) +async def paper2ppt_code_generate( + request: Request, + result_path: str = Form(...), + pagecontent: str = Form(...), + chat_api_url: Optional[str] = Form(None), + api_key: Optional[str] = Form(None), + credential_scope: Optional[str] = Form(None), + email: Optional[str] = Form(None), + model: str = Form(settings.PAPER2PPT_CONTENT_MODEL), + language: str = Form("zh"), + style: str = Form(""), + include_pdf_preview: bool = Form(True), + service: EditablePPTService = Depends(get_editable_ppt_service), +): + req = EditablePPTGenerationRequest( + result_path=result_path, + pagecontent=pagecontent, + chat_api_url=chat_api_url, + api_key=api_key, + credential_scope=credential_scope, + email=email, + model=resolve_model_name( + model, + managed_default=settings.PAPER2PPT_CONTENT_MODEL, + fallback_default=settings.PAPER2PPT_DEFAULT_MODEL, + ), + language=language, + style=style, + include_pdf_preview=include_pdf_preview, + ) + return await service.generate_editable_ppt(req=req, request=request) + + +@router.post( + "/paper2ppt/code/generate-task", + response_model=Dict[str, Any], + responses={400: {"model": ErrorResponse}, 500: {"model": ErrorResponse}}, +) +async def paper2ppt_code_generate_task( + request: Request, + result_path: str = Form(...), + pagecontent: str = Form(...), + chat_api_url: Optional[str] = Form(None), + api_key: Optional[str] = Form(None), + credential_scope: Optional[str] = Form(None), + email: Optional[str] = Form(None), + model: str = Form(settings.PAPER2PPT_CONTENT_MODEL), + language: str = Form("zh"), + style: str = Form(""), + include_pdf_preview: bool = Form(False), + task_service: Paper2PPTCodeTaskService = Depends(get_code_task_service), +): + req = EditablePPTGenerationRequest( + result_path=result_path, pagecontent=pagecontent, + chat_api_url=chat_api_url, api_key=api_key, + credential_scope=credential_scope, email=email, + model=resolve_model_name( + model, managed_default=settings.PAPER2PPT_CONTENT_MODEL, + fallback_default=settings.PAPER2PPT_DEFAULT_MODEL, + ), + language=language, style=style, include_pdf_preview=include_pdf_preview, + ) + return await task_service.submit_generate_slides_task(req=req, request=request) + + +@router.post( + "/paper2ppt/code/assemble-task", + response_model=Dict[str, Any], + responses={400: {"model": ErrorResponse}, 500: {"model": ErrorResponse}}, +) +async def paper2ppt_code_assemble_task( + request: Request, + result_path: str = Form(...), + include_pdf_preview: bool = Form(True), + task_service: Paper2PPTCodeTaskService = Depends(get_code_task_service), +): + req = AssembleEditablePPTRequest( + result_path=result_path, include_pdf_preview=include_pdf_preview, + ) + return await task_service.submit_assemble_task(req=req, request=request) + + +@router.get( + "/paper2ppt/code/tasks/{task_id}", + response_model=Dict[str, Any], + responses={404: {"model": ErrorResponse}, 500: {"model": ErrorResponse}}, +) +async def paper2ppt_code_get_task( + task_id: str, + request: Request, + task_service: Paper2PPTCodeTaskService = Depends(get_code_task_service), +): + return task_service.get_task(task_id=task_id, request=request) + + +@router.post( + "/paper2ppt/code/patch-slide-task", + response_model=Dict[str, Any], + responses={400: {"model": ErrorResponse}, 500: {"model": ErrorResponse}}, +) +async def paper2ppt_code_patch_slide_task( + request: Request, + result_path: str = Form(...), + slide_index: int = Form(...), + feedback: str = Form(...), + feedback_type: str = Form("auto"), + chat_api_url: Optional[str] = Form(None), + api_key: Optional[str] = Form(None), + credential_scope: Optional[str] = Form(None), + model: str = Form(settings.PAPER2PPT_CONTENT_MODEL), + image_model: str = Form(settings.PAPER2PPT_IMAGE_GEN_MODEL), + task_service: Paper2PPTCodeTaskService = Depends(get_code_task_service), +): + req = PatchSlideRequest( + result_path=result_path, + slide_index=slide_index, + feedback=feedback, + feedback_type=feedback_type, + chat_api_url=chat_api_url, + api_key=api_key, + credential_scope=credential_scope, + model=resolve_model_name( + model, + managed_default=settings.PAPER2PPT_CONTENT_MODEL, + fallback_default=settings.PAPER2PPT_DEFAULT_MODEL, + ), + image_model=image_model, + ) + return await task_service.submit_patch_slide_task(req=req, request=request) diff --git a/fastapi_app/schemas.py b/fastapi_app/schemas.py index 96790a13..f4686870 100644 --- a/fastapi_app/schemas.py +++ b/fastapi_app/schemas.py @@ -322,6 +322,61 @@ class FrontendPPTReviewRequest(BaseModel): layout_issues: Optional[str] = None +class EditablePPTGenerationRequest(BaseModel): + """Generate a real editable PPTX through the backend code runtime.""" + chat_api_url: Optional[str] = None + api_key: Optional[str] = None + credential_scope: Optional[str] = None + email: Optional[str] = None + model: str = settings.PAPER2PPT_CONTENT_MODEL + vlm_model: str = settings.PAPER2PPT_VLM_MODEL + image_model: str = settings.PAPER2PPT_IMAGE_GEN_MODEL + language: str = "zh" + style: str = "" + result_path: str + pagecontent: str + include_pdf_preview: bool = True + + +class AssembleEditablePPTRequest(BaseModel): + """Assemble a full editable PPTX from an existing final_ir.json.""" + result_path: str + include_pdf_preview: bool = True + + +class PatchSlideRequest(BaseModel): + """Patch a single slide based on user feedback.""" + result_path: str + slide_index: int + feedback: str + feedback_type: str = "auto" + chat_api_url: Optional[str] = None + api_key: Optional[str] = None + credential_scope: Optional[str] = None + model: str = settings.PAPER2PPT_CONTENT_MODEL + image_api_url: Optional[str] = None + image_api_key: Optional[str] = None + image_model: str = settings.PAPER2PPT_IMAGE_GEN_MODEL + + +class EditablePPTGenerationResponse(BaseModel): + """Backend response for the paper2ppt/code runtime.""" + success: bool = True + result_path: str = "" + ppt_pptx_path: str = "" + ppt_pdf_path: str = "" + planned_ir_path: str = "" + final_ir_path: str = "" + materials_manifest_path: str = "" + material_resolution_path: str = "" + ir_path: str = "" + render_log_path: str = "" + slides_dir: str = "" + slide_artifacts: List[Dict[str, Any]] = [] + all_output_files: List[str] = [] + error: str = "" + + # ===================== KB Deep Research 相关 ===================== class DeepResearchRequest(BaseModel): diff --git a/fastapi_app/services/editable_ppt_service.py b/fastapi_app/services/editable_ppt_service.py new file mode 100644 index 00000000..c882db06 --- /dev/null +++ b/fastapi_app/services/editable_ppt_service.py @@ -0,0 +1,192 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import Any, Callable, Dict + +from fastapi import HTTPException, Request + +from fastapi_app.config import settings +from fastapi_app.schemas import AssembleEditablePPTRequest, EditablePPTGenerationRequest +from fastapi_app.services.managed_api_service import ( + resolve_image_generation_credentials, + resolve_llm_credentials, + resolve_model_name, +) +from fastapi_app.utils import _to_outputs_url +from vendor.presentagent_runtime.contracts import EditablePPTRunRequest +from vendor.presentagent_runtime.runner import ( + assemble_deck_from_final_ir, + run_from_pagecontent, +) + + +def _create_paper2ppt_service(): + from fastapi_app.services.paper2ppt_service import Paper2PPTService + return Paper2PPTService() + + +class EditablePPTService: + def __init__(self, paper2ppt_service=None) -> None: + self._paper2ppt_service = paper2ppt_service or _create_paper2ppt_service() + + @staticmethod + def _requires_agent_runtime(pagecontent: list[dict[str, Any]]) -> bool: + for item in pagecontent or []: + if not isinstance(item, dict): + continue + direct = item.get("asset_paths") or item.get("ppt_img_path") + if direct: + continue + return True + return False + + @staticmethod + def _pick_requested_runtime_model(explicit_model: str, default_model: str, fallback_model: str) -> str: + normalized = str(explicit_model or "").strip() + if normalized and normalized != default_model: + return normalized + return fallback_model + + def _build_runtime_request( + self, + req: EditablePPTGenerationRequest, + base_dir: Path, + *, + include_pdf_preview: bool, + ) -> EditablePPTRunRequest: + pagecontent = self._paper2ppt_service.parse_pagecontent_json(req.pagecontent) + pagecontent = self._paper2ppt_service.normalize_pagecontent_asset_paths_for_runtime(pagecontent) + requires_agent_runtime = self._requires_agent_runtime(pagecontent) + + credential_scope = self._paper2ppt_service.resolve_credential_scope(req.credential_scope) + resolved_chat_api_url, resolved_api_key = resolve_llm_credentials( + req.chat_api_url, req.api_key, scope=credential_scope, + ) + + vlm_model = "" + resolved_vlm_api_url = None + resolved_vlm_api_key = None + image_model = "" + resolved_image_api_url = None + resolved_image_api_key = None + if requires_agent_runtime: + resolved_vlm_api_url, resolved_vlm_api_key = resolve_llm_credentials( + req.chat_api_url, req.api_key, scope=credential_scope, + ) + resolved_image_api_url, resolved_image_api_key = resolve_image_generation_credentials( + req.chat_api_url, req.api_key, scope=credential_scope, + ) + vlm_model = resolve_model_name( + self._pick_requested_runtime_model(req.vlm_model, settings.PAPER2PPT_VLM_MODEL, req.model), + managed_default=settings.PAPER2PPT_VLM_MODEL, + fallback_default=settings.PAPER2PPT_CONTENT_MODEL, + ) + image_model = resolve_model_name( + self._pick_requested_runtime_model(req.image_model, settings.PAPER2PPT_IMAGE_GEN_MODEL, req.model), + managed_default=settings.PAPER2PPT_IMAGE_GEN_MODEL, + fallback_default=settings.PAPER2PPT_DEFAULT_IMAGE_MODEL, + ) + + return EditablePPTRunRequest( + result_path=str(base_dir), + pagecontent=pagecontent, + language=req.language, + style=req.style, + model=resolve_model_name( + req.model, + managed_default=settings.PAPER2PPT_CONTENT_MODEL, + fallback_default=settings.PAPER2PPT_DEFAULT_MODEL, + ), + api_url=resolved_chat_api_url, + api_key=resolved_api_key, + vlm_model=vlm_model, + vlm_api_url=resolved_vlm_api_url, + vlm_api_key=resolved_vlm_api_key, + image_model=image_model, + image_api_url=resolved_image_api_url, + image_api_key=resolved_image_api_key, + enable_agent_planner=True, + enable_material_resolution=True, + enable_llm_codegen=False, + include_pdf_preview=include_pdf_preview, + ) + + async def generate_editable_ppt_slides( + self, + req: EditablePPTGenerationRequest, + request: Request | None, + progress_callback: Callable[[str, dict], None] | None = None, + ) -> Dict[str, Any]: + base_dir = self._paper2ppt_service.resolve_result_path(req.result_path) + if not base_dir.exists(): + raise HTTPException(status_code=400, detail=f"result_path not exists: {base_dir}") + + runtime_req = self._build_runtime_request(req, base_dir, include_pdf_preview=False) + artifacts = await asyncio.to_thread( + run_from_pagecontent, runtime_req, progress_callback=progress_callback, + ) + return self._paper2ppt_service.normalize_editable_ppt_response( + result_path=str(base_dir), artifacts=artifacts, request=request, + ) + + async def assemble_editable_ppt( + self, + req: AssembleEditablePPTRequest, + request: Request | None, + progress_callback: Callable[[str, dict], None] | None = None, + ) -> Dict[str, Any]: + base_dir = self._paper2ppt_service.resolve_result_path(req.result_path) + if not base_dir.exists(): + raise HTTPException(status_code=400, detail=f"result_path not exists: {base_dir}") + + from vendor.presentagent_runtime.renderer.artifact_store import resolve_artifact_base_dir + artifact_base = resolve_artifact_base_dir(base_dir) + final_ir_path = artifact_base / "code_runtime" / "ir" / "final" / "final_ir.json" + if not final_ir_path.exists(): + raise HTTPException( + status_code=400, + detail=f"final_ir.json not found; run generate-task first (expected at {final_ir_path})", + ) + + output_pptx = base_dir / "paper2ppt_code_editable.pptx" + + def _run_assemble() -> dict: + return assemble_deck_from_final_ir( + final_ir_path=final_ir_path, + output_pptx=output_pptx, + include_pdf_preview=bool(req.include_pdf_preview), + progress_callback=progress_callback, + ) + + assembled = await asyncio.to_thread(_run_assemble) + pptx_abs = assembled.get("pptx_path") or "" + pdf_abs = assembled.get("pdf_path") or "" + response: Dict[str, Any] = { + "success": True, + "result_path": str(base_dir), + "ppt_pptx_path": pptx_abs, + "ppt_pdf_path": pdf_abs, + "error": "", + } + if request is not None: + response["ppt_pptx_path"] = _to_outputs_url(pptx_abs, request) if pptx_abs else "" + response["ppt_pdf_path"] = _to_outputs_url(pdf_abs, request) if pdf_abs else "" + return response + + async def generate_editable_ppt( + self, + req: EditablePPTGenerationRequest, + request: Request | None, + ) -> Dict[str, Any]: + slides_response = await self.generate_editable_ppt_slides(req=req, request=request) + if not req.include_pdf_preview: + return slides_response + assemble_req = AssembleEditablePPTRequest( + result_path=slides_response["result_path"], include_pdf_preview=True, + ) + assembled = await self.assemble_editable_ppt(req=assemble_req, request=request) + merged = dict(slides_response) + merged["ppt_pptx_path"] = assembled.get("ppt_pptx_path") or merged.get("ppt_pptx_path", "") + merged["ppt_pdf_path"] = assembled.get("ppt_pdf_path") or merged.get("ppt_pdf_path", "") + return merged diff --git a/fastapi_app/services/paper2ppt_code_task_service.py b/fastapi_app/services/paper2ppt_code_task_service.py new file mode 100644 index 00000000..0df278f2 --- /dev/null +++ b/fastapi_app/services/paper2ppt_code_task_service.py @@ -0,0 +1,717 @@ +from __future__ import annotations + +import asyncio +import contextlib +import hashlib +import json +import time +import traceback +import uuid +from datetime import datetime, timezone +from pathlib import Path +from threading import Lock +from typing import Any, Dict + +from fastapi import HTTPException, Request + +from dataflow_agent.logger import get_logger +from dataflow_agent.utils import get_project_root +from fastapi_app.schemas import AssembleEditablePPTRequest, EditablePPTGenerationRequest, PatchSlideRequest +from fastapi_app.services.editable_ppt_service import EditablePPTService +from fastapi_app.utils import _to_outputs_url + +log = get_logger(__name__) + +PROJECT_ROOT = get_project_root() +TASK_ROOT = (PROJECT_ROOT / "outputs" / ".tasks" / "paper2ppt_code").resolve() +_ACTIVE_TASKS: set[asyncio.Task[Any]] = set() +_SUBMISSION_WINDOW_SECONDS = 20 +_TASK_SUBMISSION_LOCK = Lock() +_TASK_HEARTBEAT_INTERVAL_SECONDS = 15 +_TASK_STALE_TIMEOUT_SECONDS = 180 + +_STAGE_ORDER = ["planning", "final_ir", "slide_rendering", "assembling"] +_STAGE_TOTAL = 4 + +_STAGE_INDEX: dict[str, int] = {stage: idx for idx, stage in enumerate(_STAGE_ORDER)} + + +def _initial_progress() -> Dict[str, Any]: + return { + "stage": "planning", + "stageIndex": 0, + "stageTotal": _STAGE_TOTAL, + "slideDone": 0, + "slideTotal": 0, + "message": "Starting…", + "plannedIrPath": "", + "materialsManifestPath": "", + "materialResolutionPath": "", + "finalIrPath": "", + "slideArtifacts": [], + } + + +class Paper2PPTCodeTaskService: + """File-backed async task service for paper2ppt code mode.""" + + def __init__(self, service: EditablePPTService | None = None) -> None: + self.service = service or EditablePPTService() + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + async def submit_generate_slides_task( + self, + req: EditablePPTGenerationRequest, + request: Request | None = None, + ) -> Dict[str, Any]: + submission_key = self._resolve_generate_submission_key(req, request) + + with _TASK_SUBMISSION_LOCK: + if submission_key: + existing_record = self._find_recent_submission(submission_key) + if existing_record is not None: + return self._serialize_record(existing_record, request) + + task_id = uuid.uuid4().hex + payload = req.model_dump() + + record = { + "task_id": task_id, + "task_type": "generate", + "status": "queued", + "message": "Slide generation queued", + "error": None, + "created_at": self._now_iso(), + "updated_at": self._now_iso(), + "request": payload, + "progress": _initial_progress(), + "result": None, + } + self._write_record(task_id, record) + if submission_key: + self._write_submission(submission_key, task_id) + + task = asyncio.create_task(self._run_generate_task(task_id)) + _ACTIVE_TASKS.add(task) + task.add_done_callback(_ACTIVE_TASKS.discard) + + return self._serialize_record(record, request) + + async def submit_assemble_task( + self, + req: AssembleEditablePPTRequest, + request: Request | None = None, + ) -> Dict[str, Any]: + submission_key = self._resolve_assemble_submission_key(req, request) + + with _TASK_SUBMISSION_LOCK: + if submission_key: + existing_record = self._find_recent_submission(submission_key) + if existing_record is not None: + return self._serialize_record(existing_record, request) + + task_id = uuid.uuid4().hex + payload = req.model_dump() + + record = { + "task_id": task_id, + "task_type": "assemble", + "status": "queued", + "message": "Assembly queued", + "error": None, + "created_at": self._now_iso(), + "updated_at": self._now_iso(), + "request": payload, + "progress": { + "stage": "assembling", + "stageIndex": _STAGE_INDEX["assembling"], + "stageTotal": _STAGE_TOTAL, + "slideDone": 0, + "slideTotal": 0, + "message": "Assembly queued", + "slideArtifacts": [], + }, + "result": None, + } + self._write_record(task_id, record) + if submission_key: + self._write_submission(submission_key, task_id) + + task = asyncio.create_task(self._run_assemble_task(task_id)) + _ACTIVE_TASKS.add(task) + task.add_done_callback(_ACTIVE_TASKS.discard) + + return self._serialize_record(record, request) + + def get_task(self, task_id: str, request: Request | None = None) -> Dict[str, Any]: + record = self._refresh_record_state(task_id) + return self._serialize_record(record, request) + + async def submit_patch_slide_task( + self, + req: PatchSlideRequest, + request: Request | None = None, + ) -> Dict[str, Any]: + task_id = uuid.uuid4().hex + payload = req.model_dump() + record = { + "task_id": task_id, + "task_type": "patch_slide", + "status": "queued", + "message": "Patch queued", + "error": None, + "created_at": self._now_iso(), + "updated_at": self._now_iso(), + "request": payload, + "progress": { + "stage": "patch_analyzing", + "slideIndex": req.slide_index, + "message": "Patch queued", + "slideArtifact": None, + }, + "result": None, + } + self._write_record(task_id, record) + + task = asyncio.create_task(self._run_patch_slide_task(task_id)) + _ACTIVE_TASKS.add(task) + task.add_done_callback(_ACTIVE_TASKS.discard) + + return self._serialize_record(record, request) + + # ------------------------------------------------------------------ + # Background task runners + # ------------------------------------------------------------------ + + async def _run_generate_task(self, task_id: str) -> None: + record = self._read_record(task_id) + payload = record.get("request") or {} + req = EditablePPTGenerationRequest(**payload) + heartbeat_task = asyncio.create_task(self._heartbeat_task(task_id)) + + def on_progress(event: str, params: dict) -> None: + try: + current = self._read_record(task_id) + progress: Dict[str, Any] = dict(current.get("progress") or _initial_progress()) + + if event == "planning_done": + progress["stage"] = "final_ir" + progress["stageIndex"] = _STAGE_INDEX["final_ir"] + progress["message"] = "Planning done, building final IR…" + progress["plannedIrPath"] = str(params.get("planned_ir_path") or "") + progress["materialsManifestPath"] = str(params.get("materials_manifest_path") or "") + progress["materialResolutionPath"] = str(params.get("material_resolution_path") or "") + + elif event == "final_ir_done": + progress["stage"] = "slide_rendering" + progress["stageIndex"] = _STAGE_INDEX["slide_rendering"] + progress["message"] = "Final IR ready, rendering slides…" + progress["finalIrPath"] = str(params.get("final_ir_path") or "") + + elif event == "slide_rendered": + # runner emits: index (0-based), total, slide_id, title, pptx_path, preview_png_path + slide_total = int(params.get("total") or 0) + slide_index = int(params.get("index") or 0) + slide_done = slide_index + 1 + progress["stage"] = "slide_rendering" + progress["stageIndex"] = _STAGE_INDEX["slide_rendering"] + progress["slideTotal"] = slide_total + progress["slideDone"] = slide_done + progress["message"] = f"Rendered slide {slide_done}/{slide_total}" + artifact = { + "index": slide_index, + "slide_id": params.get("slide_id", ""), + "title": params.get("title", ""), + "pptx_path": params.get("pptx_path", ""), + "preview_png_path": params.get("preview_png_path", ""), + "status": "rendered", + } + existing: list = list(progress.get("slideArtifacts") or []) + existing.append(artifact) + progress["slideArtifacts"] = existing + + elif event == "rendering_done": + # runner emits full slide_artifacts list (with updated preview paths) + raw_artifacts = params.get("slide_artifacts") + if isinstance(raw_artifacts, list): + progress["slideArtifacts"] = raw_artifacts + progress["message"] = "All slides rendered" + + current["progress"] = progress + self._write_record(task_id, current) + except Exception: # noqa: BLE001 + log.warning("[paper2ppt-code-task] progress update failed for task %s", task_id) + + try: + self._update_record( + task_id, + status="running", + message="Generating slides…", + error=None, + ) + + result = await self.service.generate_editable_ppt_slides( + req=req, + request=None, + # Call directly from worker thread — on_progress only does file I/O (thread-safe). + # Using call_soon_threadsafe would defer writes until the event loop polls, + # which causes progress updates to batch instead of flushing per-slide. + progress_callback=on_progress, + ) + + # Mark progress as done + final_record = self._read_record(task_id) + progress = dict(final_record.get("progress") or _initial_progress()) + progress["stage"] = "done" + progress["message"] = "Generation complete" + final_record["progress"] = progress + final_record["status"] = "done" + final_record["message"] = "Task completed" + final_record["error"] = None + final_record["result"] = result + self._write_record(task_id, final_record) + + except HTTPException as exc: + message = str(exc.detail) + log.warning("[paper2ppt-code-task] task %s failed: %s", task_id, message) + self._update_record( + task_id, + status="failed", + message=message, + error=message, + result=None, + ) + except Exception as exc: # noqa: BLE001 + message = str(exc) or exc.__class__.__name__ + log.exception("[paper2ppt-code-task] task %s failed", task_id) + self._update_record( + task_id, + status="failed", + message=message, + error=message, + result={ + "traceback": traceback.format_exc(limit=20), + }, + ) + finally: + heartbeat_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await heartbeat_task + + async def _run_assemble_task(self, task_id: str) -> None: + record = self._read_record(task_id) + payload = record.get("request") or {} + req = AssembleEditablePPTRequest(**payload) + heartbeat_task = asyncio.create_task(self._heartbeat_task(task_id)) + + try: + self._update_record( + task_id, + status="running", + message="Assembling PPTX…", + error=None, + ) + + result = await self.service.assemble_editable_ppt( + req=req, + request=None, + ) + + self._update_record( + task_id, + status="done", + message="Assembly completed", + error=None, + result=result, + ) + except HTTPException as exc: + message = str(exc.detail) + log.warning("[paper2ppt-code-task] assemble task %s failed: %s", task_id, message) + self._update_record( + task_id, + status="failed", + message=message, + error=message, + result=None, + ) + except Exception as exc: # noqa: BLE001 + message = str(exc) or exc.__class__.__name__ + log.exception("[paper2ppt-code-task] assemble task %s failed", task_id) + self._update_record( + task_id, + status="failed", + message=message, + error=message, + result={ + "traceback": traceback.format_exc(limit=20), + }, + ) + finally: + heartbeat_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await heartbeat_task + + async def _heartbeat_task(self, task_id: str) -> None: + while True: + await asyncio.sleep(_TASK_HEARTBEAT_INTERVAL_SECONDS) + try: + record = self._read_record(task_id) + record["updated_at"] = self._now_iso() + self._write_record(task_id, record) + except HTTPException: + return + except Exception: + log.warning("[paper2ppt-code-task] heartbeat update failed for task %s", task_id) + return + + async def _run_patch_slide_task(self, task_id: str) -> None: + record = self._read_record(task_id) + payload = record.get("request") or {} + req = PatchSlideRequest(**payload) + heartbeat_task = asyncio.create_task(self._heartbeat_task(task_id)) + + def on_progress(event: str, params: dict) -> None: + try: + current = self._read_record(task_id) + progress: Dict[str, Any] = dict(current.get("progress") or {}) + progress["message"] = { + "patch_analyzing": "分析修改意见…", + "patch_ir_done": "SlideIR 已更新,准备生成代码…", + "patch_codegen": "重新生成幻灯片代码…", + "patch_rendering": "重新渲染幻灯片…", + "patch_done": "修改完成", + }.get(event, event) + progress["stage"] = event + if event == "patch_done": + artifact = { + "index": int(params.get("index") or req.slide_index), + "slide_id": params.get("slide_id", ""), + "title": params.get("title", ""), + "pptx_path": params.get("pptx_path", ""), + "preview_png_path": params.get("preview_png_path", ""), + "status": "rendered", + } + progress["slideArtifact"] = artifact + current["progress"] = progress + self._write_record(task_id, current) + except Exception: + pass + + try: + self._update_record(task_id, status="running", message="Patching slide…", error=None) + + from fastapi_app.services.managed_api_service import resolve_image_generation_credentials, resolve_llm_credentials + from fastapi_app.services.paper2ppt_service import Paper2PPTService + from vendor.presentagent_runtime.runner import repatch_single_slide + + credential_scope = Paper2PPTService().resolve_credential_scope(req.credential_scope) + resolved_api_url, resolved_api_key = resolve_llm_credentials( + req.chat_api_url, req.api_key, scope=credential_scope, + ) + resolved_image_url, resolved_image_key = resolve_image_generation_credentials( + req.image_api_url, req.image_api_key, scope=credential_scope, + ) + result_path = Paper2PPTService().resolve_result_path(req.result_path) + + sa = await asyncio.to_thread( + repatch_single_slide, + str(result_path), + req.slide_index, + req.feedback, + req.feedback_type, + api_url=resolved_api_url or "", + api_key=resolved_api_key or "", + model=req.model or "", + image_api_url=resolved_image_url or "", + image_api_key=resolved_image_key or "", + image_model=req.image_model or "", + progress_callback=on_progress, + ) + + final_record = self._read_record(task_id) + progress = dict(final_record.get("progress") or {}) + progress["stage"] = "done" + progress["message"] = "修改完成" + artifact_dict = sa.model_dump() + progress["slideArtifact"] = artifact_dict + final_record["progress"] = progress + final_record["status"] = "done" + final_record["message"] = "Patch completed" + final_record["error"] = None + final_record["result"] = { + "success": True, + "slide_index": req.slide_index, + "slide_artifact": artifact_dict, + } + self._write_record(task_id, final_record) + + except Exception as exc: + message = str(exc) or exc.__class__.__name__ + log.exception("[paper2ppt-code-task] patch task %s failed", task_id) + self._update_record( + task_id, + status="failed", + message=message, + error=message, + result={"traceback": traceback.format_exc(limit=20)}, + ) + finally: + heartbeat_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await heartbeat_task + + # ------------------------------------------------------------------ + # Serialization + # ------------------------------------------------------------------ + + def _serialize_record( + self, + record: Dict[str, Any], + request: Request | None = None, + ) -> Dict[str, Any]: + status = str(record.get("status") or "queued") + result = record.get("result") if status == "done" else None + + progress = record.get("progress") + if progress and isinstance(progress, dict): + progress = self._url_ify_progress(progress, request) + + if result and isinstance(result, dict): + result = self._url_ify_result(result, request) + + return { + "success": True, + "task_id": record["task_id"], + "task_type": record.get("task_type", "generate"), + "status": status, + "message": record.get("message", ""), + "error": record.get("error"), + "progress": progress, + "result": result, + } + + def _url_ify_progress(self, progress: Dict[str, Any], request: Request | None) -> Dict[str, Any]: + result = dict(progress) + raw_artifacts = result.get("slideArtifacts") or [] + if isinstance(raw_artifacts, list) and raw_artifacts: + url_artifacts = [] + for sa in raw_artifacts: + if not isinstance(sa, dict): + url_artifacts.append(sa) + continue + pptx_p = str(sa.get("pptx_path") or "") + preview_p = str(sa.get("preview_png_path") or "") + url_artifacts.append({ + **sa, + "pptx_path": _to_outputs_url(pptx_p, request) if pptx_p else "", + "preview_png_path": _to_outputs_url(preview_p, request) if preview_p else "", + }) + result["slideArtifacts"] = url_artifacts + return result + + def _url_ify_result(self, result: Dict[str, Any], request: Request | None) -> Dict[str, Any]: + out = dict(result) + # URL-convert per-slide artifacts stored as absolute paths (list form) + raw_artifacts = out.get("slide_artifacts") or [] + if isinstance(raw_artifacts, list) and raw_artifacts: + url_artifacts = [] + for sa in raw_artifacts: + if not isinstance(sa, dict): + url_artifacts.append(sa) + continue + pptx_p = str(sa.get("pptx_path") or "") + preview_p = str(sa.get("preview_png_path") or "") + url_artifacts.append({ + **sa, + "pptx_path": _to_outputs_url(pptx_p, request) if pptx_p else "", + "preview_png_path": _to_outputs_url(preview_p, request) if preview_p else "", + }) + out["slide_artifacts"] = url_artifacts + # URL-convert single slide_artifact (patch task result) + single = out.get("slide_artifact") + if isinstance(single, dict): + pptx_p = str(single.get("pptx_path") or "") + preview_p = str(single.get("preview_png_path") or "") + out["slide_artifact"] = { + **single, + "pptx_path": _to_outputs_url(pptx_p, request) if pptx_p else "", + "preview_png_path": _to_outputs_url(preview_p, request) if preview_p else "", + } + # URL-convert top-level path fields + for field in ("ppt_pptx_path", "ppt_pdf_path", "ir_path", "render_log_path", + "planned_ir_path", "final_ir_path", "materials_manifest_path", + "material_resolution_path"): + val = str(out.get(field) or "") + if val and not val.startswith(("/outputs/", "http://", "https://")): + out[field] = _to_outputs_url(val, request) + return out + + # ------------------------------------------------------------------ + # Submission dedup + # ------------------------------------------------------------------ + + def _resolve_generate_submission_key( + self, + req: EditablePPTGenerationRequest, + request: Request | None, + ) -> str | None: + request_state = getattr(request, "state", None) + existing = getattr(request_state, "workflow_submission_key", None) + if existing: + return str(existing).strip() or None + + payload = { + "path": "/api/v1/paper2ppt/code/generate-task", + "result_path": str(req.result_path or "").strip(), + "pagecontent": str(req.pagecontent or "").strip(), + "model": str(req.model or "").strip(), + "language": str(req.language or "").strip(), + "style": str(req.style or "").strip(), + } + encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True).encode("utf-8") + submission_key = hashlib.sha256(encoded).hexdigest() + if request_state is not None: + request_state.workflow_submission_key = submission_key + return submission_key + + def _resolve_assemble_submission_key( + self, + req: AssembleEditablePPTRequest, + request: Request | None, + ) -> str | None: + request_state = getattr(request, "state", None) + existing = getattr(request_state, "workflow_submission_key", None) + if existing: + return str(existing).strip() or None + + payload = { + "path": "/api/v1/paper2ppt/code/assemble-task", + "result_path": str(req.result_path or "").strip(), + "include_pdf_preview": bool(req.include_pdf_preview), + } + encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True).encode("utf-8") + submission_key = hashlib.sha256(encoded).hexdigest() + if request_state is not None: + request_state.workflow_submission_key = submission_key + return submission_key + + def _find_recent_submission(self, submission_key: str) -> Dict[str, Any] | None: + submission_file = self._submission_file(submission_key) + if not submission_file.exists(): + return None + try: + payload = json.loads(submission_file.read_text(encoding="utf-8")) + except Exception: # noqa: BLE001 + return None + + created_at = float(payload.get("created_at") or 0) + if time.time() - created_at > _SUBMISSION_WINDOW_SECONDS: + return None + + task_id = str(payload.get("task_id") or "").strip() + if not task_id: + return None + + try: + record = self._refresh_record_state(task_id) + except HTTPException: + return None + if str(record.get("status") or "").lower() == "failed": + return None + return record + + def _write_submission(self, submission_key: str, task_id: str) -> None: + submission_file = self._submission_file(submission_key) + submission_file.parent.mkdir(parents=True, exist_ok=True) + payload = { + "task_id": task_id, + "created_at": time.time(), + } + tmp_file = submission_file.with_suffix(".tmp") + tmp_file.write_text( + json.dumps(payload, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + tmp_file.replace(submission_file) + + # ------------------------------------------------------------------ + # Stale detection + # ------------------------------------------------------------------ + + def _refresh_record_state(self, task_id: str) -> Dict[str, Any]: + record = self._read_record(task_id) + status = str(record.get("status") or "").strip().lower() + + if status not in {"queued", "running"}: + return record + + age_seconds = self._record_age_seconds(record) + if age_seconds is None or age_seconds < _TASK_STALE_TIMEOUT_SECONDS: + return record + + message = "任务已中断:后端 worker 可能已重启,请重新生成" + return self._update_record( + task_id, + status="failed", + message=message, + error=message, + ) + + # ------------------------------------------------------------------ + # File I/O helpers + # ------------------------------------------------------------------ + + def _task_dir(self, task_id: str) -> Path: + return TASK_ROOT / task_id + + def _task_file(self, task_id: str) -> Path: + return self._task_dir(task_id) / "task.json" + + def _submission_file(self, submission_key: str) -> Path: + return TASK_ROOT / ".submissions" / f"{submission_key}.json" + + def _read_record(self, task_id: str) -> Dict[str, Any]: + task_file = self._task_file(task_id) + if not task_file.exists(): + raise HTTPException(status_code=404, detail=f"task not found: {task_id}") + try: + return json.loads(task_file.read_text(encoding="utf-8")) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=500, detail=f"failed to load task: {task_id}") from exc + + def _write_record(self, task_id: str, record: Dict[str, Any]) -> None: + task_dir = self._task_dir(task_id) + task_dir.mkdir(parents=True, exist_ok=True) + record["updated_at"] = self._now_iso() + + task_file = self._task_file(task_id) + tmp_file = task_file.with_suffix(".tmp") + tmp_file.write_text( + json.dumps(record, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + tmp_file.replace(task_file) + + def _update_record(self, task_id: str, **updates: Any) -> Dict[str, Any]: + record = self._read_record(task_id) + record.update(updates) + self._write_record(task_id, record) + return record + + def _record_age_seconds(self, record: Dict[str, Any]) -> float | None: + updated_at = str(record.get("updated_at") or "").strip() + if not updated_at: + return None + try: + updated = datetime.fromisoformat(updated_at) + except ValueError: + return None + if updated.tzinfo is None: + updated = updated.replace(tzinfo=timezone.utc) + return max(0.0, (datetime.now(timezone.utc) - updated).total_seconds()) + + def _now_iso(self) -> str: + return datetime.now(timezone.utc).isoformat() diff --git a/fastapi_app/services/paper2ppt_service.py b/fastapi_app/services/paper2ppt_service.py index 60869791..fc5634f3 100644 --- a/fastapi_app/services/paper2ppt_service.py +++ b/fastapi_app/services/paper2ppt_service.py @@ -103,6 +103,7 @@ import os from pathlib import Path from typing import Any, Dict, List, Optional +from typing import TYPE_CHECKING from fastapi import HTTPException, Request, UploadFile import httpx @@ -137,6 +138,9 @@ log = get_logger(__name__) +if TYPE_CHECKING: + from vendor.presentagent_runtime.contracts import EditablePPTRunArtifacts + PROJECT_ROOT = get_project_root() BASE_OUTPUT_DIR = get_outputs_root() _PREVIEW_MAX_SIDE = 1280 @@ -169,6 +173,9 @@ def _resolve_credential_scope(raw_scope: Optional[str], default_scope: str = "pa scope = (raw_scope or "").strip().lower() return scope or default_scope + def resolve_credential_scope(self, raw_scope: Optional[str], default_scope: str = "paper2ppt") -> str: + return self._resolve_credential_scope(raw_scope, default_scope) + @staticmethod def _parse_page_index_list(raw: Optional[str], *, max_count: int | None = None) -> list[int]: text = str(raw or "").strip() @@ -619,6 +626,75 @@ def normalize_ppt_response( return normalized + def normalize_editable_ppt_response( + self, + *, + result_path: str, + artifacts: "EditablePPTRunArtifacts", + request: Request | None, + ) -> Dict[str, Any]: + normalized: Dict[str, Any] = { + "success": True, + "result_path": str(result_path), + "ppt_pptx_path": artifacts.pptx_path, + "ppt_pdf_path": artifacts.pdf_path, + "planned_ir_path": artifacts.planned_ir_path, + "final_ir_path": artifacts.final_ir_path, + "materials_manifest_path": artifacts.materials_manifest_path, + "material_resolution_path": artifacts.material_resolution_path, + "ir_path": artifacts.ir_path, + "render_log_path": artifacts.log_path, + "slides_dir": getattr(artifacts, "slides_dir", "") or "", + "slide_artifacts": [ + sa.model_dump() for sa in getattr(artifacts, "slide_artifacts", []) or [] + ], + "all_output_files": [], + "error": "", + } + + if request is not None: + normalized["ppt_pptx_path"] = _to_outputs_url(artifacts.pptx_path, request) + if artifacts.pdf_path: + normalized["ppt_pdf_path"] = _to_outputs_url(artifacts.pdf_path, request) + if artifacts.planned_ir_path: + normalized["planned_ir_path"] = _to_outputs_url(artifacts.planned_ir_path, request) + if artifacts.final_ir_path: + normalized["final_ir_path"] = _to_outputs_url(artifacts.final_ir_path, request) + if artifacts.materials_manifest_path: + normalized["materials_manifest_path"] = _to_outputs_url(artifacts.materials_manifest_path, request) + if artifacts.material_resolution_path: + normalized["material_resolution_path"] = _to_outputs_url(artifacts.material_resolution_path, request) + normalized["ir_path"] = _to_outputs_url(artifacts.ir_path, request) + normalized["render_log_path"] = _to_outputs_url(artifacts.log_path, request) + slide_items: list[dict[str, Any]] = [] + for sa in normalized.get("slide_artifacts", []): + pptx_p = str(sa.get("pptx_path") or "") + preview_p = str(sa.get("preview_png_path") or "") + slide_items.append({ + **sa, + "pptx_path": _to_outputs_url(pptx_p, request) if pptx_p else "", + "preview_png_path": _to_outputs_url(preview_p, request) if preview_p else "", + }) + normalized["slide_artifacts"] = slide_items + normalized["all_output_files"] = self._collect_output_files_as_urls(str(result_path), request) + + return normalized + + def normalize_pagecontent_asset_paths_for_runtime( + self, + pagecontent: List[Dict[str, Any]], + ) -> List[Dict[str, Any]]: + normalized = copy.deepcopy(pagecontent or []) + for item in normalized: + if not isinstance(item, dict): + continue + for key in ["ppt_img_path", "asset_ref", "generated_img_path"]: + if key in item and item[key]: + value = str(item[key]).strip() + if value.startswith(("http://", "https://", "/outputs/")) or Path(value).is_absolute() or value.startswith("outputs/"): + item[key] = str(resolve_outputs_path(value, must_exist=False)) + return normalized + def _collect_failed_pages(self, pagecontent: List[Dict[str, Any]]) -> List[Dict[str, Any]]: failed_pages: List[Dict[str, Any]] = [] for fallback_idx, item in enumerate(pagecontent or []): @@ -1065,6 +1141,9 @@ def _parse_pagecontent_json(self, pagecontent_json: str) -> List[Dict[str, Any]] raise HTTPException(status_code=400, detail=f"pagecontent[{i}] must be an object(dict)") return self._normalize_pagecontent_items(obj) + def parse_pagecontent_json(self, pagecontent_json: str) -> List[Dict[str, Any]]: + return self._parse_pagecontent_json(pagecontent_json) + def _extract_outline_text(self, value: Any) -> str: if value is None: return "" diff --git a/frontend-workflow/src/App.tsx b/frontend-workflow/src/App.tsx index 936fbe82..3b0a452e 100644 --- a/frontend-workflow/src/App.tsx +++ b/frontend-workflow/src/App.tsx @@ -37,6 +37,7 @@ const pageIds = [ 'paper2ppt', 'paper2ppt-image', 'paper2ppt-frontend', + 'paper2ppt-code', 'paper2video', 'paper2poster', 'paper2citation', @@ -64,6 +65,7 @@ const pagePaths: Record = { 'paper2ppt': '/paper2ppt', 'paper2ppt-image': '/paper2ppt/image', 'paper2ppt-frontend': '/paper2ppt/frontend', + 'paper2ppt-code': '/paper2ppt/code', 'paper2video': '/paper2video', 'paper2poster': '/paper2poster', 'paper2citation': '/paper2citation', @@ -245,6 +247,7 @@ function App() { )} {activePage === 'paper2ppt-frontend' && } + {activePage === 'paper2ppt-code' && } {activePage === 'paper2video' && } {activePage === 'paper2poster' && } {activePage === 'paper2citation' && } diff --git a/frontend-workflow/src/components/AppSidebar.tsx b/frontend-workflow/src/components/AppSidebar.tsx index 325c4b32..8c3f8fdf 100644 --- a/frontend-workflow/src/components/AppSidebar.tsx +++ b/frontend-workflow/src/components/AppSidebar.tsx @@ -8,6 +8,7 @@ import { Presentation, FileText, MonitorSmartphone, + FileCode2, ImagePlus, Image, Wand2, @@ -86,6 +87,13 @@ export const AppSidebar = ({ isOpen, onClose, activePage, onPageChange }: AppSid tooltipKey: t('app.navSubTooltip.paper2pptFrontend'), icon: MonitorSmartphone, gradient: 'from-amber-500 to-orange-500' + }, + { + id: 'paper2ppt-code', + labelKey: t('app.navSub.paper2pptCode'), + tooltipKey: t('app.navSubTooltip.paper2pptCode'), + icon: FileCode2, + gradient: 'from-cyan-500 to-blue-500' } ]), [t]); diff --git a/frontend-workflow/src/components/HomePage.tsx b/frontend-workflow/src/components/HomePage.tsx index ec098e93..bd74d9a6 100644 --- a/frontend-workflow/src/components/HomePage.tsx +++ b/frontend-workflow/src/components/HomePage.tsx @@ -241,6 +241,14 @@ export function HomePage({ onNavigate }: HomePageProps) { {t('app.home.frontendCta')} + diff --git a/frontend-workflow/src/components/paper2ppt/CodeCompleteStep.tsx b/frontend-workflow/src/components/paper2ppt/CodeCompleteStep.tsx new file mode 100644 index 00000000..68230136 --- /dev/null +++ b/frontend-workflow/src/components/paper2ppt/CodeCompleteStep.tsx @@ -0,0 +1,179 @@ +import React from 'react'; +import { + AlertCircle, + CheckCircle2, + Download, + ExternalLink, + FileCode2, + FileText, + Loader2, + RotateCcw, +} from 'lucide-react'; +import { CodeSlideArtifact, SlideOutline } from './types'; + +interface CodeCompleteStepProps { + outlineData: SlideOutline[]; + downloadUrl: string | null; + pdfPreviewUrl: string | null; + irUrl: string | null; + renderLogUrl: string | null; + plannedIrUrl?: string | null; + finalIrUrl?: string | null; + materialsManifestUrl?: string | null; + materialResolutionUrl?: string | null; + slideArtifacts?: CodeSlideArtifact[]; + isGeneratingFinal: boolean; + taskMessage?: string; + handleGenerateFinal: () => void; + handleDownloadPptx: () => void; + handleDownloadPdf: () => void; + handleReset: () => void; + error: string | null; +} + +const DebugRow: React.FC<{ label: string; url?: string | null }> = ({ label, url }) => ( +
+ {label} + {url ? ( + + 打开 + + ) : ( + 未生成 + )} +
+); + +const CodeCompleteStep: React.FC = ({ + outlineData, + downloadUrl, + pdfPreviewUrl, + irUrl, + renderLogUrl, + plannedIrUrl, + finalIrUrl, + materialsManifestUrl, + materialResolutionUrl, + slideArtifacts = [], + isGeneratingFinal, + taskMessage, + handleGenerateFinal, + handleDownloadPptx, + handleDownloadPdf, + handleReset, + error, +}) => { + return ( +
+
+
+ +
+

代码型可编辑 PPT 已准备就绪

+

共处理 {outlineData.length} 页

+
+ +
+ + {isGeneratingFinal && taskMessage && ( +
{taskMessage}
+ )} +
+ +
+
+ + 主产物 +
+
+
+ 完整可编辑 PPTX + {downloadUrl ? ( + + ) : ( + 未组装 + )} +
+
+ PDF 预览 + {pdfPreviewUrl ? ( + + ) : ( + 未组装 + )} +
+
+
+ +
+
+ + Agent 调试产物 +
+
+ + + + + + +
+
+ + {slideArtifacts.length > 0 && ( +
+ 单页 PPTX 下载 ({slideArtifacts.length}) +
+ {slideArtifacts.map((a) => ( +
+ + 第 {a.index + 1} 页 · {a.title || '-'} + + {a.pptxUrl ? ( + + 下载 + + ) : ( + - + )} +
+ ))} +
+
+ )} + +
+ +
+ + {error && ( +
+ {error} +
+ )} +
+ ); +}; + +export default CodeCompleteStep; diff --git a/frontend-workflow/src/components/paper2ppt/CodeGenerateStep.tsx b/frontend-workflow/src/components/paper2ppt/CodeGenerateStep.tsx new file mode 100644 index 00000000..542c8b2b --- /dev/null +++ b/frontend-workflow/src/components/paper2ppt/CodeGenerateStep.tsx @@ -0,0 +1,339 @@ +import React, { useState } from 'react'; +import { + AlertCircle, + ArrowLeft, + CheckCircle2, + Code2, + Download, + Edit3, + FileText, + Image, + Loader2, + MessageSquare, + Type, + X, +} from 'lucide-react'; +import { CodeSlideArtifact, CodeStage, CodeTaskProgress, PatchFeedbackType, SlideOutline, Step } from './types'; + +interface PatchState { + slideIndex: number; + feedback: string; + feedbackType: PatchFeedbackType; + isSubmitting: boolean; + error: string | null; +} + +interface CodeGenerateStepProps { + outlineData: SlideOutline[]; + isGenerating: boolean; + taskMessage?: string; + progress: CodeTaskProgress | null; + slideArtifacts: CodeSlideArtifact[]; + error: string | null; + resultPath: string | null; + setCurrentStep: (step: Step) => void; + handleConfirmSlide: () => void; + onPatchSlide: (slideIndex: number, feedback: string, feedbackType: PatchFeedbackType) => Promise; + patchingSlideIndex: number | null; +} + +const STAGE_ORDER: CodeStage[] = ['planning', 'final_ir', 'slide_rendering']; +const STAGE_LABEL: Record = { + planning: '规划并绑定素材', + final_ir: '生成最终 IR', + slide_rendering: '逐页渲染 PPTX', + done: '完成', +}; + +const QUICK_FEEDBACK: Array<{ label: string; text: string; type: PatchFeedbackType; icon: React.ReactNode }> = [ + { label: '补充文本', text: '请补充更多详细的文本内容', type: 'text', icon: }, + { label: '添加图片', text: '请为这一页添加合适的配图', type: 'image', icon: }, + { label: '文本+图片', text: '请补充文本内容并添加配图', type: 'both', icon: }, +]; + +const stageReached = (progress: CodeTaskProgress | null, stage: CodeStage): boolean => { + if (!progress) return false; + const currentIdx = STAGE_ORDER.indexOf(progress.stage as CodeStage); + const targetIdx = STAGE_ORDER.indexOf(stage); + if (progress.stage === 'done') return true; + return currentIdx > targetIdx; +}; + +const stageActive = (progress: CodeTaskProgress | null, stage: CodeStage): boolean => { + return progress?.stage === stage; +}; + +const CodeGenerateStep: React.FC = ({ + outlineData, + isGenerating, + taskMessage, + progress, + slideArtifacts, + error, + setCurrentStep, + handleConfirmSlide, + onPatchSlide, + patchingSlideIndex, +}) => { + const allRendered = !isGenerating && slideArtifacts.length > 0; + const [openPatchIndex, setOpenPatchIndex] = useState(null); + const [patchState, setPatchState] = useState({ + slideIndex: -1, + feedback: '', + feedbackType: 'auto', + isSubmitting: false, + error: null, + }); + + const openPatch = (idx: number) => { + setOpenPatchIndex(idx); + setPatchState({ slideIndex: idx, feedback: '', feedbackType: 'auto', isSubmitting: false, error: null }); + }; + + const closePatch = () => { + setOpenPatchIndex(null); + setPatchState({ slideIndex: -1, feedback: '', feedbackType: 'auto', isSubmitting: false, error: null }); + }; + + const applyQuickFeedback = (text: string, type: PatchFeedbackType) => { + setPatchState((prev) => ({ ...prev, feedback: text, feedbackType: type })); + }; + + const submitPatch = async (idx: number) => { + if (!patchState.feedback.trim()) return; + setPatchState((prev) => ({ ...prev, isSubmitting: true, error: null })); + try { + await onPatchSlide(idx, patchState.feedback.trim(), patchState.feedbackType); + closePatch(); + } catch (err) { + setPatchState((prev) => ({ + ...prev, + isSubmitting: false, + error: err instanceof Error ? err.message : '修改失败,请重试', + })); + } + }; + + return ( +
+
+

+ Code Deck Runtime +

+

Agent 正在逐页生成可编辑 PPTX

+
+ + {/* Stage progress bar */} +
+
+ {STAGE_ORDER.map((stage, idx) => { + const reached = stageReached(progress, stage); + const active = stageActive(progress, stage); + return ( + +
+
+ {reached ? : active ? : idx + 1} +
+
+ {STAGE_LABEL[stage]} +
+
+ {idx < STAGE_ORDER.length - 1 &&
} + + ); + })} +
+ {(progress || taskMessage) && ( +
+ {progress?.message || taskMessage || '等待中...'} + {progress?.stage === 'slide_rendering' && progress.slideTotal > 0 && ( + + ({progress.slideDone}/{progress.slideTotal}) + + )} +
+ )} +
+ + {/* Per-slide grid */} +
+
+ + 单页产物 + {!isGenerating && slideArtifacts.length > 0 && ( + 点击卡片底部「修改」可对单页提出修改意见 + )} +
+
+ {outlineData.map((slide, idx) => { + const artifact = slideArtifacts.find((a) => a.index === idx); + const isPatching = patchingSlideIndex === idx; + const isPatchOpen = openPatchIndex === idx; + const canPatch = !isGenerating && artifact != null && !isPatching; + + return ( +
+ {/* Preview area */} +
+ {isPatching ? ( +
+ + 正在修改... +
+ ) : artifact?.previewUrl ? ( + {artifact.title + ) : artifact?.status === 'code_ready' ? ( +
+ + 代码已生成 +
+ ) : artifact ? ( + 已生成(无缩略图) + ) : ( + + )} +
+ + {/* Card info */} +
+
+ 第 {idx + 1} 页 · {artifact?.title || slide.title} +
+
+ {artifact?.pptxUrl ? ( + + 下载 PPTX + + ) : artifact?.status === 'code_ready' ? ( +
等待渲染...
+ ) : ( +
等待渲染...
+ )} + + {canPatch && ( + + )} +
+
+ + {/* Patch panel */} + {isPatchOpen && ( +
+ {/* Quick feedback buttons */} +
+ {QUICK_FEEDBACK.map((qf) => ( + + ))} +
+ + {/* Feedback textarea */} +