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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,239 @@
import { useEffect, useRef } from "react";
import {
Decoration,
EditorView,
keymap,
lineNumbers,
placeholder as cmPlaceholder,
drawSelection,
} from "@codemirror/view";
import { Compartment, EditorState, type Extension } from "@codemirror/state";
import {
defaultKeymap,
history,
historyKeymap,
redo,
} from "@codemirror/commands";
import { syntaxHighlighting, HighlightStyle } from "@codemirror/language";
import { tags } from "@lezer/highlight";
import type { Theme } from "@excalidraw/element/types";
import { mermaidLite } from "./mermaid-lang-lite";
export interface CodeMirrorEditorProps {
value: string;
onChange: (value: string) => void;
onKeyboardSubmit?: () => void;
placeholder?: string;
theme: Theme;
errorLine?: number | null;
}
// ---- Dark theme ----
const darkTheme = EditorView.theme(
{
"&": {
backgroundColor: "#1e1e1e",
color: "#d4d4d4",
},
".cm-content": { caretColor: "#fff" },
".cm-cursor": { borderLeftColor: "#fff" },
".cm-gutters": {
backgroundColor: "#1e1e1e",
color: "#858585",
border: "none",
},
".cm-activeLineGutter": { backgroundColor: "#2a2a2a" },
".cm-activeLine": { backgroundColor: "#2a2a2a" },
".cm-errorLine": { backgroundColor: "rgba(255, 0, 0, 0.15)" },
},
{ dark: true },
);
const darkHighlight = HighlightStyle.define([
{ tag: tags.keyword, color: "#569cd6" },
{ tag: tags.string, color: "#ce9178" },
{ tag: tags.comment, color: "#6a9955" },
{ tag: tags.number, color: "#b5cea8" },
{ tag: tags.operator, color: "#d4d4d4" },
{ tag: tags.punctuation, color: "#d4d4d4" },
{ tag: tags.variableName, color: "#9cdcfe" },
{ tag: tags.bracket, color: "#ffd700" },
]);
// ---- Light theme ----
const lightTheme = EditorView.theme({
"&": {
backgroundColor: "#ffffff",
color: "#1e1e1e",
},
".cm-content": { caretColor: "#000" },
".cm-cursor": { borderLeftColor: "#000" },
".cm-gutters": {
backgroundColor: "#fff",
color: "#999",
border: "none",
},
".cm-activeLineGutter": { backgroundColor: "#e8e8e8" },
".cm-activeLine": { backgroundColor: "#e8e8e8" },
".cm-errorLine": { backgroundColor: "rgba(255, 0, 0, 0.1)" },
});
const lightHighlight = HighlightStyle.define([
{ tag: tags.keyword, color: "#0000ff" },
{ tag: tags.string, color: "#a31515" },
{ tag: tags.comment, color: "#008000" },
{ tag: tags.number, color: "#098658" },
{ tag: tags.operator, color: "#1e1e1e" },
{ tag: tags.punctuation, color: "#1e1e1e" },
{ tag: tags.variableName, color: "#001080" },
{ tag: tags.bracket, color: "#af00db" },
]);
// ---- Error line decoration ----
const errorLineDeco = Decoration.line({ class: "cm-errorLine" });
const getErrorLineExtension = (
errorLine: number | null | undefined,
doc: { line(n: number): { from: number }; lines: number },
): Extension => {
if (!errorLine || errorLine < 1 || errorLine > doc.lines) {
return EditorView.decorations.of(Decoration.none);
}
const line = doc.line(errorLine);
return EditorView.decorations.of(
Decoration.set([errorLineDeco.range(line.from)]),
);
};
// ---- Helpers ----
const getThemeExtensions = (theme: Theme) => {
if (theme === "dark") {
return [darkTheme, syntaxHighlighting(darkHighlight)];
}
return [lightTheme, syntaxHighlighting(lightHighlight)];
};
const CodeMirrorEditor = ({
value,
onChange,
onKeyboardSubmit,
placeholder,
theme,
errorLine,
}: CodeMirrorEditorProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const onChangeRef = useRef(onChange);
const onKeyboardSubmitRef = useRef(onKeyboardSubmit);
const themeCompartmentRef = useRef(new Compartment());
const errorLineCompartmentRef = useRef(new Compartment());
onChangeRef.current = onChange;
onKeyboardSubmitRef.current = onKeyboardSubmit;
useEffect(() => {
if (!containerRef.current) {
return;
}
const themeCompartment = themeCompartmentRef.current;
const view = new EditorView({
state: EditorState.create({
doc: value,
extensions: [
keymap.of([
{
key: "Mod-Enter",
run: () => {
onKeyboardSubmitRef.current?.();
return true;
},
},
// historyKeymap binds Mod-Shift-z only on Mac; add it for all platforms
{ key: "Mod-Shift-z", run: redo, preventDefault: true },
]),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
onChangeRef.current(update.state.doc.toString());
}
}),
history(),
keymap.of([...defaultKeymap, ...historyKeymap]),
lineNumbers(),
EditorView.lineWrapping,
themeCompartment.of(getThemeExtensions(theme)),
errorLineCompartmentRef.current.of([]),
mermaidLite(),
drawSelection({ drawRangeCursor: true }),
...(placeholder ? [cmPlaceholder(placeholder)] : []),
],
}),
parent: containerRef.current,
});
viewRef.current = view;
view.focus();
return () => {
view.destroy();
viewRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Swap theme dynamically via compartment
useEffect(() => {
const view = viewRef.current;
if (!view) {
return;
}
view.dispatch({
effects: themeCompartmentRef.current.reconfigure(
getThemeExtensions(theme),
),
});
}, [theme]);
// Update error line highlight
useEffect(() => {
const view = viewRef.current;
if (!view) {
return;
}
view.dispatch({
effects: errorLineCompartmentRef.current.reconfigure(
getErrorLineExtension(errorLine, view.state.doc),
),
});
}, [errorLine]);
// Sync external value changes into EditorView
useEffect(() => {
const view = viewRef.current;
if (!view) {
return;
}
const currentDoc = view.state.doc.toString();
if (value !== currentDoc) {
view.dispatch({
changes: { from: 0, to: currentDoc.length, insert: value },
});
}
}, [value]);
return (
<div
ref={containerRef}
className="ttd-dialog-input ttd-dialog-input--codemirror"
/>
);
};
export default CodeMirrorEditor;

View File

@@ -0,0 +1,10 @@
.excalidraw {
.dialog-mermaid {
&-title {
margin-block: 0.25rem;
font-size: 1.25rem;
font-weight: 700;
padding-inline: 2.5rem;
}
}
}

View File

@@ -0,0 +1,314 @@
import { useState, useRef, useEffect, useDeferredValue } from "react";
import { EDITOR_LS_KEYS, debounce, isDevEnv } from "@excalidraw/common";
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
import { useApp } from "../App";
import { ArrowRightIcon } from "../icons";
import { EditorLocalStorage } from "../../data/EditorLocalStorage";
import { t } from "../../i18n";
import Trans from "../Trans";
import { useUIAppState } from "../../context/ui-appState";
import { TTDDialogInput } from "./TTDDialogInput";
import { TTDDialogOutput } from "./TTDDialogOutput";
import { TTDDialogPanel } from "./TTDDialogPanel";
import { TTDDialogPanels } from "./TTDDialogPanels";
import { TTDDialogSubmitShortcut } from "./TTDDialogSubmitShortcut";
import {
getMermaidErrorLineNumber,
isMermaidAutoFixableError,
} from "./utils/mermaidError";
import { getMermaidAutoFixCandidates } from "./utils/mermaidAutoFix";
import {
convertMermaidToExcalidraw,
insertToEditor,
saveMermaidDataToStorage,
resetPreview,
} from "./common";
import "./MermaidToExcalidraw.scss";
import type { BinaryFiles } from "../../types";
import type { MermaidToExcalidrawLibProps } from "./types";
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]";
const debouncedSaveMermaidDefinition = debounce(saveMermaidDataToStorage, 300);
const AUTO_FIX_DEBOUNCE_MS = 500;
const AUTO_FIX_MAX_DEPTH = 4;
const AUTO_FIX_MAX_CANDIDATES = 30;
const getErrorMessage = (error: unknown): string => {
if (error instanceof Error) {
return error.message;
}
if (typeof error === "string") {
return error;
}
if (
error &&
typeof error === "object" &&
"message" in error &&
typeof (error as { message?: unknown }).message === "string"
) {
return (error as { message: string }).message;
}
return "";
};
const MermaidToExcalidraw = ({
mermaidToExcalidrawLib,
isActive,
}: {
mermaidToExcalidrawLib: MermaidToExcalidrawLibProps;
isActive?: boolean;
}) => {
const [text, setText] = useState(
() =>
EditorLocalStorage.get<string>(EDITOR_LS_KEYS.MERMAID_TO_EXCALIDRAW) ||
MERMAID_EXAMPLE,
);
const deferredText = useDeferredValue(text);
const [error, setError] = useState<Error | null>(null);
const [autoFixCandidate, setAutoFixCandidate] = useState<string | null>(null);
const errorLine = (() => {
if (!error?.message) {
return null;
}
return getMermaidErrorLineNumber(error.message, deferredText);
})();
const canvasRef = useRef<HTMLDivElement>(null);
const data = useRef<{
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles | null;
}>({ elements: [], files: null });
const app = useApp();
const { theme } = useUIAppState();
useEffect(() => {
const doRender = async () => {
try {
if (!deferredText.trim()) {
resetPreview({ canvasRef, setError });
return;
}
const result = await convertMermaidToExcalidraw({
canvasRef,
data,
mermaidToExcalidrawLib,
setError,
mermaidDefinition: deferredText,
theme,
});
if (!result.success) {
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(
() => () => {
debouncedSaveMermaidDefinition.flush();
},
[],
);
useEffect(() => {
const errorMessage = error?.message ?? "";
const sourceText = deferredText;
const shouldTryAutoFix =
isActive &&
isMermaidAutoFixableError(errorMessage) &&
!!sourceText.trim() &&
mermaidToExcalidrawLib.loaded;
if (!shouldTryAutoFix) {
setAutoFixCandidate(null);
return;
}
const candidates = getMermaidAutoFixCandidates(sourceText, errorMessage);
if (!candidates.length) {
setAutoFixCandidate(null);
return;
}
let cancelled = false;
const timer = setTimeout(async () => {
try {
const api = await mermaidToExcalidrawLib.api;
const seen = new Set<string>([sourceText]);
const queue = candidates.map((candidate) => ({
text: candidate,
depth: 1,
}));
let triedCandidates = 0;
while (queue.length > 0 && triedCandidates < AUTO_FIX_MAX_CANDIDATES) {
const current = queue.shift();
if (!current || seen.has(current.text)) {
continue;
}
seen.add(current.text);
triedCandidates += 1;
try {
await api.parseMermaidToExcalidraw(current.text);
if (!cancelled) {
setAutoFixCandidate(current.text);
}
return;
} catch (candidateError) {
if (current.depth >= AUTO_FIX_MAX_DEPTH) {
continue;
}
const nextErrorMessage = getErrorMessage(candidateError);
if (!nextErrorMessage) {
continue;
}
const nextCandidates = getMermaidAutoFixCandidates(
current.text,
nextErrorMessage,
);
for (const nextCandidate of nextCandidates) {
if (!seen.has(nextCandidate)) {
queue.push({
text: nextCandidate,
depth: current.depth + 1,
});
}
}
}
}
} catch {
// ignore auto-fix probe errors
}
if (!cancelled) {
setAutoFixCandidate(null);
}
}, AUTO_FIX_DEBOUNCE_MS);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [deferredText, error?.message, isActive, mermaidToExcalidrawLib]);
const onInsertToEditor = () => {
insertToEditor({
app,
data,
text,
shouldSaveMermaidDataToStorage: true,
});
};
const onApplyAutoFix = () => {
if (!autoFixCandidate) {
return;
}
setText(autoFixCandidate);
};
return (
<>
<div className="ttd-dialog-desc">
<Trans
i18nKey="mermaid.description"
flowchartLink={(el) => (
<a
href="https://mermaid.js.org/syntax/flowchart.html"
target="_blank"
rel="noreferrer"
>
{el}
</a>
)}
sequenceLink={(el) => (
<a
href="https://mermaid.js.org/syntax/sequenceDiagram.html"
target="_blank"
rel="noreferrer"
>
{el}
</a>
)}
classLink={(el) => (
<a
href="https://mermaid.js.org/syntax/classDiagram.html"
target="_blank"
rel="noreferrer"
>
{el}
</a>
)}
erdLink={(el) => (
<a
href="https://mermaid.js.org/syntax/entityRelationshipDiagram.html"
target="_blank"
rel="noreferrer"
>
{el}
</a>
)}
/>
</div>
<TTDDialogPanels>
<TTDDialogPanel>
<TTDDialogInput
input={text}
placeholder={t("mermaid.inputPlaceholder")}
onChange={(value) => setText(value)}
errorLine={errorLine}
onKeyboardSubmit={() => {
onInsertToEditor();
}}
/>
</TTDDialogPanel>
<TTDDialogPanel
panelActions={[
{
action: () => {
onInsertToEditor();
},
label: t("mermaid.button"),
icon: ArrowRightIcon,
variant: "button",
},
]}
renderSubmitShortcut={() => <TTDDialogSubmitShortcut />}
>
<TTDDialogOutput
canvasRef={canvasRef}
loaded={mermaidToExcalidrawLib.loaded}
error={error}
sourceText={text}
autoFixAvailable={!!autoFixCandidate}
onApplyAutoFix={onApplyAutoFix}
/>
</TTDDialogPanel>
</TTDDialogPanels>
</>
);
};
export default MermaidToExcalidraw;

View File

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

View File

@@ -0,0 +1,742 @@
@use "../../css/variables.module.scss" as *;
@use "./Chat/Chat.scss";
$verticalBreakpoint: 861px;
$fullScreenModalBreakpoint: 600px;
.excalidraw {
.Modal.Dialog.ttd-dialog {
padding: 1.25rem;
&.Dialog--fullscreen {
margin-top: 0;
}
.Island {
padding-inline: 0 !important;
height: 100%;
display: flex;
flex-direction: column;
flex: 1 1 auto;
}
.Modal__content {
max-height: 100%;
min-height: 95vh;
height: 100%;
@media screen and (min-width: $verticalBreakpoint) {
max-height: min(950px, calc(100vh - 4rem));
height: 100%;
min-height: unset;
}
}
.Dialog__content {
flex: 1 1 auto;
@media screen and (max-width: $verticalBreakpoint) {
margin-top: 0 !important;
}
}
}
.ttd-dialog-desc {
font-size: 15px;
font-style: italic;
font-weight: 500;
margin: 0.5rem 0 1.5rem 0;
}
.ttd-dialog-tabs-root {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.ttd-dialog-tab-trigger {
color: var(--color-on-surface);
font-size: 0.875rem;
margin: 0;
padding: 0 1rem;
background-color: transparent;
border: 0;
height: 2.875rem;
font-weight: 600;
font-family: inherit;
letter-spacing: 0.4px;
&[data-state="active"] {
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 {
border-bottom: 1px solid var(--color-surface-high);
margin-bottom: 1rem;
padding-inline: 2.5rem;
@media screen and (max-width: $verticalBreakpoint) {
margin-bottom: 1rem;
}
}
.ttd-dialog-content {
padding-inline: 2.5rem;
height: 100%;
display: flex;
flex-direction: column;
overflow: visible;
outline: none;
&[hidden] {
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 {
width: auto;
height: 10rem;
resize: none;
border-radius: var(--border-radius-lg);
border: 1px solid var(--dialog-border-color);
white-space: pre-wrap;
padding: 0.85rem;
box-sizing: border-box;
font-family: monospace;
@media screen and (min-width: $verticalBreakpoint) {
width: 100%;
height: 100%;
}
}
.ttd-dialog-input--loading {
display: flex;
align-items: center;
justify-content: center;
}
.ttd-dialog-input--codemirror {
padding: 0;
overflow: hidden;
// Override height:100% from .ttd-dialog-input — use flex sizing
// so the editor fills remaining space without overflowing the panel
height: 0;
flex: 1 1 0;
min-height: 0;
.cm-editor {
height: 100%;
font-family: monospace;
&.cm-focused {
outline: none;
}
}
.cm-scroller {
padding: 0.85rem 0;
overflow: auto;
}
.cm-gutters {
padding-left: 0.25rem;
}
.cm-content {
padding: 0;
}
.cm-placeholder {
color: var(--color-gray-40);
font-style: italic;
}
}
.ttd-dialog-output-wrapper {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
box-sizing: border-box;
flex-grow: 1;
position: relative;
overflow: hidden;
max-width: 100%;
max-height: 100%;
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==")
left center;
border-radius: var(--border-radius-lg);
border: 1px solid var(--dialog-border-color);
height: 400px;
width: auto;
max-width: 100%;
@media screen and (max-width: $fullScreenModalBreakpoint) {
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 {
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
object-fit: contain;
}
}
.ttd-dialog-output-canvas-container {
display: flex;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
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 {
position: absolute;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
.ttd-dialog-output-error-content {
display: flex;
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-summary {
width: 100%;
max-width: 640px;
color: var(--color-gray-50);
font-size: 0.9rem;
text-align: left;
&__headline {
font-weight: 600;
color: var(--color-gray-60);
}
&__label {
margin-top: 0.35rem;
font-weight: 500;
}
&__causes {
margin: 0.35rem 0 0;
padding-left: 2rem;
}
}
.ttd-dialog-output-error-message {
text-align: left;
font-weight: 400;
color: var(--color-gray-50);
word-break: break-word;
white-space: pre-wrap;
max-width: 640px;
width: 100%;
font-family: monospace;
&__caret {
color: var(--color-danger);
}
}
.ttd-dialog-output-error-autofix-slot {
align-self: flex-start;
margin-top: 0.35rem;
min-height: 2.5rem;
display: flex;
align-items: flex-start;
}
.ttd-dialog-output-error-autofix {
margin-top: 0;
white-space: nowrap;
}
}
.ttd-dialog-panels {
height: 100%;
@media screen and (min-width: $verticalBreakpoint) {
display: grid;
grid-template-columns: 1fr 1fr;
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 {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
@media screen and (max-width: $verticalBreakpoint) {
height: 50%;
}
&__header {
display: flex;
align-items: center;
gap: 0.3rem;
height: 36px;
margin-top: 0.2rem;
margin-bottom: 0.5rem;
flex-shrink: 0;
label {
font-size: 14px;
line-height: 22px;
font-style: normal;
font-weight: 600;
}
}
&__label-wrapper {
display: flex;
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);
}
}
&__menu-wrapper {
position: relative;
.ttd-dialog-menu-trigger {
height: 40px;
width: 40px;
}
.dropdown-menu {
margin-top: 0.375rem;
right: 0;
}
}
textarea {
height: 100%;
resize: none;
border-radius: var(--border-radius-lg);
border: 1px solid var(--dialog-border-color);
white-space: pre-wrap;
padding: 0.85rem;
box-sizing: border-box;
width: 100%;
font-family: monospace;
@media screen and (max-width: $verticalBreakpoint) {
width: auto;
}
}
}
.ttd-dialog-panel-button-container {
display: flex;
align-items: center;
flex-grow: 0;
height: 40px;
flex-shrink: 0;
margin-top: 1rem;
margin-bottom: 0.5rem;
@media screen and (max-width: $verticalBreakpoint) {
margin-top: 0.5rem;
}
&.invisible {
visibility: hidden;
@media screen and (max-width: $verticalBreakpoint) {
display: none;
}
}
}
.ttd-dialog-panel-button {
&.excalidraw-button {
font-family: inherit;
font-weight: 600;
height: 2.5rem;
font-size: 12px;
color: #fff;
background-color: var(--color-primary);
width: 100%;
&:hover {
background-color: var(--color-primary-darker);
}
&:active {
background-color: var(--color-primary-darkest);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
&:hover {
background-color: var(--color-primary);
}
}
@media screen and (min-width: $verticalBreakpoint) {
width: auto;
min-width: 7.5rem;
}
@at-root .excalidraw.theme--dark#{&} {
color: var(--color-gray-100);
}
}
& {
position: relative;
}
div {
display: contents;
&.invisible {
visibility: hidden;
}
&.Spinner {
display: flex !important;
position: absolute;
inset: 0;
--spinner-color: white;
@at-root .excalidraw.theme--dark#{&} {
--spinner-color: var(--color-gray-100);
}
}
span {
padding-left: 0.5rem;
display: flex;
}
}
}
.ttd-dialog-submit-shortcut {
margin-inline-start: 0.5rem;
font-size: 0.625rem;
opacity: 0.6;
display: flex;
gap: 0.125rem;
&__key {
border: 1px solid gray;
padding: 2px 3px;
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;
}
}

View File

@@ -0,0 +1,135 @@
import { useEffect, useState } from "react";
import { useUIAppState } from "../../context/ui-appState";
import { t } from "../../i18n";
import { useApp } from "../App";
import { Dialog } from "../Dialog";
import { withInternalFallback } from "../hoc/withInternalFallback";
import MermaidToExcalidraw from "./MermaidToExcalidraw";
import TextToDiagram from "./TextToDiagram";
import TTDDialogTabs from "./TTDDialogTabs";
import { TTDDialogTabTriggers } from "./TTDDialogTabTriggers";
import { TTDDialogTabTrigger } from "./TTDDialogTabTrigger";
import { TTDDialogTab } from "./TTDDialogTab";
import "./TTDDialog.scss";
import { TTDWelcomeMessage } from "./TTDWelcomeMessage";
import type {
MermaidToExcalidrawLibProps,
TTDPersistenceAdapter,
TTTDDialog,
} from "./types";
export const TTDDialog = (
props:
| {
onTextSubmit: TTTDDialog.onTextSubmit;
renderWelcomeScreen?: TTTDDialog.renderWelcomeScreen;
renderWarning?: TTTDDialog.renderWarning;
persistenceAdapter: TTDPersistenceAdapter;
}
| { __fallback: true },
) => {
const appState = useUIAppState();
if (appState.openDialog?.name !== "ttd") {
return null;
}
return <TTDDialogBase {...props} tab={appState.openDialog.tab} />;
};
TTDDialog.WelcomeMessage = TTDWelcomeMessage;
/**
* Text to diagram (TTD) dialog
*/
const TTDDialogBase = withInternalFallback(
"TTDDialogBase",
({
tab,
...rest
}: {
tab: "text-to-diagram" | "mermaid";
} & (
| {
onTextSubmit(
props: TTTDDialog.OnTextSubmitProps,
): Promise<TTTDDialog.OnTextSubmitRetValue>;
renderWelcomeScreen?: TTTDDialog.renderWelcomeScreen;
renderWarning?: TTTDDialog.renderWarning;
persistenceAdapter: TTDPersistenceAdapter;
}
| { __fallback: true }
)) => {
const app = useApp();
const [mermaidToExcalidrawLib, setMermaidToExcalidrawLib] =
useState<MermaidToExcalidrawLibProps>({
loaded: false,
api: import("@excalidraw/mermaid-to-excalidraw"),
});
useEffect(() => {
const fn = async () => {
await mermaidToExcalidrawLib.api;
setMermaidToExcalidrawLib((prev) => ({ ...prev, loaded: true }));
};
fn();
}, [mermaidToExcalidrawLib.api]);
return (
<Dialog
className="ttd-dialog"
onCloseRequest={() => {
app.setOpenDialog(null);
}}
size={1520}
title={false}
{...rest}
autofocus={false}
>
<TTDDialogTabs dialog="ttd" tab={tab}>
{"__fallback" in rest && rest.__fallback ? (
<p className="dialog-mermaid-title">{t("mermaid.title")}</p>
) : (
<TTDDialogTabTriggers>
<TTDDialogTabTrigger tab="text-to-diagram">
<div className="ttd-dialog-tab-trigger__content">
{t("labels.textToDiagram")}
<div className="ttd-dialog-tab-trigger__badge">
{t("chat.aiBeta")}
</div>
</div>
</TTDDialogTabTrigger>
<TTDDialogTabTrigger tab="mermaid">
{t("mermaid.label")}
</TTDDialogTabTrigger>
</TTDDialogTabTriggers>
)}
{!("__fallback" in rest) && (
<TTDDialogTab className="ttd-dialog-content" tab="text-to-diagram">
<TextToDiagram
mermaidToExcalidrawLib={mermaidToExcalidrawLib}
onTextSubmit={rest.onTextSubmit}
renderWelcomeScreen={rest.renderWelcomeScreen}
renderWarning={rest.renderWarning}
persistenceAdapter={rest.persistenceAdapter}
/>
</TTDDialogTab>
)}
<TTDDialogTab className="ttd-dialog-content" tab="mermaid">
<MermaidToExcalidraw
mermaidToExcalidrawLib={mermaidToExcalidrawLib}
isActive={tab === "mermaid"}
/>
</TTDDialogTab>
</TTDDialogTabs>
</Dialog>
);
},
);

View File

@@ -0,0 +1,137 @@
import { useEffect, useRef, useState } from "react";
import { EVENT, KEYS } from "@excalidraw/common";
import Spinner from "../Spinner";
import { useUIAppState } from "../../context/ui-appState";
import type { ComponentType } from "react";
import type { CodeMirrorEditorProps } from "./CodeMirrorEditor";
interface TTDDialogInputProps {
input: string;
placeholder: string;
onChange: (value: string) => void;
onKeyboardSubmit?: () => void;
errorLine?: number | null;
}
type EditorState =
| { type: "loading" }
| { type: "ready"; component: ComponentType<CodeMirrorEditorProps> }
| { type: "fallback" };
const SPINNER_DELAY_MS = 300;
export const TTDDialogInput = ({
input,
placeholder,
onChange,
onKeyboardSubmit,
errorLine,
}: TTDDialogInputProps) => {
const ref = useRef<HTMLTextAreaElement>(null);
const callbackRef = useRef(onKeyboardSubmit);
callbackRef.current = onKeyboardSubmit;
const [editorState, setEditorState] = useState<EditorState>({
type: "loading",
});
const [showSpinner, setShowSpinner] = useState(false);
const { theme } = useUIAppState();
// Lazy-load CodeMirror editor
useEffect(() => {
let cancelled = false;
const spinnerTimer = setTimeout(() => {
if (!cancelled) {
setShowSpinner(true);
}
}, SPINNER_DELAY_MS);
import("./CodeMirrorEditor")
.then((mod) => {
if (!cancelled) {
setEditorState({ type: "ready", component: mod.default });
}
})
.catch(() => {
if (!cancelled) {
setEditorState({ type: "fallback" });
}
})
.finally(() => {
clearTimeout(spinnerTimer);
});
return () => {
cancelled = true;
clearTimeout(spinnerTimer);
};
}, []);
// Keyboard shortcut + focus for textarea fallback
useEffect(() => {
if (editorState.type !== "fallback") {
return;
}
if (!callbackRef.current) {
return;
}
const textarea = ref.current;
if (textarea) {
const handleKeyDown = (event: KeyboardEvent) => {
if (event[KEYS.CTRL_OR_CMD] && event.key === KEYS.ENTER) {
event.preventDefault();
callbackRef.current?.();
}
};
textarea.focus();
textarea.addEventListener(EVENT.KEYDOWN, handleKeyDown);
return () => {
textarea.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
};
}
}, [editorState.type]);
if (editorState.type === "ready") {
const CodeMirrorEditor = editorState.component;
return (
<CodeMirrorEditor
value={input}
onChange={onChange}
onKeyboardSubmit={onKeyboardSubmit}
placeholder={placeholder}
theme={theme}
errorLine={errorLine}
/>
);
}
if (editorState.type === "fallback") {
return (
<textarea
className="ttd-dialog-input"
onChange={(e) => onChange(e.target.value)}
value={input}
placeholder={placeholder}
ref={ref}
/>
);
}
// Loading state
if (showSpinner) {
return (
<div className="ttd-dialog-input ttd-dialog-input--loading">
<Spinner />
</div>
);
}
return null;
};

View File

@@ -0,0 +1,122 @@
import clsx from "clsx";
import { Button } from "../Button";
import Spinner from "../Spinner";
import { t } from "../../i18n";
import { alertTriangleIcon } from "../icons";
import {
formatMermaidParseErrorMessage,
getMermaidSyntaxErrorGuidance,
isMermaidCaretLine,
} from "./utils/mermaidError";
interface TTDDialogOutputProps {
error: Error | null;
canvasRef: React.RefObject<HTMLDivElement | null>;
loaded: boolean;
hideErrorDetails?: boolean;
sourceText?: string;
autoFixAvailable?: boolean;
onApplyAutoFix?: () => void;
}
export const TTDDialogOutput = ({
error,
canvasRef,
loaded,
hideErrorDetails,
sourceText,
autoFixAvailable,
onApplyAutoFix,
}: TTDDialogOutputProps) => {
const errorMessage = error
? hideErrorDetails
? t("chat.errors.mermaidParseError")
: formatMermaidParseErrorMessage(error.message)
: null;
const syntaxGuidance =
error && !hideErrorDetails
? getMermaidSyntaxErrorGuidance(error.message, sourceText)
: null;
const showAutoFixButton =
!!autoFixAvailable && !!onApplyAutoFix && !hideErrorDetails;
const errorMessageLines = errorMessage?.split(/\r?\n/) ?? [];
return (
<div
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>
{syntaxGuidance && (
<div className="ttd-dialog-output-error-summary">
<div className="ttd-dialog-output-error-summary__headline">
{syntaxGuidance.summary}
</div>
<div className="ttd-dialog-output-error-summary__label">
Likely causes:
</div>
<ul className="ttd-dialog-output-error-summary__causes">
{syntaxGuidance.likelyCauses.map((cause) => (
<li key={cause}>{cause}</li>
))}
</ul>
</div>
)}
<div className="ttd-dialog-output-error-message">
{errorMessageLines.map((line, index) => (
<span
key={`error-line-${index}`}
className={
isMermaidCaretLine(line)
? "ttd-dialog-output-error-message__caret"
: undefined
}
>
{line}
{index < errorMessageLines.length - 1 ? "\n" : ""}
</span>
))}
</div>
{!hideErrorDetails && (
<div className="ttd-dialog-output-error-autofix-slot">
{showAutoFixButton ? (
<Button
className="ttd-dialog-panel-button ttd-dialog-output-error-autofix"
onSelect={onApplyAutoFix}
>
{t("mermaid.autoFixAvailable")}
</Button>
) : null}
</div>
)}
</div>
</div>
)}
{loaded ? (
<div
key="canvas"
className={clsx("ttd-dialog-output-canvas-container", {
invisible: !!error,
})}
>
<div ref={canvasRef} className="ttd-dialog-output-canvas-content" />
</div>
) : (
<Spinner size="2rem" />
)}
</div>
);
};

View File

@@ -0,0 +1,124 @@
import clsx from "clsx";
import { Fragment } from "react";
import { Button } from "../Button";
import Spinner from "../Spinner";
import type { ReactNode } from "react";
export type TTDPanelAction = {
label: string;
action?: () => void;
icon?: ReactNode;
variant: "button" | "link" | "rateLimit";
disabled?: boolean;
className?: string;
};
interface TTDDialogPanelProps {
label?: string | ReactNode;
children: ReactNode;
panelActions?: TTDPanelAction[];
onTextSubmitInProgess?: boolean;
renderTopRight?: () => ReactNode;
renderSubmitShortcut?: () => ReactNode;
className?: string;
panelActionJustifyContent?:
| "flex-start"
| "flex-end"
| "center"
| "space-between"
| "space-around"
| "space-evenly";
}
export const TTDDialogPanel = ({
label,
children,
panelActions = [],
onTextSubmitInProgess,
renderTopRight,
renderSubmitShortcut,
className,
panelActionJustifyContent = "flex-start",
}: TTDDialogPanelProps) => {
const renderPanelAction = (panelAction: TTDPanelAction) => {
if (panelAction?.variant === "link") {
return (
<button
className={clsx(
"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>
);
}
if (panelAction?.variant === "button") {
return (
<Button
className={clsx("ttd-dialog-panel-button", panelAction.className)}
onSelect={panelAction.action ? panelAction.action : () => {}}
disabled={panelAction?.disabled || onTextSubmitInProgess}
>
<div className={clsx({ invisible: onTextSubmitInProgess })}>
{panelAction?.label}
{panelAction?.icon && <span>{panelAction.icon}</span>}
</div>
{onTextSubmitInProgess && <Spinner />}
</Button>
);
}
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>
);
};

View File

@@ -0,0 +1,5 @@
import type { ReactNode } from "react";
export const TTDDialogPanels = ({ children }: { children: ReactNode }) => {
return <div className="ttd-dialog-panels">{children}</div>;
};

View File

@@ -0,0 +1,14 @@
import { getShortcutKey } from "../../shortcut";
export const TTDDialogSubmitShortcut = () => {
return (
<div className="ttd-dialog-submit-shortcut">
<div className="ttd-dialog-submit-shortcut__key">
{getShortcutKey("CtrlOrCmd")}
</div>
<div className="ttd-dialog-submit-shortcut__key">
{getShortcutKey("Enter")}
</div>
</div>
);
};

View File

@@ -0,0 +1,17 @@
import { Tabs as RadixTabs } from "radix-ui";
export const TTDDialogTab = ({
tab,
children,
...rest
}: {
tab: string;
children: React.ReactNode;
} & React.HTMLAttributes<HTMLDivElement>) => {
return (
<RadixTabs.Content {...rest} value={tab}>
{children}
</RadixTabs.Content>
);
};
TTDDialogTab.displayName = "TTDDialogTab";

View File

@@ -0,0 +1,21 @@
import { Tabs as RadixTabs } from "radix-ui";
export const TTDDialogTabTrigger = ({
children,
tab,
onSelect,
...rest
}: {
children: React.ReactNode;
tab: string;
onSelect?: React.ReactEventHandler<HTMLButtonElement> | undefined;
} & Omit<React.HTMLAttributes<HTMLButtonElement>, "onSelect">) => {
return (
<RadixTabs.Trigger value={tab} asChild onSelect={onSelect}>
<button type="button" className="ttd-dialog-tab-trigger" {...rest}>
{children}
</button>
</RadixTabs.Trigger>
);
};
TTDDialogTabTrigger.displayName = "TTDDialogTabTrigger";

View File

@@ -0,0 +1,13 @@
import { Tabs as RadixTabs } from "radix-ui";
export const TTDDialogTabTriggers = ({
children,
...rest
}: { children: React.ReactNode } & React.HTMLAttributes<HTMLDivElement>) => {
return (
<RadixTabs.List className="ttd-dialog-triggers" {...rest}>
{children}
</RadixTabs.List>
);
};
TTDDialogTabTriggers.displayName = "TTDDialogTabTriggers";

View File

@@ -0,0 +1,58 @@
import { Tabs as RadixTabs } from "radix-ui";
import { useRef } from "react";
import { isMemberOf } from "@excalidraw/common";
import { useExcalidrawSetAppState } from "../App";
import type { ReactNode } from "react";
const TTDDialogTabs = (
props: {
children: ReactNode;
} & { dialog: "ttd"; tab: "text-to-diagram" | "mermaid" },
) => {
const setAppState = useExcalidrawSetAppState();
const rootRef = useRef<HTMLDivElement>(null);
const minHeightRef = useRef<number>(0);
return (
<RadixTabs.Root
ref={rootRef}
className="ttd-dialog-tabs-root"
value={props.tab}
onValueChange={(
// at least in test enviros, `tab` can be `undefined`
tab: string | undefined,
) => {
if (!tab) {
return;
}
const modalContentNode =
rootRef.current?.closest<HTMLElement>(".Modal__content");
if (modalContentNode) {
const currHeight = modalContentNode.offsetHeight || 0;
if (currHeight > minHeightRef.current) {
minHeightRef.current = currHeight;
modalContentNode.style.minHeight = `min(${minHeightRef.current}px, 100%)`;
}
}
if (
props.dialog === "ttd" &&
isMemberOf(["text-to-diagram", "mermaid"], tab)
) {
setAppState({
openDialog: { name: props.dialog, tab },
});
}
}}
>
{props.children}
</RadixTabs.Root>
);
};
TTDDialogTabs.displayName = "TTDDialogTabs";
export default TTDDialogTabs;

View File

@@ -0,0 +1,36 @@
import { trackEvent } from "../../analytics";
import { useTunnels } from "../../context/tunnels";
import { useI18n } from "../../i18n";
import { useExcalidrawSetAppState } from "../App";
import DropdownMenu from "../dropdownMenu/DropdownMenu";
import { brainIcon } from "../icons";
import type { JSX, ReactNode } from "react";
export const TTDDialogTrigger = ({
children,
icon,
}: {
children?: ReactNode;
icon?: JSX.Element;
}) => {
const { t } = useI18n();
const { TTDDialogTriggerTunnel } = useTunnels();
const setAppState = useExcalidrawSetAppState();
return (
<TTDDialogTriggerTunnel.In>
<DropdownMenu.Item
onSelect={() => {
trackEvent("ai", "dialog open", "ttd");
setAppState({ openDialog: { name: "ttd", tab: "text-to-diagram" } });
}}
icon={icon ?? brainIcon}
badge={<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>}
>
{children ?? t("labels.textToDiagram")}
</DropdownMenu.Item>
</TTDDialogTriggerTunnel.In>
);
};
TTDDialogTrigger.displayName = "TTDDialogTrigger";

View File

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

View File

@@ -0,0 +1,11 @@
import { t } from "../../i18n";
export const TTDWelcomeMessage = () => {
return (
<div className="chat-interface__welcome-screen__welcome-message">
<h3>{t("chat.placeholder.title")}</h3>
<p>{t("chat.placeholder.description")}</p>
<p>{t("chat.placeholder.hint")}</p>
</div>
);
};

View File

@@ -0,0 +1,266 @@
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,
TTDPersistenceAdapter,
TTTDDialog,
} from "./types";
const TextToDiagramContent = ({
mermaidToExcalidrawLib,
onTextSubmit,
renderWelcomeScreen,
renderWarning,
persistenceAdapter,
}: {
mermaidToExcalidrawLib: MermaidToExcalidrawLibProps;
onTextSubmit: (
props: TTTDDialog.OnTextSubmitProps,
) => Promise<TTTDDialog.OnTextSubmitRetValue>;
renderWelcomeScreen?: TTTDDialog.renderWelcomeScreen;
renderWarning?: TTTDDialog.renderWarning;
persistenceAdapter: TTDPersistenceAdapter;
}) => {
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({ persistenceAdapter });
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({ persistenceAdapter });
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({ prompt: repairPrompt, isRepairFlow: 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" &&
typeof previousMessage.content === "string"
) {
setLastRetryAttempt();
await onGenerate({
prompt: previousMessage.content,
isRepairFlow: 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}
onGenerate={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}
renderWelcomeScreen={renderWelcomeScreen}
/>
{showPreview && (
<TTDPreviewPanel
canvasRef={canvasRef}
hideErrorDetails={lastAssistantMessage?.errorType === "parse"}
error={error}
loaded={mermaidToExcalidrawLib.loaded}
onInsert={handleInsertToEditor}
/>
)}
</div>
);
};
export const TextToDiagram = ({
mermaidToExcalidrawLib,
onTextSubmit,
renderWelcomeScreen,
renderWarning,
persistenceAdapter,
}: {
mermaidToExcalidrawLib: MermaidToExcalidrawLibProps;
onTextSubmit(
props: TTTDDialog.OnTextSubmitProps,
): Promise<TTTDDialog.OnTextSubmitRetValue>;
renderWelcomeScreen?: TTTDDialog.renderWelcomeScreen;
renderWarning?: TTTDDialog.renderWarning;
persistenceAdapter: TTDPersistenceAdapter;
}) => {
return (
<TextToDiagramContent
mermaidToExcalidrawLib={mermaidToExcalidrawLib}
onTextSubmit={onTextSubmit}
renderWelcomeScreen={renderWelcomeScreen}
renderWarning={renderWarning}
persistenceAdapter={persistenceAdapter}
/>
);
};
export default TextToDiagram;

View File

@@ -0,0 +1,89 @@
import { describe, expect, it, vi } from "vitest";
import { convertMermaidToExcalidraw } from "./common";
type ConvertMermaidArgs = Parameters<typeof convertMermaidToExcalidraw>[0];
type ParseMermaidToExcalidraw = Awaited<
ConvertMermaidArgs["mermaidToExcalidrawLib"]["api"]
>["parseMermaidToExcalidraw"];
const createConvertArgs = (
mermaidDefinition: string,
parseMermaidToExcalidraw: ParseMermaidToExcalidraw,
): ConvertMermaidArgs => {
const parent = document.createElement("div");
const canvas = document.createElement("div");
parent.appendChild(canvas);
return {
canvasRef: { current: canvas },
mermaidToExcalidrawLib: {
loaded: true,
api: Promise.resolve({ parseMermaidToExcalidraw }),
},
mermaidDefinition,
setError: vi.fn(),
data: {
current: {
elements: [],
files: null,
},
},
theme: "light",
};
};
describe("convertMermaidToExcalidraw", () => {
it("returns the original parse error when quote-normalized fallback also fails", async () => {
const originalError = new Error("Parse error on line 9: ...");
const fallbackError = new Error("Parse error on line 6: ...");
const parseMermaidToExcalidraw = vi
.fn<ParseMermaidToExcalidraw>()
.mockRejectedValueOnce(originalError)
.mockRejectedValueOnce(fallbackError);
const mermaidDefinition =
'graph TD\nA["One"]\nB["Two"]x\nC["Three"]\nD["Four"]';
const result = await convertMermaidToExcalidraw(
createConvertArgs(mermaidDefinition, parseMermaidToExcalidraw),
);
expect(parseMermaidToExcalidraw).toHaveBeenCalledTimes(2);
expect(parseMermaidToExcalidraw).toHaveBeenNthCalledWith(
1,
mermaidDefinition,
);
expect(parseMermaidToExcalidraw).toHaveBeenNthCalledWith(
2,
mermaidDefinition.replace(/"/g, "'"),
);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toBe(originalError);
}
});
it("does not retry quote normalization when the input has no double quotes", async () => {
const originalError = new Error("Parse error on line 9: ...");
const parseMermaidToExcalidraw = vi
.fn<ParseMermaidToExcalidraw>()
.mockRejectedValueOnce(originalError);
const mermaidDefinition = "graph TD\nA[One]\nB[Two]x";
const result = await convertMermaidToExcalidraw(
createConvertArgs(mermaidDefinition, parseMermaidToExcalidraw),
);
expect(parseMermaidToExcalidraw).toHaveBeenCalledTimes(1);
expect(parseMermaidToExcalidraw).toHaveBeenCalledWith(mermaidDefinition);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toBe(originalError);
}
});
});

View File

@@ -0,0 +1,170 @@
import {
DEFAULT_EXPORT_PADDING,
EDITOR_LS_KEYS,
THEME,
} from "@excalidraw/common";
import { convertToExcalidrawElements } from "@excalidraw/element";
import { exportToCanvas } from "@excalidraw/utils";
import type {
NonDeletedExcalidrawElement,
Theme,
} from "@excalidraw/element/types";
import { EditorLocalStorage } from "../../data/EditorLocalStorage";
import type { MermaidToExcalidrawLibProps } from "./types";
import type { AppClassProperties, BinaryFiles } from "../../types";
export const resetPreview = ({
canvasRef,
setError,
}: {
canvasRef: React.RefObject<HTMLDivElement | null>;
setError: (error: Error | null) => void;
}) => {
const canvasNode = canvasRef.current;
if (!canvasNode) {
return;
}
const parent = canvasNode.parentElement;
if (!parent) {
return;
}
parent.style.background = "";
setError(null);
canvasNode.replaceChildren();
};
export const convertMermaidToExcalidraw = async ({
canvasRef,
mermaidToExcalidrawLib,
mermaidDefinition,
setError,
data,
theme,
}: {
canvasRef: React.RefObject<HTMLDivElement | null>;
mermaidToExcalidrawLib: MermaidToExcalidrawLibProps;
mermaidDefinition: string;
setError: (error: Error | null) => void;
data: React.MutableRefObject<{
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles | null;
}>;
theme: Theme;
}): Promise<{ success: true } | { success: false; error?: Error }> => {
const canvasNode = canvasRef.current;
const parent = canvasNode?.parentElement;
if (!canvasNode || !parent) {
return { success: false };
}
if (!mermaidDefinition) {
resetPreview({ canvasRef, setError });
return { success: false };
}
let ret;
try {
const api = await mermaidToExcalidrawLib.api;
try {
ret = await api.parseMermaidToExcalidraw(mermaidDefinition);
} catch (err: unknown) {
const originalParseError = err as Error;
if (!mermaidDefinition.includes('"')) {
return { success: false, error: originalParseError };
}
try {
ret = await api.parseMermaidToExcalidraw(
mermaidDefinition.replace(/"/g, "'"),
);
} catch {
// Keep the original error so line/column references stay aligned with
// the user's unmodified input.
return { success: false, error: originalParseError };
}
}
const { elements, files = {} } = ret;
setError(null);
data.current = {
elements: convertToExcalidrawElements(elements, {
regenerateIds: true,
}),
files,
};
const canvas = await exportToCanvas({
elements: data.current.elements,
files: data.current.files,
exportPadding: DEFAULT_EXPORT_PADDING,
maxWidthOrHeight:
Math.max(parent.offsetWidth, parent.offsetHeight) *
window.devicePixelRatio,
appState: {
exportWithDarkMode: theme === THEME.DARK,
},
});
parent.style.background = "var(--default-bg-color)";
canvasNode.replaceChildren(canvas);
return { success: true };
} catch (err: any) {
parent.style.background = "var(--default-bg-color)";
if (mermaidDefinition) {
setError(err);
}
// Return error so caller can display meaningful error message
return { success: false, error: err };
}
};
export const saveMermaidDataToStorage = (mermaidDefinition: string) => {
EditorLocalStorage.set(
EDITOR_LS_KEYS.MERMAID_TO_EXCALIDRAW,
mermaidDefinition,
);
};
export const insertToEditor = ({
app,
data,
text,
shouldSaveMermaidDataToStorage,
}: {
app: AppClassProperties;
data: React.MutableRefObject<{
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles | null;
}>;
text?: string;
shouldSaveMermaidDataToStorage?: boolean;
}) => {
const { elements: newElements, files } = data.current;
if (!newElements.length) {
return;
}
app.addElementsFromPasteOrLibrary({
elements: newElements,
files,
position: "center",
fit: "scale-down",
});
app.setOpenDialog(null);
if (shouldSaveMermaidDataToStorage && text) {
saveMermaidDataToStorage(text);
}
};

View File

@@ -0,0 +1,114 @@
import { useCallback, 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, TTDPersistenceAdapter } from "../types";
interface UseChatManagementProps {
persistenceAdapter: TTDPersistenceAdapter;
}
export const useChatManagement = ({
persistenceAdapter,
}: UseChatManagementProps) => {
const setError = useSetAtom(errorAtom);
const [chatHistory, setChatHistory] = useAtom(chatHistoryAtom);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const { restoreChat, deleteChat, createNewChatId } = useTTDChatStorage({
persistenceAdapter,
});
const applyChatToState = useCallback(
(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);
},
[setError, setChatHistory],
);
const resetChatState = useCallback(async () => {
const newSessionId = await createNewChatId();
setChatHistory({
id: newSessionId,
messages: [],
currentPrompt: "",
});
setError(null);
}, [createNewChatId, setChatHistory, setError]);
const onRestoreChat = useCallback(
(chat: SavedChat) => {
const restoredChat = restoreChat(chat);
applyChatToState(restoredChat);
setIsMenuOpen(false);
},
[restoreChat, applyChatToState],
);
const handleDeleteChat = useCallback(
async (chatId: string, event: React.MouseEvent) => {
event.stopPropagation();
const isDeletingActiveChat = chatId === chatHistory.id;
const updatedChats = await deleteChat(chatId);
if (isDeletingActiveChat) {
if (updatedChats.length > 0) {
const nextChat = updatedChats[0];
applyChatToState(nextChat);
} else {
await resetChatState();
}
}
},
[chatHistory.id, deleteChat, applyChatToState, resetChatState],
);
const handleNewChat = useCallback(async () => {
await resetChatState();
setIsMenuOpen(false);
}, [resetChatState]);
const handleMenuToggle = useCallback(() => {
setIsMenuOpen((prev) => !prev);
}, []);
const handleMenuClose = useCallback(() => {
setIsMenuOpen(false);
}, []);
return {
isMenuOpen,
onRestoreChat,
handleDeleteChat,
handleNewChat,
handleMenuToggle,
handleMenuClose,
};
};

View File

@@ -0,0 +1,213 @@
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);
} else if (!lastAssistantMessage?.isGenerating) {
throttledRenderMermaid.flush();
resetThrottleState();
if (lastAssistantMessage?.content) {
throttledRenderMermaid(lastAssistantMessage.content);
}
}
}, [
resetThrottleState,
throttledRenderMermaid,
lastAssistantMessage?.isGenerating,
lastAssistantMessage?.content,
]);
// 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,
};
};

View File

@@ -0,0 +1,225 @@
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 { LLMMessage, 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 onGenerate: TTTDDialog.OnGenerate = async ({
prompt,
isRepairFlow = false,
}) => {
if (!validatePrompt(prompt)) {
return;
}
if (streamingAbortControllerRef.current) {
streamingAbortControllerRef.current.abort();
}
setError(null);
const abortController = new AbortController();
streamingAbortControllerRef.current = abortController;
if (!isRepairFlow) {
addUserMessage(prompt);
addAssistantMessage();
} else {
setChatHistory((prev) =>
updateAssistantContent(prev, {
isGenerating: true,
content: "",
error: undefined,
errorType: undefined,
errorDetails: undefined,
}),
);
}
try {
trackEvent("ai", "generate", "ttd");
const previousMessages = getMessagesForLLM(chatHistory);
const messages: LLMMessage[] = [
...previousMessages.slice(-3),
{ role: "user", content: prompt },
];
const { generatedResponse, error, rateLimit, rateLimitRemaining } =
await onTextSubmit({
messages,
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?.status === 429 || rateLimitRemaining === 0) {
setChatHistory((chatHistory) => {
if (error?.status === 429) {
chatHistory = removeLastAssistantMessage(chatHistory);
}
chatHistory = {
...chatHistory,
messages: chatHistory.messages.filter(
(msg) =>
msg.type !== "warning" ||
msg.warningType === "rateLimitExceeded" ||
msg.warningType === "messageLimitExceeded",
),
};
const messages = addMessages(chatHistory, [
{
type: "warning",
warningType:
rateLimitRemaining === 0
? "messageLimitExceeded"
: "rateLimitExceeded",
},
]);
return messages;
});
}
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;
}
const _error = new Error(
error.message || t("chat.errors.requestFailed"),
);
if (error.status !== 429) {
setAssistantError(_error.message, "network");
}
setError(_error);
return;
}
try {
await parseMermaidToExcalidraw(generatedResponse ?? "");
trackEvent("ai", "mermaid parse success", "ttd");
} catch (error: any) {
trackEvent("ai", "mermaid parse failed", "ttd");
const _error = new Error(
error.message || t("chat.errors.mermaidParseError"),
);
setAssistantError(_error.message, "parse");
setError(_error);
}
} catch (error: any) {
const _error = new Error(
error.message || t("chat.errors.generationFailed"),
);
setAssistantError(_error.message, "other");
setError(_error);
} finally {
streamingAbortControllerRef.current = null;
}
};
const handleAbort = () => {
if (streamingAbortControllerRef.current) {
streamingAbortControllerRef.current.abort();
}
};
return {
onGenerate,
handleAbort,
};
};

View File

@@ -0,0 +1,82 @@
import { StreamLanguage } from "@codemirror/language";
const mermaidStreamParser = StreamLanguage.define({
token(stream) {
// Comments: %%...
if (stream.match(/^%%.*$/)) {
return "comment";
}
// Strings
if (stream.match(/^"(?:[^"\\]|\\.)*"/)) {
return "string";
}
// Diagram type keywords (at start of line or after whitespace)
if (
stream.match(
/^(flowchart|graph|sequenceDiagram|classDiagram|stateDiagram|erDiagram|gantt|pie|mindmap|journey|gitGraph|timeline|quadrantChart|sankey|xychart)\b/i,
)
) {
return "keyword";
}
// Direction keywords
if (stream.match(/^(TB|TD|BT|RL|LR)\b/)) {
return "keyword";
}
// Keywords
if (
stream.match(
/^(subgraph|end|participant|actor|loop|alt|else|opt|par|critical|break|rect|note|over|activate|deactivate|title|section|class|style|linkStyle|classDef|click)\b/i,
)
) {
return "keyword";
}
// Arrows: -->, ---, -.->, ===>, etc.
if (stream.match(/^[-.=<>|ox]+>/)) {
return "operator";
}
if (stream.match(/^<[-.=<>|ox]+/)) {
return "operator";
}
if (stream.match(/^--+|\.\.+|==+/)) {
return "operator";
}
// Labels in brackets/parens: [text], (text), {text}, ((text)), etc.
if (stream.match(/^[[\](){}|<>]/)) {
return "bracket";
}
// Node IDs (alphanumeric)
if (stream.match(/^[A-Za-z_][A-Za-z0-9_]*/)) {
return "variableName";
}
// Numbers
if (stream.match(/^\d+(\.\d+)?/)) {
return "number";
}
// Punctuation
if (stream.match(/^[,:;]/)) {
return "punctuation";
}
// Skip whitespace
if (stream.eatSpace()) {
return null;
}
// Skip any other character
stream.next();
return null;
},
});
export function mermaidLite() {
return mermaidStreamParser;
}

View File

@@ -0,0 +1,124 @@
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 type SavedChats = SavedChat[];
/**
* Interface for TTD chat persistence. Preferably should be stable
* (e.g. static class/singleton)
*/
export interface TTDPersistenceAdapter {
/**
* Load saved chats from storage.
*/
loadChats(): Promise<SavedChats>;
/**
* Save chats to storage.
*/
saveChats(chats: SavedChats): Promise<void>;
}
export interface MermaidToExcalidrawLibProps {
loaded: boolean;
api: Promise<{
parseMermaidToExcalidraw: (
definition: string,
config?: MermaidConfig,
) => Promise<MermaidToExcalidrawResult>;
}>;
}
export namespace TTTDDialog {
export type OnGenerate = (opts: {
prompt: string;
isRepairFlow?: boolean;
}) => Promise<void>;
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;
export type renderWelcomeScreen = (props: {
/** null if not rate limit data currently available */
rateLimits: RateLimits | null;
}) => React.ReactNode | undefined;
}

View File

@@ -0,0 +1,192 @@
import { useCallback, useEffect, useRef } from "react";
import { randomId } from "@excalidraw/common";
import { atom, useAtom } from "../../editor-jotai";
import { chatHistoryAtom } from "./TTDContext";
import type { SavedChat, SavedChats, TTDPersistenceAdapter } from "./types";
interface UseTTDChatStorageProps {
persistenceAdapter: TTDPersistenceAdapter;
}
interface UseTTDChatStorageReturn {
savedChats: SavedChats;
saveCurrentChat: () => Promise<void>;
deleteChat: (chatId: string) => Promise<SavedChats>;
restoreChat: (chat: SavedChat) => SavedChat;
createNewChatId: () => Promise<string>;
}
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 - starts empty, populated via onLoadChats
export const savedChatsAtom = atom<SavedChats>([]);
export const isLoadingChatsAtom = atom<boolean>(false);
export const chatsLoadedAtom = atom<boolean>(false);
export const useTTDChatStorage = ({
persistenceAdapter,
}: UseTTDChatStorageProps): UseTTDChatStorageReturn => {
const [chatHistory] = useAtom(chatHistoryAtom);
const [savedChats, setSavedChats] = useAtom(savedChatsAtom);
const [isLoading, setIsLoading] = useAtom(isLoadingChatsAtom);
const [chatsLoaded, setChatsLoaded] = useAtom(chatsLoadedAtom);
// Ref to track latest savedChats for async operations
const savedChatsRef = useRef(savedChats);
savedChatsRef.current = savedChats;
const lastMessageInHistory =
chatHistory?.messages[chatHistory?.messages.length - 1];
// Load chats on-demand
const loadChats = useCallback(async () => {
if (chatsLoaded || isLoading) {
return;
}
setIsLoading(true);
try {
const chats = await persistenceAdapter.loadChats();
setSavedChats(chats);
setChatsLoaded(true);
} catch (error) {
console.warn("Failed to load chats:", error);
setSavedChats([]);
setChatsLoaded(true);
} finally {
setIsLoading(false);
}
}, [
chatsLoaded,
isLoading,
setSavedChats,
setIsLoading,
setChatsLoaded,
persistenceAdapter,
]);
// INITIAL LOAD
useEffect(() => {
loadChats();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const saveCurrentChat = useCallback(async () => {
if (chatHistory.messages.length === 0) {
return;
}
const firstUserMessage = chatHistory.messages.find(
(msg) => msg.type === "user",
);
if (!firstUserMessage || typeof firstUserMessage.content !== "string") {
return;
}
const title = generateChatTitle(firstUserMessage.content);
const currentSavedChats = savedChatsRef.current;
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);
try {
await persistenceAdapter.saveChats(updatedChats);
} catch (error) {
console.warn("Failed to save chats:", error);
}
}, [chatHistory, setSavedChats, persistenceAdapter]);
// Auto-save when generation completes
useEffect(() => {
if (!lastMessageInHistory?.isGenerating) {
saveCurrentChat();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
chatHistory.messages?.length,
lastMessageInHistory?.id,
lastMessageInHistory?.isGenerating,
]);
const deleteChat = useCallback(
async (chatId: string): Promise<SavedChats> => {
const updatedChats = savedChatsRef.current.filter(
(chat) => chat.id !== chatId,
);
setSavedChats(updatedChats);
try {
await persistenceAdapter.saveChats(updatedChats);
} catch (error) {
console.warn("Failed to save after delete:", error);
}
return updatedChats;
},
[setSavedChats, persistenceAdapter],
);
const restoreChat = useCallback((chat: SavedChat): SavedChat => {
// Save is handled by the caller after state update
return chat;
}, []);
const createNewChatId = useCallback(async (): Promise<string> => {
await saveCurrentChat();
return randomId();
}, [saveCurrentChat]);
return {
savedChats,
saveCurrentChat,
deleteChat,
restoreChat,
createNewChatId,
};
};

View File

@@ -0,0 +1,225 @@
import { RequestError } from "../../../errors";
import type { LLMMessage, TTTDDialog } from "../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;
status?: number;
};
};
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,
};
}
export 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,
}),
};
}
}

View File

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

View File

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

View File

@@ -0,0 +1,92 @@
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 = findLastIndex(
chatHistory.messages ?? [],
(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;
};

View File

@@ -0,0 +1,116 @@
import { describe, expect, it } from "vitest";
import { getMermaidAutoFixCandidates } from "./mermaidAutoFix";
describe("getMermaidAutoFixCandidates", () => {
it("suggests removing trailing token after a closed label shape", () => {
const sourceText = `graph TD
L3_TCP["TCP (Transmission Control Protocol)"]x
L3_UDP["UDP (User Datagram Protocol)"]`;
const errorMessage = `Parse error on line 2:
...ission Control Protocol)"]x
-----------------------------^
Expecting 'SEMI', got 'NODE_STRING'`;
const candidates = getMermaidAutoFixCandidates(sourceText, errorMessage);
expect(candidates).toContain(`graph TD
L3_TCP["TCP (Transmission Control Protocol)"]
L3_UDP["UDP (User Datagram Protocol)"]`);
});
it("suggests appending missing end statements", () => {
const sourceText = `graph TD
subgraph A
A1[Start]`;
const errorMessage = `Parse error on line 3:
... A1[Start]
-------------^
Expecting 'end'`;
const candidates = getMermaidAutoFixCandidates(sourceText, errorMessage);
expect(candidates).toContain(`graph TD
subgraph A
A1[Start]
end`);
});
it("returns empty list for non-parse errors", () => {
expect(
getMermaidAutoFixCandidates("graph TD\nA-->B", "Network error"),
).toEqual([]);
});
it("extracts line index from lexical error format too", () => {
const sourceText = `graph TD
subgraph Layers["X"]x
direction TB`;
const errorMessage = `Lexical error on line 2. Unrecognized text.
... subgraph Layers["X"]x direction
-----------------------^`;
const candidates = getMermaidAutoFixCandidates(sourceText, errorMessage);
expect(candidates).toContain(`graph TD
subgraph Layers["X"]
direction TB`);
});
it("removes extra > after edge label", () => {
const sourceText = `flowchart TD
A["User Input"] -->|text|> B["Tokenization"]
A["User Input"] -->|text|> B["Tokenization"]`;
const errorMessage = `Parse error on line 2:
...A["User Input"] -->|text|> B["Tokenization"]
---------------------------^
Expecting 'NODE_STRING', got 'GT'`;
const candidates = getMermaidAutoFixCandidates(sourceText, errorMessage);
expect(candidates).toContain(`flowchart TD
A["User Input"] -->|text| B["Tokenization"]
A["User Input"] -->|text| B["Tokenization"]`);
});
it("suggests removing the last invalid deactivate for participant errors", () => {
const sourceText = `sequenceDiagram
participant QAEngineer as QA
activate QA
QA->>QA: Verifies Fix
deactivate QA
QA->>QA: Verifies Again
deactivate QA`;
const errorMessage = "Trying to inactivate an inactive participant (QA)";
const candidates = getMermaidAutoFixCandidates(sourceText, errorMessage);
expect(candidates).toContain(`sequenceDiagram
participant QAEngineer as QA
activate QA
QA->>QA: Verifies Fix
deactivate QA
QA->>QA: Verifies Again`);
});
it("adds a fallback candidate that removes all invalid deactivations", () => {
const sourceText = `sequenceDiagram
participant QAEngineer as QA
deactivate QA
QA->>QA: Verifies Fix
deactivate QA`;
const errorMessage = "Trying to inactivate an inactive participant (QA)";
const candidates = getMermaidAutoFixCandidates(sourceText, errorMessage);
expect(candidates).toContain(`sequenceDiagram
participant QAEngineer as QA
QA->>QA: Verifies Fix`);
});
});

View File

@@ -0,0 +1,175 @@
import {
getMermaidErrorLineNumber,
getMermaidInactiveParticipant,
isMermaidAutoFixableError,
isMermaidParseSyntaxError,
} from "./mermaidError";
const getErrorLineIndex = (message: string, sourceText: string) => {
const lineNumber = getMermaidErrorLineNumber(message, sourceText);
if (lineNumber == null) {
return null;
}
return lineNumber - 1;
};
const replaceLineAt = (
lines: string[],
index: number,
transform: (line: string) => string,
) => {
if (index < 0 || index >= lines.length) {
return null;
}
const nextLine = transform(lines[index]);
if (nextLine === lines[index]) {
return null;
}
const nextLines = [...lines];
nextLines[index] = nextLine;
return nextLines.join("\n");
};
const stripTrailingTokenAfterShape = (line: string) => {
const alphaTailMatch = line.match(
/^(.*(?:\[[^\]]*]|\([^)]*\)|\{[^}]*}|"(?:[^"]*)"|'(?:[^']*)'))([A-Za-z]+)\s*$/,
);
if (alphaTailMatch) {
return alphaTailMatch[1];
}
const punctuationTailMatch = line.match(
/^(.*(?:\[[^\]]*]|\([^)]*\)|\{[^}]*}|"(?:[^"]*)"|'(?:[^']*)'))([,;:])\s*$/,
);
if (punctuationTailMatch) {
return punctuationTailMatch[1];
}
return line;
};
const removeExtraArrowheadAfterEdgeLabel = (line: string) => {
// Common typo in generated Mermaid: `-->|label|> Target` (extra `>`).
// Convert it to `-->|label| Target`.
return line.replace(/(\|[^|\n]+\|)\s*>\s*(?=[A-Za-z0-9_("[{'`])/g, "$1 ");
};
const escapeRegExp = (value: string) =>
value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const removeLastDeactivateForParticipant = (
sourceText: string,
participant: string,
) => {
const pattern = new RegExp(
`^\\s*deactivate\\s+${escapeRegExp(participant)}(?:\\s+%%.*)?\\s*$`,
);
const lines = sourceText.split(/\r?\n/);
for (let index = lines.length - 1; index >= 0; index--) {
if (pattern.test(lines[index])) {
return lines.filter((_, lineIndex) => lineIndex !== index).join("\n");
}
}
return null;
};
const removeAllDeactivateForParticipant = (
sourceText: string,
participant: string,
) => {
const pattern = new RegExp(
`^\\s*deactivate\\s+${escapeRegExp(participant)}(?:\\s+%%.*)?\\s*$`,
);
const lines = sourceText.split(/\r?\n/);
let removedAny = false;
const remainingLines = lines.filter((line) => {
if (!pattern.test(line)) {
return true;
}
removedAny = true;
return false;
});
return removedAny ? remainingLines.join("\n") : null;
};
const appendMissingEnds = (sourceText: string) => {
const subgraphCount = (sourceText.match(/^\s*subgraph\b/gm) || []).length;
const endCount = (sourceText.match(/^\s*end\s*$/gm) || []).length;
const missingCount = subgraphCount - endCount;
if (missingCount <= 0) {
return null;
}
const endings = Array.from({ length: missingCount }, () => "end").join("\n");
return `${sourceText.trimEnd()}\n${endings}`;
};
const normalizeSmartQuotes = (sourceText: string) =>
sourceText.replace(/[“”]/g, '"').replace(/[‘’]/g, "'");
export const getMermaidAutoFixCandidates = (
sourceText: string,
errorMessage: string,
) => {
if (!isMermaidAutoFixableError(errorMessage) || !sourceText.trim()) {
return [];
}
const candidates: string[] = [];
const seen = new Set<string>();
const addCandidate = (candidate: string | null) => {
if (!candidate || candidate === sourceText || seen.has(candidate)) {
return;
}
seen.add(candidate);
candidates.push(candidate);
};
const inactiveParticipant = getMermaidInactiveParticipant(errorMessage);
if (inactiveParticipant) {
addCandidate(
removeLastDeactivateForParticipant(sourceText, inactiveParticipant),
);
// Fallback for repeated invalid inactivations in one diagram.
addCandidate(
removeAllDeactivateForParticipant(sourceText, inactiveParticipant),
);
}
if (isMermaidParseSyntaxError(errorMessage)) {
const lines = sourceText.split(/\r?\n/);
const errorLineIndex = getErrorLineIndex(errorMessage, sourceText);
const lineIndexesToTry =
errorLineIndex == null
? []
: [errorLineIndex, errorLineIndex - 1, errorLineIndex + 1];
for (const lineIndex of lineIndexesToTry) {
addCandidate(
replaceLineAt(lines, lineIndex, (line) =>
stripTrailingTokenAfterShape(line),
),
);
addCandidate(
replaceLineAt(lines, lineIndex, (line) =>
removeExtraArrowheadAfterEdgeLabel(line),
),
);
}
// Also try full-text replacement so repeated occurrences on other lines
// are fixed together in a single candidate.
addCandidate(removeExtraArrowheadAfterEdgeLabel(sourceText));
addCandidate(appendMissingEnds(sourceText));
const normalizedQuotes = normalizeSmartQuotes(sourceText);
addCandidate(normalizedQuotes === sourceText ? null : normalizedQuotes);
}
return candidates;
};

View File

@@ -0,0 +1,155 @@
import { describe, expect, it } from "vitest";
import {
formatMermaidParseErrorMessage,
getMermaidErrorLineNumber,
getMermaidInactiveParticipant,
getMermaidSyntaxErrorGuidance,
isMermaidAutoFixableError,
isMermaidParseSyntaxError,
isMermaidCaretLine,
} from "./mermaidError";
describe("formatMermaidParseErrorMessage", () => {
it("strips the noisy Expecting clause from Mermaid parse errors", () => {
const message = `Parse error on line 6:
... Control Protocol)"]x L3_UDP
----------------------^
Expecting 'SEMI', 'NEWLINE', 'SPACE', got 'NODE_STRING'`;
expect(formatMermaidParseErrorMessage(message)).toBe(`Parse error on line 6:
... Control Protocol)"]x L3_UDP
----------------------^`);
});
it("keeps Mermaid parse errors unchanged when no Expecting clause exists", () => {
const message = `Parse error on line 3:
... some snippet
----^`;
expect(formatMermaidParseErrorMessage(message)).toBe(message);
});
it("does not modify non-Mermaid parse messages", () => {
const message =
"Unexpected token while parsing JSON. Expecting value at position 10.";
expect(formatMermaidParseErrorMessage(message)).toBe(message);
});
});
describe("isMermaidCaretLine", () => {
it("returns true for Mermaid caret lines", () => {
expect(isMermaidCaretLine("-----------------------^")).toBe(true);
});
it("returns false for regular lines", () => {
expect(isMermaidCaretLine(`... Control Protocol)"]x`)).toBe(false);
});
});
describe("isMermaidParseSyntaxError", () => {
it("returns true for Mermaid parser syntax errors", () => {
expect(isMermaidParseSyntaxError("Parse error on line 6: ...")).toBe(true);
});
it("returns true for Mermaid lexical syntax errors", () => {
expect(
isMermaidParseSyntaxError("Lexical error on line 2. Unrecognized text."),
).toBe(true);
});
it("returns false for non-parse errors", () => {
expect(isMermaidParseSyntaxError("Network error")).toBe(false);
});
});
describe("isMermaidAutoFixableError", () => {
it("returns true for Mermaid parser syntax errors", () => {
expect(isMermaidAutoFixableError("Parse error on line 6: ...")).toBe(true);
});
it("returns true for inactive participant runtime errors", () => {
expect(
isMermaidAutoFixableError(
"Trying to inactivate an inactive participant (QA)",
),
).toBe(true);
});
it("returns false for non-fixable errors", () => {
expect(isMermaidAutoFixableError("Network error")).toBe(false);
});
});
describe("getMermaidInactiveParticipant", () => {
it("extracts the participant id from inactive participant errors", () => {
expect(
getMermaidInactiveParticipant(
"Trying to inactivate an inactive participant (QA)",
),
).toBe("QA");
});
it("returns null for unrelated errors", () => {
expect(
getMermaidInactiveParticipant("Parse error on line 3: ..."),
).toBeNull();
});
});
describe("getMermaidErrorLineNumber", () => {
it("extracts line number from parse error format", () => {
expect(getMermaidErrorLineNumber("Parse error on line 6: ...")).toBe(6);
});
it("extracts line number from lexical error format", () => {
expect(
getMermaidErrorLineNumber("Lexical error on line 2. Unrecognized text."),
).toBe(2);
});
it("returns null for messages without Mermaid line details", () => {
expect(getMermaidErrorLineNumber("Network error")).toBeNull();
});
it("infers line from inactive participant errors when source text is provided", () => {
const sourceText = `sequenceDiagram
participant QA
deactivate QA
QA->>QA: Verifies Fix
deactivate QA`;
expect(
getMermaidErrorLineNumber(
"Trying to inactivate an inactive participant (QA)",
sourceText,
),
).toBe(5);
});
});
describe("getMermaidSyntaxErrorGuidance", () => {
it("returns summary and likely causes for Mermaid parse errors", () => {
const message = `Parse error on line 6:
... Control Protocol)"]x
----------------------^`;
const source = `graph TD
subgraph Layers["X"]
L3_TCP["TCP (Transmission Control Protocol)"]x`;
expect(getMermaidSyntaxErrorGuidance(message, source)).toEqual({
summary: "Syntax error near line 6.",
likelyCauses: expect.arrayContaining([
"A block is missing an `end` statement.",
]),
});
});
it("returns null for non-parse errors", () => {
expect(
getMermaidSyntaxErrorGuidance("Network error", "graph TD"),
).toBeNull();
});
});

View File

@@ -0,0 +1,133 @@
const MERMAID_SYNTAX_ERROR_LINE = /(?:Parse|Lexical) error on line (\d+)[.:]/i;
const MERMAID_INACTIVE_PARTICIPANT_ERROR =
/Trying to inactivate an inactive participant \((.+)\)/i;
const MERMAID_CARET_LINE = /^\s*-+\^\s*$/;
export const isMermaidParseSyntaxError = (message: string) =>
MERMAID_SYNTAX_ERROR_LINE.test(message);
export const isMermaidAutoFixableError = (message: string) =>
isMermaidParseSyntaxError(message) ||
MERMAID_INACTIVE_PARTICIPANT_ERROR.test(message);
export const isMermaidCaretLine = (line: string) =>
MERMAID_CARET_LINE.test(line);
export const getMermaidInactiveParticipant = (
message: string,
): string | null => {
const match = message.match(MERMAID_INACTIVE_PARTICIPANT_ERROR);
if (!match?.[1]) {
return null;
}
return match[1].trim();
};
const escapeRegExp = (value: string) =>
value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const getInactiveParticipantLineNumber = (
message: string,
sourceText: string,
): number | null => {
const participant = getMermaidInactiveParticipant(message);
if (!participant) {
return null;
}
const deactivatePattern = new RegExp(
`^\\s*deactivate\\s+${escapeRegExp(participant)}(?:\\s+%%.*)?\\s*$`,
);
const lines = sourceText.split(/\r?\n/);
for (let index = lines.length - 1; index >= 0; index--) {
if (deactivatePattern.test(lines[index])) {
return index + 1;
}
}
return null;
};
export const getMermaidErrorLineNumber = (
message: string,
sourceText?: string,
): number | null => {
const match = message.match(MERMAID_SYNTAX_ERROR_LINE);
if (!match) {
if (!sourceText) {
return null;
}
return getInactiveParticipantLineNumber(message, sourceText);
}
return Number.parseInt(match[1], 10);
};
const countMatches = (text: string, re: RegExp) =>
(text.match(re) || []).length;
export const getMermaidSyntaxErrorGuidance = (
message: string,
sourceText?: string,
): { summary: string; likelyCauses: string[] } | null => {
if (!isMermaidParseSyntaxError(message)) {
return null;
}
const errorLine = getMermaidErrorLineNumber(message, sourceText);
const summary = errorLine
? `Syntax error near line ${errorLine}.`
: "Syntax error in Mermaid diagram.";
const likelyCauses: string[] = [];
if (sourceText) {
const openBrackets = countMatches(sourceText, /\[/g);
const closeBrackets = countMatches(sourceText, /\]/g);
if (openBrackets !== closeBrackets) {
likelyCauses.push("Unbalanced square brackets in a node label.");
}
const openParens = countMatches(sourceText, /\(/g);
const closeParens = countMatches(sourceText, /\)/g);
if (openParens !== closeParens) {
likelyCauses.push("Unbalanced parentheses in a node shape.");
}
const openBraces = countMatches(sourceText, /\{/g);
const closeBraces = countMatches(sourceText, /\}/g);
if (openBraces !== closeBraces) {
likelyCauses.push("Unbalanced braces in a decision node.");
}
const subgraphCount = countMatches(sourceText, /^\s*subgraph\b/gm);
const endCount = countMatches(sourceText, /^\s*end\s*$/gm);
if (subgraphCount > endCount) {
likelyCauses.push("A block is missing an `end` statement.");
}
}
if (/got 'NODE_STRING'/.test(message) || /got 'PS'/.test(message)) {
likelyCauses.push(
"An extra character/token may appear after a node or label definition.",
);
}
if (likelyCauses.length === 0) {
likelyCauses.push(
"A node or edge line is malformed (missing/extra delimiters).",
);
likelyCauses.push("A block (`subgraph`, `class`, etc.) may be incomplete.");
}
return {
summary,
likelyCauses: [...new Set(likelyCauses)],
};
};
export const formatMermaidParseErrorMessage = (message: string) => {
if (!isMermaidParseSyntaxError(message)) {
return message;
}
return message.replace(/\n\s*Expecting[\s\S]*$/, "").trimEnd();
};

View File

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

View File

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