snapshot of https://github.com/excalidraw/excalidraw.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Tests / test (push) Failing after 15s
Some checks failed
Tests / test (push) Failing after 15s
This commit is contained in:
403
packages/excalidraw/components/TTDDialog/Chat/Chat.scss
Normal file
403
packages/excalidraw/components/TTDDialog/Chat/Chat.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
191
packages/excalidraw/components/TTDDialog/Chat/ChatInterface.tsx
Normal file
191
packages/excalidraw/components/TTDDialog/Chat/ChatInterface.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
218
packages/excalidraw/components/TTDDialog/Chat/ChatMessage.tsx
Normal file
218
packages/excalidraw/components/TTDDialog/Chat/ChatMessage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
161
packages/excalidraw/components/TTDDialog/Chat/TTDChatPanel.tsx
Normal file
161
packages/excalidraw/components/TTDDialog/Chat/TTDChatPanel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
3
packages/excalidraw/components/TTDDialog/Chat/index.ts
Normal file
3
packages/excalidraw/components/TTDDialog/Chat/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { ChatInterface } from "./ChatInterface";
|
||||
export { ChatMessage } from "./ChatMessage";
|
||||
export { useChatAgent } from "./useChatAgent";
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user