Skip to content
Open
2 changes: 1 addition & 1 deletion .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ module.exports = {
},
overrides: [
{
files: ['cypress/**', 'demos/**'],
files: ['cypress/**', 'demos/**', 'packages/cli/**', 'packages/sdk/**'],
rules: {
'no-console': 'off',
},
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ jobs:
timeout-minutes: 15
strategy:
matrix:
node: ['18.18.x']
node: ['18.18.x', '20.x', '22.x', '24.x']
pkg: ['sdk', 'cli']
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on:
Expand Down
1 change: 1 addition & 0 deletions cSpell.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"words": [
"Alois",
"Cataa",
"collab",
"colour",
"Cookiebot",
"ENONET",
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/.eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,6 @@ module.exports = /** @type {import("eslint").Linter.Config} */ ({
parser: '@typescript-eslint/parser',
},
rules: {
"no-console": ["warn"], // TODO: fix all of these
"no-console": "off", // Console output is expected in CLI applications
}
});
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"mermaid-chart": "dist/cli.js"
},
"engines": {
"node": "^18.18.0 || ^20.0.0"
"node": "^18.18.0 || ^20.0.0 || ^22.0.0 || ^24.0.0"
},
"scripts": {
"dev": "tsx src/cli.ts",
Expand Down
11 changes: 11 additions & 0 deletions packages/sdk/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,14 @@ export class OAuthError extends Error {
super(message);
}
}

/**
* Error thrown when AI credits limit is exceeded.
* This corresponds to HTTP 402 status code from the repair API.
*/
export class AICreditsLimitExceededError extends Error {
constructor(message: string = 'AI credits limit exceeded') {
super(message);
this.name = 'AICreditsLimitExceededError';
}
}
24 changes: 24 additions & 0 deletions packages/sdk/src/index.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* E2E tests
*/
import { MermaidChart } from './index.js';
import { AICreditsLimitExceededError } from './errors.js';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';

import process from 'node:process';
Expand Down Expand Up @@ -203,3 +204,26 @@ describe('getDocument', () => {
expect(error?.response?.status).toBe(404);
});
});

describe('repairDiagram', () => {
it('should repair a broken diagram', async () => {
const brokenCode = 'graph TD\n A[Start] --> B{Decision}\n B -->|Yes| C[End';
const error = 'Syntax error: missing closing bracket';

try {
const result = await client.repairDiagram({
code: brokenCode,
error: error,
});

expect(result).toHaveProperty('result');
expect(result).toHaveProperty('code');
expect(['ok', 'fail']).toContain(result.result);
} catch (error) {
if (error instanceof AICreditsLimitExceededError) {
return; // Credits exceeded is acceptable
}
throw error;
}
}, 60000); // 60 seconds timeout for AI repair operations
});
36 changes: 36 additions & 0 deletions packages/sdk/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MermaidChart } from './index.js';
import { AICreditsLimitExceededError } from './errors.js';
import type { AuthorizationData } from './types.js';

import { OAuth2Client } from '@badgateway/oauth2-client';
Expand Down Expand Up @@ -85,4 +86,39 @@ describe('MermaidChart', () => {
await expect(client.getAccessToken()).resolves.toBe('test-example-access_token');
});
});

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

it('should repair diagram successfully', async () => {
vi.spyOn(client, 'repairDiagram').mockResolvedValue({
result: 'ok' as const,
code: '```mermaid\ngraph TD\n A[Start] --> B[End]\n```',
solved: true,
});

const result = await client.repairDiagram({
code: 'graph TD\n A[Start] --> B{Decision}',
error: 'Syntax error',
});

expect(result.result).toBe('ok');
expect(result.solved).toBe(true);
});

it('should throw AICreditsLimitExceededError on 402', async () => {
vi.spyOn(client, 'repairDiagram').mockRejectedValue(
new AICreditsLimitExceededError('AI credits limit exceeded'),
);

await expect(
client.repairDiagram({
code: 'graph TD\n A --> B',
error: 'Syntax error',
}),
).rejects.toThrow(AICreditsLimitExceededError);
});
});
});
48 changes: 47 additions & 1 deletion packages/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import { OAuth2Client, generateCodeVerifier } from '@badgateway/oauth2-client';
import type { AxiosInstance, AxiosResponse } from 'axios';
import defaultAxios from 'axios';
import { v4 as uuid } from 'uuid';
import { OAuthError, RequiredParameterMissingError } from './errors.js';
import {
AICreditsLimitExceededError,
OAuthError,
RequiredParameterMissingError,
} from './errors.js';
import type {
AuthState,
AuthorizationData,
Expand All @@ -11,6 +15,8 @@ import type {
MCDocument,
MCProject,
MCUser,
RepairDiagramRequest,
RepairDiagramResponse,
} from './types.js';
import { URLS } from './urls.js';

Expand Down Expand Up @@ -266,4 +272,44 @@ export class MermaidChart {
const raw = await this.axios.get<string>(URLS.raw(document, theme).svg);
return raw.data;
}

/**
* Repairs a broken Mermaid diagram using AI.
*
* @param request - The repair request containing diagram code and error message
* @returns The repair response with repaired code and status
* @throws {@link AICreditsLimitExceededError} if credits limit exceeded (HTTP 402)
*/
public async repairDiagram(request: RepairDiagramRequest): Promise<RepairDiagramResponse> {
try {
const requestWithDefaults = {
...request,
inChat: false,
};

const response = await this.axios.post<RepairDiagramResponse>(
URLS.rest.openai.repair,
requestWithDefaults,
);
return response.data;
} catch (error: unknown) {
if (
error &&
typeof error === 'object' &&
'response' in error &&
error.response &&
typeof error.response === 'object' &&
'status' in error.response &&
error.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',
);
}
throw error;
}
}
}
48 changes: 48 additions & 0 deletions packages/sdk/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,51 @@ export interface AuthorizationData {
state: string;
scope: string[];
}

/**
* Request parameters for repairing a diagram.
*/
export interface RepairDiagramRequest {
/** The Mermaid diagram code that needs to be repaired */
code: string;
/** The error message from the broken diagram */
error: string;
inChat?: boolean;
/**
* The diagram UUID to associate this repair with, or
* `undefined` if it is not associated with a diagram (e.g. in the playground).
*/
diagramDocumentID?: string;
/**
* The diagram ID associated with this repair.
*/
diagramID?: string;
/**
* The user ID associated with this repair.
*/
userID?: string;
}

/**
* Response from repairing a diagram.
* Matches OpenAIGenerationResult from collab.
*/
export interface RepairDiagramResponse {
/**
* The status of the repair: 'ok' if successful, 'fail' if not.
* `ok` indicates that a valid mermaid code block was generated.
* It may still fail to render.
*
* `fail` indicates that there were no exceptions/errors, but no valid
* mermaid code block was generated.
*/
result: 'ok' | 'fail';
/**
* Markdown message, that may contain a valid mermaid code block
*/
code: string;
/**
* Whether the diagram repair was successful (only present for repair responses)
*/
solved?: boolean;
}
3 changes: 3 additions & 0 deletions packages/sdk/src/urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ export const URLS = {
};
},
},
openai: {
repair: `/rest-api/openai/repair`,
},
},
raw: (document: Pick<MCDocument, 'documentID' | 'major' | 'minor'>, theme: 'light' | 'dark') => {
const base = `/raw/${document.documentID}?version=v${document.major}.${document.minor}&theme=${theme}&format=`;
Expand Down