Fix 5 post-Phase8 bugs: SSE crash, custom templates, ordering, persistence, JSON display

- outlines.py: Fix ECONNRESET/socket-hang-up — Depends session closes before
  StreamingResponse generator runs; capture presentation data upfront, use
  async_session_maker() inside inner() for the final DB commit (same pattern as Phase 4)
- useCustomTemplates.ts: Filter null-template items in summary map (crashed on
  presentations without a TemplateModel); use item.layout_count instead of hardcoded 0
- TemplateSelection.tsx: Move custom AI templates section above built-in templates
- presentationGeneration.ts + OutlinePage.tsx: Add selectedTemplateId to Redux so
  template selection persists when navigating away and back to /outline; clearOutlines
  also resets selectedTemplateId for new presentation flows
- DocumentPreviewPage.tsx: Detect JSON file content (table decomposition output) and
  convert to markdown table or pretty-printed code block before passing to MarkdownRenderer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-03-01 20:25:28 +00:00
parent df99af91ac
commit e360983249
6 changed files with 157 additions and 68 deletions

View file

@ -17,7 +17,7 @@ from models.sse_response import (
SSEStatusResponse,
)
from services.temp_file_service import TEMP_FILE_SERVICE
from services.database import get_async_session
from services.database import get_async_session, async_session_maker
from services.documents_loader import DocumentsLoader
from utils.llm_calls.generate_presentation_outlines import generate_ppt_outline
from utils.ppt_utils import get_presentation_title_from_outlines
@ -34,6 +34,20 @@ async def stream_outlines(
if not presentation:
raise HTTPException(status_code=404, detail="Presentation not found")
# Capture all data from the presentation before the endpoint returns
# (Depends session closes when endpoint function returns, before StreamingResponse runs)
presentation_id = presentation.id
presentation_content = presentation.content
presentation_file_paths = presentation.file_paths
presentation_n_slides = presentation.n_slides
presentation_language = presentation.language
presentation_tone = presentation.tone
presentation_verbosity = presentation.verbosity
presentation_instructions = presentation.instructions
presentation_include_title_slide = presentation.include_title_slide
presentation_include_table_of_contents = presentation.include_table_of_contents
presentation_web_search = presentation.web_search
temp_dir = TEMP_FILE_SERVICE.create_temp_dir()
async def inner():
@ -42,8 +56,8 @@ async def stream_outlines(
).to_string()
additional_context = ""
if presentation.file_paths:
documents_loader = DocumentsLoader(file_paths=presentation.file_paths)
if presentation_file_paths:
documents_loader = DocumentsLoader(file_paths=presentation_file_paths)
await documents_loader.load_documents(temp_dir)
documents = documents_loader.documents
if documents:
@ -51,23 +65,23 @@ async def stream_outlines(
presentation_outlines_text = ""
n_slides_to_generate = presentation.n_slides
if presentation.include_table_of_contents:
needed_toc_count = math.ceil((presentation.n_slides - 1) / 10)
n_slides_to_generate = presentation_n_slides
if presentation_include_table_of_contents:
needed_toc_count = math.ceil((presentation_n_slides - 1) / 10)
n_slides_to_generate -= math.ceil(
(presentation.n_slides - needed_toc_count) / 10
(presentation_n_slides - needed_toc_count) / 10
)
async for chunk in generate_ppt_outline(
presentation.content,
presentation_content,
n_slides_to_generate,
presentation.language,
presentation_language,
additional_context,
presentation.tone,
presentation.verbosity,
presentation.instructions,
presentation.include_title_slide,
presentation.web_search,
presentation_tone,
presentation_verbosity,
presentation_instructions,
presentation_include_title_slide,
presentation_web_search,
):
# Give control to the event loop
await asyncio.sleep(0)
@ -106,14 +120,22 @@ async def stream_outlines(
:n_slides_to_generate
]
presentation.outlines = presentation_outlines.model_dump()
presentation.title = get_presentation_title_from_outlines(presentation_outlines)
outlines_data = presentation_outlines.model_dump()
title = get_presentation_title_from_outlines(presentation_outlines)
sql_session.add(presentation)
await sql_session.commit()
yield SSECompleteResponse(
key="presentation", value=presentation.model_dump(mode="json")
).to_string()
# Use a fresh session for the DB write — Depends session is already closed
async with async_session_maker() as db:
pres = await db.get(PresentationModel, presentation_id)
if pres:
pres.outlines = outlines_data
pres.title = title
db.add(pres)
await db.commit()
await db.refresh(pres)
yield SSECompleteResponse(
key="presentation", value=pres.model_dump(mode="json")
).to_string()
else:
yield SSEErrorResponse(detail="Presentation not found after outline generation").to_string()
return StreamingResponse(inner(), media_type="text/event-stream")

View file

@ -191,6 +191,31 @@ const DocumentsPreviewPage: React.FC = () => {
}
}, [documentKeys]);
// Try to render JSON content as a readable markdown table/list
const formatJsonContent = (raw: string): string => {
try {
const parsed = JSON.parse(raw);
// Array of objects → markdown table
if (Array.isArray(parsed) && parsed.length > 0 && typeof parsed[0] === 'object') {
const keys = Object.keys(parsed[0]);
const header = `| ${keys.join(' | ')} |`;
const sep = `| ${keys.map(() => '---').join(' | ')} |`;
const rows = parsed.map((row: any) =>
`| ${keys.map((k) => String(row[k] ?? '')).join(' | ')} |`
);
return [header, sep, ...rows].join('\n');
}
// Object with table_data array
if (parsed && Array.isArray(parsed.table_data) && parsed.table_data.length > 0) {
return formatJsonContent(JSON.stringify(parsed.table_data));
}
// Fallback: pretty-print as code block
return '```json\n' + JSON.stringify(parsed, null, 2) + '\n```';
} catch {
return raw;
}
};
// Render helpers
const renderDocumentContent = () => {
if (!selectedDocument) return null;
@ -199,6 +224,10 @@ const DocumentsPreviewPage: React.FC = () => {
if (!isDocument) return null;
const rawContent = textContents[selectedDocument] || "";
const isJsonContent = rawContent.trimStart().startsWith('{') || rawContent.trimStart().startsWith('[');
const displayContent = isJsonContent ? formatJsonContent(rawContent) : rawContent;
return (
<div className="h-full mr-4">
<div className="overflow-y-auto custom_scrollbar h-full">
@ -208,7 +237,7 @@ const DocumentsPreviewPage: React.FC = () => {
<Skeleton className="w-full h-full" />
) : (
<MarkdownRenderer
content={textContents[selectedDocument] || ""}
content={displayContent}
/>
)}
</div>

View file

@ -1,9 +1,9 @@
"use client";
import React, { useState } from "react";
import React, { useState, useEffect } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { RootState } from "@/store/store";
import { useSelector } from "react-redux";
import { useSelector, useDispatch } from "react-redux";
import { OverlayLoader } from "@/components/ui/overlay-loader";
import Wrapper from "@/components/Wrapper";
import OutlineContent from "./OutlineContent";
@ -16,14 +16,34 @@ import { useOutlineManagement } from "../hooks/useOutlineManagement";
import { usePresentationGeneration } from "../hooks/usePresentationGeneration";
import TemplateSelection from "./TemplateSelection";
import { TemplateLayoutsWithSettings } from "@/app/presentation-templates/utils";
import { templates } from "@/app/presentation-templates";
import { setSelectedTemplateId } from "@/store/slices/presentationGeneration";
const OutlinePage: React.FC = () => {
const { presentation_id, outlines } = useSelector(
const dispatch = useDispatch();
const { presentation_id, outlines, selectedTemplateId } = useSelector(
(state: RootState) => state.presentationGeneration
);
const [activeTab, setActiveTab] = useState<string>(TABS.OUTLINE);
const [selectedTemplate, setSelectedTemplate] = useState<TemplateLayoutsWithSettings | string | null>(null);
// Restore selectedTemplate from Redux (persists across navigation)
// Custom templates are stored as string (templateId), built-ins as TemplateLayoutsWithSettings
const [selectedTemplate, setSelectedTemplate] = useState<TemplateLayoutsWithSettings | string | null>(() => {
if (!selectedTemplateId) return null;
// Check if it matches a built-in template
const builtin = templates.find((t) => t.id === selectedTemplateId);
if (builtin) return builtin;
// Otherwise it's a custom template id (string)
return selectedTemplateId;
});
const handleSelectTemplate = (template: TemplateLayoutsWithSettings | string) => {
setSelectedTemplate(template);
// Persist to Redux
const id = typeof template === 'string' ? template : template.id;
dispatch(setSelectedTemplateId(id));
};
// Custom hooks
const streamState = useOutlineStreaming(presentation_id);
const { handleDragEnd, handleAddSlide } = useOutlineManagement(outlines);
@ -33,6 +53,13 @@ const OutlinePage: React.FC = () => {
selectedTemplate,
setActiveTab
);
// Also sync if selectedTemplateId changes externally (e.g. after clearing)
useEffect(() => {
if (!selectedTemplateId) {
setSelectedTemplate(null);
}
}, [selectedTemplateId]);
if (!presentation_id) {
return <EmptyStateView />;
}
@ -75,7 +102,7 @@ const OutlinePage: React.FC = () => {
<div>
<TemplateSelection
selectedTemplate={selectedTemplate}
onSelectTemplate={setSelectedTemplate}
onSelectTemplate={handleSelectTemplate}
/>
</div>
</TabsContent>

View file

@ -41,6 +41,38 @@ const TemplateSelection: React.FC<TemplateSelectionProps> = ({
return (
<div className="space-y-8 mb-4">
{/* Custom AI Templates — shown first */}
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-semibold text-gray-900">Custom AI Templates</h3>
</div>
{customLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
<span className="ml-3 text-gray-600">Loading custom templates...</span>
</div>
) : customTemplates.length === 0 ? (
<Card className="p-8 text-center">
<p className="text-gray-500">No custom templates yet.</p>
<p className="text-sm text-gray-400 mt-2">
Custom templates you create will appear here.
</p>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{customTemplates.map((template: CustomTemplates) => (
<CustomTemplateCard
key={template.id}
template={template}
onSelectTemplate={onSelectTemplate}
selectedTemplate={typeof selectedTemplate === 'string' ? selectedTemplate : null}
/>
))}
</div>
)}
</div>
{/* In Built Templates */}
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-3">In Built Templates</h3>
@ -97,38 +129,6 @@ const TemplateSelection: React.FC<TemplateSelectionProps> = ({
})}
</div>
</div>
{/* Custom AI Templates */}
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-semibold text-gray-900">Custom AI Templates</h3>
</div>
{customLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
<span className="ml-3 text-gray-600">Loading custom templates...</span>
</div>
) : customTemplates.length === 0 ? (
<Card className="p-8 text-center">
<p className="text-gray-500">No custom templates yet.</p>
<p className="text-sm text-gray-400 mt-2">
Custom templates you create will appear here.
</p>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{customTemplates.map((template: CustomTemplates) => (
<CustomTemplateCard
key={template.id}
template={template}
onSelectTemplate={onSelectTemplate}
selectedTemplate={typeof selectedTemplate === 'string' ? selectedTemplate : null}
/>
))}
</div>
)}
</div>
</div>
);
};

View file

@ -264,14 +264,16 @@ export function useCustomTemplateSummaries() {
// }
// });
const mappedTemplates: CustomTemplates[] = data.presentations.map((item: any) => {
return {
id: item.template.id,
name: item.template.name || "Custom Template",
layoutCount: 0,
isCustom: true as const,
}
});
const mappedTemplates: CustomTemplates[] = data.presentations
.filter((item: any) => item.template != null)
.map((item: any) => {
return {
id: item.template.id,
name: item.template.name || "Custom Template",
layoutCount: item.layout_count ?? 0,
isCustom: true as const,
}
});
setTemplates(mappedTemplates);

View file

@ -23,6 +23,8 @@ interface PresentationGenerationState {
presentationData: PresentationData | null;
isSlidesRendered: boolean;
isLayoutLoading: boolean;
/** ID of the selected template (string = custom templateId, or built-in template id) */
selectedTemplateId: string | null;
}
const initialState: PresentationGenerationState = {
@ -34,6 +36,7 @@ const initialState: PresentationGenerationState = {
isStreaming: null,
error: null,
presentationData: null,
selectedTemplateId: null,
};
const presentationGenerationSlice = createSlice({
@ -55,6 +58,10 @@ const presentationGenerationSlice = createSlice({
state.presentation_id = action.payload;
state.error = null;
},
// Selected template
setSelectedTemplateId: (state, action: PayloadAction<string | null>) => {
state.selectedTemplateId = action.payload;
},
// Slides rendereimport { useEffect } from "react"d
setSlidesRendered: (state, action: PayloadAction<boolean>) => {
state.isSlidesRendered = action.payload;
@ -70,6 +77,7 @@ const presentationGenerationSlice = createSlice({
},
clearOutlines: (state) => {
state.outlines = [];
state.selectedTemplateId = null;
},
// Set outlines
setOutlines: (state, action: PayloadAction<{ content: string }[]>) => {
@ -392,6 +400,7 @@ export const {
deleteSlideOutline,
setPresentationData,
setOutlines,
setSelectedTemplateId,
// slides operations
addSlide,
updateSlide,