telygent-ui 0.1.5 → 0.1.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "telygent-ui",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Telygent UI CLI",
5
5
  "type": "commonjs",
6
6
  "main": "dist/index.js",
@@ -6,8 +6,9 @@ import {MarkdownRenderer} from "./MarkdownRenderer";
6
6
  import AIWriter from "react-aiwriter";
7
7
  import * as echarts from "echarts";
8
8
  import type {EChartsOption} from "echarts";
9
+ import {Check, Copy, Download} from "lucide-react";
9
10
 
10
- import {cn} from "@/lib/utils";
11
+ import {cn} from "./utils";
11
12
  import {type ChatMessage, type ChatSummaryCard, type ChatVisualization} from "./ChatProvider";
12
13
  import {useDatabaseChat} from "../hooks/use-database-chat";
13
14
 
@@ -17,8 +18,17 @@ const MemoizedAIWriter = React.memo(({children}: {children: React.ReactNode}) =>
17
18
  });
18
19
  MemoizedAIWriter.displayName = "MemoizedAIWriter";
19
20
 
20
- const EChart = ({option}: {option: EChartsOption}) => {
21
+ const getChartFilename = (seed: string) => {
22
+ const normalized = seed
23
+ .toLowerCase()
24
+ .replace(/[^a-z0-9]+/g, "-")
25
+ .replace(/^-+|-+$/g, "");
26
+ return normalized || "chart";
27
+ };
28
+
29
+ const EChart = ({option, fileName}: {option: EChartsOption; fileName: string}) => {
21
30
  const chartRef = React.useRef<HTMLDivElement>(null);
31
+ const chartInstanceRef = React.useRef<echarts.ECharts | null>(null);
22
32
 
23
33
  React.useEffect(() => {
24
34
  if (!chartRef.current) {
@@ -26,6 +36,7 @@ const EChart = ({option}: {option: EChartsOption}) => {
26
36
  }
27
37
 
28
38
  const instance = echarts.init(chartRef.current);
39
+ chartInstanceRef.current = instance;
29
40
  instance.setOption(option);
30
41
 
31
42
  const handleResize = () => instance.resize();
@@ -34,10 +45,130 @@ const EChart = ({option}: {option: EChartsOption}) => {
34
45
  return () => {
35
46
  window.removeEventListener("resize", handleResize);
36
47
  instance.dispose();
48
+ chartInstanceRef.current = null;
37
49
  };
38
50
  }, [option]);
39
51
 
40
- return <div ref={chartRef} className="h-64 w-full" />;
52
+ const handleExportChart = React.useCallback(() => {
53
+ const instance = chartInstanceRef.current;
54
+ if (!instance) {
55
+ return;
56
+ }
57
+
58
+ try {
59
+ const dataUrl = instance.getDataURL({
60
+ type: "png",
61
+ pixelRatio: 2,
62
+ backgroundColor: "#ffffff",
63
+ });
64
+
65
+ const link = document.createElement("a");
66
+ link.href = dataUrl;
67
+ link.download = `${getChartFilename(fileName)}.png`;
68
+ document.body.appendChild(link);
69
+ link.click();
70
+ document.body.removeChild(link);
71
+ } catch (error) {
72
+ console.error("Unable to export chart image.", error);
73
+ }
74
+ }, [fileName]);
75
+
76
+ return (
77
+ <div>
78
+ <div ref={chartRef} className="h-64 w-full" />
79
+ <button
80
+ type="button"
81
+ onClick={handleExportChart}
82
+ className="mt-2 inline-flex items-center gap-1 text-xs font-medium text-slate-600 transition hover:text-slate-700"
83
+ aria-label="Export chart as PNG"
84
+ >
85
+ <Download className="h-4 w-4" />
86
+ <span>Export PNG</span>
87
+ </button>
88
+ </div>
89
+ );
90
+ };
91
+
92
+ const inlineComputedStyles = (source: Element, target: Element) => {
93
+ const style = window.getComputedStyle(source);
94
+ const css = Array.from(style)
95
+ .map((property) => `${property}: ${style.getPropertyValue(property)};`)
96
+ .join(" ");
97
+
98
+ (target as HTMLElement).setAttribute("style", css);
99
+
100
+ const sourceChildren = Array.from(source.children);
101
+ const targetChildren = Array.from(target.children);
102
+ sourceChildren.forEach((child, index) => {
103
+ const targetChild = targetChildren[index];
104
+ if (!targetChild) {
105
+ return;
106
+ }
107
+ inlineComputedStyles(child, targetChild);
108
+ });
109
+ };
110
+
111
+ const replaceCanvasWithImages = (sourceRoot: HTMLElement, targetRoot: HTMLElement) => {
112
+ const sourceCanvases = Array.from(sourceRoot.querySelectorAll("canvas"));
113
+ const targetCanvases = Array.from(targetRoot.querySelectorAll("canvas"));
114
+
115
+ sourceCanvases.forEach((sourceCanvas, index) => {
116
+ const targetCanvas = targetCanvases[index];
117
+ if (!targetCanvas) {
118
+ return;
119
+ }
120
+
121
+ try {
122
+ const img = document.createElement("img");
123
+ img.src = sourceCanvas.toDataURL("image/png");
124
+ const canvasStyle = window.getComputedStyle(sourceCanvas);
125
+ img.style.width = canvasStyle.width;
126
+ img.style.height = canvasStyle.height;
127
+ img.style.display = canvasStyle.display;
128
+ targetCanvas.replaceWith(img);
129
+ } catch (_error) {
130
+ // Canvas could be tainted in some host apps; leave it as-is on failure.
131
+ }
132
+ });
133
+ };
134
+
135
+ const normalizeCopiedText = (renderedText: string, fallbackText: string) => {
136
+ const source = (renderedText || fallbackText || "").replace(/\r\n/g, "\n");
137
+ return source
138
+ .replace(/[ \t]+\n/g, "\n")
139
+ .replace(/\n{3,}/g, "\n\n")
140
+ .trim();
141
+ };
142
+
143
+ const copyStyledMessage = async (sourceElement: HTMLElement, plainText: string) => {
144
+ const cloned = sourceElement.cloneNode(true) as HTMLElement;
145
+ inlineComputedStyles(sourceElement, cloned);
146
+ replaceCanvasWithImages(sourceElement, cloned);
147
+
148
+ const richHtml = `<div>${cloned.outerHTML}</div>`;
149
+ const richTextFallback = normalizeCopiedText(sourceElement.innerText, plainText);
150
+ const clipboard = navigator.clipboard;
151
+ const ClipboardItemCtor = (window as any).ClipboardItem;
152
+
153
+ if (clipboard?.write && ClipboardItemCtor) {
154
+ try {
155
+ const item = new ClipboardItemCtor({
156
+ "text/html": new Blob([richHtml], {type: "text/html"}),
157
+ "text/plain": new Blob([richTextFallback], {type: "text/plain"}),
158
+ });
159
+ await clipboard.write([item]);
160
+ return;
161
+ } catch (_error) {
162
+ // Some hosts reject rich clipboard types; fall back to plain text below.
163
+ }
164
+ }
165
+
166
+ if (clipboard?.writeText) {
167
+ await clipboard.writeText(richTextFallback);
168
+ return;
169
+ }
170
+
171
+ throw new Error("Clipboard API unavailable.");
41
172
  };
42
173
 
43
174
  const renderVisualization = (viz: ChatVisualization, key: string) => {
@@ -45,12 +176,13 @@ const renderVisualization = (viz: ChatVisualization, key: string) => {
45
176
  return null;
46
177
  }
47
178
 
179
+ const chartName = viz.title || key || "chart";
48
180
  return (
49
181
  <div key={key} className="rounded-2xl border border-slate-200 bg-white p-3">
50
182
  {viz.title ? (
51
183
  <p className="mb-2 text-sm font-semibold text-slate-700">{viz.title}</p>
52
184
  ) : null}
53
- <EChart option={viz.options as EChartsOption} />
185
+ <EChart option={viz.options as EChartsOption} fileName={chartName} />
54
186
  </div>
55
187
  );
56
188
  };
@@ -101,6 +233,26 @@ const renderMessageWithRichContent = (
101
233
  );
102
234
  const usedVizIds = new Set<string>();
103
235
  const usedCardIds = new Set<string>();
236
+ const cardBuffer: React.ReactNode[] = [];
237
+ let cardGroupIndex = 0;
238
+
239
+ const flushCards = () => {
240
+ if (!cardBuffer.length) {
241
+ return;
242
+ }
243
+ if (cardBuffer.length === 1) {
244
+ parts.push(cardBuffer[0]);
245
+ cardBuffer.length = 0;
246
+ return;
247
+ }
248
+ parts.push(
249
+ <div key={`card-grid-${cardGroupIndex}`} className="grid gap-4 md:grid-cols-2">
250
+ {cardBuffer}
251
+ </div>
252
+ );
253
+ cardBuffer.length = 0;
254
+ cardGroupIndex += 1;
255
+ };
104
256
 
105
257
  let match: RegExpExecArray | null;
106
258
  let lastIndex = 0;
@@ -114,6 +266,7 @@ const renderMessageWithRichContent = (
114
266
  if (start > lastIndex) {
115
267
  const text = message.slice(lastIndex, start).trim();
116
268
  if (text) {
269
+ flushCards();
117
270
  parts.push(
118
271
  isLatest ? (
119
272
  <MemoizedAIWriter key={`text-${start}`}>
@@ -129,6 +282,7 @@ const renderMessageWithRichContent = (
129
282
  if (type === "viz") {
130
283
  const viz = vizById.get(id);
131
284
  if (viz) {
285
+ flushCards();
132
286
  usedVizIds.add(id);
133
287
  parts.push(renderVisualization(viz, `viz-${id}-${start}`));
134
288
  }
@@ -138,7 +292,7 @@ const renderMessageWithRichContent = (
138
292
  const card = cardsById.get(id);
139
293
  if (card) {
140
294
  usedCardIds.add(id);
141
- parts.push(renderSummaryCard(card, `card-${id}-${start}`));
295
+ cardBuffer.push(renderSummaryCard(card, `card-${id}-${start}`));
142
296
  }
143
297
  }
144
298
 
@@ -147,6 +301,7 @@ const renderMessageWithRichContent = (
147
301
 
148
302
  const trailing = message.slice(lastIndex).trim();
149
303
  if (trailing) {
304
+ flushCards();
150
305
  parts.push(
151
306
  isLatest ? (
152
307
  <MemoizedAIWriter key={`text-${lastIndex}`}>
@@ -157,18 +312,24 @@ const renderMessageWithRichContent = (
157
312
  )
158
313
  );
159
314
  }
315
+ flushCards();
160
316
 
161
317
  const unused = visualizations.filter((viz) => !viz.id || !usedVizIds.has(viz.id));
162
318
  const unusedCards = summaryCards.filter((card) => !card.id || !usedCardIds.has(card.id));
163
319
  if (unused.length || unusedCards.length) {
320
+ const unusedCardItems = unusedCards.map((card, cardIndex) =>
321
+ renderSummaryCard(card, `card-unused-${card.id ?? cardIndex}`)
322
+ );
164
323
  parts.push(
165
324
  <div key="viz-unused" className="mt-4 space-y-4">
166
325
  {unused.map((viz, vizIndex) =>
167
326
  renderVisualization(viz, `viz-unused-${viz.id ?? vizIndex}`)
168
327
  )}
169
- {unusedCards.map((card, cardIndex) =>
170
- renderSummaryCard(card, `card-unused-${card.id ?? cardIndex}`)
171
- )}
328
+ {unusedCardItems.length === 1 ? (
329
+ unusedCardItems[0]
330
+ ) : unusedCardItems.length > 1 ? (
331
+ <div className="grid gap-4 md:grid-cols-2">{unusedCardItems}</div>
332
+ ) : null}
172
333
  </div>
173
334
  );
174
335
  }
@@ -176,6 +337,52 @@ const renderMessageWithRichContent = (
176
337
  return <div className="space-y-4">{parts}</div>;
177
338
  };
178
339
 
340
+ const AssistantMessageBody = React.memo(function AssistantMessageBody({
341
+ item,
342
+ isLatestMessage,
343
+ }: {
344
+ item: ChatMessage;
345
+ isLatestMessage: boolean;
346
+ }) {
347
+ return (
348
+ <div className="mdx text-sm text-slate-700 max-w-none">
349
+ {item.status === "thinking" ? (
350
+ <p className="mb-3 flex items-center gap-2 text-xs text-slate-400">
351
+ <span className="h-3 w-3 animate-pulse rounded-full bg-slate-400" />
352
+ <span>Thinking…</span>
353
+ </p>
354
+ ) : null}
355
+ {(item.visualizations && item.visualizations.length > 0) ||
356
+ (item.summaryCards && item.summaryCards.length > 0)
357
+ ? renderMessageWithRichContent(
358
+ item.content,
359
+ item.visualizations ?? [],
360
+ item.summaryCards ?? [],
361
+ isLatestMessage
362
+ )
363
+ : isLatestMessage
364
+ ? (
365
+ <MemoizedAIWriter>
366
+ <MarkdownRenderer>{item.content || ""}</MarkdownRenderer>
367
+ </MemoizedAIWriter>
368
+ )
369
+ : (
370
+ <MarkdownRenderer>{item.content || ""}</MarkdownRenderer>
371
+ )}
372
+ </div>
373
+ );
374
+ });
375
+ AssistantMessageBody.displayName = "AssistantMessageBody";
376
+
377
+ const UserMessageBody = React.memo(function UserMessageBody({content}: {content: string}) {
378
+ return (
379
+ <div className="mdx p-4 text-sm leading-relaxed">
380
+ <MarkdownRenderer>{content}</MarkdownRenderer>
381
+ </div>
382
+ );
383
+ });
384
+ UserMessageBody.displayName = "UserMessageBody";
385
+
179
386
  export type ChatInterfaceProps = {
180
387
  className?: string;
181
388
  viewContext?: string;
@@ -211,6 +418,9 @@ export function ChatInterface({
211
418
  const containerRef = React.useRef<HTMLDivElement>(null);
212
419
  const scrollRef = React.useRef<HTMLDivElement>(null);
213
420
  const inputRef = React.useRef<HTMLTextAreaElement>(null);
421
+ const messageContentRefs = React.useRef<Record<string, HTMLDivElement | null>>({});
422
+ const copyResetTimerRef = React.useRef<number | null>(null);
423
+ const [copiedMessageKey, setCopiedMessageKey] = React.useState<string | null>(null);
214
424
 
215
425
  React.useEffect(() => {
216
426
  if (!timeline.length) {
@@ -224,11 +434,45 @@ export function ChatInterface({
224
434
  }
225
435
  }, [timeline, loading]);
226
436
 
437
+ React.useEffect(() => {
438
+ return () => {
439
+ if (copyResetTimerRef.current !== null) {
440
+ window.clearTimeout(copyResetTimerRef.current);
441
+ }
442
+ };
443
+ }, []);
444
+
445
+ const handleCopyMessage = React.useCallback(async (messageKey: string, plainText: string) => {
446
+ const sourceElement = messageContentRefs.current[messageKey];
447
+ if (!sourceElement) {
448
+ return;
449
+ }
450
+
451
+ try {
452
+ await copyStyledMessage(sourceElement, plainText);
453
+ setCopiedMessageKey(messageKey);
454
+
455
+ if (copyResetTimerRef.current !== null) {
456
+ window.clearTimeout(copyResetTimerRef.current);
457
+ }
458
+
459
+ copyResetTimerRef.current = window.setTimeout(() => {
460
+ setCopiedMessageKey((current) => (current === messageKey ? null : current));
461
+ }, 1800);
462
+ } catch (error) {
463
+ console.error("Unable to copy message content.", error);
464
+ }
465
+ }, []);
466
+
227
467
  const renderedTimeline = React.useMemo(() => {
228
- return timeline.map((item: ChatMessage, index: number) =>
229
- item.role === "assistant" ? (
468
+ return timeline.map((item: ChatMessage, index: number) => {
469
+ const messageKey = item.id ? `${item.role}-${item.id}` : `${item.role}-${index}`;
470
+ const isLatestMessage = timeline.length === index + 1;
471
+ const hasCopied = copiedMessageKey === messageKey;
472
+
473
+ return item.role === "assistant" ? (
230
474
  <div
231
- key={index}
475
+ key={messageKey}
232
476
  className="flex flex-col gap-3 animate-in fade-in slide-in-from-bottom-2 duration-500"
233
477
  >
234
478
  <div className="flex items-center gap-3">
@@ -242,58 +486,74 @@ export function ChatInterface({
242
486
  </p>
243
487
  </div>
244
488
  </div>
245
- <div
246
- className={cn(
247
- "ml-12 rounded-3xl rounded-tl-sm border border-slate-200 bg-white/90 p-4 shadow-[0_12px_30px_rgba(0,0,0,0.08)]",
248
- item.status === "thinking"
249
- ? "min-h-[76px] h-auto overflow-y-auto"
250
- : ""
251
- )}
252
- >
253
- <div className="mdx text-sm text-slate-700 max-w-none">
254
- {item.status === "thinking" ? (
255
- <p className="mb-3 flex items-center gap-2 text-xs text-slate-400">
256
- <span className="h-3 w-3 animate-pulse rounded-full bg-slate-400" />
257
- <span>Thinking…</span>
258
- </p>
259
- ) : null}
260
- {(item.visualizations && item.visualizations.length > 0) ||
261
- (item.summaryCards && item.summaryCards.length > 0)
262
- ? renderMessageWithRichContent(
263
- item.content,
264
- item.visualizations ?? [],
265
- item.summaryCards ?? [],
266
- timeline.length === index + 1
267
- )
268
- : timeline.length === index + 1
269
- ? (
270
- <MemoizedAIWriter>
271
- <MarkdownRenderer>{item.content || ""}</MarkdownRenderer>
272
- </MemoizedAIWriter>
273
- )
274
- : (
275
- <MarkdownRenderer>{item.content || ""}</MarkdownRenderer>
276
- )}
489
+ <div className="ml-12">
490
+ <div
491
+ className={cn(
492
+ "rounded-3xl rounded-tl-sm border border-slate-200 bg-white/90 p-4 shadow-[0_12px_30px_rgba(0,0,0,0.08)]",
493
+ item.status === "thinking"
494
+ ? "min-h-[76px] h-auto overflow-y-auto"
495
+ : ""
496
+ )}
497
+ ref={(node) => {
498
+ messageContentRefs.current[messageKey] = node;
499
+ }}
500
+ >
501
+ <AssistantMessageBody item={item} isLatestMessage={isLatestMessage} />
277
502
  </div>
503
+ <button
504
+ type="button"
505
+ onClick={() => {
506
+ void handleCopyMessage(messageKey, item.content || "");
507
+ }}
508
+ className="mt-1 inline-flex items-center gap-1 text-[11px] font-medium text-slate-600 transition hover:text-slate-700"
509
+ aria-label="Copy formatted message"
510
+ >
511
+ {hasCopied ? (
512
+ <Check className="h-4 w-4" />
513
+ ) : (
514
+ <Copy className="h-4 w-4" />
515
+ )}
516
+ {hasCopied ? <span>Copied</span> : null}
517
+ </button>
278
518
  </div>
279
519
  </div>
280
520
  ) : (
281
521
  <div
282
- key={index}
522
+ key={messageKey}
283
523
  className="flex flex-col items-end gap-2 animate-in fade-in slide-in-from-bottom-2 duration-500"
284
524
  >
285
525
  <p className="text-xs text-slate-500">
286
526
  {moment(item.createdAt ?? new Date()).fromNow()}
287
527
  </p>
288
- <div className="max-w-[85%] rounded-3xl rounded-tr-sm bg-slate-900 text-white shadow-[0_20px_40px_rgba(15,23,42,0.25)]">
289
- <div className="mdx p-4 text-sm leading-relaxed">
290
- <MarkdownRenderer>{item.content}</MarkdownRenderer>
528
+ <div className="max-w-[85%]">
529
+ <div
530
+ className="rounded-3xl rounded-tr-sm bg-slate-900 text-white shadow-[0_20px_40px_rgba(15,23,42,0.25)]"
531
+ ref={(node) => {
532
+ messageContentRefs.current[messageKey] = node;
533
+ }}
534
+ >
535
+ <UserMessageBody content={item.content} />
291
536
  </div>
537
+ <button
538
+ type="button"
539
+ onClick={() => {
540
+ void handleCopyMessage(messageKey, item.content || "");
541
+ }}
542
+ className="mt-1 inline-flex items-center gap-1 text-[11px] font-medium text-slate-600 transition hover:text-slate-700"
543
+ aria-label="Copy formatted message"
544
+ >
545
+ {hasCopied ? (
546
+ <Check className="h-4 w-4" />
547
+ ) : (
548
+ <Copy className="h-4 w-4" />
549
+ )}
550
+ {hasCopied ? <span>Copied</span> : null}
551
+ </button>
292
552
  </div>
293
553
  </div>
294
554
  )
295
- );
296
- }, [timeline]);
555
+ });
556
+ }, [timeline, copiedMessageKey, handleCopyMessage, aiName]);
297
557
  const hasThinkingPlaceholder = React.useMemo(() => {
298
558
  if (!timeline.length) {
299
559
  return false;
@@ -0,0 +1,6 @@
1
+ import {type ClassValue, clsx} from "clsx";
2
+ import {twMerge} from "tailwind-merge";
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }
@@ -7,6 +7,7 @@
7
7
  "components/ChatProvider.tsx",
8
8
  "components/ChatInterface.tsx",
9
9
  "components/MarkdownRenderer.tsx",
10
+ "components/utils.ts",
10
11
  "components/ai-mdx.css",
11
12
  "hooks/use-database-chat.ts",
12
13
  "hooks/use-rtk-stream-adapter.ts"
@@ -16,7 +17,9 @@
16
17
  "markdown-to-jsx",
17
18
  "react-aiwriter",
18
19
  "moment",
19
- "lucide-react"
20
+ "lucide-react",
21
+ "clsx",
22
+ "tailwind-merge"
20
23
  ],
21
24
  "peerDependencies": [
22
25
  "@reduxjs/toolkit"