snapshot of https://github.com/excalidraw/excalidraw.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Tests / test (push) Failing after 15s
Some checks failed
Tests / test (push) Failing after 15s
This commit is contained in:
225
packages/excalidraw/components/TTDDialog/utils/TTDStreamFetch.ts
Normal file
225
packages/excalidraw/components/TTDDialog/utils/TTDStreamFetch.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import { RequestError } from "../../../errors";
|
||||
|
||||
import type { LLMMessage, TTTDDialog } from "../types";
|
||||
|
||||
interface RateLimitInfo {
|
||||
rateLimit?: number;
|
||||
rateLimitRemaining?: number;
|
||||
}
|
||||
|
||||
interface StreamingOptions {
|
||||
url: string;
|
||||
messages: readonly LLMMessage[];
|
||||
onChunk?: (chunk: string) => void;
|
||||
extractRateLimits?: boolean;
|
||||
signal?: AbortSignal;
|
||||
onStreamCreated?: () => void;
|
||||
}
|
||||
|
||||
export type StreamChunk =
|
||||
| {
|
||||
type: "content";
|
||||
delta: string;
|
||||
}
|
||||
| {
|
||||
type: "done";
|
||||
finishReason: "stop" | "length" | "content_filter" | "tool_calls" | null;
|
||||
}
|
||||
| {
|
||||
type: "error";
|
||||
error: {
|
||||
message: string;
|
||||
status?: number;
|
||||
};
|
||||
};
|
||||
|
||||
function extractRateLimitHeaders(headers: Headers): RateLimitInfo {
|
||||
const rateLimit = headers.get("X-Ratelimit-Limit");
|
||||
const rateLimitRemaining = headers.get("X-Ratelimit-Remaining");
|
||||
|
||||
return {
|
||||
rateLimit: rateLimit ? parseInt(rateLimit, 10) : undefined,
|
||||
rateLimitRemaining: rateLimitRemaining
|
||||
? parseInt(rateLimitRemaining, 10)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function* parseSSEStream(
|
||||
reader: ReadableStreamDefaultReader<Uint8Array>,
|
||||
): AsyncGenerator<string, void, unknown> {
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
if (!trimmedLine) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith("data: ")) {
|
||||
const data = trimmedLine.slice(6);
|
||||
yield data;
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
export async function TTDStreamFetch(
|
||||
options: StreamingOptions,
|
||||
): Promise<TTTDDialog.OnTextSubmitRetValue> {
|
||||
const {
|
||||
url,
|
||||
messages,
|
||||
onChunk,
|
||||
onStreamCreated,
|
||||
extractRateLimits = true,
|
||||
signal,
|
||||
} = options;
|
||||
|
||||
try {
|
||||
let fullResponse = "";
|
||||
let rateLimitInfo: RateLimitInfo = {};
|
||||
let error: RequestError | null = null;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ messages }),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (extractRateLimits) {
|
||||
rateLimitInfo = extractRateLimitHeaders(response.headers);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 429) {
|
||||
return {
|
||||
...rateLimitInfo,
|
||||
error: new RequestError({
|
||||
message: "Rate limit exceeded",
|
||||
status: 429,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
throw new RequestError({
|
||||
message: text || "Generation failed...",
|
||||
status: response.status,
|
||||
});
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
|
||||
if (!reader) {
|
||||
throw new RequestError({
|
||||
message: "Couldn't get reader from response body",
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
|
||||
onStreamCreated?.();
|
||||
|
||||
try {
|
||||
for await (const data of parseSSEStream(reader)) {
|
||||
if (data === "[DONE]") {
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const chunk: StreamChunk = JSON.parse(data);
|
||||
|
||||
if (chunk === null) {
|
||||
break;
|
||||
}
|
||||
|
||||
switch (chunk.type) {
|
||||
case "content": {
|
||||
const delta = chunk.delta;
|
||||
if (delta) {
|
||||
fullResponse += delta;
|
||||
onChunk?.(delta);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "error":
|
||||
error = new RequestError({
|
||||
message: chunk.error.message,
|
||||
status: 500,
|
||||
});
|
||||
break;
|
||||
case "done":
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to parse SSE data:", data, e);
|
||||
}
|
||||
}
|
||||
} catch (streamError: any) {
|
||||
if (streamError.name === "AbortError") {
|
||||
error = new RequestError({ message: "Request aborted", status: 499 });
|
||||
} else {
|
||||
error = new RequestError({
|
||||
message: streamError.message || "Streaming error",
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
...rateLimitInfo,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
if (!fullResponse) {
|
||||
return {
|
||||
...rateLimitInfo,
|
||||
error: new RequestError({
|
||||
message: "Generation failed...",
|
||||
status: response.status,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
generatedResponse: fullResponse,
|
||||
error: null,
|
||||
...rateLimitInfo,
|
||||
};
|
||||
} catch (err: any) {
|
||||
if (err.name === "AbortError") {
|
||||
return {
|
||||
error: new RequestError({ message: "Request aborted", status: 499 }),
|
||||
};
|
||||
}
|
||||
return {
|
||||
error: new RequestError({
|
||||
message: err.message || "Request failed",
|
||||
status: 500,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
658
packages/excalidraw/components/TTDDialog/utils/chat.test.ts
Normal file
658
packages/excalidraw/components/TTDDialog/utils/chat.test.ts
Normal file
@@ -0,0 +1,658 @@
|
||||
import {
|
||||
addMessages,
|
||||
getLastAssistantMessage,
|
||||
getMessagesForLLM,
|
||||
removeLastAssistantMessage,
|
||||
updateAssistantContent,
|
||||
} from "./chat";
|
||||
|
||||
import type { TChat } from "../types";
|
||||
|
||||
describe("chat utils", () => {
|
||||
describe("updateAssistantContent", () => {
|
||||
it("should update the last assistant message with new payload", () => {
|
||||
const chatHistory: TChat.ChatHistory = {
|
||||
id: "chat-1",
|
||||
currentPrompt: "",
|
||||
messages: [
|
||||
{
|
||||
id: "1",
|
||||
type: "user",
|
||||
content: "Hello",
|
||||
timestamp: new Date("2024-01-01"),
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
type: "assistant",
|
||||
content: "Hi there",
|
||||
timestamp: new Date("2024-01-01"),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = updateAssistantContent(chatHistory, {
|
||||
content: "Hi there, how can I help?",
|
||||
});
|
||||
|
||||
expect(result.messages[1].content).toBe("Hi there, how can I help?");
|
||||
expect(result.messages[1].id).toBe("2"); // ID should remain the same
|
||||
});
|
||||
|
||||
it("should update only the last assistant message when multiple exist", () => {
|
||||
const chatHistory: TChat.ChatHistory = {
|
||||
id: "chat-1",
|
||||
currentPrompt: "",
|
||||
messages: [
|
||||
{
|
||||
id: "1",
|
||||
type: "assistant",
|
||||
content: "First assistant message",
|
||||
timestamp: new Date("2024-01-01"),
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
type: "user",
|
||||
content: "User message",
|
||||
timestamp: new Date("2024-01-01"),
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
type: "assistant",
|
||||
content: "Second assistant message",
|
||||
timestamp: new Date("2024-01-01"),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = updateAssistantContent(chatHistory, {
|
||||
content: "Updated second message",
|
||||
});
|
||||
|
||||
expect(result.messages[0].content).toBe("First assistant message");
|
||||
expect(result.messages[2].content).toBe("Updated second message");
|
||||
});
|
||||
|
||||
it("should update isGenerating flag", () => {
|
||||
const chatHistory: TChat.ChatHistory = {
|
||||
id: "chat-1",
|
||||
currentPrompt: "",
|
||||
messages: [
|
||||
{
|
||||
id: "1",
|
||||
type: "assistant",
|
||||
content: "Message",
|
||||
timestamp: new Date("2024-01-01"),
|
||||
isGenerating: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = updateAssistantContent(chatHistory, {
|
||||
isGenerating: true,
|
||||
});
|
||||
|
||||
expect(result.messages[0].isGenerating).toBe(true);
|
||||
});
|
||||
|
||||
it("should update error information", () => {
|
||||
const chatHistory: TChat.ChatHistory = {
|
||||
id: "chat-1",
|
||||
currentPrompt: "",
|
||||
messages: [
|
||||
{
|
||||
id: "1",
|
||||
type: "assistant",
|
||||
content: "Message",
|
||||
timestamp: new Date("2024-01-01"),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = updateAssistantContent(chatHistory, {
|
||||
error: "Something went wrong",
|
||||
errorType: "network",
|
||||
});
|
||||
|
||||
expect(result.messages[0].error).toBe("Something went wrong");
|
||||
expect(result.messages[0].errorType).toBe("network");
|
||||
});
|
||||
|
||||
it("should return unchanged chatHistory if no assistant message exists", () => {
|
||||
const chatHistory: TChat.ChatHistory = {
|
||||
id: "chat-1",
|
||||
currentPrompt: "",
|
||||
messages: [
|
||||
{
|
||||
id: "1",
|
||||
type: "user",
|
||||
content: "Hello",
|
||||
timestamp: new Date("2024-01-01"),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = updateAssistantContent(chatHistory, {
|
||||
content: "New content",
|
||||
});
|
||||
|
||||
expect(result).toEqual(chatHistory);
|
||||
});
|
||||
|
||||
it("should not mutate original chatHistory", () => {
|
||||
const chatHistory: TChat.ChatHistory = {
|
||||
id: "chat-1",
|
||||
currentPrompt: "",
|
||||
messages: [
|
||||
{
|
||||
id: "1",
|
||||
type: "assistant",
|
||||
content: "Original",
|
||||
timestamp: new Date("2024-01-01"),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = updateAssistantContent(chatHistory, {
|
||||
content: "Updated",
|
||||
});
|
||||
|
||||
expect(chatHistory.messages[0].content).toBe("Original");
|
||||
expect(result.messages[0].content).toBe("Updated");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLastAssistantMessage", () => {
|
||||
it("should return the last assistant message", () => {
|
||||
const chatHistory: TChat.ChatHistory = {
|
||||
id: "chat-1",
|
||||
currentPrompt: "",
|
||||
messages: [
|
||||
{
|
||||
id: "1",
|
||||
type: "user",
|
||||
content: "Hello",
|
||||
timestamp: new Date("2024-01-01"),
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
type: "assistant",
|
||||
content: "Hi there",
|
||||
timestamp: new Date("2024-01-01"),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = getLastAssistantMessage(chatHistory);
|
||||
|
||||
expect(result).toEqual({
|
||||
id: "2",
|
||||
type: "assistant",
|
||||
content: "Hi there",
|
||||
timestamp: new Date("2024-01-01"),
|
||||
});
|
||||
});
|
||||
|
||||
it("should return the last assistant message when multiple exist", () => {
|
||||
const chatHistory: TChat.ChatHistory = {
|
||||
id: "chat-1",
|
||||
currentPrompt: "",
|
||||
messages: [
|
||||
{
|
||||
id: "1",
|
||||
type: "assistant",
|
||||
content: "First",
|
||||
timestamp: new Date("2024-01-01"),
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
type: "user",
|
||||
content: "User",
|
||||
timestamp: new Date("2024-01-01"),
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
type: "assistant",
|
||||
content: "Second",
|
||||
timestamp: new Date("2024-01-01"),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = getLastAssistantMessage(chatHistory);
|
||||
|
||||
expect(result.id).toBe("3");
|
||||
expect(result.content).toBe("Second");
|
||||
});
|
||||
|
||||
it("should return undefined if no assistant message exists", () => {
|
||||
const chatHistory: TChat.ChatHistory = {
|
||||
id: "chat-1",
|
||||
currentPrompt: "",
|
||||
messages: [
|
||||
{
|
||||
id: "1",
|
||||
type: "user",
|
||||
content: "Hello",
|
||||
timestamp: new Date("2024-01-01"),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = getLastAssistantMessage(chatHistory);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return undefined for empty messages array", () => {
|
||||
const chatHistory: TChat.ChatHistory = {
|
||||
id: "chat-1",
|
||||
currentPrompt: "",
|
||||
messages: [],
|
||||
};
|
||||
|
||||
const result = getLastAssistantMessage(chatHistory);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("addMessages", () => {
|
||||
it("should add a single message with id and timestamp", () => {
|
||||
const chatHistory: TChat.ChatHistory = {
|
||||
id: "chat-1",
|
||||
currentPrompt: "",
|
||||
messages: [],
|
||||
};
|
||||
|
||||
const result = addMessages(chatHistory, [
|
||||
{
|
||||
type: "user",
|
||||
content: "Hello",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result.messages).toHaveLength(1);
|
||||
expect(result.messages[0]).toMatchObject({
|
||||
type: "user",
|
||||
content: "Hello",
|
||||
});
|
||||
expect(result.messages[0].id).toBeDefined();
|
||||
expect(result.messages[0].timestamp).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("should add multiple messages", () => {
|
||||
const chatHistory: TChat.ChatHistory = {
|
||||
id: "chat-1",
|
||||
currentPrompt: "",
|
||||
messages: [],
|
||||
};
|
||||
|
||||
const result = addMessages(chatHistory, [
|
||||
{
|
||||
type: "user",
|
||||
content: "Hello",
|
||||
},
|
||||
{
|
||||
type: "assistant",
|
||||
content: "Hi there",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result.messages).toHaveLength(2);
|
||||
expect(result.messages[0].type).toBe("user");
|
||||
expect(result.messages[1].type).toBe("assistant");
|
||||
});
|
||||
|
||||
it("should append to existing messages", () => {
|
||||
const chatHistory: TChat.ChatHistory = {
|
||||
id: "chat-1",
|
||||
currentPrompt: "",
|
||||
messages: [
|
||||
{
|
||||
id: "1",
|
||||
type: "user",
|
||||
content: "Existing",
|
||||
timestamp: new Date("2024-01-01"),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = addMessages(chatHistory, [
|
||||
{
|
||||
type: "assistant",
|
||||
content: "New message",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result.messages).toHaveLength(2);
|
||||
expect(result.messages[0].content).toBe("Existing");
|
||||
expect(result.messages[1].content).toBe("New message");
|
||||
});
|
||||
|
||||
it("should add warning messages", () => {
|
||||
const chatHistory: TChat.ChatHistory = {
|
||||
id: "chat-1",
|
||||
currentPrompt: "",
|
||||
messages: [],
|
||||
};
|
||||
|
||||
const result = addMessages(chatHistory, [
|
||||
{
|
||||
type: "warning",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result.messages).toHaveLength(1);
|
||||
expect(result.messages[0].type).toBe("warning");
|
||||
});
|
||||
|
||||
it("should preserve additional message properties", () => {
|
||||
const chatHistory: TChat.ChatHistory = {
|
||||
id: "chat-1",
|
||||
currentPrompt: "",
|
||||
messages: [],
|
||||
};
|
||||
|
||||
const result = addMessages(chatHistory, [
|
||||
{
|
||||
type: "assistant",
|
||||
content: "Message",
|
||||
isGenerating: true,
|
||||
error: "Error text",
|
||||
errorType: "parse",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result.messages[0].isGenerating).toBe(true);
|
||||
expect(result.messages[0].error).toBe("Error text");
|
||||
expect(result.messages[0].errorType).toBe("parse");
|
||||
});
|
||||
|
||||
it("should not mutate original chatHistory", () => {
|
||||
const chatHistory: TChat.ChatHistory = {
|
||||
id: "chat-1",
|
||||
currentPrompt: "",
|
||||
messages: [],
|
||||
};
|
||||
|
||||
const result = addMessages(chatHistory, [
|
||||
{
|
||||
type: "user",
|
||||
content: "Hello",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(chatHistory.messages).toHaveLength(0);
|
||||
expect(result.messages).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should generate unique IDs for each message", () => {
|
||||
const chatHistory: TChat.ChatHistory = {
|
||||
id: "chat-1",
|
||||
currentPrompt: "",
|
||||
messages: [],
|
||||
};
|
||||
|
||||
const result = addMessages(chatHistory, [
|
||||
{
|
||||
type: "user",
|
||||
content: "Message 1",
|
||||
},
|
||||
{
|
||||
type: "assistant",
|
||||
content: "Message 2",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result.messages[0].id).not.toBe(result.messages[1].id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeLastAssistantMessage", () => {
|
||||
it("should remove the last assistant message", () => {
|
||||
const chatHistory: TChat.ChatHistory = {
|
||||
id: "chat-1",
|
||||
currentPrompt: "",
|
||||
messages: [
|
||||
{
|
||||
id: "1",
|
||||
type: "user",
|
||||
content: "Hello",
|
||||
timestamp: new Date("2024-01-01"),
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
type: "assistant",
|
||||
content: "Hi there",
|
||||
timestamp: new Date("2024-01-01"),
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
type: "user",
|
||||
content: "How are you?",
|
||||
timestamp: new Date("2024-01-01"),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = removeLastAssistantMessage(chatHistory);
|
||||
|
||||
expect(result.messages).toHaveLength(2);
|
||||
expect(result.messages[0].id).toBe("1");
|
||||
expect(result.messages[1].id).toBe("3");
|
||||
});
|
||||
|
||||
it("should remove only the last assistant message when multiple exist", () => {
|
||||
const chatHistory: TChat.ChatHistory = {
|
||||
id: "chat-1",
|
||||
currentPrompt: "",
|
||||
messages: [
|
||||
{
|
||||
id: "1",
|
||||
type: "assistant",
|
||||
content: "First",
|
||||
timestamp: new Date("2024-01-01"),
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
type: "user",
|
||||
content: "User",
|
||||
timestamp: new Date("2024-01-01"),
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
type: "assistant",
|
||||
content: "Second",
|
||||
timestamp: new Date("2024-01-01"),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = removeLastAssistantMessage(chatHistory);
|
||||
|
||||
expect(result.messages).toHaveLength(2);
|
||||
expect(result.messages[0].id).toBe("1");
|
||||
expect(result.messages[1].id).toBe("2");
|
||||
});
|
||||
|
||||
it("should return unchanged chatHistory if no assistant message exists", () => {
|
||||
const chatHistory: TChat.ChatHistory = {
|
||||
id: "chat-1",
|
||||
currentPrompt: "",
|
||||
messages: [
|
||||
{
|
||||
id: "1",
|
||||
type: "user",
|
||||
content: "Hello",
|
||||
timestamp: new Date("2024-01-01"),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = removeLastAssistantMessage(chatHistory);
|
||||
|
||||
expect(result).toEqual(chatHistory);
|
||||
});
|
||||
|
||||
it("should handle empty messages array", () => {
|
||||
const chatHistory: TChat.ChatHistory = {
|
||||
id: "chat-1",
|
||||
currentPrompt: "",
|
||||
messages: [],
|
||||
};
|
||||
|
||||
const result = removeLastAssistantMessage(chatHistory);
|
||||
|
||||
expect(result).toEqual(chatHistory);
|
||||
});
|
||||
|
||||
it("should not mutate original chatHistory", () => {
|
||||
const chatHistory: TChat.ChatHistory = {
|
||||
id: "chat-1",
|
||||
currentPrompt: "",
|
||||
messages: [
|
||||
{
|
||||
id: "1",
|
||||
type: "assistant",
|
||||
content: "Message",
|
||||
timestamp: new Date("2024-01-01"),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = removeLastAssistantMessage(chatHistory);
|
||||
|
||||
expect(chatHistory.messages).toHaveLength(1);
|
||||
expect(result.messages).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMessagesForApi", () => {
|
||||
it("should filter out messages with empty content", () => {
|
||||
const chatHistory: TChat.ChatHistory = {
|
||||
id: "chat-1",
|
||||
currentPrompt: "",
|
||||
messages: [
|
||||
{
|
||||
id: "1",
|
||||
type: "user",
|
||||
content: "",
|
||||
timestamp: new Date("2024-01-01"),
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
type: "user",
|
||||
content: "Valid question",
|
||||
timestamp: new Date("2024-01-01"),
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
type: "assistant",
|
||||
content: "",
|
||||
timestamp: new Date("2024-01-01"),
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
type: "assistant",
|
||||
content: "Valid answer",
|
||||
timestamp: new Date("2024-01-01"),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = getMessagesForLLM(chatHistory);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].content).toBe("Valid question");
|
||||
expect(result[1].content).toBe("Valid answer");
|
||||
});
|
||||
|
||||
it("should return only user message if no assistant messages", () => {
|
||||
const chatHistory: TChat.ChatHistory = {
|
||||
id: "chat-1",
|
||||
currentPrompt: "",
|
||||
messages: [
|
||||
{
|
||||
id: "1",
|
||||
type: "user",
|
||||
content: "Question",
|
||||
timestamp: new Date("2024-01-01"),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = getMessagesForLLM(chatHistory);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
role: "user",
|
||||
content: "Question",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return only assistant messages if no user message", () => {
|
||||
const chatHistory: TChat.ChatHistory = {
|
||||
id: "chat-1",
|
||||
currentPrompt: "",
|
||||
messages: [
|
||||
{
|
||||
id: "1",
|
||||
type: "assistant",
|
||||
content: "Answer",
|
||||
timestamp: new Date("2024-01-01"),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = getMessagesForLLM(chatHistory);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
role: "assistant",
|
||||
content: "Answer",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return empty array for empty messages", () => {
|
||||
const chatHistory: TChat.ChatHistory = {
|
||||
id: "chat-1",
|
||||
currentPrompt: "",
|
||||
messages: [],
|
||||
};
|
||||
|
||||
const result = getMessagesForLLM(chatHistory);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should filter out warning messages", () => {
|
||||
const chatHistory: TChat.ChatHistory = {
|
||||
id: "chat-1",
|
||||
currentPrompt: "",
|
||||
messages: [
|
||||
{
|
||||
id: "1",
|
||||
type: "user",
|
||||
content: "Question",
|
||||
timestamp: new Date("2024-01-01"),
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
type: "warning",
|
||||
content: "warning",
|
||||
timestamp: new Date("2024-01-01"),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = getMessagesForLLM(chatHistory);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
role: "user",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
92
packages/excalidraw/components/TTDDialog/utils/chat.ts
Normal file
92
packages/excalidraw/components/TTDDialog/utils/chat.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { findLastIndex, randomId } from "@excalidraw/common";
|
||||
|
||||
import type { LLMMessage, TChat } from "../types";
|
||||
|
||||
export const updateAssistantContent = (
|
||||
chatHistory: TChat.ChatHistory,
|
||||
payload: Partial<TChat.ChatMessage>,
|
||||
) => {
|
||||
const { messages } = chatHistory;
|
||||
|
||||
const lastAssistantIndex = findLastIndex(
|
||||
messages,
|
||||
(msg) => msg.type === "assistant",
|
||||
);
|
||||
|
||||
if (lastAssistantIndex === -1) {
|
||||
return chatHistory;
|
||||
}
|
||||
|
||||
const lastMessage = messages[lastAssistantIndex];
|
||||
|
||||
const updatedMessages = messages.slice();
|
||||
|
||||
updatedMessages[lastAssistantIndex] = {
|
||||
...lastMessage,
|
||||
...payload,
|
||||
};
|
||||
|
||||
return {
|
||||
...chatHistory,
|
||||
messages: updatedMessages,
|
||||
};
|
||||
};
|
||||
|
||||
export const getLastAssistantMessage = (chatHistory: TChat.ChatHistory) => {
|
||||
const { messages } = chatHistory;
|
||||
|
||||
const lastAssistantIndex = findLastIndex(
|
||||
messages,
|
||||
(msg) => msg.type === "assistant",
|
||||
);
|
||||
|
||||
return messages[lastAssistantIndex];
|
||||
};
|
||||
|
||||
export const addMessages = (
|
||||
chatHistory: TChat.ChatHistory,
|
||||
messages: Array<Omit<TChat.ChatMessage, "id" | "timestamp">>,
|
||||
) => {
|
||||
const newMessages: Array<TChat.ChatMessage> = messages.map((message) => ({
|
||||
...message,
|
||||
id: randomId(),
|
||||
timestamp: new Date(),
|
||||
}));
|
||||
|
||||
return {
|
||||
...chatHistory,
|
||||
messages: [...chatHistory.messages, ...newMessages],
|
||||
};
|
||||
};
|
||||
|
||||
export const removeLastAssistantMessage = (chatHistory: TChat.ChatHistory) => {
|
||||
const lastMsgIdx = findLastIndex(
|
||||
chatHistory.messages ?? [],
|
||||
(msg) => msg.type === "assistant",
|
||||
);
|
||||
|
||||
if (lastMsgIdx !== -1) {
|
||||
return {
|
||||
...chatHistory,
|
||||
messages: chatHistory.messages.filter((_, idx) => idx !== lastMsgIdx),
|
||||
};
|
||||
}
|
||||
return chatHistory;
|
||||
};
|
||||
|
||||
export const getMessagesForLLM = (
|
||||
chatHistory: TChat.ChatHistory,
|
||||
): LLMMessage[] => {
|
||||
const messages: LLMMessage[] = [];
|
||||
|
||||
for (const msg of chatHistory.messages) {
|
||||
if (msg.content && (msg.type === "user" || msg.type === "assistant")) {
|
||||
messages.push({
|
||||
role: msg.type,
|
||||
content: msg.content,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getMermaidAutoFixCandidates } from "./mermaidAutoFix";
|
||||
|
||||
describe("getMermaidAutoFixCandidates", () => {
|
||||
it("suggests removing trailing token after a closed label shape", () => {
|
||||
const sourceText = `graph TD
|
||||
L3_TCP["TCP (Transmission Control Protocol)"]x
|
||||
L3_UDP["UDP (User Datagram Protocol)"]`;
|
||||
|
||||
const errorMessage = `Parse error on line 2:
|
||||
...ission Control Protocol)"]x
|
||||
-----------------------------^
|
||||
Expecting 'SEMI', got 'NODE_STRING'`;
|
||||
|
||||
const candidates = getMermaidAutoFixCandidates(sourceText, errorMessage);
|
||||
|
||||
expect(candidates).toContain(`graph TD
|
||||
L3_TCP["TCP (Transmission Control Protocol)"]
|
||||
L3_UDP["UDP (User Datagram Protocol)"]`);
|
||||
});
|
||||
|
||||
it("suggests appending missing end statements", () => {
|
||||
const sourceText = `graph TD
|
||||
subgraph A
|
||||
A1[Start]`;
|
||||
|
||||
const errorMessage = `Parse error on line 3:
|
||||
... A1[Start]
|
||||
-------------^
|
||||
Expecting 'end'`;
|
||||
|
||||
const candidates = getMermaidAutoFixCandidates(sourceText, errorMessage);
|
||||
|
||||
expect(candidates).toContain(`graph TD
|
||||
subgraph A
|
||||
A1[Start]
|
||||
end`);
|
||||
});
|
||||
|
||||
it("returns empty list for non-parse errors", () => {
|
||||
expect(
|
||||
getMermaidAutoFixCandidates("graph TD\nA-->B", "Network error"),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("extracts line index from lexical error format too", () => {
|
||||
const sourceText = `graph TD
|
||||
subgraph Layers["X"]x
|
||||
direction TB`;
|
||||
|
||||
const errorMessage = `Lexical error on line 2. Unrecognized text.
|
||||
... subgraph Layers["X"]x direction
|
||||
-----------------------^`;
|
||||
|
||||
const candidates = getMermaidAutoFixCandidates(sourceText, errorMessage);
|
||||
|
||||
expect(candidates).toContain(`graph TD
|
||||
subgraph Layers["X"]
|
||||
direction TB`);
|
||||
});
|
||||
|
||||
it("removes extra > after edge label", () => {
|
||||
const sourceText = `flowchart TD
|
||||
A["User Input"] -->|text|> B["Tokenization"]
|
||||
A["User Input"] -->|text|> B["Tokenization"]`;
|
||||
|
||||
const errorMessage = `Parse error on line 2:
|
||||
...A["User Input"] -->|text|> B["Tokenization"]
|
||||
---------------------------^
|
||||
Expecting 'NODE_STRING', got 'GT'`;
|
||||
|
||||
const candidates = getMermaidAutoFixCandidates(sourceText, errorMessage);
|
||||
|
||||
expect(candidates).toContain(`flowchart TD
|
||||
A["User Input"] -->|text| B["Tokenization"]
|
||||
A["User Input"] -->|text| B["Tokenization"]`);
|
||||
});
|
||||
|
||||
it("suggests removing the last invalid deactivate for participant errors", () => {
|
||||
const sourceText = `sequenceDiagram
|
||||
participant QAEngineer as QA
|
||||
activate QA
|
||||
QA->>QA: Verifies Fix
|
||||
deactivate QA
|
||||
QA->>QA: Verifies Again
|
||||
deactivate QA`;
|
||||
|
||||
const errorMessage = "Trying to inactivate an inactive participant (QA)";
|
||||
|
||||
const candidates = getMermaidAutoFixCandidates(sourceText, errorMessage);
|
||||
|
||||
expect(candidates).toContain(`sequenceDiagram
|
||||
participant QAEngineer as QA
|
||||
activate QA
|
||||
QA->>QA: Verifies Fix
|
||||
deactivate QA
|
||||
QA->>QA: Verifies Again`);
|
||||
});
|
||||
|
||||
it("adds a fallback candidate that removes all invalid deactivations", () => {
|
||||
const sourceText = `sequenceDiagram
|
||||
participant QAEngineer as QA
|
||||
deactivate QA
|
||||
QA->>QA: Verifies Fix
|
||||
deactivate QA`;
|
||||
|
||||
const errorMessage = "Trying to inactivate an inactive participant (QA)";
|
||||
|
||||
const candidates = getMermaidAutoFixCandidates(sourceText, errorMessage);
|
||||
|
||||
expect(candidates).toContain(`sequenceDiagram
|
||||
participant QAEngineer as QA
|
||||
QA->>QA: Verifies Fix`);
|
||||
});
|
||||
});
|
||||
175
packages/excalidraw/components/TTDDialog/utils/mermaidAutoFix.ts
Normal file
175
packages/excalidraw/components/TTDDialog/utils/mermaidAutoFix.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import {
|
||||
getMermaidErrorLineNumber,
|
||||
getMermaidInactiveParticipant,
|
||||
isMermaidAutoFixableError,
|
||||
isMermaidParseSyntaxError,
|
||||
} from "./mermaidError";
|
||||
|
||||
const getErrorLineIndex = (message: string, sourceText: string) => {
|
||||
const lineNumber = getMermaidErrorLineNumber(message, sourceText);
|
||||
if (lineNumber == null) {
|
||||
return null;
|
||||
}
|
||||
return lineNumber - 1;
|
||||
};
|
||||
|
||||
const replaceLineAt = (
|
||||
lines: string[],
|
||||
index: number,
|
||||
transform: (line: string) => string,
|
||||
) => {
|
||||
if (index < 0 || index >= lines.length) {
|
||||
return null;
|
||||
}
|
||||
const nextLine = transform(lines[index]);
|
||||
if (nextLine === lines[index]) {
|
||||
return null;
|
||||
}
|
||||
const nextLines = [...lines];
|
||||
nextLines[index] = nextLine;
|
||||
return nextLines.join("\n");
|
||||
};
|
||||
|
||||
const stripTrailingTokenAfterShape = (line: string) => {
|
||||
const alphaTailMatch = line.match(
|
||||
/^(.*(?:\[[^\]]*]|\([^)]*\)|\{[^}]*}|"(?:[^"]*)"|'(?:[^']*)'))([A-Za-z]+)\s*$/,
|
||||
);
|
||||
if (alphaTailMatch) {
|
||||
return alphaTailMatch[1];
|
||||
}
|
||||
|
||||
const punctuationTailMatch = line.match(
|
||||
/^(.*(?:\[[^\]]*]|\([^)]*\)|\{[^}]*}|"(?:[^"]*)"|'(?:[^']*)'))([,;:])\s*$/,
|
||||
);
|
||||
if (punctuationTailMatch) {
|
||||
return punctuationTailMatch[1];
|
||||
}
|
||||
|
||||
return line;
|
||||
};
|
||||
|
||||
const removeExtraArrowheadAfterEdgeLabel = (line: string) => {
|
||||
// Common typo in generated Mermaid: `-->|label|> Target` (extra `>`).
|
||||
// Convert it to `-->|label| Target`.
|
||||
return line.replace(/(\|[^|\n]+\|)\s*>\s*(?=[A-Za-z0-9_("[{'`])/g, "$1 ");
|
||||
};
|
||||
|
||||
const escapeRegExp = (value: string) =>
|
||||
value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
|
||||
const removeLastDeactivateForParticipant = (
|
||||
sourceText: string,
|
||||
participant: string,
|
||||
) => {
|
||||
const pattern = new RegExp(
|
||||
`^\\s*deactivate\\s+${escapeRegExp(participant)}(?:\\s+%%.*)?\\s*$`,
|
||||
);
|
||||
const lines = sourceText.split(/\r?\n/);
|
||||
|
||||
for (let index = lines.length - 1; index >= 0; index--) {
|
||||
if (pattern.test(lines[index])) {
|
||||
return lines.filter((_, lineIndex) => lineIndex !== index).join("\n");
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const removeAllDeactivateForParticipant = (
|
||||
sourceText: string,
|
||||
participant: string,
|
||||
) => {
|
||||
const pattern = new RegExp(
|
||||
`^\\s*deactivate\\s+${escapeRegExp(participant)}(?:\\s+%%.*)?\\s*$`,
|
||||
);
|
||||
const lines = sourceText.split(/\r?\n/);
|
||||
let removedAny = false;
|
||||
const remainingLines = lines.filter((line) => {
|
||||
if (!pattern.test(line)) {
|
||||
return true;
|
||||
}
|
||||
removedAny = true;
|
||||
return false;
|
||||
});
|
||||
|
||||
return removedAny ? remainingLines.join("\n") : null;
|
||||
};
|
||||
|
||||
const appendMissingEnds = (sourceText: string) => {
|
||||
const subgraphCount = (sourceText.match(/^\s*subgraph\b/gm) || []).length;
|
||||
const endCount = (sourceText.match(/^\s*end\s*$/gm) || []).length;
|
||||
const missingCount = subgraphCount - endCount;
|
||||
|
||||
if (missingCount <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const endings = Array.from({ length: missingCount }, () => "end").join("\n");
|
||||
return `${sourceText.trimEnd()}\n${endings}`;
|
||||
};
|
||||
|
||||
const normalizeSmartQuotes = (sourceText: string) =>
|
||||
sourceText.replace(/[“”]/g, '"').replace(/[‘’]/g, "'");
|
||||
|
||||
export const getMermaidAutoFixCandidates = (
|
||||
sourceText: string,
|
||||
errorMessage: string,
|
||||
) => {
|
||||
if (!isMermaidAutoFixableError(errorMessage) || !sourceText.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const candidates: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const addCandidate = (candidate: string | null) => {
|
||||
if (!candidate || candidate === sourceText || seen.has(candidate)) {
|
||||
return;
|
||||
}
|
||||
seen.add(candidate);
|
||||
candidates.push(candidate);
|
||||
};
|
||||
|
||||
const inactiveParticipant = getMermaidInactiveParticipant(errorMessage);
|
||||
if (inactiveParticipant) {
|
||||
addCandidate(
|
||||
removeLastDeactivateForParticipant(sourceText, inactiveParticipant),
|
||||
);
|
||||
// Fallback for repeated invalid inactivations in one diagram.
|
||||
addCandidate(
|
||||
removeAllDeactivateForParticipant(sourceText, inactiveParticipant),
|
||||
);
|
||||
}
|
||||
|
||||
if (isMermaidParseSyntaxError(errorMessage)) {
|
||||
const lines = sourceText.split(/\r?\n/);
|
||||
const errorLineIndex = getErrorLineIndex(errorMessage, sourceText);
|
||||
const lineIndexesToTry =
|
||||
errorLineIndex == null
|
||||
? []
|
||||
: [errorLineIndex, errorLineIndex - 1, errorLineIndex + 1];
|
||||
|
||||
for (const lineIndex of lineIndexesToTry) {
|
||||
addCandidate(
|
||||
replaceLineAt(lines, lineIndex, (line) =>
|
||||
stripTrailingTokenAfterShape(line),
|
||||
),
|
||||
);
|
||||
addCandidate(
|
||||
replaceLineAt(lines, lineIndex, (line) =>
|
||||
removeExtraArrowheadAfterEdgeLabel(line),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Also try full-text replacement so repeated occurrences on other lines
|
||||
// are fixed together in a single candidate.
|
||||
addCandidate(removeExtraArrowheadAfterEdgeLabel(sourceText));
|
||||
|
||||
addCandidate(appendMissingEnds(sourceText));
|
||||
|
||||
const normalizedQuotes = normalizeSmartQuotes(sourceText);
|
||||
addCandidate(normalizedQuotes === sourceText ? null : normalizedQuotes);
|
||||
}
|
||||
|
||||
return candidates;
|
||||
};
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
formatMermaidParseErrorMessage,
|
||||
getMermaidErrorLineNumber,
|
||||
getMermaidInactiveParticipant,
|
||||
getMermaidSyntaxErrorGuidance,
|
||||
isMermaidAutoFixableError,
|
||||
isMermaidParseSyntaxError,
|
||||
isMermaidCaretLine,
|
||||
} from "./mermaidError";
|
||||
|
||||
describe("formatMermaidParseErrorMessage", () => {
|
||||
it("strips the noisy Expecting clause from Mermaid parse errors", () => {
|
||||
const message = `Parse error on line 6:
|
||||
... Control Protocol)"]x L3_UDP
|
||||
----------------------^
|
||||
Expecting 'SEMI', 'NEWLINE', 'SPACE', got 'NODE_STRING'`;
|
||||
|
||||
expect(formatMermaidParseErrorMessage(message)).toBe(`Parse error on line 6:
|
||||
... Control Protocol)"]x L3_UDP
|
||||
----------------------^`);
|
||||
});
|
||||
|
||||
it("keeps Mermaid parse errors unchanged when no Expecting clause exists", () => {
|
||||
const message = `Parse error on line 3:
|
||||
... some snippet
|
||||
----^`;
|
||||
|
||||
expect(formatMermaidParseErrorMessage(message)).toBe(message);
|
||||
});
|
||||
|
||||
it("does not modify non-Mermaid parse messages", () => {
|
||||
const message =
|
||||
"Unexpected token while parsing JSON. Expecting value at position 10.";
|
||||
|
||||
expect(formatMermaidParseErrorMessage(message)).toBe(message);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isMermaidCaretLine", () => {
|
||||
it("returns true for Mermaid caret lines", () => {
|
||||
expect(isMermaidCaretLine("-----------------------^")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for regular lines", () => {
|
||||
expect(isMermaidCaretLine(`... Control Protocol)"]x`)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isMermaidParseSyntaxError", () => {
|
||||
it("returns true for Mermaid parser syntax errors", () => {
|
||||
expect(isMermaidParseSyntaxError("Parse error on line 6: ...")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for Mermaid lexical syntax errors", () => {
|
||||
expect(
|
||||
isMermaidParseSyntaxError("Lexical error on line 2. Unrecognized text."),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for non-parse errors", () => {
|
||||
expect(isMermaidParseSyntaxError("Network error")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isMermaidAutoFixableError", () => {
|
||||
it("returns true for Mermaid parser syntax errors", () => {
|
||||
expect(isMermaidAutoFixableError("Parse error on line 6: ...")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for inactive participant runtime errors", () => {
|
||||
expect(
|
||||
isMermaidAutoFixableError(
|
||||
"Trying to inactivate an inactive participant (QA)",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for non-fixable errors", () => {
|
||||
expect(isMermaidAutoFixableError("Network error")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMermaidInactiveParticipant", () => {
|
||||
it("extracts the participant id from inactive participant errors", () => {
|
||||
expect(
|
||||
getMermaidInactiveParticipant(
|
||||
"Trying to inactivate an inactive participant (QA)",
|
||||
),
|
||||
).toBe("QA");
|
||||
});
|
||||
|
||||
it("returns null for unrelated errors", () => {
|
||||
expect(
|
||||
getMermaidInactiveParticipant("Parse error on line 3: ..."),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMermaidErrorLineNumber", () => {
|
||||
it("extracts line number from parse error format", () => {
|
||||
expect(getMermaidErrorLineNumber("Parse error on line 6: ...")).toBe(6);
|
||||
});
|
||||
|
||||
it("extracts line number from lexical error format", () => {
|
||||
expect(
|
||||
getMermaidErrorLineNumber("Lexical error on line 2. Unrecognized text."),
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it("returns null for messages without Mermaid line details", () => {
|
||||
expect(getMermaidErrorLineNumber("Network error")).toBeNull();
|
||||
});
|
||||
|
||||
it("infers line from inactive participant errors when source text is provided", () => {
|
||||
const sourceText = `sequenceDiagram
|
||||
participant QA
|
||||
deactivate QA
|
||||
QA->>QA: Verifies Fix
|
||||
deactivate QA`;
|
||||
|
||||
expect(
|
||||
getMermaidErrorLineNumber(
|
||||
"Trying to inactivate an inactive participant (QA)",
|
||||
sourceText,
|
||||
),
|
||||
).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMermaidSyntaxErrorGuidance", () => {
|
||||
it("returns summary and likely causes for Mermaid parse errors", () => {
|
||||
const message = `Parse error on line 6:
|
||||
... Control Protocol)"]x
|
||||
----------------------^`;
|
||||
|
||||
const source = `graph TD
|
||||
subgraph Layers["X"]
|
||||
L3_TCP["TCP (Transmission Control Protocol)"]x`;
|
||||
|
||||
expect(getMermaidSyntaxErrorGuidance(message, source)).toEqual({
|
||||
summary: "Syntax error near line 6.",
|
||||
likelyCauses: expect.arrayContaining([
|
||||
"A block is missing an `end` statement.",
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for non-parse errors", () => {
|
||||
expect(
|
||||
getMermaidSyntaxErrorGuidance("Network error", "graph TD"),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
133
packages/excalidraw/components/TTDDialog/utils/mermaidError.ts
Normal file
133
packages/excalidraw/components/TTDDialog/utils/mermaidError.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
const MERMAID_SYNTAX_ERROR_LINE = /(?:Parse|Lexical) error on line (\d+)[.:]/i;
|
||||
const MERMAID_INACTIVE_PARTICIPANT_ERROR =
|
||||
/Trying to inactivate an inactive participant \((.+)\)/i;
|
||||
const MERMAID_CARET_LINE = /^\s*-+\^\s*$/;
|
||||
|
||||
export const isMermaidParseSyntaxError = (message: string) =>
|
||||
MERMAID_SYNTAX_ERROR_LINE.test(message);
|
||||
|
||||
export const isMermaidAutoFixableError = (message: string) =>
|
||||
isMermaidParseSyntaxError(message) ||
|
||||
MERMAID_INACTIVE_PARTICIPANT_ERROR.test(message);
|
||||
|
||||
export const isMermaidCaretLine = (line: string) =>
|
||||
MERMAID_CARET_LINE.test(line);
|
||||
|
||||
export const getMermaidInactiveParticipant = (
|
||||
message: string,
|
||||
): string | null => {
|
||||
const match = message.match(MERMAID_INACTIVE_PARTICIPANT_ERROR);
|
||||
if (!match?.[1]) {
|
||||
return null;
|
||||
}
|
||||
return match[1].trim();
|
||||
};
|
||||
|
||||
const escapeRegExp = (value: string) =>
|
||||
value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
|
||||
const getInactiveParticipantLineNumber = (
|
||||
message: string,
|
||||
sourceText: string,
|
||||
): number | null => {
|
||||
const participant = getMermaidInactiveParticipant(message);
|
||||
if (!participant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const deactivatePattern = new RegExp(
|
||||
`^\\s*deactivate\\s+${escapeRegExp(participant)}(?:\\s+%%.*)?\\s*$`,
|
||||
);
|
||||
const lines = sourceText.split(/\r?\n/);
|
||||
for (let index = lines.length - 1; index >= 0; index--) {
|
||||
if (deactivatePattern.test(lines[index])) {
|
||||
return index + 1;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getMermaidErrorLineNumber = (
|
||||
message: string,
|
||||
sourceText?: string,
|
||||
): number | null => {
|
||||
const match = message.match(MERMAID_SYNTAX_ERROR_LINE);
|
||||
if (!match) {
|
||||
if (!sourceText) {
|
||||
return null;
|
||||
}
|
||||
return getInactiveParticipantLineNumber(message, sourceText);
|
||||
}
|
||||
return Number.parseInt(match[1], 10);
|
||||
};
|
||||
|
||||
const countMatches = (text: string, re: RegExp) =>
|
||||
(text.match(re) || []).length;
|
||||
|
||||
export const getMermaidSyntaxErrorGuidance = (
|
||||
message: string,
|
||||
sourceText?: string,
|
||||
): { summary: string; likelyCauses: string[] } | null => {
|
||||
if (!isMermaidParseSyntaxError(message)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const errorLine = getMermaidErrorLineNumber(message, sourceText);
|
||||
const summary = errorLine
|
||||
? `Syntax error near line ${errorLine}.`
|
||||
: "Syntax error in Mermaid diagram.";
|
||||
|
||||
const likelyCauses: string[] = [];
|
||||
|
||||
if (sourceText) {
|
||||
const openBrackets = countMatches(sourceText, /\[/g);
|
||||
const closeBrackets = countMatches(sourceText, /\]/g);
|
||||
if (openBrackets !== closeBrackets) {
|
||||
likelyCauses.push("Unbalanced square brackets in a node label.");
|
||||
}
|
||||
|
||||
const openParens = countMatches(sourceText, /\(/g);
|
||||
const closeParens = countMatches(sourceText, /\)/g);
|
||||
if (openParens !== closeParens) {
|
||||
likelyCauses.push("Unbalanced parentheses in a node shape.");
|
||||
}
|
||||
|
||||
const openBraces = countMatches(sourceText, /\{/g);
|
||||
const closeBraces = countMatches(sourceText, /\}/g);
|
||||
if (openBraces !== closeBraces) {
|
||||
likelyCauses.push("Unbalanced braces in a decision node.");
|
||||
}
|
||||
|
||||
const subgraphCount = countMatches(sourceText, /^\s*subgraph\b/gm);
|
||||
const endCount = countMatches(sourceText, /^\s*end\s*$/gm);
|
||||
if (subgraphCount > endCount) {
|
||||
likelyCauses.push("A block is missing an `end` statement.");
|
||||
}
|
||||
}
|
||||
|
||||
if (/got 'NODE_STRING'/.test(message) || /got 'PS'/.test(message)) {
|
||||
likelyCauses.push(
|
||||
"An extra character/token may appear after a node or label definition.",
|
||||
);
|
||||
}
|
||||
|
||||
if (likelyCauses.length === 0) {
|
||||
likelyCauses.push(
|
||||
"A node or edge line is malformed (missing/extra delimiters).",
|
||||
);
|
||||
likelyCauses.push("A block (`subgraph`, `class`, etc.) may be incomplete.");
|
||||
}
|
||||
|
||||
return {
|
||||
summary,
|
||||
likelyCauses: [...new Set(likelyCauses)],
|
||||
};
|
||||
};
|
||||
|
||||
export const formatMermaidParseErrorMessage = (message: string) => {
|
||||
if (!isMermaidParseSyntaxError(message)) {
|
||||
return message;
|
||||
}
|
||||
|
||||
return message.replace(/\n\s*Expecting[\s\S]*$/, "").trimEnd();
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user