ppt-tool/frontend/store/slices/presentationGeneration.ts
Vadym Samoilenko e360983249 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>
2026-03-01 20:25:28 +00:00

415 lines
12 KiB
TypeScript

import { Slide } from "@/app/(presentation-generator)/types/slide";
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
export interface PresentationData {
id: string;
language: string;
layout: {
name: string;
ordered: boolean;
slides: any[];
};
n_slides: number;
title: string;
slides: any;
}
interface PresentationGenerationState {
presentation_id: string | null;
isLoading: boolean;
isStreaming: boolean | null;
outlines: { content: string }[];
error: string | null;
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 = {
presentation_id: null,
outlines: [],
isSlidesRendered: false,
isLayoutLoading: false,
isLoading: false,
isStreaming: null,
error: null,
presentationData: null,
selectedTemplateId: null,
};
const presentationGenerationSlice = createSlice({
name: "presentationGeneration",
initialState,
reducers: {
setStreaming: (state, action: PayloadAction<boolean>) => {
state.isStreaming = action.payload;
},
// Loading
setLoading: (state, action: PayloadAction<boolean>) => {
state.isLoading = action.payload;
},
setLayoutLoading: (state, action: PayloadAction<boolean>) => {
state.isLayoutLoading = action.payload;
},
// Presentation ID
setPresentationId: (state, action: PayloadAction<string>) => {
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;
},
// Error
setError: (state, action: PayloadAction<string>) => {
state.error = action.payload;
state.isLoading = false;
},
// Clear presentation data
clearPresentationData: (state) => {
state.presentationData = null;
},
clearOutlines: (state) => {
state.outlines = [];
state.selectedTemplateId = null;
},
// Set outlines
setOutlines: (state, action: PayloadAction<{ content: string }[]>) => {
state.outlines = action.payload;
},
// Set presentation data
setPresentationData: (state, action: PayloadAction<PresentationData>) => {
state.presentationData = action.payload;
},
deleteSlideOutline: (state, action: PayloadAction<{ index: number }>) => {
if (state.outlines) {
// Remove the slide at the given index
state.outlines = state.outlines.filter(
(_, idx) => idx !== action.payload.index
);
}
},
// SLIDE OPERATIONS
addSlide: (
state,
action: PayloadAction<{ slide: Slide; index: number }>
) => {
if (state.presentationData?.slides) {
// Insert the new slide at the specified index
state.presentationData.slides.splice(
action.payload.index,
0,
action.payload.slide
);
// Update indices for all slides to ensure they remain sequential
state.presentationData.slides = state.presentationData.slides.map(
(slide: any, idx: number) => ({
...slide,
index: idx,
})
);
}
},
deletePresentationSlide: (state, action: PayloadAction<number>) => {
if (state.presentationData) {
state.presentationData.slides.splice(action.payload, 1);
state.presentationData.slides = state.presentationData.slides.map(
(slide: any, idx: number) => ({
...slide,
index: idx,
})
);
}
},
updateSlide: (
state,
action: PayloadAction<{ index: number; slide: Slide }>
) => {
if (
state.presentationData &&
state.presentationData.slides[action.payload.index]
) {
state.presentationData.slides[action.payload.index] =
action.payload.slide;
}
},
// Update slide content at specific data path (for Tiptap text editing)
updateSlideContent: (
state,
action: PayloadAction<{
slideIndex: number;
dataPath: string;
content: string;
}>
) => {
if (
state.presentationData &&
state.presentationData.slides &&
state.presentationData.slides[action.payload.slideIndex]
) {
const slide = state.presentationData.slides[action.payload.slideIndex];
const { dataPath, content } = action.payload;
// Helper function to set nested property value
const setNestedValue = (obj: any, path: string, value: string) => {
const keys = path.split(/[.\[\]]+/).filter(Boolean);
let current = obj;
// Navigate to the parent object
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (isNaN(Number(key))) {
// String key
if (!current[key]) {
current[key] = {};
}
current = current[key];
} else {
// Array index
const index = Number(key);
if (!current[index]) {
current[index] = {};
}
current = current[index];
}
}
// Set the final value
const finalKey = keys[keys.length - 1];
if (isNaN(Number(finalKey))) {
current[finalKey] = value;
} else {
current[Number(finalKey)] = value;
}
};
// Update the slide content
if (dataPath && slide.content) {
setNestedValue(slide.content, dataPath, content);
}
}
},
addNewSlide: (state, action: PayloadAction<{ slideData: any; index: number }>) => {
if (state.presentationData?.slides) {
// Insert the new slide at the specified index + 1 (after current slide)
state.presentationData.slides.splice(action.payload.index + 1, 0, action.payload.slideData);
// Update indices for all slides to ensure they remain sequential
state.presentationData.slides = state.presentationData.slides.map(
(slide: any, idx: number) => ({
...slide,
index: idx,
})
);
}
},
// Update slide image at specific data path
updateSlideImage: (
state,
action: PayloadAction<{
slideIndex: number;
dataPath: string;
imageUrl: string;
prompt?: string;
}>
) => {
if (
state.presentationData &&
state.presentationData.slides &&
state.presentationData.slides[action.payload.slideIndex]
) {
const slide = state.presentationData.slides[action.payload.slideIndex];
const { dataPath, imageUrl, prompt } = action.payload;
// Helper function to set nested property value for images
const setNestedImageValue = (obj: any, path: string, url: string, promptText?: string) => {
const keys = path.split(/[.\[\]]+/).filter(Boolean);
let current = obj;
// Navigate to the parent object
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (isNaN(Number(key))) {
if (!current[key]) {
current[key] = {};
}
current = current[key];
} else {
const index = Number(key);
if (!current[index]) {
current[index] = {};
}
current = current[index];
}
}
// Set the image properties
const finalKey = keys[keys.length - 1];
const target = isNaN(Number(finalKey)) ? current[finalKey] : current[Number(finalKey)];
// Preserve existing properties if the target already exists
const updatedValue = {
...(target && typeof target === 'object' ? target : {}),
__image_url__: url,
__image_prompt__: promptText || (target?.__image_prompt__) || ''
};
if (isNaN(Number(finalKey))) {
current[finalKey] = updatedValue;
} else {
current[Number(finalKey)] = updatedValue;
}
};
// Update the slide image
if (dataPath && slide.content) {
setNestedImageValue(slide.content, dataPath, imageUrl, prompt);
}
// Also update the images array if it exists
if (slide.images && Array.isArray(slide.images)) {
const imageIndex = parseInt(dataPath.split('[')[1]?.split(']')[0]) || 0;
if (slide.images[imageIndex] !== undefined) {
slide.images[imageIndex] = imageUrl;
}
}
}
},
updateImageProperties: (
state,
action: PayloadAction<{
slideIndex: number;
itemIndex: number;
properties: any;
}>
) => {
if (
state.presentationData &&
state.presentationData.slides &&
state.presentationData.slides[action.payload.slideIndex]
) {
const slide = state.presentationData.slides[action.payload.slideIndex];
const { itemIndex, properties } = action.payload;
slide['properties'] = {
...slide.properties,
[itemIndex]: properties
};
}
},
// Update slide icon at specific data path
updateSlideIcon: (
state,
action: PayloadAction<{
slideIndex: number;
dataPath: string;
iconUrl: string;
query?: string;
}>
) => {
if (
state.presentationData &&
state.presentationData.slides &&
state.presentationData.slides[action.payload.slideIndex]
) {
const slide = state.presentationData.slides[action.payload.slideIndex];
const { dataPath, iconUrl, query } = action.payload;
// Helper function to set nested property value for icons
const setNestedIconValue = (obj: any, path: string, url: string, queryText?: string) => {
const keys = path.split(/[.\[\]]+/).filter(Boolean);
let current = obj;
// Navigate to the parent object
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (isNaN(Number(key))) {
if (!current[key]) {
current[key] = {};
}
current = current[key];
} else {
const index = Number(key);
if (!current[index]) {
current[index] = {};
}
current = current[index];
}
}
// Set the icon properties
const finalKey = keys[keys.length - 1];
const target = isNaN(Number(finalKey)) ? current[finalKey] : current[Number(finalKey)];
// Preserve existing properties if the target already exists
const updatedValue = {
...(target && typeof target === 'object' ? target : {}),
__icon_url__: url,
__icon_query__: queryText || (target?.__icon_query__) || ''
};
if (isNaN(Number(finalKey))) {
current[finalKey] = updatedValue;
} else {
current[Number(finalKey)] = updatedValue;
}
// Add debugging
console.log('Redux: Updated slide icon at path:', path, 'with URL:', url);
};
// Update the slide icon
if (dataPath && slide.content) {
setNestedIconValue(slide.content, dataPath, iconUrl, query);
}
// Also update the icons array if it exists
if (slide.icons && Array.isArray(slide.icons)) {
const iconIndex = parseInt(dataPath.split('[')[1]?.split(']')[0]) || 0;
if (slide.icons[iconIndex] !== undefined) {
slide.icons[iconIndex] = iconUrl;
}
}
}
},
},
});
export const {
setStreaming,
setLoading,
setLayoutLoading,
setPresentationId,
setSlidesRendered,
setError,
clearPresentationData,
clearOutlines,
deleteSlideOutline,
setPresentationData,
setOutlines,
setSelectedTemplateId,
// slides operations
addSlide,
updateSlide,
deletePresentationSlide,
updateSlideContent,
updateSlideImage,
updateImageProperties,
updateSlideIcon,
addNewSlide,
} = presentationGenerationSlice.actions;
export default presentationGenerationSlice.reducer;