Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/sdk/src/index.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ describe('deleteDocument', () => {
expect(deletedDoc.projectID).toStrictEqual(newDocument.projectID);

expect(await client.getDocuments(testProjectId)).not.toContainEqual(newDocument);
});
}, 20000); // 20 seconds — test makes 4 sequential HTTP calls
});

describe('getDocument', () => {
Expand Down
47 changes: 47 additions & 0 deletions packages/sdk/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,53 @@ describe('MermaidChart', () => {
});
});

describe('#diagramChat', () => {
beforeEach(async () => {
await client.setAccessToken('test-access-token');
});

it('should parse stream body into text and documentChatThreadID', async () => {
const streamBody = [
'0:"Hello, "',
'0:"here is your diagram!"',
'2:[{"documentChatThreadID":"thread-abc-123"}]',
'e:{"finishReason":"stop"}',
'd:{"finishReason":"stop"}',
].join('\n');

// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.spyOn((client as any).axios, 'post').mockResolvedValue({ data: streamBody });

const result = await client.diagramChat({
message: 'Create a flowchart',
documentID: 'doc-123',
});

expect(result.text).toBe('Hello, here is your diagram!');
expect(result.documentChatThreadID).toBe('thread-abc-123');
expect(result.documentID).toBe('doc-123');
});

it('should throw AICreditsLimitExceededError on 402', async () => {
// Mock the underlying axios call to simulate a 402 response from the API
// so the actual error-mapping logic inside diagramChat() is exercised.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.spyOn((client as any).axios, 'post').mockRejectedValue({
response: {
status: 402,
data: 'AI credits limit exceeded',
},
});

await expect(
client.diagramChat({
message: 'Create a flowchart',
documentID: 'doc-123',
}),
).rejects.toThrow(AICreditsLimitExceededError);
});
});

describe('#repairDiagram', () => {
beforeEach(async () => {
await client.setAccessToken('test-access-token');
Expand Down
134 changes: 134 additions & 0 deletions packages/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
import type {
AuthState,
AuthorizationData,
DiagramChatRequest,
DiagramChatResponse,
Document,
InitParams,
MCDocument,
Expand All @@ -24,6 +26,63 @@ import { URLS } from './urls.js';
const defaultBaseURL = 'https://www.mermaid.ai'; // "http://127.0.0.1:5174"
const authorizationURLTimeout = 60_000;

/**
* Parses text tokens from a Vercel AI SDK data-stream response body.
*
* The stream format uses line prefixes:
* `0:"text_chunk"\n` – text token (JSON-encoded string)
* `2:[{"documentChatThreadID":"thread-abc-123"}]\n` – data payload (JSON-encoded array)
* `e:{...}\n` – step finish
* `d:{...}\n` – stream done
*/
function parseVercelAIStreamText(rawBody: string): string {
return rawBody
.split('\n')
.filter((line) => line.startsWith('0:'))
.map((line) => {
try {
const value = JSON.parse(line.slice(2));
return typeof value === 'string' ? value : '';
} catch {
return '';
}
})
.join('');
}

/**
* Extracts data payloads from a Vercel AI SDK data-stream response body.
* Returns the first `documentChatThreadID` found in the stream, if any.
*/
function parseVercelAIStreamData(rawBody: string): { documentChatThreadID?: string } {
let documentChatThreadID: string | undefined;

for (const line of rawBody.split('\n')) {
if (!line.startsWith('2:')) {
continue;
}
try {
const items: unknown[] = JSON.parse(line.slice(2));
for (const item of items) {
if (item && typeof item === 'object' && 'documentChatThreadID' in item) {
const value = (item as Record<string, unknown>).documentChatThreadID;
if (typeof value === 'string') {
documentChatThreadID = value;
break;
}
}
}
} catch {
// ignore malformed lines
}
if (documentChatThreadID) {
break;
}
}

return { documentChatThreadID };
}

export class MermaidChart {
private clientID: string;
#baseURL!: string;
Expand Down Expand Up @@ -319,4 +378,79 @@ export class MermaidChart {
throw error;
}
}

/**
* Chat with Mermaid AI about a diagram.
*
* Sends a single user message to the Mermaid AI chat endpoint. The backend
* automatically fetches the full conversation history from the database
* (when `documentChatThreadID` is provided), so callers never need to track
* or resend previous messages.
*
* @param request - The chat request containing the user message and diagram context
* @returns The AI response text and the chat thread ID
* @throws {@link AICreditsLimitExceededError} if AI credits limit is exceeded (HTTP 402)
*/
public async diagramChat(request: DiagramChatRequest): Promise<DiagramChatResponse> {
const { message, documentID, code = '', documentChatThreadID } = request;

// Send only the current user message. The backend will prepend the stored
// conversation history when autoFetchHistory is true (see AIChatRequestData).
const messages = [
{
id: uuid(),
role: 'user' as const,
content: message,
experimental_attachments: [] as [],
},
];

const requestBody = {
messages,
code,
documentID,
documentChatThreadID,
// parentID null: the backend already handles finding the correct parent
parentID: null,
// Tell the backend to fetch DB history and prepend it before calling the AI.
autoFetchHistory: true,
};

try {
// responseType: 'text' buffers the full stream body as a plain string so we
// can parse the Vercel AI SDK data-stream format after the request completes.
const response = await this.axios.post<string>(URLS.rest.openai.chat, requestBody, {
responseType: 'text',
timeout: 120_000,
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

diagramChat() hard-codes a 120s Axios timeout, which bypasses the instance-level requestTimeout configured via InitParams (and used when creating the Axios client). Consider respecting this.requestTimeout (or using something like max(this.requestTimeout, 120_000) and documenting it) so callers can control request timing consistently across SDK methods.

Suggested change
timeout: 120_000,
timeout: Math.max(this.requestTimeout ?? 0, 120_000),

Copilot uses AI. Check for mistakes.
});

const rawBody = response.data;
const text = parseVercelAIStreamText(rawBody);
const { documentChatThreadID: returnedThreadID } = parseVercelAIStreamData(rawBody);

return {
text,
documentChatThreadID: returnedThreadID ?? documentChatThreadID,
documentID,
};
} catch (error: unknown) {
if (
error &&
typeof error === 'object' &&
'response' in error &&
error.response &&
typeof error.response === 'object' &&
'status' in error.response &&
(error as { response: { status: number } }).response.status === 402
) {
const axiosError = error as { response: { status: number; data?: unknown } };
throw new AICreditsLimitExceededError(
typeof axiosError.response.data === 'string'
? axiosError.response.data
: 'AI credits limit exceeded',
);
}
Comment on lines +436 to +452
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 402->AICreditsLimitExceededError mapping logic is now duplicated in both repairDiagram() and diagramChat(). To reduce divergence risk (e.g., message formatting changes), consider extracting a small shared helper (or a private method) to detect the 402 Axios shape and build the error consistently.

Copilot uses AI. Check for mistakes.
throw error;
}
}
}
43 changes: 43 additions & 0 deletions packages/sdk/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,49 @@ export interface RepairDiagramRequest {
userID?: string;
}

/**
* Request parameters for chatting with the Mermaid AI about a diagram.
*/
export interface DiagramChatRequest {
/** The user's chat message / question */
message: string;
/**
* MermaidChart document ID to associate the chat thread with.
*/
documentID: string;
/**
* Optional Mermaid diagram code to use as context.
* Pass the current diagram code when the user wants to modify or discuss a specific
* diagram. Leave empty (or omit) for general Mermaid questions.
* Defaults to an empty string.
*/
code?: string;
/**
* Existing chat thread ID to continue a conversation.
* Returned from a previous diagramChat() call.
* When provided, the backend automatically fetches the stored conversation
* history from the database so the AI has full context.
*/
documentChatThreadID?: string;
}

/**
* Response from chatting with the Mermaid AI.
*/
export interface DiagramChatResponse {
/** The full AI-generated response text (may contain Mermaid code blocks) */
text: string;
/**
* The document ID used for this conversation. Same as the one passed in the request.
*/
documentID: string;
/**
* The chat thread ID created or used for this conversation.
* Pass this back as documentChatThreadID in subsequent calls to continue the thread.
*/
documentChatThreadID?: string;
}

/**
* Response from repairing a diagram.
* Matches OpenAIGenerationResult from collab.
Expand Down
1 change: 1 addition & 0 deletions packages/sdk/src/urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export const URLS = {
},
openai: {
repair: `/rest-api/openai/repair`,
chat: `/rest-api/openai/chat`,
},
},
raw: (document: Pick<MCDocument, 'documentID' | 'major' | 'minor'>, theme: 'light' | 'dark') => {
Expand Down
Loading