feat(Nextjs): Outlines streaming added
This commit is contained in:
parent
97104eef5f
commit
b048197f02
6 changed files with 234 additions and 121 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue