@nqminds/mcp-client 1.0.29 → 1.0.31

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.
@@ -1 +1 @@
1
- {"version":3,"file":"MCPChat.d.ts","sourceRoot":"","sources":["../src/MCPChat.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAmD,MAAM,OAAO,CAAC;AAGxE,OAAO,KAAK,EAAyB,YAAY,EAAe,MAAM,SAAS,CAAC;AAkNhF,wBAAgB,OAAO,CAAC,EACtB,aAAa,EACb,WAA6B,EAC7B,YAAiB,EACjB,SAAc,GACf,EAAE,YAAY,qBAshBd"}
1
+ {"version":3,"file":"MCPChat.d.ts","sourceRoot":"","sources":["../src/MCPChat.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAmD,MAAM,OAAO,CAAC;AAIxE,OAAO,KAAK,EAAyB,YAAY,EAAe,MAAM,SAAS,CAAC;AAgchF,wBAAgB,OAAO,CAAC,EACtB,aAAa,EACb,WAA6B,EAC7B,YAAiB,EACjB,SAAc,GACf,EAAE,YAAY,qBA6hBd"}
package/dist/MCPChat.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import React, { useState, useRef, useEffect, useCallback } from "react";
3
3
  import ReactMarkdown from "react-markdown";
4
4
  import remarkGfm from "remark-gfm";
5
+ import { renderToStaticMarkup } from "react-dom/server";
5
6
  /**
6
7
  * Strips utm_source=openai (and any surrounding & or ?) from a URL.
7
8
  */
@@ -10,6 +11,101 @@ function stripUtmSource(url) {
10
11
  .replace(/[?&]utm_source=openai/gi, (match) => (match.startsWith("?") ? "?" : ""))
11
12
  .replace(/\?$/, "");
12
13
  }
14
+ /**
15
+ * Fixes broken markdown tables produced by streaming LLM output.
16
+ *
17
+ * The LLM often emits an entire table (title + header + separator + data rows)
18
+ * as a single line, e.g.:
19
+ * "My title | Col A | Col B | |---|---| | val1 | val2 | | val3 | val4 |"
20
+ *
21
+ * Strategy:
22
+ * 1. Split content into lines.
23
+ * 2. For each line, detect whether a markdown separator pattern
24
+ * (|---|---| etc.) appears *somewhere* but the line is NOT already a
25
+ * standalone separator or a normal single table row.
26
+ * 3. If so, extract any leading title text, then split the remainder into
27
+ * individual pipe-delimited rows by counting pipes.
28
+ * 4. Emit blank + title (if any) + one row per line + blank.
29
+ *
30
+ * Deliberately avoids touching `preprocessLinks` output (markdown links
31
+ * have the form `[text](url)` which contains no `|`, so they are safe).
32
+ */
33
+ function fixBrokenTables(content) {
34
+ const SEP_CELL = /^[ \t]*:?-{2,}:?[ \t]*$/;
35
+ // Matches a run of pipe-delimited separator cells: |---|---:|:---|
36
+ const SEP_RE = /\|[ \t]*:?-{2,}:?[ \t]*(?:\|[ \t]*:?-{2,}:?[ \t]*)+\|/;
37
+ const lines = content.split("\n");
38
+ const out = [];
39
+ for (let i = 0; i < lines.length; i++) {
40
+ const line = lines[i];
41
+ // Find a separator sub-string in this line
42
+ const sepMatch = SEP_RE.exec(line);
43
+ if (!sepMatch) {
44
+ out.push(line);
45
+ continue;
46
+ }
47
+ const sepStr = sepMatch[0];
48
+ // Count columns from the separator
49
+ const cols = sepStr.split("|").filter((s) => SEP_CELL.test(s)).length;
50
+ if (cols < 1) {
51
+ out.push(line);
52
+ continue;
53
+ }
54
+ // If the line is ONLY the separator (possibly with surrounding whitespace)
55
+ // it's already correct — leave it alone.
56
+ if (line.trim() === sepStr.trim()) {
57
+ out.push(line);
58
+ continue;
59
+ }
60
+ // There may be a title/description before the first pipe.
61
+ // Everything before the very first "|" in the line is the title.
62
+ const firstPipeIdx = line.indexOf("|");
63
+ const title = firstPipeIdx > 0 ? line.substring(0, firstPipeIdx).trim() : "";
64
+ // Table content is everything from the first "|" onwards.
65
+ const tableText = firstPipeIdx >= 0 ? line.substring(firstPipeIdx) : line;
66
+ // Split into individual cells by pipe, preserving the "|" delimiters.
67
+ // We walk character-by-character and emit a new row every time we have
68
+ // accumulated exactly (cols + 1) pipe characters.
69
+ const rows = [];
70
+ let currentRow = "";
71
+ let pipeCount = 0;
72
+ const perRow = cols + 1; // a N-column row has N+1 pipes
73
+ for (let k = 0; k < tableText.length; k++) {
74
+ const ch = tableText[k];
75
+ currentRow += ch;
76
+ if (ch === "|") {
77
+ pipeCount++;
78
+ if (pipeCount === perRow) {
79
+ rows.push(currentRow.trim());
80
+ currentRow = "";
81
+ pipeCount = 0;
82
+ // Skip any whitespace between rows
83
+ while (k + 1 < tableText.length && tableText[k + 1] === " ")
84
+ k++;
85
+ }
86
+ }
87
+ }
88
+ // Any leftover (incomplete row — happens during streaming)
89
+ if (currentRow.trim())
90
+ rows.push(currentRow.trim());
91
+ // Need at least header + separator to bother reconstructing
92
+ if (rows.length < 2) {
93
+ out.push(line);
94
+ continue;
95
+ }
96
+ // Emit: blank line, optional title, then each row on its own line, blank line
97
+ if (out.length > 0 && out[out.length - 1] !== "")
98
+ out.push("");
99
+ if (title) {
100
+ out.push(title);
101
+ out.push("");
102
+ }
103
+ for (const row of rows)
104
+ out.push(row);
105
+ out.push("");
106
+ }
107
+ return out.join("\n");
108
+ }
13
109
  /**
14
110
  * Post-processes AI response content to ensure all links open in a new tab
15
111
  * and have tracking parameters removed.
@@ -27,6 +123,139 @@ function preprocessLinks(content) {
27
123
  // Strip utm params from plain markdown links [text](url)
28
124
  return step1.replace(/\[([^\]]*)\]\(([^)]*)\)/g, (_, text, url) => `[${text}](${stripUtmSource(url)})`);
29
125
  }
126
+ /**
127
+ * Renders an assistant message as a styled HTML document and triggers
128
+ * the browser's print dialog (which offers "Save as PDF").
129
+ */
130
+ function downloadMessageAsPdf(markdownContent, companyNumber) {
131
+ const html = renderToStaticMarkup(React.createElement(ReactMarkdown, { remarkPlugins: [remarkGfm] }, preprocessLinks(fixBrokenTables(markdownContent))));
132
+ const title = companyNumber
133
+ ? `FLAIR Report — ${companyNumber}`
134
+ : "FLAIR Report";
135
+ const now = new Date();
136
+ const timestamp = now.toLocaleDateString("en-GB", {
137
+ day: "2-digit", month: "long", year: "numeric",
138
+ }) + " at " + now.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
139
+ const doc = `<!DOCTYPE html>
140
+ <html lang="en">
141
+ <head>
142
+ <meta charset="utf-8" />
143
+ <title>${title}</title>
144
+ <style>
145
+ @page { margin: 20mm 15mm; }
146
+ body {
147
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
148
+ font-size: 13px;
149
+ line-height: 1.6;
150
+ color: #1a1a1a;
151
+ max-width: 780px;
152
+ margin: 0 auto;
153
+ padding: 0;
154
+ }
155
+ .report-header {
156
+ border-bottom: 2px solid #2563eb;
157
+ padding-bottom: 10px;
158
+ margin-bottom: 20px;
159
+ }
160
+ .report-header h1 {
161
+ font-size: 20px;
162
+ margin: 0 0 4px 0;
163
+ color: #2563eb;
164
+ }
165
+ .report-header .meta {
166
+ font-size: 11px;
167
+ color: #666;
168
+ }
169
+ h1, h2, h3, h4 { margin-top: 18px; margin-bottom: 8px; }
170
+ h1 { font-size: 18px; }
171
+ h2 { font-size: 16px; border-bottom: 1px solid #e5e7eb; padding-bottom: 4px; }
172
+ h3 { font-size: 14px; }
173
+ p { margin: 0 0 8px 0; }
174
+ ul, ol { margin: 4px 0 8px 0; padding-left: 24px; }
175
+ li { margin-bottom: 3px; }
176
+ table {
177
+ border-collapse: collapse;
178
+ width: 100%;
179
+ margin: 10px 0;
180
+ font-size: 12px;
181
+ }
182
+ th, td {
183
+ border: 1px solid #d1d5db;
184
+ padding: 6px 8px;
185
+ text-align: left;
186
+ }
187
+ th {
188
+ background-color: #f3f4f6;
189
+ font-weight: 600;
190
+ }
191
+ tr:nth-child(even) { background-color: #f9fafb; }
192
+ code {
193
+ background: #f3f4f6;
194
+ padding: 1px 4px;
195
+ border-radius: 3px;
196
+ font-size: 12px;
197
+ }
198
+ pre {
199
+ background: #f3f4f6;
200
+ padding: 10px;
201
+ border-radius: 4px;
202
+ overflow-x: auto;
203
+ font-size: 12px;
204
+ }
205
+ pre code { background: none; padding: 0; }
206
+ blockquote {
207
+ border-left: 3px solid #d1d5db;
208
+ margin: 8px 0;
209
+ padding: 4px 12px;
210
+ color: #555;
211
+ }
212
+ a { color: #2563eb; text-decoration: none; }
213
+ hr { border: none; border-top: 1px solid #e5e7eb; margin: 16px 0; }
214
+ .report-footer {
215
+ margin-top: 30px;
216
+ padding-top: 10px;
217
+ border-top: 1px solid #e5e7eb;
218
+ font-size: 10px;
219
+ color: #999;
220
+ text-align: center;
221
+ }
222
+ </style>
223
+ </head>
224
+ <body>
225
+ <div class="report-header">
226
+ <h1>${title}</h1>
227
+ <div class="meta">Generated ${timestamp} by FLAIR AI</div>
228
+ </div>
229
+ ${html}
230
+ <div class="report-footer">
231
+ This report was generated by FLAIR AI and should be independently verified.
232
+ </div>
233
+ </body>
234
+ </html>`;
235
+ const printWindow = window.open("", "_blank");
236
+ if (!printWindow) {
237
+ // Popup blocked — fall back to blob download of HTML
238
+ const blob = new Blob([doc], { type: "text/html" });
239
+ const url = URL.createObjectURL(blob);
240
+ const a = document.createElement("a");
241
+ a.href = url;
242
+ a.download = `${title.replace(/[^a-zA-Z0-9 _-]/g, "")}.html`;
243
+ a.click();
244
+ URL.revokeObjectURL(url);
245
+ return;
246
+ }
247
+ printWindow.document.write(doc);
248
+ printWindow.document.close();
249
+ // Wait for content to render before triggering print
250
+ printWindow.addEventListener("load", () => printWindow.print());
251
+ // Fallback if load already fired
252
+ setTimeout(() => {
253
+ try {
254
+ printWindow.print();
255
+ }
256
+ catch { /* ignore */ }
257
+ }, 600);
258
+ }
30
259
  const DEFAULT_ACTIONS = [
31
260
  {
32
261
  label: "Company overview",
@@ -499,10 +728,11 @@ export function MCPChat({ companyNumber, apiEndpoint = "/api/mcp/chat", customSt
499
728
  msg.role === "assistant" ? (React.createElement("div", { className: "mcp-chat-message-content markdown-content" },
500
729
  React.createElement(ReactMarkdown, { remarkPlugins: [remarkGfm], components: {
501
730
  a: ({ href, children }) => (React.createElement("a", { href: href, target: "_blank", rel: "noopener noreferrer" }, children)),
502
- } }, preprocessLinks(msg.content)))) : (React.createElement("div", { className: "mcp-chat-message-content" }, msg.content)),
731
+ } }, preprocessLinks(fixBrokenTables(msg.content))))) : (React.createElement("div", { className: "mcp-chat-message-content" }, msg.content)),
503
732
  msg.role === "assistant" && !msg.isStreaming && (React.createElement("div", { className: "mcp-chat-message-timestamp" },
504
733
  msg.timestamp.toLocaleTimeString(),
505
- msg.tokenInfo && (React.createElement("span", { className: "mcp-chat-token-info" }, msg.tokenInfo)))))))),
734
+ msg.tokenInfo && (React.createElement("span", { className: "mcp-chat-token-info" }, msg.tokenInfo)),
735
+ React.createElement("button", { className: "mcp-btn-download-pdf", onClick: () => downloadMessageAsPdf(msg.content, companyNumber), title: "Download as PDF" }, "\uD83D\uDCC4 PDF"))))))),
506
736
  isLoading && (React.createElement("div", { className: "mcp-chat-message mcp-chat-message-assistant" },
507
737
  React.createElement("div", { className: "mcp-chat-thinking" },
508
738
  React.createElement("div", { className: "mcp-chat-thinking-title" },
@@ -465,6 +465,25 @@
465
465
  padding-left: 8px;
466
466
  }
467
467
 
468
+ .mcp-btn-download-pdf {
469
+ font-size: 11px;
470
+ font-weight: 600;
471
+ padding: 3px 10px;
472
+ border: 1px solid #2563eb;
473
+ border-radius: 4px;
474
+ background: #2563eb;
475
+ color: #fff;
476
+ cursor: pointer;
477
+ transition: background-color 0.15s, box-shadow 0.15s;
478
+ line-height: 1.4;
479
+ letter-spacing: 0.02em;
480
+ }
481
+
482
+ .mcp-btn-download-pdf:hover {
483
+ background: #1d4ed8;
484
+ box-shadow: 0 1px 4px rgba(37, 99, 235, 0.35);
485
+ }
486
+
468
487
  /* ───────────────────────────────────────────────
469
488
  Markdown
470
489
  ─────────────────────────────────────────────── */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nqminds/mcp-client",
3
- "version": "1.0.29",
3
+ "version": "1.0.31",
4
4
  "description": "Reusable MCP client component with AI chat interface",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",