@tp3/chat-widget 0.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.
@@ -0,0 +1,486 @@
1
+ import { useState, useRef, useEffect } from "react";
2
+
3
+ export interface ChatWidgetProps {
4
+ /** Worker host, e.g. "tp3studio-chat.iaforchange.workers.dev" */
5
+ agentHost: string;
6
+ /** Agent name (DO binding in kebab-case), default: "tp3-chat-agent" */
7
+ agentName?: string;
8
+ /** Brand name shown in the header, default: "Tp3studio" */
9
+ brandName?: string;
10
+ /** Subtitle shown below the brand name, default: "Asistente virtual" */
11
+ brandSubtitle?: string;
12
+ /** Welcome message shown on first open */
13
+ welcomeMessage?: string;
14
+ }
15
+
16
+ /**
17
+ * Minimal markdown → HTML for LLM-generated text.
18
+ * Escapes HTML first (safe), then applies formatting rules.
19
+ */
20
+ function renderMarkdown(text: string): string {
21
+ let html = text
22
+ .replace(/&/g, "&")
23
+ .replace(/</g, "&lt;")
24
+ .replace(/>/g, "&gt;");
25
+
26
+ html = html.replace(/`([^`]+)`/g, "<code>$1</code>");
27
+ html = html.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
28
+ html = html.replace(/__(.+?)__/g, "<strong>$1</strong>");
29
+ html = html.replace(/(?<!\*)\*([^*\n]+?)\*(?!\*)/g, "<em>$1</em>");
30
+ html = html.replace(
31
+ /\[([^\]]+)\]\(([^)]+)\)/g,
32
+ '<a href="$2" target="_blank" rel="noopener">$1</a>',
33
+ );
34
+ html = html.replace(/^#{1,4}\s+(.+)$/gm, "<strong>$1</strong>");
35
+ html = html.replace(/^[\t ]*[-*]\s+(.+)$/gm, "• $1");
36
+ html = html.replace(/\n\n/g, "<br><br>");
37
+ html = html.replace(/\n/g, "<br>");
38
+
39
+ return html;
40
+ }
41
+
42
+ const DEFAULTS = {
43
+ agentName: "tp3-chat-agent",
44
+ brandName: "Tp3studio",
45
+ brandSubtitle: "Asistente virtual",
46
+ welcomeMessage:
47
+ "👋 ¡Hola! Soy el asistente de Tp3studio. ¿En qué puedo ayudarte?",
48
+ } as const;
49
+
50
+ export default function ChatWidget({
51
+ agentHost,
52
+ agentName = DEFAULTS.agentName,
53
+ brandName = DEFAULTS.brandName,
54
+ brandSubtitle = DEFAULTS.brandSubtitle,
55
+ welcomeMessage = DEFAULTS.welcomeMessage,
56
+ }: ChatWidgetProps) {
57
+ const [open, setOpen] = useState(false);
58
+ const [closing, setClosing] = useState(false);
59
+ const [input, setInput] = useState("");
60
+ const [messages, setMessages] = useState<{ role: string; text: string }[]>(
61
+ [],
62
+ );
63
+ const [loading, setLoading] = useState(false);
64
+ const wsRef = useRef<WebSocket | null>(null);
65
+ const messagesEnd = useRef<HTMLDivElement>(null);
66
+ const inputRef = useRef<HTMLInputElement>(null);
67
+ const typewriterQueue = useRef<string[]>([]);
68
+ const typewriterTimer = useRef<ReturnType<typeof setInterval> | null>(null);
69
+
70
+ useEffect(() => {
71
+ messagesEnd.current?.scrollIntoView({ behavior: "smooth" });
72
+ }, [messages]);
73
+
74
+ useEffect(() => {
75
+ if (!loading && open) {
76
+ inputRef.current?.focus();
77
+ }
78
+ }, [loading, open]);
79
+
80
+ const TYPEWRITER_MS = 180;
81
+ const streamDone = useRef(false);
82
+
83
+ function startTypewriter() {
84
+ let fullText = "";
85
+
86
+ typewriterTimer.current = setInterval(() => {
87
+ const queue = typewriterQueue.current;
88
+
89
+ if (queue.length > 0) {
90
+ fullText += queue.shift()!;
91
+ setMessages((prev) => {
92
+ const next = [...prev];
93
+ const last = next[next.length - 1];
94
+ if (last && last.role === "bot") {
95
+ next[next.length - 1] = { role: "bot", text: fullText };
96
+ } else {
97
+ next.push({ role: "bot", text: fullText });
98
+ }
99
+ return next;
100
+ });
101
+ return;
102
+ }
103
+
104
+ if (streamDone.current) {
105
+ clearInterval(typewriterTimer.current!);
106
+ typewriterTimer.current = null;
107
+ setLoading(false);
108
+ }
109
+ }, TYPEWRITER_MS);
110
+ }
111
+
112
+ function connectWs() {
113
+ if (wsRef.current?.readyState === WebSocket.OPEN) return;
114
+ typewriterQueue.current = [];
115
+ streamDone.current = false;
116
+ if (typewriterTimer.current) {
117
+ clearInterval(typewriterTimer.current);
118
+ typewriterTimer.current = null;
119
+ }
120
+
121
+ const sid = crypto.randomUUID().slice(0, 8);
122
+ const protocol = location.protocol === "https:" ? "wss:" : "ws:";
123
+ const ws = new WebSocket(
124
+ `${protocol}//${agentHost}/agents/${agentName}/${sid}`,
125
+ );
126
+ wsRef.current = ws;
127
+
128
+ ws.onopen = () => {
129
+ if (messages.length === 0) {
130
+ setMessages([{ role: "bot", text: welcomeMessage }]);
131
+ }
132
+ };
133
+
134
+ ws.onmessage = (event) => {
135
+ try {
136
+ const data = JSON.parse(event.data);
137
+ if (data.type === "chat-response") {
138
+ streamDone.current = true;
139
+ setMessages((prev) => [
140
+ ...prev,
141
+ { role: "bot", text: data.message },
142
+ ]);
143
+ setLoading(false);
144
+ } else if (data.type === "chat-chunk") {
145
+ if (!data.done) {
146
+ if (!typewriterTimer.current) {
147
+ startTypewriter();
148
+ }
149
+ typewriterQueue.current.push(data.text || "");
150
+ } else {
151
+ streamDone.current = true;
152
+ }
153
+ }
154
+ } catch {}
155
+ };
156
+
157
+ ws.onerror = () => {
158
+ streamDone.current = true;
159
+ if (typewriterTimer.current) {
160
+ clearInterval(typewriterTimer.current);
161
+ typewriterTimer.current = null;
162
+ }
163
+ setMessages((prev) => [
164
+ ...prev,
165
+ { role: "bot", text: "⚠ Error al conectar con el asistente." },
166
+ ]);
167
+ setLoading(false);
168
+ };
169
+
170
+ ws.onclose = () => {
171
+ streamDone.current = true;
172
+ if (typewriterTimer.current) {
173
+ clearInterval(typewriterTimer.current);
174
+ typewriterTimer.current = null;
175
+ }
176
+ wsRef.current = null;
177
+ };
178
+ }
179
+
180
+ function send() {
181
+ const text = input.trim();
182
+ if (!text || loading) return;
183
+ setInput("");
184
+ setMessages((prev) => [...prev, { role: "user", text }]);
185
+ setLoading(true);
186
+
187
+ const trySend = () => {
188
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
189
+ const history = messages
190
+ .filter((m) => m.role !== "error")
191
+ .slice(-20)
192
+ .map((m) => ({
193
+ role: m.role === "bot" ? "assistant" : "user",
194
+ content: m.text,
195
+ }));
196
+ wsRef.current.send(
197
+ JSON.stringify({ type: "chat", message: text, history }),
198
+ );
199
+ } else {
200
+ setTimeout(trySend, 200);
201
+ }
202
+ };
203
+ trySend();
204
+ }
205
+
206
+ const chatWidth = 380;
207
+ const chatHeight = 540;
208
+
209
+ return (
210
+ <>
211
+ <style>{`
212
+ :root {
213
+ --chat-primary: #6366F1;
214
+ --chat-primary-fg: #fff;
215
+ --chat-primary-hover: #4F46E5;
216
+ --chat-bot-bubble: #F4F4F5;
217
+ --chat-bot-text: #18181B;
218
+ --chat-font-heading: "Outfit", sans-serif;
219
+ --chat-font-body: "Nunito", sans-serif;
220
+ --chat-border: #E4E4E7;
221
+ --chat-shadow: 0 8px 24px rgba(0,0,0,.12);
222
+ --chat-shadow-lg: 0 12px 48px rgba(0,0,0,.18);
223
+ --chat-code-bg: rgba(99,102,241,.12);
224
+ --chat-close-btn-bg: rgba(255,255,255,.1);
225
+ }
226
+ @keyframes slide-up { from { opacity:0; transform:translateY(16px) scale(0.96); } to { opacity:1; transform:translateY(0) scale(1); } }
227
+ @keyframes slide-down { from { opacity:1; transform:translateY(0) scale(1); } to { opacity:0; transform:translateY(16px) scale(0.96); } }
228
+ .chat-window { animation:slide-up .35s cubic-bezier(.16,1,.3,1) forwards; }
229
+ .chat-window-out { animation:slide-down .25s ease-in forwards; }
230
+ @keyframes typing-dot { 0%,60% { opacity:.2; transform:translateY(0); } 30% { opacity:1; transform:translateY(-6px); } 100% { opacity:.2; transform:translateY(0); } }
231
+ .typing-indicator { display:flex; align-items:center; gap:4px; padding:8px 14px; }
232
+ .typing-indicator span { width:7px; height:7px; border-radius:50%; background:var(--chat-primary); animation:typing-dot 1.4s infinite ease-in-out; }
233
+ .typing-indicator span:nth-child(2) { animation-delay:.15s; }
234
+ .typing-indicator span:nth-child(3) { animation-delay:.3s; }
235
+ .bot-msg a { color:var(--chat-primary); text-decoration:underline; }
236
+ .bot-msg a:hover { color:var(--chat-primary-hover); }
237
+ .bot-msg code { background:var(--chat-code-bg); color:var(--chat-primary); padding:1px 5px; border-radius:4px; font-size:.9em; font-family:monospace; }
238
+ .bot-msg strong { font-weight:600; color:var(--chat-bot-text); }
239
+ .bot-msg em { font-style:italic; }
240
+ @media (max-width: 480px) {
241
+ .chat-window, .chat-window-out {
242
+ width: 100% !important; height: 100% !important;
243
+ max-width: 100% !important; max-height: 100% !important;
244
+ bottom: 0 !important; right: 0 !important;
245
+ border-radius: 0 !important;
246
+ }
247
+ }
248
+ `}</style>
249
+
250
+ {!open && (
251
+ <button
252
+ onClick={() => {
253
+ connectWs();
254
+ setOpen(true);
255
+ }}
256
+ style={{
257
+ position: "fixed",
258
+ bottom: 24,
259
+ right: 24,
260
+ zIndex: 9999,
261
+ width: 56,
262
+ height: 56,
263
+ borderRadius: 16,
264
+ background: "var(--chat-primary)",
265
+ color: "var(--chat-primary-fg)",
266
+ border: "none",
267
+ cursor: "pointer",
268
+ display: "flex",
269
+ alignItems: "center",
270
+ justifyContent: "center",
271
+ boxShadow: "var(--chat-shadow)",
272
+ }}
273
+ aria-label="Abrir chat"
274
+ >
275
+ <svg
276
+ width="22"
277
+ height="22"
278
+ viewBox="0 0 24 24"
279
+ fill="none"
280
+ stroke="currentColor"
281
+ strokeWidth="2"
282
+ strokeLinecap="round"
283
+ >
284
+ <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
285
+ </svg>
286
+ </button>
287
+ )}
288
+
289
+ {open && (
290
+ <div
291
+ className={closing ? "chat-window-out" : "chat-window"}
292
+ style={{
293
+ position: "fixed",
294
+ bottom: 24,
295
+ right: 24,
296
+ zIndex: 9998,
297
+ width: chatWidth,
298
+ maxWidth: "calc(100vw - 48px)",
299
+ height: chatHeight,
300
+ maxHeight: "calc(100vh - 48px)",
301
+ background: "var(--chat-primary-fg)",
302
+ display: "flex",
303
+ flexDirection: "column",
304
+ borderRadius: 16,
305
+ overflow: "hidden",
306
+ boxShadow: "var(--chat-shadow-lg)",
307
+ }}
308
+ >
309
+ {/* Header */}
310
+ <div
311
+ style={{
312
+ background: "var(--chat-primary)",
313
+ color: "var(--chat-primary-fg)",
314
+ padding: "14px 20px",
315
+ display: "flex",
316
+ alignItems: "center",
317
+ justifyContent: "space-between",
318
+ flexShrink: 0,
319
+ }}
320
+ >
321
+ <div>
322
+ <div
323
+ style={{
324
+ fontFamily: "var(--chat-font-heading)",
325
+ fontWeight: 600,
326
+ fontSize: 16,
327
+ }}
328
+ >
329
+ {brandName}
330
+ </div>
331
+ <div style={{ fontSize: 12, opacity: 0.75, marginTop: 1 }}>
332
+ {brandSubtitle}
333
+ </div>
334
+ </div>
335
+ <button
336
+ onClick={() => {
337
+ wsRef.current?.close();
338
+ setClosing(true);
339
+ setTimeout(() => {
340
+ setOpen(false);
341
+ setClosing(false);
342
+ }, 250);
343
+ }}
344
+ style={{
345
+ width: 32,
346
+ height: 32,
347
+ borderRadius: 12,
348
+ background: "var(--chat-close-btn-bg)",
349
+ border: "none",
350
+ color: "var(--chat-primary-fg)",
351
+ cursor: "pointer",
352
+ }}
353
+ aria-label="Cerrar chat"
354
+ >
355
+
356
+ </button>
357
+ </div>
358
+
359
+ {/* Messages */}
360
+ <div
361
+ style={{
362
+ flex: 1,
363
+ overflowY: "auto",
364
+ padding: "12px 16px",
365
+ display: "flex",
366
+ flexDirection: "column",
367
+ gap: 10,
368
+ fontFamily: "var(--chat-font-body)",
369
+ }}
370
+ >
371
+ {messages.map((m, i) => (
372
+ <div
373
+ key={i}
374
+ className={m.role === "bot" ? "bot-msg" : ""}
375
+ style={{
376
+ maxWidth: "85%",
377
+ padding: "10px 14px",
378
+ borderRadius: 16,
379
+ fontSize: 14,
380
+ lineHeight: 1.45,
381
+ alignSelf: m.role === "user" ? "flex-end" : "flex-start",
382
+ background:
383
+ m.role === "user"
384
+ ? "var(--chat-primary)"
385
+ : "var(--chat-bot-bubble)",
386
+ color:
387
+ m.role === "user"
388
+ ? "var(--chat-primary-fg)"
389
+ : "var(--chat-bot-text)",
390
+ borderBottomRightRadius: m.role === "user" ? 4 : 16,
391
+ borderBottomLeftRadius: m.role === "user" ? 16 : 4,
392
+ }}
393
+ >
394
+ {m.role === "bot" ? (
395
+ <span
396
+ dangerouslySetInnerHTML={{
397
+ __html: renderMarkdown(m.text),
398
+ }}
399
+ />
400
+ ) : (
401
+ m.text
402
+ )}
403
+ </div>
404
+ ))}
405
+ {loading && (
406
+ <div
407
+ className="typing-indicator"
408
+ style={{
409
+ alignSelf: "flex-start",
410
+ background: "var(--chat-bot-bubble)",
411
+ borderRadius: 16,
412
+ borderBottomLeftRadius: 4,
413
+ }}
414
+ >
415
+ <span />
416
+ <span />
417
+ <span />
418
+ </div>
419
+ )}
420
+ <div ref={messagesEnd} />
421
+ </div>
422
+
423
+ {/* Input */}
424
+ <form
425
+ onSubmit={(e) => {
426
+ e.preventDefault();
427
+ send();
428
+ }}
429
+ style={{
430
+ display: "flex",
431
+ gap: 8,
432
+ padding: "12px 16px 16px",
433
+ borderTop: "1px solid var(--chat-border)",
434
+ flexShrink: 0,
435
+ }}
436
+ >
437
+ <input
438
+ ref={inputRef}
439
+ value={input}
440
+ onChange={(e) => setInput(e.target.value)}
441
+ placeholder="Escribe tu mensaje..."
442
+ disabled={loading}
443
+ style={{
444
+ flex: 1,
445
+ padding: "10px 14px",
446
+ border: "1px solid var(--chat-border)",
447
+ borderRadius: 12,
448
+ fontSize: 14,
449
+ outline: "none",
450
+ fontFamily: "var(--chat-font-body)",
451
+ }}
452
+ />
453
+ <button
454
+ type="submit"
455
+ disabled={loading || !input.trim()}
456
+ style={{
457
+ width: 40,
458
+ height: 40,
459
+ borderRadius: 12,
460
+ background: "var(--chat-primary)",
461
+ color: "var(--chat-primary-fg)",
462
+ border: "none",
463
+ cursor: "pointer",
464
+ opacity: loading || !input.trim() ? 0.4 : 1,
465
+ }}
466
+ title="Enviar"
467
+ >
468
+ <svg
469
+ width="18"
470
+ height="18"
471
+ viewBox="0 0 24 24"
472
+ fill="none"
473
+ stroke="currentColor"
474
+ strokeWidth="2"
475
+ strokeLinecap="round"
476
+ >
477
+ <line x1="22" y1="2" x2="11" y2="13" />
478
+ <polygon points="22 2 15 22 11 13 2 9 22 2" />
479
+ </svg>
480
+ </button>
481
+ </form>
482
+ </div>
483
+ )}
484
+ </>
485
+ );
486
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ES2022",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "skipLibCheck": true,
8
+ "jsx": "react-jsx",
9
+ "declaration": true,
10
+ "outDir": "dist"
11
+ },
12
+ "include": ["src"]
13
+ }