@svadmin/ui 0.7.0 → 0.9.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.
- package/package.json +1 -1
- package/src/components/AutoTable.svelte +83 -0
- package/src/components/ChatDialog.svelte +469 -0
- package/src/components/Layout.svelte +2 -0
- package/src/components/MarkdownRenderer.svelte +140 -0
- package/src/components/ui/select/select.svelte +3 -1
- package/src/index.ts +1 -1
package/package.json
CHANGED
|
@@ -436,6 +436,8 @@
|
|
|
436
436
|
</div>
|
|
437
437
|
{:else}
|
|
438
438
|
<div in:fade={{ duration: 150 }}>
|
|
439
|
+
<!-- Desktop Table (hidden on mobile) -->
|
|
440
|
+
<div class="hidden md:block">
|
|
439
441
|
<Table.Root>
|
|
440
442
|
<Table.Header>
|
|
441
443
|
{#each tbl.getHeaderGroups() as headerGroup}
|
|
@@ -582,6 +584,87 @@
|
|
|
582
584
|
{/each}
|
|
583
585
|
</Table.Body>
|
|
584
586
|
</Table.Root>
|
|
587
|
+
</div>
|
|
588
|
+
|
|
589
|
+
<!-- Mobile Card View (visible only on small screens) -->
|
|
590
|
+
<div class="md:hidden space-y-3 p-2">
|
|
591
|
+
{#each tbl.getRowModel().rows as row}
|
|
592
|
+
{@const record = row.original}
|
|
593
|
+
{@const id = record[primaryKey] as string | number}
|
|
594
|
+
<div
|
|
595
|
+
class="rounded-xl border bg-card p-4 shadow-sm transition-colors {row.getIsSelected() ? 'ring-2 ring-primary bg-accent/30' : ''}"
|
|
596
|
+
>
|
|
597
|
+
<!-- Card header: ID + select + actions -->
|
|
598
|
+
<div class="flex items-center justify-between mb-3">
|
|
599
|
+
<div class="flex items-center gap-2">
|
|
600
|
+
{#if selectable && canDelete}
|
|
601
|
+
<Checkbox
|
|
602
|
+
checked={row.getIsSelected()}
|
|
603
|
+
onCheckedChange={() => row.toggleSelected()}
|
|
604
|
+
/>
|
|
605
|
+
{/if}
|
|
606
|
+
<span class="text-xs font-mono text-muted-foreground">#{id}</span>
|
|
607
|
+
</div>
|
|
608
|
+
<div class="flex items-center gap-1">
|
|
609
|
+
{#if rowActions}
|
|
610
|
+
{@render rowActions({ record, id })}
|
|
611
|
+
{:else}
|
|
612
|
+
{#if canEdit}
|
|
613
|
+
<TooltipButton tooltip={t('common.edit')} variant="ghost" size="icon-sm" onclick={() => navigate(`/${resourceName}/edit/${id}`)}>
|
|
614
|
+
<Pencil class="h-4 w-4" />
|
|
615
|
+
</TooltipButton>
|
|
616
|
+
{/if}
|
|
617
|
+
<TooltipButton tooltip={t('common.detail')} variant="ghost" size="icon-sm" onclick={() => navigate(`/${resourceName}/show/${id}`)}>
|
|
618
|
+
<Eye class="h-4 w-4" />
|
|
619
|
+
</TooltipButton>
|
|
620
|
+
{#if canDelete}
|
|
621
|
+
<TooltipButton tooltip={t('common.delete')} variant="ghost" size="icon-sm" onclick={() => confirmDelete(id)} class="hover:text-destructive">
|
|
622
|
+
<Trash2 class="h-4 w-4" />
|
|
623
|
+
</TooltipButton>
|
|
624
|
+
{/if}
|
|
625
|
+
{/if}
|
|
626
|
+
</div>
|
|
627
|
+
</div>
|
|
628
|
+
<!-- Card fields -->
|
|
629
|
+
<div class="space-y-2">
|
|
630
|
+
{#each visibleFields.slice(0, 6) as field}
|
|
631
|
+
{@const value = record[field.key]}
|
|
632
|
+
<div class="flex items-start justify-between gap-4">
|
|
633
|
+
<span class="text-xs font-medium text-muted-foreground shrink-0">{field.label}</span>
|
|
634
|
+
<span class="text-sm text-right truncate max-w-[60%]">
|
|
635
|
+
{#if cellRenderer}
|
|
636
|
+
{@render cellRenderer({ field, value, record })}
|
|
637
|
+
{:else if field.type === 'boolean'}
|
|
638
|
+
<span class="inline-block h-2 w-2 rounded-full {value ? 'bg-green-500' : 'bg-muted-foreground/30'}"></span>
|
|
639
|
+
{:else if field.type === 'date' && value}
|
|
640
|
+
{new Date(value as string).toLocaleDateString()}
|
|
641
|
+
{:else if field.type === 'tags' && Array.isArray(value)}
|
|
642
|
+
<div class="flex flex-wrap gap-1 justify-end">
|
|
643
|
+
{#each (value as string[]).slice(0, 2) as tag}
|
|
644
|
+
<Badge variant="secondary" class="text-[10px]">{tag}</Badge>
|
|
645
|
+
{/each}
|
|
646
|
+
</div>
|
|
647
|
+
{:else if field.type === 'select' && field.options}
|
|
648
|
+
{@const opt = field.options.find(o => o.value === value)}
|
|
649
|
+
<Badge variant="outline" class="text-[10px]">{opt?.label ?? value ?? '—'}</Badge>
|
|
650
|
+
{:else}
|
|
651
|
+
{value ?? '—'}
|
|
652
|
+
{/if}
|
|
653
|
+
</span>
|
|
654
|
+
</div>
|
|
655
|
+
{/each}
|
|
656
|
+
</div>
|
|
657
|
+
</div>
|
|
658
|
+
{:else}
|
|
659
|
+
<div class="text-center py-10 text-muted-foreground">
|
|
660
|
+
{#if emptyState}
|
|
661
|
+
{@render emptyState()}
|
|
662
|
+
{:else}
|
|
663
|
+
{t('common.noData')}
|
|
664
|
+
{/if}
|
|
665
|
+
</div>
|
|
666
|
+
{/each}
|
|
667
|
+
</div>
|
|
585
668
|
</div>
|
|
586
669
|
{/if}
|
|
587
670
|
</div>
|
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { getChatProvider, getChatContext, setChatContext } from '@svadmin/core';
|
|
3
|
+
import type { ChatMessage, ChatAction } from '@svadmin/core';
|
|
4
|
+
import { t } from '@svadmin/core/i18n';
|
|
5
|
+
import { useParsed } from '@svadmin/core';
|
|
6
|
+
import { fly, fade, scale } from 'svelte/transition';
|
|
7
|
+
import { Button } from './ui/button/index.js';
|
|
8
|
+
import TooltipButton from './TooltipButton.svelte';
|
|
9
|
+
import { MessageCircle, X, Minus, Send, Loader2, Bot, Trash2 } from 'lucide-svelte';
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
/** localStorage key for chat history persistence. Set to '' to disable. */
|
|
13
|
+
persistKey?: string;
|
|
14
|
+
/** Custom persistence callback — called when messages change */
|
|
15
|
+
onPersist?: (messages: ChatMessage[]) => void;
|
|
16
|
+
/** Custom restore callback — called on mount to load history */
|
|
17
|
+
onRestore?: () => ChatMessage[];
|
|
18
|
+
/** Callback when user clicks an action button in an assistant message */
|
|
19
|
+
onAction?: (action: ChatAction, message: ChatMessage) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const {
|
|
23
|
+
persistKey = 'svadmin-chat',
|
|
24
|
+
onPersist,
|
|
25
|
+
onRestore,
|
|
26
|
+
onAction,
|
|
27
|
+
}: Props = $props();
|
|
28
|
+
|
|
29
|
+
let open = $state(false);
|
|
30
|
+
let minimized = $state(false);
|
|
31
|
+
let inputValue = $state('');
|
|
32
|
+
let messages = $state<ChatMessage[]>([]);
|
|
33
|
+
let isStreaming = $state(false);
|
|
34
|
+
let messagesContainer: HTMLDivElement | undefined = $state();
|
|
35
|
+
let abortController: AbortController | null = null;
|
|
36
|
+
let messageIdCounter = $state(0);
|
|
37
|
+
let initialized = $state(false);
|
|
38
|
+
|
|
39
|
+
const provider = $derived(getChatProvider());
|
|
40
|
+
const parsed = useParsed();
|
|
41
|
+
|
|
42
|
+
// ─── Auto-update ChatContext from current route ─────────────
|
|
43
|
+
$effect(() => {
|
|
44
|
+
setChatContext({
|
|
45
|
+
currentResource: parsed.resource,
|
|
46
|
+
selectedRecordId: parsed.id,
|
|
47
|
+
currentView: parsed.action as 'list' | 'edit' | 'create' | 'show' | undefined,
|
|
48
|
+
pathname: `/${parsed.resource ?? ''}${parsed.action ? '/' + parsed.action : ''}${parsed.id ? '/' + parsed.id : ''}`,
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// ─── Restore persisted history on mount ─────────────────────
|
|
53
|
+
$effect(() => {
|
|
54
|
+
if (initialized) return;
|
|
55
|
+
initialized = true;
|
|
56
|
+
if (onRestore) {
|
|
57
|
+
messages = onRestore();
|
|
58
|
+
} else if (persistKey) {
|
|
59
|
+
try {
|
|
60
|
+
const stored = localStorage.getItem(persistKey);
|
|
61
|
+
if (stored) {
|
|
62
|
+
const parsed = JSON.parse(stored) as ChatMessage[];
|
|
63
|
+
if (Array.isArray(parsed) && parsed.length > 0) {
|
|
64
|
+
messages = parsed;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
// ignore corrupt data
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// ─── Persist on change (debounced) ──────────────────────────
|
|
74
|
+
let persistTimer: ReturnType<typeof setTimeout> | null = null;
|
|
75
|
+
|
|
76
|
+
$effect(() => {
|
|
77
|
+
// Access `messages` to subscribe to changes
|
|
78
|
+
const snapshot = messages;
|
|
79
|
+
if (!initialized) return;
|
|
80
|
+
|
|
81
|
+
if (persistTimer) clearTimeout(persistTimer);
|
|
82
|
+
persistTimer = setTimeout(() => {
|
|
83
|
+
if (onPersist) {
|
|
84
|
+
onPersist(snapshot);
|
|
85
|
+
} else if (persistKey) {
|
|
86
|
+
try {
|
|
87
|
+
localStorage.setItem(persistKey, JSON.stringify(snapshot));
|
|
88
|
+
} catch {
|
|
89
|
+
// storage full or unavailable
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}, 300);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
function genId(): string {
|
|
96
|
+
messageIdCounter++;
|
|
97
|
+
return `msg-${Date.now()}-${messageIdCounter}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function scrollToBottom() {
|
|
101
|
+
requestAnimationFrame(() => {
|
|
102
|
+
if (messagesContainer) {
|
|
103
|
+
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Build a system message with the current admin context */
|
|
109
|
+
function buildContextSystemMessage(): ChatMessage | null {
|
|
110
|
+
const ctx = getChatContext();
|
|
111
|
+
if (!ctx.currentResource) return null;
|
|
112
|
+
|
|
113
|
+
const parts: string[] = [];
|
|
114
|
+
parts.push(`Resource: ${ctx.currentResource}`);
|
|
115
|
+
if (ctx.currentView) parts.push(`View: ${ctx.currentView}`);
|
|
116
|
+
if (ctx.selectedRecordId) parts.push(`Selected Record ID: ${ctx.selectedRecordId}`);
|
|
117
|
+
if (ctx.pathname) parts.push(`Path: ${ctx.pathname}`);
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
id: 'ctx-system',
|
|
121
|
+
role: 'system',
|
|
122
|
+
content: `[Admin Context] ${parts.join(' | ')}`,
|
|
123
|
+
timestamp: Date.now(),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function sendMessage() {
|
|
128
|
+
if (!inputValue.trim() || isStreaming || !provider) return;
|
|
129
|
+
|
|
130
|
+
const userMsg: ChatMessage = {
|
|
131
|
+
id: genId(),
|
|
132
|
+
role: 'user',
|
|
133
|
+
content: inputValue.trim(),
|
|
134
|
+
timestamp: Date.now(),
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
messages = [...messages, userMsg];
|
|
138
|
+
inputValue = '';
|
|
139
|
+
scrollToBottom();
|
|
140
|
+
|
|
141
|
+
// Create placeholder assistant message
|
|
142
|
+
const assistantMsg: ChatMessage = {
|
|
143
|
+
id: genId(),
|
|
144
|
+
role: 'assistant',
|
|
145
|
+
content: '',
|
|
146
|
+
timestamp: Date.now(),
|
|
147
|
+
};
|
|
148
|
+
messages = [...messages, assistantMsg];
|
|
149
|
+
isStreaming = true;
|
|
150
|
+
scrollToBottom();
|
|
151
|
+
|
|
152
|
+
abortController = new AbortController();
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
// Build message list with context system message prepended
|
|
156
|
+
const contextMsg = buildContextSystemMessage();
|
|
157
|
+
const allMessages = messages.filter((m) => m.content);
|
|
158
|
+
const messagesToSend = contextMsg ? [contextMsg, ...allMessages] : allMessages;
|
|
159
|
+
|
|
160
|
+
const result = provider.sendMessage(
|
|
161
|
+
messagesToSend,
|
|
162
|
+
{ signal: abortController.signal },
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
if (result && typeof result === 'object' && Symbol.asyncIterator in result) {
|
|
166
|
+
// Streaming response
|
|
167
|
+
for await (const chunk of result as AsyncGenerator<string>) {
|
|
168
|
+
assistantMsg.content += chunk;
|
|
169
|
+
messages = [...messages.slice(0, -1), { ...assistantMsg }];
|
|
170
|
+
scrollToBottom();
|
|
171
|
+
}
|
|
172
|
+
} else {
|
|
173
|
+
// Non-streaming response
|
|
174
|
+
const text = await (result as Promise<string>);
|
|
175
|
+
assistantMsg.content = text;
|
|
176
|
+
messages = [...messages.slice(0, -1), { ...assistantMsg }];
|
|
177
|
+
scrollToBottom();
|
|
178
|
+
}
|
|
179
|
+
} catch (err: unknown) {
|
|
180
|
+
if (err instanceof Error && err.name === 'AbortError') return;
|
|
181
|
+
assistantMsg.content = t('chat.error') || 'Sorry, something went wrong. Please try again.';
|
|
182
|
+
messages = [...messages.slice(0, -1), { ...assistantMsg }];
|
|
183
|
+
} finally {
|
|
184
|
+
isStreaming = false;
|
|
185
|
+
abortController = null;
|
|
186
|
+
scrollToBottom();
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function stopStreaming() {
|
|
191
|
+
if (abortController) {
|
|
192
|
+
abortController.abort();
|
|
193
|
+
isStreaming = false;
|
|
194
|
+
abortController = null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function clearChat() {
|
|
199
|
+
messages = [];
|
|
200
|
+
isStreaming = false;
|
|
201
|
+
if (abortController) {
|
|
202
|
+
abortController.abort();
|
|
203
|
+
abortController = null;
|
|
204
|
+
}
|
|
205
|
+
// Also clear persisted data
|
|
206
|
+
if (persistKey) {
|
|
207
|
+
try { localStorage.removeItem(persistKey); } catch { /* noop */ }
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
212
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
213
|
+
e.preventDefault();
|
|
214
|
+
sendMessage();
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function handleGlobalKeydown(e: KeyboardEvent) {
|
|
219
|
+
if (e.ctrlKey && e.shiftKey && e.key === 'L') {
|
|
220
|
+
e.preventDefault();
|
|
221
|
+
if (open && !minimized) {
|
|
222
|
+
open = false;
|
|
223
|
+
} else {
|
|
224
|
+
open = true;
|
|
225
|
+
minimized = false;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function handleAction(action: ChatAction, msg: ChatMessage) {
|
|
231
|
+
if (onAction) {
|
|
232
|
+
onAction(action, msg);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Simple markdown→html: bold, italic, code blocks, inline code, line breaks */
|
|
237
|
+
function renderMarkdown(text: string): string {
|
|
238
|
+
return text
|
|
239
|
+
.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre class="chat-code-block"><code>$2</code></pre>')
|
|
240
|
+
.replace(/`([^`]+)`/g, '<code class="chat-inline-code">$1</code>')
|
|
241
|
+
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
|
242
|
+
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
|
243
|
+
.replace(/\n/g, '<br>');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const suggestions = $derived([
|
|
247
|
+
t('chat.suggestion1') || 'How do I create a new resource?',
|
|
248
|
+
t('chat.suggestion2') || 'Explain the data model',
|
|
249
|
+
t('chat.suggestion3') || 'Help me write a filter query',
|
|
250
|
+
]);
|
|
251
|
+
</script>
|
|
252
|
+
|
|
253
|
+
<svelte:window onkeydown={handleGlobalKeydown} />
|
|
254
|
+
|
|
255
|
+
{#if provider}
|
|
256
|
+
<!-- FAB Button -->
|
|
257
|
+
{#if !open}
|
|
258
|
+
<div transition:scale={{ duration: 200 }}>
|
|
259
|
+
<TooltipButton
|
|
260
|
+
tooltip={t('chat.title') || 'AI Assistant'}
|
|
261
|
+
variant="default"
|
|
262
|
+
size="icon"
|
|
263
|
+
class="fixed bottom-6 right-6 z-[9998] h-12 w-12 rounded-full shadow-xl hover:shadow-2xl hover:scale-110 transition-all bg-primary text-primary-foreground"
|
|
264
|
+
onclick={() => { open = true; minimized = false; }}
|
|
265
|
+
>
|
|
266
|
+
<MessageCircle class="h-5 w-5" />
|
|
267
|
+
{#if messages.length > 0}
|
|
268
|
+
<span class="absolute -top-1 -right-1 h-4 w-4 rounded-full bg-destructive text-[10px] font-bold text-destructive-foreground flex items-center justify-center">
|
|
269
|
+
{messages.filter(m => m.role === 'assistant').length}
|
|
270
|
+
</span>
|
|
271
|
+
{/if}
|
|
272
|
+
</TooltipButton>
|
|
273
|
+
</div>
|
|
274
|
+
{/if}
|
|
275
|
+
|
|
276
|
+
<!-- Chat Panel -->
|
|
277
|
+
{#if open}
|
|
278
|
+
<div
|
|
279
|
+
class="fixed bottom-6 right-6 z-[9998] flex flex-col rounded-2xl border bg-card shadow-2xl overflow-hidden"
|
|
280
|
+
class:w-[400px]={!minimized}
|
|
281
|
+
class:h-[560px]={!minimized}
|
|
282
|
+
class:w-[280px]={minimized}
|
|
283
|
+
class:h-auto={minimized}
|
|
284
|
+
transition:fly={{ y: 300, duration: 250 }}
|
|
285
|
+
>
|
|
286
|
+
<!-- Header -->
|
|
287
|
+
<div class="flex items-center justify-between px-4 py-3 bg-primary text-primary-foreground shrink-0">
|
|
288
|
+
<div class="flex items-center gap-2.5">
|
|
289
|
+
<div class="flex items-center justify-center h-7 w-7 rounded-full bg-primary-foreground/20">
|
|
290
|
+
<Bot class="h-4 w-4" />
|
|
291
|
+
</div>
|
|
292
|
+
<div>
|
|
293
|
+
<h3 class="text-sm font-semibold leading-none">
|
|
294
|
+
{t('chat.title') || 'AI Assistant'}
|
|
295
|
+
</h3>
|
|
296
|
+
{#if isStreaming}
|
|
297
|
+
<p class="text-[10px] opacity-80 mt-0.5">{t('chat.typing') || 'Typing...'}</p>
|
|
298
|
+
{/if}
|
|
299
|
+
</div>
|
|
300
|
+
</div>
|
|
301
|
+
<div class="flex items-center gap-0.5">
|
|
302
|
+
{#if messages.length > 0}
|
|
303
|
+
<TooltipButton
|
|
304
|
+
tooltip={t('chat.clear') || 'Clear'}
|
|
305
|
+
variant="ghost"
|
|
306
|
+
size="icon"
|
|
307
|
+
class="h-7 w-7 text-primary-foreground/70 hover:text-primary-foreground hover:bg-primary-foreground/10"
|
|
308
|
+
onclick={clearChat}
|
|
309
|
+
>
|
|
310
|
+
<Trash2 class="h-3.5 w-3.5" />
|
|
311
|
+
</TooltipButton>
|
|
312
|
+
{/if}
|
|
313
|
+
<TooltipButton
|
|
314
|
+
tooltip={minimized ? (t('common.expand') || 'Expand') : (t('common.collapse') || 'Minimize')}
|
|
315
|
+
variant="ghost"
|
|
316
|
+
size="icon"
|
|
317
|
+
class="h-7 w-7 text-primary-foreground/70 hover:text-primary-foreground hover:bg-primary-foreground/10"
|
|
318
|
+
onclick={() => minimized = !minimized}
|
|
319
|
+
>
|
|
320
|
+
<Minus class="h-3.5 w-3.5" />
|
|
321
|
+
</TooltipButton>
|
|
322
|
+
<TooltipButton
|
|
323
|
+
tooltip={t('common.close') || 'Close'}
|
|
324
|
+
variant="ghost"
|
|
325
|
+
size="icon"
|
|
326
|
+
class="h-7 w-7 text-primary-foreground/70 hover:text-primary-foreground hover:bg-primary-foreground/10"
|
|
327
|
+
onclick={() => open = false}
|
|
328
|
+
>
|
|
329
|
+
<X class="h-3.5 w-3.5" />
|
|
330
|
+
</TooltipButton>
|
|
331
|
+
</div>
|
|
332
|
+
</div>
|
|
333
|
+
|
|
334
|
+
{#if !minimized}
|
|
335
|
+
<!-- Messages -->
|
|
336
|
+
<div
|
|
337
|
+
bind:this={messagesContainer}
|
|
338
|
+
class="flex-1 overflow-y-auto p-4 space-y-3 scroll-smooth"
|
|
339
|
+
>
|
|
340
|
+
{#if messages.length === 0}
|
|
341
|
+
<!-- Welcome state -->
|
|
342
|
+
<div class="flex flex-col items-center justify-center h-full text-center px-4" in:fade={{ duration: 200 }}>
|
|
343
|
+
<div class="h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center mb-4">
|
|
344
|
+
<Bot class="h-6 w-6 text-primary" />
|
|
345
|
+
</div>
|
|
346
|
+
<h4 class="text-sm font-semibold text-foreground mb-1">
|
|
347
|
+
{t('chat.welcome') || 'How can I help?'}
|
|
348
|
+
</h4>
|
|
349
|
+
<p class="text-xs text-muted-foreground mb-4">
|
|
350
|
+
{t('chat.welcomeDesc') || 'Ask me anything about your admin panel.'}
|
|
351
|
+
</p>
|
|
352
|
+
<div class="flex flex-col gap-2 w-full max-w-[240px]">
|
|
353
|
+
{#each suggestions as suggestion}
|
|
354
|
+
<button
|
|
355
|
+
class="text-left text-xs px-3 py-2 rounded-lg border border-border hover:bg-accent hover:text-accent-foreground transition-colors cursor-pointer"
|
|
356
|
+
onclick={() => { inputValue = suggestion; }}
|
|
357
|
+
>
|
|
358
|
+
{suggestion}
|
|
359
|
+
</button>
|
|
360
|
+
{/each}
|
|
361
|
+
</div>
|
|
362
|
+
</div>
|
|
363
|
+
{:else}
|
|
364
|
+
{#each messages as msg (msg.id)}
|
|
365
|
+
<div
|
|
366
|
+
class="flex {msg.role === 'user' ? 'justify-end' : 'justify-start'}"
|
|
367
|
+
in:fly={{ y: 10, duration: 150 }}
|
|
368
|
+
>
|
|
369
|
+
<div
|
|
370
|
+
class="max-w-[85%] rounded-2xl px-3.5 py-2.5 text-sm leading-relaxed {msg.role === 'user'
|
|
371
|
+
? 'bg-primary text-primary-foreground rounded-br-md'
|
|
372
|
+
: 'bg-muted text-foreground rounded-bl-md'}"
|
|
373
|
+
>
|
|
374
|
+
{#if msg.role === 'assistant'}
|
|
375
|
+
{#if msg.content}
|
|
376
|
+
<div class="chat-markdown">{@html renderMarkdown(msg.content)}</div>
|
|
377
|
+
<!-- Action Buttons -->
|
|
378
|
+
{#if msg.actions && msg.actions.length > 0}
|
|
379
|
+
<div class="flex flex-wrap gap-1.5 mt-2 pt-2 border-t border-border/50">
|
|
380
|
+
{#each msg.actions as action}
|
|
381
|
+
<Button
|
|
382
|
+
variant={action.variant ?? 'outline'}
|
|
383
|
+
size="sm"
|
|
384
|
+
class="h-7 text-xs"
|
|
385
|
+
onclick={() => handleAction(action, msg)}
|
|
386
|
+
>
|
|
387
|
+
{action.label}
|
|
388
|
+
</Button>
|
|
389
|
+
{/each}
|
|
390
|
+
</div>
|
|
391
|
+
{/if}
|
|
392
|
+
{:else}
|
|
393
|
+
<div class="flex items-center gap-1.5 text-muted-foreground">
|
|
394
|
+
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
|
395
|
+
<span class="text-xs">{t('chat.thinking') || 'Thinking...'}</span>
|
|
396
|
+
</div>
|
|
397
|
+
{/if}
|
|
398
|
+
{:else}
|
|
399
|
+
{msg.content}
|
|
400
|
+
{/if}
|
|
401
|
+
</div>
|
|
402
|
+
</div>
|
|
403
|
+
{/each}
|
|
404
|
+
{/if}
|
|
405
|
+
</div>
|
|
406
|
+
|
|
407
|
+
<!-- Input area -->
|
|
408
|
+
<div class="shrink-0 border-t bg-card p-3">
|
|
409
|
+
<div class="flex items-end gap-2">
|
|
410
|
+
<textarea
|
|
411
|
+
bind:value={inputValue}
|
|
412
|
+
onkeydown={handleKeydown}
|
|
413
|
+
placeholder={t('chat.placeholder') || 'Type a message...'}
|
|
414
|
+
rows={1}
|
|
415
|
+
disabled={isStreaming}
|
|
416
|
+
class="flex-1 resize-none rounded-xl border border-input bg-background px-3 py-2.5 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1 disabled:opacity-50 max-h-[100px] min-h-[40px]"
|
|
417
|
+
></textarea>
|
|
418
|
+
{#if isStreaming}
|
|
419
|
+
<Button
|
|
420
|
+
variant="destructive"
|
|
421
|
+
size="icon"
|
|
422
|
+
class="h-10 w-10 rounded-xl shrink-0"
|
|
423
|
+
onclick={stopStreaming}
|
|
424
|
+
>
|
|
425
|
+
<X class="h-4 w-4" />
|
|
426
|
+
</Button>
|
|
427
|
+
{:else}
|
|
428
|
+
<Button
|
|
429
|
+
variant="default"
|
|
430
|
+
size="icon"
|
|
431
|
+
class="h-10 w-10 rounded-xl shrink-0"
|
|
432
|
+
onclick={sendMessage}
|
|
433
|
+
disabled={!inputValue.trim()}
|
|
434
|
+
>
|
|
435
|
+
<Send class="h-4 w-4" />
|
|
436
|
+
</Button>
|
|
437
|
+
{/if}
|
|
438
|
+
</div>
|
|
439
|
+
<p class="text-[10px] text-muted-foreground mt-1.5 text-center">
|
|
440
|
+
<kbd class="px-1 py-0.5 rounded border border-border bg-muted text-[9px] font-mono">Ctrl+Shift+L</kbd>
|
|
441
|
+
{t('chat.shortcutHint') || 'to toggle'}
|
|
442
|
+
</p>
|
|
443
|
+
</div>
|
|
444
|
+
{/if}
|
|
445
|
+
</div>
|
|
446
|
+
{/if}
|
|
447
|
+
{/if}
|
|
448
|
+
|
|
449
|
+
<style>
|
|
450
|
+
:global(.chat-markdown pre.chat-code-block) {
|
|
451
|
+
background: hsl(var(--muted));
|
|
452
|
+
border-radius: 0.5rem;
|
|
453
|
+
padding: 0.75rem;
|
|
454
|
+
margin: 0.5rem 0;
|
|
455
|
+
overflow-x: auto;
|
|
456
|
+
font-size: 0.75rem;
|
|
457
|
+
line-height: 1.5;
|
|
458
|
+
}
|
|
459
|
+
:global(.chat-markdown code.chat-inline-code) {
|
|
460
|
+
background: hsl(var(--muted));
|
|
461
|
+
padding: 0.125rem 0.375rem;
|
|
462
|
+
border-radius: 0.25rem;
|
|
463
|
+
font-size: 0.8125em;
|
|
464
|
+
font-family: ui-monospace, monospace;
|
|
465
|
+
}
|
|
466
|
+
:global(.chat-markdown strong) {
|
|
467
|
+
font-weight: 600;
|
|
468
|
+
}
|
|
469
|
+
</style>
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import Toast from './Toast.svelte';
|
|
6
6
|
import Header from './Header.svelte';
|
|
7
7
|
import CommandPalette from './CommandPalette.svelte';
|
|
8
|
+
import ChatDialog from './ChatDialog.svelte';
|
|
8
9
|
import TooltipButton from './TooltipButton.svelte';
|
|
9
10
|
import { t } from '@svadmin/core/i18n';
|
|
10
11
|
import { getAuthProvider } from '@svadmin/core';
|
|
@@ -124,6 +125,7 @@
|
|
|
124
125
|
</div>
|
|
125
126
|
</div>
|
|
126
127
|
<CommandPalette bind:open={commandOpen} />
|
|
128
|
+
<ChatDialog />
|
|
127
129
|
<Toast />
|
|
128
130
|
{/if}
|
|
129
131
|
</div>
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Marked } from 'marked';
|
|
3
|
+
import { markedHighlight } from 'marked-highlight';
|
|
4
|
+
import hljs from 'highlight.js';
|
|
5
|
+
import 'highlight.js/styles/github-dark.css'; // or your preferred theme
|
|
6
|
+
import DOMPurify from 'isomorphic-dompurify';
|
|
7
|
+
import { Check, Copy } from 'lucide-svelte';
|
|
8
|
+
import { Button } from './ui/button/index.js';
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
/** The raw markdown string to render */
|
|
12
|
+
content: string;
|
|
13
|
+
/** Whether to enable streaming/typing effect styling */
|
|
14
|
+
streaming?: boolean;
|
|
15
|
+
/** Custom class for the container */
|
|
16
|
+
class?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let { content, streaming = false, class: className = '' }: Props = $props();
|
|
20
|
+
|
|
21
|
+
// Configure marked with syntax highlighting
|
|
22
|
+
const marked = new Marked(
|
|
23
|
+
markedHighlight({
|
|
24
|
+
langPrefix: 'hljs language-',
|
|
25
|
+
highlight(code, lang) {
|
|
26
|
+
const language = hljs.getLanguage(lang) ? lang : 'plaintext';
|
|
27
|
+
return hljs.highlight(code, { language }).value;
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
// Render HTML safely
|
|
33
|
+
const html = $derived(DOMPurify.sanitize(marked.parse(content || '') as string));
|
|
34
|
+
|
|
35
|
+
// Handle copy code blocks
|
|
36
|
+
let copiedBlock = $state<string | null>(null);
|
|
37
|
+
|
|
38
|
+
function handleCopy(event: MouseEvent) {
|
|
39
|
+
const target = event.target as HTMLElement;
|
|
40
|
+
const button = target.closest('.copy-btn');
|
|
41
|
+
if (!button) return;
|
|
42
|
+
|
|
43
|
+
const pre = button.closest('.code-block-wrapper')?.querySelector('pre');
|
|
44
|
+
if (!pre) return;
|
|
45
|
+
|
|
46
|
+
const code = pre.textContent || '';
|
|
47
|
+
navigator.clipboard.writeText(code);
|
|
48
|
+
|
|
49
|
+
const id = Math.random().toString(36);
|
|
50
|
+
copiedBlock = id;
|
|
51
|
+
button.setAttribute('data-copied-id', id);
|
|
52
|
+
|
|
53
|
+
setTimeout(() => {
|
|
54
|
+
if (copiedBlock === id) copiedBlock = null;
|
|
55
|
+
}, 2000);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Inject copy buttons into the HTML after rendering
|
|
59
|
+
// We do this via action instead of raw string manipulation for safety
|
|
60
|
+
function enhanceCodeBlocks(node: HTMLElement, _: string) {
|
|
61
|
+
return {
|
|
62
|
+
update(newHtml: string) {
|
|
63
|
+
// Find all pre > code blocks that don't have wrappers yet
|
|
64
|
+
const pres = node.querySelectorAll('pre:not(.enhanced)');
|
|
65
|
+
pres.forEach(pre => {
|
|
66
|
+
pre.classList.add('enhanced');
|
|
67
|
+
const wrapper = document.createElement('div');
|
|
68
|
+
wrapper.className = 'code-block-wrapper group relative my-4 rounded-md bg-zinc-950';
|
|
69
|
+
|
|
70
|
+
const header = document.createElement('div');
|
|
71
|
+
header.className = 'flex items-center justify-between px-4 py-2 text-xs text-zinc-400 border-b border-zinc-800';
|
|
72
|
+
|
|
73
|
+
const lang = Array.from(pre.querySelector('code')?.classList || []).find(c => c.startsWith('language-'))?.replace('language-', '') || 'Code';
|
|
74
|
+
header.innerHTML = `
|
|
75
|
+
<span>${lang}</span>
|
|
76
|
+
<button class="copy-btn hover:text-white transition-colors flex items-center gap-1">
|
|
77
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
|
|
78
|
+
</button>
|
|
79
|
+
`;
|
|
80
|
+
|
|
81
|
+
pre.replaceWith(wrapper);
|
|
82
|
+
wrapper.appendChild(header);
|
|
83
|
+
|
|
84
|
+
const scrollContainer = document.createElement('div');
|
|
85
|
+
scrollContainer.className = 'overflow-x-auto p-4';
|
|
86
|
+
scrollContainer.appendChild(pre);
|
|
87
|
+
wrapper.appendChild(scrollContainer);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Add event listener for copy buttons
|
|
91
|
+
node.addEventListener('click', handleCopy);
|
|
92
|
+
},
|
|
93
|
+
destroy() {
|
|
94
|
+
node.removeEventListener('click', handleCopy);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
</script>
|
|
99
|
+
|
|
100
|
+
<div
|
|
101
|
+
class="prose prose-sm dark:prose-invert max-w-none break-words {className}"
|
|
102
|
+
class:streaming={streaming}
|
|
103
|
+
use:enhanceCodeBlocks={html}
|
|
104
|
+
>
|
|
105
|
+
{@html html}
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
<style>
|
|
109
|
+
/* Base Markdown Styles */
|
|
110
|
+
:global(.prose) {
|
|
111
|
+
line-height: 1.6;
|
|
112
|
+
}
|
|
113
|
+
:global(.prose p) {
|
|
114
|
+
margin-top: 0.5em;
|
|
115
|
+
margin-bottom: 0.5em;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/* Streaming cursor effect */
|
|
119
|
+
.streaming > :global(*:last-child)::after {
|
|
120
|
+
content: '▋';
|
|
121
|
+
display: inline-block;
|
|
122
|
+
animation: blink 1s step-start infinite;
|
|
123
|
+
margin-left: 2px;
|
|
124
|
+
vertical-align: baseline;
|
|
125
|
+
color: currentColor;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
@keyframes blink {
|
|
129
|
+
50% { opacity: 0; }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/* Handle copied state for dynamically injected buttons */
|
|
133
|
+
:global(.copy-btn[data-copied-id]) {
|
|
134
|
+
color: #10b981; /* Tailwind emerald-500 */
|
|
135
|
+
}
|
|
136
|
+
:global(.copy-btn[data-copied-id] svg) {
|
|
137
|
+
/* Swap to Check icon */
|
|
138
|
+
content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="%2310b981" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>');
|
|
139
|
+
}
|
|
140
|
+
</style>
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
import { cn, type WithElementRef } from "../../../utils.js";
|
|
3
3
|
import type { HTMLSelectAttributes } from "svelte/elements";
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
// Use HTMLElement instead of HTMLSelectElement to avoid TS error:
|
|
6
|
+
// HTMLSelectElement.remove() returns void, incompatible with HTMLElement's Element return
|
|
7
|
+
type Props = WithElementRef<HTMLSelectAttributes, HTMLElement> & {
|
|
6
8
|
placeholder?: string;
|
|
7
9
|
};
|
|
8
10
|
|
package/src/index.ts
CHANGED
|
@@ -46,7 +46,7 @@ export { default as StepsForm } from './components/StepsForm.svelte';
|
|
|
46
46
|
export { default as InfiniteList } from './components/InfiniteList.svelte';
|
|
47
47
|
export { default as ComboboxField } from './components/ComboboxField.svelte';
|
|
48
48
|
export { default as PasswordInput } from './components/PasswordInput.svelte';
|
|
49
|
-
|
|
49
|
+
export { default as ChatDialog } from './components/ChatDialog.svelte';
|
|
50
50
|
// Field display components
|
|
51
51
|
export { default as NumberField } from './components/fields/NumberField.svelte';
|
|
52
52
|
export { default as DateField } from './components/fields/DateField.svelte';
|