@adminforth/chat-surface-adapter-telegram 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type AdminUser, type ChatSurfaceAdapter, type ChatSurfaceEventSink, type ChatSurfaceIncomingMessage, type ChatSurfaceRequestContext, type IAdminForth } from "adminforth";
2
2
  import { AdapterOptions } from "./types.js";
3
3
  export type { AdapterOptions, TelegramStreamingMode } from "./types.js";
4
+ export { getFinalMessageStreamPreview, renderFinalMessageImages, renderHtmlBlockToPng, renderTablePng, renderVegaLitePng, type RenderedMessage, type RenderedMessageImage, type RenderTableColumn, type RenderTablePngInput, type VegaLiteSpec, } from "./renderers.js";
4
5
  type TelegramUpdate = {
5
6
  message?: {
6
7
  text?: string;
@@ -37,6 +38,8 @@ export declare class TelegramChatSurfaceAdapter implements ChatSurfaceAdapter {
37
38
  incoming: ChatSurfaceIncomingMessage;
38
39
  }): Promise<AdminUser | null>;
39
40
  private sendMessage;
41
+ private sendFinalMessage;
42
+ private sendPhoto;
40
43
  private sendChatAction;
41
44
  private sendMessageDraft;
42
45
  }
package/dist/index.js CHANGED
@@ -8,7 +8,9 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
8
8
  });
9
9
  };
10
10
  import { AdminForthFilterOperators, } from "adminforth";
11
+ import { getFinalMessageStreamPreview, renderFinalMessageImages } from "./renderers.js";
11
12
  import { randomInt } from "node:crypto";
13
+ export { getFinalMessageStreamPreview, renderFinalMessageImages, renderHtmlBlockToPng, renderTablePng, renderVegaLitePng, } from "./renderers.js";
12
14
  const TELEGRAM_API_BASE_URL = "https://api.telegram.org";
13
15
  const TELEGRAM_SECRET_HEADER = "x-telegram-bot-api-secret-token";
14
16
  const TELEGRAM_MESSAGE_MAX_LENGTH = 4096;
@@ -117,13 +119,14 @@ export class TelegramChatSurfaceAdapter {
117
119
  }
118
120
  };
119
121
  const flushDraft = () => __awaiter(this, void 0, void 0, function* () {
122
+ const draftPreviewText = getFinalMessageStreamPreview(text);
120
123
  if (closed ||
121
124
  done ||
122
125
  streamingMode !== "draft" ||
123
- !text) {
126
+ !draftPreviewText) {
124
127
  return;
125
128
  }
126
- const draftText = truncateTelegramDraft(text);
129
+ const draftText = truncateTelegramDraft(draftPreviewText);
127
130
  if (draftText === lastDraftText) {
128
131
  return;
129
132
  }
@@ -161,7 +164,7 @@ export class TelegramChatSurfaceAdapter {
161
164
  done = true;
162
165
  stopTyping();
163
166
  clearDraftTimer();
164
- yield this.sendMessage(chatId, text || event.text);
167
+ yield this.sendFinalMessage(chatId, text || event.text);
165
168
  return;
166
169
  }
167
170
  if (event.type === "error") {
@@ -223,6 +226,32 @@ export class TelegramChatSurfaceAdapter {
223
226
  }
224
227
  });
225
228
  }
229
+ sendFinalMessage(chatId, text) {
230
+ return __awaiter(this, void 0, void 0, function* () {
231
+ const renderedMessage = yield renderFinalMessageImages(text);
232
+ yield this.sendMessage(chatId, renderedMessage.text);
233
+ for (const image of renderedMessage.images) {
234
+ yield this.sendPhoto(chatId, image.buffer, image.filename);
235
+ }
236
+ });
237
+ }
238
+ sendPhoto(chatId, png, filename) {
239
+ return __awaiter(this, void 0, void 0, function* () {
240
+ const photoBytes = new Uint8Array(png);
241
+ const formData = new FormData();
242
+ formData.append("chat_id", chatId);
243
+ formData.append("photo", new Blob([photoBytes], {
244
+ type: "image/png",
245
+ }), filename);
246
+ const response = yield fetch(`${TELEGRAM_API_BASE_URL}/bot${this.options.botToken}/sendPhoto`, {
247
+ method: "POST",
248
+ body: formData,
249
+ });
250
+ if (!response.ok) {
251
+ throw new Error(`Telegram sendPhoto failed: ${response.status} ${yield response.text()}`);
252
+ }
253
+ });
254
+ }
226
255
  sendChatAction(chatId, action) {
227
256
  return __awaiter(this, void 0, void 0, function* () {
228
257
  const response = yield fetch(`${TELEGRAM_API_BASE_URL}/bot${this.options.botToken}/sendChatAction`, {
@@ -0,0 +1,23 @@
1
+ export type RenderTableColumn = string | {
2
+ key: string;
3
+ label: string;
4
+ };
5
+ export type RenderTablePngInput = {
6
+ columns: RenderTableColumn[];
7
+ rows: Record<string, unknown>[];
8
+ title?: string;
9
+ };
10
+ export type VegaLiteSpec = Record<string, unknown>;
11
+ export type RenderedMessageImage = {
12
+ buffer: Buffer;
13
+ filename: string;
14
+ };
15
+ export type RenderedMessage = {
16
+ text: string;
17
+ images: RenderedMessageImage[];
18
+ };
19
+ export declare function getFinalMessageStreamPreview(text: string): string;
20
+ export declare function renderHtmlBlockToPng(html: string, selector: string): Promise<Buffer>;
21
+ export declare function renderTablePng(input: RenderTablePngInput): Promise<Buffer>;
22
+ export declare function renderVegaLitePng(spec: VegaLiteSpec): Promise<Buffer>;
23
+ export declare function renderFinalMessageImages(text: string): Promise<RenderedMessage>;
@@ -0,0 +1,361 @@
1
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
2
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
3
+ return new (P || (P = Promise))(function (resolve, reject) {
4
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
5
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
6
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
7
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
8
+ });
9
+ };
10
+ const DEFAULT_VIEWPORT_WIDTH = 1200;
11
+ const DEFAULT_VIEWPORT_HEIGHT = 800;
12
+ const HTML_ESCAPE_RE = /[&<>"']/g;
13
+ const VEGA_LITE_BLOCK_RE = /```vega-lite\s*([\s\S]*?)```/g;
14
+ const MARKDOWN_TABLE_SEPARATOR_RE = /^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$/;
15
+ const MARKDOWN_TABLE_ROW_RE = /^\s*\|.*\|\s*$/;
16
+ const VEGA_LITE_FENCE_START = "```vega-lite";
17
+ const HTML_ESCAPE_MAP = {
18
+ "&": "&amp;",
19
+ "<": "&lt;",
20
+ ">": "&gt;",
21
+ '"': "&quot;",
22
+ "'": "&#39;",
23
+ };
24
+ function escapeHtml(value) {
25
+ return String(value !== null && value !== void 0 ? value : "").replace(HTML_ESCAPE_RE, (char) => HTML_ESCAPE_MAP[char]);
26
+ }
27
+ function splitMarkdownTableRow(line) {
28
+ const trimmed = line.trim();
29
+ const withoutOuterPipes = trimmed.startsWith("|") && trimmed.endsWith("|")
30
+ ? trimmed.slice(1, -1)
31
+ : trimmed;
32
+ return withoutOuterPipes.split("|").map((cell) => cell.trim());
33
+ }
34
+ function parseMarkdownTable(lines) {
35
+ if (lines.length < 3) {
36
+ return null;
37
+ }
38
+ const headers = splitMarkdownTableRow(lines[0]);
39
+ const rows = lines.slice(2).map((line) => splitMarkdownTableRow(line));
40
+ const columns = headers.map((header, index) => ({
41
+ key: String(index),
42
+ label: header || `Column ${index + 1}`,
43
+ }));
44
+ return {
45
+ columns,
46
+ rows: rows.map((row) => Object.fromEntries(columns.map((column, index) => { var _a; return [column.key, (_a = row[index]) !== null && _a !== void 0 ? _a : ""]; }))),
47
+ };
48
+ }
49
+ function findVegaLiteBlocks(text) {
50
+ const blocks = [];
51
+ for (const match of text.matchAll(VEGA_LITE_BLOCK_RE)) {
52
+ const source = match[0];
53
+ const json = match[1];
54
+ const start = match.index;
55
+ if (start === undefined) {
56
+ continue;
57
+ }
58
+ blocks.push({
59
+ type: "vega-lite",
60
+ source,
61
+ spec: JSON.parse(json),
62
+ start,
63
+ end: start + source.length,
64
+ });
65
+ }
66
+ return blocks;
67
+ }
68
+ function findMarkdownTableBlocks(text, occupiedBlocks) {
69
+ const blocks = [];
70
+ const lines = text.split("\n");
71
+ let offset = 0;
72
+ for (let index = 0; index < lines.length - 1; index += 1) {
73
+ const headerLine = lines[index];
74
+ const separatorLine = lines[index + 1];
75
+ const tableStart = offset;
76
+ if (occupiedBlocks.some((block) => tableStart >= block.start && tableStart < block.end)
77
+ || !MARKDOWN_TABLE_ROW_RE.test(headerLine)
78
+ || !MARKDOWN_TABLE_SEPARATOR_RE.test(separatorLine)) {
79
+ offset += headerLine.length + 1;
80
+ continue;
81
+ }
82
+ const tableLines = [headerLine, separatorLine];
83
+ let endLineIndex = index + 2;
84
+ let tableEnd = tableStart + headerLine.length + 1 + separatorLine.length;
85
+ while (endLineIndex < lines.length && MARKDOWN_TABLE_ROW_RE.test(lines[endLineIndex])) {
86
+ tableEnd += 1 + lines[endLineIndex].length;
87
+ tableLines.push(lines[endLineIndex]);
88
+ endLineIndex += 1;
89
+ }
90
+ const table = parseMarkdownTable(tableLines);
91
+ if (table) {
92
+ blocks.push({
93
+ type: "table",
94
+ source: tableLines.join("\n"),
95
+ table,
96
+ start: tableStart,
97
+ end: tableEnd,
98
+ });
99
+ }
100
+ for (; index < endLineIndex - 1; index += 1) {
101
+ offset += lines[index].length + 1;
102
+ }
103
+ index -= 1;
104
+ }
105
+ return blocks;
106
+ }
107
+ function findFirstMarkdownTableStart(text) {
108
+ const lines = text.split("\n");
109
+ let offset = 0;
110
+ for (let index = 0; index < lines.length - 1; index += 1) {
111
+ const headerLine = lines[index];
112
+ const separatorLine = lines[index + 1];
113
+ if (MARKDOWN_TABLE_ROW_RE.test(headerLine)
114
+ && MARKDOWN_TABLE_SEPARATOR_RE.test(separatorLine)) {
115
+ return offset;
116
+ }
117
+ offset += headerLine.length + 1;
118
+ }
119
+ return -1;
120
+ }
121
+ function getPartialVegaLiteFenceStartLength(text) {
122
+ for (let length = Math.min(text.length, VEGA_LITE_FENCE_START.length - 1); length > 0; length -= 1) {
123
+ if (VEGA_LITE_FENCE_START.startsWith(text.slice(-length))) {
124
+ return length;
125
+ }
126
+ }
127
+ return 0;
128
+ }
129
+ function normalizeTextAfterBlockRemoval(text) {
130
+ return text
131
+ .split("\n")
132
+ .map((line) => line.trimEnd())
133
+ .join("\n")
134
+ .replace(/\n{3,}/g, "\n\n")
135
+ .trim();
136
+ }
137
+ export function getFinalMessageStreamPreview(text) {
138
+ const vegaLiteStart = text.indexOf(VEGA_LITE_FENCE_START);
139
+ const tableStart = findFirstMarkdownTableStart(text);
140
+ const renderableStarts = [vegaLiteStart, tableStart].filter((start) => start >= 0);
141
+ if (renderableStarts.length) {
142
+ return text.slice(0, Math.min(...renderableStarts)).trimEnd();
143
+ }
144
+ const partialVegaLiteFenceStartLength = getPartialVegaLiteFenceStartLength(text);
145
+ if (!partialVegaLiteFenceStartLength) {
146
+ return text;
147
+ }
148
+ return text.slice(0, -partialVegaLiteFenceStartLength);
149
+ }
150
+ function getChromium() {
151
+ return __awaiter(this, void 0, void 0, function* () {
152
+ const { chromium } = yield import("playwright");
153
+ return chromium;
154
+ });
155
+ }
156
+ export function renderHtmlBlockToPng(html, selector) {
157
+ return __awaiter(this, void 0, void 0, function* () {
158
+ const chromium = yield getChromium();
159
+ const browser = yield chromium.launch({
160
+ headless: true,
161
+ });
162
+ try {
163
+ const page = yield browser.newPage({
164
+ viewport: {
165
+ width: DEFAULT_VIEWPORT_WIDTH,
166
+ height: DEFAULT_VIEWPORT_HEIGHT,
167
+ },
168
+ });
169
+ yield page.setContent(html, {
170
+ waitUntil: "networkidle",
171
+ });
172
+ yield page.evaluate(() => document.fonts.ready);
173
+ const hasRenderReady = yield page.evaluate(() => (Boolean(window.__adminforthRenderReady)));
174
+ if (hasRenderReady) {
175
+ yield page.waitForFunction(() => {
176
+ const renderState = window;
177
+ return renderState.__adminforthRenderDone || renderState.__adminforthRenderError;
178
+ });
179
+ const renderError = yield page.evaluate(() => (window.__adminforthRenderError));
180
+ if (renderError) {
181
+ throw new Error(renderError);
182
+ }
183
+ }
184
+ const block = page.locator(selector);
185
+ yield block.waitFor({
186
+ state: "visible",
187
+ });
188
+ return yield block.screenshot({
189
+ type: "png",
190
+ animations: "disabled",
191
+ });
192
+ }
193
+ finally {
194
+ yield browser.close();
195
+ }
196
+ });
197
+ }
198
+ export function renderTablePng(input) {
199
+ return __awaiter(this, void 0, void 0, function* () {
200
+ const normalizedColumns = input.columns.map((column) => (typeof column === "string"
201
+ ? { key: column, label: column }
202
+ : column));
203
+ const headerHtml = normalizedColumns
204
+ .map((column) => `<th>${escapeHtml(column.label)}</th>`)
205
+ .join("");
206
+ const rowsHtml = input.rows
207
+ .map((row) => (`<tr>${normalizedColumns
208
+ .map((column) => `<td>${escapeHtml(row[column.key])}</td>`)
209
+ .join("")}</tr>`))
210
+ .join("");
211
+ return renderHtmlBlockToPng(`
212
+ <!doctype html>
213
+ <html>
214
+ <head>
215
+ <meta charset="utf-8" />
216
+ <style>
217
+ body {
218
+ margin: 0;
219
+ background: #f6f8fa;
220
+ color: #17202a;
221
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
222
+ }
223
+
224
+ #adminforth-table-render {
225
+ display: inline-block;
226
+ padding: 28px;
227
+ background: #ffffff;
228
+ }
229
+
230
+ h1 {
231
+ margin: 0 0 18px;
232
+ font-size: 24px;
233
+ font-weight: 700;
234
+ }
235
+
236
+ table {
237
+ border-collapse: collapse;
238
+ min-width: 560px;
239
+ max-width: 1120px;
240
+ font-size: 15px;
241
+ line-height: 1.45;
242
+ }
243
+
244
+ th,
245
+ td {
246
+ padding: 11px 14px;
247
+ border: 1px solid #d8dee4;
248
+ text-align: left;
249
+ vertical-align: top;
250
+ }
251
+
252
+ th {
253
+ background: #eef2f6;
254
+ font-weight: 700;
255
+ }
256
+
257
+ tr:nth-child(even) td {
258
+ background: #f9fbfc;
259
+ }
260
+ </style>
261
+ </head>
262
+ <body>
263
+ <section id="adminforth-table-render">
264
+ ${input.title ? `<h1>${escapeHtml(input.title)}</h1>` : ""}
265
+ <table>
266
+ <thead>
267
+ <tr>${headerHtml}</tr>
268
+ </thead>
269
+ <tbody>${rowsHtml}</tbody>
270
+ </table>
271
+ </section>
272
+ </body>
273
+ </html>
274
+ `, "#adminforth-table-render");
275
+ });
276
+ }
277
+ export function renderVegaLitePng(spec) {
278
+ return __awaiter(this, void 0, void 0, function* () {
279
+ return renderHtmlBlockToPng(`
280
+ <!doctype html>
281
+ <html>
282
+ <head>
283
+ <meta charset="utf-8" />
284
+ <script src="https://cdn.jsdelivr.net/npm/vega@5"></script>
285
+ <script src="https://cdn.jsdelivr.net/npm/vega-lite@5"></script>
286
+ <script src="https://cdn.jsdelivr.net/npm/vega-embed@6"></script>
287
+ <style>
288
+ body {
289
+ margin: 0;
290
+ background: #ffffff;
291
+ color: #17202a;
292
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
293
+ }
294
+
295
+ #adminforth-vega-lite-render {
296
+ display: inline-block;
297
+ padding: 24px;
298
+ background: #ffffff;
299
+ }
300
+
301
+ #adminforth-vega-lite-render details {
302
+ display: none;
303
+ }
304
+ </style>
305
+ </head>
306
+ <body>
307
+ <section id="adminforth-vega-lite-render"></section>
308
+ <script>
309
+ window.__adminforthRenderReady = vegaEmbed(
310
+ "#adminforth-vega-lite-render",
311
+ ${JSON.stringify(spec)},
312
+ { actions: false, renderer: "svg" }
313
+ )
314
+ .then(function () {
315
+ window.__adminforthRenderDone = true;
316
+ })
317
+ .catch(function (error) {
318
+ window.__adminforthRenderError = String(error && error.message ? error.message : error);
319
+ });
320
+ </script>
321
+ </body>
322
+ </html>
323
+ `, "#adminforth-vega-lite-render");
324
+ });
325
+ }
326
+ export function renderFinalMessageImages(text) {
327
+ return __awaiter(this, void 0, void 0, function* () {
328
+ const vegaLiteBlocks = findVegaLiteBlocks(text);
329
+ const tableBlocks = findMarkdownTableBlocks(text, vegaLiteBlocks);
330
+ const blocks = [...vegaLiteBlocks, ...tableBlocks].sort((a, b) => a.start - b.start);
331
+ if (!blocks.length) {
332
+ return {
333
+ text,
334
+ images: [],
335
+ };
336
+ }
337
+ const remainingTextParts = [];
338
+ const images = [];
339
+ let cursor = 0;
340
+ for (const [index, block] of blocks.entries()) {
341
+ remainingTextParts.push(text.slice(cursor, block.start));
342
+ cursor = block.end;
343
+ if (block.type === "vega-lite") {
344
+ images.push({
345
+ buffer: yield renderVegaLitePng(block.spec),
346
+ filename: `chart-${index + 1}.png`,
347
+ });
348
+ continue;
349
+ }
350
+ images.push({
351
+ buffer: yield renderTablePng(block.table),
352
+ filename: `table-${index + 1}.png`,
353
+ });
354
+ }
355
+ remainingTextParts.push(text.slice(cursor));
356
+ return {
357
+ text: normalizeTextAfterBlockRemoval(remainingTextParts.join("")),
358
+ images,
359
+ };
360
+ });
361
+ }
package/index.ts CHANGED
@@ -8,8 +8,21 @@ import {
8
8
  type IAdminForth,
9
9
  } from "adminforth";
10
10
  import { AdapterOptions } from "./types.js";
11
+ import { getFinalMessageStreamPreview, renderFinalMessageImages } from "./renderers.js";
11
12
  import { randomInt } from "node:crypto";
12
13
  export type { AdapterOptions, TelegramStreamingMode } from "./types.js";
14
+ export {
15
+ getFinalMessageStreamPreview,
16
+ renderFinalMessageImages,
17
+ renderHtmlBlockToPng,
18
+ renderTablePng,
19
+ renderVegaLitePng,
20
+ type RenderedMessage,
21
+ type RenderedMessageImage,
22
+ type RenderTableColumn,
23
+ type RenderTablePngInput,
24
+ type VegaLiteSpec,
25
+ } from "./renderers.js";
13
26
 
14
27
  type TelegramUpdate = {
15
28
  message?: {
@@ -165,16 +178,18 @@ export class TelegramChatSurfaceAdapter implements ChatSurfaceAdapter {
165
178
  };
166
179
 
167
180
  const flushDraft = async () => {
181
+ const draftPreviewText = getFinalMessageStreamPreview(text);
182
+
168
183
  if (
169
184
  closed ||
170
185
  done ||
171
186
  streamingMode !== "draft" ||
172
- !text
187
+ !draftPreviewText
173
188
  ) {
174
189
  return;
175
190
  }
176
191
 
177
- const draftText = truncateTelegramDraft(text);
192
+ const draftText = truncateTelegramDraft(draftPreviewText);
178
193
 
179
194
  if (draftText === lastDraftText) {
180
195
  return;
@@ -225,7 +240,7 @@ export class TelegramChatSurfaceAdapter implements ChatSurfaceAdapter {
225
240
  stopTyping();
226
241
  clearDraftTimer();
227
242
 
228
- await this.sendMessage(
243
+ await this.sendFinalMessage(
229
244
  chatId,
230
245
  text || event.text,
231
246
  );
@@ -301,6 +316,34 @@ export class TelegramChatSurfaceAdapter implements ChatSurfaceAdapter {
301
316
  }
302
317
  }
303
318
 
319
+ private async sendFinalMessage(chatId: string, text: string) {
320
+ const renderedMessage = await renderFinalMessageImages(text);
321
+
322
+ await this.sendMessage(chatId, renderedMessage.text);
323
+
324
+ for (const image of renderedMessage.images) {
325
+ await this.sendPhoto(chatId, image.buffer, image.filename);
326
+ }
327
+ }
328
+
329
+ private async sendPhoto(chatId: string, png: Buffer, filename: string) {
330
+ const photoBytes = new Uint8Array(png);
331
+ const formData = new FormData();
332
+ formData.append("chat_id", chatId);
333
+ formData.append("photo", new Blob([photoBytes], {
334
+ type: "image/png",
335
+ }), filename);
336
+
337
+ const response = await fetch(`${TELEGRAM_API_BASE_URL}/bot${this.options.botToken}/sendPhoto`, {
338
+ method: "POST",
339
+ body: formData,
340
+ });
341
+
342
+ if (!response.ok) {
343
+ throw new Error(`Telegram sendPhoto failed: ${response.status} ${await response.text()}`);
344
+ }
345
+ }
346
+
304
347
  private async sendChatAction(chatId: string, action: "typing") {
305
348
  const response = await fetch(`${TELEGRAM_API_BASE_URL}/bot${this.options.botToken}/sendChatAction`, {
306
349
  method: "POST",
@@ -343,4 +386,4 @@ export class TelegramChatSurfaceAdapter implements ChatSurfaceAdapter {
343
386
  }
344
387
  }
345
388
 
346
- export default TelegramChatSurfaceAdapter;
389
+ export default TelegramChatSurfaceAdapter;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adminforth/chat-surface-adapter-telegram",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -29,6 +29,9 @@
29
29
  "semantic-release-slack-bot": "^4.0.2",
30
30
  "typescript": "^5.9.3"
31
31
  },
32
+ "dependencies": {
33
+ "playwright": "^1.57.0"
34
+ },
32
35
  "peerDependencies": {
33
36
  "@adminforth/agent": ">=1.0.2",
34
37
  "adminforth": ">=2.59.0"
package/renderers.ts ADDED
@@ -0,0 +1,463 @@
1
+ type PlaywrightChromium = typeof import("playwright").chromium;
2
+
3
+ export type RenderTableColumn =
4
+ | string
5
+ | {
6
+ key: string;
7
+ label: string;
8
+ };
9
+
10
+ export type RenderTablePngInput = {
11
+ columns: RenderTableColumn[];
12
+ rows: Record<string, unknown>[];
13
+ title?: string;
14
+ };
15
+
16
+ export type VegaLiteSpec = Record<string, unknown>;
17
+
18
+ export type RenderedMessageImage = {
19
+ buffer: Buffer;
20
+ filename: string;
21
+ };
22
+
23
+ export type RenderedMessage = {
24
+ text: string;
25
+ images: RenderedMessageImage[];
26
+ };
27
+
28
+ type RenderableBlock =
29
+ | {
30
+ type: "vega-lite";
31
+ source: string;
32
+ spec: VegaLiteSpec;
33
+ start: number;
34
+ end: number;
35
+ }
36
+ | {
37
+ type: "table";
38
+ source: string;
39
+ table: RenderTablePngInput;
40
+ start: number;
41
+ end: number;
42
+ };
43
+
44
+ const DEFAULT_VIEWPORT_WIDTH = 1200;
45
+ const DEFAULT_VIEWPORT_HEIGHT = 800;
46
+ const HTML_ESCAPE_RE = /[&<>"']/g;
47
+ const VEGA_LITE_BLOCK_RE = /```vega-lite\s*([\s\S]*?)```/g;
48
+ const MARKDOWN_TABLE_SEPARATOR_RE = /^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$/;
49
+ const MARKDOWN_TABLE_ROW_RE = /^\s*\|.*\|\s*$/;
50
+ const VEGA_LITE_FENCE_START = "```vega-lite";
51
+ const HTML_ESCAPE_MAP: Record<string, string> = {
52
+ "&": "&amp;",
53
+ "<": "&lt;",
54
+ ">": "&gt;",
55
+ '"': "&quot;",
56
+ "'": "&#39;",
57
+ };
58
+
59
+ function escapeHtml(value: unknown) {
60
+ return String(value ?? "").replace(HTML_ESCAPE_RE, (char) => HTML_ESCAPE_MAP[char]);
61
+ }
62
+
63
+ function splitMarkdownTableRow(line: string) {
64
+ const trimmed = line.trim();
65
+ const withoutOuterPipes = trimmed.startsWith("|") && trimmed.endsWith("|")
66
+ ? trimmed.slice(1, -1)
67
+ : trimmed;
68
+
69
+ return withoutOuterPipes.split("|").map((cell) => cell.trim());
70
+ }
71
+
72
+ function parseMarkdownTable(lines: string[]): RenderTablePngInput | null {
73
+ if (lines.length < 3) {
74
+ return null;
75
+ }
76
+
77
+ const headers = splitMarkdownTableRow(lines[0]);
78
+ const rows = lines.slice(2).map((line) => splitMarkdownTableRow(line));
79
+ const columns = headers.map((header, index) => ({
80
+ key: String(index),
81
+ label: header || `Column ${index + 1}`,
82
+ }));
83
+
84
+ return {
85
+ columns,
86
+ rows: rows.map((row) => Object.fromEntries(
87
+ columns.map((column, index) => [column.key, row[index] ?? ""]),
88
+ )),
89
+ };
90
+ }
91
+
92
+ function findVegaLiteBlocks(text: string): RenderableBlock[] {
93
+ const blocks: RenderableBlock[] = [];
94
+
95
+ for (const match of text.matchAll(VEGA_LITE_BLOCK_RE)) {
96
+ const source = match[0];
97
+ const json = match[1];
98
+ const start = match.index;
99
+
100
+ if (start === undefined) {
101
+ continue;
102
+ }
103
+
104
+ blocks.push({
105
+ type: "vega-lite",
106
+ source,
107
+ spec: JSON.parse(json),
108
+ start,
109
+ end: start + source.length,
110
+ });
111
+ }
112
+
113
+ return blocks;
114
+ }
115
+
116
+ function findMarkdownTableBlocks(text: string, occupiedBlocks: RenderableBlock[]): RenderableBlock[] {
117
+ const blocks: RenderableBlock[] = [];
118
+ const lines = text.split("\n");
119
+ let offset = 0;
120
+
121
+ for (let index = 0; index < lines.length - 1; index += 1) {
122
+ const headerLine = lines[index];
123
+ const separatorLine = lines[index + 1];
124
+ const tableStart = offset;
125
+
126
+ if (
127
+ occupiedBlocks.some((block) => tableStart >= block.start && tableStart < block.end)
128
+ || !MARKDOWN_TABLE_ROW_RE.test(headerLine)
129
+ || !MARKDOWN_TABLE_SEPARATOR_RE.test(separatorLine)
130
+ ) {
131
+ offset += headerLine.length + 1;
132
+ continue;
133
+ }
134
+
135
+ const tableLines = [headerLine, separatorLine];
136
+ let endLineIndex = index + 2;
137
+ let tableEnd = tableStart + headerLine.length + 1 + separatorLine.length;
138
+
139
+ while (endLineIndex < lines.length && MARKDOWN_TABLE_ROW_RE.test(lines[endLineIndex])) {
140
+ tableEnd += 1 + lines[endLineIndex].length;
141
+ tableLines.push(lines[endLineIndex]);
142
+ endLineIndex += 1;
143
+ }
144
+
145
+ const table = parseMarkdownTable(tableLines);
146
+
147
+ if (table) {
148
+ blocks.push({
149
+ type: "table",
150
+ source: tableLines.join("\n"),
151
+ table,
152
+ start: tableStart,
153
+ end: tableEnd,
154
+ });
155
+ }
156
+
157
+ for (; index < endLineIndex - 1; index += 1) {
158
+ offset += lines[index].length + 1;
159
+ }
160
+ index -= 1;
161
+ }
162
+
163
+ return blocks;
164
+ }
165
+
166
+ function findFirstMarkdownTableStart(text: string) {
167
+ const lines = text.split("\n");
168
+ let offset = 0;
169
+
170
+ for (let index = 0; index < lines.length - 1; index += 1) {
171
+ const headerLine = lines[index];
172
+ const separatorLine = lines[index + 1];
173
+
174
+ if (
175
+ MARKDOWN_TABLE_ROW_RE.test(headerLine)
176
+ && MARKDOWN_TABLE_SEPARATOR_RE.test(separatorLine)
177
+ ) {
178
+ return offset;
179
+ }
180
+
181
+ offset += headerLine.length + 1;
182
+ }
183
+
184
+ return -1;
185
+ }
186
+
187
+ function getPartialVegaLiteFenceStartLength(text: string) {
188
+ for (let length = Math.min(text.length, VEGA_LITE_FENCE_START.length - 1); length > 0; length -= 1) {
189
+ if (VEGA_LITE_FENCE_START.startsWith(text.slice(-length))) {
190
+ return length;
191
+ }
192
+ }
193
+
194
+ return 0;
195
+ }
196
+
197
+ function normalizeTextAfterBlockRemoval(text: string) {
198
+ return text
199
+ .split("\n")
200
+ .map((line) => line.trimEnd())
201
+ .join("\n")
202
+ .replace(/\n{3,}/g, "\n\n")
203
+ .trim();
204
+ }
205
+
206
+ export function getFinalMessageStreamPreview(text: string) {
207
+ const vegaLiteStart = text.indexOf(VEGA_LITE_FENCE_START);
208
+ const tableStart = findFirstMarkdownTableStart(text);
209
+ const renderableStarts = [vegaLiteStart, tableStart].filter((start) => start >= 0);
210
+
211
+ if (renderableStarts.length) {
212
+ return text.slice(0, Math.min(...renderableStarts)).trimEnd();
213
+ }
214
+
215
+ const partialVegaLiteFenceStartLength = getPartialVegaLiteFenceStartLength(text);
216
+
217
+ if (!partialVegaLiteFenceStartLength) {
218
+ return text;
219
+ }
220
+
221
+ return text.slice(0, -partialVegaLiteFenceStartLength);
222
+ }
223
+
224
+ async function getChromium(): Promise<PlaywrightChromium> {
225
+ const { chromium } = await import("playwright");
226
+
227
+ return chromium;
228
+ }
229
+
230
+ export async function renderHtmlBlockToPng(html: string, selector: string): Promise<Buffer> {
231
+ const chromium = await getChromium();
232
+ const browser = await chromium.launch({
233
+ headless: true,
234
+ });
235
+
236
+ try {
237
+ const page = await browser.newPage({
238
+ viewport: {
239
+ width: DEFAULT_VIEWPORT_WIDTH,
240
+ height: DEFAULT_VIEWPORT_HEIGHT,
241
+ },
242
+ });
243
+
244
+ await page.setContent(html, {
245
+ waitUntil: "networkidle",
246
+ });
247
+ await page.evaluate(() => document.fonts.ready);
248
+ const hasRenderReady = await page.evaluate(() => (
249
+ Boolean((window as typeof window & {
250
+ __adminforthRenderReady?: Promise<void>;
251
+ }).__adminforthRenderReady)
252
+ ));
253
+
254
+ if (hasRenderReady) {
255
+ await page.waitForFunction(() => {
256
+ const renderState = window as typeof window & {
257
+ __adminforthRenderDone?: boolean;
258
+ __adminforthRenderError?: string;
259
+ };
260
+
261
+ return renderState.__adminforthRenderDone || renderState.__adminforthRenderError;
262
+ });
263
+
264
+ const renderError = await page.evaluate(() => (
265
+ (window as typeof window & {
266
+ __adminforthRenderError?: string;
267
+ }).__adminforthRenderError
268
+ ));
269
+
270
+ if (renderError) {
271
+ throw new Error(renderError);
272
+ }
273
+ }
274
+
275
+ const block = page.locator(selector);
276
+ await block.waitFor({
277
+ state: "visible",
278
+ });
279
+
280
+ return await block.screenshot({
281
+ type: "png",
282
+ animations: "disabled",
283
+ });
284
+ } finally {
285
+ await browser.close();
286
+ }
287
+ }
288
+
289
+ export async function renderTablePng(input: RenderTablePngInput): Promise<Buffer> {
290
+ const normalizedColumns = input.columns.map((column) => (
291
+ typeof column === "string"
292
+ ? { key: column, label: column }
293
+ : column
294
+ ));
295
+
296
+ const headerHtml = normalizedColumns
297
+ .map((column) => `<th>${escapeHtml(column.label)}</th>`)
298
+ .join("");
299
+ const rowsHtml = input.rows
300
+ .map((row) => (
301
+ `<tr>${
302
+ normalizedColumns
303
+ .map((column) => `<td>${escapeHtml(row[column.key])}</td>`)
304
+ .join("")
305
+ }</tr>`
306
+ ))
307
+ .join("");
308
+
309
+ return renderHtmlBlockToPng(`
310
+ <!doctype html>
311
+ <html>
312
+ <head>
313
+ <meta charset="utf-8" />
314
+ <style>
315
+ body {
316
+ margin: 0;
317
+ background: #f6f8fa;
318
+ color: #17202a;
319
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
320
+ }
321
+
322
+ #adminforth-table-render {
323
+ display: inline-block;
324
+ padding: 28px;
325
+ background: #ffffff;
326
+ }
327
+
328
+ h1 {
329
+ margin: 0 0 18px;
330
+ font-size: 24px;
331
+ font-weight: 700;
332
+ }
333
+
334
+ table {
335
+ border-collapse: collapse;
336
+ min-width: 560px;
337
+ max-width: 1120px;
338
+ font-size: 15px;
339
+ line-height: 1.45;
340
+ }
341
+
342
+ th,
343
+ td {
344
+ padding: 11px 14px;
345
+ border: 1px solid #d8dee4;
346
+ text-align: left;
347
+ vertical-align: top;
348
+ }
349
+
350
+ th {
351
+ background: #eef2f6;
352
+ font-weight: 700;
353
+ }
354
+
355
+ tr:nth-child(even) td {
356
+ background: #f9fbfc;
357
+ }
358
+ </style>
359
+ </head>
360
+ <body>
361
+ <section id="adminforth-table-render">
362
+ ${input.title ? `<h1>${escapeHtml(input.title)}</h1>` : ""}
363
+ <table>
364
+ <thead>
365
+ <tr>${headerHtml}</tr>
366
+ </thead>
367
+ <tbody>${rowsHtml}</tbody>
368
+ </table>
369
+ </section>
370
+ </body>
371
+ </html>
372
+ `, "#adminforth-table-render");
373
+ }
374
+
375
+ export async function renderVegaLitePng(spec: VegaLiteSpec): Promise<Buffer> {
376
+ return renderHtmlBlockToPng(`
377
+ <!doctype html>
378
+ <html>
379
+ <head>
380
+ <meta charset="utf-8" />
381
+ <script src="https://cdn.jsdelivr.net/npm/vega@5"></script>
382
+ <script src="https://cdn.jsdelivr.net/npm/vega-lite@5"></script>
383
+ <script src="https://cdn.jsdelivr.net/npm/vega-embed@6"></script>
384
+ <style>
385
+ body {
386
+ margin: 0;
387
+ background: #ffffff;
388
+ color: #17202a;
389
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
390
+ }
391
+
392
+ #adminforth-vega-lite-render {
393
+ display: inline-block;
394
+ padding: 24px;
395
+ background: #ffffff;
396
+ }
397
+
398
+ #adminforth-vega-lite-render details {
399
+ display: none;
400
+ }
401
+ </style>
402
+ </head>
403
+ <body>
404
+ <section id="adminforth-vega-lite-render"></section>
405
+ <script>
406
+ window.__adminforthRenderReady = vegaEmbed(
407
+ "#adminforth-vega-lite-render",
408
+ ${JSON.stringify(spec)},
409
+ { actions: false, renderer: "svg" }
410
+ )
411
+ .then(function () {
412
+ window.__adminforthRenderDone = true;
413
+ })
414
+ .catch(function (error) {
415
+ window.__adminforthRenderError = String(error && error.message ? error.message : error);
416
+ });
417
+ </script>
418
+ </body>
419
+ </html>
420
+ `, "#adminforth-vega-lite-render");
421
+ }
422
+
423
+ export async function renderFinalMessageImages(text: string): Promise<RenderedMessage> {
424
+ const vegaLiteBlocks = findVegaLiteBlocks(text);
425
+ const tableBlocks = findMarkdownTableBlocks(text, vegaLiteBlocks);
426
+ const blocks = [...vegaLiteBlocks, ...tableBlocks].sort((a, b) => a.start - b.start);
427
+
428
+ if (!blocks.length) {
429
+ return {
430
+ text,
431
+ images: [],
432
+ };
433
+ }
434
+
435
+ const remainingTextParts: string[] = [];
436
+ const images: RenderedMessageImage[] = [];
437
+ let cursor = 0;
438
+
439
+ for (const [index, block] of blocks.entries()) {
440
+ remainingTextParts.push(text.slice(cursor, block.start));
441
+ cursor = block.end;
442
+
443
+ if (block.type === "vega-lite") {
444
+ images.push({
445
+ buffer: await renderVegaLitePng(block.spec),
446
+ filename: `chart-${index + 1}.png`,
447
+ });
448
+ continue;
449
+ }
450
+
451
+ images.push({
452
+ buffer: await renderTablePng(block.table),
453
+ filename: `table-${index + 1}.png`,
454
+ });
455
+ }
456
+
457
+ remainingTextParts.push(text.slice(cursor));
458
+
459
+ return {
460
+ text: normalizeTextAfterBlockRemoval(remainingTextParts.join("")),
461
+ images,
462
+ };
463
+ }