@r2digisolutions/components 0.17.2 → 0.17.4
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,5 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import type
|
|
2
|
+
import { tick, type Snippet } from 'svelte';
|
|
3
3
|
import IconButton from '../../atoms/IconButton/IconButton.svelte';
|
|
4
4
|
import {
|
|
5
5
|
Sparkles,
|
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
Volume2,
|
|
11
11
|
VolumeX,
|
|
12
12
|
LoaderCircle,
|
|
13
|
-
RotateCcw
|
|
13
|
+
RotateCcw,
|
|
14
|
+
Copy,
|
|
15
|
+
Check
|
|
14
16
|
} from '@lucide/svelte';
|
|
15
17
|
|
|
16
18
|
export interface AssistantMessage {
|
|
@@ -26,11 +28,13 @@
|
|
|
26
28
|
placeholder?: string;
|
|
27
29
|
emptyHint?: string;
|
|
28
30
|
suggestions?: string[];
|
|
31
|
+
/** Chips shown under the conversation (after the user has messages). */
|
|
32
|
+
followUpSuggestions?: string[];
|
|
29
33
|
messages?: AssistantMessage[];
|
|
30
34
|
draft?: string;
|
|
31
35
|
loading?: boolean;
|
|
32
36
|
loadingLabel?: string;
|
|
33
|
-
/** When false,
|
|
37
|
+
/** When false, the spinner bubble is hidden (parent paints status in a message). */
|
|
34
38
|
showLoadingBubble?: boolean;
|
|
35
39
|
error?: string | null;
|
|
36
40
|
voiceEnabled?: boolean;
|
|
@@ -43,12 +47,15 @@
|
|
|
43
47
|
stopMicLabel?: string;
|
|
44
48
|
voiceOnLabel?: string;
|
|
45
49
|
voiceOffLabel?: string;
|
|
50
|
+
copyChatLabel?: string;
|
|
51
|
+
copiedChatLabel?: string;
|
|
46
52
|
class?: string;
|
|
47
53
|
result?: Snippet<[AssistantMessage]>;
|
|
48
54
|
footerExtra?: Snippet;
|
|
49
55
|
onsend?: () => void;
|
|
50
56
|
onclose?: () => void;
|
|
51
57
|
onnewchat?: () => void;
|
|
58
|
+
oncopychat?: () => void | Promise<void>;
|
|
52
59
|
onmicclick?: () => void;
|
|
53
60
|
onvoiceoutputchange?: (on: boolean) => void;
|
|
54
61
|
onsuggestion?: (text: string) => void;
|
|
@@ -61,6 +68,7 @@
|
|
|
61
68
|
placeholder = 'Escribe un mensaje…',
|
|
62
69
|
emptyHint = '¿En qué puedo ayudarte?',
|
|
63
70
|
suggestions = [],
|
|
71
|
+
followUpSuggestions = [],
|
|
64
72
|
messages = [],
|
|
65
73
|
draft = $bindable(''),
|
|
66
74
|
loading = false,
|
|
@@ -77,21 +85,46 @@
|
|
|
77
85
|
stopMicLabel = 'Detener micrófono',
|
|
78
86
|
voiceOnLabel = 'Voz activada',
|
|
79
87
|
voiceOffLabel = 'Voz desactivada',
|
|
88
|
+
copyChatLabel = 'Copiar chat',
|
|
89
|
+
copiedChatLabel = 'Copiado',
|
|
80
90
|
class: className = '',
|
|
81
91
|
result,
|
|
82
92
|
footerExtra,
|
|
83
93
|
onsend,
|
|
84
94
|
onclose,
|
|
85
95
|
onnewchat,
|
|
96
|
+
oncopychat,
|
|
86
97
|
onmicclick,
|
|
87
98
|
onvoiceoutputchange,
|
|
88
99
|
onsuggestion
|
|
89
100
|
}: AssistantChatProps = $props();
|
|
90
101
|
|
|
91
102
|
let scrollEl = $state<HTMLDivElement | null>(null);
|
|
103
|
+
let inputEl: HTMLInputElement | null = null;
|
|
104
|
+
/** Restore the composer after send / SvelteKit form focus reset. */
|
|
105
|
+
let retainComposerFocus = false;
|
|
106
|
+
let copiedFlash = $state(false);
|
|
92
107
|
|
|
93
108
|
const isEmpty = $derived(messages.length === 0 && !loading);
|
|
94
109
|
const canSend = $derived(draft.trim().length > 0 && !loading);
|
|
110
|
+
const pendingAssistantId = $derived.by(() => {
|
|
111
|
+
if (!loading || messages.length === 0) return null;
|
|
112
|
+
const last = messages[messages.length - 1];
|
|
113
|
+
if (last?.role === 'assistant' && !last.content.trim()) return last.id;
|
|
114
|
+
return null;
|
|
115
|
+
});
|
|
116
|
+
const showTrailingLoader = $derived(loading && showLoadingBubble && !pendingAssistantId);
|
|
117
|
+
|
|
118
|
+
function focusComposer() {
|
|
119
|
+
inputEl?.focus({ preventScroll: true });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Parent can call this after client-side navigation (goto). */
|
|
123
|
+
export function focus() {
|
|
124
|
+
retainComposerFocus = true;
|
|
125
|
+
focusComposer();
|
|
126
|
+
void tick().then(focusComposer);
|
|
127
|
+
}
|
|
95
128
|
|
|
96
129
|
$effect(() => {
|
|
97
130
|
void messages.length;
|
|
@@ -104,14 +137,26 @@
|
|
|
104
137
|
return () => cancelAnimationFrame(raf);
|
|
105
138
|
});
|
|
106
139
|
|
|
140
|
+
$effect(() => {
|
|
141
|
+
if (!open || loading || !retainComposerFocus) return;
|
|
142
|
+
const id = requestAnimationFrame(() => {
|
|
143
|
+
if (open && retainComposerFocus) focusComposer();
|
|
144
|
+
if (!loading) retainComposerFocus = false;
|
|
145
|
+
});
|
|
146
|
+
return () => cancelAnimationFrame(id);
|
|
147
|
+
});
|
|
148
|
+
|
|
107
149
|
function handleClose() {
|
|
150
|
+
retainComposerFocus = false;
|
|
108
151
|
open = false;
|
|
109
152
|
onclose?.();
|
|
110
153
|
}
|
|
111
154
|
|
|
112
155
|
function handleSend() {
|
|
113
156
|
if (!canSend) return;
|
|
157
|
+
retainComposerFocus = true;
|
|
114
158
|
onsend?.();
|
|
159
|
+
void tick().then(focusComposer);
|
|
115
160
|
}
|
|
116
161
|
|
|
117
162
|
function handleKeydown(e: KeyboardEvent) {
|
|
@@ -130,6 +175,15 @@
|
|
|
130
175
|
voiceOutput = !voiceOutput;
|
|
131
176
|
onvoiceoutputchange?.(voiceOutput);
|
|
132
177
|
}
|
|
178
|
+
|
|
179
|
+
async function handleCopyChat() {
|
|
180
|
+
if (!oncopychat || messages.length === 0) return;
|
|
181
|
+
await oncopychat();
|
|
182
|
+
copiedFlash = true;
|
|
183
|
+
window.setTimeout(() => {
|
|
184
|
+
copiedFlash = false;
|
|
185
|
+
}, 1600);
|
|
186
|
+
}
|
|
133
187
|
</script>
|
|
134
188
|
|
|
135
189
|
<div
|
|
@@ -154,6 +208,19 @@
|
|
|
154
208
|
<p class="text-xs text-muted truncate">{statusLabel}</p>
|
|
155
209
|
{/if}
|
|
156
210
|
</div>
|
|
211
|
+
<IconButton
|
|
212
|
+
variant="ghost"
|
|
213
|
+
size="xs"
|
|
214
|
+
label={copiedFlash ? copiedChatLabel : copyChatLabel}
|
|
215
|
+
disabled={messages.length === 0 || !oncopychat}
|
|
216
|
+
onclick={() => void handleCopyChat()}
|
|
217
|
+
>
|
|
218
|
+
{#if copiedFlash}
|
|
219
|
+
<Check size={14} strokeWidth={2} />
|
|
220
|
+
{:else}
|
|
221
|
+
<Copy size={14} strokeWidth={2} />
|
|
222
|
+
{/if}
|
|
223
|
+
</IconButton>
|
|
157
224
|
<IconButton variant="ghost" size="xs" label={newChatLabel} onclick={() => onnewchat?.()}>
|
|
158
225
|
<RotateCcw size={14} strokeWidth={2} />
|
|
159
226
|
</IconButton>
|
|
@@ -193,7 +260,7 @@
|
|
|
193
260
|
{#if message.role === 'user'}
|
|
194
261
|
<div class="flex justify-end">
|
|
195
262
|
<div
|
|
196
|
-
class="rounded-2xl rounded-br-sm bg-brand-600 px-3.5 py-2.5 text-sm leading-relaxed text-white max-w-[80%]"
|
|
263
|
+
class="rounded-2xl rounded-br-sm bg-brand-600 px-3.5 py-2.5 text-sm leading-relaxed text-white max-w-[80%] whitespace-pre-wrap"
|
|
197
264
|
>
|
|
198
265
|
{message.content}
|
|
199
266
|
</div>
|
|
@@ -208,9 +275,16 @@
|
|
|
208
275
|
<Sparkles size={12} strokeWidth={2} />
|
|
209
276
|
</span>
|
|
210
277
|
<div
|
|
211
|
-
class="rounded-2xl rounded-bl-sm bg-surface-overlay px-3.5 py-2.5 text-sm leading-relaxed text-primary max-w-[calc(100%-2rem)]"
|
|
278
|
+
class="rounded-2xl rounded-bl-sm bg-surface-overlay px-3.5 py-2.5 text-sm leading-relaxed text-primary max-w-[calc(100%-2rem)] whitespace-pre-wrap"
|
|
212
279
|
>
|
|
213
|
-
{message.
|
|
280
|
+
{#if message.id === pendingAssistantId}
|
|
281
|
+
<span class="gap-2 text-muted flex items-center">
|
|
282
|
+
<LoaderCircle size={14} strokeWidth={2} class="animate-spin shrink-0" />
|
|
283
|
+
<span class="text-xs">{loadingLabel}</span>
|
|
284
|
+
</span>
|
|
285
|
+
{:else}
|
|
286
|
+
{message.content}
|
|
287
|
+
{/if}
|
|
214
288
|
</div>
|
|
215
289
|
</div>
|
|
216
290
|
{#if result}
|
|
@@ -222,7 +296,7 @@
|
|
|
222
296
|
{/if}
|
|
223
297
|
{/each}
|
|
224
298
|
|
|
225
|
-
{#if
|
|
299
|
+
{#if showTrailingLoader}
|
|
226
300
|
<div class="gap-2 flex items-center">
|
|
227
301
|
<span
|
|
228
302
|
class="h-6 w-6 bg-brand-100 text-brand-600 dark:bg-brand-900/40 dark:text-brand-400 flex shrink-0 items-center justify-center rounded-full"
|
|
@@ -238,6 +312,20 @@
|
|
|
238
312
|
</div>
|
|
239
313
|
</div>
|
|
240
314
|
{/if}
|
|
315
|
+
|
|
316
|
+
{#if !loading && followUpSuggestions.length}
|
|
317
|
+
<div class="ml-8 gap-2 flex flex-wrap">
|
|
318
|
+
{#each followUpSuggestions as suggestion (suggestion)}
|
|
319
|
+
<button
|
|
320
|
+
type="button"
|
|
321
|
+
class="border-border bg-surface px-3 py-1.5 text-xs text-secondary hover:border-brand-400 hover:text-brand-600 dark:hover:text-brand-400 rounded-full border transition-colors"
|
|
322
|
+
onclick={() => handleSuggestion(suggestion)}
|
|
323
|
+
>
|
|
324
|
+
{suggestion}
|
|
325
|
+
</button>
|
|
326
|
+
{/each}
|
|
327
|
+
</div>
|
|
328
|
+
{/if}
|
|
241
329
|
</div>
|
|
242
330
|
{/if}
|
|
243
331
|
</div>
|
|
@@ -246,6 +334,17 @@
|
|
|
246
334
|
<footer class="border-border px-3 py-2.5 shrink-0 border-t">
|
|
247
335
|
<div class="gap-1.5 flex items-center">
|
|
248
336
|
<input
|
|
337
|
+
{@attach (node) => {
|
|
338
|
+
inputEl = node;
|
|
339
|
+
$effect(() => {
|
|
340
|
+
if (!open || loading || !retainComposerFocus) return;
|
|
341
|
+
node.focus();
|
|
342
|
+
retainComposerFocus = false;
|
|
343
|
+
});
|
|
344
|
+
return () => {
|
|
345
|
+
if (inputEl === node) inputEl = null;
|
|
346
|
+
};
|
|
347
|
+
}}
|
|
249
348
|
type="text"
|
|
250
349
|
bind:value={draft}
|
|
251
350
|
placeholder={listening ? statusLabel || placeholder : placeholder}
|
|
@@ -253,7 +352,6 @@
|
|
|
253
352
|
'h-9 min-w-0 rounded-xl bg-surface px-3 text-sm text-primary placeholder:text-muted focus:border-brand-400 focus:ring-brand-500/20 flex-1 border transition-shadow outline-none focus:ring-2',
|
|
254
353
|
listening ? 'border-red-400 ring-red-500/20 ring-2' : 'border-border'
|
|
255
354
|
]}
|
|
256
|
-
disabled={loading}
|
|
257
355
|
onkeydown={handleKeydown}
|
|
258
356
|
aria-label={placeholder}
|
|
259
357
|
/>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Snippet } from 'svelte';
|
|
2
2
|
export interface AssistantMessage {
|
|
3
3
|
id: string;
|
|
4
4
|
role: 'user' | 'assistant';
|
|
@@ -11,11 +11,13 @@ interface AssistantChatProps {
|
|
|
11
11
|
placeholder?: string;
|
|
12
12
|
emptyHint?: string;
|
|
13
13
|
suggestions?: string[];
|
|
14
|
+
/** Chips shown under the conversation (after the user has messages). */
|
|
15
|
+
followUpSuggestions?: string[];
|
|
14
16
|
messages?: AssistantMessage[];
|
|
15
17
|
draft?: string;
|
|
16
18
|
loading?: boolean;
|
|
17
19
|
loadingLabel?: string;
|
|
18
|
-
/** When false,
|
|
20
|
+
/** When false, the spinner bubble is hidden (parent paints status in a message). */
|
|
19
21
|
showLoadingBubble?: boolean;
|
|
20
22
|
error?: string | null;
|
|
21
23
|
voiceEnabled?: boolean;
|
|
@@ -28,16 +30,21 @@ interface AssistantChatProps {
|
|
|
28
30
|
stopMicLabel?: string;
|
|
29
31
|
voiceOnLabel?: string;
|
|
30
32
|
voiceOffLabel?: string;
|
|
33
|
+
copyChatLabel?: string;
|
|
34
|
+
copiedChatLabel?: string;
|
|
31
35
|
class?: string;
|
|
32
36
|
result?: Snippet<[AssistantMessage]>;
|
|
33
37
|
footerExtra?: Snippet;
|
|
34
38
|
onsend?: () => void;
|
|
35
39
|
onclose?: () => void;
|
|
36
40
|
onnewchat?: () => void;
|
|
41
|
+
oncopychat?: () => void | Promise<void>;
|
|
37
42
|
onmicclick?: () => void;
|
|
38
43
|
onvoiceoutputchange?: (on: boolean) => void;
|
|
39
44
|
onsuggestion?: (text: string) => void;
|
|
40
45
|
}
|
|
41
|
-
declare const AssistantChat: import("svelte").Component<AssistantChatProps, {
|
|
46
|
+
declare const AssistantChat: import("svelte").Component<AssistantChatProps, {
|
|
47
|
+
focus: () => void;
|
|
48
|
+
}, "open" | "draft" | "voiceOutput">;
|
|
42
49
|
type AssistantChat = ReturnType<typeof AssistantChat>;
|
|
43
50
|
export default AssistantChat;
|