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.
This commit is contained in:
parent
3b5f28f018
commit
64cdac963b
10 changed files with 468 additions and 356 deletions
|
|
@ -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",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)],
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
8
servers/fastapi/uv.lock
generated
8
servers/fastapi/uv.lock
generated
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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 = () => (
|
|||
</div>
|
||||
);
|
||||
|
||||
const TOOL_LABELS: Record<string, string> = {
|
||||
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<AssistantActivity, "id"> | 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 <Loader2 className="h-3 w-3 animate-spin text-[#98A2B3]" />;
|
||||
}
|
||||
|
||||
if (state === "success") {
|
||||
return <CheckCircle2 className="h-3 w-3 text-[#12B76A]" />;
|
||||
}
|
||||
|
||||
if (state === "error") {
|
||||
return <XCircle className="h-3 w-3 text-[#F04438]" />;
|
||||
}
|
||||
|
||||
return <CircleDot className="h-3 w-3 text-[#98A2B3]" />;
|
||||
};
|
||||
|
||||
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[] }) => (
|
||||
<div className="border-t border-[#ECEEF2] px-3 py-3">
|
||||
<div className="flex flex-col">
|
||||
{activity.map((activityItem, index) => (
|
||||
<div
|
||||
key={activityItem.id}
|
||||
className="relative flex gap-3 pb-3 last:pb-0"
|
||||
>
|
||||
{index < activity.length - 1 && (
|
||||
<span className="absolute left-[5px] top-4 h-[calc(100%-0.5rem)] w-px bg-[#E4E7EC]" />
|
||||
)}
|
||||
<span className="relative z-10 mt-0.5 flex h-3 w-3 shrink-0 items-center justify-center bg-[#FAFAFB]">
|
||||
<ActivityIcon state={activityItem.state} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{activityItem.tool && (
|
||||
<span className="rounded-full bg-white px-1.5 py-0.5 text-[10px] font-medium text-[#667085] ring-1 ring-[#EAECF0]">
|
||||
{activityItem.tool}
|
||||
</span>
|
||||
)}
|
||||
{activityItem.round && (
|
||||
<span className="text-[10px] text-[#98A2B3]">
|
||||
step {activityItem.round}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 whitespace-pre-wrap break-words text-xs leading-4 text-[#667085]">
|
||||
{activityItem.label}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Chat = ({
|
||||
presentationId,
|
||||
currentSlide,
|
||||
|
|
@ -421,6 +437,9 @@ const Chat = ({
|
|||
const [conversationId, setConversationId] = useState<string | null>(null);
|
||||
const [isHistoryLoading, setIsHistoryLoading] = useState(false);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [activeAssistantMessageId, setActiveAssistantMessageId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [expandedActivityByMessage, setExpandedActivityByMessage] = useState<
|
||||
Record<string, boolean>
|
||||
|
|
@ -428,12 +447,17 @@ const Chat = ({
|
|||
|
||||
const inputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(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 (
|
||||
<div className="flex h-full w-full flex-col bg-white">
|
||||
<div className="flex items-center justify-between px-4 pt-8">
|
||||
<h4 className="flex items-center gap-2 text-sm font-semibold text-[#101828]">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M19.1407 9.46542C16.5537 9.21616 14.5067 7.17009 14.2577 4.58528L13.8376 0.220703L13.4175 4.58528C13.1685 7.17053 11.1215 9.2166 8.53451 9.46542L4.1731 9.88521L8.53451 10.305C11.1215 10.5543 13.1685 12.6003 13.4175 15.1852L13.8376 19.5497L14.2577 15.1852C14.5067 12.5999 16.5537 10.5538 19.1407 10.305L23.5021 9.88521L19.1407 9.46542Z"
|
||||
fill="#7A5AF8"
|
||||
/>
|
||||
<path
|
||||
d="M9.07681 16.8431C7.62808 16.7035 6.48175 15.5577 6.34232 14.1102L6.10707 11.666L5.87183 14.1102C5.7324 15.5579 4.58606 16.7037 3.13734 16.8431L0.694946 17.0781L3.13734 17.3132C4.58606 17.4528 5.7324 18.5986 5.87183 20.0461L6.10707 22.4903L6.34232 20.0461C6.48175 18.5984 7.62808 17.4526 9.07681 17.3132L11.5192 17.0781L9.07681 16.8431Z"
|
||||
fill="#7A5AF8"
|
||||
/>
|
||||
</svg>
|
||||
AI Assistant
|
||||
</h4>
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="flex items-center gap-2 text-sm font-semibold text-[#101828]">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M19.1407 9.46542C16.5537 9.21616 14.5067 7.17009 14.2577 4.58528L13.8376 0.220703L13.4175 4.58528C13.1685 7.17053 11.1215 9.2166 8.53451 9.46542L4.1731 9.88521L8.53451 10.305C11.1215 10.5543 13.1685 12.6003 13.4175 15.1852L13.8376 19.5497L14.2577 15.1852C14.5067 12.5999 16.5537 10.5538 19.1407 10.305L23.5021 9.88521L19.1407 9.46542Z"
|
||||
fill="#7A5AF8"
|
||||
/>
|
||||
<path
|
||||
d="M9.07681 16.8431C7.62808 16.7035 6.48175 15.5577 6.34232 14.1102L6.10707 11.666L5.87183 14.1102C5.7324 15.5579 4.58606 16.7037 3.13734 16.8431L0.694946 17.0781L3.13734 17.3132C4.58606 17.4528 5.7324 18.5986 5.87183 20.0461L6.10707 22.4903L6.34232 20.0461C6.48175 18.5984 7.62808 17.4526 9.07681 17.3132L11.5192 17.0781L9.07681 16.8431Z"
|
||||
fill="#7A5AF8"
|
||||
/>
|
||||
</svg>
|
||||
AI Assistant
|
||||
</h4>
|
||||
{isSending && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-[#F4F3FF] px-2 py-0.5 text-[10px] font-medium text-[#6941C6]">
|
||||
<Loader2 className="h-2.5 w-2.5 animate-spin" />
|
||||
Live
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetChat}
|
||||
|
|
@ -905,10 +963,18 @@ const Chat = ({
|
|||
{message.content}
|
||||
</div>
|
||||
) : (
|
||||
<MarkdownRenderer
|
||||
content={message.content}
|
||||
className="chat-markdown mb-0 text-sm font-normal leading-5 text-[#535862]"
|
||||
/>
|
||||
<div className="chat-markdown mb-0 text-sm font-normal leading-5 text-[#535862]">
|
||||
<MarkdownRenderer
|
||||
content={message.content}
|
||||
className="chat-markdown mb-0 text-sm font-normal leading-5 text-[#535862]"
|
||||
/>
|
||||
{isSending && message.id === activeAssistantMessageId && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="ml-1 inline-block h-4 w-0.5 animate-pulse rounded-full bg-[#98A2B3] align-middle"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="text-sm font-normal leading-5 text-[#535862]">
|
||||
|
|
@ -919,49 +985,47 @@ const Chat = ({
|
|||
</div>
|
||||
)}
|
||||
{message.activity && message.activity.length > 0 && (
|
||||
<div className="mt-3 overflow-hidden rounded-2xl border border-[#ECEEF2] bg-[#FAFAFB] shadow-[0_1px_2px_rgba(16,24,40,0.04)]">
|
||||
<div className="mt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleActivityExpanded(message.id)}
|
||||
className="flex w-full items-center justify-between gap-3 px-3 py-2.5 text-left transition-colors hover:bg-[#F6F7F9]"
|
||||
className="inline-flex items-center gap-1 text-left text-xs font-medium text-[#667085] hover:text-[#475467]"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-3.5 w-3.5 text-[#667085]" />
|
||||
<span className="text-[11px] font-semibold text-[#475467]">
|
||||
{getActivityHeading(message.activity)}
|
||||
</span>
|
||||
<span className="rounded-full bg-white px-1.5 py-0.5 text-[10px] text-[#98A2B3] ring-1 ring-[#EAECF0]">
|
||||
{message.activity.length} steps
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 truncate text-xs leading-4 text-[#98A2B3]">
|
||||
{getActivitySummary(message.activity)}
|
||||
</div>
|
||||
</div>
|
||||
{expandedActivityByMessage[message.id] ? (
|
||||
<ChevronUp className="h-3.5 w-3.5 shrink-0 text-[#98A2B3]" />
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-[#98A2B3]" />
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
)}
|
||||
<span>Thinking</span>
|
||||
{message.activity.some((item) => item.state === "running") && (
|
||||
<Loader2 className="h-3 w-3 animate-spin text-[#98A2B3]" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expandedActivityByMessage[message.id] && (
|
||||
<ActivityTimeline activity={message.activity} />
|
||||
<div className="mt-2 space-y-1.5 pl-4">
|
||||
{message.activity.map((activityItem) => (
|
||||
<div
|
||||
key={activityItem.id}
|
||||
className="text-xs leading-4 text-[#667085]"
|
||||
>
|
||||
{activityItem.tool && (
|
||||
<span className="mr-1 text-[#475467]">
|
||||
{getToolLabel(activityItem.tool)}:
|
||||
</span>
|
||||
)}
|
||||
<span>{activityItem.label}</span>
|
||||
</div>
|
||||
))}
|
||||
{message.toolCalls && message.toolCalls.length > 0 && (
|
||||
<div className="pt-0.5 text-[11px] text-[#98A2B3]">
|
||||
Tools called: {message.toolCalls.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{message.toolCalls && message.toolCalls.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-1">
|
||||
{message.toolCalls.map((toolCall) => (
|
||||
<span
|
||||
key={`${message.id}-${toolCall}`}
|
||||
className="rounded-full bg-[#F5F5F5] px-2 py-0.5 text-[10px] text-[#8C8C8C]"
|
||||
>
|
||||
Used {toolCall}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
|
@ -1000,23 +1064,42 @@ const Chat = ({
|
|||
>
|
||||
<Plus className="h-3 w-3 text-black" />
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!input.trim() || isSending || isHistoryLoading}
|
||||
className="absolute bottom-3 right-3 flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-[#191919] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(270deg, #D5CAFC 2.4%, #E3D2EB 27.88%, #F4DCD3 69.23%, #FDE4C2 100%)",
|
||||
borderRadius: "34px",
|
||||
}}
|
||||
>
|
||||
{isSending ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin text-[#191919]" />
|
||||
) : (
|
||||
{isSending ? (
|
||||
<div className="absolute bottom-3 right-3 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="flex cursor-wait items-center gap-1.5 rounded-[34px] border border-[#EAECF0] bg-[#F9FAFB] px-3 py-2 text-sm font-medium text-[#667085]"
|
||||
aria-label="Chat is processing"
|
||||
>
|
||||
<Loader2 className="h-3 w-3 animate-spin text-[#667085]" />
|
||||
Processing
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={stopStreaming}
|
||||
className="flex items-center gap-1.5 rounded-[34px] border border-[#E4E7EC] bg-white px-3 py-2 text-sm font-medium text-[#344054] transition-colors hover:bg-[#F9FAFB]"
|
||||
aria-label="Stop chat response"
|
||||
>
|
||||
<Square className="h-3 w-3 fill-current" />
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!input.trim() || isHistoryLoading}
|
||||
className="absolute bottom-3 right-3 flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-[#191919] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(270deg, #D5CAFC 2.4%, #E3D2EB 27.88%, #F4DCD3 69.23%, #FDE4C2 100%)",
|
||||
borderRadius: "34px",
|
||||
}}
|
||||
>
|
||||
<Send className="h-3 w-3 text-[#191919]" />
|
||||
)}
|
||||
Send
|
||||
</button>
|
||||
Send
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<PresentationPageProps> = ({
|
|||
fetchUserSlides
|
||||
);
|
||||
|
||||
usePresentationUndoRedo();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isStreaming) return;
|
||||
|
||||
|
|
@ -242,7 +239,7 @@ const PresentationPage: React.FC<PresentationPageProps> = ({
|
|||
<Chat
|
||||
presentationId={presentation_id}
|
||||
currentSlide={selectedSlide}
|
||||
onPresentationChanged={fetchUserSlides}
|
||||
onPresentationChanged={() => fetchUserSlides({ clearHistory: false })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
};
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue