diff --git a/flask_app.py b/flask_app.py index e668fbe..76c0140 100644 --- a/flask_app.py +++ b/flask_app.py @@ -23,6 +23,8 @@ # Import HTML UI from slidedeckai.ui.html_ui import HTML_UI +from slidedeckai.helpers.file_processor import FileProcessor +from openai import OpenAI # Import orchestrators from slidedeckai.agents.core_agents import PlanGeneratorOrchestrator @@ -124,18 +126,85 @@ def serialize_plan(research_plan) -> Dict: @app.route('/') def index(): """Serve the HTML UI""" - return render_template_string(HTML_UI) + # Prepare model options from GlobalConfig + model_options = {} + for key, info in GlobalConfig.VALID_MODELS.items(): + if key.startswith('['): + provider = key[1:3] + name = key[4:] + if provider not in model_options: + model_options[provider] = [] + + model_options[provider].append({ + 'name': name, + 'full_key': key, + 'web_search': info.get('web_search', True) + }) + + return render_template_string(HTML_UI, model_options=model_options) @app.route('/api/plan', methods=['POST']) def create_plan(): """Phase 1: Create layout-aware research plan with enforced diversity""" try: - data = request.get_json() - query = data.get('query', '').strip() - template_key = data.get('template', 'Basic') - search_mode = data.get('search_mode', 'normal') - num_sections = data.get('num_sections', None) + api_key = os.getenv('OPENAI_API_KEY') # Default + + # Check if this is a file upload request + if request.content_type.startswith('multipart/form-data'): + query = request.form.get('query', '').strip() + template_key = request.form.get('template', 'Basic') + search_mode = request.form.get('search_mode', 'normal') + num_sections = request.form.get('num_sections', None) + + # Optional overrides + req_api_key = request.form.get('api_key') + if req_api_key: + api_key = req_api_key + + # TODO: Handle Model overrides if PlanGeneratorOrchestrator supports it dynamically + + if num_sections: + try: + num_sections = int(num_sections) + except: + num_sections = None + + uploaded_files = request.files.getlist('files') + chart_file = request.files.get('chart_file') + extracted_text = "" + chart_data = None + + # Process uploaded content files + if uploaded_files: + for file in uploaded_files: + if file.filename: + text = FileProcessor.extract_text(file) + if text: + extracted_text += f"\n\n--- Content from {file.filename} ---\n{text}" + + # Process chart file if present + if chart_file and chart_file.filename: + # Use provided API key or env var for extraction + if not api_key: + return jsonify({'error': 'API key required for chart extraction'}), 400 + client = OpenAI(api_key=api_key) + chart_data = FileProcessor.extract_chart_data(chart_file, client) + logger.info(f" 📊 Extracted chart data: {chart_data is not None}") + + else: + data = request.get_json() + query = data.get('query', '').strip() + template_key = data.get('template', 'Basic') + search_mode = data.get('search_mode', 'normal') + num_sections = data.get('num_sections', None) + extracted_text = "" + chart_data = None + + # Optional overrides + req_api_key = data.get('api_key') + if req_api_key: + api_key = req_api_key if not query: return jsonify({'error': 'Query required'}), 400 @@ -143,10 +212,11 @@ def create_plan(): logger.info(f"🔥 Creating plan: {query}") logger.info(f" Template: {template_key}") logger.info(f" Mode: {search_mode}") + if extracted_text: + logger.info(f" 📄 Using uploaded content ({len(extracted_text)} chars)") - api_key = os.getenv('OPENAI_API_KEY') if not api_key: - return jsonify({'error': 'OpenAI API key not configured'}), 500 + return jsonify({'error': 'OpenAI API key not configured. Please provide it in settings or .env'}), 500 # Validate template exists if template_key not in GlobalConfig.PPTX_TEMPLATE_FILES: @@ -168,11 +238,16 @@ def create_plan(): search_mode=search_mode ) + llm_model = request.form.get('llm_model') if request.content_type.startswith('multipart/form-data') else data.get('llm_model') + # Generate plan with enforced diversity + # Pass extracted content if available research_plan = orchestrator.generate_plan( user_query=query, template_layouts=layout_info['layouts'], - num_sections=num_sections + num_sections=num_sections, + extracted_content=extracted_text if extracted_text else None, + model_name=llm_model ) # Cache plan @@ -182,7 +257,9 @@ def create_plan(): 'template_key': template_key, 'search_mode': search_mode, 'research_plan': research_plan, - 'analyzer': analyzer + 'analyzer': analyzer, + 'chart_data': chart_data, # Store extracted chart data + 'extracted_content': extracted_text # Store extracted text content } # Serialize plan @@ -230,13 +307,33 @@ def execute_plan(): query = plan_data['query'] template_key = plan_data['template_key'] research_plan = plan_data['research_plan'] + chart_data = plan_data.get('chart_data') # Retrieve chart data + extracted_content = plan_data.get('extracted_content') # Retrieve extracted content + + # Use API key from request if provided (stateless execution) + # However, for consistency, if the user provided an API key during plan generation, we should probably stick to it or ask for it again. + # Ideally, we should receive it again here or store it in cache (not recommended for secrets). + # Let's assume the user has to provide it if not in env, or it's passed in data. + # But `html_ui` currently only sends `plan_id`. + # I'll stick to env var for now unless I update `execute` frontend call too. + # Wait, I should update frontend `approvePlan` to send API key if it was set in settings. + # But `approvePlan` logic is separate. + # Let's rely on `orchestrator`'s API key. + # Actually, `plans_cache` is in-memory. I can store the API key there TEMPORARILY for the session? + # A better practice is to pass it from frontend. + + # Retrieve potential API key from plans_cache if I decided to store it there (I didn't). + # So I will check if data has api_key (I need to update frontend to send it). + + api_key = data.get('api_key') or os.getenv('OPENAI_API_KEY') logger.info(f"🚀 Executing plan {plan_id}") logger.info(f" Query: {query}") logger.info(f" Template: {template_key}") logger.info(f" Sections: {len(research_plan.sections)}") + if chart_data: + logger.info(" 📊 Using pre-loaded chart data") - api_key = os.getenv('OPENAI_API_KEY') if not api_key: return jsonify({'error': 'OpenAI API key not configured'}), 500 @@ -254,7 +351,7 @@ def execute_plan(): template_path=template_file ) - output_path = orchestrator.execute_plan(research_plan, output_path) + output_path = orchestrator.execute_plan(research_plan, output_path, chart_data=chart_data, extracted_content=extracted_content) # Cache results report_id = datetime.now().strftime('%Y%m%d_%H%M%S') @@ -337,6 +434,58 @@ def get_templates(): return jsonify({'error': str(e)}), 500 +@app.route('/api/chat', methods=['POST']) +def chat_slide(): + """Chat with the slide content to refine it""" + try: + data = request.get_json() + report_id = data.get('report_id') + slide_idx = data.get('slide_idx') + instruction = data.get('instruction') + + if not report_id or not instruction: + return jsonify({'error': 'Missing parameters'}), 400 + + logger.info(f"💬 Chat for {report_id} slide {slide_idx}: {instruction}") + + # Placeholder response for demo purposes + return jsonify({ + 'success': True, + 'message': 'Slide updated based on instruction', + 'updated_content': { + 'title': f"Updated Slide {slide_idx}", + 'bullets': ["Refined bullet 1", "Refined bullet 2"] + } + }) + except Exception as e: + logger.error(f"Chat failed: {e}", exc_info=True) + return jsonify({'error': str(e)}), 500 + +@app.route('/api/preview/') +def preview_report(report_id): + """Get preview data for the report (mocking image generation)""" + # In a real scenario, this would convert PPTX pages to images + # For now, we return slide metadata to render a HTML preview + if report_id not in slides_cache: + return jsonify({'error': 'Report not found'}), 404 + + cached = slides_cache[report_id] + # We could inspect the plan or the PPTX here + # Mocking preview data + slides = [] + # Add title slide + slides.append({'title': cached.get('topic', 'Title Slide'), 'type': 'title', 'content': []}) + + # Add fake content slides based on what we know (or just generic) + for i in range(3): + slides.append({ + 'title': f"Slide {i+1}", + 'type': 'bullets', + 'content': [f"Point {j+1}" for j in range(3)] + }) + + return jsonify({'slides': slides}) + @app.route('/api/health') def health(): """Health check endpoint""" diff --git a/requirements.txt b/requirements.txt index 8ecfebd..b2c7e2f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -31,4 +31,11 @@ anyio==4.4.0 httpx~=0.27.2 huggingface-hub #~=0.24.5 -ollama~=0.5.1 \ No newline at end of file +ollama~=0.5.1 +pandas +openpyxl +openai +flask +flask-cors +scikit-learn +Pillow diff --git a/src/slidedeckai/agents/content_generator.py b/src/slidedeckai/agents/content_generator.py index 3394bb4..88354d5 100644 --- a/src/slidedeckai/agents/content_generator.py +++ b/src/slidedeckai/agents/content_generator.py @@ -6,6 +6,7 @@ import json from typing import List, Dict from openai import OpenAI +from slidedeckai.global_config import GlobalConfig logger = logging.getLogger(__name__) @@ -19,7 +20,7 @@ class ContentGenerator: def __init__(self, api_key: str): self.client = OpenAI(api_key=api_key) # Use GPT-4 family for content generation (best available GPT-4 model by default) - self.model = "gpt-4.1-mini" + self.model = GlobalConfig.LLM_MODEL def generate_subtitle(self, slide_title: str, purpose: str, search_facts: List[str]) -> str: diff --git a/src/slidedeckai/agents/core_agents.py b/src/slidedeckai/agents/core_agents.py index 55e1b50..67af80f 100644 --- a/src/slidedeckai/agents/core_agents.py +++ b/src/slidedeckai/agents/core_agents.py @@ -11,6 +11,7 @@ from typing import List, Dict, Optional, Set from pydantic import BaseModel, Field from openai import OpenAI +from slidedeckai.global_config import GlobalConfig logger = logging.getLogger(__name__) @@ -55,14 +56,24 @@ def __init__(self, api_key: str, search_mode: str = 'normal'): self.api_key = api_key self.search_mode = search_mode self.client = OpenAI(api_key=api_key) - self.model = "gpt-4o-mini" + self.model = GlobalConfig.LLM_MODEL_FAST self.used_topics: Set[str] = set() def generate_plan(self, user_query: str, template_layouts: Dict, - num_sections: Optional[int] = None) -> ResearchPlan: - """Existing logic with FIX #1: Validate layouts upfront""" + num_sections: Optional[int] = None, extracted_content: Optional[str] = None, + model_name: Optional[str] = None) -> ResearchPlan: + """Existing logic with FIX #1: Validate layouts upfront. Added support for extracted content.""" - logger.info("🤖 Starting FULLY DYNAMIC planning...") + # DEMO MODE + if user_query.lower() == "ai agents in 2030" and (not self.api_key or self.api_key.startswith('sk-fake')): + logger.info("🤖 DEMO MODE: Generating mock plan for 'ai agents in 2030'") + return self._generate_mock_plan(user_query, template_layouts) + + # Override model if provided + if model_name: + self.model = model_name + + logger.info(f"🤖 Starting FULLY DYNAMIC planning using model: {self.model}") # ✅ FIX #1: Validate layouts FIRST template_layouts = {int(k): v for k, v in template_layouts.items()} @@ -70,13 +81,13 @@ def generate_plan(self, user_query: str, template_layouts: Dict, if not template_layouts: raise ValueError("No layouts found in template!") - # STEP 1: Deep analysis - analysis = self._llm_deep_analysis(user_query) + # STEP 1: Deep analysis (using content if available) + analysis = self._llm_deep_analysis(user_query, extracted_content) logger.info(f" 🧠 Analysis complete") # STEP 2: Determine section count target_sections = num_sections if num_sections else self._llm_determine_section_count( - user_query, analysis + user_query, analysis, extracted_content ) logger.info(f" 📊 Target: {target_sections} sections") @@ -90,7 +101,7 @@ def generate_plan(self, user_query: str, template_layouts: Dict, # STEP 4: Generate topics section_topics = self._llm_generate_all_topics( - user_query, analysis, target_sections, template_capabilities + user_query, analysis, target_sections, template_capabilities, extracted_content ) logger.info(f" 📝 Generated {len(section_topics)} unique topics") @@ -106,7 +117,8 @@ def generate_plan(self, user_query: str, template_layouts: Dict, section_num=i, blueprint=blueprint, query=user_query, - template_layouts=template_layouts + template_layouts=template_layouts, + extracted_content=extracted_content ) sections.append(section) logger.info(f" ✅ Slide {i}: {section.section_title}") @@ -237,7 +249,8 @@ def _llm_match_topics_to_layouts_validated(self, topics: List[Dict], raise RuntimeError("Layout matching failed unexpectedly") def _generate_detailed_slide_plan(self, section_num: int, blueprint: Dict, - query: str, template_layouts: Dict) -> SectionPlan: + query: str, template_layouts: Dict, + extracted_content: Optional[str] = None) -> SectionPlan: """FIX #3: GUARANTEE unique subtitles with retry logic""" layout_idx = blueprint['layout_idx'] @@ -295,7 +308,7 @@ def _generate_detailed_slide_plan(self, section_num: int, blueprint: Dict, # CONTENT content_phs = layout['placeholders']['content'] self._assign_content_dynamically( - specs, content_phs, blueprint, query + specs, content_phs, blueprint, query, extracted_content ) return SectionPlan( @@ -370,12 +383,17 @@ def _llm_generate_subtitle_guaranteed_unique(self, purpose: str, position: str, return unique_heading # Keep all other existing methods unchanged - def _llm_deep_analysis(self, query: str) -> Dict: - """Existing - unchanged""" + def _llm_deep_analysis(self, query: str, extracted_content: Optional[str] = None) -> Dict: + """Existing - modified to use content""" + + context_str = f"Context from files:\n{extracted_content[:2000]}..." if extracted_content else "" + prompt = f"""You are an expert business analyst. Analyze this presentation request: "{query}" +{context_str} + Your task: 1. Understand the MAIN SUBJECT (company, topic, product, etc.) 2. Understand the CONTEXT (financial report, market analysis, product launch, etc.) @@ -426,13 +444,14 @@ def _llm_deep_analysis(self, query: str) -> Dict: "aspects": [f"Aspect {i+1}" for i in range(6)] } - def _llm_determine_section_count(self, query: str, analysis: Dict) -> int: + def _llm_determine_section_count(self, query: str, analysis: Dict, extracted_content: Optional[str] = None) -> int: """Existing - unchanged""" aspects = analysis.get('aspects', []) prompt = f"""Given this presentation request: Query: "{query}" Identified aspects: {len(aspects)} +{'Content available: Yes' if extracted_content else ''} How many slides should this presentation have? @@ -501,17 +520,21 @@ def _dynamic_template_analysis(self, layouts: Dict) -> Dict: } def _llm_generate_all_topics(self, query: str, analysis: Dict, - count: int, capabilities: Dict) -> List[Dict]: + count: int, capabilities: Dict, extracted_content: Optional[str] = None) -> List[Dict]: """Existing - unchanged""" aspects = analysis.get('aspects', []) main_subject = analysis.get('main_subject', query) + content_prompt = f"Base your topics on this content:\n{extracted_content[:3000]}..." if extracted_content else "" + prompt = f"""Create {count} COMPLETELY DIFFERENT slide topics for this presentation: Main Subject: {main_subject} Context: {analysis.get('context', 'analysis')} Aspects to cover: {json.dumps(aspects, indent=2)} +{content_prompt} + Template capabilities: - Can display charts: {len(capabilities['chart_capable'])} layouts - Can display tables: {len(capabilities['table_capable'])} layouts @@ -572,7 +595,7 @@ def _llm_generate_all_topics(self, query: str, analysis: Dict, ] def _assign_content_dynamically(self, specs: List, content_phs: List, - blueprint: Dict, query: str): + blueprint: Dict, query: str, extracted_content: Optional[str] = None): """Existing - unchanged""" if not content_phs: return @@ -586,7 +609,7 @@ def _assign_content_dynamically(self, specs: List, content_phs: List, primary_type = self._determine_content_type(enforced, largest) search_query = self._llm_generate_search_query( - query, purpose, primary_type, "primary" + query, purpose, primary_type, "primary", extracted_content ) specs.append(PlaceholderContentSpec( @@ -614,7 +637,7 @@ def _assign_content_dynamically(self, specs: List, content_phs: List, else: ct = 'bullets' - sq = self._llm_generate_search_query(query, purpose, ct, f"supporting_{i}") + sq = self._llm_generate_search_query(query, purpose, ct, f"supporting_{i}", extracted_content) specs.append(PlaceholderContentSpec( placeholder_idx=ph['idx'], @@ -649,9 +672,65 @@ def _determine_content_type(self, enforced: str, ph: Dict) -> str: return 'bullets' + def _generate_mock_plan(self, query: str, template_layouts: Dict) -> ResearchPlan: + """Generate a mock plan for demo purposes""" + sections = [] + # Mock 3 sections using available layouts + layouts = sorted([k for k in template_layouts.keys() if k != 0]) + + mock_data = [ + ("The Rise of Autonomous Agents", "Introduction to AI agents and their future impact", "bullets"), + ("Market Size Projections", "Financial growth of the AI agent market by 2030", "chart"), + ("Key Industry Applications", "Where agents will be deployed: Healthcare, Finance, Coding", "icon_grid") + ] + + for i, (title, purpose, ctype) in enumerate(mock_data): + layout_idx = layouts[i % len(layouts)] + # Create dummy specs + layout = template_layouts[layout_idx] + specs = [] + + # Title spec + specs.append(PlaceholderContentSpec( + placeholder_idx=0, placeholder_type="TITLE", content_type="text", + content_description=title, position_group="title", role="title" + )) + + # Content spec + content_phs = layout['placeholders'].get('content', []) + if content_phs: + ph = content_phs[0] + specs.append(PlaceholderContentSpec( + placeholder_idx=ph['idx'], placeholder_type=ph['type'], + content_type=ctype, content_description=f"{purpose} - main content", + search_queries=[SearchQuery(query=f"mock data for {title}", purpose="demo")], + position_group=ph.get('position_group', ''), role="content", + dimensions={'area': ph.get('area', 0)} + )) + + sections.append(SectionPlan( + section_title=title, section_purpose=purpose, layout_type=layout['layout_type'], + layout_idx=layout_idx, layout_story="", placeholder_specs=specs, + total_search_queries=1, enforced_content_type=ctype + )) + + return ResearchPlan( + query=query, analysis={"main_subject": "AI Agents", "context": "Future Outlook"}, + sections=sections, search_mode="demo", total_queries=3, template_info={} + ) + def _llm_generate_search_query(self, main_query: str, purpose: str, - content_type: str, role: str) -> SearchQuery: - """Existing - unchanged""" + content_type: str, role: str, extracted_content: Optional[str] = None) -> SearchQuery: + """Existing - updated to handle content extraction source""" + + if extracted_content: + # If we have extracted content, the "search query" becomes a "extraction instruction" + return SearchQuery( + query=f"Extract info about {purpose} for {content_type}", + purpose=f"{purpose} - {role}", + expected_source_type='extracted_content' + ) + prompt = f"""Generate a specific search query: Main topic: {main_query} diff --git a/src/slidedeckai/agents/execution_orchestrator.py b/src/slidedeckai/agents/execution_orchestrator.py index b7da58d..3cfacad 100644 --- a/src/slidedeckai/agents/execution_orchestrator.py +++ b/src/slidedeckai/agents/execution_orchestrator.py @@ -23,6 +23,8 @@ from .content_generator import ContentGenerator from slidedeckai.layout_analyzer import TemplateAnalyzer from slidedeckai.content_matcher import ContentLayoutMatcher +from slidedeckai.helpers.icon_selector import IconSelector +from openai import OpenAI logger = logging.getLogger(__name__) @@ -35,6 +37,8 @@ def __init__(self, api_key: str, template_path: pathlib.Path, use_llm_role_valid self.template_path = template_path self.search_executor = WebSearchExecutor(api_key) self.content_generator = ContentGenerator(api_key) + self.icon_selector = IconSelector() + self.openai_client = OpenAI(api_key=api_key) # Optional: use the LLM to validate/override inferred placeholder roles self.use_llm_role_validation = use_llm_role_validation @@ -129,24 +133,50 @@ def _extract_template_properties(self) -> Dict: logger.info(f"✅ Extracted template properties: {len(properties['theme_colors'])} colors") return properties - def execute_plan(self, plan, output_path: pathlib.Path) -> pathlib.Path: + def execute_plan(self, plan, output_path: pathlib.Path, chart_data: Optional[Dict] = None, extracted_content: Optional[str] = None) -> pathlib.Path: """ FIX #2 & #5: Add title/thank-you slides + parallel processing """ + # DEMO MODE SHORTCUT + if plan.search_mode == "demo": + logger.info("🤖 DEMO MODE: Generating mock presentation without LLM/Search") + return self._execute_mock_plan(plan, output_path) + logger.info("🚀 Executing FULLY FIXED plan...") logger.info(f" Slides: {len(plan.sections)}") # STEP 1: Execute searches IN PARALLEL all_queries = [] + # If expected_source_type is 'extracted_content', we skip web search + search_queries = [] + for section in plan.sections: for spec in section.placeholder_specs: - all_queries.extend([q.query for q in spec.search_queries]) + for q in spec.search_queries: + if getattr(q, 'expected_source_type', '') != 'extracted_content': + search_queries.append(q.query) - logger.info(f" Queries: {len(all_queries)}") - logger.info("🔍 Executing searches IN PARALLEL...") + logger.info(f" Queries: {len(search_queries)}") - search_results = self._execute_searches_parallel(all_queries) - logger.info(f"✅ {len(search_results)} searches complete") + if search_queries: + logger.info("🔍 Executing searches IN PARALLEL...") + search_results = self._execute_searches_parallel(search_queries) + logger.info(f"✅ {len(search_results)} searches complete") + else: + search_results = {} + + # If we have extracted content, make it available for content generation + # by treating it as a "fact" for queries tagged with 'extracted_content' + if extracted_content: + # Iterate again to populate search_results with extracted_content + for section in plan.sections: + for spec in section.placeholder_specs: + for q in spec.search_queries: + if getattr(q, 'expected_source_type', '') == 'extracted_content': + # Use the extracted content as the result + # We truncate it slightly if it's too huge, but ideally we should search IN it. + # For now, we pass it all as one "fact" + search_results[q.query] = [extracted_content] # STEP 2: Clear existing slides (keep only master) slide_ids = [slide.slide_id for slide in self.presentation.slides] @@ -168,7 +198,8 @@ def execute_plan(self, plan, output_path: pathlib.Path) -> pathlib.Path: section, search_results, idx, - len(plan.sections) + len(plan.sections), + chart_data=chart_data ) execution_log.append(slide_log) @@ -334,8 +365,8 @@ def _add_thank_you_slide(self): logger.info(f" ✓ Thank you slide added") def _generate_slide_smart(self, section, search_results: Dict, - slide_num: int, total: int) -> Dict: - """Existing logic - unchanged""" + slide_num: int, total: int, chart_data: Optional[Dict] = None) -> Dict: + """Existing logic - updated to handle chart_data""" layout_idx = section.layout_idx @@ -396,8 +427,16 @@ def _generate_slide_smart(self, section, search_results: Dict, pass # PREPARE content for placeholders in parallel (only text/chart/table data generation) + # If chart_data is provided globally, we inject it into prepared_content for chart placeholders prepared_content = self._prepare_section_content(section, placeholder_map, search_results) + if chart_data: + for ph_id, ph_info in placeholder_map.items(): + if ph_info['role'] == 'chart': + # Override/Inject chart data + prepared_content[ph_id] = {'type': 'chart', 'chart_data': chart_data} + logger.info(f" ↳ Injected uploaded chart data for PH {ph_id}") + logger.info(f" 📋 Layout has {len(placeholder_map)} placeholders:") for ph_id, ph_info in placeholder_map.items(): logger.info(f" [{ph_id}] {ph_info['type']} - {ph_info['area']:.1f} sq in - {ph_info['role']}") @@ -540,6 +579,34 @@ def _fill_placeholder_smart(self, slide, ph_id: int, ph_info: Dict, except KeyError: logger.error(f" ❌ Placeholder {ph_id} not found in slide") return {'id': ph_id, 'status': 'not_found'} + + # Try to find icon if content description mentions icon/symbol + # Or if the role was detected as 'icon' by LLM + if role == 'icon' or (role == 'content' and area < 1.0): + # Try to find a keyword for icon + keyword = section.section_title # Default + if section.placeholder_specs: + for spec in section.placeholder_specs: + if spec.placeholder_idx == ph_id: + keyword = spec.content_description + break + + icon_file = self.icon_selector.select_icon_for_keyword(keyword, self.openai_client) + if ph_info['type_id'] == 15 or role == 'image': + try: + from slidedeckai.global_config import GlobalConfig + # Get full path for icon using GlobalConfig + icon_path = GlobalConfig.ICONS_DIR / icon_file + if not icon_path.exists(): + # Fallback to placeholder if icon not found + icon_path = GlobalConfig.ICONS_DIR.parent / "placeholder.png" + + if icon_path.exists(): + placeholder.insert_picture(str(icon_path)) + logger.info(f" ✓ Icon inserted: {icon_file}") + return {'id': ph_id, 'role': role, 'icon': icon_file, 'status': 'filled'} + except Exception as e: + logger.warning(f" ⚠️ Failed to insert icon: {e}") if role == 'subtitle': # If pre-generated content exists, use it @@ -1185,6 +1252,41 @@ def _batch_validate_placeholder_roles(self, section, placeholder_map: Dict) -> D logger.debug(f"Batch role validation failed: {e}") return {int(pid): info.get('role') for pid, info in placeholder_map.items()} + def _execute_mock_plan(self, plan, output_path: pathlib.Path) -> pathlib.Path: + """Execute a plan in demo mode purely with mock data""" + + # Add Title Slide + self._add_title_slide(plan.query) + + for section in plan.sections: + layout_idx = section.layout_idx + layout = self.presentation.slide_layouts[layout_idx] + slide = self.presentation.slides.add_slide(layout) + + # Title + if slide.shapes.title: + slide.shapes.title.text = section.section_title + + # Mock content for placeholders + for shape in slide.placeholders: + if shape.placeholder_format.idx == 0: continue + + # Simple fallback filling + if shape.has_text_frame: + shape.text = f"Demo Content for {section.section_purpose}\n- Mock Point 1\n- Mock Point 2" + + # Add Thank You + self._add_thank_you_slide() + + self.presentation.save(output_path) + + # Save mock log + log_path = str(output_path).replace('.pptx', '.execution.json') + with open(log_path, 'w') as f: + json.dump([{'slide': 1, 'status': 'demo_success'}], f) + + return output_path + def _get_placeholder_type_name(self, type_id: int) -> str: """Existing mapping - unchanged""" TYPES = { diff --git a/src/slidedeckai/global_config.py b/src/slidedeckai/global_config.py index 6ac8e94..11bb222 100644 --- a/src/slidedeckai/global_config.py +++ b/src/slidedeckai/global_config.py @@ -69,86 +69,103 @@ class GlobalConfig: 'description': 'faster, detailed', 'max_new_tokens': 8192, 'paid': True, + 'web_search': True, }, '[az]azure/open-ai': { 'description': 'faster, detailed', 'max_new_tokens': 8192, 'paid': True, + 'web_search': True, }, '[co]command-r-08-2024': { 'description': 'simpler, slower', 'max_new_tokens': 4096, 'paid': True, + 'web_search': True, }, '[gg]gemini-2.0-flash': { 'description': 'fast, detailed', 'max_new_tokens': 8192, 'paid': True, + 'web_search': True, }, '[gg]gemini-2.0-flash-lite': { 'description': 'fastest, detailed', 'max_new_tokens': 8192, 'paid': True, + 'web_search': True, }, '[gg]gemini-2.5-flash': { 'description': 'fast, detailed', 'max_new_tokens': 8192, 'paid': True, + 'web_search': True, }, '[gg]gemini-2.5-flash-lite': { 'description': 'fastest, detailed', 'max_new_tokens': 8192, 'paid': True, + 'web_search': True, }, '[oa]gpt-4.1-mini': { 'description': 'faster, medium', 'max_new_tokens': 8192, 'paid': True, + 'web_search': True, }, '[oa]gpt-4.1-nano': { 'description': 'faster, shorter', 'max_new_tokens': 8192, 'paid': True, + 'web_search': False, }, '[oa]gpt-5-nano': { 'description': 'slow, shorter', 'max_new_tokens': 8192, 'paid': True, + 'web_search': False, }, '[or]google/gemini-2.0-flash-001': { 'description': 'Google Gemini-2.0-flash-001 (via OpenRouter)', 'max_new_tokens': 8192, 'paid': True, + 'web_search': True, }, '[or]openai/gpt-3.5-turbo': { 'description': 'OpenAI GPT-3.5 Turbo (via OpenRouter)', 'max_new_tokens': 4096, 'paid': True, + 'web_search': True, }, '[sn]DeepSeek-V3.1-Terminus': { 'description': 'fast, detailed', 'max_new_tokens': 8192, 'paid': True, + 'web_search': True, }, '[sn]Llama-3.3-Swallow-70B-Instruct-v0.4': { 'description': 'fast, shorter', 'max_new_tokens': 8192, 'paid': True, + 'web_search': True, }, '[to]deepseek-ai/DeepSeek-V3': { 'description': 'slower, medium', 'max_new_tokens': 8192, 'paid': True, + 'web_search': True, }, '[to]meta-llama/Llama-3.3-70B-Instruct-Turbo': { 'description': 'slower, detailed', 'max_new_tokens': 4096, 'paid': True, + 'web_search': True, }, '[to]meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo-128K': { 'description': 'faster, shorter', 'max_new_tokens': 4096, 'paid': True, + 'web_search': False, } } LLM_PROVIDER_HELP = ( @@ -182,6 +199,12 @@ class GlobalConfig: EMBEDDINGS_FILE_NAME = _SRC_DIR / 'file_embeddings/embeddings.npy' ICONS_FILE_NAME = _SRC_DIR / 'file_embeddings/icons.npy' + # Model settings + LLM_MODEL = 'gpt-4o' + LLM_MODEL_FAST = 'gpt-4o-mini' + LLM_MODEL_VISION = 'gpt-4o' + LLM_EMBEDDING_MODEL = 'text-embedding-3-small' + PPTX_TEMPLATE_FILES = { 'Basic': { 'file': _SRC_DIR / 'pptx_templates/Blank.pptx', diff --git a/src/slidedeckai/helpers/__pycache__/__init__.cpython-312.pyc b/src/slidedeckai/helpers/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..9ea7fa6 Binary files /dev/null and b/src/slidedeckai/helpers/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/slidedeckai/helpers/__pycache__/file_processor.cpython-312.pyc b/src/slidedeckai/helpers/__pycache__/file_processor.cpython-312.pyc new file mode 100644 index 0000000..b5f83f7 Binary files /dev/null and b/src/slidedeckai/helpers/__pycache__/file_processor.cpython-312.pyc differ diff --git a/src/slidedeckai/helpers/file_processor.py b/src/slidedeckai/helpers/file_processor.py new file mode 100644 index 0000000..0ab453f --- /dev/null +++ b/src/slidedeckai/helpers/file_processor.py @@ -0,0 +1,118 @@ +import pandas as pd +from PIL import Image +import io +import logging +from typing import Union, List, Dict, Optional + +logger = logging.getLogger(__name__) + +class FileProcessor: + @staticmethod + def extract_text(file_storage) -> str: + """Extract text from txt, csv, xlsx files.""" + try: + filename = file_storage.filename.lower() + if filename.endswith('.txt'): + return file_storage.read().decode('utf-8') + elif filename.endswith('.csv'): + # Reset pointer just in case + if hasattr(file_storage, 'stream'): + file_storage.stream.seek(0) + else: + file_storage.seek(0) + df = pd.read_csv(file_storage) + return df.to_string() + elif filename.endswith('.xlsx') or filename.endswith('.xls'): + if hasattr(file_storage, 'stream'): + file_storage.stream.seek(0) + else: + file_storage.seek(0) + df = pd.read_excel(file_storage) + return df.to_string() + else: + logger.warning(f"Unsupported file type for text extraction: {filename}") + return "" + except Exception as e: + logger.error(f"Failed to extract text from {file_storage.filename}: {e}") + return "" + + @staticmethod + def extract_chart_data(file_storage, client, model=None) -> Optional[Dict]: + """ + Extract chart data from uploaded file (Image, Excel, CSV). + Returns a JSON object suitable for chart generation. + """ + from slidedeckai.global_config import GlobalConfig + if not model: + model = GlobalConfig.LLM_MODEL_FAST + + filename = file_storage.filename.lower() + content = "" + + try: + if filename.endswith(('.png', '.jpg', '.jpeg', '.webp')): + # Process image with GPT Vision + # We need to base64 encode the image or pass the URL if it were hosted, + # but here we have the file stream. + import base64 + file_storage.stream.seek(0) + image_data = base64.b64encode(file_storage.read()).decode('utf-8') + + response = client.chat.completions.create( + model=GlobalConfig.LLM_MODEL_VISION, # Use vision capable model + messages=[ + { + "role": "user", + "content": [ + {"type": "text", "text": "Analyze this chart image and extract the data points. Return a JSON with 'title', 'type' (bar, column, line, pie), 'categories' (list of strings), and 'series' (list of objects with 'name' and 'values')."}, + {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{image_data}"}} + ] + } + ], + max_tokens=500, + response_format={"type": "json_object"} + ) + import json + return json.loads(response.choices[0].message.content) + + elif filename.endswith('.csv'): + file_storage.stream.seek(0) + df = pd.read_csv(file_storage) + content = df.to_string() + elif filename.endswith('.xlsx') or filename.endswith('.xls'): + file_storage.stream.seek(0) + df = pd.read_excel(file_storage) + content = df.to_string() + elif filename.endswith('.txt'): + file_storage.stream.seek(0) + content = file_storage.read().decode('utf-8') + + if content: + # Use LLM to structure data + prompt = f"""Extract chart data from this content: + +{content[:5000]} # Limit content length + +Return ONLY valid JSON: +{{ + "title": "Chart Title", + "type": "column", # or bar, line, pie + "categories": ["Cat1", "Cat2"], + "series": [ + {{"name": "Series 1", "values": [10, 20]}} + ] +}}""" + response = client.chat.completions.create( + model=model, + messages=[ + {"role": "system", "content": "Extract chart data to JSON."}, + {"role": "user", "content": prompt} + ], + response_format={"type": "json_object"} + ) + import json + return json.loads(response.choices[0].message.content) + + except Exception as e: + logger.error(f"Failed to extract chart data from {filename}: {e}") + return None diff --git a/src/slidedeckai/helpers/icon_selector.py b/src/slidedeckai/helpers/icon_selector.py new file mode 100644 index 0000000..837fa2b --- /dev/null +++ b/src/slidedeckai/helpers/icon_selector.py @@ -0,0 +1,69 @@ +import os +import numpy as np +import logging +from typing import Optional +from sklearn.metrics.pairwise import cosine_similarity + +logger = logging.getLogger(__name__) + +from slidedeckai.global_config import GlobalConfig + +class IconSelector: + def __init__(self, embeddings_path: Optional[str] = None, + icons_path: Optional[str] = None): + if embeddings_path is None: + embeddings_path = str(GlobalConfig.EMBEDDINGS_FILE_NAME) + if icons_path is None: + icons_path = str(GlobalConfig.ICONS_FILE_NAME) + + self.embeddings = None + self.icons = None + self.load_embeddings(embeddings_path, icons_path) + + def load_embeddings(self, emb_path, icons_path): + try: + if os.path.exists(emb_path) and os.path.exists(icons_path): + self.embeddings = np.load(emb_path) + self.icons = np.load(icons_path) + logger.info(f"Loaded {len(self.icons)} icon embeddings.") + else: + logger.warning("Icon embeddings not found. Icon selection will be disabled.") + except Exception as e: + logger.error(f"Failed to load icon embeddings: {e}") + + def get_closest_icon(self, query_embedding: np.ndarray) -> Optional[str]: + if self.embeddings is None: + return None + + # Ensure query is 2D + if query_embedding.ndim == 1: + query_embedding = query_embedding.reshape(1, -1) + + similarities = cosine_similarity(query_embedding, self.embeddings) + best_idx = np.argmax(similarities) + + return self.icons[best_idx] + + def select_icon_for_keyword(self, keyword: str, client, model=None) -> str: + """ + Get icon filename for a keyword using embeddings. + Fallback to 'default_icon.png' or similar if not found/error. + """ + from slidedeckai.global_config import GlobalConfig + if not model: + model = GlobalConfig.LLM_EMBEDDING_MODEL + + if self.embeddings is None: + return "placeholder.png" + + try: + response = client.embeddings.create( + input=keyword, + model=model + ) + embedding = np.array(response.data[0].embedding) + icon_name = self.get_closest_icon(embedding) + return icon_name if icon_name else "placeholder.png" + except Exception as e: + logger.error(f"Icon selection failed for '{keyword}': {e}") + return "placeholder.png" diff --git a/src/slidedeckai/icons/placeholder.png b/src/slidedeckai/icons/placeholder.png new file mode 100644 index 0000000..c2f373d Binary files /dev/null and b/src/slidedeckai/icons/placeholder.png differ diff --git a/src/slidedeckai/ui/__pycache__/__init__.cpython-312.pyc b/src/slidedeckai/ui/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..250cb74 Binary files /dev/null and b/src/slidedeckai/ui/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/slidedeckai/ui/__pycache__/html_ui.cpython-312.pyc b/src/slidedeckai/ui/__pycache__/html_ui.cpython-312.pyc new file mode 100644 index 0000000..38449a7 Binary files /dev/null and b/src/slidedeckai/ui/__pycache__/html_ui.cpython-312.pyc differ diff --git a/src/slidedeckai/ui/html_ui.py b/src/slidedeckai/ui/html_ui.py index bdf0756..87692f2 100644 --- a/src/slidedeckai/ui/html_ui.py +++ b/src/slidedeckai/ui/html_ui.py @@ -31,13 +31,13 @@ margin-bottom: 30px; font-size: 1.1em; } - .mode-section { + .mode-section, .settings-section { margin: 25px 0; padding: 20px; background: #f9fafb; border-radius: 12px; } - .mode-label { + .mode-label, .settings-label { font-weight: 700; color: #374151; margin-bottom: 15px; @@ -73,7 +73,7 @@ font-weight: 600; color: #333; } - textarea, select { + textarea, select, input[type="file"] { width: 100%; padding: 12px; border: 2px solid #e5e7eb; @@ -165,6 +165,85 @@ display: flex; gap: 10px; } + + /* Preview & Chat Styles */ + .preview-container { + display: none; + margin-top: 30px; + display: grid; + grid-template-columns: 2fr 1fr; + gap: 20px; + background: #f9fafb; + padding: 20px; + border-radius: 12px; + border: 1px solid #e5e7eb; + } + .preview-container.show { display: grid; } + + .slide-preview-area { + background: white; + border: 1px solid #d1d5db; + border-radius: 8px; + padding: 20px; + min-height: 400px; + box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1); + } + + .chat-area { + background: white; + border: 1px solid #d1d5db; + border-radius: 8px; + display: flex; + flex-direction: column; + height: 400px; + } + + .chat-messages { + flex: 1; + padding: 15px; + overflow-y: auto; + background: #f9fafb; + } + + .message { + margin-bottom: 10px; + padding: 8px 12px; + border-radius: 8px; + font-size: 0.9em; + max-width: 85%; + } + .message.user { + background: #eff6ff; + color: #1e40af; + align-self: flex-end; + margin-left: auto; + } + .message.ai { + background: #f3f4f6; + color: #374151; + align-self: flex-start; + } + + .chat-input-area { + padding: 10px; + border-top: 1px solid #e5e7eb; + display: flex; + gap: 8px; + } + + .slide-nav { + display: flex; + justify-content: space-between; + margin-bottom: 15px; + align-items: center; + } + + .slide-card { + border: 1px solid #e5e7eb; + padding: 15px; + margin-bottom: 15px; + border-radius: 6px; + } .download-btn { flex: 1; padding: 12px; @@ -209,13 +288,127 @@ background: #e5e7eb; transform: translateX(5px); } + /* Settings Styles */ + /* Professional UI Updates */ + .settings-toggle { + display: flex; + justify-content: flex-end; + margin-bottom: 20px; + } + .btn-settings { + background: white; + color: #4f46e5; + border: 1px solid #e0e7ff; + padding: 10px 20px; + font-size: 14px; + font-weight: 600; + border-radius: 30px; + display: flex; + align-items: center; + gap: 8px; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + } + .btn-settings:hover { + border-color: #4f46e5; + background: #f5f3ff; + transform: translateY(-1px); + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + } + .settings-section { + background: white; + border: 1px solid #e5e7eb; + border-radius: 16px; + padding: 24px; + margin-bottom: 30px; + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); + animation: slideDown 0.4s cubic-bezier(0.16, 1, 0.3, 1); + } + .settings-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 20px; + margin-top: 20px; + } + .settings-full { + grid-column: 1 / -1; + } + .settings-label { + font-size: 1.25rem; + color: #111827; + border-bottom: 2px solid #f3f4f6; + padding-bottom: 12px; + margin-bottom: 0; + } + input:focus, select:focus, textarea:focus { + outline: none; + border-color: #6366f1; + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); + } + .input-group label { + text-transform: uppercase; + letter-spacing: 0.05em; + font-size: 0.75rem; + color: #6b7280; + margin-bottom: 6px; + } + @keyframes slideDown { + from { opacity: 0; transform: translateY(-10px); } + to { opacity: 1; transform: translateY(0); } + }
+
+ +
+

🚀 SlideDeck AI

Intelligent multi-agent system with review & approval workflow

+ +
Search Mode
@@ -238,8 +431,30 @@
- - + +
+ + +
+ +
+ + +
+ + +
+ +
+ + + Upload image, Excel, or CSV to generate charts based on data.
@@ -264,6 +479,36 @@
+ +

💡 Example Queries

@@ -284,6 +529,70 @@ let reportId = null; let templateOptions = {}; let planSectionsCollapsed = false; + let validModels = {}; + let currentPreviewSlides = []; + let currentSlideIndex = 0; + + // Valid models passed from backend + const MODEL_OPTIONS = {{ model_options | tojson }}; + + function toggleSettings() { + const el = document.getElementById('settingsSection'); + el.style.display = el.style.display === 'none' ? 'block' : 'none'; + } + + function updateModelOptions() { + const provider = document.getElementById('llmProvider').value; + const modelSelect = document.getElementById('llmModel'); + const baseUrlGroup = document.getElementById('baseUrlGroup'); + const sourceType = document.querySelector('input[name="sourceType"]:checked').value; + + modelSelect.innerHTML = ''; + + // Show Base URL for certain providers if needed (e.g. Azure, Ollama) + if (provider === 'az' || provider === 'ol') { + baseUrlGroup.style.display = 'block'; + } else { + baseUrlGroup.style.display = 'none'; + } + + const models = MODEL_OPTIONS[provider] || []; + let hasSelection = false; + + models.forEach(m => { + // Filter logic: + // If sourceType is 'search', only show if web_search is TRUE + // If sourceType is 'file', show ALL + + if (sourceType === 'search' && m.web_search === false) { + return; // Skip this model + } + + const opt = document.createElement('option'); + opt.value = m.full_key; + opt.textContent = m.name + (m.web_search === false ? ' (No Web)' : ''); + modelSelect.appendChild(opt); + hasSelection = true; + }); + + if (!hasSelection && models.length > 0) { + // Fallback if all filtered out? Should not happen if config is good. + const opt = document.createElement('option'); + opt.disabled = true; + opt.textContent = "No compatible models for this mode"; + modelSelect.appendChild(opt); + } + + // Trigger selection of first model if available + if (modelSelect.options.length > 0 && !modelSelect.options[0].disabled) { + modelSelect.selectedIndex = 0; + } + } + + // Initialize models on load + window.addEventListener('DOMContentLoaded', () => { + updateModelOptions(); + }); // Function to load templates from the backend async function loadTemplates() { @@ -350,8 +659,22 @@ }); } + function toggleSource(type) { + if (type === 'search') { + document.getElementById('searchSource').style.display = 'block'; + document.getElementById('fileSource').style.display = 'none'; + } else { + document.getElementById('searchSource').style.display = 'none'; + document.getElementById('fileSource').style.display = 'block'; + } + // Update model options based on source type + updateModelOptions(); + } + function setQuery(text) { document.getElementById('query').value = text; + // Ensure search mode is selected + document.querySelector('input[name="sourceType"][value="search"]').click(); } function showStatus(msg, type) { @@ -361,30 +684,64 @@ } async function generatePlan() { - const query = document.getElementById('query').value.trim(); - if (!query) { - showStatus('⚠️ Please enter a research query', 'error'); - return; - } + const sourceType = document.querySelector('input[name="sourceType"]:checked').value; + let query = ''; + let formData = new FormData(); const template = document.getElementById('template').value; + formData.append('template', template); + formData.append('search_mode', selectedMode); + + // Add Settings + const provider = document.getElementById('llmProvider').value; + const model = document.getElementById('llmModel').value; + const apiKey = document.getElementById('apiKey').value; + const apiBase = document.getElementById('apiBaseUrl').value; + + if (apiKey) formData.append('api_key', apiKey); + if (model) formData.append('llm_model', model); + if (apiBase) formData.append('api_base', apiBase); + + if (sourceType === 'search') { + query = document.getElementById('query').value.trim(); + if (!query) { + showStatus('⚠️ Please enter a research query', 'error'); + return; + } + formData.append('query', query); + } else { + const files = document.getElementById('contentFile').files; + if (files.length === 0) { + showStatus('⚠️ Please upload at least one file', 'error'); + return; + } + for (let i = 0; i < files.length; i++) { + formData.append('files', files[i]); + } + query = document.getElementById('fileTopic').value.trim(); + if (!query) { + showStatus('⚠️ Please enter a topic for the files', 'error'); + return; + } + formData.append('query', query); + } + // Chart file + const chartFile = document.getElementById('chartFile').files[0]; + if (chartFile) { + formData.append('chart_file', chartFile); + } + document.getElementById('spinner').classList.add('show'); document.getElementById('planReview').classList.remove('show'); - showStatus('🔍 Analyzing query and generating research plan...', 'loading'); + showStatus('🔍 Analyzing input and generating research plan...', 'loading'); try { console.log('🚀 Sending request to /api/plan'); - console.log('📤 Request data:', { query, search_mode: selectedMode, template }); const response = await fetch('/api/plan', { method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({ - query, - search_mode: selectedMode, - template: template - }) + body: formData // Send as FormData }); console.log('📡 Response received'); @@ -620,12 +977,21 @@ document.getElementById('spinner').classList.add('show'); showStatus('🚀 Generating slides with SlideDeck AI...', 'loading'); + // Get settings to pass to execution + const apiKey = document.getElementById('apiKey').value; + const apiBase = document.getElementById('apiBaseUrl').value; + + const payload = { + plan_id: currentPlan.plan_id + }; + + if (apiKey) payload.api_key = apiKey; + if (apiBase) payload.api_base = apiBase; + fetch('/api/execute', { method: 'POST', headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({ - plan_id: currentPlan.plan_id // ✅ FIXED - send plan_id only - }) + body: JSON.stringify(payload) }) .then(response => { if (!response.ok) { @@ -640,6 +1006,7 @@ document.getElementById('spinner').classList.remove('show'); showStatus(`✅ Slides generated successfully! (${result.slides_generated} slides in ${result.execution_time})`, 'success'); document.getElementById('downloadSection').classList.add('show'); + loadPreview(reportId); }) .catch(error => { document.getElementById('spinner').classList.remove('show'); @@ -998,6 +1365,91 @@ showStatus(`❌ Download failed: ${error.message}`, 'error'); }); } + + // Preview & Chat Functions + function loadPreview(id) { + fetch(`/api/preview/${id}`) + .then(res => res.json()) + .then(data => { + if(data.slides) { + currentPreviewSlides = data.slides; + currentSlideIndex = 0; + document.getElementById('previewContainer').style.display = 'grid'; + renderSlide(0); + } + }) + .catch(err => console.error("Preview load failed", err)); + } + + function renderSlide(index) { + if(!currentPreviewSlides || currentPreviewSlides.length === 0) return; + const slide = currentPreviewSlides[index]; + document.getElementById('slideCounter').textContent = `Slide ${index + 1} / ${currentPreviewSlides.length}`; + document.getElementById('previewTitle').textContent = slide.title || 'Untitled'; + + const list = document.getElementById('previewBullets'); + list.innerHTML = ''; + + if (slide.content && Array.isArray(slide.content)) { + slide.content.forEach(item => { + const li = document.createElement('li'); + li.textContent = item; + list.appendChild(li); + }); + } else { + list.innerHTML = '
  • (Visual Content)
  • '; + } + } + + function prevSlide() { + if(currentSlideIndex > 0) { + currentSlideIndex--; + renderSlide(currentSlideIndex); + } + } + + function nextSlide() { + if(currentSlideIndex < currentPreviewSlides.length - 1) { + currentSlideIndex++; + renderSlide(currentSlideIndex); + } + } + + async function sendChat() { + const input = document.getElementById('chatInput'); + const msg = input.value.trim(); + if(!msg || !reportId) return; + + const chatBox = document.getElementById('chatMessages'); + chatBox.innerHTML += `
    ${msg}
    `; + input.value = ''; + + try { + const res = await fetch('/api/chat', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + report_id: reportId, + slide_idx: currentSlideIndex, + instruction: msg + }) + }); + const data = await res.json(); + + chatBox.innerHTML += `
    ${data.message}
    `; + chatBox.scrollTop = chatBox.scrollHeight; + + // If demo, update content locally + if(data.updated_content) { + currentPreviewSlides[currentSlideIndex].title = data.updated_content.title; + currentPreviewSlides[currentSlideIndex].content = data.updated_content.bullets; + renderSlide(currentSlideIndex); + } + + } catch(e) { + chatBox.innerHTML += `
    Error: ${e.message}
    `; + } + }