snapshot of https://github.com/excalidraw/excalidraw.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Tests / test (push) Failing after 15s

This commit is contained in:
upstream-mirror
2026-09-16 05:31:04 +05:00
commit 1a8d07ca6c
1286 changed files with 360144 additions and 0 deletions

View File

@@ -0,0 +1,403 @@
@use "../../../css/variables.module.scss" as *;
$verticalBreakpoint: 861px;
.excalidraw {
&.theme--dark {
.chat-message {
&--assistant {
.chat-message__content {
background: var(--color-surface-lowest);
}
}
&--system {
.chat-message__content {
color: var(--color-surface-low);
}
}
}
}
.chat-interface {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
position: relative;
@media screen and (max-width: $verticalBreakpoint) {
min-height: 200px;
}
&__messages {
flex: 1 1 0;
overflow-y: auto;
margin-bottom: 0.5rem;
padding: 1rem 0.5rem 0 0.5rem;
display: flex;
flex-direction: column;
gap: 2rem;
min-height: 0;
border-top-left-radius: var(--border-radius-lg);
border-top-right-radius: var(--border-radius-lg);
@media screen and (max-width: $verticalBreakpoint) {
min-height: 100px;
padding: 0.75rem;
}
}
&__welcome-screen {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: 200px;
&__welcome-message {
text-align: center;
h3 {
margin: 0 0 0.5rem 0;
font-size: 1.25rem;
font-weight: 600;
}
p {
margin: 0 0 0.2rem 0;
font-size: 0.875rem;
color: var(--color-on-surface);
}
}
@media screen and (max-width: $verticalBreakpoint) {
min-height: 100px;
}
}
&__input-outer {
position: relative;
min-height: 71px;
}
&__input-container {
display: flex;
flex-direction: column;
justify-content: flex-end;
}
&__input-wrapper {
position: absolute;
bottom: 0;
left: 0;
right: 0;
display: flex;
align-items: flex-end;
gap: 0.5rem;
border: 1px solid var(--dialog-border-color);
border-radius: var(--border-radius-lg);
padding: 0.75rem;
background: var(--color-surface-lowest);
transition: border-color 0.2s ease;
&:focus-within {
border-color: var(--color-primary);
}
@media screen and (max-width: $verticalBreakpoint) {
padding: 0.5rem 0.75rem;
}
}
&__input {
flex: 1;
border: none;
outline: none;
font-size: 0.875rem;
line-height: 1.5;
min-height: 24px;
max-height: 120px;
overflow-x: hidden;
border: none !important;
background: transparent !important;
color: var(--color-on-surface);
&::placeholder {
color: var(--color-gray-40);
}
@media screen and (max-width: $verticalBreakpoint) {
line-height: 1.4;
min-height: 20px;
max-height: 100px;
resize: none;
padding: 0;
&::placeholder {
opacity: 0.6;
}
}
}
&__send-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: var(--color-primary);
color: var(--color-surface-lowest);
cursor: pointer;
margin-bottom: 8px;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
&:hover {
background-color: var(--color-primary-darker);
}
svg {
transform: rotate(-90deg);
}
}
}
.chat-message {
display: flex;
align-items: center;
gap: 0.5rem;
&--user {
justify-content: flex-end;
.chat-message__content {
background: var(--color-primary-light);
color: var(--text-primary-color);
border-radius: var(--border-radius-md);
min-width: 6rem;
}
}
&--assistant {
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
.chat-message__content {
background: var(--color-surface-low);
color: var(--color-on-surface);
border-radius: var(--border-radius-md);
min-width: 6rem;
.chat-message__body {
font-family: monospace;
}
}
}
&--system {
justify-content: flex-start;
margin-bottom: 0;
.chat-message__content {
background: var(--color-warning);
color: var(--color-on-surface);
border-radius: var(--border-radius-md);
min-width: 6rem;
.chat-message__body {
font-family: monospace;
}
}
}
&__content {
max-width: 80%;
padding: 0.75rem 1rem;
box-shadow: var(--chat-msg-shadow);
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin-bottom: 0.5rem;
font-size: 0.75rem;
}
&__role {
font-weight: 600;
}
&__timestamp {
font-size: 0.625rem;
}
&__actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
&__action {
display: inline-flex;
align-items: center;
justify-content: center;
background: none;
border: none;
cursor: pointer;
padding: 0.25rem 0;
color: var(--color-gray-60);
transition: color 0.2s ease;
flex-shrink: 0;
&.invisible {
visibility: hidden;
pointer-events: none;
}
&:hover {
color: var(--color-primary);
}
svg {
width: 14px;
height: 14px;
}
}
&__action--danger {
&:hover {
color: var(--color-danger);
}
}
&__body {
line-height: 1.5;
}
&__text {
white-space: pre-wrap;
word-wrap: break-word;
}
&__cursor {
display: inline-block;
margin-left: 2px;
color: currentColor;
animation: blink 1s infinite;
}
&__loading {
display: flex;
align-items: center;
gap: 0.5rem;
}
&__typing-indicator {
display: flex;
gap: 0.25rem;
span {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
opacity: 0.4;
animation: typing 1.4s infinite ease-in-out;
&:nth-child(1) {
animation-delay: -0.32s;
}
&:nth-child(2) {
animation-delay: -0.16s;
}
&:nth-child(3) {
animation-delay: 0s;
}
}
}
&__error {
color: var(--color-danger);
font-weight: 500;
white-space: pre-wrap;
word-wrap: break-word;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
&__error_message {
background: var(--color-warning-background);
color: var(--color-warning-color);
padding: 0.5rem 0.75rem;
border-radius: var(--border-radius-md);
margin-top: 1rem;
font-size: 0.875rem;
}
&__error-link {
display: flex;
align-items: center;
background: none;
border: none;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
padding: 0;
margin-top: 1rem;
text-decoration: underline;
font-family: inherit;
color: var(--link-color);
&:hover {
color: var(--link-color-hover);
}
&:active {
color: var(--link-color-active);
}
&:disabled {
cursor: not-allowed;
text-decoration: none;
}
}
}
@keyframes typing {
0%,
80%,
100% {
transform: scale(0.8);
opacity: 0.4;
}
40% {
transform: scale(1);
opacity: 1;
}
}
@keyframes blink {
0%,
50% {
opacity: 1;
}
51%,
100% {
opacity: 0;
}
}
}

View File

@@ -0,0 +1,88 @@
import clsx from "clsx";
import { t } from "../../../i18n";
import { historyIcon, TrashIcon } from "../../icons";
import DropdownMenu from "../../dropdownMenu/DropdownMenu";
import { FilledButton } from "../../FilledButton";
import type { SavedChat } from "../types";
interface ChatHistoryMenuProps {
isOpen: boolean;
onToggle: () => void;
onClose: () => void;
onNewChat: () => void;
onRestoreChat: (chat: SavedChat) => void;
onDeleteChat: (chatId: string, event: React.MouseEvent) => void;
savedChats: SavedChat[];
activeSessionId: string;
disabled?: boolean;
isNewChatBtnVisible?: boolean;
}
export const ChatHistoryMenu = ({
isOpen,
onToggle,
onClose,
onNewChat,
onRestoreChat,
onDeleteChat,
isNewChatBtnVisible,
savedChats,
activeSessionId,
disabled,
}: ChatHistoryMenuProps) => {
return (
<div className="ttd-chat-history-menu">
{isNewChatBtnVisible && (
<FilledButton onClick={onNewChat} disabled={disabled}>
{t("chat.newChat")}
</FilledButton>
)}
{savedChats.length > 0 && (
<div className="ttd-dialog-panel__menu-wrapper">
<DropdownMenu open={isOpen}>
<DropdownMenu.Trigger
onToggle={onToggle}
className="ttd-dialog-menu-trigger"
disabled={disabled}
title={t("chat.menu")}
aria-label={t("chat.menu")}
>
{historyIcon}
</DropdownMenu.Trigger>
<DropdownMenu.Content onClickOutside={onClose} onSelect={onClose}>
<>
{savedChats.map((chat) => (
<DropdownMenu.ItemCustom
key={chat.id}
className={clsx("ttd-chat-menu-item", {
"ttd-chat-menu-item--active": chat.id === activeSessionId,
})}
onClick={() => {
onRestoreChat(chat);
}}
>
<span className="ttd-chat-menu-item__title">
{chat.title}
</span>
<button
className="ttd-chat-menu-item__delete"
onClick={(e) => onDeleteChat(chat.id, e)}
title={t("chat.deleteChat")}
aria-label={t("chat.deleteChat")}
type="button"
>
{TrashIcon}
</button>
</DropdownMenu.ItemCustom>
))}
</>
</DropdownMenu.Content>
</DropdownMenu>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,191 @@
import React, { useRef, useEffect, useLayoutEffect } from "react";
import { KEYS } from "@excalidraw/common";
import { ArrowRightIcon, stop as StopIcon } from "../../icons";
import { InlineIcon } from "../../InlineIcon";
import { t } from "../../../i18n";
import { TTDWelcomeMessage } from "../TTDWelcomeMessage";
import { ChatMessage } from "./ChatMessage";
import type { TChat, TTTDDialog } from "../types";
import type { FormEventHandler } from "react";
export const ChatInterface = ({
chatId,
messages,
currentPrompt,
onPromptChange,
onGenerate,
isGenerating,
rateLimits,
onAbort,
onMermaidTabClick,
onAiRepairClick,
onDeleteMessage,
onInsertMessage,
onRetry,
renderWelcomeScreen,
renderWarning,
}: {
chatId: string;
messages: TChat.ChatMessage[];
currentPrompt: string;
onPromptChange: (prompt: string) => void;
onGenerate: TTTDDialog.OnGenerate;
isGenerating: boolean;
rateLimits?: {
rateLimit: number;
rateLimitRemaining: number;
} | null;
onViewAsMermaid?: () => void;
generatedResponse?: string | null;
onAbort?: () => void;
onMermaidTabClick?: (message: TChat.ChatMessage) => void;
onAiRepairClick?: (message: TChat.ChatMessage) => void;
onDeleteMessage?: (messageId: string) => void;
onInsertMessage?: (message: TChat.ChatMessage) => void;
onRetry?: (message: TChat.ChatMessage) => void;
renderWelcomeScreen?: TTTDDialog.renderWelcomeScreen;
renderWarning?: TTTDDialog.renderWarning;
}) => {
const messagesEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useLayoutEffect(() => {
messagesEndRef.current?.scrollIntoView();
}, [messages]);
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.focus();
}
}, [chatId]);
const handleInputChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = event.target.value;
onPromptChange(value);
};
const handleSubmit = () => {
if (isGenerating && onAbort) {
onAbort();
return;
}
const trimmedPrompt = currentPrompt.trim();
if (!trimmedPrompt) {
return;
}
onGenerate({ prompt: trimmedPrompt });
onPromptChange("");
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === KEYS.ENTER && !event.shiftKey) {
event.preventDefault();
if (!isGenerating) {
handleSubmit();
}
}
};
const canSend =
currentPrompt.trim().length > 3 &&
!isGenerating &&
(rateLimits?.rateLimitRemaining ?? 1) > 0;
const canStop = isGenerating && !!onAbort;
const onInput: FormEventHandler<HTMLTextAreaElement> = (ev) => {
const target = ev.target as HTMLTextAreaElement;
target.style.height = "auto";
target.style.height = `${Math.min(target.scrollHeight, 120)}px`;
};
return (
<div className="chat-interface">
<div className="chat-interface__messages">
{messages.length === 0 ? (
<div className="chat-interface__welcome-screen">
{renderWelcomeScreen ? (
renderWelcomeScreen({ rateLimits: rateLimits ?? null })
) : (
<TTDWelcomeMessage />
)}
</div>
) : (
messages.map((message, index) => (
<ChatMessage
key={message.id}
message={message}
onMermaidTabClick={onMermaidTabClick}
onAiRepairClick={onAiRepairClick}
onDeleteMessage={onDeleteMessage}
onInsertMessage={onInsertMessage}
onRetry={onRetry}
rateLimitRemaining={rateLimits?.rateLimitRemaining}
isLastMessage={index === messages.length - 1}
renderWarning={renderWarning}
// so we don't allow to repair parse errors which aren't the last message
allowFixingParseError={
message.errorType === "parse" && index === messages.length - 1
}
/>
))
)}
<div ref={messagesEndRef} id="messages-end" />
</div>
<div className="chat-interface__input-container">
<div className="chat-interface__input-outer">
<div
className="chat-interface__input-wrapper"
style={{
borderColor: isGenerating
? "var(--dialog-border-color)"
: undefined,
}}
>
<textarea
ref={textareaRef}
autoFocus
className="chat-interface__input"
value={currentPrompt}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder={
isGenerating
? t("chat.generating")
: rateLimits?.rateLimitRemaining === 0
? t("chat.rateLimit.messageLimitInputPlaceholder")
: messages.length > 0
? t("chat.inputPlaceholderWithMessages")
: t("chat.inputPlaceholder", { shortcut: "Shift + Enter" })
}
disabled={rateLimits?.rateLimitRemaining === 0}
rows={1}
cols={30}
onInput={onInput}
/>
<button
className="chat-interface__send-button"
onClick={handleSubmit}
disabled={!canSend && !canStop}
type="button"
>
<InlineIcon
size="1.5em"
icon={isGenerating ? StopIcon : ArrowRightIcon}
/>
</button>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,218 @@
import clsx from "clsx";
import React, { useState, useEffect } from "react";
import { t } from "../../../i18n";
import { FilledButton } from "../../FilledButton";
import { TrashIcon, codeIcon, stackPushIcon, RetryIcon } from "../../icons";
import type { TChat, TTTDDialog } from "../types";
export const ChatMessage: React.FC<{
message: TChat.ChatMessage;
onMermaidTabClick?: (message: TChat.ChatMessage) => void;
onAiRepairClick?: (message: TChat.ChatMessage) => void;
onDeleteMessage?: (messageId: string) => void;
onInsertMessage?: (message: TChat.ChatMessage) => void;
onRetry?: (message: TChat.ChatMessage) => void;
rateLimitRemaining?: number;
isLastMessage?: boolean;
renderWarning?: TTTDDialog.renderWarning;
allowFixingParseError?: boolean;
}> = ({
message,
onMermaidTabClick,
onAiRepairClick,
onDeleteMessage,
onInsertMessage,
onRetry,
rateLimitRemaining,
isLastMessage,
renderWarning,
allowFixingParseError,
}) => {
const [canRetry, setCanRetry] = useState(false);
useEffect(() => {
if (!message.error || !isLastMessage) {
return;
}
if (message.error && !message.lastAttemptAt) {
setCanRetry(true);
return;
}
const timeSinceLastAttempt = Date.now() - message.lastAttemptAt!;
const remainingTime = Math.max(0, 5000 - timeSinceLastAttempt);
if (remainingTime === 0) {
setCanRetry(true);
return;
}
setCanRetry(false);
const timer = setTimeout(() => {
setCanRetry(true);
}, remainingTime);
return () => clearTimeout(timer);
}, [message.error, message.lastAttemptAt, isLastMessage]);
const formatTime = (date: Date) => {
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
};
if (message.type === "warning") {
const customOverride = renderWarning?.(message);
return (
<div className="chat-message chat-message--system">
<div className="chat-message__content">
<div className="chat-message__header">
<span className="chat-message__role">{t("chat.role.system")}</span>
<span className="chat-message__timestamp">
{formatTime(message.timestamp)}
</span>
</div>
<div className="chat-message__body">
<div className="chat-message__text">
{customOverride ? (
customOverride
) : message.warningType === "messageLimitExceeded" ? (
<>
{t("chat.rateLimit.messageLimit")}
<div style={{ marginTop: "10px" }}>
<FilledButton
onClick={() => {
window.open(
`${
import.meta.env.VITE_APP_PLUS_LP
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=ttdChatBanner#excalidraw-redirect`,
"_blank",
"noopener",
);
}}
>
{t("chat.upsellBtnLabel")}
</FilledButton>
</div>
</>
) : (
t("chat.rateLimit.generalRateLimit")
)}
</div>
</div>
</div>
</div>
);
}
return (
<div className={`chat-message chat-message--${message.type}`}>
<div className="chat-message__content">
<div className="chat-message__header">
<span className="chat-message__role">
{message.type === "user"
? t("chat.role.user")
: t("chat.role.assistant")}
</span>
<span className="chat-message__timestamp">
{formatTime(message.timestamp)}
</span>
</div>
<div className="chat-message__body">
{message.error ? (
<>
<div className="chat-message__error">{message.content}</div>
{message.errorType !== "parse" && (
<div className="chat-message__error_message">
Error: {message.error || t("chat.errors.generationFailed")}
</div>
)}
{message.errorType === "parse" && allowFixingParseError && (
<div className="chat-message__error_message">
<p>{t("chat.errors.invalidDiagram")}</p>
<div className="chat-message__error-actions">
{onMermaidTabClick && (
<button
className="chat-message__error-link"
onClick={() => onMermaidTabClick(message)}
type="button"
>
{t("chat.errors.fixInMermaid")}
</button>
)}
{onAiRepairClick && (
<button
className="chat-message__error-link"
onClick={() => onAiRepairClick(message)}
disabled={rateLimitRemaining === 0}
type="button"
>
{t("chat.errors.aiRepair")}
</button>
)}
</div>
</div>
)}
</>
) : (
<div className="chat-message__text">
{message.content}
{message.isGenerating && (
<span className="chat-message__cursor">▋</span>
)}
</div>
)}
</div>
</div>
{message.type === "assistant" && !message.isGenerating && (
<div className="chat-message__actions">
{!message.error && onInsertMessage && (
<button
className="chat-message__action"
onClick={() => onInsertMessage(message)}
type="button"
aria-label={t("chat.insert")}
title={t("chat.insert")}
>
{stackPushIcon}
</button>
)}
{onMermaidTabClick && message.content && (
<button
className="chat-message__action"
onClick={() => onMermaidTabClick(message)}
type="button"
aria-label={t("chat.viewAsMermaid")}
title={t("chat.viewAsMermaid")}
>
{codeIcon}
</button>
)}
{onDeleteMessage && message.errorType !== "network" && (
<button
className="chat-message__action chat-message__action--danger"
onClick={() => onDeleteMessage(message.id)}
type="button"
aria-label={t("chat.deleteMessage")}
title={t("chat.deleteMessage")}
>
{TrashIcon}
</button>
)}
{message.errorType === "network" && onRetry && isLastMessage && (
<button
className={clsx("chat-message__action", { invisible: !canRetry })}
onClick={() => onRetry(message)}
type="button"
aria-label={t("chat.retry")}
title={t("chat.retry")}
>
{RetryIcon}
</button>
)}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,161 @@
import { t } from "../../../i18n";
import { ArrowRightIcon } from "../../icons";
import { InlineIcon } from "../../InlineIcon";
import { TTDDialogPanel } from "../TTDDialogPanel";
import { useAtom } from "../../../editor-jotai";
import { rateLimitsAtom } from "../TTDContext";
import { ChatHistoryMenu } from "./ChatHistoryMenu";
import { ChatInterface } from "./ChatInterface";
import type { TTDPanelAction } from "../TTDDialogPanel";
import type { SavedChat, TChat, TTTDDialog } from "../types";
export const TTDChatPanel = ({
chatId,
messages,
currentPrompt,
onPromptChange,
onGenerate,
isGenerating,
generatedResponse,
isMenuOpen,
onMenuToggle,
onMenuClose,
onNewChat,
onRestoreChat,
onDeleteChat,
savedChats,
activeSessionId,
onAbort,
onMermaidTabClick,
onAiRepairClick,
onDeleteMessage,
onInsertMessage,
onRetry,
onViewAsMermaid,
renderWelcomeScreen,
renderWarning,
}: {
chatId: string;
messages: TChat.ChatMessage[];
currentPrompt: string;
onPromptChange: (prompt: string) => void;
onGenerate: TTTDDialog.OnGenerate;
isGenerating: boolean;
generatedResponse: string | null | undefined;
isMenuOpen: boolean;
onMenuToggle: () => void;
onMenuClose: () => void;
onNewChat: () => void;
onRestoreChat: (chat: SavedChat) => void;
onDeleteChat: (chatId: string, event: React.MouseEvent) => void;
savedChats: SavedChat[];
activeSessionId: string;
onAbort: () => void;
onMermaidTabClick: (message: TChat.ChatMessage) => void;
onAiRepairClick: (message: TChat.ChatMessage) => void;
onDeleteMessage: (messageId: string) => void;
onInsertMessage: (message: TChat.ChatMessage) => void;
onRetry?: (message: TChat.ChatMessage) => void;
onViewAsMermaid: () => void;
renderWelcomeScreen?: TTTDDialog.renderWelcomeScreen;
renderWarning?: TTTDDialog.renderWarning;
}) => {
const [rateLimits] = useAtom(rateLimitsAtom);
const getPanelActions = () => {
const actions: TTDPanelAction[] = [];
if (rateLimits) {
actions.push({
label: t("chat.rateLimitRemaining", {
count: rateLimits.rateLimitRemaining,
}),
variant: "rateLimit",
className:
rateLimits.rateLimitRemaining < 5
? "ttd-dialog-panel__rate-limit--danger"
: "",
});
}
if (generatedResponse) {
actions.push({
action: onViewAsMermaid,
label: t("chat.viewAsMermaid"),
icon: <InlineIcon icon={ArrowRightIcon} />,
variant: "link",
});
}
return actions;
};
const actions = getPanelActions();
const getPanelActionFlexProp = () => {
if (actions.length === 2) {
return "space-between";
}
if (actions.length === 1 && actions[0].variant === "rateLimit") {
return "flex-start";
}
return "flex-end";
};
return (
<TTDDialogPanel
label={
<div className="ttd-dialog-panel__label-wrapper">
<div className="ttd-dialog-panel__label-group"></div>
<div className="ttd-dialog-panel__header-right">
<ChatHistoryMenu
isNewChatBtnVisible={!!messages.length}
isOpen={isMenuOpen}
onToggle={onMenuToggle}
onClose={onMenuClose}
onNewChat={onNewChat}
onRestoreChat={onRestoreChat}
onDeleteChat={onDeleteChat}
savedChats={savedChats}
activeSessionId={activeSessionId}
disabled={isGenerating}
/>
</div>
</div>
}
className="ttd-dialog-chat-panel"
panelActionJustifyContent={getPanelActionFlexProp()}
panelActions={actions}
>
<ChatInterface
chatId={chatId}
messages={messages}
currentPrompt={currentPrompt}
onPromptChange={onPromptChange}
onGenerate={onGenerate}
isGenerating={isGenerating}
generatedResponse={generatedResponse}
onAbort={onAbort}
onMermaidTabClick={onMermaidTabClick}
onAiRepairClick={onAiRepairClick}
onDeleteMessage={onDeleteMessage}
onInsertMessage={onInsertMessage}
onRetry={onRetry}
rateLimits={rateLimits}
renderWelcomeScreen={renderWelcomeScreen}
renderWarning={renderWarning}
/>
</TTDDialogPanel>
);
};

View File

@@ -0,0 +1,3 @@
export { ChatInterface } from "./ChatInterface";
export { ChatMessage } from "./ChatMessage";
export { useChatAgent } from "./useChatAgent";

View File

@@ -0,0 +1,76 @@
import { useAtom } from "../../../editor-jotai";
import { chatHistoryAtom } from "../../TTDDialog/TTDContext";
import {
addMessages,
updateAssistantContent,
} from "../../TTDDialog/utils/chat";
export const useChatAgent = () => {
const [chatHistory, setChatHistory] = useAtom(chatHistoryAtom);
const addUserMessage = (content: string) => {
setChatHistory((prev) =>
addMessages(prev, [
{
type: "user",
content,
},
]),
);
};
const addAssistantMessage = () => {
setChatHistory((prev) =>
addMessages(prev, [
{
type: "assistant",
content: "",
isGenerating: true,
},
]),
);
};
const setLastRetryAttempt = () => {
setChatHistory((prev) =>
updateAssistantContent(prev, {
lastAttemptAt: Date.now(),
}),
);
};
const setAssistantError = (
errorMessage: string,
errorType: "parse" | "network" | "other" = "other",
errorDetails?: Error | unknown,
) => {
const serializedErrorDetails = errorDetails
? JSON.stringify({
name: errorDetails instanceof Error ? errorDetails.name : "Error",
message:
errorDetails instanceof Error
? errorDetails.message
: String(errorDetails),
stack: errorDetails instanceof Error ? errorDetails.stack : undefined,
})
: undefined;
setChatHistory((prev) =>
updateAssistantContent(prev, {
isGenerating: false,
error: errorMessage,
errorType,
errorDetails: serializedErrorDetails,
}),
);
};
return {
addUserMessage,
addAssistantMessage,
setAssistantError,
chatHistory,
setChatHistory,
setLastRetryAttempt,
};
};