feat: TextToDiagram v2 (#10530)

* feat: introducing TextToDiagram v2 feature

* fix: eslint issue

* debug mermaid bundle size

* tests: covering the utils

* fix: import mock chunks dynamically to shrink the bundle size

* fix: removing replay feature

* fix: removing unused prop

* fix: bumping workbox cache limit

* snapshots + yarn.lock

* bump mermaid-to-excalidraw@2 and split into its own chunk

* bump node@20

* css tweaks

* move files around & rewrite stream chunk schema

* random naming & file structure refactor + some tweaks

* fix preview theme

* support custom warning renderer

* label and css fix

* fix and rwrite 429 handling

* fix label

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
Tamas L
2026-01-15 19:15:41 +01:00
committed by GitHub
parent 6ebf52279d
commit a0b98a944f
45 changed files with 5226 additions and 942 deletions
+2 -2
View File
@@ -12,7 +12,7 @@ VITE_APP_WS_SERVER_URL=http://localhost:3002
VITE_APP_PLUS_LP=https://plus.excalidraw.com VITE_APP_PLUS_LP=https://plus.excalidraw.com
VITE_APP_PLUS_APP=http://localhost:3000 VITE_APP_PLUS_APP=http://localhost:3000
VITE_APP_AI_BACKEND=http://localhost:3015 VITE_APP_AI_BACKEND=http://localhost:3016
VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}' VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}'
@@ -27,7 +27,7 @@ VITE_APP_ENABLE_TRACKING=true
FAST_REFRESH=false FAST_REFRESH=false
# The port the run the dev server # The port the run the dev server
VITE_APP_PORT=3000 VITE_APP_PORT=3001
#Debug flags #Debug flags
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
- name: "Install Node" - name: "Install Node"
uses: actions/setup-node@v2 uses: actions/setup-node@v2
with: with:
node-version: "18.x" node-version: "20.x"
- name: "Install Deps" - name: "Install Deps"
run: yarn install run: yarn install
- name: "Test Coverage" - name: "Test Coverage"
+14 -52
View File
@@ -4,6 +4,7 @@ import {
getTextFromElements, getTextFromElements,
MIME_TYPES, MIME_TYPES,
TTDDialog, TTDDialog,
TTDStreamFetch,
} from "@excalidraw/excalidraw"; } from "@excalidraw/excalidraw";
import { getDataURL } from "@excalidraw/excalidraw/data/blob"; import { getDataURL } from "@excalidraw/excalidraw/data/blob";
import { safelyParseJSON } from "@excalidraw/common"; import { safelyParseJSON } from "@excalidraw/common";
@@ -99,60 +100,21 @@ export const AIComponents = ({
/> />
<TTDDialog <TTDDialog
onTextSubmit={async (input) => { onTextSubmit={async (props) => {
try { const { onChunk, onStreamCreated, signal, messages } = props;
const response = await fetch(
`${
import.meta.env.VITE_APP_AI_BACKEND
}/v1/ai/text-to-diagram/generate`,
{
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ prompt: input }),
},
);
const rateLimit = response.headers.has("X-Ratelimit-Limit") const result = await TTDStreamFetch({
? parseInt(response.headers.get("X-Ratelimit-Limit") || "0", 10) url: `${
: undefined; import.meta.env.VITE_APP_AI_BACKEND
}/v1/ai/text-to-diagram/chat-streaming`,
messages,
onChunk,
onStreamCreated,
extractRateLimits: true,
signal,
});
const rateLimitRemaining = response.headers.has( return result;
"X-Ratelimit-Remaining",
)
? parseInt(
response.headers.get("X-Ratelimit-Remaining") || "0",
10,
)
: undefined;
const json = await response.json();
if (!response.ok) {
if (response.status === 429) {
return {
rateLimit,
rateLimitRemaining,
error: new Error(
"Too many requests today, please try again tomorrow!",
),
};
}
throw new Error(json.message || "Generation failed...");
}
const generatedResponse = json.generatedResponse;
if (!generatedResponse) {
throw new Error("Generation failed...");
}
return { generatedResponse, rateLimit, rateLimitRemaining };
} catch (err: any) {
throw new Error("Request failed");
}
}} }}
/> />
</> </>
+5
View File
@@ -102,6 +102,10 @@ export default defineConfig(({ mode }) => {
// Taking the substring after "locales/" // Taking the substring after "locales/"
return `locales/${id.substring(index + 8)}`; return `locales/${id.substring(index + 8)}`;
} }
if (id.includes("@excalidraw/mermaid-to-excalidraw")) {
return "mermaid-to-excalidraw";
}
}, },
}, },
}, },
@@ -196,6 +200,7 @@ export default defineConfig(({ mode }) => {
}, },
}, },
], ],
maximumFileSizeToCacheInBytes: 2.3 * 1024 ** 2, // 2.3MB
}, },
manifest: { manifest: {
short_name: "Excalidraw", short_name: "Excalidraw",
@@ -44,7 +44,6 @@ import { getSelectedElements } from "../../scene";
import { import {
LockedIcon, LockedIcon,
UnlockedIcon, UnlockedIcon,
clockIcon,
searchIcon, searchIcon,
boltIcon, boltIcon,
bucketFillIcon, bucketFillIcon,
@@ -52,6 +51,7 @@ import {
mermaidLogoIcon, mermaidLogoIcon,
brainIconThin, brainIconThin,
LibraryIcon, LibraryIcon,
historyCommandIcon,
} from "../icons"; } from "../icons";
import { SHAPES } from "../shapes"; import { SHAPES } from "../shapes";
@@ -928,7 +928,7 @@ function CommandPaletteInner({
marginLeft: "6px", marginLeft: "6px",
}} }}
> >
{clockIcon} {historyCommandIcon}
</div> </div>
</div> </div>
<CommandItem <CommandItem
@@ -60,7 +60,19 @@
} }
&[disabled] { &[disabled] {
pointer-events: none; cursor: not-allowed;
&.ExcButton--variant-filled,
&:hover {
--back-color: var(--color-surface-low) !important;
--text-color: var(--color-on-surface-variant) !important;
}
&.ExcButton--variant-outlined,
&.ExcButton--variant-icon {
--text-color: var(--color-on-surface-variant);
--border-color: var(--color-surface-high);
}
} }
&, &,
@@ -33,6 +33,7 @@ export type FilledButtonProps = {
fullWidth?: boolean; fullWidth?: boolean;
icon?: React.ReactNode; icon?: React.ReactNode;
disabled?: boolean;
}; };
export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>( export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
@@ -48,6 +49,7 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
fullWidth, fullWidth,
className, className,
status, status,
disabled,
}, },
ref, ref,
) => { ) => {
@@ -94,7 +96,7 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
type="button" type="button"
aria-label={label} aria-label={label}
ref={ref} ref={ref}
disabled={_status === "loading" || _status === "success"} disabled={disabled || _status === "loading" || _status === "success"}
> >
<div className="ExcButton__contents"> <div className="ExcButton__contents">
{_status === "loading" ? ( {_status === "loading" ? (
-1
View File
@@ -49,7 +49,6 @@ export const Modal: React.FC<{
aria-modal="true" aria-modal="true"
onKeyDown={handleKeydown} onKeyDown={handleKeydown}
aria-labelledby={props.labelledBy} aria-labelledby={props.labelledBy}
data-prevent-outside-click
> >
<div <div
className="Modal__background" className="Modal__background"
@@ -0,0 +1,386 @@
@import "../../../css/variables.module.scss";
$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;
}
}
&__empty-state {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: 200px;
&-content {
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-link {
display: flex;
align-items: center;
background: none;
border: none;
color: var(--color-primary);
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
padding: 0;
margin-top: 1rem;
text-decoration: underline;
font-family: inherit;
&: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,92 @@
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}
placement="bottom"
>
<>
{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>
);
};
@@ -0,0 +1,178 @@
import React, { useRef, useEffect } from "react";
import { KEYS } from "@excalidraw/common";
import { ArrowRightIcon, stop as StopIcon } from "../../icons";
import { InlineIcon } from "../../InlineIcon";
import { t } from "../../../i18n";
import { ChatMessage } from "./ChatMessage";
import type { TChat, TTTDDialog } from "../types";
import type { FormEventHandler } from "react";
export const ChatInterface = ({
chatId,
messages,
currentPrompt,
onPromptChange,
onSendMessage,
isGenerating,
rateLimits,
placeholder,
onAbort,
onMermaidTabClick,
onAiRepairClick,
onDeleteMessage,
onInsertMessage,
onRetry,
renderWarning,
}: {
chatId: string;
messages: TChat.ChatMessage[];
currentPrompt: string;
onPromptChange: (prompt: string) => void;
onSendMessage: (message: string) => void;
isGenerating: boolean;
rateLimits?: {
rateLimit: number;
rateLimitRemaining: number;
} | null;
onViewAsMermaid?: () => void;
generatedResponse?: string | null;
placeholder: {
title: string;
description: string;
hint: 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;
renderWarning?: TTTDDialog.renderWarning;
}) => {
const messagesEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
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;
}
onSendMessage(trimmedPrompt);
onPromptChange("");
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === KEYS.ENTER && !event.shiftKey) {
event.preventDefault();
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__empty-state">
<div className="chat-interface__empty-state-content">
<h3>{placeholder.title}</h3>
<p>{placeholder.description}</p>
<p>{placeholder.hint}</p>
</div>
</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}
/>
))
)}
<div ref={messagesEndRef} />
</div>
<div className="chat-interface__input-container">
<div className="chat-interface__input-outer">
<div className="chat-interface__input-wrapper">
<textarea
ref={textareaRef}
autoFocus
className="chat-interface__input"
value={currentPrompt}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder={
rateLimits?.rateLimitRemaining === 0
? t("chat.rateLimit.messageLimitInputPlaceholder")
: messages.length > 0
? t("chat.inputPlaceholderWithMessages")
: t("chat.inputPlaceholder", { shortcut: "Shift + Enter" })
}
disabled={isGenerating || 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>
);
};
@@ -0,0 +1,212 @@
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;
}> = ({
message,
onMermaidTabClick,
onAiRepairClick,
onDeleteMessage,
onInsertMessage,
onRetry,
rateLimitRemaining,
isLastMessage,
renderWarning,
}) => {
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.error}</div>
{message.errorType === "parse" && (
<>
<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>
);
};
@@ -0,0 +1,163 @@
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 ".";
import type { TTDPanelAction } from "../TTDDialogPanel";
import type { SavedChat, TChat, TTTDDialog } from "../types";
export const TTDChatPanel = ({
chatId,
messages,
currentPrompt,
onPromptChange,
onSendMessage,
isGenerating,
generatedResponse,
isMenuOpen,
onMenuToggle,
onMenuClose,
onNewChat,
onRestoreChat,
onDeleteChat,
savedChats,
activeSessionId,
onAbort,
onMermaidTabClick,
onAiRepairClick,
onDeleteMessage,
onInsertMessage,
onRetry,
onViewAsMermaid,
renderWarning,
}: {
chatId: string;
messages: TChat.ChatMessage[];
currentPrompt: string;
onPromptChange: (prompt: string) => void;
onSendMessage: (message: string, isRepairFlow?: boolean) => void;
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;
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}
onSendMessage={onSendMessage}
isGenerating={isGenerating}
generatedResponse={generatedResponse}
onAbort={onAbort}
onMermaidTabClick={onMermaidTabClick}
onAiRepairClick={onAiRepairClick}
onDeleteMessage={onDeleteMessage}
onInsertMessage={onInsertMessage}
onRetry={onRetry}
rateLimits={rateLimits}
placeholder={{
title: t("chat.placeholder.title"),
description: t("chat.placeholder.description"),
hint: t("chat.placeholder.hint"),
}}
renderWarning={renderWarning}
/>
</TTDDialogPanel>
);
};
@@ -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,
};
};
@@ -10,6 +10,8 @@ import { EditorLocalStorage } from "../../data/EditorLocalStorage";
import { t } from "../../i18n"; import { t } from "../../i18n";
import Trans from "../Trans"; import Trans from "../Trans";
import { useUIAppState } from "../../context/ui-appState";
import { TTDDialogInput } from "./TTDDialogInput"; import { TTDDialogInput } from "./TTDDialogInput";
import { TTDDialogOutput } from "./TTDDialogOutput"; import { TTDDialogOutput } from "./TTDDialogOutput";
import { TTDDialogPanel } from "./TTDDialogPanel"; import { TTDDialogPanel } from "./TTDDialogPanel";
@@ -19,12 +21,13 @@ import {
convertMermaidToExcalidraw, convertMermaidToExcalidraw,
insertToEditor, insertToEditor,
saveMermaidDataToStorage, saveMermaidDataToStorage,
resetPreview,
} from "./common"; } from "./common";
import "./MermaidToExcalidraw.scss"; import "./MermaidToExcalidraw.scss";
import type { BinaryFiles } from "../../types"; import type { BinaryFiles } from "../../types";
import type { MermaidToExcalidrawLibProps } from "./common"; import type { MermaidToExcalidrawLibProps } from "./types";
const MERMAID_EXAMPLE = const MERMAID_EXAMPLE =
"flowchart TD\n A[Christmas] -->|Get money| B(Go shopping)\n B --> C{Let me think}\n C -->|One| D[Laptop]\n C -->|Two| E[iPhone]\n C -->|Three| F[Car]"; "flowchart TD\n A[Christmas] -->|Get money| B(Go shopping)\n B --> C{Let me think}\n C -->|One| D[Laptop]\n C -->|Two| E[iPhone]\n C -->|Three| F[Car]";
@@ -33,8 +36,10 @@ const debouncedSaveMermaidDefinition = debounce(saveMermaidDataToStorage, 300);
const MermaidToExcalidraw = ({ const MermaidToExcalidraw = ({
mermaidToExcalidrawLib, mermaidToExcalidrawLib,
isActive,
}: { }: {
mermaidToExcalidrawLib: MermaidToExcalidrawLibProps; mermaidToExcalidrawLib: MermaidToExcalidrawLibProps;
isActive?: boolean;
}) => { }) => {
const [text, setText] = useState( const [text, setText] = useState(
() => () =>
@@ -51,22 +56,40 @@ const MermaidToExcalidraw = ({
}>({ elements: [], files: null }); }>({ elements: [], files: null });
const app = useApp(); const app = useApp();
const { theme } = useUIAppState();
useEffect(() => { useEffect(() => {
convertMermaidToExcalidraw({ const doRender = async () => {
canvasRef, try {
data, if (!deferredText) {
mermaidToExcalidrawLib, resetPreview({ canvasRef, setError });
setError, return;
mermaidDefinition: deferredText, }
}).catch((err) => { const result = await convertMermaidToExcalidraw({
if (isDevEnv()) { canvasRef,
console.error("Failed to parse mermaid definition", err); data,
} mermaidToExcalidrawLib,
}); setError,
mermaidDefinition: deferredText,
theme,
});
debouncedSaveMermaidDefinition(deferredText); if (!result.success) {
}, [deferredText, mermaidToExcalidrawLib]); const err = result.error ?? new Error("Invalid mermaid definition");
setError(err);
}
} catch (err) {
if (isDevEnv()) {
console.error("Failed to parse mermaid definition", err);
}
}
};
if (isActive) {
doRender();
debouncedSaveMermaidDefinition(deferredText);
}
}, [deferredText, mermaidToExcalidrawLib, isActive, theme]);
useEffect( useEffect(
() => () => { () => () => {
@@ -103,10 +126,10 @@ const MermaidToExcalidraw = ({
/> />
</div> </div>
<TTDDialogPanels> <TTDDialogPanels>
<TTDDialogPanel label={t("mermaid.syntax")}> <TTDDialogPanel>
<TTDDialogInput <TTDDialogInput
input={text} input={text}
placeholder={"Write Mermaid diagram defintion here..."} placeholder={t("mermaid.inputPlaceholder")}
onChange={(event) => setText(event.target.value)} onChange={(event) => setText(event.target.value)}
onKeyboardSubmit={() => { onKeyboardSubmit={() => {
onInsertToEditor(); onInsertToEditor();
@@ -114,14 +137,16 @@ const MermaidToExcalidraw = ({
/> />
</TTDDialogPanel> </TTDDialogPanel>
<TTDDialogPanel <TTDDialogPanel
label={t("mermaid.preview")} panelActions={[
panelAction={{ {
action: () => { action: () => {
onInsertToEditor(); onInsertToEditor();
},
label: t("mermaid.button"),
icon: ArrowRightIcon,
variant: "button",
}, },
label: t("mermaid.button"), ]}
icon: ArrowRightIcon,
}}
renderSubmitShortcut={() => <TTDDialogSubmitShortcut />} renderSubmitShortcut={() => <TTDDialogSubmitShortcut />}
> >
<TTDDialogOutput <TTDDialogOutput
@@ -0,0 +1,17 @@
import { randomId } from "@excalidraw/common";
import { atom } from "../../editor-jotai";
import type { RateLimits, TChat } from "./types";
export const rateLimitsAtom = atom<RateLimits | null>(null);
export const showPreviewAtom = atom<boolean>(false);
export const errorAtom = atom<Error | null>(null);
export const chatHistoryAtom = atom<TChat.ChatHistory>({
id: randomId(),
messages: [],
currentPrompt: "",
});
@@ -1,6 +1,8 @@
@use "../../css/variables.module" as *; @use "../../css/variables.module.scss" as *;
@use "./Chat/Chat.scss";
$verticalBreakpoint: 861px; $verticalBreakpoint: 861px;
$fullScreenModalBreakpoint: 600px;
.excalidraw { .excalidraw {
.Modal.Dialog.ttd-dialog { .Modal.Dialog.ttd-dialog {
@@ -16,21 +18,26 @@ $verticalBreakpoint: 861px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1 1 auto; flex: 1 1 auto;
box-shadow: none;
} }
.Modal__content { .Modal__content {
height: auto;
max-height: 100%; max-height: 100%;
min-height: 95vh;
height: 100%;
@media screen and (min-width: $verticalBreakpoint) { @media screen and (min-width: $verticalBreakpoint) {
max-height: 750px; max-height: min(950px, calc(100vh - 4rem));
height: 100%; height: 100%;
min-height: unset;
} }
} }
.Dialog__content { .Dialog__content {
flex: 1 1 auto; flex: 1 1 auto;
@media screen and (max-width: $verticalBreakpoint) {
margin-top: 0 !important;
}
} }
} }
@@ -38,7 +45,7 @@ $verticalBreakpoint: 861px;
font-size: 15px; font-size: 15px;
font-style: italic; font-style: italic;
font-weight: 500; font-weight: 500;
margin-bottom: 1.5rem; margin: 0.5rem 0 1.5rem 0;
} }
.ttd-dialog-tabs-root { .ttd-dialog-tabs-root {
@@ -63,12 +70,33 @@ $verticalBreakpoint: 861px;
&[data-state="active"] { &[data-state="active"] {
border-bottom: 2px solid var(--color-primary); border-bottom: 2px solid var(--color-primary);
} }
&__content {
display: flex;
align-items: center;
}
&__badge {
display: flex;
align-items: center;
justify-content: center;
padding: 1px 6px;
margin-left: 10px;
font-size: 10px;
border-radius: 12px;
background: var(--color-promo);
color: var(--color-surface-lowest);
}
} }
.ttd-dialog-triggers { .ttd-dialog-triggers {
border-bottom: 1px solid var(--color-surface-high); border-bottom: 1px solid var(--color-surface-high);
margin-bottom: 1.5rem; margin-bottom: 1rem;
padding-inline: 2.5rem; padding-inline: 2.5rem;
@media screen and (max-width: $verticalBreakpoint) {
margin-bottom: 1rem;
}
} }
.ttd-dialog-content { .ttd-dialog-content {
@@ -76,10 +104,102 @@ $verticalBreakpoint: 861px;
height: 100%; height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: visible;
outline: none;
&[hidden] { &[hidden] {
display: none; display: none;
} }
@media screen and (max-width: $verticalBreakpoint) {
padding-inline: 1rem;
flex-grow: 1;
}
}
.ttd-dialog-panel__header .dropdown-menu {
z-index: 2;
margin: 0;
right: 0;
left: auto;
min-width: 280px;
.dropdown-menu-container.dropdown-menu-container {
padding-inline: 0.5rem !important;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
max-height: min(400px, 70vh);
height: fit-content;
overflow-y: auto;
box-shadow: var(--shadow-island);
border-radius: var(--border-radius-lg);
background-color: var(--island-bg-color-alt);
}
}
.ttd-dialog-layout {
display: grid;
grid-template-columns: 1fr 1fr;
height: 100%;
&--split {
gap: 2rem;
min-width: 0;
@media screen and (max-width: $verticalBreakpoint) {
grid-template-columns: 1fr;
grid-template-rows: 1fr auto;
grid-row-gap: 0;
}
.ttd-dialog-chat-panel {
min-width: 0;
@media screen and (max-width: $verticalBreakpoint) {
width: 100%;
flex: 0 0 auto;
height: auto;
min-height: 250px;
}
}
.ttd-dialog-preview-panel {
min-width: 0;
overflow: hidden;
@media screen and (max-width: $verticalBreakpoint) {
width: 100%;
flex: 0 0 auto;
max-width: 100%;
min-height: 280px;
height: unset;
}
}
}
&--chat-only {
grid-template-columns: 1fr;
.invisible {
display: none;
}
.ttd-dialog-chat-panel {
@media screen and (max-width: $verticalBreakpoint) {
height: unset;
}
}
@media screen and (max-width: $verticalBreakpoint) {
.chat-interface {
max-height: 100%;
&__messages {
max-height: unset;
}
}
}
}
} }
.ttd-dialog-input { .ttd-dialog-input {
@@ -101,12 +221,15 @@ $verticalBreakpoint: 861px;
.ttd-dialog-output-wrapper { .ttd-dialog-output-wrapper {
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 0.85rem;
box-sizing: border-box; box-sizing: border-box;
flex-grow: 1; flex-grow: 1;
position: relative; position: relative;
overflow: hidden;
max-width: 100%;
max-height: 100%;
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==") background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==")
left center; left center;
@@ -115,16 +238,29 @@ $verticalBreakpoint: 861px;
height: 400px; height: 400px;
width: auto; width: auto;
max-width: 100%;
@media screen and (min-width: $verticalBreakpoint) { @media screen and (max-width: $fullScreenModalBreakpoint) {
width: 100%;
// acts as min-height
height: 200px; height: 200px;
} }
@media screen and (max-width: $verticalBreakpoint) {
width: 100%;
max-width: 100%;
}
&--error {
background: none;
border: 1px solid var(--dialog-border-color);
border-radius: var(--border-radius-lg);
}
canvas { canvas {
max-width: 100%; max-width: 100%;
max-height: 100%; max-height: 100%;
width: auto;
height: auto;
object-fit: contain;
} }
} }
@@ -135,28 +271,74 @@ $verticalBreakpoint: 861px;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-grow: 1; flex-grow: 1;
position: relative;
user-select: none;
&.invisible {
visibility: hidden;
pointer-events: none;
}
}
.ttd-dialog-output-canvas-content {
display: flex;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
flex-grow: 1;
canvas {
image-rendering: auto;
}
} }
.ttd-dialog-output-error { .ttd-dialog-output-error {
color: red;
font-weight: 700;
font-size: 30px;
word-break: break-word;
overflow: auto;
max-height: 100%;
height: 100%;
width: 100%;
text-align: center;
position: absolute; position: absolute;
z-index: 10; z-index: 10;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
p { .ttd-dialog-output-error-content {
font-weight: 500; display: flex;
font-family: Cascadia; flex-direction: column;
align-items: center;
gap: 1rem;
padding: 2.5rem 2rem;
background: var(--color-surface-primary);
}
.ttd-dialog-output-error-icon {
color: var(--color-danger);
display: flex;
align-items: center;
justify-content: center;
svg {
width: 52px;
height: 52px;
stroke-width: 1.5;
}
}
.ttd-dialog-output-error-title {
font-size: 1.25rem;
font-weight: 600;
color: var(--color-danger);
text-align: center;
margin-top: 0.25rem;
}
.ttd-dialog-output-error-message {
text-align: left; text-align: left;
font-weight: 400;
color: var(--color-gray-50);
word-break: break-word;
white-space: pre-wrap; white-space: pre-wrap;
font-size: 0.875rem; max-width: 100%;
padding: 0 10px; font-family: monospace;
} }
} }
@@ -166,37 +348,86 @@ $verticalBreakpoint: 861px;
@media screen and (min-width: $verticalBreakpoint) { @media screen and (min-width: $verticalBreakpoint) {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 4rem; gap: 2rem;
} }
} }
.ttd-dialog-chat-panel,
.ttd-dialog-preview-panel {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-width: 0;
align-items: stretch;
}
.ttd-dialog-panel { .ttd-dialog-panel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 100%; width: 100%;
height: 100%;
@media screen and (max-width: $verticalBreakpoint) {
height: 50%;
}
&__header { &__header {
display: flex; display: flex;
margin: 0px 4px 4px 4px;
align-items: center; align-items: center;
gap: 1rem; gap: 0.3rem;
height: 36px;
margin-top: 0.2rem;
margin-bottom: 0.5rem;
flex-shrink: 0;
label { label {
font-size: 14px; font-size: 14px;
line-height: 22px;
font-style: normal; font-style: normal;
font-weight: 600; font-weight: 600;
} }
} }
&:first-child { &__label-wrapper {
.ttd-dialog-panel-button-container:not(.invisible) { display: flex;
margin-bottom: 4rem; justify-content: space-between;
align-items: center;
width: 100%;
}
&__label-group {
display: flex;
gap: 5px;
}
&__header-right {
display: flex;
align-items: center;
gap: 0.75rem;
}
&__rate-limit {
font-size: 0.75rem;
font-weight: 500;
white-space: nowrap;
&--danger {
color: var(--color-danger);
} }
} }
@media screen and (min-width: $verticalBreakpoint) { &__menu-wrapper {
.ttd-dialog-panel-button-container:not(.invisible) { position: relative;
margin-bottom: 0.5rem !important;
.ttd-dialog-menu-trigger {
height: 40px;
width: 40px;
}
.dropdown-menu {
margin-top: 0.375rem;
right: 0;
} }
} }
@@ -213,23 +444,28 @@ $verticalBreakpoint: 861px;
@media screen and (max-width: $verticalBreakpoint) { @media screen and (max-width: $verticalBreakpoint) {
width: auto; width: auto;
height: 10rem;
} }
} }
} }
.ttd-dialog-panel-button-container { .ttd-dialog-panel-button-container {
display: flex;
align-items: center;
flex-grow: 0;
height: 40px;
flex-shrink: 0;
margin-top: 1rem; margin-top: 1rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
&.invisible { @media screen and (max-width: $verticalBreakpoint) {
.ttd-dialog-panel-button { margin-top: 0.5rem;
display: none; }
@media screen and (min-width: $verticalBreakpoint) { &.invisible {
display: block; visibility: hidden;
visibility: hidden;
} @media screen and (max-width: $verticalBreakpoint) {
display: none;
} }
} }
} }
@@ -314,4 +550,109 @@ $verticalBreakpoint: 861px;
border-radius: 4px; border-radius: 4px;
} }
} }
.ttd-dialog-panel-action-link {
display: flex;
align-items: center;
gap: 0.25rem;
background: none;
border: none;
color: var(--color-primary);
font-weight: 500;
font-family: inherit;
height: 40px;
&__icon {
display: inline-flex;
align-items: center;
svg {
width: 12px;
height: 12px;
}
}
@media screen and (max-width: $verticalBreakpoint) {
height: unset;
}
}
.dropdown-menu-item-custom.ttd-chat-menu-item {
display: flex;
width: unset;
align-items: center;
justify-content: space-between;
padding: 0.25rem 0.5rem;
cursor: pointer;
position: relative;
border: 1px solid transparent;
border-radius: var(--border-radius-md);
margin-top: 1px;
&:hover {
background-color: var(--button-hover-bg);
}
&:active {
border: 1px solid var(--button-active-border);
}
&--active {
background-color: var(--color-surface-primary-container);
}
.ttd-chat-menu-item__title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&:hover {
.ttd-chat-menu-item__delete {
visibility: visible;
}
}
.ttd-chat-menu-item__delete {
border: none;
padding: 0.25rem;
cursor: pointer;
flex-shrink: 0;
margin-left: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
visibility: hidden;
&:hover {
color: var(--color-danger);
}
@at-root .excalidraw.theme--dark#{&} {
svg {
color: var(--color-on-surface);
}
&:hover svg {
color: var(--color-danger);
}
}
svg {
width: 16px;
height: 16px;
}
}
}
.ttd-dialog-preview-panel--hidden {
display: none;
}
.ttd-chat-history-menu {
display: flex;
gap: 1rem;
align-items: center;
}
} }
@@ -1,71 +1,27 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useState } from "react";
import { isFiniteNumber } from "@excalidraw/math";
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
import { trackEvent } from "../../analytics";
import { useUIAppState } from "../../context/ui-appState"; import { useUIAppState } from "../../context/ui-appState";
import { atom, useAtom } from "../../editor-jotai";
import { t } from "../../i18n"; import { t } from "../../i18n";
import { useApp, useExcalidrawSetAppState } from "../App"; import { useApp } from "../App";
import { Dialog } from "../Dialog"; import { Dialog } from "../Dialog";
import { InlineIcon } from "../InlineIcon";
import { withInternalFallback } from "../hoc/withInternalFallback"; import { withInternalFallback } from "../hoc/withInternalFallback";
import { ArrowRightIcon } from "../icons";
import MermaidToExcalidraw from "./MermaidToExcalidraw"; import MermaidToExcalidraw from "./MermaidToExcalidraw";
import TextToDiagram from "./TextToDiagram";
import TTDDialogTabs from "./TTDDialogTabs"; import TTDDialogTabs from "./TTDDialogTabs";
import { TTDDialogTabTriggers } from "./TTDDialogTabTriggers"; import { TTDDialogTabTriggers } from "./TTDDialogTabTriggers";
import { TTDDialogTabTrigger } from "./TTDDialogTabTrigger"; import { TTDDialogTabTrigger } from "./TTDDialogTabTrigger";
import { TTDDialogTab } from "./TTDDialogTab"; import { TTDDialogTab } from "./TTDDialogTab";
import { TTDDialogInput } from "./TTDDialogInput";
import { TTDDialogOutput } from "./TTDDialogOutput";
import { TTDDialogPanel } from "./TTDDialogPanel";
import { TTDDialogPanels } from "./TTDDialogPanels";
import {
convertMermaidToExcalidraw,
insertToEditor,
saveMermaidDataToStorage,
} from "./common";
import { TTDDialogSubmitShortcut } from "./TTDDialogSubmitShortcut";
import "./TTDDialog.scss"; import "./TTDDialog.scss";
import type { ChangeEventHandler } from "react"; import type { MermaidToExcalidrawLibProps, TTTDDialog } from "./types";
import type { MermaidToExcalidrawLibProps } from "./common";
import type { BinaryFiles } from "../../types";
const MIN_PROMPT_LENGTH = 3;
const MAX_PROMPT_LENGTH = 1000;
const rateLimitsAtom = atom<{
rateLimit: number;
rateLimitRemaining: number;
} | null>(null);
const ttdGenerationAtom = atom<{
generatedResponse: string | null;
prompt: string | null;
} | null>(null);
type OnTestSubmitRetValue = {
rateLimit?: number | null;
rateLimitRemaining?: number | null;
} & (
| { generatedResponse: string | undefined; error?: null | undefined }
| {
error: Error;
generatedResponse?: null | undefined;
}
);
export const TTDDialog = ( export const TTDDialog = (
props: props:
| { | {
onTextSubmit(value: string): Promise<OnTestSubmitRetValue>; onTextSubmit: TTTDDialog.onTextSubmit;
renderWarning?: TTTDDialog.renderWarning;
} }
| { __fallback: true }, | { __fallback: true },
) => { ) => {
@@ -81,7 +37,7 @@ export const TTDDialog = (
/** /**
* Text to diagram (TTD) dialog * Text to diagram (TTD) dialog
*/ */
export const TTDDialogBase = withInternalFallback( const TTDDialogBase = withInternalFallback(
"TTDDialogBase", "TTDDialogBase",
({ ({
tab, tab,
@@ -90,127 +46,14 @@ export const TTDDialogBase = withInternalFallback(
tab: "text-to-diagram" | "mermaid"; tab: "text-to-diagram" | "mermaid";
} & ( } & (
| { | {
onTextSubmit(value: string): Promise<OnTestSubmitRetValue>; onTextSubmit(
props: TTTDDialog.OnTextSubmitProps,
): Promise<TTTDDialog.OnTextSubmitRetValue>;
renderWarning?: TTTDDialog.renderWarning;
} }
| { __fallback: true } | { __fallback: true }
)) => { )) => {
const app = useApp(); const app = useApp();
const setAppState = useExcalidrawSetAppState();
const someRandomDivRef = useRef<HTMLDivElement>(null);
const [ttdGeneration, setTtdGeneration] = useAtom(ttdGenerationAtom);
const [text, setText] = useState(ttdGeneration?.prompt ?? "");
const prompt = text.trim();
const handleTextChange: ChangeEventHandler<HTMLTextAreaElement> = (
event,
) => {
setText(event.target.value);
setTtdGeneration((s) => ({
generatedResponse: s?.generatedResponse ?? null,
prompt: event.target.value,
}));
};
const [onTextSubmitInProgess, setOnTextSubmitInProgess] = useState(false);
const [rateLimits, setRateLimits] = useAtom(rateLimitsAtom);
const onGenerate = async () => {
if (
prompt.length > MAX_PROMPT_LENGTH ||
prompt.length < MIN_PROMPT_LENGTH ||
onTextSubmitInProgess ||
rateLimits?.rateLimitRemaining === 0 ||
// means this is not a text-to-diagram dialog (needed for TS only)
"__fallback" in rest
) {
if (prompt.length < MIN_PROMPT_LENGTH) {
setError(
new Error(
`Prompt is too short (min ${MIN_PROMPT_LENGTH} characters)`,
),
);
}
if (prompt.length > MAX_PROMPT_LENGTH) {
setError(
new Error(
`Prompt is too long (max ${MAX_PROMPT_LENGTH} characters)`,
),
);
}
return;
}
try {
setOnTextSubmitInProgess(true);
trackEvent("ai", "generate", "ttd");
const { generatedResponse, error, rateLimit, rateLimitRemaining } =
await rest.onTextSubmit(prompt);
if (typeof generatedResponse === "string") {
setTtdGeneration((s) => ({
generatedResponse,
prompt: s?.prompt ?? null,
}));
}
if (isFiniteNumber(rateLimit) && isFiniteNumber(rateLimitRemaining)) {
setRateLimits({ rateLimit, rateLimitRemaining });
}
if (error) {
setError(error);
return;
}
if (!generatedResponse) {
setError(new Error("Generation failed"));
return;
}
try {
await convertMermaidToExcalidraw({
canvasRef: someRandomDivRef,
data,
mermaidToExcalidrawLib,
setError,
mermaidDefinition: generatedResponse,
});
trackEvent("ai", "mermaid parse success", "ttd");
} catch (error: any) {
console.info(
`%cTTD mermaid render errror: ${error.message}`,
"color: red",
);
console.info(
`>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>\nTTD mermaid definition render errror: ${error.message}`,
"color: yellow",
);
trackEvent("ai", "mermaid parse failed", "ttd");
setError(
new Error(
"Generated an invalid diagram :(. You may also try a different prompt.",
),
);
}
} catch (error: any) {
let message: string | undefined = error.message;
if (!message || message === "Failed to fetch") {
message = "Request failed";
}
setError(new Error(message));
} finally {
setOnTextSubmitInProgess(false);
}
};
const refOnGenerate = useRef(onGenerate);
refOnGenerate.current = onGenerate;
const [mermaidToExcalidrawLib, setMermaidToExcalidrawLib] = const [mermaidToExcalidrawLib, setMermaidToExcalidrawLib] =
useState<MermaidToExcalidrawLibProps>({ useState<MermaidToExcalidrawLibProps>({
@@ -226,20 +69,13 @@ export const TTDDialogBase = withInternalFallback(
fn(); fn();
}, [mermaidToExcalidrawLib.api]); }, [mermaidToExcalidrawLib.api]);
const data = useRef<{
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles | null;
}>({ elements: [], files: null });
const [error, setError] = useState<Error | null>(null);
return ( return (
<Dialog <Dialog
className="ttd-dialog" className="ttd-dialog"
onCloseRequest={() => { onCloseRequest={() => {
app.setOpenDialog(null); app.setOpenDialog(null);
}} }}
size={1200} size={1520}
title={false} title={false}
{...rest} {...rest}
autofocus={false} autofocus={false}
@@ -250,150 +86,34 @@ export const TTDDialogBase = withInternalFallback(
) : ( ) : (
<TTDDialogTabTriggers> <TTDDialogTabTriggers>
<TTDDialogTabTrigger tab="text-to-diagram"> <TTDDialogTabTrigger tab="text-to-diagram">
<div style={{ display: "flex", alignItems: "center" }}> <div className="ttd-dialog-tab-trigger__content">
{t("labels.textToDiagram")} {t("labels.textToDiagram")}
<div <div className="ttd-dialog-tab-trigger__badge">
style={{ {t("chat.aiBeta")}
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "1px 6px",
marginLeft: "10px",
fontSize: 10,
borderRadius: "12px",
background: "var(--color-promo)",
color: "var(--color-surface-lowest)",
}}
>
AI Beta
</div> </div>
</div> </div>
</TTDDialogTabTrigger> </TTDDialogTabTrigger>
<TTDDialogTabTrigger tab="mermaid">Mermaid</TTDDialogTabTrigger> <TTDDialogTabTrigger tab="mermaid">
{t("mermaid.label")}
</TTDDialogTabTrigger>
</TTDDialogTabTriggers> </TTDDialogTabTriggers>
)} )}
{!("__fallback" in rest) && (
<TTDDialogTab className="ttd-dialog-content" tab="text-to-diagram">
<TextToDiagram
mermaidToExcalidrawLib={mermaidToExcalidrawLib}
onTextSubmit={rest.onTextSubmit}
renderWarning={rest.renderWarning}
/>
</TTDDialogTab>
)}
<TTDDialogTab className="ttd-dialog-content" tab="mermaid"> <TTDDialogTab className="ttd-dialog-content" tab="mermaid">
<MermaidToExcalidraw <MermaidToExcalidraw
mermaidToExcalidrawLib={mermaidToExcalidrawLib} mermaidToExcalidrawLib={mermaidToExcalidrawLib}
isActive={tab === "mermaid"}
/> />
</TTDDialogTab> </TTDDialogTab>
{!("__fallback" in rest) && (
<TTDDialogTab className="ttd-dialog-content" tab="text-to-diagram">
<div className="ttd-dialog-desc">
Currently we use Mermaid as a middle step, so you'll get best
results if you describe a diagram, workflow, flow chart, and
similar.
</div>
<TTDDialogPanels>
<TTDDialogPanel
label={t("labels.prompt")}
panelAction={{
action: onGenerate,
label: "Generate",
icon: ArrowRightIcon,
}}
onTextSubmitInProgess={onTextSubmitInProgess}
panelActionDisabled={
prompt.length > MAX_PROMPT_LENGTH ||
rateLimits?.rateLimitRemaining === 0
}
renderTopRight={() => {
if (!rateLimits) {
return null;
}
return (
<div
className="ttd-dialog-rate-limit"
style={{
fontSize: 12,
marginLeft: "auto",
color:
rateLimits.rateLimitRemaining === 0
? "var(--color-danger)"
: undefined,
}}
>
{rateLimits.rateLimitRemaining} requests left today
</div>
);
}}
renderSubmitShortcut={() => <TTDDialogSubmitShortcut />}
renderBottomRight={() => {
if (typeof ttdGeneration?.generatedResponse === "string") {
return (
<div
className="excalidraw-link"
style={{ marginLeft: "auto", fontSize: 14 }}
onClick={() => {
if (
typeof ttdGeneration?.generatedResponse ===
"string"
) {
saveMermaidDataToStorage(
ttdGeneration.generatedResponse,
);
setAppState({
openDialog: { name: "ttd", tab: "mermaid" },
});
}
}}
>
View as Mermaid
<InlineIcon icon={ArrowRightIcon} />
</div>
);
}
const ratio = prompt.length / MAX_PROMPT_LENGTH;
if (ratio > 0.8) {
return (
<div
style={{
marginLeft: "auto",
fontSize: 12,
fontFamily: "monospace",
color:
ratio > 1 ? "var(--color-danger)" : undefined,
}}
>
Length: {prompt.length}/{MAX_PROMPT_LENGTH}
</div>
);
}
return null;
}}
>
<TTDDialogInput
onChange={handleTextChange}
input={text}
placeholder={"Describe what you want to see..."}
onKeyboardSubmit={() => {
refOnGenerate.current();
}}
/>
</TTDDialogPanel>
<TTDDialogPanel
label="Preview"
panelAction={{
action: () => {
console.info("Panel action clicked");
insertToEditor({ app, data });
},
label: "Insert",
icon: ArrowRightIcon,
}}
>
<TTDDialogOutput
canvasRef={someRandomDivRef}
error={error}
loaded={mermaidToExcalidrawLib.loaded}
/>
</TTDDialogPanel>
</TTDDialogPanels>
</TTDDialogTab>
)}
</TTDDialogTabs> </TTDDialogTabs>
</Dialog> </Dialog>
); );
@@ -1,36 +1,56 @@
import Spinner from "../Spinner"; import clsx from "clsx";
const ErrorComp = ({ error }: { error: string }) => { import Spinner from "../Spinner";
return ( import { t } from "../../i18n";
<div import { alertTriangleIcon } from "../icons";
data-testid="ttd-dialog-output-error"
className="ttd-dialog-output-error"
>
Error! <p>{error}</p>
</div>
);
};
interface TTDDialogOutputProps { interface TTDDialogOutputProps {
error: Error | null; error: Error | null;
canvasRef: React.RefObject<HTMLDivElement | null>; canvasRef: React.RefObject<HTMLDivElement | null>;
loaded: boolean; loaded: boolean;
hideErrorDetails?: boolean;
} }
export const TTDDialogOutput = ({ export const TTDDialogOutput = ({
error, error,
canvasRef, canvasRef,
loaded, loaded,
hideErrorDetails,
}: TTDDialogOutputProps) => { }: TTDDialogOutputProps) => {
return ( return (
<div className="ttd-dialog-output-wrapper"> <div
{error && <ErrorComp error={error.message} />} className={`ttd-dialog-output-wrapper ${
error ? "ttd-dialog-output-wrapper--error" : ""
}`}
>
{error && (
<div
key="error"
data-testid="ttd-dialog-output-error"
className="ttd-dialog-output-error"
>
<div className="ttd-dialog-output-error-content">
<div className="ttd-dialog-output-error-icon">
{alertTriangleIcon}
</div>
<div className="ttd-dialog-output-error-title">
{t("ttd.error")}
</div>
<div className="ttd-dialog-output-error-message">
{hideErrorDetails ? t("ttd.errorMermaidSyntax") : error.message}
</div>
</div>
</div>
)}
{loaded ? ( {loaded ? (
<div <div
ref={canvasRef} key="canvas"
style={{ opacity: error ? "0.15" : 1 }} className={clsx("ttd-dialog-output-canvas-container", {
className="ttd-dialog-output-canvas-container" invisible: !!error,
/> })}
>
<div ref={canvasRef} className="ttd-dialog-output-canvas-content" />
</div>
) : ( ) : (
<Spinner size="2rem" /> <Spinner size="2rem" />
)} )}
@@ -1,53 +1,76 @@
import clsx from "clsx"; import clsx from "clsx";
import { Fragment } from "react";
import { Button } from "../Button"; import { Button } from "../Button";
import Spinner from "../Spinner"; import Spinner from "../Spinner";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
interface TTDDialogPanelProps { export type TTDPanelAction = {
label: string; label: string;
action?: () => void;
icon?: ReactNode;
variant: "button" | "link" | "rateLimit";
disabled?: boolean;
className?: string;
};
interface TTDDialogPanelProps {
label?: string | ReactNode;
children: ReactNode; children: ReactNode;
panelAction?: { panelActions?: TTDPanelAction[];
label: string;
action: () => void;
icon?: ReactNode;
};
panelActionDisabled?: boolean;
onTextSubmitInProgess?: boolean; onTextSubmitInProgess?: boolean;
renderTopRight?: () => ReactNode; renderTopRight?: () => ReactNode;
renderSubmitShortcut?: () => ReactNode; renderSubmitShortcut?: () => ReactNode;
renderBottomRight?: () => ReactNode; className?: string;
panelActionJustifyContent?:
| "flex-start"
| "flex-end"
| "center"
| "space-between"
| "space-around"
| "space-evenly";
} }
export const TTDDialogPanel = ({ export const TTDDialogPanel = ({
label, label,
children, children,
panelAction, panelActions = [],
panelActionDisabled = false,
onTextSubmitInProgess, onTextSubmitInProgess,
renderTopRight, renderTopRight,
renderSubmitShortcut, renderSubmitShortcut,
renderBottomRight, className,
panelActionJustifyContent = "flex-start",
}: TTDDialogPanelProps) => { }: TTDDialogPanelProps) => {
return ( const renderPanelAction = (panelAction: TTDPanelAction) => {
<div className="ttd-dialog-panel"> if (panelAction?.variant === "link") {
<div className="ttd-dialog-panel__header"> return (
<label>{label}</label> <button
{renderTopRight?.()} className={clsx(
</div> "ttd-dialog-panel-action-link",
panelAction.className,
)}
onClick={panelAction.action}
disabled={panelAction?.disabled || onTextSubmitInProgess}
type="button"
>
{panelAction.label}
{panelAction.icon && (
<span className="ttd-dialog-panel-action-link__icon">
{panelAction.icon}
</span>
)}
</button>
);
}
{children} if (panelAction?.variant === "button") {
<div return (
className={clsx("ttd-dialog-panel-button-container", {
invisible: !panelAction,
})}
style={{ display: "flex", alignItems: "center" }}
>
<Button <Button
className="ttd-dialog-panel-button" className={clsx("ttd-dialog-panel-button", panelAction.className)}
onSelect={panelAction ? panelAction.action : () => {}} onSelect={panelAction.action ? panelAction.action : () => {}}
disabled={panelActionDisabled || onTextSubmitInProgess} disabled={panelAction?.disabled || onTextSubmitInProgess}
> >
<div className={clsx({ invisible: onTextSubmitInProgess })}> <div className={clsx({ invisible: onTextSubmitInProgess })}>
{panelAction?.label} {panelAction?.label}
@@ -55,10 +78,46 @@ export const TTDDialogPanel = ({
</div> </div>
{onTextSubmitInProgess && <Spinner />} {onTextSubmitInProgess && <Spinner />}
</Button> </Button>
{!panelActionDisabled && );
!onTextSubmitInProgess && }
renderSubmitShortcut?.()}
{renderBottomRight?.()} if (panelAction?.variant === "rateLimit") {
return (
<div
className={clsx(
"ttd-dialog-panel__rate-limit",
panelAction.className,
)}
>
{panelAction.label}
</div>
);
}
};
return (
<div className={clsx("ttd-dialog-panel", className)}>
{(label || renderTopRight) && (
<div className="ttd-dialog-panel__header">
{typeof label === "string" ? <label>{label}</label> : label}
{renderTopRight?.()}
</div>
)}
{children}
<div
className={clsx("ttd-dialog-panel-button-container", {
invisible: !panelActions.length,
})}
style={{
justifyContent: panelActionJustifyContent,
}}
>
{panelActions.filter(Boolean).map((panelAction) => (
<Fragment key={panelAction.label}>
{renderPanelAction(panelAction)}
</Fragment>
))}
{!onTextSubmitInProgess && renderSubmitShortcut?.()}
</div> </div>
</div> </div>
); );
@@ -0,0 +1,47 @@
import { t } from "../../i18n";
import { ArrowRightIcon } from "../icons";
import { TTDDialogPanel } from "./TTDDialogPanel";
import { TTDDialogOutput } from "./TTDDialogOutput";
import type { TTDPanelAction } from "./TTDDialogPanel";
interface TTDPreviewPanelProps {
canvasRef: React.RefObject<HTMLDivElement | null>;
error: Error | null;
loaded: boolean;
onInsert: () => void;
hideErrorDetails?: boolean;
}
export const TTDPreviewPanel = ({
canvasRef,
error,
loaded,
onInsert,
hideErrorDetails,
}: TTDPreviewPanelProps) => {
const actions: TTDPanelAction[] = [
{
action: onInsert,
label: t("chat.insert"),
icon: ArrowRightIcon,
variant: "button",
},
];
return (
<TTDDialogPanel
panelActionJustifyContent="flex-end"
panelActions={actions}
className="ttd-dialog-preview-panel"
>
<TTDDialogOutput
canvasRef={canvasRef}
error={error}
loaded={loaded}
hideErrorDetails={hideErrorDetails}
/>
</TTDDialogPanel>
);
};
@@ -0,0 +1,244 @@
import { useRef } from "react";
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
import { useAtom, useAtomValue } from "../../editor-jotai";
import { useApp, useExcalidrawSetAppState } from "../App";
import { useChatAgent } from "./Chat";
import {
convertMermaidToExcalidraw,
insertToEditor,
saveMermaidDataToStorage,
} from "./common";
import { errorAtom, chatHistoryAtom, showPreviewAtom } from "./TTDContext";
import { useTTDChatStorage } from "./useTTDChatStorage";
import { useMermaidRenderer } from "./hooks/useMermaidRenderer";
import { useTextGeneration } from "./hooks/useTextGeneration";
import { useChatManagement } from "./hooks/useChatManagement";
import { TTDChatPanel } from "./Chat/TTDChatPanel";
import { TTDPreviewPanel } from "./TTDPreviewPanel";
import { getLastAssistantMessage } from "./utils/chat";
import type { BinaryFiles } from "../../types";
import type { MermaidToExcalidrawLibProps, TChat, TTTDDialog } from "./types";
const TextToDiagramContent = ({
mermaidToExcalidrawLib,
onTextSubmit,
renderWarning,
}: {
mermaidToExcalidrawLib: MermaidToExcalidrawLibProps;
onTextSubmit: (
props: TTTDDialog.OnTextSubmitProps,
) => Promise<TTTDDialog.OnTextSubmitRetValue>;
renderWarning?: TTTDDialog.renderWarning;
}) => {
const app = useApp();
const setAppState = useExcalidrawSetAppState();
const canvasRef = useRef<HTMLDivElement | null>(null);
const [error, setError] = useAtom(errorAtom);
const [chatHistory, setChatHistory] = useAtom(chatHistoryAtom);
const showPreview = useAtomValue(showPreviewAtom);
const { savedChats } = useTTDChatStorage();
const lastAssistantMessage = getLastAssistantMessage(chatHistory);
const { setLastRetryAttempt } = useChatAgent();
const { data } = useMermaidRenderer({
canvasRef,
mermaidToExcalidrawLib,
});
const { onGenerate, handleAbort } = useTextGeneration({
onTextSubmit,
});
const {
isMenuOpen,
onRestoreChat,
handleDeleteChat,
handleNewChat,
handleMenuToggle,
handleMenuClose,
} = useChatManagement();
const onViewAsMermaid = () => {
if (typeof lastAssistantMessage?.content === "string") {
saveMermaidDataToStorage(lastAssistantMessage.content);
setAppState({
openDialog: { name: "ttd", tab: "mermaid" },
});
}
};
const handleMermaidTabClick = (message: TChat.ChatMessage) => {
const mermaidContent = message.content || "";
if (mermaidContent) {
saveMermaidDataToStorage(mermaidContent);
setAppState({
openDialog: { name: "ttd", tab: "mermaid" },
});
}
};
const handleInsertMessage = async (message: TChat.ChatMessage) => {
const mermaidContent = message.content || "";
if (!mermaidContent.trim() || !mermaidToExcalidrawLib.loaded) {
return;
}
const tempDataRef = {
current: {
elements: [] as readonly NonDeletedExcalidrawElement[],
files: null as BinaryFiles | null,
},
};
const result = await convertMermaidToExcalidraw({
canvasRef,
data: tempDataRef,
mermaidToExcalidrawLib,
setError,
mermaidDefinition: mermaidContent,
theme: app.state.theme,
});
if (result.success) {
insertToEditor({
app,
data: tempDataRef,
text: mermaidContent,
shouldSaveMermaidDataToStorage: true,
});
}
};
const handleAiRepairClick = async (message: TChat.ChatMessage) => {
const mermaidContent = message.content || "";
const errorMessage = message.error || "";
if (!mermaidContent) {
return;
}
const repairPrompt = `Fix the error in this Mermaid diagram. The diagram is:\n\n\`\`\`mermaid\n${mermaidContent}\n\`\`\`\n\nThe exception/error is: ${errorMessage}\n\nPlease fix the Mermaid syntax and regenerate a valid diagram.`;
await onGenerate(repairPrompt, true);
};
const handleRetry = async (message: TChat.ChatMessage) => {
const messageIndex = chatHistory.messages.findIndex(
(msg) => msg.id === message.id,
);
if (messageIndex > 0) {
const previousMessage = chatHistory.messages[messageIndex - 1];
if (previousMessage.type === "user" && previousMessage.content) {
setLastRetryAttempt();
await onGenerate(previousMessage.content, true);
}
}
};
const handleInsertToEditor = () => {
insertToEditor({ app, data });
};
const handleDeleteMessage = (messageId: string) => {
const assistantMessageIndex = chatHistory.messages.findIndex(
(msg) => msg.id === messageId && msg.type === "assistant",
);
const remainingMessages = chatHistory.messages.slice(
0,
assistantMessageIndex - 1,
);
setChatHistory({
...chatHistory,
messages: remainingMessages,
});
};
const handlePromptChange = (newPrompt: string) => {
setChatHistory((prev) => ({
...prev,
currentPrompt: newPrompt,
}));
};
return (
<div
className={`ttd-dialog-layout ${
showPreview
? "ttd-dialog-layout--split"
: "ttd-dialog-layout--chat-only"
}`}
>
<TTDChatPanel
chatId={chatHistory.id}
messages={chatHistory.messages}
currentPrompt={chatHistory.currentPrompt}
onPromptChange={handlePromptChange}
onSendMessage={onGenerate}
isGenerating={lastAssistantMessage?.isGenerating ?? false}
generatedResponse={lastAssistantMessage?.content}
isMenuOpen={isMenuOpen}
onMenuToggle={handleMenuToggle}
onMenuClose={handleMenuClose}
onNewChat={handleNewChat}
onRestoreChat={onRestoreChat}
onDeleteChat={handleDeleteChat}
savedChats={savedChats}
activeSessionId={chatHistory.id}
onAbort={handleAbort}
onMermaidTabClick={handleMermaidTabClick}
onAiRepairClick={handleAiRepairClick}
onDeleteMessage={handleDeleteMessage}
onInsertMessage={handleInsertMessage}
onRetry={handleRetry}
onViewAsMermaid={onViewAsMermaid}
renderWarning={renderWarning}
/>
{showPreview && (
<TTDPreviewPanel
canvasRef={canvasRef}
hideErrorDetails={lastAssistantMessage?.errorType === "parse"}
error={error}
loaded={mermaidToExcalidrawLib.loaded}
onInsert={handleInsertToEditor}
/>
)}
</div>
);
};
export const TextToDiagram = ({
mermaidToExcalidrawLib,
onTextSubmit,
renderWarning,
}: {
mermaidToExcalidrawLib: MermaidToExcalidrawLibProps;
onTextSubmit(
props: TTTDDialog.OnTextSubmitProps,
): Promise<TTTDDialog.OnTextSubmitRetValue>;
renderWarning?: TTTDDialog.renderWarning;
}) => {
return (
<TextToDiagramContent
mermaidToExcalidrawLib={mermaidToExcalidrawLib}
onTextSubmit={onTextSubmit}
renderWarning={renderWarning}
/>
);
};
export default TextToDiagram;
@@ -1,18 +1,22 @@
import { DEFAULT_EXPORT_PADDING, EDITOR_LS_KEYS } from "@excalidraw/common"; import { DEFAULT_EXPORT_PADDING, EDITOR_LS_KEYS } from "@excalidraw/common";
import type { MermaidConfig } from "@excalidraw/mermaid-to-excalidraw"; import type {
import type { MermaidToExcalidrawResult } from "@excalidraw/mermaid-to-excalidraw/dist/interfaces"; NonDeletedExcalidrawElement,
Theme,
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import { EditorLocalStorage } from "../../data/EditorLocalStorage"; import { EditorLocalStorage } from "../../data/EditorLocalStorage";
import { canvasToBlob } from "../../data/blob"; import {
import { t } from "../../i18n"; convertToExcalidrawElements,
import { convertToExcalidrawElements, exportToCanvas } from "../../index"; exportToCanvas,
THEME,
} from "../../index";
import type { MermaidToExcalidrawLibProps } from "./types";
import type { AppClassProperties, BinaryFiles } from "../../types"; import type { AppClassProperties, BinaryFiles } from "../../types";
const resetPreview = ({ export const resetPreview = ({
canvasRef, canvasRef,
setError, setError,
}: { }: {
@@ -33,17 +37,14 @@ const resetPreview = ({
canvasNode.replaceChildren(); canvasNode.replaceChildren();
}; };
export interface MermaidToExcalidrawLibProps { export const convertMermaidToExcalidraw = async ({
loaded: boolean; canvasRef,
api: Promise<{ mermaidToExcalidrawLib,
parseMermaidToExcalidraw: ( mermaidDefinition,
definition: string, setError,
config?: MermaidConfig, data,
) => Promise<MermaidToExcalidrawResult>; theme,
}>; }: {
}
interface ConvertMermaidToExcalidrawFormatProps {
canvasRef: React.RefObject<HTMLDivElement | null>; canvasRef: React.RefObject<HTMLDivElement | null>;
mermaidToExcalidrawLib: MermaidToExcalidrawLibProps; mermaidToExcalidrawLib: MermaidToExcalidrawLibProps;
mermaidDefinition: string; mermaidDefinition: string;
@@ -52,38 +53,36 @@ interface ConvertMermaidToExcalidrawFormatProps {
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles | null; files: BinaryFiles | null;
}>; }>;
} theme: Theme;
}): Promise<{ success: true } | { success: false; error?: Error }> => {
export const convertMermaidToExcalidraw = async ({
canvasRef,
mermaidToExcalidrawLib,
mermaidDefinition,
setError,
data,
}: ConvertMermaidToExcalidrawFormatProps) => {
const canvasNode = canvasRef.current; const canvasNode = canvasRef.current;
const parent = canvasNode?.parentElement; const parent = canvasNode?.parentElement;
if (!canvasNode || !parent) { if (!canvasNode || !parent) {
return; return { success: false };
} }
if (!mermaidDefinition) { if (!mermaidDefinition) {
resetPreview({ canvasRef, setError }); resetPreview({ canvasRef, setError });
return; return { success: false };
} }
let ret;
try { try {
const api = await mermaidToExcalidrawLib.api; const api = await mermaidToExcalidrawLib.api;
let ret;
try { try {
ret = await api.parseMermaidToExcalidraw(mermaidDefinition); try {
} catch (err: any) { ret = await api.parseMermaidToExcalidraw(mermaidDefinition);
ret = await api.parseMermaidToExcalidraw( } catch (err: unknown) {
mermaidDefinition.replace(/"/g, "'"), ret = await api.parseMermaidToExcalidraw(
); mermaidDefinition.replace(/"/g, "'"),
);
}
} catch (err: unknown) {
return { success: false, error: err as Error };
} }
const { elements, files } = ret; const { elements, files } = ret;
setError(null); setError(null);
@@ -102,34 +101,23 @@ export const convertMermaidToExcalidraw = async ({
Math.max(parent.offsetWidth, parent.offsetHeight) * Math.max(parent.offsetWidth, parent.offsetHeight) *
window.devicePixelRatio, window.devicePixelRatio,
appState: { appState: {
// TODO hack (will be refactored in TTD v2) exportWithDarkMode: theme === THEME.DARK,
exportWithDarkMode: document
.querySelector(".excalidraw-container")
?.classList.contains("theme--dark"),
}, },
}); });
// if converting to blob fails, there's some problem that will
// likely prevent preview and export (e.g. canvas too big)
try {
await canvasToBlob(canvas);
} catch (e: any) {
if (e.name === "CANVAS_POSSIBLY_TOO_BIG") {
throw new Error(t("canvasError.canvasTooBig"));
}
throw e;
}
parent.style.background = "var(--default-bg-color)"; parent.style.background = "var(--default-bg-color)";
canvasNode.replaceChildren(canvas); canvasNode.replaceChildren(canvas);
return { success: true };
} catch (err: any) { } catch (err: any) {
parent.style.background = "var(--default-bg-color)"; parent.style.background = "var(--default-bg-color)";
if (mermaidDefinition) { if (mermaidDefinition) {
setError(err); setError(err);
} }
throw err; // Return error so caller can display meaningful error message
return { success: false, error: err };
} }
}; };
export const saveMermaidDataToStorage = (mermaidDefinition: string) => { export const saveMermaidDataToStorage = (mermaidDefinition: string) => {
EditorLocalStorage.set( EditorLocalStorage.set(
EDITOR_LS_KEYS.MERMAID_TO_EXCALIDRAW, EDITOR_LS_KEYS.MERMAID_TO_EXCALIDRAW,
@@ -0,0 +1,95 @@
import { useState } from "react";
import { useAtom, useSetAtom } from "../../../editor-jotai";
import { errorAtom, chatHistoryAtom } from "../TTDContext";
import { useTTDChatStorage } from "../useTTDChatStorage";
import { getLastAssistantMessage } from "../utils/chat";
import type { SavedChat } from "../types";
export const useChatManagement = () => {
const setError = useSetAtom(errorAtom);
const [chatHistory, setChatHistory] = useAtom(chatHistoryAtom);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const { restoreChat, deleteChat, createNewChatId } = useTTDChatStorage();
const resetChatState = () => {
const newSessionId = createNewChatId();
setChatHistory({
id: newSessionId,
messages: [],
currentPrompt: "",
});
setError(null);
};
const applyChatToState = (chat: SavedChat) => {
const restoredMessages = chat.messages.map((msg) => ({
...msg,
timestamp:
msg.timestamp instanceof Date ? msg.timestamp : new Date(msg.timestamp),
}));
const history = {
id: chat.id,
messages: restoredMessages,
currentPrompt: "",
};
const lastAssistantMsg = getLastAssistantMessage(history);
setError(
lastAssistantMsg?.error ? new Error(lastAssistantMsg?.error) : null,
);
setChatHistory(history);
};
const onRestoreChat = (chat: SavedChat) => {
const restoredChat = restoreChat(chat);
applyChatToState(restoredChat);
setIsMenuOpen(false);
};
const handleDeleteChat = (chatId: string, event: React.MouseEvent) => {
event.stopPropagation();
const isDeletingActiveChat = chatId === chatHistory.id;
const updatedChats = deleteChat(chatId);
if (isDeletingActiveChat) {
if (updatedChats.length > 0) {
const nextChat = updatedChats[0];
applyChatToState(nextChat);
} else {
resetChatState();
}
}
};
const handleNewChat = () => {
resetChatState();
setIsMenuOpen(false);
};
const handleMenuToggle = () => {
setIsMenuOpen((prev) => !prev);
};
const handleMenuClose = () => {
setIsMenuOpen(false);
};
return {
isMenuOpen,
onRestoreChat,
handleDeleteChat,
handleNewChat,
handleMenuToggle,
handleMenuClose,
};
};
@@ -0,0 +1,218 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
import { useAtom } from "../../../editor-jotai";
import { chatHistoryAtom, errorAtom, showPreviewAtom } from "../TTDContext";
import { convertMermaidToExcalidraw } from "../common";
import { isValidMermaidSyntax } from "../utils/mermaidValidation";
import { getLastAssistantMessage } from "../utils/chat";
import { useUIAppState } from "../../../context/ui-appState";
import type { BinaryFiles } from "../../../types";
import type { MermaidToExcalidrawLibProps } from "../types";
const FAST_THROTTLE_DELAY = 300;
const SLOW_THROTTLE_DELAY = 3000;
const RENDER_SPEED_THRESHOLD = 100;
const PARSE_FAIL_DELAY = 100;
interface UseMermaidRendererProps {
mermaidToExcalidrawLib: MermaidToExcalidrawLibProps;
canvasRef: React.RefObject<HTMLDivElement | null>;
}
export const useMermaidRenderer = ({
mermaidToExcalidrawLib,
canvasRef,
}: UseMermaidRendererProps) => {
const [chatHistory] = useAtom(chatHistoryAtom);
const [, setError] = useAtom(errorAtom);
const [showPreview, setShowPreview] = useAtom(showPreviewAtom);
const isRenderingRef = useRef(false);
const lastAssistantMessage = useMemo(
() => getLastAssistantMessage(chatHistory),
[chatHistory],
);
// Keeping lastAssistantMesssage in ref, so I can access it in useEffect hooks
const lastAssistantMessageRef = useRef(lastAssistantMessage);
useEffect(() => {
lastAssistantMessageRef.current = lastAssistantMessage;
}, [lastAssistantMessage]);
const data = useRef<{
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles | null;
}>({
elements: [],
files: null,
});
const lastRenderTimeRef = useRef(0);
const pendingContentRef = useRef<string | null>(null);
const hasErrorOffsetRef = useRef(false);
const currentThrottleDelayRef = useRef(FAST_THROTTLE_DELAY);
const { theme } = useUIAppState();
const renderMermaid = useCallback(
async (mermaidDefinition: string): Promise<boolean> => {
if (!mermaidDefinition.trim() || !mermaidToExcalidrawLib.loaded) {
return false;
}
if (isRenderingRef.current) {
return false;
}
isRenderingRef.current = true;
const renderStartTime = performance.now();
const result = await convertMermaidToExcalidraw({
canvasRef,
data,
mermaidToExcalidrawLib,
setError,
mermaidDefinition,
theme,
});
const renderDuration = performance.now() - renderStartTime;
if (renderDuration < RENDER_SPEED_THRESHOLD) {
currentThrottleDelayRef.current = FAST_THROTTLE_DELAY;
} else {
currentThrottleDelayRef.current = SLOW_THROTTLE_DELAY;
}
isRenderingRef.current = false;
return result.success;
},
[canvasRef, mermaidToExcalidrawLib, setError, theme],
);
const throttledRenderMermaid = useMemo(() => {
const fn = async (content: string) => {
const now = Date.now();
const timeSinceLastRender = now - lastRenderTimeRef.current;
const throttleDelay = currentThrottleDelayRef.current;
if (!isValidMermaidSyntax(content)) {
if (!hasErrorOffsetRef.current) {
lastRenderTimeRef.current = Math.max(
lastRenderTimeRef.current,
now - throttleDelay + PARSE_FAIL_DELAY,
);
hasErrorOffsetRef.current = true;
}
pendingContentRef.current = content;
return;
}
hasErrorOffsetRef.current = false;
if (timeSinceLastRender < throttleDelay) {
pendingContentRef.current = content;
return;
}
pendingContentRef.current = null;
const success = await renderMermaid(content);
lastRenderTimeRef.current = Date.now();
if (!success) {
lastRenderTimeRef.current =
lastRenderTimeRef.current - throttleDelay + PARSE_FAIL_DELAY;
hasErrorOffsetRef.current = true;
}
};
fn.flush = async () => {
if (pendingContentRef.current) {
const content = pendingContentRef.current;
pendingContentRef.current = null;
await renderMermaid(content);
lastRenderTimeRef.current = Date.now();
}
};
fn.cancel = () => {
pendingContentRef.current = null;
};
return fn;
}, [renderMermaid]);
const resetThrottleState = useCallback(() => {
lastRenderTimeRef.current = 0;
pendingContentRef.current = null;
hasErrorOffsetRef.current = false;
currentThrottleDelayRef.current = FAST_THROTTLE_DELAY;
}, []);
// this hook is responsible for keep rendering during streaming
useEffect(() => {
if (lastAssistantMessage?.content && lastAssistantMessage?.isGenerating) {
throttledRenderMermaid(lastAssistantMessage.content);
}
}, [
throttledRenderMermaid,
lastAssistantMessage?.isGenerating,
lastAssistantMessage?.content,
]);
// make sure the last bits are rendered once the streaming is completed
useEffect(() => {
if (!lastAssistantMessage?.isGenerating) {
throttledRenderMermaid.flush();
resetThrottleState();
}
}, [
resetThrottleState,
throttledRenderMermaid,
lastAssistantMessage?.isGenerating,
]);
// render the last message if the user navigates between the existing chats
useEffect(() => {
const msg = lastAssistantMessageRef.current;
if (!msg?.content || msg.error) {
return;
}
if (!showPreview) {
return;
}
renderMermaid(msg.content);
}, [chatHistory?.id, renderMermaid, showPreview]);
useEffect(() => {
if (
!chatHistory.messages?.filter((msg) => msg.type === "assistant").length
) {
const canvasNode = canvasRef.current;
if (canvasNode) {
const parent = canvasNode.parentElement;
if (parent) {
parent.style.background = "";
canvasNode.replaceChildren();
}
}
setShowPreview(false);
} else if (!showPreview) {
setShowPreview(true);
}
}, [chatHistory.messages, setShowPreview, canvasRef, showPreview]);
return {
data,
};
};
@@ -0,0 +1,252 @@
import { useRef } from "react";
import { parseMermaidToExcalidraw } from "@excalidraw/mermaid-to-excalidraw";
import { isFiniteNumber } from "@excalidraw/math";
import { useAtom } from "../../../editor-jotai";
import { trackEvent } from "../../../analytics";
import { t } from "../../../i18n";
import { errorAtom, rateLimitsAtom, chatHistoryAtom } from "../TTDContext";
import { useChatAgent } from "../Chat";
import {
addMessages,
getLastAssistantMessage,
getMessagesForLLM,
removeLastAssistantMessage,
updateAssistantContent,
} from "../utils/chat";
import type { TTTDDialog } from "../types";
const MIN_PROMPT_LENGTH = 3;
const MAX_PROMPT_LENGTH = 10000;
export const useTextGeneration = ({
onTextSubmit,
}: {
onTextSubmit: (
props: TTTDDialog.OnTextSubmitProps,
) => Promise<TTTDDialog.OnTextSubmitRetValue>;
}) => {
const [, setError] = useAtom(errorAtom);
const [rateLimits, setRateLimits] = useAtom(rateLimitsAtom);
const [chatHistory, setChatHistory] = useAtom(chatHistoryAtom);
const { addUserMessage, addAssistantMessage, setAssistantError } =
useChatAgent();
const streamingAbortControllerRef = useRef<AbortController | null>(null);
const validatePrompt = (prompt: string): boolean => {
if (
prompt.length > MAX_PROMPT_LENGTH ||
prompt.length < MIN_PROMPT_LENGTH ||
rateLimits?.rateLimitRemaining === 0
) {
if (prompt.length < MIN_PROMPT_LENGTH) {
setError(
new Error(
t("chat.errors.promptTooShort", { min: MIN_PROMPT_LENGTH }),
),
);
}
if (prompt.length > MAX_PROMPT_LENGTH) {
setError(
new Error(t("chat.errors.promptTooLong", { max: MAX_PROMPT_LENGTH })),
);
}
return false;
}
return true;
};
const getReadableErrorMsg = (msg: string) => {
try {
const content = JSON.parse(msg);
const innerMessages = JSON.parse(content.message);
return innerMessages
.map((oneMsg: { message: string }) => oneMsg.message)
.join("\n");
} catch (err) {
return msg;
}
};
const handleError = (error: Error, errorType: "parse" | "network") => {
if (errorType === "parse") {
trackEvent("ai", "mermaid parse failed", "ttd");
}
const msg = getReadableErrorMsg(error.message);
setAssistantError(msg, errorType);
setError(error);
};
const onGenerate = async (
promptWithContext: string,
isRepairFlow = false,
) => {
if (!validatePrompt(promptWithContext)) {
return;
}
if (streamingAbortControllerRef.current) {
streamingAbortControllerRef.current.abort();
}
setError(null);
const abortController = new AbortController();
streamingAbortControllerRef.current = abortController;
if (!isRepairFlow) {
addUserMessage(promptWithContext);
addAssistantMessage();
} else {
const lastAsisstantMessage = getLastAssistantMessage(chatHistory);
if (lastAsisstantMessage?.errorType === "network") {
setChatHistory((prev) =>
updateAssistantContent(prev, {
isGenerating: true,
error: undefined,
errorType: undefined,
errorDetails: undefined,
}),
);
}
}
try {
trackEvent("ai", "generate", "ttd");
const previousMessages = getMessagesForLLM(chatHistory);
const { generatedResponse, error, rateLimit, rateLimitRemaining } =
await onTextSubmit({
messages: [
...previousMessages.slice(-3),
{ role: "user", content: promptWithContext },
],
onStreamCreated: () => {
if (isRepairFlow) {
setChatHistory((prev) =>
updateAssistantContent(prev, {
content: "",
error: "",
isGenerating: true,
}),
);
}
},
onChunk: (chunk: string) => {
setChatHistory((prev) => {
const lastAssistantMessage = getLastAssistantMessage(prev);
return updateAssistantContent(prev, {
content: lastAssistantMessage.content + chunk,
});
});
},
signal: abortController.signal,
});
setChatHistory((prev) =>
updateAssistantContent(prev, {
isGenerating: false,
}),
);
if (isFiniteNumber(rateLimit) && isFiniteNumber(rateLimitRemaining)) {
setRateLimits({ rateLimit, rateLimitRemaining });
}
if (error) {
const isAborted =
error.name === "AbortError" ||
error.message === "Aborted" ||
abortController.signal.aborted;
// do nothing if the request was aborted by the user
if (isAborted) {
return;
}
if (error.status === 429) {
setChatHistory((prev) => {
const chatHistory = removeLastAssistantMessage(prev);
return {
...chatHistory,
messages: chatHistory.messages.filter(
(msg) =>
msg.type !== "warning" ||
msg.warningType === "rateLimitExceeded" ||
msg.warningType === "messageLimitExceeded",
),
};
});
setChatHistory((chatHistory) => {
return addMessages(chatHistory, [
{
type: "warning",
warningType:
rateLimitRemaining === 0
? "messageLimitExceeded"
: "rateLimitExceeded",
},
]);
});
return;
} else if (rateLimitRemaining === 0) {
setChatHistory((chatHistory) => {
chatHistory = {
...chatHistory,
messages: chatHistory.messages.filter(
(msg) =>
msg.type !== "warning" ||
msg.warningType === "rateLimitExceeded" ||
msg.warningType === "messageLimitExceeded",
),
};
return addMessages(chatHistory, [
{
type: "warning",
warningType: "messageLimitExceeded",
},
]);
});
}
handleError(error as Error, "network");
return;
}
await parseMermaidToExcalidraw(generatedResponse ?? "");
trackEvent("ai", "mermaid parse success", "ttd");
} catch (error: unknown) {
handleError(error as Error, "parse");
} finally {
streamingAbortControllerRef.current = null;
}
};
const handleAbort = () => {
if (streamingAbortControllerRef.current) {
streamingAbortControllerRef.current.abort();
}
};
return {
onGenerate,
handleAbort,
};
};
@@ -0,0 +1,96 @@
import type { RequestError } from "@excalidraw/excalidraw/errors";
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
import type { MermaidConfig } from "@excalidraw/mermaid-to-excalidraw";
import type { MermaidToExcalidrawResult } from "@excalidraw/mermaid-to-excalidraw/dist/interfaces";
import type { BinaryFiles } from "../../types";
export type LLMMessage = {
role: "user" | "assistant";
content: string;
};
export type MermaidData = {
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles | null;
};
export interface RateLimits {
rateLimit: number;
rateLimitRemaining: number;
}
export namespace TChat {
export type ChatMessage = {
id: string;
timestamp: Date;
isGenerating?: boolean;
error?: string;
errorDetails?: string;
errorType?: "parse" | "network" | "other";
lastAttemptAt?: number;
type: "user" | "assistant" | "warning";
warningType?: /* daily rate limit */
"messageLimitExceeded" | /* general 429 */ "rateLimitExceeded";
content?: string;
};
export type ChatHistory = {
id: string;
messages: ChatMessage[];
currentPrompt: string;
};
}
export interface SavedChat {
id: string;
title: string;
messages: TChat.ChatMessage[];
currentPrompt: string;
timestamp: number;
}
export interface MermaidToExcalidrawLibProps {
loaded: boolean;
api: Promise<{
parseMermaidToExcalidraw: (
definition: string,
config?: MermaidConfig,
) => Promise<MermaidToExcalidrawResult>;
}>;
}
export namespace TTTDDialog {
export type OnTextSubmitProps = {
messages: LLMMessage[];
onChunk?: (chunk: string) => void;
onStreamCreated?: () => void;
signal?: AbortSignal;
};
export type OnTextSubmitRetValue = {
rateLimit?: number | null;
rateLimitRemaining?: number | null;
} & (
| { generatedResponse: string; error: null }
| {
error: RequestError;
generatedResponse?: null;
}
);
// TTDDialog props
export type onTextSubmit = (
props: OnTextSubmitProps,
) => Promise<OnTextSubmitRetValue>;
/**
* return undefined to use default rendering
*/
export type renderWarning = (
chatMessage: TChat.ChatMessage,
) => React.ReactNode | undefined;
}
@@ -0,0 +1,153 @@
import { useEffect } from "react";
import { randomId } from "@excalidraw/common";
import { atom, useAtom } from "../../editor-jotai";
import { chatHistoryAtom } from "./TTDContext";
import type { SavedChat } from "./types";
const TTD_CHATS_STORAGE_KEY = "excalidraw-ttd-chats";
interface UseTTDChatStorageReturn {
savedChats: SavedChats;
saveCurrentChat: () => void;
deleteChat: (chatId: string) => SavedChats;
restoreChat: (chat: SavedChat) => SavedChat;
createNewChatId: () => string;
}
type SavedChats = SavedChat[];
const saveChatsToStorage = (chats: SavedChats) => {
try {
window.localStorage.setItem(TTD_CHATS_STORAGE_KEY, JSON.stringify(chats));
} catch (error: any) {
console.warn(`Failed to save chats to localStorage: ${error.message}`);
}
};
const loadChatsFromStorage = (): SavedChats => {
try {
const data = window.localStorage.getItem(TTD_CHATS_STORAGE_KEY);
if (data) {
return JSON.parse(data) as SavedChats;
}
} catch (error: any) {
console.warn(`Failed to load chats from localStorage: ${error.message}`);
}
return [];
};
const generateChatTitle = (firstMessage: string): string => {
const trimmed = firstMessage.trim();
if (trimmed.length <= 50) {
return trimmed;
}
return `${trimmed.substring(0, 47)}...`;
};
// Shared atom for saved chats - initialized once from localStorage
export const savedChatsAtom = atom<SavedChats>(loadChatsFromStorage());
export const useTTDChatStorage = (): UseTTDChatStorageReturn => {
const [chatHistory] = useAtom(chatHistoryAtom);
const [savedChats, setSavedChats] = useAtom(savedChatsAtom);
const lastMessageInHistory =
chatHistory?.messages[chatHistory?.messages.length - 1];
const saveCurrentChat = () => {
if (chatHistory.messages.length === 0) {
return;
}
const firstUserMessage = chatHistory.messages.find(
(msg) => msg.type === "user",
);
if (!firstUserMessage || !firstUserMessage.content) {
return;
}
const title = generateChatTitle(firstUserMessage.content);
const currentSavedChats = loadChatsFromStorage();
const existingChat = currentSavedChats.find(
(chat) => chat.id === chatHistory.id,
);
const messagesChanged =
!existingChat ||
existingChat.messages.length !== chatHistory.messages.length ||
existingChat.messages.some(
(msg, i) =>
msg.id !== chatHistory.messages[i]?.id ||
msg.content !== chatHistory.messages[i]?.content,
);
const chatToSave: SavedChat = {
id: chatHistory.id,
title,
messages: chatHistory.messages
.filter((msg) => msg.type === "user" || msg.type === "assistant")
.map((msg) => ({
...msg,
timestamp:
msg.timestamp instanceof Date
? msg.timestamp
: new Date(msg.timestamp),
})),
currentPrompt: chatHistory.currentPrompt,
timestamp: messagesChanged
? Date.now()
: existingChat?.timestamp ?? Date.now(),
};
const updatedChats = [
...currentSavedChats.filter((chat) => chat.id !== chatHistory.id),
chatToSave,
]
.sort((a, b) => b.timestamp - a.timestamp)
.slice(0, 10);
setSavedChats(updatedChats);
saveChatsToStorage(updatedChats);
};
useEffect(() => {
if (!lastMessageInHistory?.isGenerating) {
saveCurrentChat();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
chatHistory.messages?.length,
lastMessageInHistory?.id,
lastMessageInHistory?.isGenerating,
]);
const deleteChat = (chatId: string): SavedChats => {
const updatedChats = savedChats.filter((chat) => chat.id !== chatId);
setSavedChats(updatedChats);
saveChatsToStorage(updatedChats);
return updatedChats;
};
const restoreChat = (chat: SavedChat): SavedChat => {
saveCurrentChat();
return chat;
};
const createNewChatId = (): string => {
saveCurrentChat();
return randomId();
};
return {
savedChats,
saveCurrentChat,
deleteChat,
restoreChat,
createNewChatId,
};
};
@@ -0,0 +1,228 @@
import { RequestError } from "@excalidraw/excalidraw/errors";
import type {
LLMMessage,
TTTDDialog,
} from "@excalidraw/excalidraw/components/TTDDialog/types";
interface RateLimitInfo {
rateLimit?: number;
rateLimitRemaining?: number;
}
interface StreamingOptions {
url: string;
messages: readonly LLMMessage[];
onChunk?: (chunk: string) => void;
extractRateLimits?: boolean;
signal?: AbortSignal;
onStreamCreated?: () => void;
}
export type StreamChunk =
| {
type: "content";
delta: string;
}
| {
type: "done";
finishReason: "stop" | "length" | "content_filter" | "tool_calls" | null;
}
| {
type: "error";
error: {
message: string;
code?: string;
};
};
function extractRateLimitHeaders(headers: Headers): RateLimitInfo {
const rateLimit = headers.get("X-Ratelimit-Limit");
const rateLimitRemaining = headers.get("X-Ratelimit-Remaining");
return {
rateLimit: rateLimit ? parseInt(rateLimit, 10) : undefined,
rateLimitRemaining: rateLimitRemaining
? parseInt(rateLimitRemaining, 10)
: undefined,
};
}
async function* parseSSEStream(
reader: ReadableStreamDefaultReader<Uint8Array>,
): AsyncGenerator<string, void, unknown> {
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
const trimmedLine = line.trim();
if (!trimmedLine) {
continue;
}
if (trimmedLine.startsWith("data: ")) {
const data = trimmedLine.slice(6);
yield data;
}
}
}
} finally {
reader.releaseLock();
}
}
export async function TTDStreamFetch(
options: StreamingOptions,
): Promise<TTTDDialog.OnTextSubmitRetValue> {
const {
url,
messages,
onChunk,
onStreamCreated,
extractRateLimits = true,
signal,
} = options;
try {
let fullResponse = "";
let rateLimitInfo: RateLimitInfo = {};
let error: RequestError | null = null;
const response = await fetch(url, {
method: "POST",
headers: {
Accept: "text/event-stream",
"Content-Type": "application/json",
},
body: JSON.stringify({ messages }),
signal,
});
if (extractRateLimits) {
rateLimitInfo = extractRateLimitHeaders(response.headers);
}
if (!response.ok) {
if (response.status === 429) {
return {
...rateLimitInfo,
error: new RequestError({
message: "Rate limit exceeded",
status: 429,
}),
};
}
const text = await response.text();
throw new RequestError({
message: text || "Generation failed...",
status: response.status,
});
}
const reader = response.body?.getReader();
if (!reader) {
throw new RequestError({
message: "Couldn't get reader from response body",
status: 500,
});
}
onStreamCreated?.();
try {
for await (const data of parseSSEStream(reader)) {
if (data === "[DONE]") {
break;
}
try {
const chunk: StreamChunk = JSON.parse(data);
if (chunk === null) {
break;
}
switch (chunk.type) {
case "content": {
const delta = chunk.delta;
if (delta) {
fullResponse += delta;
onChunk?.(delta);
}
break;
}
case "error":
error = new RequestError({
message: chunk.error.message,
status: 500,
});
break;
case "done":
break;
}
} catch (e) {
console.warn("Failed to parse SSE data:", data, e);
}
}
} catch (streamError: any) {
if (streamError.name === "AbortError") {
error = new RequestError({ message: "Request aborted", status: 499 });
} else {
error = new RequestError({
message: streamError.message || "Streaming error",
status: 500,
});
}
}
if (error) {
return {
...rateLimitInfo,
error,
};
}
if (!fullResponse) {
return {
...rateLimitInfo,
error: new RequestError({
message: "Generation failed...",
status: response.status,
}),
};
}
return {
generatedResponse: fullResponse,
error: null,
...rateLimitInfo,
};
} catch (err: any) {
if (err.name === "AbortError") {
return {
error: new RequestError({ message: "Request aborted", status: 499 }),
};
}
return {
error: new RequestError({
message: err.message || "Request failed",
status: 500,
}),
};
}
}
@@ -0,0 +1,497 @@
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
import { TTDStreamFetch } from "./TTDStreamFetch";
import type { StreamChunk } from "./TTDStreamFetch";
function createMockStream(chunks: string[]): ReadableStream<Uint8Array> {
const encoder = new TextEncoder();
let index = 0;
return new ReadableStream({
async pull(controller) {
if (index < chunks.length) {
controller.enqueue(encoder.encode(chunks[index]));
index++;
} else {
controller.close();
}
},
});
}
const createContentChunkData = (delta: string): string => {
const data: StreamChunk & { type: "content" } = { type: "content", delta };
return JSON.stringify(data);
};
const createContentChunk = (delta: string): string => {
return `data: ${createContentChunkData(delta)}\n\n`;
};
const createDataChunk = (data: string): string => {
return `data: ${data}\n\n`;
};
const DONE_CHUNK = "data: [DONE]\n\n";
describe("TTDStreamFetch", () => {
let originalFetch: typeof global.fetch;
beforeEach(() => {
originalFetch = global.fetch;
});
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
describe("successful streaming", () => {
it("should stream data chunks and return full response", async () => {
const chunks: string[] = [];
const mockChunks = [
createContentChunk("Hello "),
createContentChunk("world"),
DONE_CHUNK,
];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
body: createMockStream(mockChunks),
});
const result = await TTDStreamFetch({
url: "https://api.example.com/stream",
messages: [],
onChunk: (chunk) => chunks.push(chunk),
});
expect(result.generatedResponse).toBe("Hello world");
expect(chunks).toEqual(["Hello ", "world"]);
expect(result.error).toBeNull();
});
it("should handle multi-line chunks", async () => {
const mockChunks = [
createContentChunk("Line 1\n"),
createContentChunk("Line 2"),
DONE_CHUNK,
];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
body: createMockStream(mockChunks),
});
const result = await TTDStreamFetch({
url: "https://api.example.com/stream",
messages: [],
});
expect(result.generatedResponse).toBe("Line 1\nLine 2");
});
it("should call onStreamCreated callback", async () => {
const onStreamCreated = vi.fn();
const mockChunks = [createContentChunk("test"), DONE_CHUNK];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
body: createMockStream(mockChunks),
});
await TTDStreamFetch({
url: "https://api.example.com/stream",
messages: [],
onStreamCreated,
});
expect(onStreamCreated).toHaveBeenCalledTimes(1);
});
it("should handle empty chunks gracefully", async () => {
const mockChunks = [
createContentChunk(""),
createContentChunk("valid"),
DONE_CHUNK,
];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
body: createMockStream(mockChunks),
});
const result = await TTDStreamFetch({
url: "https://api.example.com/stream",
messages: [],
});
expect(result.generatedResponse).toBe("valid");
});
it("should handle null chunk as stream termination", async () => {
const mockChunks = [
createContentChunk("before null"),
createDataChunk("null"),
createContentChunk("after null"),
];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
body: createMockStream(mockChunks),
});
const result = await TTDStreamFetch({
url: "https://api.example.com/stream",
messages: [],
});
expect(result.generatedResponse).toBe("before null");
});
});
describe("rate limit handling", () => {
it("should extract rate limit headers when enabled", async () => {
const mockChunks = [createContentChunk("test"), DONE_CHUNK];
const headers = new Headers();
headers.set("X-Ratelimit-Limit", "100");
headers.set("X-Ratelimit-Remaining", "95");
global.fetch = vi.fn().mockResolvedValue({
ok: true,
headers,
body: createMockStream(mockChunks),
});
const result = await TTDStreamFetch({
url: "https://api.example.com/stream",
messages: [],
extractRateLimits: true,
});
expect(result.rateLimit).toBe(100);
expect(result.rateLimitRemaining).toBe(95);
});
it("should not extract rate limits when disabled", async () => {
const mockChunks = [createContentChunk("test"), DONE_CHUNK];
const headers = new Headers();
headers.set("X-Ratelimit-Limit", "100");
headers.set("X-Ratelimit-Remaining", "95");
global.fetch = vi.fn().mockResolvedValue({
ok: true,
headers,
body: createMockStream(mockChunks),
});
const result = await TTDStreamFetch({
url: "https://api.example.com/stream",
messages: [],
extractRateLimits: false,
});
expect(result.rateLimit).toBeUndefined();
expect(result.rateLimitRemaining).toBeUndefined();
});
it("should handle missing rate limit headers", async () => {
const mockChunks = [createContentChunk("test"), DONE_CHUNK];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
body: createMockStream(mockChunks),
});
const result = await TTDStreamFetch({
url: "https://api.example.com/stream",
messages: [],
extractRateLimits: true,
});
expect(result.rateLimit).toBeUndefined();
expect(result.rateLimitRemaining).toBeUndefined();
});
it("should return specific error for 429 rate limit", async () => {
const headers = new Headers();
headers.set("X-Ratelimit-Limit", "100");
headers.set("X-Ratelimit-Remaining", "0");
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 429,
headers,
});
const result = await TTDStreamFetch({
url: "https://api.example.com/stream",
messages: [],
});
expect(result.error).toBeDefined();
expect(result.error?.message).toBe("Rate limit exceeded");
expect(result.rateLimit).toBe(100);
expect(result.rateLimitRemaining).toBe(0);
});
});
describe("error handling", () => {
it("should handle non-ok response with text", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
headers: new Headers(),
text: async () => "Server error occurred",
});
const result = await TTDStreamFetch({
url: "https://api.example.com/stream",
messages: [],
});
expect(result.error).toBeDefined();
expect(result.error?.message).toBe("Server error occurred");
});
it("should handle non-ok response without text", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
headers: new Headers(),
text: async () => "",
});
const result = await TTDStreamFetch({
url: "https://api.example.com/stream",
messages: [],
});
expect(result.error).toBeDefined();
expect(result.error?.message).toBe("Generation failed...");
});
it("should handle missing response body", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
body: null,
});
const result = await TTDStreamFetch({
url: "https://api.example.com/stream",
messages: [],
});
expect(result.error).toBeDefined();
expect(result.error?.message).toBe(
"Couldn't get reader from response body",
);
});
it("should handle empty response", async () => {
const mockChunks = [DONE_CHUNK];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
body: createMockStream(mockChunks),
});
const result = await TTDStreamFetch({
url: "https://api.example.com/stream",
messages: [],
});
expect(result.error).toBeDefined();
expect(result.error?.message).toBe("Generation failed...");
});
it("should handle network errors", async () => {
global.fetch = vi
.fn()
.mockRejectedValue(new Error("Network connection failed"));
const result = await TTDStreamFetch({
url: "https://api.example.com/stream",
messages: [],
});
expect(result.error).toBeDefined();
expect(result.error?.message).toBe("Network connection failed");
});
it("should handle invalid JSON in stream", async () => {
const consoleWarnSpy = vi
.spyOn(console, "warn")
.mockImplementation(() => {});
const mockChunks = [
createDataChunk("invalid"),
createContentChunk("valid"),
DONE_CHUNK,
];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
body: createMockStream(mockChunks),
});
const result = await TTDStreamFetch({
url: "https://api.example.com/stream",
messages: [],
});
expect(result.generatedResponse).toBe("valid");
expect(consoleWarnSpy).toHaveBeenCalled();
consoleWarnSpy.mockRestore();
});
});
describe("abort handling", () => {
it("should handle abort signal during fetch", async () => {
const abortController = new AbortController();
global.fetch = vi.fn().mockImplementation(() => {
abortController.abort();
const error = new Error("The operation was aborted");
error.name = "AbortError";
return Promise.reject(error);
});
const result = await TTDStreamFetch({
url: "https://api.example.com/stream",
messages: [],
signal: abortController.signal,
});
expect(result.error).toBeDefined();
expect(result.error?.message).toBe("Request aborted");
});
it("should handle abort error thrown during streaming", async () => {
// Create a stream that throws an AbortError
const stream = new ReadableStream({
async pull() {
const error = new Error("The operation was aborted");
error.name = "AbortError";
throw error;
},
});
global.fetch = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
body: stream,
});
const result = await TTDStreamFetch({
url: "https://api.example.com/stream",
messages: [],
});
expect(result.error).toBeDefined();
expect(result.error?.message).toBe("Request aborted");
});
});
describe("SSE parsing", () => {
it("should handle lines without data prefix", async () => {
const mockChunks = [
": comment line\n",
createContentChunk("valid"),
DONE_CHUNK,
];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
body: createMockStream(mockChunks),
});
const result = await TTDStreamFetch({
url: "https://api.example.com/stream",
messages: [],
});
expect(result.generatedResponse).toBe("valid");
});
it("should handle empty lines in stream", async () => {
const mockChunks = [
createContentChunk("first"),
"\n\n",
createContentChunk("second"),
DONE_CHUNK,
];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
body: createMockStream(mockChunks),
});
const result = await TTDStreamFetch({
url: "https://api.example.com/stream",
messages: [],
});
expect(result.generatedResponse).toBe("firstsecond");
});
it("should handle partial chunks across reads", async () => {
// Split an SSE message across multiple chunks
const mockChunks = [
"data: ",
createContentChunkData("partial"),
"\n\n",
DONE_CHUNK,
];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
body: createMockStream(mockChunks),
});
const result = await TTDStreamFetch({
url: "https://api.example.com/stream",
messages: [],
});
expect(result.generatedResponse).toBe("partial");
});
it("should handle [DONE] marker to terminate stream", async () => {
const mockChunks = [
createContentChunk("content"),
DONE_CHUNK,
createContentChunk("should not appear"),
];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
body: createMockStream(mockChunks),
});
const result = await TTDStreamFetch({
url: "https://api.example.com/stream",
messages: [],
});
expect(result.generatedResponse).toBe("content");
});
});
});
@@ -0,0 +1,658 @@
import {
addMessages,
getLastAssistantMessage,
getMessagesForLLM,
removeLastAssistantMessage,
updateAssistantContent,
} from "./chat";
import type { TChat } from "../types";
describe("chat utils", () => {
describe("updateAssistantContent", () => {
it("should update the last assistant message with new payload", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [
{
id: "1",
type: "user",
content: "Hello",
timestamp: new Date("2024-01-01"),
},
{
id: "2",
type: "assistant",
content: "Hi there",
timestamp: new Date("2024-01-01"),
},
],
};
const result = updateAssistantContent(chatHistory, {
content: "Hi there, how can I help?",
});
expect(result.messages[1].content).toBe("Hi there, how can I help?");
expect(result.messages[1].id).toBe("2"); // ID should remain the same
});
it("should update only the last assistant message when multiple exist", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [
{
id: "1",
type: "assistant",
content: "First assistant message",
timestamp: new Date("2024-01-01"),
},
{
id: "2",
type: "user",
content: "User message",
timestamp: new Date("2024-01-01"),
},
{
id: "3",
type: "assistant",
content: "Second assistant message",
timestamp: new Date("2024-01-01"),
},
],
};
const result = updateAssistantContent(chatHistory, {
content: "Updated second message",
});
expect(result.messages[0].content).toBe("First assistant message");
expect(result.messages[2].content).toBe("Updated second message");
});
it("should update isGenerating flag", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [
{
id: "1",
type: "assistant",
content: "Message",
timestamp: new Date("2024-01-01"),
isGenerating: false,
},
],
};
const result = updateAssistantContent(chatHistory, {
isGenerating: true,
});
expect(result.messages[0].isGenerating).toBe(true);
});
it("should update error information", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [
{
id: "1",
type: "assistant",
content: "Message",
timestamp: new Date("2024-01-01"),
},
],
};
const result = updateAssistantContent(chatHistory, {
error: "Something went wrong",
errorType: "network",
});
expect(result.messages[0].error).toBe("Something went wrong");
expect(result.messages[0].errorType).toBe("network");
});
it("should return unchanged chatHistory if no assistant message exists", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [
{
id: "1",
type: "user",
content: "Hello",
timestamp: new Date("2024-01-01"),
},
],
};
const result = updateAssistantContent(chatHistory, {
content: "New content",
});
expect(result).toEqual(chatHistory);
});
it("should not mutate original chatHistory", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [
{
id: "1",
type: "assistant",
content: "Original",
timestamp: new Date("2024-01-01"),
},
],
};
const result = updateAssistantContent(chatHistory, {
content: "Updated",
});
expect(chatHistory.messages[0].content).toBe("Original");
expect(result.messages[0].content).toBe("Updated");
});
});
describe("getLastAssistantMessage", () => {
it("should return the last assistant message", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [
{
id: "1",
type: "user",
content: "Hello",
timestamp: new Date("2024-01-01"),
},
{
id: "2",
type: "assistant",
content: "Hi there",
timestamp: new Date("2024-01-01"),
},
],
};
const result = getLastAssistantMessage(chatHistory);
expect(result).toEqual({
id: "2",
type: "assistant",
content: "Hi there",
timestamp: new Date("2024-01-01"),
});
});
it("should return the last assistant message when multiple exist", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [
{
id: "1",
type: "assistant",
content: "First",
timestamp: new Date("2024-01-01"),
},
{
id: "2",
type: "user",
content: "User",
timestamp: new Date("2024-01-01"),
},
{
id: "3",
type: "assistant",
content: "Second",
timestamp: new Date("2024-01-01"),
},
],
};
const result = getLastAssistantMessage(chatHistory);
expect(result.id).toBe("3");
expect(result.content).toBe("Second");
});
it("should return undefined if no assistant message exists", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [
{
id: "1",
type: "user",
content: "Hello",
timestamp: new Date("2024-01-01"),
},
],
};
const result = getLastAssistantMessage(chatHistory);
expect(result).toBeUndefined();
});
it("should return undefined for empty messages array", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [],
};
const result = getLastAssistantMessage(chatHistory);
expect(result).toBeUndefined();
});
});
describe("addMessages", () => {
it("should add a single message with id and timestamp", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [],
};
const result = addMessages(chatHistory, [
{
type: "user",
content: "Hello",
},
]);
expect(result.messages).toHaveLength(1);
expect(result.messages[0]).toMatchObject({
type: "user",
content: "Hello",
});
expect(result.messages[0].id).toBeDefined();
expect(result.messages[0].timestamp).toBeInstanceOf(Date);
});
it("should add multiple messages", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [],
};
const result = addMessages(chatHistory, [
{
type: "user",
content: "Hello",
},
{
type: "assistant",
content: "Hi there",
},
]);
expect(result.messages).toHaveLength(2);
expect(result.messages[0].type).toBe("user");
expect(result.messages[1].type).toBe("assistant");
});
it("should append to existing messages", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [
{
id: "1",
type: "user",
content: "Existing",
timestamp: new Date("2024-01-01"),
},
],
};
const result = addMessages(chatHistory, [
{
type: "assistant",
content: "New message",
},
]);
expect(result.messages).toHaveLength(2);
expect(result.messages[0].content).toBe("Existing");
expect(result.messages[1].content).toBe("New message");
});
it("should add warning messages", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [],
};
const result = addMessages(chatHistory, [
{
type: "warning",
},
]);
expect(result.messages).toHaveLength(1);
expect(result.messages[0].type).toBe("warning");
});
it("should preserve additional message properties", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [],
};
const result = addMessages(chatHistory, [
{
type: "assistant",
content: "Message",
isGenerating: true,
error: "Error text",
errorType: "parse",
},
]);
expect(result.messages[0].isGenerating).toBe(true);
expect(result.messages[0].error).toBe("Error text");
expect(result.messages[0].errorType).toBe("parse");
});
it("should not mutate original chatHistory", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [],
};
const result = addMessages(chatHistory, [
{
type: "user",
content: "Hello",
},
]);
expect(chatHistory.messages).toHaveLength(0);
expect(result.messages).toHaveLength(1);
});
it("should generate unique IDs for each message", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [],
};
const result = addMessages(chatHistory, [
{
type: "user",
content: "Message 1",
},
{
type: "assistant",
content: "Message 2",
},
]);
expect(result.messages[0].id).not.toBe(result.messages[1].id);
});
});
describe("removeLastAssistantMessage", () => {
it("should remove the last assistant message", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [
{
id: "1",
type: "user",
content: "Hello",
timestamp: new Date("2024-01-01"),
},
{
id: "2",
type: "assistant",
content: "Hi there",
timestamp: new Date("2024-01-01"),
},
{
id: "3",
type: "user",
content: "How are you?",
timestamp: new Date("2024-01-01"),
},
],
};
const result = removeLastAssistantMessage(chatHistory);
expect(result.messages).toHaveLength(2);
expect(result.messages[0].id).toBe("1");
expect(result.messages[1].id).toBe("3");
});
it("should remove only the last assistant message when multiple exist", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [
{
id: "1",
type: "assistant",
content: "First",
timestamp: new Date("2024-01-01"),
},
{
id: "2",
type: "user",
content: "User",
timestamp: new Date("2024-01-01"),
},
{
id: "3",
type: "assistant",
content: "Second",
timestamp: new Date("2024-01-01"),
},
],
};
const result = removeLastAssistantMessage(chatHistory);
expect(result.messages).toHaveLength(2);
expect(result.messages[0].id).toBe("1");
expect(result.messages[1].id).toBe("2");
});
it("should return unchanged chatHistory if no assistant message exists", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [
{
id: "1",
type: "user",
content: "Hello",
timestamp: new Date("2024-01-01"),
},
],
};
const result = removeLastAssistantMessage(chatHistory);
expect(result).toEqual(chatHistory);
});
it("should handle empty messages array", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [],
};
const result = removeLastAssistantMessage(chatHistory);
expect(result).toEqual(chatHistory);
});
it("should not mutate original chatHistory", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [
{
id: "1",
type: "assistant",
content: "Message",
timestamp: new Date("2024-01-01"),
},
],
};
const result = removeLastAssistantMessage(chatHistory);
expect(chatHistory.messages).toHaveLength(1);
expect(result.messages).toHaveLength(0);
});
});
describe("getMessagesForApi", () => {
it("should filter out messages with empty content", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [
{
id: "1",
type: "user",
content: "",
timestamp: new Date("2024-01-01"),
},
{
id: "2",
type: "user",
content: "Valid question",
timestamp: new Date("2024-01-01"),
},
{
id: "3",
type: "assistant",
content: "",
timestamp: new Date("2024-01-01"),
},
{
id: "4",
type: "assistant",
content: "Valid answer",
timestamp: new Date("2024-01-01"),
},
],
};
const result = getMessagesForLLM(chatHistory);
expect(result).toHaveLength(2);
expect(result[0].content).toBe("Valid question");
expect(result[1].content).toBe("Valid answer");
});
it("should return only user message if no assistant messages", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [
{
id: "1",
type: "user",
content: "Question",
timestamp: new Date("2024-01-01"),
},
],
};
const result = getMessagesForLLM(chatHistory);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
role: "user",
content: "Question",
});
});
it("should return only assistant messages if no user message", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [
{
id: "1",
type: "assistant",
content: "Answer",
timestamp: new Date("2024-01-01"),
},
],
};
const result = getMessagesForLLM(chatHistory);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
role: "assistant",
content: "Answer",
});
});
it("should return empty array for empty messages", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [],
};
const result = getMessagesForLLM(chatHistory);
expect(result).toEqual([]);
});
it("should filter out warning messages", () => {
const chatHistory: TChat.ChatHistory = {
id: "chat-1",
currentPrompt: "",
messages: [
{
id: "1",
type: "user",
content: "Question",
timestamp: new Date("2024-01-01"),
},
{
id: "2",
type: "warning",
content: "warning",
timestamp: new Date("2024-01-01"),
},
],
};
const result = getMessagesForLLM(chatHistory);
expect(result).toHaveLength(1);
expect(result[0]).toEqual(
expect.objectContaining({
role: "user",
}),
);
});
});
});
@@ -0,0 +1,91 @@
import { findLastIndex, randomId } from "@excalidraw/common";
import type { LLMMessage, TChat } from "../types";
export const updateAssistantContent = (
chatHistory: TChat.ChatHistory,
payload: Partial<TChat.ChatMessage>,
) => {
const { messages } = chatHistory;
const lastAssistantIndex = findLastIndex(
messages,
(msg) => msg.type === "assistant",
);
if (lastAssistantIndex === -1) {
return chatHistory;
}
const lastMessage = messages[lastAssistantIndex];
const updatedMessages = messages.slice();
updatedMessages[lastAssistantIndex] = {
...lastMessage,
...payload,
};
return {
...chatHistory,
messages: updatedMessages,
};
};
export const getLastAssistantMessage = (chatHistory: TChat.ChatHistory) => {
const { messages } = chatHistory;
const lastAssistantIndex = findLastIndex(
messages,
(msg) => msg.type === "assistant",
);
return messages[lastAssistantIndex];
};
export const addMessages = (
chatHistory: TChat.ChatHistory,
messages: Array<Omit<TChat.ChatMessage, "id" | "timestamp">>,
) => {
const newMessages: Array<TChat.ChatMessage> = messages.map((message) => ({
...message,
id: randomId(),
timestamp: new Date(),
}));
return {
...chatHistory,
messages: [...chatHistory.messages, ...newMessages],
};
};
export const removeLastAssistantMessage = (chatHistory: TChat.ChatHistory) => {
const lastMsgIdx = (chatHistory.messages ?? []).findLastIndex(
(msg) => msg.type === "assistant",
);
if (lastMsgIdx !== -1) {
return {
...chatHistory,
messages: chatHistory.messages.filter((_, idx) => idx !== lastMsgIdx),
};
}
return chatHistory;
};
export const getMessagesForLLM = (
chatHistory: TChat.ChatHistory,
): LLMMessage[] => {
const messages: LLMMessage[] = [];
for (const msg of chatHistory.messages) {
if (msg.content && (msg.type === "user" || msg.type === "assistant")) {
messages.push({
role: msg.type,
content: msg.content,
});
}
}
return messages;
};
@@ -0,0 +1,214 @@
import { isValidMermaidSyntax } from "./mermaidValidation";
describe("isValidMermaidSyntax", () => {
describe("empty and whitespace content", () => {
it("should return false for empty string", () => {
expect(isValidMermaidSyntax("")).toBe(false);
});
it("should return false for whitespace-only content", () => {
expect(isValidMermaidSyntax(" ")).toBe(false);
expect(isValidMermaidSyntax("\n\n")).toBe(false);
expect(isValidMermaidSyntax("\t\t")).toBe(false);
});
});
describe("balanced brackets, braces, and parentheses", () => {
it("should return true for content with balanced brackets", () => {
expect(isValidMermaidSyntax("flowchart LR\nA[Start]")).toBe(true);
expect(isValidMermaidSyntax("A[Node] --> B[End]")).toBe(true);
expect(isValidMermaidSyntax("[[nested]]")).toBe(true);
});
it("should return true for content with balanced braces", () => {
expect(isValidMermaidSyntax("graph TD\nA{Decision}")).toBe(true);
expect(isValidMermaidSyntax("{{nested}}")).toBe(true);
});
it("should return true for content with balanced parentheses", () => {
expect(isValidMermaidSyntax("flowchart LR\nA(Round)")).toBe(true);
expect(isValidMermaidSyntax("((nested))")).toBe(true);
});
it("should return true for content with multiple balanced delimiters", () => {
expect(isValidMermaidSyntax("A[Node] --> B{Decision} --> C(End)")).toBe(
true,
);
expect(
isValidMermaidSyntax("flowchart\nA[Start] --> B{Check} --> C(Done)"),
).toBe(true);
});
});
describe("unbalanced brackets", () => {
it("should return false for single unclosed bracket", () => {
expect(isValidMermaidSyntax("flowchart LR\nA[Start")).toBe(false);
expect(isValidMermaidSyntax("A[Node")).toBe(false);
});
it("should return false for multiple unclosed brackets", () => {
expect(isValidMermaidSyntax("A[Node\nB[Another")).toBe(false);
expect(isValidMermaidSyntax("[[[text")).toBe(false);
});
it("should return false when closing brackets outnumber opening brackets", () => {
expect(isValidMermaidSyntax("A]")).toBe(false);
expect(isValidMermaidSyntax("text]]]")).toBe(false);
});
});
describe("unbalanced braces", () => {
it("should return false for single unclosed brace", () => {
expect(isValidMermaidSyntax("graph TD\nA{Decision")).toBe(false);
expect(isValidMermaidSyntax("A{Node")).toBe(false);
});
it("should return false for multiple unclosed braces", () => {
expect(isValidMermaidSyntax("A{Node\nB{Another")).toBe(false);
expect(isValidMermaidSyntax("{{{text")).toBe(false);
});
it("should return false when closing braces outnumber opening braces", () => {
expect(isValidMermaidSyntax("A}")).toBe(false);
expect(isValidMermaidSyntax("text}}}")).toBe(false);
});
});
describe("unbalanced parentheses", () => {
it("should return false for single unclosed parenthesis", () => {
expect(isValidMermaidSyntax("flowchart LR\nA(Round")).toBe(false);
expect(isValidMermaidSyntax("A(Node")).toBe(false);
});
it("should return false for multiple unclosed parentheses", () => {
expect(isValidMermaidSyntax("A(Node\nB(Another")).toBe(false);
expect(isValidMermaidSyntax("(((text")).toBe(false);
});
it("should return false when closing parentheses outnumber opening parentheses", () => {
expect(isValidMermaidSyntax("A)")).toBe(false);
expect(isValidMermaidSyntax("text)))")).toBe(false);
});
});
describe("incomplete patterns at end of line", () => {
it("should return false for arrow patterns", () => {
expect(isValidMermaidSyntax("A -->")).toBe(false);
expect(isValidMermaidSyntax("flowchart LR\nA --")).toBe(false);
expect(isValidMermaidSyntax("B --.")).toBe(false);
expect(isValidMermaidSyntax("C ==>")).toBe(false);
expect(isValidMermaidSyntax("D ==")).toBe(false);
expect(isValidMermaidSyntax("E ~~")).toBe(false);
});
it("should return false for colon patterns", () => {
expect(isValidMermaidSyntax("A::")).toBe(false);
expect(isValidMermaidSyntax("B:")).toBe(false);
});
it("should return false for pipe and ampersand patterns", () => {
expect(isValidMermaidSyntax("A|")).toBe(false);
expect(isValidMermaidSyntax("B&")).toBe(false);
});
it("should return true when incomplete patterns are not at the end", () => {
expect(isValidMermaidSyntax("A --> B")).toBe(true);
expect(isValidMermaidSyntax("A -- text --> B")).toBe(true);
expect(isValidMermaidSyntax("A: complete")).toBe(true);
expect(isValidMermaidSyntax("A & B")).toBe(true);
expect(isValidMermaidSyntax("A | B")).toBe(true);
});
it("should only check the last line for incomplete patterns", () => {
expect(isValidMermaidSyntax("A -->\nB --> C")).toBe(true);
expect(isValidMermaidSyntax("A:\nB --> C")).toBe(true);
});
});
describe("complex real-world scenarios", () => {
it("should return true for valid flowchart", () => {
const mermaid = `flowchart TD
Start[Start] --> Decision{Is it?}
Decision -->|Yes| End1[End 1]
Decision -->|No| End2[End 2]`;
expect(isValidMermaidSyntax(mermaid)).toBe(true);
});
it("should return true for valid sequence diagram", () => {
const mermaid = `sequenceDiagram
Alice->>John: Hello John
John-->>Alice: Great!`;
expect(isValidMermaidSyntax(mermaid)).toBe(true);
});
it("should return false for incomplete flowchart (unclosed bracket)", () => {
const mermaid = `flowchart TD
Start[Start --> Decision{Is it?}
Decision -->|Yes| End[End]`;
expect(isValidMermaidSyntax(mermaid)).toBe(false);
});
it("should return false for flowchart with incomplete arrow at end", () => {
const mermaid = `flowchart TD
Start[Start] --> Decision{Is it?}
Decision -->`;
expect(isValidMermaidSyntax(mermaid)).toBe(false);
});
it("should return true for diagram with multiple node types", () => {
const mermaid = `graph LR
A[Square] --> B(Round)
B --> C{Diamond}
C --> D[Square]`;
expect(isValidMermaidSyntax(mermaid)).toBe(true);
});
it("should return false for streaming content that is incomplete", () => {
// Simulates partial AI-generated content during streaming
expect(isValidMermaidSyntax("flowchart LR\nA[Start] --")).toBe(false);
expect(isValidMermaidSyntax("flowchart LR\nA[Start")).toBe(false);
expect(isValidMermaidSyntax("graph TD\nA{Decision")).toBe(false);
});
it("should return true for complete multi-line diagram", () => {
const mermaid = `graph TB
subgraph one
a1[First]
end
subgraph two
a2[Second]
end
a1 --> a2`;
expect(isValidMermaidSyntax(mermaid)).toBe(true);
});
});
describe("edge cases", () => {
it("should handle content with only delimiters", () => {
expect(isValidMermaidSyntax("[]")).toBe(true);
expect(isValidMermaidSyntax("{}")).toBe(true);
expect(isValidMermaidSyntax("()")).toBe(true);
expect(isValidMermaidSyntax("[")).toBe(false);
expect(isValidMermaidSyntax("{")).toBe(false);
expect(isValidMermaidSyntax("(")).toBe(false);
});
it("should handle mixed valid and invalid scenarios", () => {
expect(isValidMermaidSyntax("A[B] -->")).toBe(false);
expect(isValidMermaidSyntax("A[B --")).toBe(false);
expect(isValidMermaidSyntax("A{B:")).toBe(false);
});
it("should handle strings with special characters", () => {
expect(isValidMermaidSyntax("A[Node with spaces]")).toBe(true);
expect(
isValidMermaidSyntax("A[Node with 'quotes' and numbers 123]"),
).toBe(true);
});
it("should trim content before validation", () => {
expect(isValidMermaidSyntax(" A[Node] \n")).toBe(true);
expect(isValidMermaidSyntax("\n\nA[Node]\n\n")).toBe(true);
});
});
});
@@ -0,0 +1,41 @@
export const isValidMermaidSyntax = (content: string): boolean => {
const trimmed = content.trim();
if (!trimmed) {
return false;
}
const openBrackets = (trimmed.match(/\[/g) || []).length;
const closeBrackets = (trimmed.match(/\]/g) || []).length;
const openBraces = (trimmed.match(/\{/g) || []).length;
const closeBraces = (trimmed.match(/\}/g) || []).length;
const openParens = (trimmed.match(/\(/g) || []).length;
const closeParens = (trimmed.match(/\)/g) || []).length;
if (
openBrackets !== closeBrackets ||
openBraces !== closeBraces ||
openParens !== closeParens
) {
return false;
}
const lastLine = trimmed.split("\n").pop()?.trim() || "";
const incompletePatterns = [
/-->$/,
/--$/,
/-\.$/,
/==>$/,
/==$/,
/~~$/,
/::$/,
/:$/,
/\|$/,
/&$/,
];
if (incompletePatterns.some((pattern) => pattern.test(lastLine))) {
return false;
}
return true;
};
@@ -226,6 +226,25 @@
border-color: var(--color-primary); border-color: var(--color-primary);
} }
&[disabled] {
cursor: not-allowed;
&:hover {
--background: var(--color-surface-mid);
background-color: var(--background);
}
&:active {
border-color: transparent;
}
@at-root .excalidraw.theme--dark#{&} {
&:hover {
--background: var(--color-surface-high);
}
}
}
svg { svg {
width: var(--lg-icon-size); width: var(--lg-icon-size);
height: var(--lg-icon-size); height: var(--lg-icon-size);
+43 -19
View File
@@ -1594,26 +1594,18 @@ export const FontFamilyNormalIcon = createIcon(
modifiedTablerIconProps, modifiedTablerIconProps,
); );
export const FontFamilyCodeIcon = createIcon( export const codeIcon = createIcon(
<> <g strokeWidth="1.5">
<g <path stroke="none" d="M0 0h24v24H0z" fill="none" />
clipPath="url(#a)" <path d="M7 8l-4 4l4 4" />
stroke="currentColor" <path d="M17 8l4 4l-4 4" />
strokeWidth="1.25" <path d="M14 4l-4 16" />
strokeLinecap="round" </g>,
strokeLinejoin="round" tablerIconProps,
>
<path d="M5.833 6.667 2.5 10l3.333 3.333M14.167 6.667 17.5 10l-3.333 3.333M11.667 3.333 8.333 16.667" />
</g>
<defs>
<clipPath id="a">
<path fill="#fff" d="M0 0h20v20H0z" />
</clipPath>
</defs>
</>,
modifiedTablerIconProps,
); );
export const FontFamilyCodeIcon = codeIcon;
export const TextAlignLeftIcon = createIcon( export const TextAlignLeftIcon = createIcon(
<g <g
stroke="currentColor" stroke="currentColor"
@@ -1865,6 +1857,27 @@ export const mermaidLogoIcon = createIcon(
/>, />,
); );
// tabler-icons: refresh
export const RetryIcon = createIcon(
<g strokeWidth="1.5">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M20 11a8.1 8.1 0 0 0 -15.5 -2m-.5 -4v4h4" />
<path d="M4 13a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4" />
</g>,
tablerIconProps,
);
export const stackPushIcon = createIcon(
<g>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M6 10l-2 1l8 4l8 -4l-2 -1" />
<path d="M4 15l8 4l8 -4" />
<path d="M12 4v7" />
<path d="M15 8l-3 3l-3 -3" />
</g>,
tablerIconProps,
);
export const ArrowRightIcon = createIcon( export const ArrowRightIcon = createIcon(
<g strokeWidth="1.25"> <g strokeWidth="1.25">
<path d="M4.16602 10H15.8327" /> <path d="M4.16602 10H15.8327" />
@@ -1993,7 +2006,8 @@ export const searchIcon = createIcon(
tablerIconProps, tablerIconProps,
); );
export const clockIcon = createIcon( // clock-bolt
export const historyCommandIcon = createIcon(
<g strokeWidth={1.5}> <g strokeWidth={1.5}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" /> <path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M20.984 12.53a9 9 0 1 0 -7.552 8.355" /> <path d="M20.984 12.53a9 9 0 1 0 -7.552 8.355" />
@@ -2003,6 +2017,16 @@ export const clockIcon = createIcon(
tablerIconProps, tablerIconProps,
); );
// history
export const historyIcon = createIcon(
<g strokeWidth={1.5}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M12 8l0 4l2 2" />
<path d="M3.05 11a9 9 0 1 1 .5 4m-.5 5v-5h5" />
</g>,
tablerIconProps,
);
export const microphoneIcon = createIcon( export const microphoneIcon = createIcon(
<g strokeWidth={1.5}> <g strokeWidth={1.5}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" /> <path stroke="none" d="M0 0h24v24H0z" fill="none" />
+3
View File
@@ -23,6 +23,7 @@
--input-hover-bg-color: #{$color-gray-1}; --input-hover-bg-color: #{$color-gray-1};
--input-label-color: #{$color-gray-7}; --input-label-color: #{$color-gray-7};
--island-bg-color: #ffffff; --island-bg-color: #ffffff;
--island-bg-color-alt: #fff;
--keybinding-color: var(--color-gray-40); --keybinding-color: var(--color-gray-40);
--link-color: #{$color-blue-7}; --link-color: #{$color-blue-7};
--overlay-bg-color: #{color.adjust(#fff, $alpha: -0.12)}; --overlay-bg-color: #{color.adjust(#fff, $alpha: -0.12)};
@@ -71,6 +72,7 @@
--library-dropdown-shadow: 0px 15px 6px rgba(0, 0, 0, 0.01), --library-dropdown-shadow: 0px 15px 6px rgba(0, 0, 0, 0.01),
0px 8px 5px rgba(0, 0, 0, 0.05), 0px 4px 4px rgba(0, 0, 0, 0.09), 0px 8px 5px rgba(0, 0, 0, 0.05), 0px 4px 4px rgba(0, 0, 0, 0.09),
0px 1px 2px rgba(0, 0, 0, 0.1), 0px 0px 0px rgba(0, 0, 0, 0.1); 0px 1px 2px rgba(0, 0, 0, 0.1), 0px 0px 0px rgba(0, 0, 0, 0.1);
--chat-msg-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
--space-factor: 0.25rem; --space-factor: 0.25rem;
--text-primary-color: var(--color-on-surface); --text-primary-color: var(--color-on-surface);
@@ -198,6 +200,7 @@
--input-hover-bg-color: #181818; --input-hover-bg-color: #181818;
--input-label-color: #{$color-gray-2}; --input-label-color: #{$color-gray-2};
--island-bg-color: #232329; --island-bg-color: #232329;
--island-bg-color-alt: hsl(240, 12%, 12%);
--keybinding-color: var(--color-gray-60); --keybinding-color: var(--color-gray-60);
--link-color: #{$color-blue-4}; --link-color: #{$color-blue-4};
--overlay-bg-color: #{color.adjust($color-gray-8, $alpha: -0.88)}; --overlay-bg-color: #{color.adjust($color-gray-8, $alpha: -0.88)};
+19
View File
@@ -69,3 +69,22 @@ export class ExcalidrawError extends Error {
this.name = "ExcalidrawError"; this.name = "ExcalidrawError";
} }
} }
export class RequestError extends Error {
public status: number;
public data: any;
toObject() {
return { name: this.name, status: this.status, message: this.message };
}
constructor({
message = "Something went wrong",
status = 500,
data,
}: { message?: string; status?: number; data?: any } = {}) {
super();
this.name = "RequestError";
this.message = message;
this.status = status;
this.data = data;
}
}
+1
View File
@@ -289,6 +289,7 @@ export { Stats } from "./components/Stats";
export { DefaultSidebar } from "./components/DefaultSidebar"; export { DefaultSidebar } from "./components/DefaultSidebar";
export { TTDDialog } from "./components/TTDDialog/TTDDialog"; export { TTDDialog } from "./components/TTDDialog/TTDDialog";
export { TTDDialogTrigger } from "./components/TTDDialog/TTDDialogTrigger"; export { TTDDialogTrigger } from "./components/TTDDialog/TTDDialogTrigger";
export { TTDStreamFetch } from "./components/TTDDialog/utils/TTDStreamFetch";
export { zoomToFitBounds } from "./actions/actionCanvas"; export { zoomToFitBounds } from "./actions/actionCanvas";
export { export {
+48 -1
View File
@@ -612,7 +612,54 @@
"button": "Insert", "button": "Insert",
"description": "Currently only <flowchartLink>Flowchart</flowchartLink>,<sequenceLink> Sequence, </sequenceLink> and <classLink>Class </classLink>Diagrams are supported. The other types will be rendered as image in Excalidraw.", "description": "Currently only <flowchartLink>Flowchart</flowchartLink>,<sequenceLink> Sequence, </sequenceLink> and <classLink>Class </classLink>Diagrams are supported. The other types will be rendered as image in Excalidraw.",
"syntax": "Mermaid Syntax", "syntax": "Mermaid Syntax",
"preview": "Preview" "preview": "Preview",
"label": "Mermaid",
"inputPlaceholder": "Write Mermaid diagram defintion here..."
},
"ttd": {
"error": "Error!",
"errorMermaidSyntax": "Mermaid syntax error"
},
"chat": {
"inputPlaceholder": "Start typing your diagram idea here... ({{shortcut}} for new line)",
"inputPlaceholderWithMessages": "Continue refining your diagram...",
"rateLimitRemaining": "{{count}} requests left today",
"role": {
"user": "You",
"assistant": "AI Assistant",
"system": "System"
},
"aiBeta": "AI Beta",
"label": "Chat",
"menu": "Menu",
"newChat": "New Chat",
"deleteChat": "Delete chat",
"deleteMessage": "Delete message",
"viewAsMermaid": "View as Mermaid",
"placeholder": {
"title": "Let's design your diagram",
"description": "Describe the diagram you want to create, and we'll generate it for you.",
"hint": "At the moment we know Flowchart, Sequence, and Class diagrams."
},
"preview": "Preview",
"insert": "Insert",
"retry": "Retry",
"errors": {
"promptTooShort": "Prompt is too short (min {{min}} characters)",
"promptTooLong": "Prompt is too long (max {{max}} characters)",
"generationFailed": "Generation failed",
"invalidDiagram": "Generated an invalid diagram :(. You may also try a different prompt.",
"fixInMermaid": "Edit Mermaid manually →",
"aiRepair": "Regenerate (auto-fix) →",
"requestAborted": "Request aborted",
"requestFailed": "Request failed"
},
"rateLimit": {
"messageLimit": "You've hit your AI limit on the free plan. Try out Excalidraw+ for more or come back tomorrow.",
"generalRateLimit": "Hold your horses, you're too fast for us! Please wait a moment before trying again.",
"messageLimitInputPlaceholder": "You've reached your message limit"
},
"upsellBtnLabel": "Upgrade to Plus"
}, },
"quickSearch": { "quickSearch": {
"placeholder": "Quick search" "placeholder": "Quick search"
+1 -1
View File
@@ -83,7 +83,7 @@
"@excalidraw/element": "0.18.0", "@excalidraw/element": "0.18.0",
"@excalidraw/laser-pointer": "1.3.1", "@excalidraw/laser-pointer": "1.3.1",
"@excalidraw/math": "0.18.0", "@excalidraw/math": "0.18.0",
"@excalidraw/mermaid-to-excalidraw": "1.1.3", "@excalidraw/mermaid-to-excalidraw": "2.0.0-test2",
"@excalidraw/random-username": "1.1.0", "@excalidraw/random-username": "1.1.0",
"@radix-ui/react-popover": "1.1.6", "@radix-ui/react-popover": "1.1.6",
"@radix-ui/react-tabs": "1.1.3", "@radix-ui/react-tabs": "1.1.3",
@@ -1,12 +1,12 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Test <MermaidToExcalidraw/> > should open mermaid popup when active tool is mermaid 1`] = ` exports[`Test <MermaidToExcalidraw/> > should open mermaid popup when active tool is mermaid 1`] = `
"<div class="Modal Dialog ttd-dialog" role="dialog" aria-modal="true" aria-labelledby="dialog-title" data-prevent-outside-click="true"><div class="Modal__background"></div><div class="Modal__content" style="--max-width: 1200px;" tabindex="0"><div class="Island"><div class="Dialog__content"><div dir="ltr" data-orientation="horizontal" class="ttd-dialog-tabs-root"><p class="dialog-mermaid-title">Mermaid to Excalidraw</p><div data-state="active" data-orientation="horizontal" role="tabpanel" aria-labelledby="radix-:r0:-trigger-mermaid" id="radix-:r0:-content-mermaid" tabindex="0" class="ttd-dialog-content" style=""><div class="ttd-dialog-desc">Currently only <a href="https://mermaid.js.org/syntax/flowchart.html">Flowchart</a>,<a href="https://mermaid.js.org/syntax/sequenceDiagram.html"> Sequence, </a> and <a href="https://mermaid.js.org/syntax/classDiagram.html">Class </a>Diagrams are supported. The other types will be rendered as image in Excalidraw.</div><div class="ttd-dialog-panels"><div class="ttd-dialog-panel"><div class="ttd-dialog-panel__header"><label>Mermaid Syntax</label></div><textarea class="ttd-dialog-input" placeholder="Write Mermaid diagram defintion here...">flowchart TD "<div class="Modal Dialog ttd-dialog" role="dialog" aria-modal="true" aria-labelledby="dialog-title"><div class="Modal__background"></div><div class="Modal__content" style="--max-width: 1520px;" tabindex="0"><div class="Island"><div class="Dialog__content"><div dir="ltr" data-orientation="horizontal" class="ttd-dialog-tabs-root"><p class="dialog-mermaid-title">Mermaid to Excalidraw</p><div data-state="active" data-orientation="horizontal" role="tabpanel" aria-labelledby="radix-:r0:-trigger-mermaid" id="radix-:r0:-content-mermaid" tabindex="0" class="ttd-dialog-content" style=""><div class="ttd-dialog-desc">Currently only <a href="https://mermaid.js.org/syntax/flowchart.html">Flowchart</a>,<a href="https://mermaid.js.org/syntax/sequenceDiagram.html"> Sequence, </a> and <a href="https://mermaid.js.org/syntax/classDiagram.html">Class </a>Diagrams are supported. The other types will be rendered as image in Excalidraw.</div><div class="ttd-dialog-panels"><div class="ttd-dialog-panel"><textarea class="ttd-dialog-input" placeholder="Write Mermaid diagram defintion here...">flowchart TD
A[Christmas] --&gt;|Get money| B(Go shopping) A[Christmas] --&gt;|Get money| B(Go shopping)
B --&gt; C{Let me think} B --&gt; C{Let me think}
C --&gt;|One| D[Laptop] C --&gt;|One| D[Laptop]
C --&gt;|Two| E[iPhone] C --&gt;|Two| E[iPhone]
C --&gt;|Three| F[Car]</textarea><div class="ttd-dialog-panel-button-container invisible" style="display: flex; align-items: center;"><button type="button" class="excalidraw-button ttd-dialog-panel-button"><div class=""></div></button></div></div><div class="ttd-dialog-panel"><div class="ttd-dialog-panel__header"><label>Preview</label></div><div class="ttd-dialog-output-wrapper"><div style="opacity: 1;" class="ttd-dialog-output-canvas-container"><canvas width="89" height="158" dir="ltr"></canvas></div></div><div class="ttd-dialog-panel-button-container" style="display: flex; align-items: center;"><button type="button" class="excalidraw-button ttd-dialog-panel-button"><div class="">Insert<span><svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" class="" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><g stroke-width="1.25"><path d="M4.16602 10H15.8327"></path><path d="M12.5 13.3333L15.8333 10"></path><path d="M12.5 6.66666L15.8333 9.99999"></path></g></svg></span></div></button><div class="ttd-dialog-submit-shortcut"><div class="ttd-dialog-submit-shortcut__key">Ctrl</div><div class="ttd-dialog-submit-shortcut__key">Enter</div></div></div></div></div></div></div></div></div></div></div>" C --&gt;|Three| F[Car]</textarea><div class="ttd-dialog-panel-button-container invisible" style="justify-content: flex-start;"></div></div><div class="ttd-dialog-panel"><div class="ttd-dialog-output-wrapper "><div class="ttd-dialog-output-canvas-container"><div class="ttd-dialog-output-canvas-content"><canvas width="89" height="158" dir="ltr"></canvas></div></div></div><div class="ttd-dialog-panel-button-container" style="justify-content: flex-start;"><button type="button" class="excalidraw-button ttd-dialog-panel-button"><div class="">Insert<span><svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" class="" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><g stroke-width="1.25"><path d="M4.16602 10H15.8327"></path><path d="M12.5 13.3333L15.8333 10"></path><path d="M12.5 6.66666L15.8333 9.99999"></path></g></svg></span></div></button><div class="ttd-dialog-submit-shortcut"><div class="ttd-dialog-submit-shortcut__key">Ctrl</div><div class="ttd-dialog-submit-shortcut__key">Enter</div></div></div></div></div></div></div></div></div></div></div>"
`; `;
exports[`Test <MermaidToExcalidraw/> > should show error in preview when mermaid library throws error 1`] = ` exports[`Test <MermaidToExcalidraw/> > should show error in preview when mermaid library throws error 1`] = `
File diff suppressed because one or more lines are too long
+463 -386
View File
File diff suppressed because it is too large Load Diff