From 64cdac963b5f352b0f5d7dcb9fbfa20b325f70dc Mon Sep 17 00:00:00 2001 From: sudipnext Date: Mon, 27 Apr 2026 17:21:25 +0545 Subject: [PATCH] chore: Update llmai dependency to version 0.2.2 and enhance chat prompt clarity - Updated the llmai dependency from version 0.2.1 to 0.2.2 for improved functionality. - Revised the system prompt in the chat service to clarify the use of context and tools, emphasizing the importance of accurate responses based on live data. - Enhanced the chat UI to better reflect tool usage and assistant activities, improving user experience. --- servers/fastapi/pyproject.toml | 2 +- servers/fastapi/services/chat/prompts.py | 51 +- servers/fastapi/services/chat/service.py | 5 +- servers/fastapi/utils/llm_utils.py | 39 +- servers/fastapi/uv.lock | 8 +- .../presentation/components/Chat.tsx | 447 +++++++++++------- .../components/PresentationPage.tsx | 5 +- .../hooks/PresentationUndoRedo.ts | 202 ++++---- .../presentation/hooks/usePresentationData.ts | 6 +- servers/nextjs/store/slices/undoRedoSlice.ts | 59 +-- 10 files changed, 468 insertions(+), 356 deletions(-) diff --git a/servers/fastapi/pyproject.toml b/servers/fastapi/pyproject.toml index 2c646c0b..adea8364 100644 --- a/servers/fastapi/pyproject.toml +++ b/servers/fastapi/pyproject.toml @@ -25,7 +25,7 @@ dependencies = [ "pdfplumber>=0.11.7", "python-pptx>=1.0.2", "sqlmodel>=0.0.24", - "llmai==0.2.1", + "llmai==0.2.2", "jsonschema>=4.26.0", ] diff --git a/servers/fastapi/services/chat/prompts.py b/servers/fastapi/services/chat/prompts.py index ad700576..bb368fc9 100644 --- a/servers/fastapi/services/chat/prompts.py +++ b/servers/fastapi/services/chat/prompts.py @@ -20,21 +20,44 @@ def build_system_prompt( return ( "You are Presenton's slide assistant. Be concise, accurate, and action-oriented.\n" "\n" - "How to use context vs tools\n" - "- RAG / deck memory (the block above labeled Deck memory) is the right place to ground answers about: " - "the uploaded file or its themes, the outline that was generated, planning intent, and source material. " - "It is **not** authoritative for the exact text or order of slides as they exist **right now**.\n" - "- For anything about **current** slides (what a slide actually says, layout, which slide is which, or edits to apply), " - "use tools. After tools run, their results **override** conflicting deck memory.\n" - "- If the user asks about **documents or outlines** (e.g. \"what was in the PDF\", \"original outline\"), search deck memory first; " - "if they ask about **what is on slide N** or to **change a slide**, use getPresentationOutline / searchSlides / getSlideAtIndex, not memory alone.\n" + "Operating priorities\n" + "1) Complete the user's intent with the fewest reliable tool calls.\n" + "2) Prefer verified deck state over assumptions.\n" + "3) Keep responses short and concrete.\n" "\n" - "Tool use (live SQL slide data)\n" - "Treat user slide numbers as 1-based; tool slide indexes are 0-based. " - "Use compact reads first: getPresentationOutline, searchSlides, then getSlideAtIndex; " - "set includeFullContent=true only when you need full JSON (usually right before saveSlide). " - "Before saving, inspect layout/schema, batch images/icons with generateAssets, then saveSlide with valid JSON. " - "Do not invent deck facts. When finished with tools, stop calling them and answer briefly with what changed or what you found.\n" + "Source-of-truth policy\n" + "- Tool outputs from this turn are authoritative for live deck state.\n" + "- Conversation context (user constraints, prior decisions) is next.\n" + "- Deck memory is background context and may be partial or stale.\n" + "- If sources conflict, trust tools over memory.\n" + "\n" + "When to use memory vs tools\n" + "- Use deck memory for uploaded-document meaning, original outline intent, and planning rationale.\n" + "- Use tools for anything about current slides: exact text, ordering, layout, slide identity, and edits.\n" + "- If user asks what is currently on slide N or asks for a change, do not rely on memory alone.\n" + "\n" + "Tool-use protocol (live SQL slide data)\n" + "- User slide numbers are 1-based; tool indexes are 0-based.\n" + "- Start with compact reads: getPresentationOutline -> searchSlides -> getSlideAtIndex.\n" + "- Set includeFullContent=true only when full JSON is required (typically right before saveSlide).\n" + "- Before saveSlide, validate target layout/schema (getAvailableLayouts, getContentSchemaFromLayoutId).\n" + "- Generate required assets in batch with generateAssets before saving.\n" + "- saveSlide payload must match the schema exactly; do not invent fields.\n" + "- If a tool fails, report it briefly and choose the best next step.\n" + "\n" + "Autonomous decision policy (default behavior)\n" + "- For edit requests, execute the best reasonable implementation without asking for optional preferences.\n" + "- Do not ask the user to choose among layouts/assets unless the user explicitly asks to choose.\n" + "- If visual details are unspecified (image style, icon set, exact layout), infer from slide content and deck theme.\n" + "- For requests like 'add images/icons' or 'make it better', pick a layout that best preserves existing intent and readability, then apply it.\n" + "- Ask a clarification only when blocked by a required missing fact (e.g., target slide is ambiguous, conflicting constraints, or missing required data).\n" + "- When in doubt, prefer a professional, neutral visual style and continue.\n" + "\n" + "Response policy\n" + "- Never invent slide facts, tool results, or document claims.\n" + "- If information is missing, run the right tool or ask one focused clarification.\n" + "- After enough evidence is collected, stop calling tools and provide a brief final answer.\n" + "- For edits, apply changes first, then report what changed and where; for lookups, state what you found.\n" f"{presentation_block}" f"{chat_block}" ) diff --git a/servers/fastapi/services/chat/service.py b/servers/fastapi/services/chat/service.py index 9d5e0411..086cc425 100644 --- a/servers/fastapi/services/chat/service.py +++ b/servers/fastapi/services/chat/service.py @@ -12,6 +12,7 @@ from llmai.shared import ( # type: ignore[import-not-found] AssistantMessage, Message, SystemMessage, + TextContentPart, ToolResponseMessage, UserMessage, ) @@ -166,7 +167,7 @@ class PresentationChatService: messages.append( ToolResponseMessage( id=tool_call.id, - content=[tool_response_content], + content=[TextContentPart(text=tool_response_content)], ) ) continue @@ -310,7 +311,7 @@ class PresentationChatService: messages.append( ToolResponseMessage( id=tool_call.id, - content=[tool_response_content], + content=[TextContentPart(text=tool_response_content)], ) ) diff --git a/servers/fastapi/utils/llm_utils.py b/servers/fastapi/utils/llm_utils.py index c10a7341..027decc9 100644 --- a/servers/fastapi/utils/llm_utils.py +++ b/servers/fastapi/utils/llm_utils.py @@ -10,8 +10,42 @@ from llmai.shared import ( ResponseFormat, normalize_content_parts, ) +from llmai.shared.tools import Tool # type: ignore[import-not-found] +from pydantic import BaseModel +from enums.llm_provider import LLMProvider from utils.llm_config import get_extra_body +from utils.llm_provider import get_llm_provider +from utils.schema_utils import flatten_json_schema + + +def _tools_for_google_gemini(tools: list[LLMTool]) -> list[LLMTool]: + """Gemini's Python SDK rejects ``$ref`` / ``$defs`` in function parameters; inline them.""" + converted: list[LLMTool] = [] + for tool in tools: + if not isinstance(tool, Tool): + converted.append(tool) + continue + schema_obj = tool.input_schema + if isinstance(schema_obj, dict): + raw = dict(schema_obj) + elif isinstance(schema_obj, type) and issubclass(schema_obj, BaseModel): + raw = schema_obj.model_json_schema() + elif isinstance(schema_obj, BaseModel): + raw = schema_obj.__class__.model_json_schema() + else: + converted.append(tool) + continue + flat = flatten_json_schema(raw) + converted.append( + Tool( + name=tool.name, + description=tool.description, + schema=flat, + strict=tool.strict, + ) + ) + return converted def get_generate_kwargs( @@ -30,7 +64,10 @@ def get_generate_kwargs( if max_tokens is not None: kwargs["max_tokens"] = max_tokens if tools: - kwargs["tools"] = tools + if get_llm_provider() == LLMProvider.GOOGLE: + kwargs["tools"] = _tools_for_google_gemini(tools) + else: + kwargs["tools"] = tools if response_format is not None: kwargs["response_format"] = response_format diff --git a/servers/fastapi/uv.lock b/servers/fastapi/uv.lock index 83607c2b..68c4a2dc 100644 --- a/servers/fastapi/uv.lock +++ b/servers/fastapi/uv.lock @@ -1185,7 +1185,7 @@ wheels = [ [[package]] name = "llmai" -version = "0.2.1" +version = "0.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anthropic" }, @@ -1193,9 +1193,9 @@ dependencies = [ { name = "google-genai" }, { name = "openai" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f1/34/1d4188bd842f336726004896be9ae04b693b9c21d349918631433b9f1b63/llmai-0.2.1.tar.gz", hash = "sha256:f911bd7df3eb06d1c56612ce293f926df7b3bf6c36283a353cf780c697d39d31", size = 47862, upload-time = "2026-04-24T16:28:52.417Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/28/7dc14f9a417d933f8c799665a0a86ee31489dde40e9264ef6dc41b32759c/llmai-0.2.2.tar.gz", hash = "sha256:1f62d1e3d05fa5c43bbd948275398668d8f85c8d2fde252d34562332101dd7b3", size = 47863, upload-time = "2026-04-27T10:32:08.726Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/45/7d/63b3da1be92b721b0681db5b9e96c1cbc000b63cb70ede40b20cc5302699/llmai-0.2.1-py3-none-any.whl", hash = "sha256:4c51d1186cce1e621f74a9ec70376dc1bd2e996eee484db17dce6a6e7b79a0a7", size = 59880, upload-time = "2026-04-24T16:28:50.706Z" }, + { url = "https://files.pythonhosted.org/packages/b0/a1/e34d8aaa015fd7a2cfefa391b6eb2995aee675d7f8d93f636f4da8b07c13/llmai-0.2.2-py3-none-any.whl", hash = "sha256:d65c016983036319df704927b5e7fece494efde25b4865caf9a95d555a25449c", size = 59874, upload-time = "2026-04-27T10:32:07.272Z" }, ] [[package]] @@ -1686,7 +1686,7 @@ requires-dist = [ { name = "fastmcp", specifier = ">=2.11.0" }, { name = "google-genai", specifier = ">=1.28.0" }, { name = "jsonschema", specifier = ">=4.26.0" }, - { name = "llmai", specifier = "==0.2.1" }, + { name = "llmai", specifier = "==0.2.2" }, { name = "mem0ai", extras = ["nlp"], specifier = ">=0.1.115" }, { name = "nltk", specifier = ">=3.9.1" }, { name = "openai", specifier = ">=1.98.0" }, diff --git a/servers/nextjs/app/(presentation-generator)/presentation/components/Chat.tsx b/servers/nextjs/app/(presentation-generator)/presentation/components/Chat.tsx index 54298739..19a44aaf 100644 --- a/servers/nextjs/app/(presentation-generator)/presentation/components/Chat.tsx +++ b/servers/nextjs/app/(presentation-generator)/presentation/components/Chat.tsx @@ -1,18 +1,14 @@ "use client"; import { - Activity, - CheckCircle2, ChevronDown, ChevronRight, - ChevronUp, - CircleDot, Loader2, MessageCircleMore, Plus, RefreshCw, Send, - XCircle, + Square, } from "lucide-react"; import React, { FormEvent, @@ -266,11 +262,94 @@ const AssistantMarker = () => ( ); +const TOOL_LABELS: Record = { + getPresentationOutline: "Outline reader", + searchSlides: "Slide search", + getSlideAtIndex: "Slide reader", + getAvailableLayouts: "Layout finder", + getContentSchemaFromLayoutId: "Schema checker", + generateAssets: "Asset generator", + saveSlide: "Slide saver", +}; + +const getToolLabel = (tool?: string) => { + if (!tool) { + return ""; + } + return TOOL_LABELS[tool] ?? tool; +}; + +const humanizeTraceMessage = (message: string, tool?: string) => { + const trimmed = message.trim(); + if (!trimmed) { + return ""; + } + + const lower = trimmed.toLowerCase(); + if (lower === "reading deck context") { + return "Reviewing your presentation context."; + } + if (lower === "reading the presentation outline") { + return "Reading the presentation outline."; + } + if (lower === "searching relevant slides") { + return "Searching slides for relevant content."; + } + if (lower === "opening the requested slide") { + return "Opening the selected slide."; + } + if (lower === "checking available layouts") { + return "Checking available layouts."; + } + if (lower === "checking the layout schema") { + return "Validating the slide schema."; + } + if (lower === "generating slide assets") { + return "Generating images and icons."; + } + if (lower === "saving the slide") { + return "Saving slide updates."; + } + if (lower.startsWith("using tools:")) { + const toolNames = trimmed + .slice("using tools:".length) + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean) + .map((entry) => getToolLabel(entry)); + if (toolNames.length === 0) { + return "Planning tool steps."; + } + return `Planning tools: ${toolNames.join(", ")}.`; + } + if (lower.includes("found requested data")) { + if (tool === "getSlideAtIndex") { + return "Found the requested slide details."; + } + if (tool === "getPresentationOutline") { + return "Found the requested outline details."; + } + return "Found the requested information."; + } + if (lower.endsWith("completed.")) { + return trimmed; + } + if (lower.includes("failed")) { + return trimmed; + } + return trimmed; +}; + const inferStatusState = (status: string): AssistantActivity["state"] => { const normalized = status.trim().toLowerCase(); if ( normalized.includes("preparing") || normalized.includes("thinking") || + normalized.includes("reading") || + normalized.includes("searching") || + normalized.includes("opening") || + normalized.includes("generating") || + normalized.includes("processing") || normalized.includes("finalizing") || normalized.includes("saving") ) { @@ -280,12 +359,18 @@ const inferStatusState = (status: string): AssistantActivity["state"] => { return "info"; }; +const isAbortError = (error: unknown) => + (error instanceof DOMException && error.name === "AbortError") || + (error instanceof Error && + error.message.toLowerCase().includes("aborted") && + error.message.toLowerCase().includes("request")); + const formatTraceActivity = ( trace: ChatStreamTrace ): Omit | null => { if (typeof trace.message === "string" && trace.message.trim().length > 0) { return { - label: trace.message.trim(), + label: humanizeTraceMessage(trace.message, trace.tool), kind: trace.kind, round: trace.round, tool: trace.tool, @@ -302,7 +387,7 @@ const formatTraceActivity = ( if (trace.tool && trace.status === "start") { return { - label: `Running ${trace.tool}`, + label: `Running ${getToolLabel(trace.tool)}...`, kind: trace.kind, round: trace.round, tool: trace.tool, @@ -312,7 +397,7 @@ const formatTraceActivity = ( if (trace.tool && trace.status === "success") { return { - label: `${trace.tool} completed`, + label: `${getToolLabel(trace.tool)} completed.`, kind: trace.kind, round: trace.round, tool: trace.tool, @@ -322,7 +407,7 @@ const formatTraceActivity = ( if (trace.tool && trace.status === "error") { return { - label: `${trace.tool} failed`, + label: `${getToolLabel(trace.tool)} failed.`, kind: trace.kind, round: trace.round, tool: trace.tool, @@ -332,7 +417,7 @@ const formatTraceActivity = ( if (trace.kind === "tool_plan" && Array.isArray(trace.tools) && trace.tools.length) { return { - label: `Using tools: ${trace.tools.join(", ")}`, + label: `Planning tools: ${trace.tools.map((tool) => getToolLabel(tool)).join(", ")}.`, kind: trace.kind, round: trace.round, state: "info", @@ -342,75 +427,6 @@ const formatTraceActivity = ( return null; }; -const ActivityIcon = ({ state }: { state: AssistantActivity["state"] }) => { - if (state === "running") { - return ; - } - - if (state === "success") { - return ; - } - - if (state === "error") { - return ; - } - - return ; -}; - -const getActivityHeading = (activity: AssistantActivity[]) => { - if (activity.some((item) => item.state === "running")) { - return "Thinking"; - } - - if (activity.some((item) => item.state === "error")) { - return "Needs attention"; - } - - return "Thought process"; -}; - -const getActivitySummary = (activity: AssistantActivity[]) => { - return activity[activity.length - 1]?.label ?? "Working through the request"; -}; - -const ActivityTimeline = ({ activity }: { activity: AssistantActivity[] }) => ( -
-
- {activity.map((activityItem, index) => ( -
- {index < activity.length - 1 && ( - - )} - - - -
-
- {activityItem.tool && ( - - {activityItem.tool} - - )} - {activityItem.round && ( - - step {activityItem.round} - - )} -
-

- {activityItem.label} -

-
-
- ))} -
-
-); - const Chat = ({ presentationId, currentSlide, @@ -421,6 +437,9 @@ const Chat = ({ const [conversationId, setConversationId] = useState(null); const [isHistoryLoading, setIsHistoryLoading] = useState(false); const [isSending, setIsSending] = useState(false); + const [activeAssistantMessageId, setActiveAssistantMessageId] = useState< + string | null + >(null); const [errorMessage, setErrorMessage] = useState(null); const [expandedActivityByMessage, setExpandedActivityByMessage] = useState< Record @@ -428,12 +447,17 @@ const Chat = ({ const inputRef = useRef(null); const messagesEndRef = useRef(null); + const abortControllerRef = useRef(null); useEffect(() => { let cancelled = false; + abortControllerRef.current?.abort(); + abortControllerRef.current = null; setMessages([]); setInput(""); setConversationId(null); + setIsSending(false); + setActiveAssistantMessageId(null); setErrorMessage(null); setExpandedActivityByMessage({}); @@ -617,28 +641,6 @@ const Chat = ({ ); }; - const settleAssistantActivities = ( - assistantMessageId: string, - finalState: "success" | "error" - ) => { - setMessages((previous) => - previous.map((message) => { - if (message.id !== assistantMessageId || !message.activity?.length) { - return message; - } - - return { - ...message, - activity: message.activity.map((activityItem) => - activityItem.state === "running" - ? { ...activityItem, state: finalState } - : activityItem - ), - }; - }) - ); - }; - const toggleActivityExpanded = (messageId: string) => { setExpandedActivityByMessage((previous) => ({ ...previous, @@ -646,6 +648,10 @@ const Chat = ({ })); }; + const stopStreaming = () => { + abortControllerRef.current?.abort(); + }; + const submitMessage = async (rawMessage: string) => { const trimmedMessage = rawMessage.trim(); @@ -678,11 +684,14 @@ const Chat = ({ ]); setExpandedActivityByMessage((previous) => ({ ...previous, - [assistantMessageId]: true, + [assistantMessageId]: false, })); setInput(""); setErrorMessage(null); setIsSending(true); + setActiveAssistantMessageId(assistantMessageId); + const streamAbortController = new AbortController(); + abortControllerRef.current = streamAbortController; try { const response = await PresentationChatApi.streamMessage( @@ -717,7 +726,8 @@ const Chat = ({ } appendAssistantActivity(assistantMessageId, traceActivity); }, - } + }, + { signal: streamAbortController.signal } ); setMessages((previous) => @@ -726,14 +736,17 @@ const Chat = ({ ? { ...message, content: response.response, - toolCalls: Array.isArray(response.tool_calls) - ? response.tool_calls - : [], + toolCalls: [], + activity: [], } : message ) ); - settleAssistantActivities(assistantMessageId, "success"); + setExpandedActivityByMessage((previous) => { + const next = { ...previous }; + delete next[assistantMessageId]; + return next; + }); setConversationId((previous) => { const next = typeof response.conversation_id === "string" @@ -756,13 +769,44 @@ const Chat = ({ Array.isArray(response.tool_calls) ? response.tool_calls : [] ); } catch (error) { + if (isAbortError(error)) { + setMessages((previous) => + previous.map((message) => + message.id === assistantMessageId + ? { + ...message, + toolCalls: [], + activity: [], + } + : message + ) + ); + setExpandedActivityByMessage((previous) => { + const next = { ...previous }; + delete next[assistantMessageId]; + return next; + }); + return; + } + const message = error instanceof Error ? error.message : "Failed to send chat message"; - settleAssistantActivities(assistantMessageId, "error"); - appendAssistantActivity(assistantMessageId, { - label: message, - state: "error", + setMessages((previous) => + previous.map((entry) => + entry.id === assistantMessageId + ? { + ...entry, + toolCalls: [], + activity: [], + } + : entry + ) + ); + setExpandedActivityByMessage((previous) => { + const next = { ...previous }; + delete next[assistantMessageId]; + return next; }); setErrorMessage(message); setMessages((previous) => [ @@ -775,6 +819,12 @@ const Chat = ({ ]); toast.error(message); } finally { + if (abortControllerRef.current === streamAbortController) { + abortControllerRef.current = null; + } + setActiveAssistantMessageId((current) => + current === assistantMessageId ? null : current + ); setIsSending(false); } }; @@ -800,26 +850,34 @@ const Chat = ({ return (
-

- - AI Assistant -

+
+

+ + AI Assistant +

+ {isSending && ( + + + Live + + )} +
+ {expandedActivityByMessage[message.id] && ( - +
+ {message.activity.map((activityItem) => ( +
+ {activityItem.tool && ( + + {getToolLabel(activityItem.tool)}: + + )} + {activityItem.label} +
+ ))} + {message.toolCalls && message.toolCalls.length > 0 && ( +
+ Tools called: {message.toolCalls.join(", ")} +
+ )} +
)}
)} - {message.toolCalls && message.toolCalls.length > 0 && ( -
- {message.toolCalls.map((toolCall) => ( - - Used {toolCall} - - ))} -
- )}
) )} @@ -1000,23 +1064,42 @@ const Chat = ({ > - + + + ) : ( + + Send + + )} ); diff --git a/servers/nextjs/app/(presentation-generator)/presentation/components/PresentationPage.tsx b/servers/nextjs/app/(presentation-generator)/presentation/components/PresentationPage.tsx index 50577490..2d7ed610 100644 --- a/servers/nextjs/app/(presentation-generator)/presentation/components/PresentationPage.tsx +++ b/servers/nextjs/app/(presentation-generator)/presentation/components/PresentationPage.tsx @@ -21,7 +21,6 @@ import { PresentationPageProps } from "../types"; import LoadingState from "./LoadingState"; import { applyPresentationThemeToElement } from "../utils/applyPresentationThemeDom"; -import { usePresentationUndoRedo } from "../hooks/PresentationUndoRedo"; import PresentationHeader from "./PresentationHeader"; import Chat from "./Chat"; @@ -85,8 +84,6 @@ const PresentationPage: React.FC = ({ fetchUserSlides ); - usePresentationUndoRedo(); - useEffect(() => { if (!isStreaming) return; @@ -242,7 +239,7 @@ const PresentationPage: React.FC = ({ fetchUserSlides({ clearHistory: false })} /> diff --git a/servers/nextjs/app/(presentation-generator)/presentation/hooks/PresentationUndoRedo.ts b/servers/nextjs/app/(presentation-generator)/presentation/hooks/PresentationUndoRedo.ts index f2156262..72e6c24d 100644 --- a/servers/nextjs/app/(presentation-generator)/presentation/hooks/PresentationUndoRedo.ts +++ b/servers/nextjs/app/(presentation-generator)/presentation/hooks/PresentationUndoRedo.ts @@ -1,136 +1,100 @@ import { useCallback } from "react"; import { useDispatch, useSelector } from "react-redux"; import { RootState } from "@/store/store"; -import { finishUndoRedo, redo, undo } from "@/store/slices/undoRedoSlice"; +import { redo, undo } from "@/store/slices/undoRedoSlice"; import { useKeyboardShortcut } from "../../hooks/use-keyboard-shortcut"; import { setPresentationData } from "@/store/slices/presentationGeneration"; - - - - export const usePresentationUndoRedo = () => { - const dispatch = useDispatch(); - const undoRedoState = useSelector((state: RootState) => state.undoRedo); - const { presentationData } = useSelector((state: RootState) => state.presentationGeneration); - - const canUndo = undoRedoState.past.length > 0; - const canRedo = undoRedoState.future.length > 0; - - const onUndo = useCallback(() => { - if (!canUndo) return; - - const previousState = undoRedoState.past[undoRedoState.past.length - 1]; - - dispatch(undo()); - - if (previousState) { - const newSlides = JSON.parse(JSON.stringify(previousState.slides)); - - dispatch( - setPresentationData({ - ...presentationData!, - slides: newSlides, - }) - ); - } - - setTimeout(() => { - dispatch(finishUndoRedo()); - }, 100); - }, [canUndo, dispatch, presentationData, undoRedoState.past]); - - const onRedo = useCallback(() => { - if (!canRedo) return; - - const nextState = undoRedoState.future[0]; - - dispatch(redo()); - - if (nextState) { - const newSlides = JSON.parse(JSON.stringify(nextState.slides)); - - dispatch( - setPresentationData({ - ...presentationData!, - slides: newSlides, - }) - ); - } - - setTimeout(() => { - dispatch(finishUndoRedo()); - }, 100); - }, [canRedo, dispatch, presentationData, undoRedoState.future]); - - // Handle undo - useKeyboardShortcut( - ["z"], - (e) => { - if (e.ctrlKey && !e.shiftKey && undoRedoState.past.length > 0) { - e.preventDefault(); - - // Get the previous state before dispatching undo - const previousState = undoRedoState.past[undoRedoState.past.length - 1]; - - // Perform undo - dispatch(undo()); - - // Use the previousState directly instead of relying on the updated undoRedoState - if (previousState) { - // Create a deep copy to ensure no reference issues - const newSlides = JSON.parse(JSON.stringify(previousState.slides)); - - // Update the presentation data with the properly structured slides - dispatch( - setPresentationData({ - ...presentationData!, - slides: newSlides, - }) - ); - } - // Reset the undo/redo flag - setTimeout(() => { - dispatch(finishUndoRedo()); - }, 100); - } - }, - [undoRedoState.past, presentationData] + const dispatch = useDispatch(); + const undoRedoState = useSelector((state: RootState) => state.undoRedo); + const { presentationData } = useSelector( + (state: RootState) => state.presentationGeneration ); - // Handle redo + + const canUndo = undoRedoState.past.length > 0; + const canRedo = undoRedoState.future.length > 0; + + const applySlidesSnapshot = useCallback( + (slidesSnapshot: unknown) => { + if (!presentationData || !Array.isArray(slidesSnapshot)) { + return; + } + + const clonedSlides = JSON.parse(JSON.stringify(slidesSnapshot)); + dispatch( + setPresentationData({ + ...presentationData, + slides: clonedSlides, + }) + ); + }, + [dispatch, presentationData] + ); + + const onUndo = useCallback(() => { + if (!canUndo) { + return; + } + + const previousState = undoRedoState.past[undoRedoState.past.length - 1]; + if (!previousState) { + return; + } + + dispatch(undo()); + applySlidesSnapshot(previousState.slides); + }, [applySlidesSnapshot, canUndo, dispatch, undoRedoState.past]); + + const onRedo = useCallback(() => { + if (!canRedo) { + return; + } + + const nextState = undoRedoState.future[0]; + if (!nextState) { + return; + } + + dispatch(redo()); + applySlidesSnapshot(nextState.slides); + }, [applySlidesSnapshot, canRedo, dispatch, undoRedoState.future]); + + // Handle undo (Ctrl + Z) useKeyboardShortcut( ["z"], (e) => { - if (e.ctrlKey && e.shiftKey && undoRedoState.future.length > 0) { + if (e.ctrlKey && !e.shiftKey && canUndo) { e.preventDefault(); - - // Get the next state before dispatching redo - const nextState = undoRedoState.future[0]; - - // Perform redo - dispatch(redo()); - - // Use the nextState directly instead of relying on the updated undoRedoState - if (nextState) { - // Create a deep copy to ensure no reference issues - const newSlides = JSON.parse(JSON.stringify(nextState.slides)); - - // Update the presentation data with the properly structured slides - dispatch( - setPresentationData({ - ...presentationData!, - slides: newSlides, - }) - ); - } - // Reset the undo/redo flag - setTimeout(() => { - dispatch(finishUndoRedo()); - }, 100); + onUndo(); } }, - [undoRedoState.future, presentationData] + [canUndo, onUndo] + ); + + // Handle redo (Ctrl + Shift + Z) + useKeyboardShortcut( + ["z"], + (e) => { + if (e.ctrlKey && e.shiftKey && canRedo) { + e.preventDefault(); + onRedo(); + } + }, + [canRedo, onRedo] + ); + + // Handle redo (Ctrl + Y) + useKeyboardShortcut( + ["y"], + (e) => { + if (e.ctrlKey && canRedo) { + e.preventDefault(); + onRedo(); + } + }, + [canRedo, onRedo] ); return { onUndo, onRedo, canUndo, canRedo }; -} \ No newline at end of file +}; \ No newline at end of file diff --git a/servers/nextjs/app/(presentation-generator)/presentation/hooks/usePresentationData.ts b/servers/nextjs/app/(presentation-generator)/presentation/hooks/usePresentationData.ts index 91f9da3b..196c4d5b 100644 --- a/servers/nextjs/app/(presentation-generator)/presentation/hooks/usePresentationData.ts +++ b/servers/nextjs/app/(presentation-generator)/presentation/hooks/usePresentationData.ts @@ -17,14 +17,16 @@ export const usePresentationData = ( ) => { const dispatch = useDispatch(); - const fetchUserSlides = useCallback(async () => { + const fetchUserSlides = useCallback(async (options?: { clearHistory?: boolean }) => { try { const data = await DashboardApi.getPresentation(presentationId); if (data) { dispatch(setPresentationData(data)); - dispatch(clearHistory()); + if (options?.clearHistory ?? true) { + dispatch(clearHistory()); + } setLoading(false); } if (data.fonts) { diff --git a/servers/nextjs/store/slices/undoRedoSlice.ts b/servers/nextjs/store/slices/undoRedoSlice.ts index 09fa9216..69e4f24d 100644 --- a/servers/nextjs/store/slices/undoRedoSlice.ts +++ b/servers/nextjs/store/slices/undoRedoSlice.ts @@ -13,6 +13,7 @@ interface UndoRedoState { future: HistoryState[]; maxHistorySize: number; isUndoRedoInProgress: boolean; + pendingHistorySkips: number; } // Helper function for deep copy @@ -25,7 +26,8 @@ const initialState: UndoRedoState = { present: null, future: [], maxHistorySize: 30, - isUndoRedoInProgress: false + isUndoRedoInProgress: false, + pendingHistorySkips: 0, }; const undoRedoSlice = createSlice({ @@ -33,15 +35,22 @@ const undoRedoSlice = createSlice({ initialState, reducers: { addToHistory: (state, action: PayloadAction<{slides: Slide[], actionType: string}>) => { - - // Skip if undo/redo is in progress + if (state.pendingHistorySkips > 0) { + state.pendingHistorySkips -= 1; + if (state.pendingHistorySkips === 0) { + state.isUndoRedoInProgress = false; + } + return; + } + + // Defensive guard for any stale in-progress state. if (state.isUndoRedoInProgress) { return; } - + // Deep copy the slides to avoid reference issues const newSlides = deepCopy(action.payload.slides); - + // Only add to history if the slides have actually changed if (!state.present) { state.present = { @@ -51,84 +60,80 @@ const undoRedoSlice = createSlice({ }; return; } - + // Skip if slides are identical if (JSON.stringify(state.present.slides) === JSON.stringify(newSlides)) { return; } - + // Add current state to past state.past.push(state.present); - + // Limit history size if (state.past.length > state.maxHistorySize) { state.past.shift(); } - + // Clear future on new change state.future = []; - + // Set new present state.present = { slides: newSlides, timestamp: Date.now(), actionType: action.payload.actionType }; - - }, undo: (state) => { - if (state.past.length === 0) { - + if (state.past.length === 0) { return; } - + state.isUndoRedoInProgress = true; - + state.pendingHistorySkips = 1; + // Move present to future if (state.present) { state.future.unshift(deepCopy(state.present)); } - + // Get last past state const previous = state.past[state.past.length - 1]; state.past = state.past.slice(0, -1); state.present = deepCopy(previous); - - }, redo: (state) => { if (state.future.length === 0) { - return; } - + state.isUndoRedoInProgress = true; - + state.pendingHistorySkips = 1; + // Move present to past if (state.present) { state.past.push(deepCopy(state.present)); } - + // Get first future state const next = state.future[0]; state.future = state.future.slice(1); state.present = deepCopy(next); - - }, - + finishUndoRedo: (state) => { state.isUndoRedoInProgress = false; + state.pendingHistorySkips = 0; }, clearHistory: (state) => { state.past = []; state.future = []; state.present = null; - // Keep present + state.isUndoRedoInProgress = false; + state.pendingHistorySkips = 0; } } });