feat(Nextjs): Outlines streaming added

This commit is contained in:
shiva raj badu 2025-07-16 02:28:08 +05:45
parent 97104eef5f
commit b048197f02
6 changed files with 234 additions and 121 deletions

View file

@ -11,20 +11,21 @@ import MarkdownEditor from "../../components/MarkdownEditor"
interface OutlineItemProps {
slideOutline: SlideOutline,
index: number
isStreaming: boolean
}
export function OutlineItem({
index,
slideOutline,
isStreaming,
}: OutlineItemProps) {
const {
presentation_id,
outlines,
} = useSelector((state: RootState) => state.presentationGeneration);
const dispatch = useDispatch()
const handleSlideChange = (newOutline: SlideOutline) => {
if (isStreaming) return;
const newData = outlines?.map((each, idx) => {
if (idx === index - 1) {
return newOutline
@ -45,7 +46,7 @@ export function OutlineItem({
transform,
transition,
isDragging,
} = useSortable({ id: slideOutline.title })
} = useSortable({ id: slideOutline.title || index })
const style = {
transform: CSS.Transform.toString(transform),
@ -54,6 +55,7 @@ export function OutlineItem({
const handleSlideDelete = () => {
if (isStreaming) return;
dispatch(deleteSlideOutline({ index: index - 1 }))
}
@ -83,19 +85,17 @@ export function OutlineItem({
{/* Main Title Input - Add onFocus handler */}
<div className="flex flex-col basis-full gap-2">
<input
type="text"
value={slideOutline.title}
value={slideOutline.title || ''}
onChange={(e) => handleSlideChange({ ...slideOutline, title: e.target.value })}
className="text-md sm:text-lg flex-1 font-semibold bg-transparent outline-none"
placeholder="Title goes here"
/>
{/* Editable Markdown Content */}
<MarkdownEditor
content={slideOutline.body}
content={slideOutline.body || ''}
onChange={(content) => handleSlideChange({ ...slideOutline, body: content })}
/>
</div>

View file

@ -24,6 +24,7 @@ import { toast } from "@/hooks/use-toast";
import {
setPresentationData,
setOutlines,
SlideOutline,
} from "@/store/slices/presentationGeneration";
import { OverlayLoader } from "@/components/ui/overlay-loader";
import Wrapper from "@/components/Wrapper";
@ -39,6 +40,7 @@ const OutlinePage = () => {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const { presentation_id, outlines } = useSelector(
(state: RootState) => state.presentationGeneration
);
@ -47,7 +49,6 @@ const OutlinePage = () => {
(state: RootState) => state.theme
);
const [loadingState, setLoadingState] = useState({
message: "",
isLoading: false,
@ -58,77 +59,100 @@ const OutlinePage = () => {
const [isStreaming, setStreaming] = useState<boolean>(false);
const [isLoading, setLoading] = useState<boolean>(true);
useEffect(() => {
let evtSource: EventSource;
let accumulatedChunks = "";
const fetchSlides = async () => {
setStreaming(true);
evtSource = new EventSource(
`/api/v1/ppt/outlines/stream?presentation_id=${presentation_id}`
);
evtSource.onopen = () => {
console.log('connection open');
};
setLoading(true);
evtSource.addEventListener("response", (event) => {
const data = JSON.parse(event.data);
console.log(data)
if (data.type === "chunk") {
accumulatedChunks += data.chunk;
try {
evtSource = new EventSource(
`/api/v1/ppt/outlines/stream?presentation_id=${presentation_id}`
);
// try {
// const repairedJson = jsonrepair(accumulatedChunks);
// const partialData = JSON.parse(repairedJson);
// if (partialData.slides) {
// // Check if the length of slides has changed
// if (
// partialData.slides.length !== previousSlidesLength.current &&
// partialData.slides.length > 1
// ) {
// partialData.slides.splice(-1);
evtSource.onopen = () => {
console.log('connection open');
};
// previousSlidesLength.current = partialData.slides.length + 1; // Update the previous length
// setLoading(false);
// }
// }
// } catch (error) {
// // console.error('error while repairing json', error)
// // It's okay if this fails, it just means the JSON isn't complete yet
// }
} else if (data.type === "complete") {
try {
evtSource.addEventListener("response", (event) => {
const data = JSON.parse(event.data);
if (data.type === "chunk") {
accumulatedChunks += data.chunk;
try {
const repairedJson = jsonrepair(accumulatedChunks);
const partialData = JSON.parse(repairedJson);
if (partialData.slides) {
dispatch(setOutlines(partialData.slides));
setLoading(false);
}
} catch (error) {
// It's okay if this fails, it just means the JSON isn't complete yet
}
} else if (data.type === "complete") {
try {
setLoading(false);
setStreaming(false);
const outlinesData: SlideOutline[] = JSON.parse(data.presentation).outlines;
dispatch(setOutlines(outlinesData));
evtSource.close();
} catch (error) {
evtSource.close();
console.error("Error parsing accumulated chunks:", error);
toast({
title: "Error",
description: "Failed to parse presentation data",
variant: "destructive",
});
}
accumulatedChunks = "";
} else if (data.type === "closing") {
setLoading(false);
setStreaming(false);
evtSource.close();
} catch (error) {
evtSource.close();
console.error("Error parsing accumulated chunks:", error);
}
accumulatedChunks = "";
} else if (data.type === "closing") {
});
evtSource.onerror = (error) => {
setLoading(false);
setStreaming(false);
evtSource.close();
}
});
evtSource.onerror = (error) => {
console.error("EventSource failed:", error);
toast({
title: "Connection Error",
description: "Failed to connect to the server. Please try again.",
variant: "destructive",
});
};
} catch (error) {
setLoading(false);
setStreaming(false);
evtSource.close();
};
toast({
title: "Error",
description: "Failed to initialize connection",
variant: "destructive",
});
}
};
fetchSlides();
}, [])
if (presentation_id) {
fetchSlides();
}
// Cleanup function
return () => {
if (evtSource) {
evtSource.close();
}
};
}, [presentation_id, dispatch]);
const handleDragEnd = (event: any) => {
const { active, over } = event;
@ -140,50 +164,55 @@ const OutlinePage = () => {
const oldIndex = outlines.findIndex((item) => item.title === active.id);
const newIndex = outlines.findIndex((item) => item.title === over.id);
// Create new array with reordered items and updated indices
// Reorder the array
const reorderedArray = arrayMove(outlines, oldIndex, newIndex);
// Update local state
setOutlines(reorderedArray);
// Update the store with new order
dispatch(setOutlines(reorderedArray));
}
};
const handleSubmit = async () => {
if (!outlines || outlines.length === 0) {
toast({
title: "No Outlines",
description: "Please wait for outlines to load before generating presentation",
variant: "destructive",
});
return;
}
// Generate data
setLoadingState({
message: "Generating data...",
message: "Generating presentation data...",
isLoading: true,
showProgress: false,
duration: 10,
showProgress: true,
duration: 30,
});
try {
const response = await PresentationGenerationApi.generateData({
const response = await PresentationGenerationApi.presentationPrepare({
presentation_id: presentation_id,
theme: {
name: currentTheme.toLocaleLowerCase(),
colors: currentColors,
},
outlines: outlines,
});
if (response) {
dispatch(setPresentationData(response));
router.push(
`/presentation?id=${presentation_id}&stream=true`
);
toast({
title: "Success",
description: "Presentation generated successfully!",
variant: "default",
});
router.push(`/presentation?id=${presentation_id}&stream=true`);
}
} catch (error) {
console.error("error in data generation", error);
toast({
title: "Error Adding Charts",
description: "Something went wrong, Try again",
title: "Generation Error",
description: "Failed to generate presentation. Please try again.",
variant: "destructive",
});
} finally {
@ -197,17 +226,33 @@ const OutlinePage = () => {
};
const handleAddSlide = () => {
if (!outlines) return;
const newSlide: SlideOutline = {
title: "New Slide",
body: "",
// Add any other required properties based on your SlideOutline type
};
// const newTitleWithCharts = [...outlines, { title: "New Slide", body: "" }];
// dispatch(setOutlines(newTitleWithCharts));
const updatedOutlines = [...outlines, newSlide];
setOutlines(updatedOutlines);
dispatch(setOutlines(updatedOutlines));
};
if (!presentation_id) {
return null;
return (
<Wrapper>
<div className="max-w-[1000px] mx-auto px-4 sm:px-6 pb-6">
<div className="mt-4 sm:mt-8 font-instrument_sans text-center">
<h4 className="text-lg sm:text-xl font-medium mb-4">
No Presentation ID Found
</h4>
<p className="text-gray-600">Please start a new presentation.</p>
</div>
</div>
</Wrapper>
);
}
return (
@ -219,41 +264,102 @@ const OutlinePage = () => {
duration={loadingState.duration}
/>
<div className="max-w-[1000px] mx-auto sm:px-6 pb-6">
<div className="max-w-[1000px] mx-auto px-4 sm:px-6 pb-6">
<div className="mt-4 sm:mt-8 font-instrument_sans relative">
<h4 className="text-lg sm:text-xl font-instrument_sans font-medium mb-4">
Outline
</h4>
{/* <div className="border p-2 sm:p-4 md:p-6 rounded-lg">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={outlines?.map((item) => ({ id: item.title })) || []}
strategy={verticalListSortingStrategy}
<div className="flex items-center justify-between mb-4">
<h4 className="text-lg sm:text-xl font-instrument_sans font-medium">
Outline
</h4>
{isStreaming && (
<div className="flex items-center text-sm text-blue-600">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600 mr-2"></div>
Generating outlines...
</div>
)}
</div>
{/* Skeleton loading state */}
{isLoading && (
<div className="space-y-4">
{[...Array(5)].map((_, index) => (
<div key={index} className="animate-pulse">
<div className="flex items-start space-x-3 p-3 sm:p-4 border rounded-lg">
<div className="w-6 h-6 bg-gray-200 rounded-full flex-shrink-0"></div>
<div className="flex-1 space-y-2">
<div className="h-5 bg-gray-200 rounded w-3/4"></div>
<div className="space-y-1">
<div className="h-4 bg-gray-100 rounded w-full"></div>
<div className="h-4 bg-gray-100 rounded w-5/6"></div>
<div className="h-4 bg-gray-100 rounded w-4/6"></div>
</div>
</div>
<div className="w-5 h-5 bg-gray-200 rounded flex-shrink-0"></div>
</div>
</div>
))}
<div className="w-full mt-4 h-10 bg-gray-200 rounded-[32px] animate-pulse"></div>
</div>
)}
{/* Outlines content */}
{outlines && outlines.length > 0 && (
<div className="border p-2 sm:p-4 md:p-6 rounded-lg">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
{outlines?.map((item, index) => (
<OutlineItem key={item.title} index={index + 1} slideOutline={item} />
))}
</SortableContext>
</DndContext>
<Button
variant="outline"
onClick={handleAddSlide}
className={`w-full mt-4 text-[#9034EA] border-[#9034EA] rounded-[32px] `}
>
+ Add Slide
</Button>
</div> */}
<SortableContext
items={outlines?.map((item, index) => ({ id: item.title || `slide-${index}` })) || []}
strategy={verticalListSortingStrategy}
>
{outlines?.map((item, index) => (
<OutlineItem
key={item.title || `slide-${index}`}
index={index + 1}
slideOutline={item}
isStreaming={isStreaming}
/>
))}
</SortableContext>
</DndContext>
<Button
variant="outline"
onClick={handleAddSlide}
disabled={isLoading || isStreaming}
className="w-full mt-4 text-[#9034EA] border-[#9034EA] rounded-[32px] hover:bg-[#9034EA]/10"
>
+ Add Slide
</Button>
</div>
)}
{/* Empty state */}
{!isLoading && outlines && outlines.length === 0 && (
<div className="text-center py-8">
<p className="text-gray-600 mb-4">No outlines available</p>
<Button
variant="outline"
onClick={handleAddSlide}
className="text-[#9034EA] border-[#9034EA] rounded-[32px]"
>
+ Add First Slide
</Button>
</div>
)}
</div>
<Button
disabled={loadingState.isLoading}
{/* Generate button */}
{!isStreaming && <Button
disabled={loadingState.isLoading || isLoading || isStreaming || !outlines || outlines.length === 0}
onClick={handleSubmit}
className="bg-[#5146E5] w-full rounded-[32px] text-base sm:text-lg py-4 sm:py-6 transition-all duration-300 font-roboto font-semibold hover:bg-[#5146E5]/80 text-white mt-4"
className="bg-[#5146E5] w-full rounded-[32px] text-base sm:text-lg py-4 sm:py-6 transition-all duration-300 font-roboto font-semibold hover:bg-[#5146E5]/80 text-white mt-4 disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg
className="mr-2"
width="24"
height="24"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 25 25"
fill="none"
@ -277,11 +383,13 @@ const OutlinePage = () => {
</svg>
{loadingState.isLoading
? loadingState.message
: "Generate Presentation"}
</Button>
: isLoading || isStreaming
? "Loading..."
: "Generate Presentation"}
</Button>}
</div>
</Wrapper>
);
};
export default OutlinePage;
export default OutlinePage;

View file

@ -131,7 +131,7 @@ const PresentationPage = ({ presentation_id }: { presentation_id: string }) => {
dispatch(setStreaming(true));
evtSource = new EventSource(
`/api/v1/ppt/generate/stream?presentation_id=${presentation_id}`
`/api/v1/ppt/presentation/stream?presentation_id=${presentation_id}`
);
evtSource.onopen = () => {
@ -147,6 +147,7 @@ const PresentationPage = ({ presentation_id }: { presentation_id: string }) => {
try {
const repairedJson = jsonrepair(accumulatedChunks);
const partialData = JSON.parse(repairedJson);
console.log(partialData);
if (partialData.slides) {
// Check if the length of slides has changed
if (

View file

@ -195,10 +195,10 @@ export class PresentationGenerationApi {
}
}
static async generateData(presentationData: any) {
static async presentationPrepare(presentationData: any) {
try {
const response = await fetch(
`/api/v1/ppt/generate/data`,
`/api/v1/ppt/presentation/prepare`,
{
method: "POST",
headers: getHeader(),

View file

@ -22,6 +22,10 @@ const bulletPointSlideSchema = z.object({
export const Schema = bulletPointSlideSchema
console.log(zodToJsonSchema(Schema, {
removeAdditionalStrategy: 'strict',
}))
export type BulletPointSlideData = z.infer<typeof bulletPointSlideSchema>
interface BulletPointSlideLayoutProps {

View file

@ -18,8 +18,8 @@ export interface ChartSettings {
}
export interface SlideOutline {
title: string;
body: string;
title?: string;
body?: string;
}
export interface Chart {
@ -48,7 +48,7 @@ export interface PresentationData {
theme: string | null;
title: string;
titles: string[];
vector_store: string | null;
thumbnail: string | null;
language: string;
} | null;