This commit is contained in:
Anthony Stirling
2026-01-04 21:17:35 +00:00
parent 23ac6392bb
commit acbddf88d2
49 changed files with 4436 additions and 590 deletions
+5 -2
View File
@@ -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",
]
+319 -9
View File
@@ -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)
Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

+71 -36
View File
@@ -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>
)
}
File diff suppressed because it is too large Load Diff
@@ -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;
}
}
+13 -7
View File
@@ -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 \
+9 -7
View File
@@ -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.
+249
View File
@@ -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()
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB