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:
sudipnext 2026-04-27 17:21:25 +05:45
parent 3b5f28f018
commit 64cdac963b
10 changed files with 468 additions and 356 deletions

View file

@ -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",
]

View file

@ -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}"
)

View file

@ -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)],
)
)

View file

@ -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

View file

@ -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" },

View file

@ -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>
);

View file

@ -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>

View file

@ -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 };
}
};

View file

@ -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) {

View file

@ -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;
}
}
});