@svadmin/ui 0.8.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
CHANGED
|
@@ -1,12 +1,31 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import { getChatProvider } from '@svadmin/core';
|
|
3
|
-
import type { ChatMessage } from '@svadmin/core';
|
|
2
|
+
import { getChatProvider, getChatContext, setChatContext } from '@svadmin/core';
|
|
3
|
+
import type { ChatMessage, ChatAction } from '@svadmin/core';
|
|
4
4
|
import { t } from '@svadmin/core/i18n';
|
|
5
|
+
import { useParsed } from '@svadmin/core';
|
|
5
6
|
import { fly, fade, scale } from 'svelte/transition';
|
|
6
7
|
import { Button } from './ui/button/index.js';
|
|
7
8
|
import TooltipButton from './TooltipButton.svelte';
|
|
8
9
|
import { MessageCircle, X, Minus, Send, Loader2, Bot, Trash2 } from 'lucide-svelte';
|
|
9
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
|
+
|
|
10
29
|
let open = $state(false);
|
|
11
30
|
let minimized = $state(false);
|
|
12
31
|
let inputValue = $state('');
|
|
@@ -15,8 +34,63 @@
|
|
|
15
34
|
let messagesContainer: HTMLDivElement | undefined = $state();
|
|
16
35
|
let abortController: AbortController | null = null;
|
|
17
36
|
let messageIdCounter = $state(0);
|
|
37
|
+
let initialized = $state(false);
|
|
18
38
|
|
|
19
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
|
+
});
|
|
20
94
|
|
|
21
95
|
function genId(): string {
|
|
22
96
|
messageIdCounter++;
|
|
@@ -31,6 +105,25 @@
|
|
|
31
105
|
});
|
|
32
106
|
}
|
|
33
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
|
+
|
|
34
127
|
async function sendMessage() {
|
|
35
128
|
if (!inputValue.trim() || isStreaming || !provider) return;
|
|
36
129
|
|
|
@@ -59,8 +152,13 @@
|
|
|
59
152
|
abortController = new AbortController();
|
|
60
153
|
|
|
61
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
|
+
|
|
62
160
|
const result = provider.sendMessage(
|
|
63
|
-
|
|
161
|
+
messagesToSend,
|
|
64
162
|
{ signal: abortController.signal },
|
|
65
163
|
);
|
|
66
164
|
|
|
@@ -104,6 +202,10 @@
|
|
|
104
202
|
abortController.abort();
|
|
105
203
|
abortController = null;
|
|
106
204
|
}
|
|
205
|
+
// Also clear persisted data
|
|
206
|
+
if (persistKey) {
|
|
207
|
+
try { localStorage.removeItem(persistKey); } catch { /* noop */ }
|
|
208
|
+
}
|
|
107
209
|
}
|
|
108
210
|
|
|
109
211
|
function handleKeydown(e: KeyboardEvent) {
|
|
@@ -125,6 +227,12 @@
|
|
|
125
227
|
}
|
|
126
228
|
}
|
|
127
229
|
|
|
230
|
+
function handleAction(action: ChatAction, msg: ChatMessage) {
|
|
231
|
+
if (onAction) {
|
|
232
|
+
onAction(action, msg);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
128
236
|
/** Simple markdown→html: bold, italic, code blocks, inline code, line breaks */
|
|
129
237
|
function renderMarkdown(text: string): string {
|
|
130
238
|
return text
|
|
@@ -266,6 +374,21 @@
|
|
|
266
374
|
{#if msg.role === 'assistant'}
|
|
267
375
|
{#if msg.content}
|
|
268
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}
|
|
269
392
|
{:else}
|
|
270
393
|
<div class="flex items-center gap-1.5 text-muted-foreground">
|
|
271
394
|
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
|
@@ -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>
|