From f623300c9c51f1269613d70ff999c5e70f1d270f Mon Sep 17 00:00:00 2001 From: 13315423919 <13315423919@qq.com> Date: Fri, 7 Nov 2025 09:05:38 +0800 Subject: [PATCH] Add File --- src/landppt/web/routes.py | 6257 +++++++++++++++++++++++++++++++++++++ 1 file changed, 6257 insertions(+) create mode 100644 src/landppt/web/routes.py diff --git a/src/landppt/web/routes.py b/src/landppt/web/routes.py new file mode 100644 index 0000000..80f5d01 --- /dev/null +++ b/src/landppt/web/routes.py @@ -0,0 +1,6257 @@ +""" +Web interface routes for LandPPT +""" + +from fastapi import APIRouter, Request, Form, UploadFile, File, HTTPException, Depends +from fastapi.responses import HTMLResponse, StreamingResponse, FileResponse, JSONResponse +from fastapi.templating import Jinja2Templates +from pydantic import BaseModel +import json +import uuid +import asyncio +import time +import os +import zipfile +import tempfile +import shutil +from pathlib import Path +from datetime import datetime +import urllib.parse +import subprocess +import logging +import time +from typing import Optional, Dict, Any, List + +from ..api.models import PPTGenerationRequest, PPTProject, TodoBoard, FileOutlineGenerationRequest +from ..services.enhanced_ppt_service import EnhancedPPTService +from ..services.pdf_to_pptx_converter import get_pdf_to_pptx_converter +from ..services.pyppeteer_pdf_converter import get_pdf_converter +from ..core.config import ai_config +from ..ai import get_ai_provider, get_role_provider, AIMessage, MessageRole +from ..auth.middleware import get_current_user_required, get_current_user_optional +from ..database.models import User +from ..database.database import get_db +from sqlalchemy.orm import Session +from ..utils.thread_pool import run_blocking_io, to_thread +import re +from bs4 import BeautifulSoup + +# Configure logger for this module +logger = logging.getLogger(__name__) + +router = APIRouter() +templates = Jinja2Templates(directory="src/landppt/web/templates") + +# Add custom filters +def timestamp_to_datetime(timestamp): + """Convert timestamp to readable datetime string""" + try: + if isinstance(timestamp, (int, float)): + return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S") + return str(timestamp) + except (ValueError, OSError): + return "无效时间" + +def strftime_filter(timestamp, format_string="%Y-%m-%d %H:%M"): + """Jinja2 strftime filter""" + try: + if isinstance(timestamp, (int, float)): + dt = datetime.fromtimestamp(timestamp) + return dt.strftime(format_string) + return str(timestamp) + except (ValueError, OSError): + return "无效时间" + +# Register custom filters +templates.env.filters["timestamp_to_datetime"] = timestamp_to_datetime +templates.env.filters["strftime"] = strftime_filter + +# Import shared service instances to ensure data consistency +from ..services.service_instances import ppt_service + +# AI编辑请求数据模型 +class AISlideEditRequest(BaseModel): + slideIndex: int + slideTitle: str + slideContent: str + userRequest: str + projectInfo: Dict[str, Any] + slideOutline: Optional[Dict[str, Any]] = None + chatHistory: Optional[List[Dict[str, str]]] = None + images: Optional[List[Dict[str, str]]] = None # 新增:图片信息列表 + visionEnabled: Optional[bool] = False # 新增:视觉模式启用状态 + slideScreenshot: Optional[str] = None # 新增:幻灯片截图数据(base64格式) + +# AI要点增强请求数据模型 +class AIBulletPointEnhanceRequest(BaseModel): + slideIndex: int + slideTitle: str + slideContent: str + userRequest: str + projectInfo: Dict[str, Any] + slideOutline: Optional[Dict[str, Any]] = None + contextInfo: Optional[Dict[str, Any]] = None # 包含原始要点、其他要点等上下文信息 + +# 图像重新生成请求数据模型 +class AIImageRegenerateRequest(BaseModel): + slide_index: int + image_info: Dict[str, Any] + slide_content: Dict[str, Any] + project_topic: str + project_scenario: str + regeneration_reason: Optional[str] = None + +# 一键配图请求数据模型 +class AIAutoImageGenerateRequest(BaseModel): + slide_index: int + slide_content: Dict[str, Any] + project_topic: str + project_scenario: str + +# 演讲稿生成请求数据模型 +class SpeechScriptGenerationRequest(BaseModel): + generation_type: str # "single", "multi", "full" + slide_indices: Optional[List[int]] = None # For single and multi generation + customization: Dict[str, Any] = {} # Customization options + +class SpeechScriptExportRequest(BaseModel): + export_format: str # "docx", "markdown" + scripts_data: List[Dict[str, Any]] + include_metadata: bool = True + +# 图片导出PPTX请求数据模型 +class ImagePPTXExportRequest(BaseModel): + slides: Optional[List[Dict[str, Any]]] = None # 包含index, html_content, title + images: Optional[List[Dict[str, Any]]] = None # 包含index, data(base64), width, height (向后兼容) + +# Helper function to extract slides from HTML content +async def _extract_slides_from_html(slides_html: str, existing_slides_data: list) -> list: + """ + Extract individual slides from combined HTML content and update slides_data + """ + try: + # Parse HTML content + soup = BeautifulSoup(slides_html, 'html.parser') + + # Find all slide containers - look for common slide patterns + slide_containers = [] + + # Try different patterns to find slides + patterns = [ + {'class': re.compile(r'slide')}, + {'class': re.compile(r'page')}, + {'style': re.compile(r'width:\s*1280px.*height:\s*720px', re.IGNORECASE)}, + {'style': re.compile(r'aspect-ratio:\s*16\s*/\s*9', re.IGNORECASE)} + ] + + for pattern in patterns: + containers = soup.find_all('div', pattern) + if containers: + slide_containers = containers + break + + # If no specific slide containers found, try to split by common separators + if not slide_containers: + # Look for sections or divs that might represent slides + all_divs = soup.find_all('div') + # Filter divs that might be slides (have substantial content) + slide_containers = [div for div in all_divs + if div.get_text(strip=True) and len(div.get_text(strip=True)) > 50] + + updated_slides_data = [] + + # If we found slide containers, extract them + if slide_containers: + for i, container in enumerate(slide_containers): + # Try to extract title from the slide + title = f"第{i+1}页" + title_elements = container.find_all(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']) + if title_elements: + title = title_elements[0].get_text(strip=True) or title + + # Get the HTML content of this slide + slide_html = str(container) + + # Create slide data + slide_data = { + "page_number": i + 1, + "title": title, + "html_content": slide_html, + "is_user_edited": True # Mark as user edited since it came from editor + } + + # If we have existing slide data, preserve some fields + if i < len(existing_slides_data): + existing_slide = existing_slides_data[i] + # Preserve any additional fields from existing data + for key, value in existing_slide.items(): + if key not in slide_data: + slide_data[key] = value + + updated_slides_data.append(slide_data) + + # If we couldn't extract individual slides, treat the entire content as slides + if not updated_slides_data and existing_slides_data: + # Fall back to using existing slides structure but mark as edited + for i, existing_slide in enumerate(existing_slides_data): + slide_data = existing_slide.copy() + slide_data["is_user_edited"] = True + updated_slides_data.append(slide_data) + + # If we still have no slides but have HTML content, create a single slide + if not updated_slides_data and slides_html.strip(): + slide_data = { + "page_number": 1, + "title": "编辑后的PPT", + "html_content": slides_html, + "is_user_edited": True + } + updated_slides_data.append(slide_data) + + logger.info(f"Extracted {len(updated_slides_data)} slides from HTML content") + return updated_slides_data + + except Exception as e: + logger.error(f"Error extracting slides from HTML: {e}") + # Fall back to marking existing slides as edited + if existing_slides_data: + updated_slides_data = [] + for slide in existing_slides_data: + slide_copy = slide.copy() + slide_copy["is_user_edited"] = True + updated_slides_data.append(slide_copy) + return updated_slides_data + else: + return [] + +@router.get("/home", response_class=HTMLResponse) +async def web_home( + request: Request, + user: User = Depends(get_current_user_required) +): + """Main web interface home page - redirect to dashboard for existing users""" + # Check if user has projects, if so redirect to dashboard + try: + projects_response = await ppt_service.project_manager.list_projects(page=1, page_size=1) + if projects_response.total > 0: + # User has projects, redirect to dashboard + from fastapi.responses import RedirectResponse + return RedirectResponse(url="/dashboard", status_code=302) + except: + pass # If error, show index page + + # New user or error, show index page + return templates.TemplateResponse("index.html", { + "request": request, + "ai_provider": ai_config.default_ai_provider, + "available_providers": ai_config.get_available_providers() + }) + +@router.get("/ai-config", response_class=HTMLResponse) +async def web_ai_config( + request: Request, + user: User = Depends(get_current_user_required) +): + """AI configuration page""" + from ..services.config_service import get_config_service + + config_service = get_config_service() + current_config = config_service.get_all_config() + + return templates.TemplateResponse("ai_config.html", { + "request": request, + "current_provider": ai_config.default_ai_provider, + "available_providers": ai_config.get_available_providers(), + "provider_status": { + provider: ai_config.is_provider_available(provider) + for provider in ai_config.get_available_providers() + }, + "current_config": current_config, + "user": user.to_dict() + }) + +@router.post("/api/ai/providers/openai/models") +async def get_openai_models( + request: Request, + user: User = Depends(get_current_user_required) +): + """Proxy endpoint to get OpenAI models list, avoiding CORS issues - uses frontend provided config""" + try: + import aiohttp + import json + + # Get configuration from frontend request + data = await request.json() + base_url = data.get('base_url', 'https://api.openai.com/v1') + api_key = data.get('api_key', '') + + logger.info(f"Frontend requested models from: {base_url}") + + if not api_key: + return {"success": False, "error": "API Key is required"} + + # Ensure base URL ends with /v1 + if not base_url.endswith('/v1'): + base_url = base_url.rstrip('/') + '/v1' + + models_url = f"{base_url}/models" + logger.info(f"Fetching models from: {models_url}") + + # Make request to OpenAI API using frontend provided credentials + async with aiohttp.ClientSession() as session: + headers = { + 'Authorization': f'Bearer {api_key}', + 'Content-Type': 'application/json' + } + + async with session.get(models_url, headers=headers, timeout=30) as response: + if response.status == 200: + data = await response.json() + + # Filter and sort models + models = [] + if 'data' in data and isinstance(data['data'], list): + for model in data['data']: + if model.get('id'): + models.append({ + 'id': model['id'], + 'created': model.get('created', 0), + 'owned_by': model.get('owned_by', 'unknown') + }) + + # Sort models with GPT-4 first, then GPT-3.5, then others + def get_priority(model_id): + if 'gpt-4' in model_id: + return 0 + elif 'gpt-3.5' in model_id: + return 1 + else: + return 2 + + models.sort(key=lambda x: (get_priority(x['id']), x['id'])) + logger.info(f"Successfully fetched {len(models)} models from {base_url}") + return {"success": True, "models": models} + else: + error_text = await response.text() + logger.error(f"Failed to fetch models from {base_url}: {response.status} - {error_text}") + return {"success": False, "error": f"API returned status {response.status}: {error_text}"} + + except Exception as e: + logger.error(f"Error fetching OpenAI models from frontend config: {e}") + return {"success": False, "error": str(e)} + +@router.post("/api/ai/providers/openai/test") +async def test_openai_provider_proxy( + request: Request, + user: User = Depends(get_current_user_required) +): + """Proxy endpoint to test OpenAI provider, avoiding CORS issues - uses frontend provided config""" + try: + import aiohttp + + # Get configuration from frontend request + data = await request.json() + base_url = data.get('base_url', 'https://api.openai.com/v1') + api_key = data.get('api_key', '') + model = data.get('model', 'gpt-4o') + + logger.info(f"Frontend requested test with: base_url={base_url}, model={model}") + + if not api_key: + return {"success": False, "error": "API Key is required"} + + # Ensure base URL ends with /v1 + if not base_url.endswith('/v1'): + base_url = base_url.rstrip('/') + '/v1' + + chat_url = f"{base_url}/chat/completions" + logger.info(f"Testing OpenAI provider at: {chat_url}") + + # Make test request to OpenAI API using frontend provided credentials + async with aiohttp.ClientSession() as session: + headers = { + 'Authorization': f'Bearer {api_key}', + 'Content-Type': 'application/json' + } + + payload = { + "model": model, + "messages": [ + { + "role": "user", + "content": "Say 'Hello, I am working!' in exactly 5 words." + } + ], + "max_tokens": 20, + "temperature": 0 + } + + async with session.post(chat_url, headers=headers, json=payload, timeout=30) as response: + if response.status == 200: + data = await response.json() + + logger.info(f"Test successful for {base_url} with model {model}") + + # Return with consistent format that frontend expects + return { + "success": True, + "status": "success", # Add status field for compatibility + "provider": "openai", + "model": model, + "response_preview": data['choices'][0]['message']['content'], + "usage": data.get('usage', { + "prompt_tokens": 0, + "completion_tokens": 0, + "total_tokens": 0 + }) + } + else: + error_text = await response.text() + try: + error_data = json.loads(error_text) + error_message = error_data.get('error', {}).get('message', f"API returned status {response.status}") + except: + error_message = f"API returned status {response.status}: {error_text}" + + logger.error(f"Test failed for {base_url}: {error_message}") + + return { + "success": False, + "status": "error", # Add status field for compatibility + "error": error_message + } + + except Exception as e: + logger.error(f"Error testing OpenAI provider with frontend config: {e}") + return { + "success": False, + "status": "error", # Add status field for compatibility + "error": str(e) + } + +@router.get("/scenarios", response_class=HTMLResponse) +async def web_scenarios( + request: Request, + user: User = Depends(get_current_user_required) +): + """Scenarios selection page""" + scenarios = [ + {"id": "general", "name": "通用", "description": "适用于各种通用场景的PPT模板", "icon": "📋"}, + {"id": "tourism", "name": "旅游观光", "description": "旅游线路、景点介绍等旅游相关PPT", "icon": "🌍"}, + {"id": "education", "name": "儿童科普", "description": "适合儿童的科普教育PPT", "icon": "🎓"}, + {"id": "analysis", "name": "深入分析", "description": "数据分析、研究报告等深度分析PPT", "icon": "📊"}, + {"id": "history", "name": "历史文化", "description": "历史事件、文化介绍等人文类PPT", "icon": "🏛️"}, + {"id": "technology", "name": "科技技术", "description": "技术介绍、产品发布等科技类PPT", "icon": "💻"}, + {"id": "business", "name": "方案汇报", "description": "商业计划、项目汇报等商务PPT", "icon": "💼"} + ] + return templates.TemplateResponse("scenarios.html", {"request": request, "scenarios": scenarios}) + +# Legacy route removed - now using /projects/create for new project workflow + +# Legacy task status route removed - now using project detail pages + +# Legacy preview route removed - now using project-based preview at /projects/{project_id}/fullscreen + +# Legacy tasks list route removed - now using /projects for project management + +@router.post("/upload", response_class=HTMLResponse) +async def web_upload_file( + request: Request, + file: UploadFile = File(...), + user: User = Depends(get_current_user_required) +): + """Upload file via web interface""" + try: + # Validate file type + allowed_types = [".docx", ".pdf", ".txt", ".md"] + file_extension = "." + file.filename.split(".")[-1].lower() + + if file_extension not in allowed_types: + return templates.TemplateResponse("upload_result.html", { + "request": request, + "success": False, + "error": f"Unsupported file type. Allowed types: {', '.join(allowed_types)}" + }) + + # Read file content in thread pool to avoid blocking + content = await file.read() + + # Process file in thread pool + processed_content = await ppt_service.process_uploaded_file( + filename=file.filename, + content=content, + file_type=file_extension + ) + + return templates.TemplateResponse("upload_result.html", { + "request": request, + "success": True, + "filename": file.filename, + "size": len(content), + "type": file_extension, + "processed_content": processed_content[:500] + "..." if len(processed_content) > 500 else processed_content + }) + + except Exception as e: + return templates.TemplateResponse("upload_result.html", { + "request": request, + "success": False, + "error": str(e) + }) + +@router.get("/demo", response_class=HTMLResponse) +async def web_demo( + request: Request, + user: User = Depends(get_current_user_required) +): + """Demo page with sample PPT""" + # Create a demo PPT + demo_request = PPTGenerationRequest( + scenario="technology", + topic="人工智能技术发展趋势", + requirements="面向技术人员的深度分析", + network_mode=False, + language="zh" + ) + + task_id = "demo-" + str(uuid.uuid4())[:8] + result = await ppt_service.generate_ppt(task_id, demo_request) + + return templates.TemplateResponse("demo.html", { + "request": request, + "task_id": task_id, + "outline": result.get("outline"), + "slides_html": result.get("slides_html"), + "demo_topic": demo_request.topic + }) + +@router.get("/research", response_class=HTMLResponse) +async def web_research_status( + request: Request, + user: User = Depends(get_current_user_required) +): + """DEEP Research status and management page""" + return templates.TemplateResponse("research_status.html", { + "request": request + }) + +# New Project Management Routes + +@router.get("/dashboard", response_class=HTMLResponse) +async def web_dashboard( + request: Request, + user: User = Depends(get_current_user_required) +): + """Project dashboard with overview""" + try: + # Get project statistics + projects_response = await ppt_service.project_manager.list_projects(page=1, page_size=100) + projects = projects_response.projects + + total_projects = len(projects) + completed_projects = len([p for p in projects if p.status == "completed"]) + in_progress_projects = len([p for p in projects if p.status == "in_progress"]) + draft_projects = len([p for p in projects if p.status == "draft"]) + + # Get recent projects (last 5) + recent_projects = sorted(projects, key=lambda x: x.updated_at, reverse=True)[:5] + + # Get active TODO boards + active_todo_boards = [] + for project in projects: + if project.status == "in_progress" and project.todo_board: + todo_board = await ppt_service.get_project_todo_board(project.project_id) + if todo_board: + active_todo_boards.append(todo_board) + + return templates.TemplateResponse("project_dashboard.html", { + "request": request, + "total_projects": total_projects, + "completed_projects": completed_projects, + "in_progress_projects": in_progress_projects, + "draft_projects": draft_projects, + "recent_projects": recent_projects, + "active_todo_boards": active_todo_boards[:3] # Show max 3 boards + }) + + except Exception as e: + return templates.TemplateResponse("error.html", { + "request": request, + "error": str(e) + }) + +@router.get("/projects", response_class=HTMLResponse) +async def web_projects_list( + request: Request, + page: int = 1, + status: str = None, + user: User = Depends(get_current_user_required) +): + """List all projects""" + try: + projects_response = await ppt_service.project_manager.list_projects( + page=page, page_size=10, status=status + ) + + return templates.TemplateResponse("projects_list.html", { + "request": request, + "projects": projects_response.projects, + "total": projects_response.total, + "page": projects_response.page, + "page_size": projects_response.page_size, + "status_filter": status + }) + + except Exception as e: + return templates.TemplateResponse("error.html", { + "request": request, + "error": str(e) + }) + +@router.get("/projects/{project_id}", response_class=HTMLResponse) +async def web_project_detail( + request: Request, + project_id: str, + user: User = Depends(get_current_user_required) +): + """Project detail page""" + try: + project = await ppt_service.project_manager.get_project(project_id) + if not project: + return templates.TemplateResponse("error.html", { + "request": request, + "error": "Project not found" + }) + + todo_board = await ppt_service.get_project_todo_board(project_id) + versions = await ppt_service.project_manager.get_project_versions(project_id) + + return templates.TemplateResponse("project_detail.html", { + "request": request, + "project": project, + "todo_board": todo_board, + "versions": versions + }) + + except Exception as e: + return templates.TemplateResponse("error.html", { + "request": request, + "error": str(e) + }) + +@router.get("/projects/{project_id}/todo", response_class=HTMLResponse) +async def web_project_todo_board( + request: Request, + project_id: str, + user: User = Depends(get_current_user_required) +): + """TODO board page for a project with integrated editor""" + try: + # Validate project_id format (should be UUID-like) + if project_id in ["template-selection", "todo", "edit", "preview", "fullscreen"]: + error_msg = f"无效的项目ID: {project_id}。\n\n" + error_msg += "可能的原因:\n" + error_msg += "1. URL格式错误,正确格式应为: /projects/[项目ID]/todo\n" + error_msg += "2. 您可能访问了错误的链接\n\n" + error_msg += "建议解决方案:\n" + error_msg += "• 返回项目列表页面选择正确的项目\n" + error_msg += "• 检查浏览器地址栏中的URL是否完整" + + return templates.TemplateResponse("error.html", { + "request": request, + "error": error_msg + }) + + # Check if project exists first + project = await ppt_service.project_manager.get_project(project_id) + if not project: + return templates.TemplateResponse("error.html", { + "request": request, + "error": f"项目不存在 (ID: {project_id})。请检查项目ID是否正确。" + }) + + todo_board = await ppt_service.get_project_todo_board(project_id) + if not todo_board: + return templates.TemplateResponse("error.html", { + "request": request, + "error": f"项目 '{project.topic}' 的TODO看板不存在。请联系技术支持。" + }) + + # Check if we should use the integrated editor version + project = await ppt_service.project_manager.get_project(project_id) + use_integrated_editor = ( + project and + project.confirmed_requirements and + len(todo_board.stages) > 2 and + (todo_board.stages[1].status in ['running', 'completed'] or + todo_board.stages[2].status in ['running', 'completed']) + ) + + # Also use integrated editor if PPT creation stage is about to start or running + if (project and project.confirmed_requirements and len(todo_board.stages) > 2 and + todo_board.stages[1].status == 'completed'): + use_integrated_editor = True + + template_name = "todo_board_with_editor.html" if use_integrated_editor else "todo_board.html" + + # Ensure project is not None for template + template_context = { + "request": request, + "todo_board": todo_board + } + + # Only add project if it exists + if project: + template_context["project"] = project + + return templates.TemplateResponse(template_name, template_context) + + except Exception as e: + return templates.TemplateResponse("error.html", { + "request": request, + "error": str(e) + }) + + + +@router.get("/projects/{project_id}/fullscreen", response_class=HTMLResponse) +async def web_project_fullscreen( + request: Request, + project_id: str, + user: User = Depends(get_current_user_required) +): + """Fullscreen preview of project PPT with modern presentation interface""" + try: + # 直接从数据库获取最新的项目数据,确保数据实时性 + from ..services.db_project_manager import DatabaseProjectManager + db_manager = DatabaseProjectManager() + project = await db_manager.get_project(project_id) + + if not project: + return templates.TemplateResponse("error.html", { + "request": request, + "error": "项目未找到" + }) + + # 检查是否有幻灯片数据 + if not project.slides_data or len(project.slides_data) == 0: + return templates.TemplateResponse("error.html", { + "request": request, + "error": "PPT尚未生成或无幻灯片内容" + }) + + # 使用新的分享演示模板 + return templates.TemplateResponse("project_fullscreen_presentation.html", { + "request": request, + "project": project, + "slides_count": len(project.slides_data) + }) + + except Exception as e: + logger.error(f"Error in fullscreen presentation: {e}") + return templates.TemplateResponse("error.html", { + "request": request, + "error": f"加载演示时出错: {str(e)}" + }) + +@router.get("/share/{share_token}", response_class=HTMLResponse) +async def web_shared_presentation( + request: Request, + share_token: str, + db: Session = Depends(get_db) +): + """Public presentation view - no authentication required""" + try: + from ..services.share_service import ShareService + share_service = ShareService(db) + + # Validate share token and get project + project_model = share_service.validate_share_token(share_token) + + if not project_model: + return templates.TemplateResponse("error.html", { + "request": request, + "error": "分享链接无效或已失效" + }) + + # Check if project has slides + if not project_model.slides_data or len(project_model.slides_data) == 0: + return templates.TemplateResponse("error.html", { + "request": request, + "error": "演示文稿尚未生成" + }) + + # Convert to PPTProject for template compatibility + from ..api.models import PPTProject + project = PPTProject( + project_id=project_model.project_id, + title=project_model.title, + scenario=project_model.scenario, + topic=project_model.topic, + requirements=project_model.requirements, + status=project_model.status, + outline=project_model.outline, + slides_html=project_model.slides_html, + slides_data=project_model.slides_data, + confirmed_requirements=project_model.confirmed_requirements, + version=project_model.version, + created_at=project_model.created_at, + updated_at=project_model.updated_at + ) + + # Render presentation template + return templates.TemplateResponse("project_fullscreen_presentation.html", { + "request": request, + "project": project, + "slides_count": len(project.slides_data), + "is_shared": True # Flag to indicate this is a shared view + }) + + except Exception as e: + logger.error(f"Error displaying shared presentation: {e}") + return templates.TemplateResponse("error.html", { + "request": request, + "error": f"加载分享演示时出错: {str(e)}" + }) + + +@router.get("/api/share/{share_token}/slides-data") +async def get_shared_slides_data( + share_token: str, + db: Session = Depends(get_db) +): + """Get slides data for public shared presentation - no authentication required""" + try: + from ..services.share_service import ShareService + share_service = ShareService(db) + + # Validate share token and get project + project = share_service.validate_share_token(share_token) + + if not project: + raise HTTPException(status_code=404, detail="分享链接无效或已失效") + + if not project.slides_data or len(project.slides_data) == 0: + return { + "status": "no_slides", + "message": "PPT尚未生成", + "slides_data": [], + "total_slides": 0 + } + + return { + "status": "success", + "slides_data": project.slides_data, + "total_slides": len(project.slides_data), + "project_title": project.title, + "updated_at": project.updated_at + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error getting shared slides data: {e}") + raise HTTPException(status_code=500, detail=f"获取幻灯片数据失败: {str(e)}") + + +@router.get("/api/projects/{project_id}/slides-data") +async def get_project_slides_data( + project_id: str, + user: User = Depends(get_current_user_required) +): + """获取项目最新的幻灯片数据 - 用于分享演示实时更新""" + try: + # 直接从数据库获取最新数据 + from ..services.db_project_manager import DatabaseProjectManager + db_manager = DatabaseProjectManager() + project = await db_manager.get_project(project_id) + + if not project: + raise HTTPException(status_code=404, detail="项目未找到") + + if not project.slides_data or len(project.slides_data) == 0: + return { + "status": "no_slides", + "message": "PPT尚未生成", + "slides_data": [], + "total_slides": 0 + } + + return { + "status": "success", + "slides_data": project.slides_data, + "total_slides": len(project.slides_data), + "project_title": project.title, + "updated_at": project.updated_at + } + + except Exception as e: + logger.error(f"Error getting slides data: {e}") + raise HTTPException(status_code=500, detail=f"获取幻灯片数据失败: {str(e)}") + + +@router.post("/api/projects/{project_id}/share/generate") +async def generate_share_link( + project_id: str, + user: User = Depends(get_current_user_required), + db: Session = Depends(get_db) +): + """Generate a public share link for a project""" + try: + from ..services.share_service import ShareService + share_service = ShareService(db) + + # Verify project exists and belongs to user + from ..services.db_project_manager import DatabaseProjectManager + db_manager = DatabaseProjectManager() + project = await db_manager.get_project(project_id) + + if not project: + raise HTTPException(status_code=404, detail="项目未找到") + + # Generate share token + share_token = share_service.generate_share_token(project_id) + + if not share_token: + raise HTTPException(status_code=500, detail="生成分享链接失败") + + # Construct full share URL + share_url = f"/share/{share_token}" + + return { + "success": True, + "share_token": share_token, + "share_url": share_url, + "message": "分享链接已生成" + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error generating share link: {e}") + raise HTTPException(status_code=500, detail=f"生成分享链接失败: {str(e)}") + + +@router.post("/api/projects/{project_id}/share/disable") +async def disable_share_link( + project_id: str, + user: User = Depends(get_current_user_required), + db: Session = Depends(get_db) +): + """Disable sharing for a project""" + try: + from ..services.share_service import ShareService + share_service = ShareService(db) + + # Verify project exists + from ..services.db_project_manager import DatabaseProjectManager + db_manager = DatabaseProjectManager() + project = await db_manager.get_project(project_id) + + if not project: + raise HTTPException(status_code=404, detail="项目未找到") + + # Disable sharing + success = share_service.disable_sharing(project_id) + + if not success: + raise HTTPException(status_code=500, detail="禁用分享失败") + + return { + "success": True, + "message": "分享已禁用" + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error disabling share: {e}") + raise HTTPException(status_code=500, detail=f"禁用分享失败: {str(e)}") + + +@router.get("/api/projects/{project_id}/share/info") +async def get_share_info( + project_id: str, + user: User = Depends(get_current_user_required), + db: Session = Depends(get_db) +): + """Get share information for a project""" + try: + from ..services.share_service import ShareService + share_service = ShareService(db) + + # Verify project exists + from ..services.db_project_manager import DatabaseProjectManager + db_manager = DatabaseProjectManager() + project = await db_manager.get_project(project_id) + + if not project: + raise HTTPException(status_code=404, detail="项目未找到") + + # Get share info + share_info = share_service.get_share_info(project_id) + + return { + "success": True, + **share_info + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error getting share info: {e}") + raise HTTPException(status_code=500, detail=f"获取分享信息失败: {str(e)}") + + +@router.get("/test/slides-navigation", response_class=HTMLResponse) +async def test_slides_navigation( + request: Request, + user: User = Depends(get_current_user_required) +): + """测试幻灯片导航功能""" + with open("test_slides_navigation.html", "r", encoding="utf-8") as f: + content = f.read() + return HTMLResponse(content=content) + +@router.get("/temp/{file_path:path}") +async def serve_temp_file( + file_path: str, + user: User = Depends(get_current_user_required) +): + """Serve temporary slide files""" + try: + # Construct the full path to the temp file using system temp directory + import tempfile + temp_dir = Path(tempfile.gettempdir()) / "landppt" + full_path = temp_dir / file_path + + # Security check: ensure the file is within the temp directory + if not str(full_path.resolve()).startswith(str(temp_dir.resolve())): + raise HTTPException(status_code=403, detail="Access denied") + + # Check if file exists + if not full_path.exists(): + raise HTTPException(status_code=404, detail="File not found") + + # Return the file + return FileResponse( + path=str(full_path), + media_type="text/html; charset=utf-8", + headers={"Cache-Control": "no-cache"} + ) + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.post("/projects/create", response_class=HTMLResponse) +async def web_create_project( + request: Request, + scenario: str = Form(...), + topic: str = Form(...), + requirements: str = Form(None), + language: str = Form("zh"), + network_mode: bool = Form(False), + user: User = Depends(get_current_user_required) +): + """Create new project via web interface""" + try: + # Create project request + project_request = PPTGenerationRequest( + scenario=scenario, + topic=topic, + requirements=requirements, + network_mode=network_mode, + language=language + ) + + # Create project with TODO board (without starting workflow yet) + project = await ppt_service.project_manager.create_project(project_request) + + # Update project status to in_progress + await ppt_service.project_manager.update_project_status(project.project_id, "in_progress") + + # Redirect directly to TODO page without showing redirect page + from fastapi.responses import RedirectResponse + return RedirectResponse( + url=f"/projects/{project.project_id}/todo", + status_code=302 + ) + + except Exception as e: + return templates.TemplateResponse("error.html", { + "request": request, + "error": str(e) + }) + +@router.post("/projects/{project_id}/start-workflow") +async def start_project_workflow( + project_id: str, + user: User = Depends(get_current_user_required) +): + """Start the AI workflow for a project (only if requirements are confirmed)""" + try: + # Get project + project = await ppt_service.project_manager.get_project(project_id) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + # Check if requirements are confirmed + if not project.confirmed_requirements: + return {"status": "waiting", "message": "Waiting for requirements confirmation"} + + # Extract network_mode from project metadata + network_mode = False + if project.project_metadata and isinstance(project.project_metadata, dict): + network_mode = project.project_metadata.get("network_mode", False) + + # Create project request from project data + confirmed_requirements = project.confirmed_requirements or {} + project_request = PPTGenerationRequest( + scenario=project.scenario, + topic=project.topic, + requirements=project.requirements, + language="zh", # Default language + network_mode=network_mode, + target_audience=confirmed_requirements.get('target_audience', '普通大众'), + ppt_style=confirmed_requirements.get('ppt_style', 'general'), + custom_style_prompt=confirmed_requirements.get('custom_style_prompt'), + description=confirmed_requirements.get('description') + ) + + # Start the workflow in background + asyncio.create_task(ppt_service._execute_project_workflow(project_id, project_request)) + + return {"status": "success", "message": "Workflow started"} + + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.get("/projects/{project_id}/requirements", response_class=HTMLResponse) +async def project_requirements_page( + request: Request, + project_id: str, + user: User = Depends(get_current_user_required) +): + """Show project requirements confirmation page""" + try: + # Get project + project = await ppt_service.project_manager.get_project(project_id) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + # 提供默认的展示类型选项,不再调用AI生成建议 + default_type_options = [ + "技术分享", + "产品介绍", + "学术报告", + "商业汇报", + "教学课件", + "项目展示", + "数据分析", + "综合介绍" + ] + + return templates.TemplateResponse("project_requirements.html", { + "request": request, + "project": project, + "ai_suggestions": { + "type_options": default_type_options + } + }) + + except Exception as e: + return templates.TemplateResponse("error.html", { + "request": request, + "error": str(e) + }) + +# 移除AI生成需求建议的API端点,改为使用默认选项 + +@router.get("/projects/{project_id}/outline-stream") +async def stream_outline_generation( + project_id: str, + user: User = Depends(get_current_user_required) +): + """Stream outline generation for a project""" + try: + project = await ppt_service.project_manager.get_project(project_id) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + async def generate(): + try: + async for chunk in ppt_service.generate_outline_streaming(project_id): + yield chunk + except Exception as e: + import json + error_response = {'error': str(e)} + yield f"data: {json.dumps(error_response)}\n\n" + + return StreamingResponse(generate(), media_type="text/plain") + + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.post("/projects/{project_id}/generate-outline") +async def generate_outline( + project_id: str, + user: User = Depends(get_current_user_required) +): + """Generate outline for a project (non-streaming)""" + try: + project = await ppt_service.project_manager.get_project(project_id) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + # Check if project has confirmed requirements + if not project.confirmed_requirements: + return { + "status": "error", + "error": "项目需求尚未确认,请先完成需求确认步骤" + } + + # Create PPTGenerationRequest from project data + confirmed_requirements = project.confirmed_requirements + + # Extract network_mode from project metadata + network_mode = False + if project.project_metadata and isinstance(project.project_metadata, dict): + network_mode = project.project_metadata.get("network_mode", False) + + project_request = PPTGenerationRequest( + scenario=project.scenario, + topic=confirmed_requirements.get('topic', project.topic), + requirements=project.requirements, + language="zh", # Default language + network_mode=network_mode, + target_audience=confirmed_requirements.get('target_audience', '普通大众'), + ppt_style=confirmed_requirements.get('ppt_style', 'general'), + custom_style_prompt=confirmed_requirements.get('custom_style_prompt'), + description=confirmed_requirements.get('description') + ) + + # Extract page count settings from confirmed requirements + page_count_settings = confirmed_requirements.get('page_count_settings', {}) + + # Generate outline using AI with page count settings + outline = await ppt_service.generate_outline(project_request, page_count_settings) + + # Convert outline to dict format + outline_dict = { + "title": outline.title, + "slides": outline.slides, + "metadata": outline.metadata + } + + # Format as JSON + import json + formatted_json = json.dumps(outline_dict, ensure_ascii=False, indent=2) + + # Update outline generation stage + await ppt_service._update_outline_generation_stage(project_id, outline_dict) + + return { + "status": "success", + "outline_content": formatted_json, + "message": "Outline generated successfully" + } + + except Exception as e: + logger.error(f"Error generating outline: {e}") + return { + "status": "error", + "error": str(e) + } + +@router.post("/projects/{project_id}/regenerate-outline") +async def regenerate_outline( + project_id: str, + request: Request, + user: User = Depends(get_current_user_required) +): + """Regenerate outline for a project (overwrites existing outline) with optional custom requirements""" + try: + # Get request body to extract custom requirements if provided + request_data = {} + try: + request_data = await request.json() + except: + pass # If no body or invalid JSON, use empty dict + + custom_requirements = request_data.get('custom_requirements', '') + + project = await ppt_service.project_manager.get_project(project_id) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + # Check if project has confirmed requirements + if not project.confirmed_requirements: + return { + "status": "error", + "error": "项目需求尚未确认,请先完成需求确认步骤" + } + + # Create project request from confirmed requirements + confirmed_requirements = project.confirmed_requirements + + # 如果提供了自定义需求,将其追加或覆盖原有需求 + final_requirements = confirmed_requirements.get('requirements', project.requirements) + if custom_requirements: + # 将自定义需求追加到原有需求 + if final_requirements: + final_requirements = f"{final_requirements}\n\n【本次重新生成的额外要求】\n{custom_requirements}" + else: + final_requirements = custom_requirements + + project_request = PPTGenerationRequest( + scenario=confirmed_requirements.get('scenario', 'general'), + topic=confirmed_requirements.get('topic', project.topic), + requirements=final_requirements, + language="zh", # Default language + network_mode=confirmed_requirements.get('network_mode', False), + target_audience=confirmed_requirements.get('target_audience', '普通大众'), + ppt_style=confirmed_requirements.get('ppt_style', 'general'), + custom_style_prompt=confirmed_requirements.get('custom_style_prompt'), + description=confirmed_requirements.get('description') + ) + + # Extract page count settings from confirmed requirements + page_count_settings = confirmed_requirements.get('page_count_settings', {}) + + # Check if this is a file-based project + is_file_project = confirmed_requirements.get('content_source') == 'file' + + if is_file_project: + # Check if file path exists + file_path = confirmed_requirements.get('file_path') + if not file_path: + return { + "status": "error", + "error": "文件路径信息丢失,请重新上传文件并确认需求" + } + + # Use file-based outline generation + file_request = FileOutlineGenerationRequest( + file_path=file_path, + filename=confirmed_requirements.get('filename', 'uploaded_file'), + topic=project_request.topic, + scenario=project_request.scenario, + requirements=confirmed_requirements.get('requirements', ''), + target_audience=confirmed_requirements.get('target_audience', '普通大众'), + page_count_mode=page_count_settings.get('mode', 'ai_decide'), + min_pages=page_count_settings.get('min_pages', 5), + max_pages=page_count_settings.get('max_pages', 20), + fixed_pages=page_count_settings.get('fixed_pages', 10), + ppt_style=confirmed_requirements.get('ppt_style', 'general'), + custom_style_prompt=confirmed_requirements.get('custom_style_prompt'), + file_processing_mode=confirmed_requirements.get('file_processing_mode', 'markitdown'), + content_analysis_depth=confirmed_requirements.get('content_analysis_depth', 'standard') + ) + + result = await ppt_service.generate_outline_from_file(file_request) + + if not result.success: + return { + "status": "error", + "error": result.error or "文件大纲生成失败" + } + + # Update outline generation stage + await ppt_service._update_outline_generation_stage(project_id, result.outline) + + # Format outline as JSON string + import json + outline_content = json.dumps(result.outline, ensure_ascii=False, indent=2) + + return { + "status": "success", + "outline_content": outline_content, + "message": "File-based outline regenerated successfully" + } + else: + # Use standard outline generation + outline = await ppt_service.generate_outline(project_request, page_count_settings) + + # Convert outline to dict format + outline_dict = { + "title": outline.title, + "slides": outline.slides, + "metadata": outline.metadata + } + + # Format as JSON + import json + formatted_json = json.dumps(outline_dict, ensure_ascii=False, indent=2) + + # Update outline generation stage + await ppt_service._update_outline_generation_stage(project_id, outline_dict) + + return { + "status": "success", + "outline_content": formatted_json, + "message": "Outline regenerated successfully" + } + + except Exception as e: + logger.error(f"Error regenerating outline: {e}") + return { + "status": "error", + "error": str(e) + } + +@router.post("/projects/{project_id}/generate-file-outline") +async def generate_file_outline( + project_id: str, + user: User = Depends(get_current_user_required) +): + """Generate outline from uploaded file (non-streaming)""" + try: + project = await ppt_service.project_manager.get_project(project_id) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + # Check if project has file-generated outline + file_generated_outline = None + + # 首先检查项目的outline字段 + if project.outline and project.outline.get('slides'): + # 检查是否是从文件生成的大纲 + metadata = project.outline.get('metadata', {}) + if metadata.get('generated_with_summeryfile') or metadata.get('generated_with_file'): + file_generated_outline = project.outline + logger.info(f"Project {project_id} has file-generated outline in project.outline, using it") + + # 如果项目outline中没有,再检查confirmed_requirements + if not file_generated_outline and project.confirmed_requirements and project.confirmed_requirements.get('file_generated_outline'): + file_generated_outline = project.confirmed_requirements['file_generated_outline'] + logger.info(f"Project {project_id} has file-generated outline in confirmed_requirements, using it") + + # If no existing outline but file upload is configured, wait a bit and check again + if not file_generated_outline and project.confirmed_requirements and project.confirmed_requirements.get('content_source') == 'file': + logger.info(f"Project {project_id} has file upload but no outline yet, waiting for file processing...") + + # Wait for file processing to complete (it should be done during requirements confirmation) + import asyncio + max_wait_time = 10 # Maximum wait time in seconds + wait_interval = 1 # Check every 1 second + + for i in range(max_wait_time): + await asyncio.sleep(wait_interval) + + # Refresh project data + project = await ppt_service.project_manager.get_project(project_id) + if project.confirmed_requirements and project.confirmed_requirements.get('file_generated_outline'): + file_generated_outline = project.confirmed_requirements['file_generated_outline'] + logger.info(f"Project {project_id} file outline found after waiting {i+1} seconds") + break + + if not file_generated_outline: + logger.warning(f"Project {project_id} file outline not found after waiting {max_wait_time} seconds") + + if file_generated_outline: + # Return the existing file-generated outline + import json + existing_outline = { + "title": file_generated_outline.get('title', project.topic), + "slides": file_generated_outline.get('slides', []), + "metadata": file_generated_outline.get('metadata', {}) + } + + # Ensure metadata includes correct identification + if 'metadata' not in existing_outline: + existing_outline['metadata'] = {} + existing_outline['metadata']['generated_with_summeryfile'] = True + existing_outline['metadata']['generated_at'] = time.time() + + formatted_json = json.dumps(existing_outline, ensure_ascii=False, indent=2) + + # Update outline generation stage + await ppt_service._update_outline_generation_stage(project_id, existing_outline) + + return { + "status": "success", + "outline_content": formatted_json, + "message": "File outline generated successfully" + } + else: + # Check if there's an uploaded file that needs processing + if (project.confirmed_requirements and + (project.confirmed_requirements.get('uploaded_files') or + project.confirmed_requirements.get('content_source') == 'file')): + logger.info(f"Project {project_id} has uploaded files, starting file outline generation") + + # Start file outline generation using summeryfile + try: + # Create a request object for file outline generation + from ..api.models import FileOutlineGenerationRequest + + # Get file information from confirmed requirements + uploaded_files = project.confirmed_requirements.get('uploaded_files', []) + if uploaded_files: + file_info = uploaded_files[0] # Use first file + # 使用确认的要求或项目创建时的要求作为fallback + confirmed_reqs = project.confirmed_requirements.get('requirements', '') + project_reqs = project.requirements or '' + final_reqs = confirmed_reqs or project_reqs + + file_request = FileOutlineGenerationRequest( + filename=file_info.get('filename', 'uploaded_file'), + file_path=file_info.get('file_path', ''), + topic=project.topic, + scenario='general', + requirements=final_reqs, + target_audience=project.confirmed_requirements.get('target_audience', '普通大众'), + page_count_mode=project.confirmed_requirements.get('page_count_settings', {}).get('mode', 'ai_decide'), + min_pages=project.confirmed_requirements.get('page_count_settings', {}).get('min_pages', 8), + max_pages=project.confirmed_requirements.get('page_count_settings', {}).get('max_pages', 15), + fixed_pages=project.confirmed_requirements.get('page_count_settings', {}).get('fixed_pages', 10), + ppt_style=project.confirmed_requirements.get('ppt_style', 'general'), + custom_style_prompt=project.confirmed_requirements.get('custom_style_prompt'), + file_processing_mode=project.confirmed_requirements.get('file_processing_mode', 'markitdown'), + content_analysis_depth=project.confirmed_requirements.get('content_analysis_depth', 'standard') + ) + + # Generate outline from file using summeryfile + outline_response = await ppt_service.generate_outline_from_file(file_request) + + if outline_response.success and outline_response.outline: + # Format the generated outline + import json + formatted_outline = outline_response.outline + + # Ensure metadata includes correct identification + if 'metadata' not in formatted_outline: + formatted_outline['metadata'] = {} + formatted_outline['metadata']['generated_with_summeryfile'] = True + formatted_outline['metadata']['generated_at'] = time.time() + + formatted_json = json.dumps(formatted_outline, ensure_ascii=False, indent=2) + + # Update outline generation stage + await ppt_service._update_outline_generation_stage(project_id, formatted_outline) + + return { + "status": "success", + "outline_content": formatted_json, + "message": "File outline generated successfully" + } + else: + error_msg = outline_response.error if hasattr(outline_response, 'error') else "Unknown error" + return { + "status": "error", + "error": f"Failed to generate outline from uploaded file: {error_msg}" + } + else: + return { + "status": "error", + "error": "No uploaded file information found in project requirements." + } + + except Exception as gen_error: + logger.error(f"Error generating outline from file: {gen_error}") + return { + "status": "error", + "error": f"Failed to generate outline from file: {str(gen_error)}" + } + else: + # No file outline found and no uploaded files + return { + "status": "error", + "error": "No file outline found. Please ensure you uploaded a file during requirements confirmation." + } + + except Exception as e: + logger.error(f"Error generating file outline: {e}") + return { + "status": "error", + "error": str(e) + } + +@router.post("/projects/{project_id}/update-outline") +async def update_project_outline( + project_id: str, + request: Request, + user: User = Depends(get_current_user_required) +): + """Update project outline content""" + try: + data = await request.json() + outline_content = data.get('outline_content', '') + + success = await ppt_service.update_project_outline(project_id, outline_content) + if success: + return {"status": "success", "message": "Outline updated"} + else: + raise HTTPException(status_code=500, detail="Failed to update outline") + + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.post("/projects/{project_id}/confirm-outline") +async def confirm_project_outline( + project_id: str, + user: User = Depends(get_current_user_required) +): + """Confirm project outline and enable PPT generation""" + try: + success = await ppt_service.confirm_project_outline(project_id) + if success: + return {"status": "success", "message": "Outline confirmed"} + else: + raise HTTPException(status_code=500, detail="Failed to confirm outline") + + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.get("/projects/{project_id}/todo-editor") +async def web_project_todo_editor( + request: Request, + project_id: str, + auto_start: bool = False, + user: User = Depends(get_current_user_required) +): + """Project TODO board with editor""" + try: + project = await ppt_service.project_manager.get_project(project_id) + if not project: + return templates.TemplateResponse("error.html", { + "request": request, + "error": "Project not found" + }) + + return templates.TemplateResponse("todo_board_with_editor.html", { + "request": request, + "todo_board": project.todo_board, + "project": project, + "auto_start": auto_start + }) + + except Exception as e: + return templates.TemplateResponse("error.html", { + "request": request, + "error": str(e) + }) + +@router.post("/projects/{project_id}/confirm-requirements") +async def confirm_project_requirements( + request: Request, + project_id: str, + topic: str = Form(...), + audience_type: str = Form(...), + custom_audience: str = Form(None), + page_count_mode: str = Form("ai_decide"), + min_pages: int = Form(8), + max_pages: int = Form(15), + fixed_pages: int = Form(10), + ppt_style: str = Form("general"), + custom_style_prompt: str = Form(None), + description: str = Form(None), + content_source: str = Form("manual"), + file_upload: List[UploadFile] = File(None), + file_processing_mode: str = Form("markitdown"), + content_analysis_depth: str = Form("standard"), + user: User = Depends(get_current_user_required) +): + """Confirm project requirements and generate TODO list - 支持多文件上传和联网搜索集成""" + try: + # Get project to access original requirements + project = await ppt_service.project_manager.get_project(project_id) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + # Extract network_mode from project metadata (set during project creation) + network_mode = False + if project.project_metadata and isinstance(project.project_metadata, dict): + network_mode = project.project_metadata.get("network_mode", False) + + # Process audience information + target_audience = audience_type + if audience_type == "自定义" and custom_audience: + target_audience = custom_audience + + # Handle file upload if content source is file + file_outline = None + if content_source == "file" and file_upload: + # Process uploaded files (support multiple files) and generate outline + # 使用项目创建时的 network_mode 参数 + file_outline = await _process_uploaded_files_for_outline( + file_upload, topic, target_audience, page_count_mode, min_pages, max_pages, + fixed_pages, ppt_style, custom_style_prompt, + file_processing_mode, content_analysis_depth, project.requirements, + enable_web_search=network_mode, # 使用项目的 network_mode + scenario=project.scenario, # 传递场景参数 + language="zh" # 传递语言参数 + ) + + # Update topic if it was extracted from file + if file_outline and file_outline.get('title') and not topic.strip(): + topic = file_outline['title'] + + # Process page count settings + page_count_settings = { + "mode": page_count_mode, + "min_pages": min_pages if page_count_mode == "custom_range" else None, + "max_pages": max_pages if page_count_mode == "custom_range" else None, + "fixed_pages": fixed_pages if page_count_mode == "fixed" else None + } + + # Update project with confirmed requirements + confirmed_requirements = { + "topic": topic, + "requirements": project.requirements, # 使用项目创建时的具体要求 + "target_audience": target_audience, + "audience_type": audience_type, + "custom_audience": custom_audience if audience_type == "自定义" else None, + "page_count_settings": page_count_settings, + "ppt_style": ppt_style, + "custom_style_prompt": custom_style_prompt if ppt_style == "custom" else None, + "description": description, + "content_source": content_source, + "file_processing_mode": file_processing_mode if content_source == "file" else None, + "content_analysis_depth": content_analysis_depth if content_source == "file" else None, + "file_generated_outline": file_outline + } + + # 如果是文件项目,保存文件信息 + if content_source == "file" and file_outline and 'file_info' in file_outline: + file_info = file_outline['file_info'] + file_path = file_info.get('file_path') or file_info.get('merged_file_path') + filename = file_info.get('filename') or file_info.get('merged_filename') + uploaded_files = file_info.get('uploaded_files') + + file_metadata = {} + if file_path: + file_metadata["file_path"] = file_path + if filename: + file_metadata["filename"] = filename + if uploaded_files: + file_metadata["uploaded_files"] = uploaded_files + + if file_metadata: + confirmed_requirements.update(file_metadata) + + # Store confirmed requirements in project + # 直接确认需求并更新TODO板,无需AI生成待办清单 + success = await ppt_service.confirm_requirements_and_update_workflow(project_id, confirmed_requirements) + + if not success: + raise Exception("需求确认失败") + + # Return JSON success response for AJAX request + from fastapi.responses import JSONResponse + return JSONResponse({ + "status": "success", + "message": "需求确认完成", + "redirect_url": f"/projects/{project_id}/todo" + }) + + except Exception as e: + from fastapi.responses import JSONResponse + return JSONResponse({ + "status": "error", + "message": str(e) + }, status_code=500) + +@router.get("/projects/{project_id}/stage-stream/{stage_id}") +async def stream_stage_response( + project_id: str, + stage_id: str, + user: User = Depends(get_current_user_required) +): + """Stream AI response for a complete stage""" + + async def generate_stage_stream(): + try: + # Get project and stage info + project = await ppt_service.project_manager.get_project(project_id) + if not project: + yield f"data: {json.dumps({'error': 'Project not found'})}\n\n" + return + + if not project.confirmed_requirements: + yield f"data: {json.dumps({'error': 'Project requirements not confirmed'})}\n\n" + return + + todo_board = await ppt_service.get_project_todo_board(project_id) + if not todo_board: + yield f"data: {json.dumps({'error': 'TODO board not found'})}\n\n" + return + + # Find the stage + stage = None + for s in todo_board.stages: + if s.id == stage_id: + stage = s + break + + if not stage: + yield f"data: {json.dumps({'error': 'Stage not found'})}\n\n" + return + + # Extract confirmed requirements from project + confirmed_requirements = project.confirmed_requirements + + # Check if stage is already running or completed + if stage.status == "running": + yield f"data: {json.dumps({'error': 'Stage is already running'})}\n\n" + return + elif stage.status == "completed": + yield f"data: {json.dumps({'error': 'Stage is already completed'})}\n\n" + return + + # Update stage status to running + await ppt_service.project_manager.update_stage_status( + project_id, stage_id, "running", 0.0 + ) + + # Execute the complete stage using the enhanced service + try: + if stage_id == "outline_generation": + response_content = await ppt_service._execute_outline_generation( + project_id, confirmed_requirements, ppt_service._load_prompts_md_system_prompt() + ) + elif stage_id == "ppt_creation": + response_content = await ppt_service._execute_ppt_creation( + project_id, confirmed_requirements, ppt_service._load_prompts_md_system_prompt() + ) + else: + # Fallback for other stages + response_content = await ppt_service._execute_general_stage( + project_id, stage_id, confirmed_requirements + ) + + # Stream the response word by word for better UX + if isinstance(response_content, dict): + content_text = response_content.get('message', str(response_content)) + else: + content_text = str(response_content) + + words = content_text.split() + for i, word in enumerate(words): + yield f"data: {json.dumps({'content': word + ' ', 'done': False})}\n\n" + await asyncio.sleep(0.05) # Small delay for streaming effect + + except Exception as e: + # Fallback to basic stage execution + prompt = f""" +作为PPT生成助手,请完成以下阶段任务: + +项目主题:{project.topic} +项目场景:{project.scenario} +项目要求:{project.requirements or '无特殊要求'} + +当前阶段:{stage.name} +阶段描述:{stage.description} + +请根据以上信息完成当前阶段的完整任务,并提供详细的执行结果。 +""" + + # Stream AI response using real streaming + async for chunk in ppt_service.ai_provider.stream_text_completion( + prompt=prompt, + max_tokens=2000, + temperature=0.7 + ): + if chunk: + yield f"data: {json.dumps({'content': chunk, 'done': False})}\n\n" + + # Update stage status to completed + await ppt_service.project_manager.update_stage_status( + project_id, stage_id, "completed", 100.0 + ) + + # Send completion signal + yield f"data: {json.dumps({'content': '', 'done': True})}\n\n" + + except Exception as e: + yield f"data: {json.dumps({'error': str(e)})}\n\n" + + return StreamingResponse( + generate_stage_stream(), + media_type="text/plain", + headers={"Cache-Control": "no-cache", "Connection": "keep-alive"} + ) + + + +@router.get("/projects/{project_id}/edit", response_class=HTMLResponse) +async def edit_project_ppt( + request: Request, + project_id: str, + user: User = Depends(get_current_user_required) +): + """Edit PPT slides with advanced editor""" + try: + project = await ppt_service.project_manager.get_project(project_id) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + # 允许编辑器在PPT生成过程中显示,提供更好的用户体验 + # 如果没有slides_data,创建一个空的结构供编辑器使用 + if not project.slides_data: + project.slides_data = [] + + return templates.TemplateResponse("project_slides_editor.html", { + "request": request, + "project": project + }) + + except Exception as e: + return templates.TemplateResponse("error.html", { + "request": request, + "error": str(e) + }) + +@router.post("/api/projects/{project_id}/update-html") +async def update_project_html( + project_id: str, + request: Request, + user: User = Depends(get_current_user_required) +): + """Update project HTML content and mark all slides as user-edited""" + try: + data = await request.json() + slides_html = data.get('slides_html', '') + + project = await ppt_service.project_manager.get_project(project_id) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + # Update project HTML + project.slides_html = slides_html + project.updated_at = time.time() + + # 解析HTML内容,提取各个页面并标记为用户编辑 + if project.slides_data and slides_html: + try: + # 解析HTML内容,提取各个页面 + updated_slides_data = await _extract_slides_from_html(slides_html, project.slides_data) + + # 标记所有页面为用户编辑状态 + for slide_data in updated_slides_data: + slide_data["is_user_edited"] = True + + # 更新项目的slides_data + project.slides_data = updated_slides_data + + logger.info(f"Marked {len(updated_slides_data)} slides as user-edited for project {project_id}") + + except Exception as parse_error: + logger.warning(f"Failed to parse HTML content for slide extraction: {parse_error}") + # 如果解析失败,至少标记现有的slides_data为用户编辑 + if project.slides_data: + for slide_data in project.slides_data: + slide_data["is_user_edited"] = True + + # 保存更新的HTML和slides_data到数据库 + try: + from ..services.db_project_manager import DatabaseProjectManager + db_manager = DatabaseProjectManager() + + # 保存幻灯片HTML和数据到数据库 + save_success = await db_manager.save_project_slides( + project_id, + project.slides_html, + project.slides_data or [] + ) + + if save_success: + logger.info(f"Successfully saved updated HTML and slides data to database for project {project_id}") + else: + logger.error(f"Failed to save updated HTML and slides data to database for project {project_id}") + + except Exception as save_error: + logger.error(f"Exception while saving updated HTML and slides data to database: {save_error}") + # 继续返回成功,因为内存中的数据已经更新 + + return {"status": "success", "message": "HTML updated successfully and slides marked as user-edited"} + + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.get("/api/projects/{project_id}") +async def get_project_data( + project_id: str, + user: User = Depends(get_current_user_required) +): + """Get project data for real-time updates""" + try: + project = await ppt_service.project_manager.get_project(project_id) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + return { + "project_id": project.project_id, + "title": project.title, + "status": project.status, + "slides_data": project.slides_data or [], + "slides_count": len(project.slides_data) if project.slides_data else 0, + "updated_at": project.updated_at + } + + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.put("/api/projects/{project_id}/slides") +async def update_project_slides( + project_id: str, + request: Request, + user: User = Depends(get_current_user_required) +): + """Update project slides data""" + try: + logger.info(f"🔄 开始更新项目 {project_id} 的幻灯片数据") + + data = await request.json() + slides_data = data.get('slides_data', []) + + logger.info(f"📊 接收到 {len(slides_data)} 页幻灯片数据") + + project = await ppt_service.project_manager.get_project(project_id) + if not project: + logger.error(f"❌ 项目 {project_id} 不存在") + raise HTTPException(status_code=404, detail="Project not found") + + logger.info(f"📝 更新项目幻灯片数据...") + + # Update project slides data + project.slides_data = slides_data + project.updated_at = time.time() + + # Regenerate combined HTML + if slides_data: + # 安全地获取大纲标题 + outline_title = project.title + if project.outline: + if isinstance(project.outline, dict): + outline_title = project.outline.get('title', project.title) + elif hasattr(project.outline, 'title'): + outline_title = project.outline.title + + project.slides_html = ppt_service._combine_slides_to_full_html( + slides_data, outline_title + ) + + # 标记所有幻灯片为用户编辑状态 + for i, slide_data in enumerate(project.slides_data): + slide_data["is_user_edited"] = True + + # 保存更新的幻灯片数据到数据库 + save_success = False + save_error_message = None + + try: + from ..services.db_project_manager import DatabaseProjectManager + db_manager = DatabaseProjectManager() + + # 保存幻灯片数据到数据库 + save_success = await db_manager.save_project_slides( + project_id, + project.slides_html or "", + project.slides_data + ) + + if save_success: + logger.info(f"Successfully saved updated slides data to database for project {project_id}") + else: + logger.error(f"Failed to save updated slides data to database for project {project_id}") + save_error_message = "Failed to save slides data to database" + + except Exception as save_error: + logger.error(f"❌ 保存幻灯片数据到数据库时发生异常: {save_error}") + import traceback + traceback.print_exc() + save_success = False + save_error_message = str(save_error) + + # 根据保存结果返回相应的响应 + if save_success: + return { + "status": "success", + "success": True, + "message": "Slides updated and saved to database successfully" + } + else: + # 即使数据库保存失败,内存中的数据已经更新,所以仍然返回成功,但包含警告信息 + return { + "status": "success", + "success": True, + "message": "Slides updated in memory successfully", + "warning": f"Database save failed: {save_error_message}", + "database_saved": False + } + + except Exception as e: + logger.error(f"❌ 更新项目幻灯片数据时发生错误: {e}") + import traceback + traceback.print_exc() + raise HTTPException(status_code=500, detail=str(e)) + +@router.post("/api/projects/{project_id}/regenerate-html") +async def regenerate_project_html(project_id: str): + """Regenerate project HTML with fixed encoding""" + try: + project = await ppt_service.project_manager.get_project(project_id) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + if not project.slides_data: + raise HTTPException(status_code=400, detail="No slides data found") + + # Regenerate combined HTML using the fixed method + # 安全地获取大纲标题 + outline_title = project.title + if project.outline: + if isinstance(project.outline, dict): + outline_title = project.outline.get('title', project.title) + elif hasattr(project.outline, 'title'): + outline_title = project.outline.title + + project.slides_html = ppt_service._combine_slides_to_full_html( + project.slides_data, outline_title + ) + + project.updated_at = time.time() + + # 保存重新生成的HTML到数据库 + try: + from ..services.db_project_manager import DatabaseProjectManager + db_manager = DatabaseProjectManager() + + # 保存幻灯片数据到数据库 + save_success = await db_manager.save_project_slides( + project_id, + project.slides_html, + project.slides_data + ) + + if save_success: + logger.info(f"Successfully saved regenerated HTML to database for project {project_id}") + else: + logger.error(f"Failed to save regenerated HTML to database for project {project_id}") + + except Exception as save_error: + logger.error(f"Exception while saving regenerated HTML to database: {save_error}") + # 继续返回成功,因为内存中的数据已经更新 + + return { + "success": True, + "message": "Project HTML regenerated successfully" + } + + except Exception as e: + return {"success": False, "error": str(e)} + +@router.post("/api/projects/{project_id}/slides/{slide_number}/regenerate") +async def regenerate_slide(project_id: str, slide_number: int): + """Regenerate a specific slide""" + try: + project = await ppt_service.project_manager.get_project(project_id) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + if not project.outline: + raise HTTPException(status_code=400, detail="Project outline not found") + + if not project.confirmed_requirements: + raise HTTPException(status_code=400, detail="Project requirements not confirmed") + + # Handle different outline structures + if isinstance(project.outline, dict): + slides = project.outline.get('slides', []) + else: + # If outline is a PPTOutline object + slides = project.outline.slides if hasattr(project.outline, 'slides') else [] + + if slide_number < 1 or slide_number > len(slides): + raise HTTPException(status_code=400, detail="Invalid slide number") + + slide_data = slides[slide_number - 1] + + # Load system prompt + system_prompt = ppt_service._load_prompts_md_system_prompt() + + # Ensure project has a global template selected (use default if none selected) + selected_template = await ppt_service._ensure_global_master_template_selected(project_id) + + # Regenerate the slide using template-based generation if template is available + if selected_template: + logger.info(f"Regenerating slide {slide_number} using template: {selected_template['template_name']}") + new_html_content = await ppt_service._generate_slide_with_template( + slide_data, selected_template, slide_number, len(slides), project.confirmed_requirements + ) + else: + # Fallback to original generation method if no template available + logger.warning(f"No template available for project {project_id}, using fallback generation") + new_html_content = await ppt_service._generate_single_slide_html_with_prompts( + slide_data, project.confirmed_requirements, system_prompt, slide_number, len(slides), + slides, project.slides_data, project_id=project_id + ) + + # Update the slide in project data + if not project.slides_data: + project.slides_data = [] + + # Ensure slides_data has enough entries + while len(project.slides_data) < slide_number: + new_page_number = len(project.slides_data) + 1 + project.slides_data.append({ + "page_number": new_page_number, + "title": f"第{new_page_number}页", + "html_content": "
PPT演示文稿 - 共{len(slide_files)}页
+错误信息: {str(e)}
+请确保PPT已经生成完成后再尝试导出。
+ +""" + + + +# Legacy Node.js Puppeteer check function - no longer needed with Pyppeteer +# def _check_puppeteer_available() -> bool: +# """Check if Node.js and Puppeteer are available""" +# # This function is deprecated - we now use Pyppeteer (Python) instead +# return False + +async def _generate_individual_slide_html(slide, slide_number: int, total_slides: int, topic: str) -> str: + """Generate complete HTML document for individual slide preserving original styles""" + slide_html = slide.get('html_content', '') + slide_title = slide.get('title', f'第{slide_number}页') + + # Check if it's already a complete HTML document + import re + if slide_html.strip().lower().startswith(' + + + + +' + enhanced_html = re.sub(body_pattern, navigation_html + '\n' + navigation_js + '\n', enhanced_html, flags=re.IGNORECASE) + + return enhanced_html + +async def _generate_pdf_slide_html(slide, slide_number: int, total_slides: int, topic: str) -> str: + """Generate PDF-optimized HTML for individual slide without navigation elements""" + slide_html = slide.get('html_content', '') + slide_title = slide.get('title', f'第{slide_number}页') + + # Check if it's already a complete HTML document + import re + if slide_html.strip().lower().startswith(' + +
+ + +
+ + +
+