tests
@@ -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/ .
|
||||
|
||||
|
||||
@@ -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 <<PLACEHOLDER>>.\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",
|
||||
]
|
||||
|
||||
@@ -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/<session_id>/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/<session_id>/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/<filename>", methods=["GET"])
|
||||
def serve_pdf(filename: str) -> Any:
|
||||
"""Serve generated PDF files."""
|
||||
@app.route("/output/<path:filename>", 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/<user_id>", 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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
|
After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 43 KiB |
@@ -23,21 +23,40 @@ function App() {
|
||||
if (workflow.view === 'landing') {
|
||||
return (
|
||||
<>
|
||||
<LandingView
|
||||
prompt={workflow.prompt}
|
||||
onPromptChange={workflow.setPrompt}
|
||||
onSubmit={workflow.handleInitialSubmit}
|
||||
onKeyDown={workflow.handleKeyDown}
|
||||
uploadedPdfFile={workflow.uploadedPdfFile}
|
||||
onFileSelect={workflow.setUploadedPdfFile}
|
||||
isImporting={workflow.isImporting}
|
||||
onToggleRecording={speech.toggleRecording}
|
||||
onCancelRecording={speech.cancelRecording}
|
||||
onAcceptRecording={speech.acceptRecording}
|
||||
isRecording={speech.isRecording}
|
||||
whisperStatus={speech.whisperStatus}
|
||||
waveformHistory={speech.waveformHistory}
|
||||
/>
|
||||
<LandingView
|
||||
prompt={workflow.prompt}
|
||||
onPromptChange={workflow.setPrompt}
|
||||
onSubmit={workflow.handleInitialSubmit}
|
||||
onKeyDown={workflow.handleKeyDown}
|
||||
uploadedPdfFile={workflow.uploadedPdfFile}
|
||||
onFileSelect={workflow.setUploadedPdfFile}
|
||||
isImporting={workflow.isImporting}
|
||||
onToggleRecording={speech.toggleRecording}
|
||||
onCancelRecording={speech.cancelRecording}
|
||||
onAcceptRecording={speech.acceptRecording}
|
||||
isRecording={speech.isRecording}
|
||||
whisperStatus={speech.whisperStatus}
|
||||
waveformHistory={speech.waveformHistory}
|
||||
docTypes={workflow.docTypes}
|
||||
templateCounts={workflow.templateCounts}
|
||||
templateCatalog={workflow.templateCatalog}
|
||||
selectedDocType={workflow.selectedDocType}
|
||||
selectedTemplateId={workflow.selectedTemplateId}
|
||||
templatesForSelected={workflow.templatesForSelected}
|
||||
isTemplateLoading={workflow.isTemplateLoading}
|
||||
isTemplatePanelOpen={workflow.isTemplatePanelOpen}
|
||||
onToggleTemplatePanel={() =>
|
||||
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 (
|
||||
<>
|
||||
<WorkspaceView
|
||||
styleProfile={workflow.styleProfile}
|
||||
messages={workflow.messages}
|
||||
chatEndRef={workflow.chatEndRef}
|
||||
isGenerating={workflow.isGenerating}
|
||||
isLivePreviewing={workflow.isLivePreviewing}
|
||||
isStageLoading={workflow.isStageLoading}
|
||||
prompt={workflow.prompt}
|
||||
onPromptChange={workflow.setPrompt}
|
||||
onChatSubmit={workflow.handleChatSubmit}
|
||||
onKeyDown={workflow.handleKeyDown}
|
||||
skipTemplates={workflow.skipTemplates}
|
||||
onSkipTemplatesChange={workflow.setSkipTemplates}
|
||||
onClearSession={workflow.clearSession}
|
||||
onToggleRecording={speech.toggleRecording}
|
||||
onCancelRecording={speech.cancelRecording}
|
||||
onAcceptRecording={speech.acceptRecording}
|
||||
isRecording={speech.isRecording}
|
||||
whisperStatus={speech.whisperStatus}
|
||||
waveformHistory={speech.waveformHistory}
|
||||
currentDoc={workflow.currentDoc}
|
||||
onBack={() => 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}
|
||||
</>
|
||||
|
||||
@@ -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<string, number>
|
||||
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<HTMLTextAreaElement>(null)
|
||||
const measureRef = useRef<HTMLDivElement>(null)
|
||||
const [isMultiline, setIsMultiline] = useState(false)
|
||||
const [canScrollDown, setCanScrollDown] = useState(false)
|
||||
const [textareaHeight, setTextareaHeight] = useState(24)
|
||||
const prevIsMultilineRef = useRef(isMultiline)
|
||||
const cursorPositionRef = useRef<number | null>(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<string | null>(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 = () => (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleRecording}
|
||||
className="p-2 rounded-lg text-slate-400 hover:text-white hover:bg-slate-700 transition-colors flex-shrink-0"
|
||||
title="Start voice input"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" />
|
||||
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isImporting || (!prompt.trim() && !uploadedPdfFile)}
|
||||
className="p-2 rounded-full bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex-shrink-0"
|
||||
title="Send"
|
||||
>
|
||||
{isImporting ? (
|
||||
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 10l7-7m0 0l7 7m-7-7v18" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
const PlusButton = () => (
|
||||
<label
|
||||
htmlFor="pdf-upload-landing"
|
||||
className="hidden p-2 rounded-lg text-slate-400 hover:text-white hover:bg-slate-700 transition-colors cursor-pointer flex-shrink-0"
|
||||
title="Upload PDF to extract layout"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</label>
|
||||
)
|
||||
|
||||
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 (
|
||||
<div className="min-h-screen bg-slate-900 text-slate-100 flex flex-col items-center justify-center p-4 relative overflow-hidden">
|
||||
<div className="absolute top-0 left-0 w-full h-full overflow-hidden z-0 opacity-20 pointer-events-none">
|
||||
<div className="absolute top-14 left-14 w-96 h-96 bg-blue-600 rounded-full blur-3xl filter mix-blend-screen animate-pulse"></div>
|
||||
<div className="absolute bottom-14 right-14 w-96 h-96 bg-purple-600 rounded-full blur-3xl filter mix-blend-screen animate-pulse delay-1000"></div>
|
||||
</div>
|
||||
|
||||
{/* Hidden measurement div - mirrors textarea styling */}
|
||||
<div
|
||||
ref={measureRef}
|
||||
className="absolute invisible whitespace-pre-wrap text-base"
|
||||
style={{
|
||||
width: 'calc(100% - 200px)', // Account for buttons and padding
|
||||
maxWidth: 'calc(768px - 200px)', // max-w-3xl minus buttons
|
||||
padding: '0',
|
||||
lineHeight: '1.5',
|
||||
wordBreak: 'break-word',
|
||||
overflowWrap: 'anywhere',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="z-10 w-full max-w-3xl flex flex-col items-center space-y-8">
|
||||
<div className="text-center space-y-4">
|
||||
<h1 className="text-5xl leading-tight pb-1 font-bold tracking-tighter bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent">
|
||||
Stirling
|
||||
</h1>
|
||||
<p className="text-xl text-slate-400">Intelligent Document 1.0</p>
|
||||
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-slate-100 text-slate-900">
|
||||
<header className="flex items-center justify-between px-8 py-6">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-slate-700">
|
||||
<span className="text-base">Stirling</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="rounded-full border border-slate-200 px-4 py-1.5 text-xs text-slate-500">
|
||||
Log in
|
||||
</button>
|
||||
<button className="rounded-full bg-slate-900 px-4 py-1.5 text-xs text-white">
|
||||
Get Stirling free
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form onSubmit={onSubmit} className="w-full">
|
||||
<div className="relative bg-slate-800 rounded-2xl shadow-2xl border border-slate-700">
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
id="pdf-upload-landing"
|
||||
type="file"
|
||||
accept="application/pdf"
|
||||
onChange={(e) => onFileSelect(e.target.files?.[0] || null)}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* PDF attached indicator */}
|
||||
{uploadedPdfFile && !isRecording && (
|
||||
<div className="px-4 pt-3 pb-0">
|
||||
<span className="inline-flex items-center gap-1.5 px-2 py-1 rounded-lg bg-emerald-600/20 border border-emerald-500/30 text-xs text-emerald-400">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{uploadedPdfFile.name}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onFileSelect(null)}
|
||||
className="ml-1 hover:text-emerald-300"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isRecording ? (
|
||||
/* Recording mode - waveform with buttons below */
|
||||
<div className="p-4">
|
||||
<div className="min-h-[24px] mb-3">
|
||||
<AudioWaveform waveformHistory={waveformHistory} isActive={isRecording} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div />
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancelRecording}
|
||||
className="p-2 rounded-lg text-slate-400 hover:text-white hover:bg-slate-700 transition-colors"
|
||||
title="Cancel recording"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onAcceptRecording()
|
||||
}}
|
||||
className="p-2 rounded-full bg-blue-600 text-white hover:bg-blue-700 transition-colors"
|
||||
title="Accept and transcribe"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<main className="flex flex-1 items-center justify-center px-6 pb-16">
|
||||
<div className="w-full max-w-4xl rounded-[32px] border border-slate-200 bg-white p-10 shadow-lg">
|
||||
<div className="flex flex-col items-center text-center gap-4">
|
||||
<div className="text-3xl font-semibold text-slate-900">Stirling PDF</div>
|
||||
<p className="text-sm text-slate-500">Create any PDF you can imagine with AI</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={onSubmit} className="mt-10">
|
||||
<div className="rounded-2xl border border-slate-200 bg-white px-5 py-4 shadow-sm">
|
||||
{isRecording ? (
|
||||
<div className="space-y-3">
|
||||
<AudioWaveform history={waveformHistory} />
|
||||
<div className="flex items-center justify-between text-xs text-slate-500">
|
||||
<span>{whisperStatus || 'Listening...'}</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-slate-200 px-3 py-1"
|
||||
onClick={onCancelRecording}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full bg-blue-600 px-3 py-1 text-white"
|
||||
onClick={onAcceptRecording}
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : isMultiline ? (
|
||||
/* Multiline mode - textarea on top, buttons below */
|
||||
<div className="p-4">
|
||||
<div className="relative mb-3">
|
||||
) : (
|
||||
<>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={prompt}
|
||||
onChange={(e) => onPromptChange(e.target.value)}
|
||||
onScroll={handleScroll}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Ask anything"
|
||||
className="w-full bg-transparent text-base text-white placeholder-slate-500 focus:outline-none resize-none overflow-y-auto break-words"
|
||||
style={{ height: `${textareaHeight}px`, maxHeight: '240px', overflowWrap: 'anywhere' }}
|
||||
className="w-full resize-none text-sm text-slate-700 placeholder:text-slate-400 focus:outline-none"
|
||||
rows={1}
|
||||
autoFocus
|
||||
value={prompt}
|
||||
onChange={(event) => onPromptChange(event.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Make an invoice for me to bill a client for $1500 in consulting fees"
|
||||
/>
|
||||
{/* Scroll indicator gradient */}
|
||||
{canScrollDown && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-8 pointer-events-none bg-gradient-to-t from-slate-800 to-transparent" />
|
||||
{uploadedPdfFile && (
|
||||
<div className="mt-2 text-xs text-emerald-600">{uploadedPdfFile.name}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<PlusButton />
|
||||
<ToolbarButtons />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Single line mode - all in one row */
|
||||
<div className="flex items-center gap-2 p-3">
|
||||
<PlusButton />
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={prompt}
|
||||
onChange={(e) => onPromptChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Ask anything"
|
||||
className="flex-1 bg-transparent text-base text-white placeholder-slate-500 focus:outline-none resize-none overflow-hidden min-w-0 break-words"
|
||||
style={{ height: '24px', overflowWrap: 'anywhere' }}
|
||||
rows={1}
|
||||
autoFocus
|
||||
/>
|
||||
<ToolbarButtons />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
<div className="mt-3 flex items-center gap-2 text-sm text-slate-300 justify-center">
|
||||
<div className="relative group px-4 py-2 rounded-full bg-slate-800/70 border border-slate-700 cursor-not-allowed select-none">
|
||||
<span className="flex items-center gap-2">
|
||||
Document type (auto)
|
||||
<svg className="w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 15l-7-7-7 7" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="pointer-events-none absolute -top-9 left-1/2 -translate-x-1/2 whitespace-nowrap rounded-md bg-slate-800 px-3 py-1 text-[11px] text-slate-200 border border-slate-700 opacity-0 transition-opacity duration-150 group-hover:opacity-100">
|
||||
We’ll pick the best document type for your prompt automatically using AI.
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative group px-4 py-2 rounded-full bg-slate-800/70 border border-slate-700 cursor-not-allowed select-none">
|
||||
<span className="flex items-center gap-2">
|
||||
GPT 5.1
|
||||
<svg className="w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 15l-7-7-7 7" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="pointer-events-none absolute -top-9 left-1/2 -translate-x-1/2 whitespace-nowrap rounded-md bg-slate-800 px-3 py-1 text-[11px] text-slate-200 border border-slate-700 opacity-0 transition-opacity duration-150 group-hover:opacity-100">
|
||||
More models coming soon.
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 relative">
|
||||
<label
|
||||
htmlFor="pdf-upload-landing"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full border border-slate-200 text-slate-400"
|
||||
>
|
||||
+
|
||||
</label>
|
||||
<input
|
||||
id="pdf-upload-landing"
|
||||
type="file"
|
||||
accept="application/pdf"
|
||||
onChange={(event) => onFileSelect(event.target.files?.[0] || null)}
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-slate-200 px-3 py-1 text-xs text-slate-500"
|
||||
onClick={onToggleTemplatePanel}
|
||||
>
|
||||
Template
|
||||
</button>
|
||||
<span className="text-xs text-slate-400">
|
||||
{formatDocLabel(selectedDocType)} · {formatDocLabel(selectedTemplateId)}
|
||||
</span>
|
||||
{isTemplatePanelOpen && (
|
||||
<div className="absolute left-2 top-10 w-80 rounded-2xl border border-slate-200 bg-white p-4 shadow-lg z-50">
|
||||
<div className="absolute -top-2 left-6 h-3 w-3 rotate-45 border border-slate-200 bg-white" />
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 rounded-full border border-slate-200 px-3 py-2 text-xs text-slate-500">
|
||||
<span>🔎</span>
|
||||
<input
|
||||
className="w-full bg-transparent text-sm text-slate-600 focus:outline-none"
|
||||
placeholder="Search templates..."
|
||||
value={templateSearch}
|
||||
onChange={(event) => setTemplateSearch(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||
{[
|
||||
{ id: 'popular', label: 'Popular' },
|
||||
{ id: 'legal', label: 'Legal' },
|
||||
{ id: 'financial', label: 'Financial' },
|
||||
{ id: 'academic', label: 'Academic' },
|
||||
{ id: 'marketing', label: 'Marketing' },
|
||||
{ id: 'operations', label: 'Operations' },
|
||||
].map((tab) => (
|
||||
<button
|
||||
type="button"
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTemplateTab(tab.id as typeof activeTemplateTab)}
|
||||
className={`rounded-full px-3 py-1 text-xs ${
|
||||
activeTemplateTab === tab.id
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'bg-slate-100 text-slate-500'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2 max-h-64 overflow-y-auto pr-1 relative">
|
||||
{visibleDocTypes.map((docType) => {
|
||||
const isExpanded = expandedDocType === docType
|
||||
const templates =
|
||||
templateCatalog.find((entry) => entry.docType === docType)?.templates ||
|
||||
(docType === selectedDocType ? templatesForSelected : ['default'])
|
||||
return (
|
||||
<div key={docType} className="rounded-lg border border-slate-200 bg-white">
|
||||
<div
|
||||
className="flex w-full items-center justify-between px-3 py-2 text-sm text-slate-700"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-1 items-center gap-2 text-left"
|
||||
onClick={() => onSelectTemplate(docType, 'default')}
|
||||
>
|
||||
<span>{docTypeIcon(docType)}</span>
|
||||
<span>{formatDocLabel(docType)}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-slate-400 px-2"
|
||||
onClick={() => setExpandedDocType(isExpanded ? null : docType)}
|
||||
>
|
||||
{isExpanded ? '▾' : '▸'}
|
||||
</button>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="border-t border-slate-200 px-3 py-2 space-y-2">
|
||||
{(templates || ['default']).map((templateId) => (
|
||||
<button
|
||||
type="button"
|
||||
key={`${docType}-${templateId}`}
|
||||
onClick={() => onSelectTemplate(docType, templateId)}
|
||||
className={`flex w-full items-center justify-between rounded-md px-2 py-1 text-sm ${
|
||||
selectedDocType === docType && selectedTemplateId === templateId
|
||||
? 'bg-blue-50 text-blue-700'
|
||||
: 'text-slate-600'
|
||||
}`}
|
||||
onMouseMove={(event) =>
|
||||
setHoveredTemplate({
|
||||
docType,
|
||||
templateId,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
})
|
||||
}
|
||||
onMouseLeave={() => setHoveredTemplate(null)}
|
||||
>
|
||||
<span>{formatDocLabel(templateId)}</span>
|
||||
<span className="text-xs text-slate-400">Select</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{!visibleDocTypes.length && (
|
||||
<div className="text-sm text-slate-400">No templates found.</div>
|
||||
)}
|
||||
{hoveredTemplate && (
|
||||
<div
|
||||
className="fixed z-50 w-36 rounded-lg border border-slate-200 bg-white shadow-lg p-2"
|
||||
style={{ left: hoveredTemplate.x + 12, top: hoveredTemplate.y + 12 }}
|
||||
>
|
||||
<div className="text-[10px] uppercase tracking-wide text-slate-400 mb-2">
|
||||
Preview
|
||||
</div>
|
||||
<div className="w-full bg-slate-50 rounded-md overflow-hidden" style={{ aspectRatio: '210 / 297' }}>
|
||||
<img
|
||||
src={templateThumbnailUrl(hoveredTemplate.docType, hoveredTemplate.templateId)}
|
||||
alt={`${hoveredTemplate.docType} ${hoveredTemplate.templateId} preview`}
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full border border-slate-200 text-slate-500"
|
||||
onClick={onToggleRecording}
|
||||
aria-label="Voice input"
|
||||
>
|
||||
🎤
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isImporting || (!prompt.trim() && !uploadedPdfFile)}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-slate-900 text-white disabled:opacity-40"
|
||||
aria-label="Generate"
|
||||
>
|
||||
➜
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
{whisperStatus && <div className="text-xs text-slate-400">{whisperStatus}</div>}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,113 +1,757 @@
|
||||
import { FormEvent, KeyboardEvent, MutableRefObject } from 'react'
|
||||
import { DocumentState, Message, StyleProfile, VersionEntry } from '../../types'
|
||||
import { ChatPanel } from './ChatPanel'
|
||||
import { HistoryPanel } from './HistoryPanel'
|
||||
import { PreviewPanel } from './PreviewPanel'
|
||||
import { FormEvent, KeyboardEvent, useEffect, useState } from 'react'
|
||||
import { DocumentState } from '../../types'
|
||||
import PdfThumbnailViewer from '../pdfTextEditor/PdfThumbnailViewer'
|
||||
|
||||
interface WorkspaceViewProps {
|
||||
styleProfile: StyleProfile | null
|
||||
messages: Message[]
|
||||
chatEndRef: MutableRefObject<HTMLDivElement | null>
|
||||
isGenerating: boolean
|
||||
isLivePreviewing: boolean
|
||||
isStageLoading: boolean
|
||||
prompt: string
|
||||
onPromptChange: (value: string) => void
|
||||
onChatSubmit: (event: FormEvent<HTMLFormElement>) => void
|
||||
onKeyDown: (event: KeyboardEvent<HTMLTextAreaElement>) => void
|
||||
skipTemplates: boolean
|
||||
onSkipTemplatesChange: (value: boolean) => void
|
||||
onClearSession: () => void
|
||||
onToggleRecording: () => void
|
||||
onCancelRecording: () => void
|
||||
onAcceptRecording: () => void
|
||||
isRecording: boolean
|
||||
whisperStatus: string | null
|
||||
waveformHistory: number[][]
|
||||
currentDoc: DocumentState | null
|
||||
onBack: () => void
|
||||
onOpenHistory: () => void
|
||||
onOpenImport: () => void
|
||||
isHistoryOpen: boolean
|
||||
versions: VersionEntry[]
|
||||
selectedVersionId: string | null
|
||||
onSelectVersion: (id: string) => void
|
||||
onCloseHistory: () => void
|
||||
onRefreshHistory: () => void
|
||||
onPdfUpdated: (pdfUrl: string) => void
|
||||
stage: 'outline' | 'text' | 'styling' | 'review'
|
||||
outlineRows: { section: string; details: string }[]
|
||||
outlineSections: string[]
|
||||
excludedFields: string[]
|
||||
outlineConstraints: { tone: string; audience: string; pageCount: number }
|
||||
draftRows: { label: string; value: string }[]
|
||||
setOutlineRows: (rows: { section: string; details: string }[]) => void
|
||||
setOutlineSections: (next: string[]) => void
|
||||
setExcludedFields: (next: string[]) => void
|
||||
setOutlineConstraints: (next: { tone: string; audience: string; pageCount: number }) => void
|
||||
setDraftRows: (rows: { label: string; value: string }[]) => void
|
||||
docTypes: string[]
|
||||
templateCounts: Record<string, number>
|
||||
selectedDocType: string
|
||||
selectedTemplateId: string
|
||||
templatesForSelected: string[]
|
||||
isTemplateLoading: boolean
|
||||
onSelectTemplate: (docType: string, templateId: string) => void
|
||||
templateThumbnailUrl: (docType: string, templateId: string) => string
|
||||
approveOutline: () => void
|
||||
onAiOutline: () => void
|
||||
approveDraft: () => void
|
||||
saveAndReview: () => void
|
||||
styleDraft: { layout_preference: string; font_preference: string; color_accent: string }
|
||||
setStyleDraft: (next: { layout_preference: string; font_preference: string; color_accent: string }) => void
|
||||
applyStyleAndRegenerate: () => void
|
||||
onAddPromptInfo: (value: string) => void
|
||||
onStageSelect: (stage: 'outline' | 'text' | 'styling' | 'review') => void
|
||||
imagePlaceholdersCount: number
|
||||
isAssetUploading: boolean
|
||||
assetError: string | null
|
||||
onAddPlaceholderImage: (file: File) => void
|
||||
onRemovePlaceholders: () => void
|
||||
onOpenImportTemplate: () => void
|
||||
}
|
||||
|
||||
export function WorkspaceView({
|
||||
styleProfile,
|
||||
messages,
|
||||
chatEndRef,
|
||||
isGenerating,
|
||||
isLivePreviewing,
|
||||
isStageLoading,
|
||||
prompt,
|
||||
onPromptChange,
|
||||
onChatSubmit,
|
||||
onKeyDown,
|
||||
skipTemplates,
|
||||
onSkipTemplatesChange,
|
||||
onClearSession,
|
||||
onToggleRecording,
|
||||
onCancelRecording,
|
||||
onAcceptRecording,
|
||||
isRecording,
|
||||
whisperStatus,
|
||||
waveformHistory,
|
||||
currentDoc,
|
||||
onBack,
|
||||
onOpenHistory,
|
||||
onOpenImport,
|
||||
isHistoryOpen,
|
||||
versions,
|
||||
selectedVersionId,
|
||||
onSelectVersion,
|
||||
onCloseHistory,
|
||||
onRefreshHistory,
|
||||
onPdfUpdated,
|
||||
stage,
|
||||
outlineRows,
|
||||
outlineSections,
|
||||
excludedFields,
|
||||
outlineConstraints,
|
||||
draftRows,
|
||||
setOutlineRows,
|
||||
setOutlineSections,
|
||||
setExcludedFields,
|
||||
setOutlineConstraints,
|
||||
setDraftRows,
|
||||
docTypes,
|
||||
templateCounts,
|
||||
selectedDocType,
|
||||
selectedTemplateId,
|
||||
templatesForSelected,
|
||||
isTemplateLoading,
|
||||
onSelectTemplate,
|
||||
templateThumbnailUrl,
|
||||
approveOutline,
|
||||
onAiOutline,
|
||||
approveDraft,
|
||||
saveAndReview,
|
||||
styleDraft,
|
||||
setStyleDraft,
|
||||
applyStyleAndRegenerate,
|
||||
onAddPromptInfo,
|
||||
onStageSelect,
|
||||
imagePlaceholdersCount,
|
||||
isAssetUploading,
|
||||
assetError,
|
||||
onAddPlaceholderImage,
|
||||
onRemovePlaceholders,
|
||||
onOpenImportTemplate,
|
||||
}: WorkspaceViewProps) {
|
||||
const [isTemplatePickerOpen, setIsTemplatePickerOpen] = useState(false)
|
||||
const [isDataPanelOpen, setIsDataPanelOpen] = useState(false)
|
||||
const [promptAddon, setPromptAddon] = useState('')
|
||||
const autoSize = (event: React.FormEvent<HTMLTextAreaElement>) => {
|
||||
const target = event.currentTarget
|
||||
target.style.height = 'auto'
|
||||
target.style.height = `${target.scrollHeight}px`
|
||||
}
|
||||
useEffect(() => {
|
||||
const nodes = document.querySelectorAll('textarea[data-autosize="true"]')
|
||||
nodes.forEach((node) => {
|
||||
const area = node as HTMLTextAreaElement
|
||||
area.style.height = 'auto'
|
||||
area.style.height = `${area.scrollHeight}px`
|
||||
})
|
||||
}, [outlineRows, draftRows, stage])
|
||||
|
||||
const formatDocLabel = (value: string) => {
|
||||
return value
|
||||
.split('_')
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
const docLabel = currentDoc?.documentType ? formatDocLabel(currentDoc.documentType) : 'Document'
|
||||
|
||||
const renderStageContent = () => {
|
||||
if (stage === 'outline') {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900">{docLabel} Inputs</h2>
|
||||
<p className="text-sm text-slate-500">Fill in the data you want the AI to use.</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
Selected Template
|
||||
</div>
|
||||
<div className="text-sm text-slate-700">
|
||||
{formatDocLabel(selectedDocType)} · {formatDocLabel(selectedTemplateId)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs font-semibold text-blue-600"
|
||||
onClick={() => setIsTemplatePickerOpen((prev) => !prev)}
|
||||
>
|
||||
{isTemplatePickerOpen ? 'Hide' : 'Edit'}
|
||||
</button>
|
||||
</div>
|
||||
{isTemplatePickerOpen && (
|
||||
<div className="space-y-4 pt-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
Document Type
|
||||
</label>
|
||||
<select
|
||||
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700"
|
||||
value={selectedDocType}
|
||||
onChange={(event) => onSelectTemplate(event.target.value, selectedTemplateId)}
|
||||
>
|
||||
{docTypes.map((docType) => (
|
||||
<option key={docType} value={docType}>
|
||||
{formatDocLabel(docType)} ({templateCounts[docType] ?? 1})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
Template Style
|
||||
</label>
|
||||
{isTemplateLoading ? (
|
||||
<div className="text-sm text-slate-400">Loading templates...</div>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{templatesForSelected.map((templateId) => (
|
||||
<button
|
||||
type="button"
|
||||
key={templateId}
|
||||
onClick={() => onSelectTemplate(selectedDocType, templateId)}
|
||||
className={`rounded-xl border text-left ${
|
||||
selectedTemplateId === templateId
|
||||
? 'border-blue-500 ring-2 ring-blue-200'
|
||||
: 'border-slate-200'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="w-full rounded-t-xl bg-slate-50 overflow-hidden h-36"
|
||||
style={{ aspectRatio: '210 / 297' }}
|
||||
>
|
||||
<img
|
||||
src={templateThumbnailUrl(selectedDocType, templateId)}
|
||||
alt={`${templateId} template`}
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-3 py-2 text-sm text-slate-700">
|
||||
{formatDocLabel(templateId)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200 bg-white">
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-wider text-slate-500">Data to use</div>
|
||||
<div className="text-xs text-slate-400">
|
||||
{outlineRows.filter((row) => row.details.trim()).length} filled ·{' '}
|
||||
{outlineRows.filter((row) => !row.details.trim()).length} empty
|
||||
</div>
|
||||
{outlineRows.filter((row) => !row.details.trim()).length > outlineRows.length / 2 && (
|
||||
<div className="text-xs text-amber-500 mt-1">
|
||||
Add a bit more data for better results.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs font-semibold text-blue-600"
|
||||
onClick={() => setIsDataPanelOpen((prev) => !prev)}
|
||||
>
|
||||
{isDataPanelOpen ? 'Hide' : 'Show'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="border-t border-slate-200 px-6 py-4 space-y-3">
|
||||
<div className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
Add info from prompt
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<input
|
||||
className="flex-1 rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700"
|
||||
placeholder="Add more details (e.g., biller address, invoice number)..."
|
||||
value={promptAddon}
|
||||
onChange={(event) => setPromptAddon(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
onAddPromptInfo(promptAddon)
|
||||
setPromptAddon('')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg bg-slate-900 px-4 py-2 text-sm font-semibold text-white"
|
||||
onClick={() => {
|
||||
onAddPromptInfo(promptAddon)
|
||||
setPromptAddon('')
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-xs text-slate-400">
|
||||
This appends to the original prompt and re-runs auto-fill.
|
||||
</div>
|
||||
</div>
|
||||
{isDataPanelOpen && (
|
||||
<div className="border-t border-slate-200">
|
||||
<div className="grid grid-cols-[1.2fr,2fr,auto] bg-slate-50 px-6 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
<div>Data name</div>
|
||||
<div>Value</div>
|
||||
<div />
|
||||
</div>
|
||||
{outlineRows.map((row, idx) => (
|
||||
<div
|
||||
key={`outline-${idx}`}
|
||||
className="grid grid-cols-[1.2fr,2fr,auto] px-6 py-3 border-t border-slate-100 gap-4 items-start"
|
||||
>
|
||||
<input
|
||||
className="text-sm text-slate-800 bg-transparent focus:outline-none"
|
||||
value={row.section}
|
||||
onChange={(event) => {
|
||||
const next = [...outlineRows]
|
||||
next[idx] = { ...row, section: event.target.value }
|
||||
setOutlineRows(next)
|
||||
}}
|
||||
/>
|
||||
<textarea
|
||||
rows={1}
|
||||
className="min-h-[56px] w-full resize-none overflow-hidden text-sm text-slate-500 bg-transparent focus:outline-none"
|
||||
value={row.details}
|
||||
onInput={autoSize}
|
||||
data-autosize="true"
|
||||
onChange={(event) => {
|
||||
const next = [...outlineRows]
|
||||
next[idx] = { ...row, details: event.target.value }
|
||||
setOutlineRows(next)
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-slate-400 hover:text-rose-500"
|
||||
onClick={() => {
|
||||
const next = outlineRows.filter((_, rowIndex) => rowIndex !== idx)
|
||||
const label = row.section.trim()
|
||||
if (label) {
|
||||
setExcludedFields([...excludedFields, label])
|
||||
}
|
||||
setOutlineRows(next)
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center justify-between px-6 py-3">
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs font-semibold text-blue-600"
|
||||
onClick={() => setOutlineRows([...outlineRows, { section: 'New data', details: '' }])}
|
||||
>
|
||||
Add data
|
||||
</button>
|
||||
{excludedFields.length > 0 && (
|
||||
<div className="text-xs text-slate-400">
|
||||
Excluded: {excludedFields.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-4 space-y-4">
|
||||
<div className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
AI Constraints
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold uppercase tracking-wide text-slate-500">Tone</label>
|
||||
<select
|
||||
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700"
|
||||
value={outlineConstraints.tone}
|
||||
onChange={(event) =>
|
||||
setOutlineConstraints({ ...outlineConstraints, tone: event.target.value })
|
||||
}
|
||||
>
|
||||
{['Professional', 'Formal', 'Friendly', 'Neutral', 'Academic', 'Technical', 'Narrative', 'Direct', 'Informative'].map((tone) => (
|
||||
<option key={tone} value={tone}>
|
||||
{tone}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold uppercase tracking-wide text-slate-500">Audience</label>
|
||||
<input
|
||||
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700"
|
||||
value={outlineConstraints.audience}
|
||||
onChange={(event) =>
|
||||
setOutlineConstraints({ ...outlineConstraints, audience: event.target.value })
|
||||
}
|
||||
placeholder="Audience"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold uppercase tracking-wide text-slate-500">Pages</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700"
|
||||
value={outlineConstraints.pageCount}
|
||||
onChange={(event) =>
|
||||
setOutlineConstraints({
|
||||
...outlineConstraints,
|
||||
pageCount: Math.max(1, Number(event.target.value || 1)),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-4 space-y-3">
|
||||
<div className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
Sections
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{outlineSections.map((section, idx) => (
|
||||
<div key={`section-${idx}`} className="flex items-center gap-3">
|
||||
<input
|
||||
className="flex-1 rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700"
|
||||
value={section}
|
||||
onChange={(event) => {
|
||||
const next = [...outlineSections]
|
||||
next[idx] = event.target.value
|
||||
setOutlineSections(next)
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-slate-400 hover:text-rose-500"
|
||||
onClick={() => {
|
||||
const next = outlineSections.filter((_, rowIndex) => rowIndex !== idx)
|
||||
setOutlineSections(next)
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs font-semibold text-blue-600"
|
||||
onClick={() => setOutlineSections([...outlineSections, 'New section'])}
|
||||
>
|
||||
Add section
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (stage === 'text') {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900">{docLabel} Text</h2>
|
||||
<p className="text-sm text-slate-500">Edit each section draft. Polishing happens after approval.</p>
|
||||
</div>
|
||||
<div className="border border-slate-200 rounded-2xl overflow-hidden">
|
||||
<div className="grid grid-cols-[1.2fr,2fr] bg-slate-50 px-6 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
<div>Section</div>
|
||||
<div>Content</div>
|
||||
</div>
|
||||
{draftRows.map((row, idx) => (
|
||||
<div
|
||||
key={`draft-${idx}`}
|
||||
className="grid grid-cols-[1.2fr,2fr] px-6 py-3 border-t border-slate-100 gap-4"
|
||||
>
|
||||
<input
|
||||
className="text-sm text-slate-800 bg-transparent focus:outline-none"
|
||||
value={row.label}
|
||||
onChange={(event) => {
|
||||
const next = [...draftRows]
|
||||
next[idx] = { ...row, label: event.target.value }
|
||||
setDraftRows(next)
|
||||
}}
|
||||
/>
|
||||
<textarea
|
||||
rows={1}
|
||||
className="min-h-[120px] w-full resize-none overflow-hidden text-sm text-slate-600 bg-transparent focus:outline-none"
|
||||
value={row.value}
|
||||
onInput={autoSize}
|
||||
data-autosize="true"
|
||||
onChange={(event) => {
|
||||
const next = [...draftRows]
|
||||
next[idx] = { ...row, value: event.target.value }
|
||||
setDraftRows(next)
|
||||
}}
|
||||
placeholder={isStageLoading ? 'Generating draft...' : 'Add content...'}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (stage === 'styling') {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900">{docLabel} Review</h2>
|
||||
<p className="text-sm text-slate-500">Check the generated preview before exporting.</p>
|
||||
</div>
|
||||
<div className="grid gap-4 lg:grid-cols-[1.1fr,2fr]">
|
||||
<div className="space-y-4 rounded-2xl border border-slate-200 bg-white p-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold uppercase tracking-wide text-slate-500">Font</label>
|
||||
<select
|
||||
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700"
|
||||
value={styleDraft.font_preference}
|
||||
onChange={(event) =>
|
||||
setStyleDraft({ ...styleDraft, font_preference: event.target.value })
|
||||
}
|
||||
>
|
||||
{['Serif', 'Sans', 'Modern', 'Classic', 'Minimal'].map((font) => (
|
||||
<option key={font} value={font}>
|
||||
{font}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold uppercase tracking-wide text-slate-500">Layout</label>
|
||||
<select
|
||||
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700"
|
||||
value={styleDraft.layout_preference}
|
||||
onChange={(event) =>
|
||||
setStyleDraft({ ...styleDraft, layout_preference: event.target.value })
|
||||
}
|
||||
>
|
||||
{['Compact', 'Balanced', 'Spacious', 'Grid', 'Editorial'].map((layout) => (
|
||||
<option key={layout} value={layout}>
|
||||
{layout}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold uppercase tracking-wide text-slate-500">Accent</label>
|
||||
<input
|
||||
type="color"
|
||||
className="h-10 w-full rounded-lg border border-slate-200 bg-white px-2"
|
||||
value={styleDraft.color_accent}
|
||||
onChange={(event) =>
|
||||
setStyleDraft({ ...styleDraft, color_accent: event.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded-lg bg-slate-900 px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
|
||||
onClick={applyStyleAndRegenerate}
|
||||
disabled={isGenerating || isStageLoading}
|
||||
>
|
||||
Apply Style & Regenerate
|
||||
</button>
|
||||
</div>
|
||||
<div className="border border-slate-200 rounded-2xl bg-white">
|
||||
{currentDoc?.pdfUrl ? (
|
||||
<div className="p-4">
|
||||
<PdfThumbnailViewer pdfUrl={currentDoc.pdfUrl} isLivePreviewing={isLivePreviewing} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-12 text-sm text-slate-500 text-center">
|
||||
{isStageLoading || isGenerating ? 'Generating preview...' : 'Preview will appear here.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900">{docLabel} Export</h2>
|
||||
<p className="text-sm text-slate-500">Download or export your final PDF.</p>
|
||||
</div>
|
||||
<div className="border border-slate-200 rounded-2xl bg-white">
|
||||
{currentDoc?.pdfUrl ? (
|
||||
<div className="p-4">
|
||||
<PdfThumbnailViewer pdfUrl={currentDoc.pdfUrl} isLivePreviewing={isLivePreviewing} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-12 text-sm text-slate-500 text-center">Generating the final preview...</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const StageItem = ({
|
||||
number,
|
||||
label,
|
||||
active,
|
||||
stageKey,
|
||||
}: {
|
||||
number: number
|
||||
label: string
|
||||
active: boolean
|
||||
stageKey: 'outline' | 'text' | 'styling' | 'review'
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onStageSelect(stageKey)}
|
||||
disabled={isGenerating || isStageLoading}
|
||||
className={`flex w-full items-center justify-between text-sm ${
|
||||
active ? 'text-slate-900' : 'text-slate-400'
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`flex h-6 w-6 items-center justify-center rounded-full border text-xs font-semibold ${
|
||||
active ? 'border-blue-600 text-blue-600' : 'border-slate-300 text-slate-400'
|
||||
}`}
|
||||
>
|
||||
{number}
|
||||
</span>
|
||||
<span className="font-medium">{label}</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="relative flex h-screen bg-slate-950 text-slate-100 overflow-hidden">
|
||||
<ChatPanel
|
||||
onBack={onBack}
|
||||
styleProfile={styleProfile}
|
||||
messages={messages}
|
||||
chatEndRef={chatEndRef}
|
||||
isGenerating={isGenerating}
|
||||
prompt={prompt}
|
||||
onPromptChange={onPromptChange}
|
||||
onSubmit={onChatSubmit}
|
||||
onKeyDown={onKeyDown}
|
||||
skipTemplates={skipTemplates}
|
||||
onSkipTemplatesChange={onSkipTemplatesChange}
|
||||
onClearSession={onClearSession}
|
||||
onToggleRecording={onToggleRecording}
|
||||
onCancelRecording={onCancelRecording}
|
||||
onAcceptRecording={onAcceptRecording}
|
||||
isRecording={isRecording}
|
||||
whisperStatus={whisperStatus}
|
||||
waveformHistory={waveformHistory}
|
||||
currentDoc={currentDoc}
|
||||
onOpenHistory={onOpenHistory}
|
||||
onOpenImport={onOpenImport}
|
||||
/>
|
||||
<PreviewPanel
|
||||
currentDoc={currentDoc}
|
||||
isGenerating={isGenerating}
|
||||
isLivePreviewing={isLivePreviewing}
|
||||
skipTemplates={skipTemplates}
|
||||
onPdfReplaced={onPdfUpdated}
|
||||
/>
|
||||
{isHistoryOpen && (
|
||||
<HistoryPanel
|
||||
versions={versions}
|
||||
selectedVersionId={selectedVersionId}
|
||||
onSelectVersion={onSelectVersion}
|
||||
onClose={onCloseHistory}
|
||||
onRefresh={onRefreshHistory}
|
||||
/>
|
||||
)}
|
||||
<div className="relative flex h-screen bg-slate-100 text-slate-900 overflow-hidden">
|
||||
<aside className="w-72 bg-white border-r border-slate-200 flex flex-col px-5 py-6 gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<button className="text-xs text-slate-500" onClick={onBack}>
|
||||
Stirling
|
||||
</button>
|
||||
<button className="text-xs text-slate-400">Create</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<StageItem number={1} label="Outline" active={stage === 'outline'} stageKey="outline" />
|
||||
<StageItem number={2} label="Text" active={stage === 'text'} stageKey="text" />
|
||||
<StageItem number={3} label="Review" active={stage === 'styling'} stageKey="styling" />
|
||||
<StageItem number={4} label="Export" active={stage === 'review'} stageKey="review" />
|
||||
</div>
|
||||
|
||||
{stage === 'outline' && (
|
||||
<div className="mt-2 space-y-2">
|
||||
<button
|
||||
className="w-full rounded-lg border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-100 disabled:opacity-50"
|
||||
onClick={onAiOutline}
|
||||
disabled={isGenerating || isStageLoading}
|
||||
>
|
||||
Auto-fill Fields
|
||||
</button>
|
||||
<button
|
||||
className="w-full rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
onClick={approveOutline}
|
||||
disabled={isGenerating || isStageLoading}
|
||||
>
|
||||
Approve and Continue
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{stage === 'text' && (
|
||||
<button
|
||||
className="mt-2 w-full rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
onClick={approveDraft}
|
||||
disabled={isGenerating || isStageLoading}
|
||||
>
|
||||
Approve and Continue
|
||||
</button>
|
||||
)}
|
||||
{stage === 'styling' && (
|
||||
<button
|
||||
className="mt-2 w-full rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
onClick={saveAndReview}
|
||||
disabled={isGenerating || isStageLoading}
|
||||
>
|
||||
Continue to Export
|
||||
</button>
|
||||
)}
|
||||
|
||||
{stage === 'review' && (
|
||||
<div className="space-y-2 text-xs text-slate-500">
|
||||
<div className="font-semibold text-slate-700">Export</div>
|
||||
<div className="rounded-lg border border-slate-200 px-3 py-2 text-slate-600">
|
||||
{docLabel} - 1 page
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex w-full items-center justify-center rounded-lg border border-slate-300 px-3 py-2 text-[11px] text-slate-600"
|
||||
onClick={onOpenImportTemplate}
|
||||
>
|
||||
Import template from PDF
|
||||
</button>
|
||||
{imagePlaceholdersCount > 0 && (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-amber-700">
|
||||
<div className="font-semibold">Image placeholders detected</div>
|
||||
<p className="mt-1 text-[11px] text-amber-700">
|
||||
Add images now or remove placeholders before exporting.
|
||||
</p>
|
||||
<div className="mt-2 flex flex-col gap-2">
|
||||
<label className="inline-flex w-full cursor-pointer items-center justify-center rounded-md border border-amber-300 bg-white px-3 py-1 text-[11px] text-amber-700">
|
||||
{isAssetUploading ? 'Uploading...' : 'Add image'}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/jpg,image/gif"
|
||||
className="hidden"
|
||||
disabled={isAssetUploading}
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (file) onAddPlaceholderImage(file)
|
||||
event.currentTarget.value = ''
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex w-full items-center justify-center rounded-md border border-slate-300 px-3 py-1 text-[11px] text-slate-600"
|
||||
onClick={onRemovePlaceholders}
|
||||
disabled={isAssetUploading}
|
||||
>
|
||||
Remove placeholders
|
||||
</button>
|
||||
</div>
|
||||
{assetError && <div className="mt-2 text-[11px] text-amber-600">{assetError}</div>}
|
||||
</div>
|
||||
)}
|
||||
{currentDoc?.pdfUrl && (
|
||||
<a
|
||||
href={currentDoc.pdfUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex w-full items-center justify-center rounded-lg border border-slate-300 px-3 py-2 text-slate-700"
|
||||
>
|
||||
Export and Close
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<main className="flex-1 overflow-auto px-8 py-10">
|
||||
<div className="mx-auto max-w-4xl space-y-8">
|
||||
<form
|
||||
onSubmit={onChatSubmit}
|
||||
className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full border border-slate-200 text-slate-400"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<input
|
||||
className="w-full text-sm text-slate-700 placeholder:text-slate-400 focus:outline-none"
|
||||
value={prompt}
|
||||
onChange={(event) => onPromptChange(event.target.value)}
|
||||
placeholder="Describe what you want to create..."
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-slate-200 px-3 py-1 text-xs text-slate-500"
|
||||
onClick={onOpenImportTemplate}
|
||||
>
|
||||
Template
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full bg-slate-900 text-white"
|
||||
>
|
||||
^
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
|
||||
{renderStageContent()}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<aside className="w-16 border-l border-slate-200 bg-slate-50 flex flex-col items-center py-6 gap-4 text-slate-400 text-xs">
|
||||
<div className="h-10 w-10 rounded-xl bg-slate-200" />
|
||||
<div className="h-10 w-10 rounded-xl bg-slate-200" />
|
||||
<div className="h-10 w-10 rounded-xl bg-slate-200" />
|
||||
<div className="h-10 w-10 rounded-xl bg-slate-200" />
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// Auto-generated by generate_template_thumbnails.py
|
||||
export type TemplateCatalogEntry = {
|
||||
docType: string
|
||||
templateCount: number
|
||||
templates: string[]
|
||||
}
|
||||
|
||||
export const templateCatalog: TemplateCatalogEntry[] = [
|
||||
{ docType: 'academic_articles', templateCount: 1, templates: ['default'] },
|
||||
{ docType: 'academic_journals', templateCount: 1, templates: ['default'] },
|
||||
{ docType: 'assignments', templateCount: 1, templates: ['default'] },
|
||||
{ docType: 'books', templateCount: 1, templates: ['default'] },
|
||||
{ docType: 'business_cards', templateCount: 1, templates: ['default'] },
|
||||
{ docType: 'business_reports', templateCount: 1, templates: ['default'] },
|
||||
{ docType: 'calendars', templateCount: 1, templates: ['default'] },
|
||||
{ docType: 'conference_posters', templateCount: 1, templates: ['default'] },
|
||||
{ docType: 'cover_letters', templateCount: 1, templates: ['default'] },
|
||||
{ docType: 'cvs_and_resumes', templateCount: 1, templates: ['default'] },
|
||||
{ docType: 'essays', templateCount: 1, templates: ['default'] },
|
||||
{ docType: 'formal_letters', templateCount: 1, templates: ['default'] },
|
||||
{ docType: 'invoices', templateCount: 1, templates: ['default'] },
|
||||
{ docType: 'laboratory_books', templateCount: 1, templates: ['default'] },
|
||||
{ docType: 'laboratory_reports', templateCount: 1, templates: ['default'] },
|
||||
{ docType: 'miscellaneous', templateCount: 1, templates: ['default'] },
|
||||
{ docType: 'newsletters', templateCount: 1, templates: ['default'] },
|
||||
{ docType: 'presentations', templateCount: 1, templates: ['default'] },
|
||||
{ docType: 'recipes', templateCount: 1, templates: ['default'] },
|
||||
{ docType: 'signs', templateCount: 1, templates: ['default'] },
|
||||
{ docType: 'theses', templateCount: 1, templates: ['default'] },
|
||||
{ docType: 'title_pages', templateCount: 1, templates: ['default'] }
|
||||
]
|
||||
@@ -71,6 +71,12 @@ public class AiProxyController {
|
||||
return proxy("GET", "/api/style/" + userId, request, false);
|
||||
}
|
||||
|
||||
@PostMapping("/style/{userId}")
|
||||
public ResponseEntity<StreamingResponseBody> updateStyle(
|
||||
@PathVariable("userId") String userId, HttpServletRequest request) {
|
||||
return proxy("POST", "/api/style/" + userId, request, false);
|
||||
}
|
||||
|
||||
@PostMapping("/import_template")
|
||||
public ResponseEntity<StreamingResponseBody> importTemplate(HttpServletRequest request) {
|
||||
return proxy("POST", "/api/import_template", request, false);
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
package stirling.software.proprietary.controller.api.ai;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.Optional;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.model.ai.AiCreateSession;
|
||||
import stirling.software.proprietary.service.ai.AiCreateProxyService;
|
||||
import stirling.software.proprietary.service.ai.AiCreateSessionService;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/ai/create")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class AiCreateController {
|
||||
|
||||
private final AiCreateSessionService sessionService;
|
||||
private final AiCreateProxyService proxyService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@PostMapping("/sessions")
|
||||
public ResponseEntity<CreateSessionResponse> createSession(
|
||||
@RequestBody CreateSessionRequest request) {
|
||||
if (request.prompt() == null || request.prompt().isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Prompt is required");
|
||||
}
|
||||
AiCreateSession session =
|
||||
sessionService.createSession(request.prompt(), request.docType(), request.templateId());
|
||||
return ResponseEntity.ok(new CreateSessionResponse(session.getSessionId()));
|
||||
}
|
||||
|
||||
@GetMapping("/sessions/{sessionId}")
|
||||
public ResponseEntity<AiCreateSessionResponse> getSession(
|
||||
@PathVariable String sessionId) {
|
||||
AiCreateSession session = sessionService.getSessionForCurrentUser(sessionId);
|
||||
return ResponseEntity.ok(AiCreateSessionResponse.from(session));
|
||||
}
|
||||
|
||||
@PostMapping("/sessions/{sessionId}/outline")
|
||||
public ResponseEntity<AiCreateSessionResponse> updateOutline(
|
||||
@PathVariable String sessionId, @RequestBody OutlineRequest request) {
|
||||
if (request.outlineText() == null || request.outlineText().isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Outline text is required");
|
||||
}
|
||||
String constraintsPayload = null;
|
||||
if (request.constraints() != null) {
|
||||
try {
|
||||
constraintsPayload = objectMapper.writeValueAsString(request.constraints());
|
||||
} catch (JsonProcessingException exc) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid constraints payload", exc);
|
||||
}
|
||||
}
|
||||
AiCreateSession session =
|
||||
sessionService.updateOutline(sessionId, request.outlineText(), constraintsPayload);
|
||||
return ResponseEntity.ok(AiCreateSessionResponse.from(session));
|
||||
}
|
||||
|
||||
@PostMapping("/sessions/{sessionId}/reprompt")
|
||||
public ResponseEntity<AiCreateSessionResponse> reprompt(
|
||||
@PathVariable String sessionId, @RequestBody RepromptRequest request) {
|
||||
if (request.prompt() == null || request.prompt().isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Prompt is required");
|
||||
}
|
||||
AiCreateSession session = sessionService.reprompt(sessionId, request.prompt());
|
||||
return ResponseEntity.ok(AiCreateSessionResponse.from(session));
|
||||
}
|
||||
|
||||
@PostMapping("/sessions/{sessionId}/draft")
|
||||
public ResponseEntity<AiCreateSessionResponse> updateDraft(
|
||||
@PathVariable String sessionId, @RequestBody DraftRequest request) {
|
||||
if (request.draftSections() == null || request.draftSections().isEmpty()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Draft sections are required");
|
||||
}
|
||||
String payload;
|
||||
try {
|
||||
payload = objectMapper.writeValueAsString(request.draftSections());
|
||||
} catch (JsonProcessingException exc) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid draft sections payload", exc);
|
||||
}
|
||||
AiCreateSession session = sessionService.updateDraftSections(sessionId, payload);
|
||||
return ResponseEntity.ok(AiCreateSessionResponse.from(session));
|
||||
}
|
||||
|
||||
@PostMapping("/sessions/{sessionId}/template")
|
||||
public ResponseEntity<AiCreateSessionResponse> updateTemplate(
|
||||
@PathVariable String sessionId, @RequestBody TemplateRequest request) {
|
||||
if ((request.docType() == null || request.docType().isBlank())
|
||||
&& (request.templateId() == null || request.templateId().isBlank())) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "docType or templateId is required");
|
||||
}
|
||||
AiCreateSession session =
|
||||
sessionService.updateTemplate(
|
||||
sessionId, request.docType(), request.templateId());
|
||||
return ResponseEntity.ok(AiCreateSessionResponse.from(session));
|
||||
}
|
||||
|
||||
@PostMapping("/sessions/{sessionId}/fields")
|
||||
public ResponseEntity<StreamingResponseBody> fillFields(
|
||||
@PathVariable String sessionId, HttpServletRequest request) {
|
||||
sessionService.getSessionForCurrentUser(sessionId);
|
||||
return proxy("POST", "/api/create/sessions/" + sessionId + "/fields", request, false);
|
||||
}
|
||||
|
||||
@GetMapping(value = "/sessions/{sessionId}/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||
public ResponseEntity<StreamingResponseBody> stream(
|
||||
@PathVariable String sessionId, HttpServletRequest request) {
|
||||
sessionService.getSessionForCurrentUser(sessionId);
|
||||
return proxy("GET", "/api/create/sessions/" + sessionId + "/stream", request, true);
|
||||
}
|
||||
|
||||
private ResponseEntity<StreamingResponseBody> proxy(
|
||||
String method, String path, HttpServletRequest request, boolean acceptEventStream) {
|
||||
try {
|
||||
HttpResponse<InputStream> response =
|
||||
proxyService.forward(method, path, request, acceptEventStream);
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
copyHeader(response, headers, HttpHeaders.CONTENT_TYPE);
|
||||
copyHeader(response, headers, HttpHeaders.CACHE_CONTROL);
|
||||
copyHeader(response, headers, "X-Accel-Buffering");
|
||||
copyHeader(response, headers, HttpHeaders.CONTENT_DISPOSITION);
|
||||
copyHeader(response, headers, HttpHeaders.CONTENT_LENGTH);
|
||||
if (acceptEventStream && !headers.containsKey(HttpHeaders.CONTENT_TYPE)) {
|
||||
headers.set(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_EVENT_STREAM_VALUE);
|
||||
}
|
||||
|
||||
StreamingResponseBody body =
|
||||
outputStream -> {
|
||||
try (InputStream inputStream = response.body()) {
|
||||
inputStream.transferTo(outputStream);
|
||||
}
|
||||
};
|
||||
HttpStatus status =
|
||||
Optional.ofNullable(HttpStatus.resolve(response.statusCode()))
|
||||
.orElse(HttpStatus.BAD_GATEWAY);
|
||||
return new ResponseEntity<>(body, headers, status);
|
||||
} catch (Exception exc) {
|
||||
log.error("AI create proxy failed path={}", path, exc);
|
||||
StreamingResponseBody body =
|
||||
outputStream ->
|
||||
outputStream.write(
|
||||
"{\"error\":\"AI backend unavailable\"}".getBytes());
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
return new ResponseEntity<>(body, headers, HttpStatus.SERVICE_UNAVAILABLE);
|
||||
}
|
||||
}
|
||||
|
||||
private void copyHeader(
|
||||
HttpResponse<?> response, HttpHeaders headers, String headerName) {
|
||||
response.headers().firstValue(headerName).ifPresent(value -> headers.set(headerName, value));
|
||||
}
|
||||
|
||||
public record CreateSessionRequest(String prompt, String docType, String templateId) {}
|
||||
|
||||
public record CreateSessionResponse(String sessionId) {}
|
||||
|
||||
public record OutlineRequest(String outlineText, Map<String, Object> constraints) {}
|
||||
|
||||
public record RepromptRequest(String prompt) {}
|
||||
|
||||
public record DraftRequest(List<DraftSection> draftSections) {}
|
||||
|
||||
public record DraftSection(String label, String value) {}
|
||||
|
||||
public record TemplateRequest(String docType, String templateId) {}
|
||||
|
||||
public record AiCreateSessionResponse(
|
||||
String sessionId,
|
||||
String userId,
|
||||
String docType,
|
||||
String templateId,
|
||||
String promptInitial,
|
||||
String promptLatest,
|
||||
String outlineText,
|
||||
boolean outlineApproved,
|
||||
String outlineConstraints,
|
||||
String draftSections,
|
||||
String polishedLatex,
|
||||
String status) {
|
||||
public static AiCreateSessionResponse from(AiCreateSession session) {
|
||||
return new AiCreateSessionResponse(
|
||||
session.getSessionId(),
|
||||
session.getUserId(),
|
||||
session.getDocType(),
|
||||
session.getTemplateId(),
|
||||
session.getPromptInitial(),
|
||||
session.getPromptLatest(),
|
||||
session.getOutlineText(),
|
||||
session.isOutlineApproved(),
|
||||
session.getOutlineConstraints(),
|
||||
session.getDraftSections(),
|
||||
session.getPolishedLatex(),
|
||||
session.getStatus() != null ? session.getStatus().name() : null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package stirling.software.proprietary.controller.api.ai;
|
||||
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.proprietary.model.ai.AiCreateSession;
|
||||
import stirling.software.proprietary.model.ai.AiCreateSessionStatus;
|
||||
import stirling.software.proprietary.service.ai.AiCreateSessionService;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/ai/create/internal")
|
||||
@RequiredArgsConstructor
|
||||
public class AiCreateInternalController {
|
||||
|
||||
private final AiCreateSessionService sessionService;
|
||||
|
||||
@GetMapping("/sessions/{sessionId}")
|
||||
public ResponseEntity<AiCreateController.AiCreateSessionResponse> getSession(
|
||||
@PathVariable String sessionId) {
|
||||
AiCreateSession session = sessionService.getSession(sessionId);
|
||||
return ResponseEntity.ok(AiCreateController.AiCreateSessionResponse.from(session));
|
||||
}
|
||||
|
||||
@PostMapping("/sessions/{sessionId}/update")
|
||||
public ResponseEntity<AiCreateController.AiCreateSessionResponse> updateSession(
|
||||
@PathVariable String sessionId, @RequestBody UpdateSessionRequest request) {
|
||||
AiCreateSession session =
|
||||
sessionService.applyInternalUpdate(
|
||||
sessionId,
|
||||
request.outlineText(),
|
||||
request.outlineApproved(),
|
||||
request.outlineConstraints(),
|
||||
request.draftSections(),
|
||||
request.polishedLatex(),
|
||||
request.docType(),
|
||||
request.templateId(),
|
||||
request.status());
|
||||
return ResponseEntity.ok(AiCreateController.AiCreateSessionResponse.from(session));
|
||||
}
|
||||
|
||||
public record UpdateSessionRequest(
|
||||
String outlineText,
|
||||
Boolean outlineApproved,
|
||||
String outlineConstraints,
|
||||
String draftSections,
|
||||
String polishedLatex,
|
||||
String docType,
|
||||
String templateId,
|
||||
AiCreateSessionStatus status) {}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package stirling.software.proprietary.model.ai;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Lob;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Entity
|
||||
@Table(name = "ai_create_sessions")
|
||||
@Data
|
||||
public class AiCreateSession {
|
||||
@Id private String sessionId;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String userId;
|
||||
|
||||
private String docType;
|
||||
|
||||
private String templateId;
|
||||
|
||||
@Lob private String promptInitial;
|
||||
|
||||
@Lob private String promptLatest;
|
||||
|
||||
@Lob private String outlineText;
|
||||
|
||||
private boolean outlineApproved;
|
||||
|
||||
@Lob private String outlineConstraints;
|
||||
|
||||
@Lob private String draftSections;
|
||||
|
||||
@Lob private String polishedLatex;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
private AiCreateSessionStatus status;
|
||||
|
||||
@CreationTimestamp private Instant createdAt;
|
||||
|
||||
@UpdateTimestamp private Instant updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package stirling.software.proprietary.model.ai;
|
||||
|
||||
public enum AiCreateSessionStatus {
|
||||
OUTLINE_PENDING,
|
||||
OUTLINE_APPROVED,
|
||||
DRAFT_READY,
|
||||
POLISHED_READY,
|
||||
SAVED,
|
||||
SHARED
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package stirling.software.proprietary.repository;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import stirling.software.proprietary.model.ai.AiCreateSession;
|
||||
|
||||
public interface AiCreateSessionRepository extends JpaRepository<AiCreateSession, String> {}
|
||||
@@ -0,0 +1,88 @@
|
||||
package stirling.software.proprietary.service.ai;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class AiCreateProxyService {
|
||||
|
||||
private static final String DEFAULT_AI_BASE_URL = "http://localhost:5000";
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final HttpClient httpClient;
|
||||
|
||||
public AiCreateProxyService(ApplicationProperties applicationProperties) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
this.httpClient = HttpClient.newBuilder().build();
|
||||
}
|
||||
|
||||
public HttpResponse<InputStream> forward(
|
||||
String method, String path, HttpServletRequest request, boolean acceptEventStream)
|
||||
throws IOException, InterruptedException {
|
||||
String targetUrl = buildTargetUrl(path, request.getQueryString());
|
||||
HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(targetUrl));
|
||||
|
||||
String contentType = request.getContentType();
|
||||
if (contentType != null && !contentType.isBlank()) {
|
||||
builder.header("Content-Type", contentType);
|
||||
}
|
||||
|
||||
String accept = request.getHeader("Accept");
|
||||
if (acceptEventStream) {
|
||||
builder.header("Accept", "text/event-stream");
|
||||
} else if (accept != null && !accept.isBlank()) {
|
||||
builder.header("Accept", accept);
|
||||
}
|
||||
|
||||
builder.method(method, buildBodyPublisher(method, request));
|
||||
log.debug("Proxying AI create request {} {}", method, targetUrl);
|
||||
return httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofInputStream());
|
||||
}
|
||||
|
||||
private String buildTargetUrl(String path, String queryString) {
|
||||
String baseUrl = applicationProperties.getSystem().getAiServiceBaseUrl();
|
||||
if (baseUrl == null || baseUrl.isBlank()) {
|
||||
baseUrl = DEFAULT_AI_BASE_URL;
|
||||
}
|
||||
baseUrl = baseUrl.trim();
|
||||
if (baseUrl.endsWith("/")) {
|
||||
baseUrl = baseUrl.substring(0, baseUrl.length() - 1);
|
||||
}
|
||||
if (!path.startsWith("/")) {
|
||||
path = "/" + path;
|
||||
}
|
||||
String url = baseUrl + path;
|
||||
if (queryString != null && !queryString.isBlank()) {
|
||||
url += "?" + queryString;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
private HttpRequest.BodyPublisher buildBodyPublisher(String method, HttpServletRequest request) {
|
||||
if ("GET".equalsIgnoreCase(method) || "DELETE".equalsIgnoreCase(method)) {
|
||||
return HttpRequest.BodyPublishers.noBody();
|
||||
}
|
||||
return HttpRequest.BodyPublishers.ofInputStream(
|
||||
() -> {
|
||||
try {
|
||||
return request.getInputStream();
|
||||
} catch (IOException exc) {
|
||||
throw new UncheckedIOException(exc);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package stirling.software.proprietary.service.ai;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
import stirling.software.proprietary.model.ai.AiCreateSession;
|
||||
import stirling.software.proprietary.model.ai.AiCreateSessionStatus;
|
||||
import stirling.software.proprietary.repository.AiCreateSessionRepository;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class AiCreateSessionService {
|
||||
private static final String DEFAULT_USER_ID = "default_user";
|
||||
|
||||
private final AiCreateSessionRepository repository;
|
||||
|
||||
private final Optional<UserServiceInterface> userService;
|
||||
|
||||
public AiCreateSession createSession(String prompt, String docType, String templateId) {
|
||||
String userId = resolveUserId();
|
||||
AiCreateSession session = new AiCreateSession();
|
||||
session.setSessionId(UUID.randomUUID().toString());
|
||||
session.setUserId(userId);
|
||||
session.setDocType(docType);
|
||||
session.setTemplateId(templateId);
|
||||
session.setPromptInitial(prompt);
|
||||
session.setPromptLatest(prompt);
|
||||
session.setOutlineApproved(false);
|
||||
session.setStatus(AiCreateSessionStatus.OUTLINE_PENDING);
|
||||
return repository.save(session);
|
||||
}
|
||||
|
||||
public AiCreateSession getSession(String sessionId) {
|
||||
return repository
|
||||
.findById(sessionId)
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new ResponseStatusException(
|
||||
HttpStatus.NOT_FOUND, "AI session not found"));
|
||||
}
|
||||
|
||||
public AiCreateSession getSessionForCurrentUser(String sessionId) {
|
||||
AiCreateSession session = getSession(sessionId);
|
||||
String userId = resolveUserId();
|
||||
if (!DEFAULT_USER_ID.equals(userId) && !userId.equals(session.getUserId())) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "AI session not found");
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
public AiCreateSession updateOutline(String sessionId, String outlineText, String outlineConstraints) {
|
||||
AiCreateSession session = getSessionForCurrentUser(sessionId);
|
||||
session.setOutlineText(outlineText);
|
||||
session.setOutlineApproved(true);
|
||||
if (outlineConstraints != null) {
|
||||
session.setOutlineConstraints(outlineConstraints);
|
||||
}
|
||||
session.setStatus(AiCreateSessionStatus.OUTLINE_APPROVED);
|
||||
return repository.save(session);
|
||||
}
|
||||
|
||||
public AiCreateSession updateDraftSections(String sessionId, String draftSections) {
|
||||
AiCreateSession session = getSessionForCurrentUser(sessionId);
|
||||
session.setDraftSections(draftSections);
|
||||
session.setStatus(AiCreateSessionStatus.DRAFT_READY);
|
||||
return repository.save(session);
|
||||
}
|
||||
|
||||
public AiCreateSession updateTemplate(String sessionId, String docType, String templateId) {
|
||||
AiCreateSession session = getSessionForCurrentUser(sessionId);
|
||||
if (docType != null && !docType.isBlank()) {
|
||||
session.setDocType(docType);
|
||||
}
|
||||
if (templateId != null && !templateId.isBlank()) {
|
||||
session.setTemplateId(templateId);
|
||||
}
|
||||
return repository.save(session);
|
||||
}
|
||||
|
||||
public AiCreateSession reprompt(String sessionId, String prompt) {
|
||||
AiCreateSession session = getSessionForCurrentUser(sessionId);
|
||||
session.setPromptLatest(prompt);
|
||||
session.setOutlineText(null);
|
||||
session.setOutlineApproved(false);
|
||||
session.setOutlineConstraints(null);
|
||||
session.setDraftSections(null);
|
||||
session.setPolishedLatex(null);
|
||||
session.setStatus(AiCreateSessionStatus.OUTLINE_PENDING);
|
||||
return repository.save(session);
|
||||
}
|
||||
|
||||
public AiCreateSession applyInternalUpdate(
|
||||
String sessionId,
|
||||
String outlineText,
|
||||
Boolean outlineApproved,
|
||||
String outlineConstraints,
|
||||
String draftSections,
|
||||
String polishedLatex,
|
||||
String docType,
|
||||
String templateId,
|
||||
AiCreateSessionStatus status) {
|
||||
AiCreateSession session = getSession(sessionId);
|
||||
if (outlineText != null) {
|
||||
session.setOutlineText(outlineText);
|
||||
}
|
||||
if (outlineApproved != null) {
|
||||
session.setOutlineApproved(outlineApproved);
|
||||
}
|
||||
if (outlineConstraints != null) {
|
||||
session.setOutlineConstraints(outlineConstraints);
|
||||
}
|
||||
if (draftSections != null) {
|
||||
session.setDraftSections(draftSections);
|
||||
}
|
||||
if (polishedLatex != null) {
|
||||
session.setPolishedLatex(polishedLatex);
|
||||
}
|
||||
if (docType != null) {
|
||||
session.setDocType(docType);
|
||||
}
|
||||
if (templateId != null) {
|
||||
session.setTemplateId(templateId);
|
||||
}
|
||||
if (status != null) {
|
||||
session.setStatus(status);
|
||||
}
|
||||
return repository.save(session);
|
||||
}
|
||||
|
||||
public String resolveUserId() {
|
||||
if (userService == null || userService.isEmpty()) {
|
||||
return DEFAULT_USER_ID;
|
||||
}
|
||||
try {
|
||||
String username = userService.get().getCurrentUsername();
|
||||
if (username != null
|
||||
&& !username.isBlank()
|
||||
&& !"anonymousUser".equals(username)) {
|
||||
return username;
|
||||
}
|
||||
} catch (Exception exc) {
|
||||
log.debug("Failed to resolve current username: {}", exc.getMessage());
|
||||
}
|
||||
return DEFAULT_USER_ID;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
# syntax=docker/dockerfile:1.5
|
||||
# ==============================================================================
|
||||
# Multi-stage Dockerfile for Stirling-PDF – image with everything included
|
||||
# Includes: LibreOffice, Calibre, Tesseract, OCRmyPDF, unoserver, WeasyPrint, etc.
|
||||
@@ -15,7 +16,8 @@ COPY gradle gradle/
|
||||
COPY app/core/build.gradle core/.
|
||||
COPY app/common/build.gradle common/.
|
||||
COPY app/proprietary/build.gradle proprietary/.
|
||||
RUN ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube || return 0
|
||||
RUN --mount=type=cache,target=/home/gradle/.gradle \
|
||||
./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube || return 0
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /app
|
||||
@@ -24,7 +26,8 @@ WORKDIR /app
|
||||
COPY . .
|
||||
|
||||
# Build the application (server-only JAR - no UI, includes security features controlled at runtime)
|
||||
RUN DISABLE_ADDITIONAL_FEATURES=false \
|
||||
RUN --mount=type=cache,target=/home/gradle/.gradle \
|
||||
DISABLE_ADDITIONAL_FEATURES=false \
|
||||
STIRLING_PDF_DESKTOP_UI=false \
|
||||
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
|
||||
@@ -40,7 +43,8 @@ ENV DEBIAN_FRONTEND=noninteractive
|
||||
ENV TESS_BASE_PATH=/usr/share/tesseract-ocr/5/tessdata
|
||||
|
||||
# Install core runtime dependencies + tools required by Stirling-PDF features
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates tzdata tini bash fontconfig \
|
||||
openjdk-21-jre-headless \
|
||||
ffmpeg poppler-utils ocrmypdf \
|
||||
@@ -146,13 +150,15 @@ ENV VERSION_TAG=$VERSION_TAG \
|
||||
# ==============================================================================
|
||||
# Python virtual environment for additional Python tools (WeasyPrint, OpenCV, etc.)
|
||||
# ==============================================================================
|
||||
RUN python3 -m venv /opt/venv --system-site-packages \
|
||||
&& /opt/venv/bin/pip install --no-cache-dir weasyprint pdf2image opencv-python-headless \
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
python3 -m venv /opt/venv --system-site-packages \
|
||||
&& /opt/venv/bin/pip install weasyprint pdf2image opencv-python-headless \
|
||||
&& /opt/venv/bin/python -c "import cv2; print('OpenCV version:', cv2.__version__)"
|
||||
|
||||
# Separate venv for unoserver (keeps it isolated)
|
||||
RUN python3 -m venv /opt/unoserver-venv --system-site-packages \
|
||||
&& /opt/unoserver-venv/bin/pip install --no-cache-dir unoserver
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
python3 -m venv /opt/unoserver-venv --system-site-packages \
|
||||
&& /opt/unoserver-venv/bin/pip install unoserver
|
||||
|
||||
# Make unoserver tools available in main venv PATH
|
||||
RUN ln -sf /opt/unoserver-venv/bin/unoconvert /opt/venv/bin/unoconvert \
|
||||
|
||||
@@ -37,6 +37,7 @@ Stage 1: Outline
|
||||
- User can edit any section.
|
||||
- "Approve and Continue" triggers the next stage.
|
||||
- Input box remains visible for reprompt.
|
||||
- Edit/lock rules within stages are MVP-flexible and will be refined later.
|
||||
|
||||
Stage 2: Rough Draft
|
||||
- AI fills a full rough draft from the approved outline.
|
||||
@@ -45,12 +46,12 @@ Stage 2: Rough Draft
|
||||
|
||||
Stage 3: Polished Template
|
||||
- AI applies a LaTeX template for the chosen doc type.
|
||||
- Style edits allowed, substance locked unless full redraft.
|
||||
- Style edits and locking rules are TBD for MVP.
|
||||
- Company templates available (pro tier).
|
||||
|
||||
Stage 4: Share
|
||||
- Default is a share link.
|
||||
- Optional email send.
|
||||
Stage 4: Share (post-MVP)
|
||||
- MVP can be download-only.
|
||||
- Share links and email are post-MVP.
|
||||
|
||||
## Data Model (Java)
|
||||
Session
|
||||
@@ -70,7 +71,7 @@ Session
|
||||
|
||||
Templates
|
||||
- template_id
|
||||
- owner_id or team_id
|
||||
- owner_id (team visibility rules TBD)
|
||||
- doc_type
|
||||
- latex_source
|
||||
- created_at
|
||||
@@ -97,7 +98,7 @@ Frontend -> Java (public)
|
||||
body: { outlineText }
|
||||
- POST /ai/sessions/:id/reprompt
|
||||
body: { prompt }
|
||||
- POST /ai/sessions/:id/share
|
||||
- POST /ai/sessions/:id/share (post-MVP)
|
||||
body: { email? }
|
||||
|
||||
Python -> Java (internal tools)
|
||||
@@ -134,6 +135,7 @@ Nodes
|
||||
- GenerateDraft
|
||||
- ApplyTemplate
|
||||
- SaveAndReturn
|
||||
Note: Node boundaries are provisional for MVP and may change.
|
||||
|
||||
State
|
||||
- sessionId
|
||||
@@ -167,7 +169,7 @@ Phase 2: Draft + Polish
|
||||
|
||||
Phase 3: Share + Save
|
||||
- Java stores polished LaTeX + PDF.
|
||||
- Share by link + optional email.
|
||||
- MVP can be download-only; share is post-MVP.
|
||||
|
||||
Phase 4: Hardening
|
||||
- Rate limits on AI endpoints.
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Overleaf Template Scraper
|
||||
Downloads all templates from Overleaf gallery organized by category and license.
|
||||
|
||||
Requirements:
|
||||
pip install requests beautifulsoup4 lxml
|
||||
|
||||
Note: This scraper collects template metadata and GitHub links where available.
|
||||
For actual template downloads, you'll need Overleaf credentials due to their
|
||||
requirement to "Open as Template" before downloading.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
from pathlib import Path
|
||||
from urllib.parse import urljoin, urlparse
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
|
||||
class OverleafTemplateScraper:
|
||||
def __init__(self, output_dir: str = "./overleaf_templates"):
|
||||
self.base_url = "https://www.overleaf.com"
|
||||
self.output_dir = Path(output_dir)
|
||||
self.output_dir.mkdir(exist_ok=True)
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
})
|
||||
|
||||
def get_template_pages(self, max_pages: Optional[int] = None) -> List[str]:
|
||||
"""Get all template gallery page URLs."""
|
||||
print("Discovering template pages...")
|
||||
pages = []
|
||||
|
||||
# Start with page 1
|
||||
for page_num in range(1, (max_pages or 911) + 1):
|
||||
page_url = f"{self.base_url}/latex/templates?page={page_num}"
|
||||
pages.append(page_url)
|
||||
|
||||
print(f"Found {len(pages)} pages to scrape")
|
||||
return pages
|
||||
|
||||
def scrape_template_list_page(self, page_url: str) -> List[Dict]:
|
||||
"""Scrape a single template listing page."""
|
||||
print(f"Scraping {page_url}")
|
||||
templates = []
|
||||
|
||||
try:
|
||||
response = self.session.get(page_url, timeout=30)
|
||||
response.raise_for_status()
|
||||
soup = BeautifulSoup(response.content, 'lxml')
|
||||
|
||||
# Find all template cards
|
||||
template_links = soup.find_all('a', href=lambda x: x and '/latex/templates/' in x and len(x.split('/')) >= 5)
|
||||
|
||||
for link in template_links:
|
||||
href = link.get('href')
|
||||
if href and '/latex/templates/' in href and href.count('/') >= 4:
|
||||
full_url = urljoin(self.base_url, href)
|
||||
if full_url not in [t['url'] for t in templates]:
|
||||
templates.append({
|
||||
'url': full_url,
|
||||
'title': link.get_text(strip=True) or 'Unknown'
|
||||
})
|
||||
|
||||
print(f" Found {len(templates)} templates on this page")
|
||||
|
||||
except Exception as e:
|
||||
print(f" Error scraping page: {e}")
|
||||
|
||||
return templates
|
||||
|
||||
def scrape_template_details(self, template_url: str) -> Dict:
|
||||
"""Scrape detailed information from a template page."""
|
||||
print(f" Fetching details: {template_url}")
|
||||
details = {
|
||||
'url': template_url,
|
||||
'title': '',
|
||||
'author': '',
|
||||
'license': '',
|
||||
'description': '',
|
||||
'last_updated': '',
|
||||
'tags': [],
|
||||
'github_url': None,
|
||||
'view_source_url': None
|
||||
}
|
||||
|
||||
try:
|
||||
response = self.session.get(template_url, timeout=30)
|
||||
response.raise_for_status()
|
||||
soup = BeautifulSoup(response.content, 'lxml')
|
||||
|
||||
# Extract title
|
||||
title = soup.find('h1')
|
||||
if title:
|
||||
details['title'] = title.get_text(strip=True)
|
||||
|
||||
# Extract license (look for CC BY, LPPL, etc.)
|
||||
license_text = soup.find(string=lambda x: x and ('CC BY' in x or 'LPPL' in x or 'MIT' in x or 'Public Domain' in x))
|
||||
if license_text:
|
||||
details['license'] = license_text.strip()
|
||||
|
||||
# Look for metadata section
|
||||
metadata = soup.find_all(['dt', 'dd'])
|
||||
for i in range(0, len(metadata)-1, 2):
|
||||
key = metadata[i].get_text(strip=True).lower()
|
||||
value = metadata[i+1].get_text(strip=True)
|
||||
|
||||
if 'author' in key:
|
||||
details['author'] = value
|
||||
elif 'license' in key:
|
||||
details['license'] = value
|
||||
elif 'updated' in key or 'modified' in key:
|
||||
details['last_updated'] = value
|
||||
|
||||
# Find GitHub link
|
||||
github_link = soup.find('a', href=lambda x: x and 'github.com' in x)
|
||||
if github_link:
|
||||
details['github_url'] = github_link.get('href')
|
||||
|
||||
# Find description/abstract
|
||||
abstract = soup.find(['p', 'div'], class_=lambda x: x and 'abstract' in x.lower() if x else False)
|
||||
if abstract:
|
||||
details['description'] = abstract.get_text(strip=True)
|
||||
|
||||
# Find tags/categories
|
||||
tags = soup.find_all('a', href=lambda x: x and '/latex/templates/tagged/' in x)
|
||||
details['tags'] = [tag.get_text(strip=True) for tag in tags]
|
||||
|
||||
except Exception as e:
|
||||
print(f" Error fetching template details: {e}")
|
||||
|
||||
return details
|
||||
|
||||
def save_template_metadata(self, template: Dict, category: str = "general"):
|
||||
"""Save template metadata to JSON file."""
|
||||
category_dir = self.output_dir / category
|
||||
category_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Use template ID from URL as filename
|
||||
template_id = template['url'].split('/')[-1]
|
||||
filename = category_dir / f"{template_id}.json"
|
||||
|
||||
with open(filename, 'w', encoding='utf-8') as f:
|
||||
json.dump(template, f, indent=2, ensure_ascii=False)
|
||||
|
||||
def scrape_all_templates(self, max_pages: Optional[int] = 10, delay: float = 1.0):
|
||||
"""
|
||||
Scrape all templates from Overleaf.
|
||||
|
||||
Args:
|
||||
max_pages: Maximum number of gallery pages to scrape (None for all 911 pages)
|
||||
delay: Delay between requests in seconds
|
||||
"""
|
||||
print("Starting Overleaf template scraper...")
|
||||
print(f"Output directory: {self.output_dir}")
|
||||
|
||||
# Get all gallery pages
|
||||
gallery_pages = self.get_template_pages(max_pages)
|
||||
|
||||
all_templates = []
|
||||
templates_by_license = {
|
||||
'CC BY': [],
|
||||
'CC BY-SA': [],
|
||||
'CC BY-NC': [],
|
||||
'CC BY-NC-SA': [],
|
||||
'LPPL': [],
|
||||
'MIT': [],
|
||||
'Public Domain': [],
|
||||
'Other': []
|
||||
}
|
||||
|
||||
# Scrape each gallery page
|
||||
for page_url in gallery_pages:
|
||||
templates = self.scrape_template_list_page(page_url)
|
||||
|
||||
# Get details for each template
|
||||
for template_basic in templates:
|
||||
template_details = self.scrape_template_details(template_basic['url'])
|
||||
all_templates.append(template_details)
|
||||
|
||||
# Categorize by license
|
||||
license_key = 'Other'
|
||||
for key in templates_by_license.keys():
|
||||
if key in template_details.get('license', ''):
|
||||
license_key = key
|
||||
break
|
||||
|
||||
templates_by_license[license_key].append(template_details)
|
||||
|
||||
# Save individual template metadata
|
||||
self.save_template_metadata(template_details, license_key)
|
||||
|
||||
time.sleep(delay) # Be respectful
|
||||
|
||||
time.sleep(delay)
|
||||
|
||||
# Save summary
|
||||
summary = {
|
||||
'total_templates': len(all_templates),
|
||||
'by_license': {k: len(v) for k, v in templates_by_license.items()},
|
||||
'templates': all_templates
|
||||
}
|
||||
|
||||
summary_file = self.output_dir / 'summary.json'
|
||||
with open(summary_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(summary, f, indent=2, ensure_ascii=False)
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("SCRAPING COMPLETE!")
|
||||
print("="*60)
|
||||
print(f"Total templates: {summary['total_templates']}")
|
||||
print("\nBy license:")
|
||||
for license_type, count in summary['by_license'].items():
|
||||
if count > 0:
|
||||
print(f" {license_type}: {count}")
|
||||
print(f"\nCommercially usable (CC BY, CC BY-SA, LPPL, MIT, Public Domain): "
|
||||
f"{sum(summary['by_license'][k] for k in ['CC BY', 'CC BY-SA', 'LPPL', 'MIT', 'Public Domain'])}")
|
||||
print(f"\nMetadata saved to: {self.output_dir}")
|
||||
print("\nNOTE: To download actual template files, you'll need to:")
|
||||
print("1. Use the GitHub URLs where available")
|
||||
print("2. Or manually open templates in Overleaf with credentials")
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description='Scrape Overleaf template gallery')
|
||||
parser.add_argument('--max-pages', type=int, default=10,
|
||||
help='Maximum number of pages to scrape (default: 10, use 911 for all)')
|
||||
parser.add_argument('--delay', type=float, default=1.0,
|
||||
help='Delay between requests in seconds (default: 1.0)')
|
||||
parser.add_argument('--output', type=str, default='./overleaf_templates',
|
||||
help='Output directory (default: ./overleaf_templates)')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
scraper = OverleafTemplateScraper(output_dir=args.output)
|
||||
scraper.scrape_all_templates(max_pages=args.max_pages, delay=args.delay)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
After Width: | Height: | Size: 28 KiB |