diff --git a/AI-Document-Generator-main/Dockerfile b/AI-Document-Generator-main/Dockerfile index ba6e76f67f..3d7ea31534 100644 --- a/AI-Document-Generator-main/Dockerfile +++ b/AI-Document-Generator-main/Dockerfile @@ -1,7 +1,9 @@ +# syntax=docker/dockerfile:1.5 FROM python:3.11-slim # Install full TeXLive so LLM outputs (siunitx, paracol, tikz, etc.) compile reliably. -RUN apt-get update && \ +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + apt-get update && \ apt-get install -y --no-install-recommends \ texlive-full \ latexmk \ @@ -15,7 +17,8 @@ WORKDIR /app # Copy backend files COPY backend/requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +RUN --mount=type=cache,target=/root/.cache/pip \ + pip install -r requirements.txt COPY backend/ . diff --git a/AI-Document-Generator-main/backend/ai_generation.py b/AI-Document-Generator-main/backend/ai_generation.py index 733aa9bf59..5cc2f40b16 100644 --- a/AI-Document-Generator-main/backend/ai_generation.py +++ b/AI-Document-Generator-main/backend/ai_generation.py @@ -1,7 +1,9 @@ from __future__ import annotations import json +import re from typing import Any, Dict, List, Optional +import time from config import CLIENT_MODE, SMART_MODEL, STREAMING_ENABLED, get_chat_model, logger from langchain_utils import to_lc_messages @@ -9,6 +11,463 @@ from storage import save_user_style from prompts import latex_system_prompt, latex_context_messages +def generate_outline_with_llm( + prompt: str, + document_type: str, + constraints: Optional[Dict[str, Any]] = None, +) -> str: + if CLIENT_MODE == "langchain": + constraint_text = "" + if constraints: + tone = constraints.get("tone") + audience = constraints.get("audience") + pages = constraints.get("pageCount") + constraint_text = f"Tone: {tone}. Audience: {audience}. Target pages: {pages}." + system_prompt = ( + "You are an outline generator for document creation.\n" + f"Document type: {document_type}\n" + f"{constraint_text}\n" + "Return a concise outline with section titles and short descriptions.\n" + "Keep each description to roughly 6-12 words.\n" + "Ensure the outline scope fits the target page count.\n" + "Output plain text only, using a numbered list with 5-9 sections." + ) + messages: List[Dict[str, Any]] = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": prompt}, + ] + try: + llm = get_chat_model(SMART_MODEL) + if llm: + start = time.perf_counter() + response = llm.invoke(to_lc_messages(messages)) + elapsed = time.perf_counter() - start + content = response.content or "" + usage = getattr(response, "usage_metadata", None) + logger.info( + "[AI] outline model=%s elapsed=%.2fs chars=%s usage=%s", + SMART_MODEL, + elapsed, + len(str(content)), + usage, + ) + if content: + return str(content).strip() + except Exception as exc: + logger.error("[AI] Outline generation failed, falling back: %s", exc) + + safe_prompt = prompt.strip() or "Document" + return ( + "1) Introduction - Summary of the document goals.\n" + "2) Background - Context and key assumptions.\n" + "3) Main Content - Core points and supporting details.\n" + "4) Evidence - Data, examples, or references.\n" + "5) Conclusion - Wrap-up and next steps.\n" + f"Notes: Tailor details to '{safe_prompt}'." + ) + + +def _parse_outline_to_sections(outline_text: str) -> List[Dict[str, str]]: + lines = [ + line.strip() + for line in outline_text.split("\n") + if line.strip() and not re.match(r"^(section|details)$", line.strip(), re.IGNORECASE) + ] + sections: List[Dict[str, str]] = [] + i = 0 + while i < len(lines): + cleaned = re.sub(r"^\d+[\).\s-]+", "", lines[i]).strip() + if not cleaned: + i += 1 + continue + + split = re.split(r"[-–:]+", cleaned, maxsplit=1) + if len(split) > 1: + sections.append({"label": split[0].strip() or "Section", "value": split[1].strip()}) + i += 1 + continue + + next_line = lines[i + 1].strip() if i + 1 < len(lines) else "" + if next_line and not re.match(r"^\d+[\).\s-]+", next_line): + sections.append({"label": cleaned, "value": next_line}) + i += 2 + continue + + sections.append({"label": cleaned, "value": ""}) + i += 1 + + return sections + + +def _extract_fields_from_prompt(prompt: str, fields: List[Dict[str, Any]]) -> List[Dict[str, str]]: + lines = [line.strip() for line in prompt.split("\n") if line.strip()] + kv_pairs: Dict[str, str] = {} + for line in lines: + match = re.match(r"^([^:]{2,40}):\s*(.+)$", line) + if match: + kv_pairs[match.group(1).strip().lower()] = match.group(2).strip() + + email_match = re.search(r"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}", prompt, re.IGNORECASE) + phone_match = re.search(r"(\+?\d[\d\s().-]{7,})", prompt) + date_match = re.search(r"\b\d{1,2}[\/.-]\d{1,2}[\/.-]\d{2,4}\b", prompt) + money_match = re.search(r"\$\s?\d[\d,]*(?:\.\d{2})?", prompt) + + filled: List[Dict[str, str]] = [] + for field in fields: + label = str(field.get("label", "Field")) + value = str(field.get("value", "") or "") + if value.strip(): + filled.append({"label": label, "value": value}) + continue + label_lower = label.lower() + for key, val in kv_pairs.items(): + if key in label_lower: + value = val + break + if not value and email_match and "email" in label_lower: + value = email_match.group(0) + if not value and phone_match and "phone" in label_lower: + value = phone_match.group(0) + if not value and date_match and ("date" in label_lower or "due" in label_lower): + value = date_match.group(0) + if not value and money_match and ("total" in label_lower or "amount" in label_lower): + value = money_match.group(0) + filled.append({"label": label, "value": value}) + return filled + + +def generate_field_values( + prompt: str, + document_type: str, + fields: List[Dict[str, Any]], + constraints: Optional[Dict[str, Any]] = None, +) -> List[Dict[str, str]]: + if CLIENT_MODE == "langchain": + constraint_text = "" + if constraints: + tone = constraints.get("tone") + audience = constraints.get("audience") + pages = constraints.get("pageCount") + constraint_text = f"Tone: {tone}. Audience: {audience}. Target pages: {pages}." + system_prompt = ( + "You are extracting field values from a user prompt.\n" + "Return a JSON array of objects with keys: label, value.\n" + "Only fill values that are explicitly stated or strongly implied.\n" + "If unknown, return an empty string.\n" + f"{constraint_text}\n" + "Output JSON only." + ) + messages: List[Dict[str, Any]] = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": f"Document type: {document_type}"}, + {"role": "user", "content": f"Prompt:\n{prompt}"}, + {"role": "user", "content": f"Fields:\n{json.dumps(fields, ensure_ascii=True)}"}, + ] + try: + llm = get_chat_model(SMART_MODEL) + if llm: + start = time.perf_counter() + response = llm.invoke(to_lc_messages(messages)) + elapsed = time.perf_counter() - start + content = response.content or "" + usage = getattr(response, "usage_metadata", None) + logger.info( + "[AI] field-extract model=%s elapsed=%.2fs chars=%s usage=%s", + SMART_MODEL, + elapsed, + len(str(content)), + usage, + ) + if content: + parsed = _extract_json_array(str(content)) + if parsed: + return [ + { + "label": str(item.get("label", "Field")), + "value": str(item.get("value", "")), + } + for item in parsed + if isinstance(item, dict) + ] + except Exception as exc: + logger.error("[AI] Field extraction failed, falling back: %s", exc) + + return _extract_fields_from_prompt(prompt, fields) + +def _extract_json_array(payload: str) -> Optional[List[Dict[str, Any]]]: + try: + return json.loads(payload) + except json.JSONDecodeError: + match = re.search(r"\[[\s\S]*\]", payload) + if not match: + return None + try: + return json.loads(match.group(0)) + except json.JSONDecodeError: + return None + + +def generate_section_draft( + prompt: str, + document_type: str, + outline_text: str, + constraints: Optional[Dict[str, Any]] = None, +) -> List[Dict[str, str]]: + if CLIENT_MODE == "langchain": + constraint_text = "" + if constraints: + tone = constraints.get("tone") + audience = constraints.get("audience") + pages = constraints.get("pageCount") + constraint_text = f"Tone: {tone}. Audience: {audience}. Target pages: {pages}." + system_prompt = ( + "You are generating section content for a document.\n" + "Return a JSON array of objects with keys: label, value.\n" + "Use the provided outline sections as labels; values should be polished draft text.\n" + f"{constraint_text}\n" + "Keep the total length appropriate to the target pages.\n" + "Output JSON only." + ) + messages: List[Dict[str, Any]] = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": f"Document type: {document_type}"}, + {"role": "user", "content": f"Outline:\n{outline_text}"}, + {"role": "user", "content": f"Prompt:\n{prompt}"}, + ] + try: + llm = get_chat_model(SMART_MODEL) + if llm: + start = time.perf_counter() + response = llm.invoke(to_lc_messages(messages)) + elapsed = time.perf_counter() - start + content = response.content or "" + usage = getattr(response, "usage_metadata", None) + logger.info( + "[AI] section-draft model=%s elapsed=%.2fs chars=%s usage=%s", + SMART_MODEL, + elapsed, + len(str(content)), + usage, + ) + if content: + parsed = _extract_json_array(str(content)) + if parsed: + return [ + { + "label": str(item.get("label", "Section")), + "value": str(item.get("value", "")), + } + for item in parsed + if isinstance(item, dict) + ] + except Exception as exc: + logger.error("[AI] Section draft generation failed, falling back: %s", exc) + + if outline_text.strip(): + return _parse_outline_to_sections(outline_text) + + fallback_label = "Main Content" + return [{"label": fallback_label, "value": prompt.strip() or "Draft content"}] + + +def _fallback_template_fill(template_latex: str, outline_text: str, draft_text: Optional[str] = None) -> str: + default_text = draft_text or outline_text or "Details pending." + replacements = { + "TITLE": "Project Overview", + "SUBTITLE": "Executive Summary", + "AUTHOR": "Jane Doe", + "AUTHOR_LIST": "Jane Doe, John Smith", + "AFFILIATIONS": "John Smith Consulting", + "ABSTRACT": default_text, + "KEYWORDS": "keyword1, keyword2, keyword3", + "INTRODUCTION": default_text, + "RELATED_WORK": default_text, + "METHODOLOGY": default_text, + "RESULTS": default_text, + "DISCUSSION": default_text, + "CONCLUSION": default_text, + "REFERENCES": default_text, + "MAIN_TEXT": default_text, + "FIGURES_TABLES": default_text, + "REPORT_TITLE": "Business Report", + "DATE": "2025-01-01", + "EXEC_SUMMARY": default_text, + "BACKGROUND": default_text, + "FINDINGS": default_text, + "RECOMMENDATIONS": default_text, + "APPENDIX": default_text, + "NEWSLETTER_TITLE": "Doe Consulting Monthly", + "TOP_STORY": default_text, + "UPDATES": default_text, + "SPOTLIGHT": default_text, + "FOOTER": "Contact: info@example.com", + "RECIPE_TITLE": "Recipe Title", + "SERVINGS": "Serves 4", + "TIME": "30 minutes", + "INGREDIENTS": "\\\\begin{itemize}\\\\item Ingredient A\\\\item Ingredient B\\\\end{itemize}", + "INSTRUCTIONS": default_text, + "NOTES": "Notes and tips.", + "BUSINESS_NAME": "John Smith Consulting", + "BUSINESS_ADDRESS": "123 Example Street, Example City", + "BUSINESS_CONTACT": "billing@example.com | (555) 000-0000", + "INVOICE_NUMBER": "INV-1001", + "ISSUE_DATE": "2025-01-01", + "DUE_DATE": "2025-01-15", + "CLIENT_NAME": "Doe Corporation", + "CLIENT_ADDRESS": "456 Sample Avenue, Example City", + "CLIENT_CONTACT": "ap@example.com", + "LINE_ITEMS": "Service & 1 & $1000 & $1000 \\\\\\\\", + "SUBTOTAL": "$1000", + "TAXES": "$0", + "TOTAL": "$1000", + "PAYMENT_TERMS": "Net 15", + "PAYMENT_METHODS": "Bank transfer, credit card", + "STUDENT_NAME": "Jane Doe", + "COURSE_NAME": "Business Communications", + "INSTRUCTOR_NAME": "Dr. Rivera", + "ASSIGNMENT_TITLE": "Market Analysis", + "PROMPT": default_text, + "RESPONSE": default_text, + "CHAPTER_ONE_TITLE": "Chapter One", + "CHAPTER_ONE": default_text, + "CHAPTER_TWO_TITLE": "Chapter Two", + "CHAPTER_TWO": default_text, + "PREFACE": default_text, + "PUBLISHER": "Doe Press", + "NAME": "Jane Doe", + "TITLE_PAGE": "Project Overview", + "EMAIL": "jane.doe@example.com", + "PHONE": "(555) 000-0000", + "LOCATION": "Example City, USA", + "SUMMARY": default_text, + "EXPERIENCE": default_text, + "EDUCATION": default_text, + "SKILLS": default_text, + "PROJECTS": default_text, + "SUBJECT": "Subject", + "BODY": default_text, + "RECIPIENT_NAME": "John Smith", + "RECIPIENT_TITLE": "Hiring Manager", + "RECIPIENT_COMPANY": "Doe Corporation", + "RECIPIENT_ADDRESS": "456 Sample Avenue, Example City", + "SENDER_NAME": "Jane Doe", + "SENDER_ADDRESS": "123 Example Street, Example City", + "SENDER_EMAIL": "jane.doe@example.com", + "MONTH_YEAR": "January 2025", + "THEME": "Theme", + "WEEK_ROWS": "1 & 2 & 3 & 4 & 5 & 6 & 7 \\\\\\\\ \\\\hline", + "HEADLINE": "Launch Announcement", + "SUBTEXT": "Introducing our latest release.", + "CALL_TO_ACTION": "Visit example.com to learn more.", + "CONTACT": "contact@example.com", + "EXPERIMENT_TITLE": "Experiment", + "OBJECTIVE": default_text, + "MATERIALS": default_text, + "PROCEDURE": default_text, + "OBSERVATIONS": default_text, + "INSTITUTION": "Doe Institute", + "PRESENTER": "Jane Doe", + "AGENDA": default_text, + "KEY_POINTS": default_text, + "DATA_VISUALS": default_text, + } + + def replace(match: re.Match[str]) -> str: + key = match.group(1).strip() + return replacements.get(key, default_text) + + return re.sub(r"<<([A-Z0-9_]+)>>", replace, template_latex) + + +def generate_template_fill_stream( + template_latex: str, + document_type: str, + outline_text: str, + draft_sections: Optional[List[Dict[str, str]]] = None, + constraints: Optional[Dict[str, Any]] = None, + style_profile: Optional[Dict[str, Any]] = None, +): + """Fill a LaTeX template by replacing placeholders.""" + if CLIENT_MODE == "langchain": + constraints_text = "" + if constraints: + tone = constraints.get("tone") + audience = constraints.get("audience") + pages = constraints.get("pageCount") + constraints_text = f"Tone: {tone}. Audience: {audience}. Target pages: {pages}." + style_text = "" + if style_profile: + font = style_profile.get("font_preference") + layout = style_profile.get("layout_preference") + accent = style_profile.get("color_accent") + style_text = f"Style preferences: font={font}, layout={layout}, accent={accent}." + if draft_sections: + constraints_text = f"{constraints_text}\nUse the section content to inform placeholder values." + system_prompt = ( + "You are a LaTeX template filler.\n" + "Return the full LaTeX document with placeholders filled.\n" + "Rules:\n" + "1) Only replace placeholders like <>.\n" + "2) Do not change any other LaTeX layout/commands.\n" + "3) Output ONLY LaTeX (no markdown).\n" + "4) If you add color, use the accent token name 'accent'.\n" + f"{constraints_text}\n" + f"{style_text}\n" + "Keep the final output within the target page count.\n" + ) + messages: List[Dict[str, Any]] = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": f"Document type: {document_type}"}, + {"role": "user", "content": f"Outline/context:\n{outline_text}"}, + {"role": "user", "content": f"Template:\n{template_latex}"}, + ] + if draft_sections: + messages.append( + { + "role": "user", + "content": f"Section content (JSON):\n{json.dumps(draft_sections, ensure_ascii=True)}", + } + ) + + try: + llm = get_chat_model(SMART_MODEL, streaming=True) + if llm: + start = time.perf_counter() + total_chars = 0 + chunk_count = 0 + first_chunk = None + for chunk in llm.stream(to_lc_messages(messages)): + if chunk.content: + if first_chunk is None: + first_chunk = time.perf_counter() + chunk_count += 1 + total_chars += len(str(chunk.content)) + yield chunk.content + elapsed = time.perf_counter() - start + logger.info( + "[AI] template-fill-stream model=%s elapsed=%.2fs first_chunk=%.2fs chunks=%s chars=%s", + SMART_MODEL, + elapsed, + (first_chunk - start) if first_chunk else -1.0, + chunk_count, + total_chars, + ) + return + except Exception as exc: + logger.error("[AI] Template fill failed, falling back: %s", exc) + + draft_text = None + if draft_sections: + draft_text = "\n".join( + f"{section.get('label', 'Section')}: {section.get('value', '')}" + for section in draft_sections + ) + filled = _fallback_template_fill(template_latex, outline_text, draft_text) + chunk_size = 200 + for i in range(0, len(filled), chunk_size): + yield filled[i : i + chunk_size] + + def generate_latex_with_llm( prompt: str, history: List[Dict[str, str]], @@ -35,8 +494,19 @@ def generate_latex_with_llm( ) llm = get_chat_model(SMART_MODEL) if llm: + start = time.perf_counter() response = llm.invoke(to_lc_messages(messages)) - return response.content + elapsed = time.perf_counter() - start + content = response.content or "" + usage = getattr(response, "usage_metadata", None) + logger.info( + "[AI] latex-generate model=%s elapsed=%.2fs chars=%s usage=%s", + SMART_MODEL, + elapsed, + len(str(content)), + usage, + ) + return content except Exception as exc: logger.error("[AI] LangChain generation failed, falling back to mock: %s", exc) @@ -145,9 +615,26 @@ def generate_latex_with_llm_stream( ) llm = get_chat_model(SMART_MODEL, streaming=True) if llm: + start = time.perf_counter() + total_chars = 0 + chunk_count = 0 + first_chunk = None for chunk in llm.stream(to_lc_messages(messages)): if chunk.content: + if first_chunk is None: + first_chunk = time.perf_counter() + chunk_count += 1 + total_chars += len(str(chunk.content)) yield chunk.content + elapsed = time.perf_counter() - start + logger.info( + "[AI] latex-stream model=%s elapsed=%.2fs first_chunk=%.2fs chunks=%s chars=%s", + SMART_MODEL, + elapsed, + (first_chunk - start) if first_chunk else -1.0, + chunk_count, + total_chars, + ) return except Exception as exc: logger.error("[AI] LangChain streaming failed, falling back to mock: %s", exc) @@ -162,4 +649,11 @@ def generate_latex_with_llm_stream( yield full_latex[i:i + chunk_size] -__all__ = ["generate_latex_with_llm", "generate_latex_with_llm_stream"] +__all__ = [ + "generate_outline_with_llm", + "generate_section_draft", + "generate_field_values", + "generate_latex_with_llm", + "generate_latex_with_llm_stream", + "generate_template_fill_stream", +] diff --git a/AI-Document-Generator-main/backend/app.py b/AI-Document-Generator-main/backend/app.py index d44833212e..ec4d1c2965 100644 --- a/AI-Document-Generator-main/backend/app.py +++ b/AI-Document-Generator-main/backend/app.py @@ -1,24 +1,45 @@ import os +import mimetypes import subprocess import uuid +from pathlib import Path from datetime import datetime from typing import Any, Dict, List, Optional, Tuple import re import time import threading import queue +import urllib.error +import urllib.request from concurrent.futures import ThreadPoolExecutor from flask import Flask, jsonify, request, send_file, Response, stream_with_context from flask_cors import CORS import json -from ai_generation import generate_latex_with_llm, generate_latex_with_llm_stream +from ai_generation import ( + generate_latex_with_llm, + generate_latex_with_llm_stream, + generate_outline_with_llm, + generate_section_draft, + generate_field_values, + generate_template_fill_stream, +) from briefs import gather_brief, _preprocess_intent -from config import CLIENT_MODE, SMART_MODEL, OUTPUT_DIR, get_chat_model, logger +from config import ( + CLIENT_MODE, + SMART_MODEL, + OUTPUT_DIR, + ASSETS_DIR, + TEMPLATE_DIR, + JAVA_BACKEND_URL, + PREVIEW_MAX_INFLIGHT, + get_chat_model, + logger, +) from langchain_utils import to_lc_messages from document_types import detect_document_type -from latex_utils import clean_generated_latex +from latex_utils import apply_style_overrides, clean_generated_latex from pdf_utils import compile_latex_to_pdf, render_pdf_to_images from pdf_text_editor import convert_pdf_to_text_editor_document from storage import ( @@ -52,6 +73,65 @@ def _json_body() -> Dict[str, Any]: return request.get_json(silent=True) or {} +def _java_url(path: str) -> str: + base = JAVA_BACKEND_URL.rstrip("/") + if not path.startswith("/"): + path = "/" + path + return f"{base}{path}" + + +def _java_request_json(method: str, path: str, payload: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + url = _java_url(path) + data = None + headers = {"Content-Type": "application/json"} + if payload is not None: + data = json.dumps(payload).encode("utf-8") + req = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=10) as resp: + body = resp.read().decode("utf-8") + return json.loads(body) if body else {} + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8") if exc.fp else "" + logger.error("[JAVA] %s %s failed status=%s detail=%s", method, path, exc.code, detail) + raise + + +def _fetch_ai_session(session_id: str) -> Dict[str, Any]: + return _java_request_json("GET", f"/api/v1/ai/create/internal/sessions/{session_id}") + + +def _update_ai_session(session_id: str, payload: Dict[str, Any]) -> None: + _java_request_json("POST", f"/api/v1/ai/create/internal/sessions/{session_id}/update", payload) + + +def _sanitize_doc_type(value: str) -> str: + cleaned = re.sub(r"[^a-zA-Z0-9_]+", "", (value or "").lower()) + return cleaned or "miscellaneous" + + +def _select_template(doc_type: str, template_id: Optional[str]) -> Optional[str]: + safe_doc_type = _sanitize_doc_type(doc_type) + base_dir = Path(TEMPLATE_DIR) / safe_doc_type + if not base_dir.exists() or not base_dir.is_dir(): + return None + + if template_id: + safe_template = re.sub(r"[^a-zA-Z0-9_-]+", "", template_id) + if safe_template: + candidate = base_dir / f"{safe_template}.tex" + if candidate.exists(): + return candidate.read_text(encoding="utf-8", errors="replace") + + default_path = base_dir / "default.tex" + if default_path.exists(): + return default_path.read_text(encoding="utf-8", errors="replace") + + for tex_file in sorted(base_dir.glob("*.tex")): + return tex_file.read_text(encoding="utf-8", errors="replace") + return None + + @app.route("/api/intent/check", methods=["POST"]) def intent_check() -> Any: try: @@ -137,6 +217,7 @@ def pdf_answer() -> Any: llm = get_chat_model(model_name, max_tokens=220) if not llm: return jsonify({"error": "PDF Q&A unavailable (no AI client configured)."}), 503 + start = time.perf_counter() response = llm.invoke( to_lc_messages( [ @@ -145,6 +226,16 @@ def pdf_answer() -> Any: ] ) ) + elapsed = time.perf_counter() - start + content = response.content or "" + usage = getattr(response, "usage_metadata", None) + logger.info( + "[PDF-ANSWER] model=%s elapsed=%.2fs chars=%s usage=%s", + model_name, + elapsed, + len(str(content)), + usage, + ) answer = response.content if not answer or not str(answer).strip(): answer = _heuristic_summary(context) @@ -216,7 +307,7 @@ def generate() -> Any: brief.get("structured_brief"), edit_mode=edit_mode, ) - latex_code = clean_generated_latex(latex_code_raw) + latex_code = apply_style_overrides(clean_generated_latex(latex_code_raw), style_profile) doc_type = detect_document_type(prompt, latex_code) save_user_style(user_id, {"last_doc_type": doc_type}) @@ -351,6 +442,8 @@ def generate_stream() -> Any: chunk_queue.put(("done", None)) def submit_preview(latex: str, progress: int) -> None: + if len(preview_tasks) >= PREVIEW_MAX_INFLIGHT: + return preview_job_id = f"{job_id}-preview-{progress}" def _run_compile() -> Optional[str]: @@ -462,7 +555,7 @@ def generate_stream() -> Any: ) # Final compilation with complete LaTeX - latex_code = clean_generated_latex(accumulated_latex) + latex_code = apply_style_overrides(clean_generated_latex(accumulated_latex), style_profile) final_doc_type = detect_document_type(prompt, latex_code) save_user_style(user_id, {"last_doc_type": final_doc_type}) if not skip_template and not edit_mode: @@ -517,6 +610,173 @@ def generate_stream() -> Any: return jsonify({"error": "Unable to start streaming response", "detail": str(exc)}), 500 +@app.route("/api/create/sessions//stream", methods=["GET"]) +def create_stream(session_id: str) -> Any: + phase = (request.args.get("phase") or "outline").strip().lower() + try: + session = _fetch_ai_session(session_id) + except Exception: # noqa: BLE001 + return jsonify({"error": "Session not found"}), 404 + + user_id = session.get("userId", "default_user") + prompt = session.get("promptLatest") or session.get("promptInitial") or "" + doc_type = session.get("docType") or detect_document_type(prompt, None) + template_id = session.get("templateId") + outline_text = session.get("outlineText") or "" + constraints = session.get("outlineConstraints") + if isinstance(constraints, str) and constraints.strip(): + try: + constraints = json.loads(constraints) + except json.JSONDecodeError: + constraints = None + draft_sections_raw = session.get("draftSections") + draft_sections = None + if isinstance(draft_sections_raw, list): + draft_sections = draft_sections_raw + elif isinstance(draft_sections_raw, str) and draft_sections_raw.strip(): + try: + draft_sections = json.loads(draft_sections_raw) + except json.JSONDecodeError: + draft_sections = None + style_profile = load_user_style(user_id) + + def sse(data: Dict[str, Any]) -> str: + return f"data: {json.dumps(data)}\n\n" + + def generate(): + yield sse({"type": "phase_changed", "phase": phase}) + + if phase == "outline": + outline = generate_outline_with_llm(prompt, doc_type, constraints) + _update_ai_session( + session_id, + { + "outlineText": outline, + "outlineConstraints": json.dumps(constraints, ensure_ascii=True) if constraints else None, + "docType": doc_type, + "status": "OUTLINE_PENDING", + }, + ) + yield sse({"type": "outline_ready", "outlineText": outline}) + yield sse({"type": "phase_complete", "phase": "outline"}) + return + + if phase == "draft": + base_outline = outline_text or prompt + sections = generate_section_draft(prompt, doc_type, base_outline, constraints) + _update_ai_session( + session_id, + { + "draftSections": json.dumps(sections, ensure_ascii=True), + "outlineConstraints": json.dumps(constraints, ensure_ascii=True) if constraints else None, + "docType": doc_type, + "status": "DRAFT_READY", + }, + ) + yield sse({"type": "draft_sections", "sections": sections}) + yield sse({"type": "phase_complete", "phase": "draft", "sections": sections}) + return + + if phase == "polish": + accumulated = "" + template_latex = _select_template(doc_type, template_id) + if template_latex: + for chunk in generate_template_fill_stream( + template_latex, + doc_type, + outline_text or prompt, + draft_sections=draft_sections, + constraints=constraints, + style_profile=style_profile, + ): + accumulated += chunk + yield sse({"type": "latex_delta", "phase": "polish", "delta": chunk}) + else: + section_text = "" + if draft_sections: + section_text = "\n".join( + f"{section.get('label', 'Section')}: {section.get('value', '')}" + for section in draft_sections + ) + constraint_text = "" + if constraints: + tone = constraints.get("tone") + audience = constraints.get("audience") + pages = constraints.get("pageCount") + constraint_text = f"Tone: {tone}. Audience: {audience}. Target pages: {pages}." + polish_prompt = ( + f"Create a polished LaTeX document for a {doc_type}.\n" + "Use the provided section content and keep the substance consistent.\n" + f"{constraint_text}\n" + "Keep the final document within the target page count.\n" + ) + for chunk in generate_latex_with_llm_stream( + polish_prompt, + [], + style_profile, + doc_type, + None, + None, + section_text or outline_text or prompt, + edit_mode=True, + ): + accumulated += chunk + yield sse({"type": "latex_delta", "phase": "polish", "delta": chunk}) + + accumulated = apply_style_overrides(accumulated, style_profile) + _update_ai_session( + session_id, + {"polishedLatex": accumulated, "docType": doc_type, "status": "POLISHED_READY"}, + ) + + pdf_job_id = f"{session_id}-polished" + pdf_path = compile_latex_to_pdf(accumulated, pdf_job_id, log_errors=False) + if pdf_path and os.path.exists(pdf_path): + pdf_url = f"/output/{pdf_job_id}.pdf" + yield sse({"type": "save_complete", "docId": session_id, "pdfUrl": pdf_url}) + + yield sse({"type": "phase_complete", "phase": "polish", "latex": accumulated}) + return + + yield sse({"type": "error", "message": f"Unknown phase: {phase}"}) + + return Response( + stream_with_context(generate()), + mimetype="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + +@app.route("/api/create/sessions//fields", methods=["POST"]) +def fill_fields(session_id: str) -> Any: + try: + session = _fetch_ai_session(session_id) + except Exception: # noqa: BLE001 + return jsonify({"error": "Session not found"}), 404 + + data = _json_body() + fields = data.get("fields") or [] + extra_prompt = data.get("extraPrompt") or "" + if not isinstance(fields, list): + return jsonify({"error": "Fields must be a list"}), 400 + + prompt = session.get("promptLatest") or session.get("promptInitial") or "" + if extra_prompt: + prompt = f"{prompt}\n{extra_prompt}" + doc_type = session.get("docType") or detect_document_type(prompt, None) + constraints = session.get("outlineConstraints") + if isinstance(constraints, str) and constraints.strip(): + try: + constraints = json.loads(constraints) + except json.JSONDecodeError: + constraints = None + + filled = generate_field_values(prompt, doc_type, fields, constraints) + return jsonify({"fields": filled}) + + + + @app.route("/api/progressive_render", methods=["POST"]) def progressive_render() -> Any: """Compile arbitrary LaTeX (partial or masked) for progressive previews.""" @@ -533,12 +793,13 @@ def progressive_render() -> Any: return jsonify({"error": "Progressive compilation failed"}), 500 -@app.route("/output/", methods=["GET"]) -def serve_pdf(filename: str) -> Any: - """Serve generated PDF files.""" +@app.route("/output/", methods=["GET"]) +def serve_output_file(filename: str) -> Any: + """Serve generated PDF files and stored assets.""" file_path = os.path.join(OUTPUT_DIR, filename) if os.path.exists(file_path): - return send_file(file_path, mimetype="application/pdf") + mime_type, _ = mimetypes.guess_type(file_path) + return send_file(file_path, mimetype=mime_type or "application/octet-stream") return jsonify({"error": "File not found"}), 404 @@ -552,6 +813,30 @@ def get_style(user_id: str) -> Any: return jsonify({"style": load_user_style(user_id)}) +@app.route("/api/style/", methods=["POST"]) +def update_style(user_id: str) -> Any: + data = _json_body() + if not isinstance(data, dict): + return jsonify({"error": "Style payload must be an object"}), 400 + current = load_user_style(user_id) or {} + merged = {**current, **data} + save_user_style(user_id, merged) + return jsonify({"style": merged}) + + +@app.route("/api/style/apply", methods=["POST"]) +def apply_style() -> Any: + data = _json_body() + latex = data.get("latex") + style = data.get("style") or {} + if not latex or not isinstance(latex, str): + return jsonify({"error": "Missing LaTeX payload"}), 400 + if not isinstance(style, dict): + return jsonify({"error": "Style payload must be an object"}), 400 + updated = apply_style_overrides(latex, style) + return jsonify({"latex": updated}) + + @app.route("/api/import_template", methods=["POST"]) def import_template() -> Any: """Accept a PDF upload, extract layout via vision model, and save as a template.""" @@ -585,6 +870,31 @@ Body text goes here. return jsonify({"message": "Template imported", "docType": doc_type, "pages": len(images)}) +@app.route("/api/assets/upload", methods=["POST"]) +def upload_asset() -> Any: + file = request.files.get("file") + if not file: + return jsonify({"error": "Missing file"}), 400 + + _, ext = os.path.splitext(file.filename or "") + ext = ext.lower() + if ext not in {".png", ".jpg", ".jpeg", ".gif"}: + return jsonify({"error": "Unsupported file type"}), 400 + + asset_id = f"{uuid.uuid4().hex}{ext}" + output_path = os.path.join(ASSETS_DIR, asset_id) + os.makedirs(ASSETS_DIR, exist_ok=True) + file.save(output_path) + + return jsonify( + { + "assetId": asset_id, + "assetUrl": f"/output/assets/{asset_id}", + "latexPath": f"assets/{asset_id}", + } + ) + + @app.route("/api/pdf-editor/document", methods=["GET"]) def pdf_editor_document() -> Any: """Expose a JSON snapshot of the PDF for rich text editing.""" diff --git a/AI-Document-Generator-main/backend/briefs.py b/AI-Document-Generator-main/backend/briefs.py index 758fcb34b0..f2febaa056 100644 --- a/AI-Document-Generator-main/backend/briefs.py +++ b/AI-Document-Generator-main/backend/briefs.py @@ -3,6 +3,7 @@ from __future__ import annotations import re import json from typing import Any, Dict, List, Optional +import time from config import CLIENT_MODE, FAST_MODEL, SMART_MODEL, get_chat_model, logger from langchain_utils import to_lc_messages @@ -144,7 +145,18 @@ def classify_intent_with_llm(prompt: str, history: List[Dict[str, str]], current if not llm: logger.info("[INTENT] skip llm classify: no LangChain client") return None + start = time.perf_counter() response = llm.invoke(to_lc_messages(conversation)) + elapsed = time.perf_counter() - start + content = response.content or "" + usage = getattr(response, "usage_metadata", None) + logger.info( + "[INTENT] llm_classify model=%s elapsed=%.2fs chars=%s usage=%s", + FAST_MODEL or SMART_MODEL, + elapsed, + len(str(content)), + usage, + ) content = response.content if not content: logger.info("[INTENT] llm_classify empty content") @@ -222,7 +234,18 @@ def detect_fabrication_opt_in(prompt: str, history: List[Dict[str, str]]) -> boo ) if not llm: return False + start = time.perf_counter() response = llm.invoke(to_lc_messages(conversation)) + elapsed = time.perf_counter() - start + content = response.content or "" + usage = getattr(response, "usage_metadata", None) + logger.info( + "[INTENT] fabrication-check model=%s elapsed=%.2fs chars=%s usage=%s", + FAST_MODEL or SMART_MODEL, + elapsed, + len(str(content)), + usage, + ) content = response.content if not content: return False @@ -357,6 +380,7 @@ def _ai_missing_message( llm = get_chat_model(SMART_MODEL, max_tokens=400) if not llm: return None + start = time.perf_counter() response = llm.invoke( to_lc_messages( [ @@ -365,6 +389,16 @@ def _ai_missing_message( ] ) ) + elapsed = time.perf_counter() - start + content = response.content or "" + usage = getattr(response, "usage_metadata", None) + logger.info( + "[AI] missing-questions model=%s elapsed=%.2fs chars=%s usage=%s", + SMART_MODEL, + elapsed, + len(str(content)), + usage, + ) return response.content except Exception as exc: logger.error("[AI] missing-questions failed: %s", exc) diff --git a/AI-Document-Generator-main/backend/config.py b/AI-Document-Generator-main/backend/config.py index 4ab194f19a..b8486cca07 100644 --- a/AI-Document-Generator-main/backend/config.py +++ b/AI-Document-Generator-main/backend/config.py @@ -8,16 +8,21 @@ logger = logging.getLogger(__name__) BASE_DIR = os.path.dirname(os.path.abspath(__file__)) OUTPUT_DIR = os.path.join(BASE_DIR, "output") +ASSETS_DIR = os.path.join(OUTPUT_DIR, "assets") DATA_DIR = os.path.join(BASE_DIR, "data") +TEMPLATE_DIR = os.path.join(BASE_DIR, "templates") STYLE_DB_PATH = os.path.join(DATA_DIR, "user_styles.json") TEMPLATE_DB_PATH = os.path.join(DATA_DIR, "user_templates.json") VERSIONS_DB_PATH = os.path.join(DATA_DIR, "versions.json") os.makedirs(OUTPUT_DIR, exist_ok=True) +os.makedirs(ASSETS_DIR, exist_ok=True) os.makedirs(DATA_DIR, exist_ok=True) +os.makedirs(TEMPLATE_DIR, exist_ok=True) OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY") OPENAI_BASE_URL = os.environ.get("OPENAI_BASE_URL") +JAVA_BACKEND_URL = os.environ.get("JAVA_BACKEND_URL", "http://localhost:8080") # Default to GPT-5.1 for full document generation (smart model). # Allow override via SMART_MODEL or legacy OPENAI_MODEL. SMART_MODEL = os.environ.get("SMART_MODEL") or os.environ.get("OPENAI_MODEL") or "gpt-5.1" @@ -31,6 +36,7 @@ _ChatOpenAI = None STREAMING_ENABLED = os.environ.get("AI_STREAMING", "true").lower() not in {"0", "false", "no"} if OPENAI_BASE_URL and "ollama" in OPENAI_BASE_URL and "AI_STREAMING" not in os.environ: STREAMING_ENABLED = False +PREVIEW_MAX_INFLIGHT = int(os.environ.get("AI_PREVIEW_MAX_INFLIGHT", "3")) if OPENAI_API_KEY: try: @@ -66,16 +72,20 @@ def get_chat_model( __all__ = [ "logger", "OUTPUT_DIR", + "ASSETS_DIR", "DATA_DIR", + "TEMPLATE_DIR", "STYLE_DB_PATH", "TEMPLATE_DB_PATH", "VERSIONS_DB_PATH", "OPENAI_API_KEY", "OPENAI_BASE_URL", + "JAVA_BACKEND_URL", "SMART_MODEL", "CLIENT_MODE", "LANGCHAIN_AVAILABLE", "get_chat_model", "FAST_MODEL", "STREAMING_ENABLED", + "PREVIEW_MAX_INFLIGHT", ] diff --git a/AI-Document-Generator-main/backend/latex_utils.py b/AI-Document-Generator-main/backend/latex_utils.py index 223f596045..29d36811e3 100644 --- a/AI-Document-Generator-main/backend/latex_utils.py +++ b/AI-Document-Generator-main/backend/latex_utils.py @@ -5,7 +5,12 @@ from functools import lru_cache from typing import List, Optional ALLOWED_LATEX_PACKAGES = { + "courier", + "graphicx", "geometry", + "helvet", + "lmodern", + "mathpazo", "xcolor", "tabularx", "paracol", @@ -423,15 +428,13 @@ def clean_generated_latex(latex_code: str) -> str: upgrade_tabular_tables_to_longtable( ensure_longtable_support( normalize_tabular_like_begins( - strip_placeholder_rules( - strip_number_grouping_junk( - fix_tabular_row_endings( - strip_leading_pagebreaks( - remove_leading_pagebreaks( - strip_missing_packages( - sanitize_latex( - ensure_full_latex_document(latex_code) - ) + strip_number_grouping_junk( + fix_tabular_row_endings( + strip_leading_pagebreaks( + remove_leading_pagebreaks( + strip_missing_packages( + sanitize_latex( + ensure_full_latex_document(latex_code) ) ) ) @@ -444,6 +447,80 @@ def clean_generated_latex(latex_code: str) -> str: ) +def apply_style_overrides(latex_code: str, style_profile: dict) -> str: + """Apply deterministic font + accent styling without altering layout.""" + if not latex_code: + return latex_code + + code = latex_code + font = (style_profile or {}).get("font_preference") or "" + accent = (style_profile or {}).get("color_accent") or "" + + font_map = { + "serif": ("mathpazo", "\\renewcommand{\\familydefault}{\\rmdefault}"), + "sans": ("helvet", "\\renewcommand{\\familydefault}{\\sfdefault}"), + "helvet": ("helvet", "\\renewcommand{\\familydefault}{\\sfdefault}"), + "mono": ("courier", "\\renewcommand{\\familydefault}{\\ttdefault}"), + "modern": ("lmodern", None), + } + pkg = None + family_cmd = None + if isinstance(font, str): + pkg, family_cmd = font_map.get(font.lower(), (None, None)) + + if pkg: + code = re.sub( + r"^\\usepackage\{(helvet|mathpazo|lmodern|courier)\}\s*$", + "", + code, + flags=re.MULTILINE, + ) + code = re.sub( + r"^\\renewcommand\{\\familydefault\}\{\\(sfdefault|rmdefault|ttdefault)\}\s*$", + "", + code, + flags=re.MULTILINE, + ) + + def _ensure_package_and_command(text: str) -> str: + if not pkg: + return text + insert = f"\\usepackage{{{pkg}}}\n" + if family_cmd: + insert += family_cmd + "\n" + if r"\begin{document}" in text: + return re.sub(r"(\\begin\{document\})", insert + r"\1", text, count=1) + return insert + text + + code = _ensure_package_and_command(code) + + if accent: + accent_hex_match = re.fullmatch(r"#?([0-9a-fA-F]{6})", str(accent).strip()) + if accent_hex_match: + accent_line = f"\\definecolor{{accent}}{{HTML}}{{{accent_hex_match.group(1).upper()}}}" + else: + accent_name = re.sub(r"[^A-Za-z]+", "", str(accent)) or "blue" + accent_line = f"\\colorlet{{accent}}{{{accent_name}}}" + + if re.search(r"^\\definecolor\{accent\}|^\\colorlet\{accent\}", code, flags=re.MULTILINE): + code = re.sub(r"^\\definecolor\{accent\}.*$", accent_line, code, flags=re.MULTILINE) + code = re.sub(r"^\\colorlet\{accent\}.*$", accent_line, code, flags=re.MULTILINE) + else: + needs_xcolor = r"\usepackage{xcolor}" not in code + insert = "" + if needs_xcolor: + insert += "\\usepackage{xcolor}\n" + insert += accent_line + "\n" + if r"\usepackage{xcolor}" in code: + code = re.sub(r"(\\usepackage\{xcolor\}[^\n]*\n)", r"\1" + insert, code, count=1) + elif r"\begin{document}" in code: + code = re.sub(r"(\\begin\{document\})", insert + r"\1", code, count=1) + else: + code = insert + code + + return code + + def ensure_full_latex_document(text: str) -> str: """Trim output to a single LaTeX document starting at \\documentclass and ending at \\end{document}.""" if not text: @@ -465,5 +542,6 @@ __all__ = [ "fix_tabular_row_endings", "rebalance_invoice_tables", "clean_generated_latex", + "apply_style_overrides", "ensure_full_latex_document", ] diff --git a/AI-Document-Generator-main/backend/pdf_utils.py b/AI-Document-Generator-main/backend/pdf_utils.py index 7694c193bc..fd2a32a29e 100644 --- a/AI-Document-Generator-main/backend/pdf_utils.py +++ b/AI-Document-Generator-main/backend/pdf_utils.py @@ -29,6 +29,7 @@ def _run_latex(engine: str, tex_filename: str) -> subprocess.CompletedProcess: stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=30, + cwd=OUTPUT_DIR, ) diff --git a/AI-Document-Generator-main/backend/prompts.py b/AI-Document-Generator-main/backend/prompts.py index 5dc1bc2ae0..fb0fc979ab 100644 --- a/AI-Document-Generator-main/backend/prompts.py +++ b/AI-Document-Generator-main/backend/prompts.py @@ -6,7 +6,12 @@ from typing import Dict, List, Optional, Any # Shared rules ALLOWED_LATEX_PACKAGES = [ + "courier", + "graphicx", "geometry", + "helvet", + "lmodern", + "mathpazo", "xcolor", "tabularx", "paracol", @@ -50,7 +55,7 @@ def latex_system_prompt(style_profile: Dict[str, Any], document_type: str, templ f"- " + "\n- ".join(LATEX_RULES) + "\n" "2) Use only the allowlisted packages:\n" f"- " + "\n- ".join(ALLOWED_LATEX_PACKAGES) + "\n" - f"3) Respect preferred font ({safe_style['font_preference']}), tone ({safe_style['tone']}), and color accent ({safe_style['color_accent']}).\n" + f"3) Respect preferred font ({safe_style['font_preference']}), tone ({safe_style['tone']}), and color accent ({safe_style['color_accent']}). Use the accent token name 'accent' (e.g., \\color{{accent}} or \\textcolor{{accent}}{{...}}).\n" "4) If a template hint is provided, stay close to its layout and styling.\n" "5) Do NOT add placeholder images or black boxes; omit images entirely unless an explicit path or real image content is provided. Do not use \\rule, tikz, or colored rectangles as image stand-ins.\n" "6) Return a full compilable document." diff --git a/AI-Document-Generator-main/backend/scripts/generate_template_thumbnails.py b/AI-Document-Generator-main/backend/scripts/generate_template_thumbnails.py new file mode 100644 index 0000000000..02b937270a --- /dev/null +++ b/AI-Document-Generator-main/backend/scripts/generate_template_thumbnails.py @@ -0,0 +1,262 @@ +import re +import shutil +import subprocess +import tempfile +from pathlib import Path + + +ROOT_DIR = Path(__file__).resolve().parents[2] +TEMPLATE_ROOT = ROOT_DIR / "backend" / "templates" +FRONTEND_PUBLIC = ROOT_DIR / "frontend" / "public" / "templates" +FRONTEND_CATALOG = ROOT_DIR / "frontend" / "src" / "templateCatalog.ts" +TIMEOUT_SEC = 60 + +LOREM_SENTENCE = ( + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt " + "ut labore et dolore magna aliqua." +) +LOREM_PARAGRAPH = ( + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt " + "ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco " + "laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in " + "voluptate velit esse cillum dolore eu fugiat nulla pariatur." +) +LOREM_SHORT = "Lorem ipsum dolor sit amet, consectetur adipiscing elit." + +PLACEHOLDER_REPLACEMENTS = { + "TITLE": "Sample Title", + "SUBTITLE": "Sample Subtitle", + "AUTHOR": "Sample Author", + "AUTHOR_LIST": "Sample Author One, Sample Author Two", + "AFFILIATIONS": "Sample Organization", + "ABSTRACT": LOREM_PARAGRAPH, + "KEYWORDS": "keyword1, keyword2, keyword3, keyword4", + "INTRODUCTION": LOREM_PARAGRAPH, + "RELATED_WORK": LOREM_PARAGRAPH, + "METHODOLOGY": LOREM_PARAGRAPH, + "RESULTS": LOREM_PARAGRAPH, + "DISCUSSION": LOREM_PARAGRAPH, + "CONCLUSION": LOREM_SHORT, + "REFERENCES": "Doe, J. (2024). Example Reference. Journal of Examples.", + "MAIN_TEXT": f"{LOREM_PARAGRAPH} {LOREM_PARAGRAPH}", + "FIGURES_TABLES": "Figure 1: Example chart. Table 1: Summary of results.", + "REPORT_TITLE": "Business Report", + "DATE": "2025-01-01", + "EXEC_SUMMARY": LOREM_PARAGRAPH, + "BACKGROUND": LOREM_PARAGRAPH, + "FINDINGS": f"{LOREM_SENTENCE} {LOREM_SENTENCE}", + "RECOMMENDATIONS": "Recommendation 1: Improve efficiency. Recommendation 2: Reduce costs.", + "APPENDIX": LOREM_SHORT, + "NEWSLETTER_TITLE": "Monthly Newsletter", + "TOP_STORY": LOREM_PARAGRAPH, + "UPDATES": f"{LOREM_SENTENCE} {LOREM_SENTENCE}", + "SPOTLIGHT": LOREM_PARAGRAPH, + "FOOTER": "Contact: info@example.com | 123 Main Street", + "RECIPE_TITLE": "Sample Recipe", + "SERVINGS": "Serves 4", + "TIME": "30 minutes", + "INGREDIENTS": r"\begin{itemize}\item Ingredient A\item Ingredient B\item Ingredient C\end{itemize}", + "INSTRUCTIONS": LOREM_PARAGRAPH, + "NOTES": "Notes and tips: adjust seasoning to taste.", + "BUSINESS_NAME": "Your Company", + "BUSINESS_ADDRESS": "123 Main Street, Springfield", + "BUSINESS_CONTACT": "email@example.com | (555) 555-5555", + "INVOICE_NUMBER": "INV-001", + "ISSUE_DATE": "2025-01-01", + "DUE_DATE": "2025-01-15", + "CLIENT_NAME": "Client Name", + "CLIENT_ADDRESS": "456 Client Ave, Metropolis", + "CLIENT_CONTACT": "client@example.com", + "LINE_ITEMS": r"Design Services & 8 & 120 & 960 \\ Consulting & 4 & 150 & 600 \\", + "SUBTOTAL": "1560", + "TAXES": "124.80", + "TOTAL": "1684.80", + "PAYMENT_TERMS": "Net 15", + "PAYMENT_METHODS": "Bank transfer, credit card", + "STUDENT_NAME": "Student Name", + "COURSE_NAME": "Course Name", + "INSTRUCTOR_NAME": "Instructor Name", + "ASSIGNMENT_TITLE": "Assignment Title", + "PROMPT": LOREM_SHORT, + "RESPONSE": LOREM_PARAGRAPH, + "CHAPTER_ONE_TITLE": "Chapter One", + "CHAPTER_ONE": LOREM_PARAGRAPH, + "CHAPTER_TWO_TITLE": "Chapter Two", + "CHAPTER_TWO": LOREM_PARAGRAPH, + "PREFACE": LOREM_SHORT, + "PUBLISHER": "Publisher", + "NAME": "Name", + "EMAIL": "email@example.com", + "PHONE": "(555) 555-5555", + "LOCATION": "City, Country", + "SUMMARY": LOREM_SENTENCE, + "EXPERIENCE": f"{LOREM_SENTENCE} {LOREM_SENTENCE}", + "EDUCATION": "University Name, B.S. in Example Studies", + "SKILLS": "Skills: Analysis, Design, Communication", + "PROJECTS": LOREM_SHORT, + "SUBJECT": "Subject", + "BODY": LOREM_PARAGRAPH, + "RECIPIENT_NAME": "Recipient Name", + "RECIPIENT_TITLE": "Recipient Title", + "RECIPIENT_COMPANY": "Recipient Company", + "RECIPIENT_ADDRESS": "Recipient Address", + "SENDER_NAME": "Sender Name", + "SENDER_ADDRESS": "Sender Address", + "SENDER_EMAIL": "sender@example.com", + "MONTH_YEAR": "January 2025", + "THEME": "Theme", + "WEEK_ROWS": "1 & 2 & 3 & 4 & 5 & 6 & 7 \\\\\\\\ \\\\hline", + "HEADLINE": "Headline", + "SUBTEXT": "Supporting message with a clear benefit.", + "CALL_TO_ACTION": "Call to action", + "CONTACT": "contact@example.com", + "EXPERIMENT_TITLE": "Experiment Title", + "OBJECTIVE": LOREM_SHORT, + "MATERIALS": "Materials list goes here.", + "PROCEDURE": LOREM_PARAGRAPH, + "OBSERVATIONS": LOREM_SHORT, + "INSTITUTION": "Institution", + "PRESENTER": "Presenter", + "AGENDA": "Agenda goes here.", + "KEY_POINTS": "Key points go here.", + "DATA_VISUALS": "Data visuals go here.", + "SUBTITLE": "Subtitle", + "ORGANIZATION": "Organization", +} + + +def render_template_latex(raw_latex: str) -> str: + def replace(match: re.Match[str]) -> str: + key = match.group(1).strip() + return PLACEHOLDER_REPLACEMENTS.get(key, key.replace("_", " ").title()) + + return re.sub(r"<<([A-Z0-9_]+)>>", replace, raw_latex) + + +def find_converter() -> str | None: + if shutil.which("pdftoppm"): + return "pdftoppm" + if shutil.which("magick"): + return "magick" + if shutil.which("convert"): + return "convert" + return None + + +def pdf_to_jpg(pdf_path: Path, jpg_path: Path, converter: str) -> None: + if converter == "pdftoppm": + subprocess.run( + ["pdftoppm", "-jpeg", "-f", "1", "-singlefile", str(pdf_path), str(jpg_path.with_suffix(''))], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=TIMEOUT_SEC, + ) + return + if converter == "magick": + subprocess.run( + ["magick", "convert", "-density", "150", str(pdf_path), "-quality", "90", str(jpg_path)], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=TIMEOUT_SEC, + ) + return + subprocess.run( + ["convert", "-density", "150", str(pdf_path), "-quality", "90", str(jpg_path)], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=TIMEOUT_SEC, + ) + + +def main() -> None: + if not shutil.which("pdflatex"): + raise SystemExit("pdflatex not found. Install TeX Live or MikTeX to generate thumbnails.") + + converter = find_converter() + if not converter: + raise SystemExit("No PDF-to-image converter found. Install poppler-utils or ImageMagick.") + + tex_files = list(TEMPLATE_ROOT.rglob("*.tex")) + if not tex_files: + raise SystemExit(f"No templates found in {TEMPLATE_ROOT}") + + FRONTEND_PUBLIC.mkdir(parents=True, exist_ok=True) + catalog: dict[str, list[str]] = {} + + failures: list[str] = [] + for tex_file in tex_files: + doc_type = tex_file.parent.name + template_id = tex_file.stem + target_dir = FRONTEND_PUBLIC / doc_type + target_dir.mkdir(parents=True, exist_ok=True) + target_jpg = target_dir / f"{template_id}.jpg" + + catalog.setdefault(doc_type, []) + if template_id not in catalog[doc_type]: + catalog[doc_type].append(template_id) + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = Path(tmpdir) + rendered = render_template_latex(tex_file.read_text(encoding="ascii")) + tmp_tex = tmpdir_path / "template.tex" + tmp_tex.write_text(rendered, encoding="ascii") + + try: + subprocess.run( + ["pdflatex", "-interaction=nonstopmode", "-halt-on-error", tmp_tex.name], + check=True, + cwd=tmpdir_path, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=TIMEOUT_SEC, + ) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc: + failures.append(f"{tex_file}: pdflatex failed ({exc})") + continue + + pdf_path = tmpdir_path / "template.pdf" + if not pdf_path.exists(): + failures.append(f"{tex_file}: PDF not generated") + continue + + try: + pdf_to_jpg(pdf_path, target_jpg, converter) + print(f"Wrote {target_jpg}") + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc: + failures.append(f"{tex_file}: image conversion failed ({exc})") + continue + + if failures: + print("\nFailures:") + for failure in failures: + print(f"- {failure}") + raise SystemExit("Template thumbnail generation completed with errors.") + + entries = [] + for doc_type in sorted(catalog.keys()): + templates = sorted(catalog[doc_type]) + if "default" in templates: + templates = ["default"] + [t for t in templates if t != "default"] + entries.append( + f" {{ docType: '{doc_type}', templateCount: {len(templates)}, templates: {templates} }}" + ) + + FRONTEND_CATALOG.write_text( + "// Auto-generated by generate_template_thumbnails.py\n" + "export type TemplateCatalogEntry = {\n" + " docType: string\n" + " templateCount: number\n" + " templates: string[]\n" + "}\n\n" + "export const templateCatalog: TemplateCatalogEntry[] = [\n" + + ",\n".join(entries) + + "\n]\n", + encoding="ascii", + ) + print(f"Wrote catalog {FRONTEND_CATALOG}") + + +if __name__ == "__main__": + main() diff --git a/AI-Document-Generator-main/backend/vision.py b/AI-Document-Generator-main/backend/vision.py index f5d66ecaca..280e2217ee 100644 --- a/AI-Document-Generator-main/backend/vision.py +++ b/AI-Document-Generator-main/backend/vision.py @@ -1,6 +1,7 @@ from __future__ import annotations from typing import Any, List, Optional +import time from config import CLIENT_MODE, SMART_MODEL, get_chat_model, logger from langchain_utils import to_lc_messages @@ -30,7 +31,18 @@ def vision_layout_from_images(image_urls: List[str], doc_type: str) -> Optional[ llm = get_chat_model(SMART_MODEL, max_tokens=2800) if not llm: return None + start = time.perf_counter() response = llm.invoke(to_lc_messages(messages)) + elapsed = time.perf_counter() - start + content = response.content or "" + usage = getattr(response, "usage_metadata", None) + logger.info( + "[IMPORT] vision model=%s elapsed=%.2fs chars=%s usage=%s", + SMART_MODEL, + elapsed, + len(str(content)), + usage, + ) return response.content except Exception as exc: logger.error("[IMPORT] vision generation failed: %s", exc) diff --git a/AI-Document-Generator-main/frontend/public/templates/academic_articles/default.jpg b/AI-Document-Generator-main/frontend/public/templates/academic_articles/default.jpg new file mode 100644 index 0000000000..ed563cd2a1 Binary files /dev/null and b/AI-Document-Generator-main/frontend/public/templates/academic_articles/default.jpg differ diff --git a/AI-Document-Generator-main/frontend/public/templates/academic_journals/default.jpg b/AI-Document-Generator-main/frontend/public/templates/academic_journals/default.jpg new file mode 100644 index 0000000000..ad9e098f02 Binary files /dev/null and b/AI-Document-Generator-main/frontend/public/templates/academic_journals/default.jpg differ diff --git a/AI-Document-Generator-main/frontend/public/templates/assignments/default.jpg b/AI-Document-Generator-main/frontend/public/templates/assignments/default.jpg new file mode 100644 index 0000000000..d4da4e077d Binary files /dev/null and b/AI-Document-Generator-main/frontend/public/templates/assignments/default.jpg differ diff --git a/AI-Document-Generator-main/frontend/public/templates/books/default.jpg b/AI-Document-Generator-main/frontend/public/templates/books/default.jpg new file mode 100644 index 0000000000..856479a447 Binary files /dev/null and b/AI-Document-Generator-main/frontend/public/templates/books/default.jpg differ diff --git a/AI-Document-Generator-main/frontend/public/templates/business_cards/default.jpg b/AI-Document-Generator-main/frontend/public/templates/business_cards/default.jpg new file mode 100644 index 0000000000..d771fa2d71 Binary files /dev/null and b/AI-Document-Generator-main/frontend/public/templates/business_cards/default.jpg differ diff --git a/AI-Document-Generator-main/frontend/public/templates/business_reports/default.jpg b/AI-Document-Generator-main/frontend/public/templates/business_reports/default.jpg new file mode 100644 index 0000000000..4011f0a70f Binary files /dev/null and b/AI-Document-Generator-main/frontend/public/templates/business_reports/default.jpg differ diff --git a/AI-Document-Generator-main/frontend/public/templates/calendars/default.jpg b/AI-Document-Generator-main/frontend/public/templates/calendars/default.jpg new file mode 100644 index 0000000000..e1af56c1cd Binary files /dev/null and b/AI-Document-Generator-main/frontend/public/templates/calendars/default.jpg differ diff --git a/AI-Document-Generator-main/frontend/public/templates/conference_posters/default.jpg b/AI-Document-Generator-main/frontend/public/templates/conference_posters/default.jpg new file mode 100644 index 0000000000..7a02e2c626 Binary files /dev/null and b/AI-Document-Generator-main/frontend/public/templates/conference_posters/default.jpg differ diff --git a/AI-Document-Generator-main/frontend/public/templates/cover_letters/default.jpg b/AI-Document-Generator-main/frontend/public/templates/cover_letters/default.jpg new file mode 100644 index 0000000000..068e13f244 Binary files /dev/null and b/AI-Document-Generator-main/frontend/public/templates/cover_letters/default.jpg differ diff --git a/AI-Document-Generator-main/frontend/public/templates/cvs_and_resumes/default.jpg b/AI-Document-Generator-main/frontend/public/templates/cvs_and_resumes/default.jpg new file mode 100644 index 0000000000..7ea1cb6cb9 Binary files /dev/null and b/AI-Document-Generator-main/frontend/public/templates/cvs_and_resumes/default.jpg differ diff --git a/AI-Document-Generator-main/frontend/public/templates/essays/default.jpg b/AI-Document-Generator-main/frontend/public/templates/essays/default.jpg new file mode 100644 index 0000000000..0f1d62150d Binary files /dev/null and b/AI-Document-Generator-main/frontend/public/templates/essays/default.jpg differ diff --git a/AI-Document-Generator-main/frontend/public/templates/formal_letters/default.jpg b/AI-Document-Generator-main/frontend/public/templates/formal_letters/default.jpg new file mode 100644 index 0000000000..8d86b72ec2 Binary files /dev/null and b/AI-Document-Generator-main/frontend/public/templates/formal_letters/default.jpg differ diff --git a/AI-Document-Generator-main/frontend/public/templates/invoices/default.jpg b/AI-Document-Generator-main/frontend/public/templates/invoices/default.jpg new file mode 100644 index 0000000000..5a9c2830b2 Binary files /dev/null and b/AI-Document-Generator-main/frontend/public/templates/invoices/default.jpg differ diff --git a/AI-Document-Generator-main/frontend/public/templates/laboratory_books/default.jpg b/AI-Document-Generator-main/frontend/public/templates/laboratory_books/default.jpg new file mode 100644 index 0000000000..91f88ef0f1 Binary files /dev/null and b/AI-Document-Generator-main/frontend/public/templates/laboratory_books/default.jpg differ diff --git a/AI-Document-Generator-main/frontend/public/templates/laboratory_reports/default.jpg b/AI-Document-Generator-main/frontend/public/templates/laboratory_reports/default.jpg new file mode 100644 index 0000000000..3f3e52da3e Binary files /dev/null and b/AI-Document-Generator-main/frontend/public/templates/laboratory_reports/default.jpg differ diff --git a/AI-Document-Generator-main/frontend/public/templates/miscellaneous/default.jpg b/AI-Document-Generator-main/frontend/public/templates/miscellaneous/default.jpg new file mode 100644 index 0000000000..d285fbab5e Binary files /dev/null and b/AI-Document-Generator-main/frontend/public/templates/miscellaneous/default.jpg differ diff --git a/AI-Document-Generator-main/frontend/public/templates/newsletters/default.jpg b/AI-Document-Generator-main/frontend/public/templates/newsletters/default.jpg new file mode 100644 index 0000000000..419ea17b24 Binary files /dev/null and b/AI-Document-Generator-main/frontend/public/templates/newsletters/default.jpg differ diff --git a/AI-Document-Generator-main/frontend/public/templates/presentations/default.jpg b/AI-Document-Generator-main/frontend/public/templates/presentations/default.jpg new file mode 100644 index 0000000000..77bcde6ce5 Binary files /dev/null and b/AI-Document-Generator-main/frontend/public/templates/presentations/default.jpg differ diff --git a/AI-Document-Generator-main/frontend/public/templates/recipes/default.jpg b/AI-Document-Generator-main/frontend/public/templates/recipes/default.jpg new file mode 100644 index 0000000000..9480b616f3 Binary files /dev/null and b/AI-Document-Generator-main/frontend/public/templates/recipes/default.jpg differ diff --git a/AI-Document-Generator-main/frontend/public/templates/signs/default.jpg b/AI-Document-Generator-main/frontend/public/templates/signs/default.jpg new file mode 100644 index 0000000000..bbd5a4a152 Binary files /dev/null and b/AI-Document-Generator-main/frontend/public/templates/signs/default.jpg differ diff --git a/AI-Document-Generator-main/frontend/public/templates/theses/default.jpg b/AI-Document-Generator-main/frontend/public/templates/theses/default.jpg new file mode 100644 index 0000000000..e6b0750e8a Binary files /dev/null and b/AI-Document-Generator-main/frontend/public/templates/theses/default.jpg differ diff --git a/AI-Document-Generator-main/frontend/public/templates/title_pages/default.jpg b/AI-Document-Generator-main/frontend/public/templates/title_pages/default.jpg new file mode 100644 index 0000000000..d430bb09e4 Binary files /dev/null and b/AI-Document-Generator-main/frontend/public/templates/title_pages/default.jpg differ diff --git a/AI-Document-Generator-main/frontend/src/App.tsx b/AI-Document-Generator-main/frontend/src/App.tsx index f15a31e155..89c9dd6c16 100644 --- a/AI-Document-Generator-main/frontend/src/App.tsx +++ b/AI-Document-Generator-main/frontend/src/App.tsx @@ -23,21 +23,40 @@ function App() { if (workflow.view === 'landing') { return ( <> - + + workflow.setIsTemplatePanelOpen(!workflow.isTemplatePanelOpen) + } + onSelectTemplate={workflow.applyTemplateSelection} + templateThumbnailUrl={workflow.templateThumbnailUrl} + formatDocLabel={(value: string) => + value + .split('_') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' ') + } + /> {importModal} ) @@ -46,35 +65,51 @@ function App() { return ( <> workflow.setView('landing')} - onOpenHistory={() => workflow.setIsHistoryOpen(true)} - onOpenImport={() => workflow.setShowImportModal(true)} - isHistoryOpen={workflow.isHistoryOpen} - versions={workflow.versions} - selectedVersionId={workflow.selectedVersionId} - onSelectVersion={workflow.selectVersion} - onCloseHistory={() => workflow.setIsHistoryOpen(false)} - onRefreshHistory={workflow.fetchHistory} - onPdfUpdated={workflow.applyEditedPdf} + stage={workflow.stage} + outlineRows={workflow.outlineRows} + outlineSections={workflow.outlineSections} + excludedFields={workflow.excludedFields} + outlineConstraints={workflow.outlineConstraints} + draftRows={workflow.draftRows} + setOutlineRows={workflow.setOutlineRows} + setOutlineSections={workflow.setOutlineSections} + setExcludedFields={workflow.setExcludedFields} + setOutlineConstraints={workflow.setOutlineConstraints} + setDraftRows={workflow.setDraftRows} + docTypes={workflow.docTypes} + templateCounts={workflow.templateCounts} + selectedDocType={workflow.selectedDocType} + selectedTemplateId={workflow.selectedTemplateId} + templatesForSelected={workflow.templatesForSelected} + isTemplateLoading={workflow.isTemplateLoading} + onSelectTemplate={workflow.applyTemplateSelection} + templateThumbnailUrl={workflow.templateThumbnailUrl} + approveOutline={workflow.approveOutline} + onAiOutline={() => { + workflow.fillFieldsFromAI() + }} + approveDraft={workflow.approveDraft} + saveAndReview={workflow.saveAndReview} + styleDraft={workflow.styleDraft} + setStyleDraft={workflow.setStyleDraft} + applyStyleAndRegenerate={workflow.applyStyleAndRegenerate} + onAddPromptInfo={workflow.addPromptForFields} + onStageSelect={(nextStage) => workflow.setStage(nextStage)} + imagePlaceholdersCount={workflow.imagePlaceholdersCount} + isAssetUploading={workflow.isAssetUploading} + assetError={workflow.assetError} + onAddPlaceholderImage={workflow.addImageToPlaceholders} + onRemovePlaceholders={workflow.stripImagePlaceholders} + onOpenImportTemplate={() => workflow.openImportTemplate(workflow.selectedDocType)} /> {importModal} diff --git a/AI-Document-Generator-main/frontend/src/components/landing/LandingView.tsx b/AI-Document-Generator-main/frontend/src/components/landing/LandingView.tsx index ce98c0fdbc..0faf6c84b1 100644 --- a/AI-Document-Generator-main/frontend/src/components/landing/LandingView.tsx +++ b/AI-Document-Generator-main/frontend/src/components/landing/LandingView.tsx @@ -1,4 +1,4 @@ -import { FormEvent, KeyboardEvent, useRef, useEffect, useState, useCallback, useLayoutEffect } from 'react' +import { FormEvent, KeyboardEvent, useMemo, useState } from 'react' import { AudioWaveform } from '../ui/AudioWaveform' interface LandingViewProps { @@ -15,6 +15,18 @@ interface LandingViewProps { isRecording: boolean whisperStatus: string | null waveformHistory: number[][] + docTypes: string[] + templateCounts: Record + templateCatalog: { docType: string; templateCount: number; templates: string[] }[] + selectedDocType: string + selectedTemplateId: string + templatesForSelected: string[] + isTemplateLoading: boolean + isTemplatePanelOpen: boolean + onToggleTemplatePanel: () => void + onSelectTemplate: (docType: string, templateId: string) => void + templateThumbnailUrl: (docType: string, templateId: string) => string + formatDocLabel: (value: string) => string } export function LandingView({ @@ -31,297 +43,318 @@ export function LandingView({ isRecording, whisperStatus, waveformHistory, + docTypes, + templateCounts, + templateCatalog, + selectedDocType, + selectedTemplateId, + templatesForSelected, + isTemplateLoading, + isTemplatePanelOpen, + onToggleTemplatePanel, + onSelectTemplate, + templateThumbnailUrl, + formatDocLabel, }: LandingViewProps) { - const textareaRef = useRef(null) - const measureRef = useRef(null) - const [isMultiline, setIsMultiline] = useState(false) - const [canScrollDown, setCanScrollDown] = useState(false) - const [textareaHeight, setTextareaHeight] = useState(24) - const prevIsMultilineRef = useRef(isMultiline) - const cursorPositionRef = useRef(null) - - // Measure the actual content height using a hidden div - const measureContent = useCallback(() => { - if (!measureRef.current) return 24 - measureRef.current.textContent = prompt || 'X' // Use 'X' as minimum to get line height - const height = measureRef.current.scrollHeight - return height - }, [prompt]) + const [templateSearch, setTemplateSearch] = useState('') + const [activeTemplateTab, setActiveTemplateTab] = useState<'popular' | 'legal' | 'financial' | 'academic' | 'marketing' | 'operations'>('popular') + const [expandedDocType, setExpandedDocType] = useState(null) + const [hoveredTemplate, setHoveredTemplate] = useState<{ docType: string; templateId: string; x: number; y: number } | null>(null) - const updateScrollIndicator = useCallback(() => { - const textarea = textareaRef.current - if (!textarea) return - const canScroll = textarea.scrollHeight > textarea.clientHeight + 1 - const isAtBottom = textarea.scrollTop + textarea.clientHeight >= textarea.scrollHeight - 2 - setCanScrollDown(canScroll && !isAtBottom) - }, []) - - // Update layout based on content - const updateLayout = useCallback(() => { - // Save cursor position before potential layout change - if (textareaRef.current) { - cursorPositionRef.current = textareaRef.current.selectionStart + const popularDocTypes = new Set(['cvs_and_resumes', 'invoices', 'cover_letters', 'business_reports', 'presentations']) + const legalDocTypes = new Set(['formal_letters', 'theses']) + const financialDocTypes = new Set(['invoices', 'business_reports', 'calendars']) + const academicDocTypes = new Set(['academic_articles', 'academic_journals', 'assignments', 'theses']) + const marketingDocTypes = new Set(['newsletters', 'signs', 'presentations']) + const operationsDocTypes = new Set(['laboratory_reports', 'laboratory_books', 'business_reports']) + + const visibleDocTypes = useMemo(() => { + const filtered = docTypes.filter((docType) => + formatDocLabel(docType).toLowerCase().includes(templateSearch.toLowerCase()), + ) + if (activeTemplateTab === 'legal') { + return filtered.filter((docType) => legalDocTypes.has(docType)) } - - const contentHeight = measureContent() - const isMulti = contentHeight > 32 // More than ~1.5 lines - setIsMultiline(isMulti) - - // Set textarea height (capped at 240px) - const newHeight = Math.min(Math.max(contentHeight, 24), 240) - setTextareaHeight(newHeight) - - // Update scroll indicator after layout settles - requestAnimationFrame(updateScrollIndicator) - }, [measureContent, updateScrollIndicator]) - - // Restore cursor position when layout mode changes - useEffect(() => { - if (prevIsMultilineRef.current !== isMultiline) { - prevIsMultilineRef.current = isMultiline - // Use requestAnimationFrame to ensure the new textarea is mounted - requestAnimationFrame(() => { - if (textareaRef.current && cursorPositionRef.current !== null) { - const pos = cursorPositionRef.current - textareaRef.current.focus() - textareaRef.current.setSelectionRange(pos, pos) - } - }) + if (activeTemplateTab === 'financial') { + return filtered.filter((docType) => financialDocTypes.has(docType)) } - }, [isMultiline]) - - // Update on every prompt change - use layoutEffect for synchronous update - useLayoutEffect(() => { - updateLayout() - }, [prompt, updateLayout]) - - // Also update after a small delay to catch any missed updates - useEffect(() => { - const timer = setTimeout(updateLayout, 10) - return () => clearTimeout(timer) - }, [prompt, updateLayout]) - - // Handle scroll to update gradient visibility - const handleScroll = useCallback(() => { - updateScrollIndicator() - }, [updateScrollIndicator]) - - // Toolbar buttons component to avoid duplication - const ToolbarButtons = () => ( -
- - -
- ) - - const PlusButton = () => ( - - ) - + if (activeTemplateTab === 'academic') { + return filtered.filter((docType) => academicDocTypes.has(docType)) + } + if (activeTemplateTab === 'marketing') { + return filtered.filter((docType) => marketingDocTypes.has(docType)) + } + if (activeTemplateTab === 'operations') { + return filtered.filter((docType) => operationsDocTypes.has(docType)) + } + return filtered.filter((docType) => popularDocTypes.has(docType)) + }, [ + academicDocTypes, + activeTemplateTab, + docTypes, + financialDocTypes, + formatDocLabel, + legalDocTypes, + marketingDocTypes, + operationsDocTypes, + popularDocTypes, + templateSearch, + ]) + + const docTypeIcon = (docType: string) => { + switch (docType) { + case 'cvs_and_resumes': + return '📄' + case 'invoices': + return '🧾' + case 'cover_letters': + return '✉️' + case 'business_reports': + return '📊' + case 'formal_letters': + return '📝' + case 'theses': + return '🎓' + case 'presentations': + return '🖥️' + case 'recipes': + return '🍲' + default: + return '📁' + } + } return ( -
-
-
-
-
- - {/* Hidden measurement div - mirrors textarea styling */} -