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