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
|
@@ -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 "
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
170
|
-
|
|
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.
|
|
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={
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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={
|
|
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%]
|
|
289
|
-
<div
|
|
290
|
-
|
|
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;
|
package/registry/index.json
CHANGED
|
@@ -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"
|