diff --git a/backend/app/generator_service.py b/backend/app/generator_service.py index f5a3551..26714ff 100644 --- a/backend/app/generator_service.py +++ b/backend/app/generator_service.py @@ -52,6 +52,15 @@ class GeneratorService: def __init__(self, ppt_service: PPTService): self.ppt_service = ppt_service + def apply_theme_preset(self, path: str, preset: str, output_path: str | None = None) -> tuple[str, ThemeConfig]: + prs = Presentation(path) + req = GenerateRequest(topic="theme-apply", preset=preset) + theme = self._select_theme(req, []) + self.ppt_service.apply_theme_to_presentation(prs, theme) + save_path = output_path or self.ppt_service._default_output(path, f"preset-{theme.name}") + prs.save(save_path) + return save_path, theme + def generate(self, req: GenerateRequest) -> tuple[str, List[OutlineSlide], ThemeConfig]: if os.getenv("FAKE_LLM_RESPONSES", "0") == "1": outline = self._fake_llm_outline(req) diff --git a/backend/app/main.py b/backend/app/main.py index afd4ccf..88ef0fc 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,6 +2,7 @@ from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from .chat_service import ChatPlanner @@ -12,6 +13,7 @@ GenerateRequest, GenerateResponse, ThemeApplyRequest, + ThemePresetApplyRequest, UpdateRequest, ) from .ppt_service import PPTService @@ -78,6 +80,19 @@ def preview_ppt(path: str): raise HTTPException(status_code=400, detail=str(e)) +@app.get("/api/ppt/download") +def download_ppt(path: str): + try: + p = Path(path) + if not p.exists() or not p.is_file(): + raise HTTPException(status_code=404, detail="PPT file not found") + return FileResponse(path=str(p), filename=p.name, media_type="application/vnd.openxmlformats-officedocument.presentationml.presentation") + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + @app.post("/api/ppt/update") def update_ppt(req: UpdateRequest): try: @@ -116,3 +131,20 @@ def apply_theme(req: ThemeApplyRequest): } except Exception as e: raise HTTPException(status_code=400, detail=str(e)) + + +@app.post("/api/theme/apply-preset") +def apply_theme_preset(req: ThemePresetApplyRequest): + try: + output, theme = generator_service.apply_theme_preset(req.path, req.preset, req.output_path) + return { + "output_path": output, + "theme": { + "name": theme.name, + "font_name": theme.font_name, + "title_size_pt": theme.title_size_pt, + "body_size_pt": theme.body_size_pt, + }, + } + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) diff --git a/backend/app/models.py b/backend/app/models.py index 985aa59..15f76aa 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -44,6 +44,12 @@ class ThemeApplyRequest(BaseModel): output_path: Optional[str] = None +class ThemePresetApplyRequest(BaseModel): + path: str + preset: str + output_path: Optional[str] = None + + class ChatResponse(BaseModel): plan: List[EditInstruction] output_path: str diff --git a/backend/app/ppt_service.py b/backend/app/ppt_service.py index a849b14..f440d45 100644 --- a/backend/app/ppt_service.py +++ b/backend/app/ppt_service.py @@ -52,6 +52,12 @@ def apply_edits(self, path: str, edits: List[EditInstruction], output_path: str shape = slide.shapes[edit.shape_index] if hasattr(shape, "text"): shape.text = edit.new_text + + # Re-apply inferred theme after text edits so color/font style is not lost + # when certain PPT text boxes reset run formatting. + theme = self.infer_theme_for_presentation(prs) + self.apply_theme_to_presentation(prs, theme) + save_path = output_path or self._default_output(path, "edited") prs.save(save_path) return save_path diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 22d915c..bfe6637 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,10 +1,15 @@ -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' const API_BASE = import.meta.env.VITE_API_BASE || `${window.location.protocol}//${window.location.hostname}:8000` -const PRESET_OPTIONS = ['Business', 'Tech', 'Education', 'Marketing'] +const PRESET_OPTIONS = [ + { name: 'Business', desc: 'Executive, clean, data-focused', colors: ['#3b82f6', '#1e3a8a'] }, + { name: 'Tech', desc: 'Modern, product & engineering', colors: ['#22c55e', '#0f766e'] }, + { name: 'Education', desc: 'Clear, instructional, calm', colors: ['#eab308', '#a16207'] }, + { name: 'Marketing', desc: 'Bold, storytelling, conversion', colors: ['#f43f5e', '#be123c'] }, +] const LANGUAGE_OPTIONS = [ { label: 'English', value: 'en-US' }, @@ -25,6 +30,7 @@ async function api(path, options = {}) { } const toAbsolute = (url) => (url?.startsWith('http') ? url : `${API_BASE}${url}`) +const withVersion = (url, version) => `${toAbsolute(url)}${toAbsolute(url).includes('?') ? '&' : '?'}v=${version}` const initialForm = { topic: '', @@ -37,16 +43,31 @@ const initialForm = { export default function App() { const [form, setForm] = useState(initialForm) + const [leftWidth, setLeftWidth] = useState(360) + const [isResizing, setIsResizing] = useState(false) + const [pptPath, setPptPath] = useState('') const [doc, setDoc] = useState(null) const [loading, setLoading] = useState(false) const [notice, setNotice] = useState('') + const [previewImages, setPreviewImages] = useState([]) + const [previewVersion, setPreviewVersion] = useState(Date.now()) + const [selectedSlideIndex, setSelectedSlideIndex] = useState(() => Number(localStorage.getItem('chatppt:selectedSlide') || 0)) + const [dirtySlides, setDirtySlides] = useState(() => new Set()) + + const [undoStack, setUndoStack] = useState([]) + const [redoStack, setRedoStack] = useState([]) + const [isDirty, setIsDirty] = useState(false) + + const [chatOpen, setChatOpen] = useState(true) const [chatInput, setChatInput] = useState('') const [messages, setMessages] = useState([ - { role: 'assistant', text: 'Enter a topic and click "Generate PPT". Then continue editing through chat.' }, + { role: 'assistant', text: 'Enter a topic and click "Generate PPT". Then continue editing via chat.' }, ]) + const autosaveTimerRef = useRef(null) + const titleAndBody = useMemo(() => { if (!doc) return [] return doc.slides.map((s) => ({ @@ -55,14 +76,80 @@ export default function App() { })) }, [doc]) - const loadPPT = async (path = pptPath) => { + const selectedSlide = useMemo( + () => titleAndBody.find((s) => s.slide_index === selectedSlideIndex) || titleAndBody[0] || null, + [titleAndBody, selectedSlideIndex], + ) + + useEffect(() => { + localStorage.setItem('chatppt:selectedSlide', String(selectedSlideIndex)) + }, [selectedSlideIndex]) + + useEffect(() => { + if (!isResizing) return + + const onMove = (e) => { + const min = 300 + const max = Math.min(520, window.innerWidth * 0.45) + setLeftWidth(Math.max(min, Math.min(max, e.clientX - 32))) + } + + const onUp = () => setIsResizing(false) + + window.addEventListener('mousemove', onMove) + window.addEventListener('mouseup', onUp) + return () => { + window.removeEventListener('mousemove', onMove) + window.removeEventListener('mouseup', onUp) + } + }, [isResizing]) + + useEffect(() => { + if (!isDirty || !doc || !pptPath) return + clearTimeout(autosaveTimerRef.current) + autosaveTimerRef.current = setTimeout(() => { + saveManualEdits({ silent: true }) + }, 1500) + return () => clearTimeout(autosaveTimerRef.current) + }, [doc, isDirty, pptPath]) + + const loadPPT = async (path = pptPath, { keepSelection = true } = {}) => { setLoading(true) try { const data = await api(`/api/ppt?path=${encodeURIComponent(path)}`) setDoc(data) + if (keepSelection) { + setSelectedSlideIndex((prev) => Math.min(prev, Math.max(0, (data.slides?.length || 1) - 1))) + } else { + setSelectedSlideIndex(0) + } setPptPath(path) + setDirtySlides(new Set()) + setUndoStack([]) + setRedoStack([]) + setIsDirty(false) const preview = await api(`/api/ppt/preview?path=${encodeURIComponent(path)}`) setPreviewImages(preview.images || []) + setPreviewVersion(Date.now()) + } finally { + setLoading(false) + } + } + + const applyPresetImmediately = async (presetName) => { + setForm((f) => ({ ...f, preset: presetName })) + if (!pptPath) return + + setLoading(true) + try { + const data = await api('/api/theme/apply-preset', { + method: 'POST', + body: JSON.stringify({ path: pptPath, preset: presetName }), + }) + await loadPPT(data.output_path) + setNotice(`Preset switched to ${presetName} and applied immediately.`) + } catch (e) { + setNotice(`Apply preset failed: ${e.message}`) } finally { setLoading(false) } @@ -85,7 +172,7 @@ export default function App() { method: 'POST', body: JSON.stringify(payload), }) - await loadPPT(data.output_path) + await loadPPT(data.output_path, { keepSelection: false }) setNotice(`Generated and loaded: ${data.output_path} (Theme: ${data.theme.name})`) setMessages((m) => [ ...m, @@ -103,18 +190,43 @@ export default function App() { const updateText = (slideIndex, shapeIndex, value) => { setDoc((prev) => { + if (!prev) return prev + setUndoStack((s) => [...s, structuredClone(prev)]) + setRedoStack([]) const cloned = structuredClone(prev) const slide = cloned.slides.find((s) => s.slide_index === slideIndex) const shape = slide?.shapes.find((sh) => sh.shape_index === shapeIndex) - if (shape) shape.text = value + if (shape) { + shape.text = value + setDirtySlides((set0) => new Set([...set0, slideIndex])) + setIsDirty(true) + } return cloned }) } - const saveManualEdits = async () => { + const undo = () => { + if (!undoStack.length || !doc) return + const prev = undoStack[undoStack.length - 1] + setUndoStack((s) => s.slice(0, -1)) + setRedoStack((s) => [...s, structuredClone(doc)]) + setDoc(prev) + setIsDirty(true) + } + + const redo = () => { + if (!redoStack.length || !doc) return + const next = redoStack[redoStack.length - 1] + setRedoStack((s) => s.slice(0, -1)) + setUndoStack((s) => [...s, structuredClone(doc)]) + setDoc(next) + setIsDirty(true) + } + + const saveManualEdits = async ({ silent = false } = {}) => { if (!doc || !pptPath) return setLoading(true) - setNotice('') + if (!silent) setNotice('') try { const edits = [] for (const s of doc.slides) { @@ -127,7 +239,7 @@ export default function App() { body: JSON.stringify({ path: pptPath, edits }), }) await loadPPT(data.output_path) - setNotice(`Text edits saved: ${data.output_path}`) + setNotice(silent ? `Auto-saved at ${new Date().toLocaleTimeString()}` : `Text edits saved: ${data.output_path}`) } catch (e) { setNotice(e.message) } finally { @@ -161,6 +273,49 @@ export default function App() { } } + const copySlideText = async () => { + if (!selectedSlide) return + const text = selectedSlide.editableShapes.map((s) => `[${s.role}] ${s.text}`).join('\n') + await navigator.clipboard.writeText(text) + setNotice(`Slide ${selectedSlide.slide_index + 1} text copied`) + } + + const rewriteTitle = async () => { + if (!selectedSlide || !pptPath) return + const message = `Rewrite the title of slide ${selectedSlide.slide_index + 1} to be clearer and more professional, keep it concise.` + setLoading(true) + try { + const data = await api('/api/chat', { + method: 'POST', + body: JSON.stringify({ path: pptPath, message }), + }) + setMessages((m) => [...m, { role: 'assistant', text: `Rewrite title done (${data.used_llm ? 'LLM' : 'fallback'}).` }]) + await loadPPT(data.output_path) + } catch (e) { + setNotice(`Rewrite title failed: ${e.message}`) + } finally { + setLoading(false) + } + } + + const expandBullets = async () => { + if (!selectedSlide || !pptPath) return + const message = `Expand the key bullet points on slide ${selectedSlide.slide_index + 1} with more detail, keep structure clear.` + setLoading(true) + try { + const data = await api('/api/chat', { + method: 'POST', + body: JSON.stringify({ path: pptPath, message }), + }) + setMessages((m) => [...m, { role: 'assistant', text: `Expand bullets done (${data.used_llm ? 'LLM' : 'fallback'}).` }]) + await loadPPT(data.output_path) + } catch (e) { + setNotice(`Expand bullets failed: ${e.message}`) + } finally { + setLoading(false) + } + } + return (