@svadmin/ui 0.8.0 → 0.10.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/AICommandBar.svelte +180 -0
- package/src/components/AnomalyBadge.svelte +65 -0
- package/src/components/ChatDialog.svelte +126 -3
- package/src/components/CopilotPanel.svelte +163 -0
- package/src/components/DevTools.svelte +2 -2
- package/src/components/InsightCard.svelte +98 -0
- package/src/components/Layout.svelte +1 -2
- package/src/components/MarkdownRenderer.svelte +144 -0
- package/src/components/SmartSuggest.svelte +158 -0
- package/src/components/VoiceInput.svelte +100 -0
- package/src/components/ui/button/button.svelte +10 -2
- package/src/components/ui/pagination/pagination-ellipsis.svelte +1 -1
- package/src/components/ui/sheet/sheet.svelte +1 -0
- package/src/index.ts +8 -1
package/package.json
CHANGED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { getResources, getChatProvider } from '@svadmin/core';
|
|
3
|
+
import { navigate } from '@svadmin/core/router';
|
|
4
|
+
import { t } from '@svadmin/core/i18n';
|
|
5
|
+
import { toggleTheme } from '@svadmin/core';
|
|
6
|
+
import { Command } from 'cmdk-sv';
|
|
7
|
+
import * as Dialog from './ui/dialog/index.js';
|
|
8
|
+
import { Search, LayoutDashboard, Plus, Sun, FileText, Sparkles, Loader2, Send } from 'lucide-svelte';
|
|
9
|
+
import MarkdownRenderer from './MarkdownRenderer.svelte';
|
|
10
|
+
|
|
11
|
+
let { open = $bindable(false) } = $props<{ open?: boolean }>();
|
|
12
|
+
let searchValue = $state('');
|
|
13
|
+
|
|
14
|
+
const resources = getResources();
|
|
15
|
+
const provider = $derived(getChatProvider());
|
|
16
|
+
|
|
17
|
+
// AI State
|
|
18
|
+
let aiMode = $state(false);
|
|
19
|
+
let aiResponse = $state('');
|
|
20
|
+
let isPredicting = $state(false);
|
|
21
|
+
let abortController: AbortController | null = null;
|
|
22
|
+
|
|
23
|
+
function close() {
|
|
24
|
+
open = false;
|
|
25
|
+
searchValue = '';
|
|
26
|
+
aiMode = false;
|
|
27
|
+
aiResponse = '';
|
|
28
|
+
if (abortController) {
|
|
29
|
+
abortController.abort();
|
|
30
|
+
abortController = null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function act(fn: () => void) {
|
|
35
|
+
fn();
|
|
36
|
+
close();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function askAI() {
|
|
40
|
+
if (!provider || !searchValue.trim()) return;
|
|
41
|
+
|
|
42
|
+
aiMode = true;
|
|
43
|
+
isPredicting = true;
|
|
44
|
+
aiResponse = '';
|
|
45
|
+
|
|
46
|
+
if (abortController) abortController.abort();
|
|
47
|
+
abortController = new AbortController();
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const result = provider.sendMessage(
|
|
51
|
+
[{ id: 'cmd', role: 'user', content: searchValue, timestamp: Date.now() }],
|
|
52
|
+
{ signal: abortController.signal }
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
if (Symbol.asyncIterator in result) {
|
|
56
|
+
for await (const chunk of result as AsyncIterable<string>) {
|
|
57
|
+
aiResponse += chunk;
|
|
58
|
+
}
|
|
59
|
+
} else {
|
|
60
|
+
aiResponse = await result;
|
|
61
|
+
}
|
|
62
|
+
} catch (err: any) {
|
|
63
|
+
if (err.name !== 'AbortError') {
|
|
64
|
+
aiResponse = 'Failed to get response from AI.';
|
|
65
|
+
}
|
|
66
|
+
} finally {
|
|
67
|
+
isPredicting = false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function handleKeyDown(e: KeyboardEvent) {
|
|
72
|
+
if (e.key === 'Enter' && searchValue.trim() && !aiMode) {
|
|
73
|
+
// If pressing enter on an active search, cmdk usually handles selection.
|
|
74
|
+
// But if there's no selection or we want to force AI, we can intercept.
|
|
75
|
+
// For simplicity, we trigger AI on Ctrl+Enter
|
|
76
|
+
if (e.ctrlKey || e.metaKey) {
|
|
77
|
+
e.preventDefault();
|
|
78
|
+
askAI();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const itemClass = 'relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50';
|
|
84
|
+
</script>
|
|
85
|
+
|
|
86
|
+
<Dialog.Dialog bind:open onOpenChange={(v: boolean) => !v && close()}>
|
|
87
|
+
<Dialog.DialogContent class="overflow-hidden p-0 sm:max-w-[600px] border-none shadow-2xl">
|
|
88
|
+
<Command.Root
|
|
89
|
+
shouldFilter={!aiMode}
|
|
90
|
+
class="flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground [&_[data-cmdk-group-heading]]:text-muted-foreground [&_[data-cmdk-group-heading]]:px-2 [&_[data-cmdk-group-heading]]:py-1.5 [&_[data-cmdk-group-heading]]:text-xs [&_[data-cmdk-group-heading]]:font-semibold [&_[data-cmdk-group-heading]]:uppercase [&_[data-cmdk-group-heading]]:tracking-wider"
|
|
91
|
+
>
|
|
92
|
+
<div class="flex items-center border-b px-3 h-12 relative" data-cmdk-input-wrapper="">
|
|
93
|
+
{#if aiMode}
|
|
94
|
+
<Sparkles class="mr-2 h-4 w-4 shrink-0 text-amber-500 animate-pulse" />
|
|
95
|
+
{:else}
|
|
96
|
+
<Search class="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
|
97
|
+
{/if}
|
|
98
|
+
<Command.Input
|
|
99
|
+
bind:value={searchValue}
|
|
100
|
+
onkeydown={handleKeyDown}
|
|
101
|
+
placeholder={aiMode ? "Ask AI anything..." : "Search commands or ask AI (Ctrl+Enter)..."}
|
|
102
|
+
class="flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
|
103
|
+
/>
|
|
104
|
+
{#if searchValue.trim() && !aiMode}
|
|
105
|
+
<button
|
|
106
|
+
class="absolute right-3 flex items-center gap-1 text-[10px] font-medium text-amber-500 bg-amber-500/10 hover:bg-amber-500/20 px-2 py-1 rounded transition-colors"
|
|
107
|
+
onclick={askAI}
|
|
108
|
+
>
|
|
109
|
+
<Sparkles class="h-3 w-3" />
|
|
110
|
+
<kbd class="font-sans">Ctrl+Enter</kbd> to Ask AI
|
|
111
|
+
</button>
|
|
112
|
+
{/if}
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
{#if !aiMode}
|
|
116
|
+
<Command.List class="max-h-[360px] overflow-y-auto overflow-x-hidden p-1">
|
|
117
|
+
<Command.Empty class="py-6 text-center text-sm text-muted-foreground">
|
|
118
|
+
No standard commands found. Press <kbd class="px-1 py-0.5 rounded bg-muted border font-sans text-[10px]">Ctrl+Enter</kbd> to ask AI.
|
|
119
|
+
</Command.Empty>
|
|
120
|
+
|
|
121
|
+
<!-- Navigation -->
|
|
122
|
+
<Command.Group heading={t('common.home')}>
|
|
123
|
+
<Command.Item value="home" onSelect={() => act(() => navigate('/'))} class={itemClass}>
|
|
124
|
+
<LayoutDashboard class="h-4 w-4 text-muted-foreground" />
|
|
125
|
+
{t('common.home')}
|
|
126
|
+
</Command.Item>
|
|
127
|
+
{#each resources as r}
|
|
128
|
+
<Command.Item value={r.name} onSelect={() => act(() => navigate(`/${r.name}`))} class={itemClass}>
|
|
129
|
+
<FileText class="h-4 w-4 text-muted-foreground" />
|
|
130
|
+
{r.label}
|
|
131
|
+
</Command.Item>
|
|
132
|
+
{/each}
|
|
133
|
+
</Command.Group>
|
|
134
|
+
|
|
135
|
+
<Command.Separator class="mx-1 my-1 h-px bg-border" />
|
|
136
|
+
|
|
137
|
+
<!-- Actions -->
|
|
138
|
+
<Command.Group heading={t('common.actions')}>
|
|
139
|
+
{#each resources as r}
|
|
140
|
+
<Command.Item value={`create-${r.name}`} onSelect={() => act(() => navigate(`/${r.name}/create`))} class={itemClass}>
|
|
141
|
+
<Plus class="h-4 w-4 text-muted-foreground" />
|
|
142
|
+
{t('common.create')} {r.label}
|
|
143
|
+
</Command.Item>
|
|
144
|
+
{/each}
|
|
145
|
+
<Command.Item value="toggle-theme" onSelect={() => act(() => toggleTheme())} class={itemClass}>
|
|
146
|
+
<Sun class="h-4 w-4 text-muted-foreground" />
|
|
147
|
+
{t('common.toggleTheme')}
|
|
148
|
+
</Command.Item>
|
|
149
|
+
</Command.Group>
|
|
150
|
+
</Command.List>
|
|
151
|
+
{:else}
|
|
152
|
+
<!-- AI Response Area -->
|
|
153
|
+
<div class="h-[360px] max-h-[360px] overflow-y-auto p-4 bg-muted/20">
|
|
154
|
+
<div class="flex items-start gap-3">
|
|
155
|
+
<div class="mt-0.5 w-8 h-8 rounded-full bg-amber-500/10 text-amber-500 flex items-center justify-center shrink-0">
|
|
156
|
+
<Sparkles class="h-4 w-4" />
|
|
157
|
+
</div>
|
|
158
|
+
<div class="flex-1 w-full text-sm">
|
|
159
|
+
{#if !aiResponse && isPredicting}
|
|
160
|
+
<div class="flex items-center gap-2 text-muted-foreground mt-1.5">
|
|
161
|
+
<Loader2 class="h-4 w-4 animate-spin" /> Thinking...
|
|
162
|
+
</div>
|
|
163
|
+
{:else}
|
|
164
|
+
<MarkdownRenderer content={aiResponse} streaming={isPredicting} />
|
|
165
|
+
{/if}
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
<div class="p-2 border-t flex justify-end gap-2 bg-muted/10">
|
|
170
|
+
<button
|
|
171
|
+
class="text-xs px-3 py-1.5 rounded-md hover:bg-muted transition-colors text-muted-foreground"
|
|
172
|
+
onclick={() => { aiMode = false; searchValue = ''; }}
|
|
173
|
+
>
|
|
174
|
+
Clear & Return
|
|
175
|
+
</button>
|
|
176
|
+
</div>
|
|
177
|
+
{/if}
|
|
178
|
+
</Command.Root>
|
|
179
|
+
</Dialog.DialogContent>
|
|
180
|
+
</Dialog.Dialog>
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Badge } from './ui/badge/index.js';
|
|
3
|
+
import { TrendingUp, TrendingDown, AlertTriangle } from 'lucide-svelte';
|
|
4
|
+
import TooltipButton from './TooltipButton.svelte';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
/** The actual current value */
|
|
8
|
+
value: number;
|
|
9
|
+
/** The baseline/expected value to compare against */
|
|
10
|
+
baseline: number;
|
|
11
|
+
/** Threshold percentage to trigger an anomaly (e.g. 0.2 means 20% diff) */
|
|
12
|
+
threshold?: number;
|
|
13
|
+
/** Optional label formatting */
|
|
14
|
+
formatter?: (val: number) => string;
|
|
15
|
+
/** If true, lower is better (e.g. latency). Default false (higher is better e.g. revenue). */
|
|
16
|
+
lowerIsBetter?: boolean;
|
|
17
|
+
class?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let {
|
|
21
|
+
value,
|
|
22
|
+
baseline,
|
|
23
|
+
threshold = 0.2,
|
|
24
|
+
formatter = (v) => v.toString(),
|
|
25
|
+
lowerIsBetter = false,
|
|
26
|
+
class: className = ''
|
|
27
|
+
}: Props = $props();
|
|
28
|
+
|
|
29
|
+
// Calculate percentage difference
|
|
30
|
+
const diff = $derived(baseline === 0 ? 0 : (value - baseline) / Math.abs(baseline));
|
|
31
|
+
const isAnomaly = $derived(Math.abs(diff) >= threshold);
|
|
32
|
+
|
|
33
|
+
// Determine sentiment (good, bad, neutral)
|
|
34
|
+
const isGood = $derived(
|
|
35
|
+
!isAnomaly ? null :
|
|
36
|
+
(lowerIsBetter ? diff < 0 : diff > 0)
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const percentLabel = $derived(`${Math.abs(diff * 100).toFixed(1)}%`);
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
{#if !isAnomaly}
|
|
43
|
+
<span class="text-muted-foreground text-sm font-medium {className}">
|
|
44
|
+
{formatter(value)}
|
|
45
|
+
</span>
|
|
46
|
+
{:else}
|
|
47
|
+
<TooltipButton
|
|
48
|
+
tooltip={`Baseline: ${formatter(baseline)} (${diff > 0 ? '+' : '-'}${percentLabel})`}
|
|
49
|
+
variant="ghost"
|
|
50
|
+
size="sm"
|
|
51
|
+
class="p-0 h-auto hover:bg-transparent"
|
|
52
|
+
>
|
|
53
|
+
<Badge
|
|
54
|
+
variant={isGood ? 'default' : 'destructive'}
|
|
55
|
+
class="flex items-center gap-1 font-mono text-xs px-1.5 py-0 {isGood ? 'bg-green-500 hover:bg-green-600 text-white' : ''} {className}"
|
|
56
|
+
>
|
|
57
|
+
{#if diff > 0}
|
|
58
|
+
<TrendingUp class="h-3 w-3" />
|
|
59
|
+
{:else}
|
|
60
|
+
<TrendingDown class="h-3 w-3" />
|
|
61
|
+
{/if}
|
|
62
|
+
{formatter(value)}
|
|
63
|
+
</Badge>
|
|
64
|
+
</TooltipButton>
|
|
65
|
+
{/if}
|
|
@@ -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,163 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { getChatProvider, getChatContext } from '@svadmin/core';
|
|
3
|
+
import { Sparkles, X, ChevronRight, Check } from 'lucide-svelte';
|
|
4
|
+
import { fade, slide } from 'svelte/transition';
|
|
5
|
+
import { Button } from './ui/button/index.js';
|
|
6
|
+
import MarkdownRenderer from './MarkdownRenderer.svelte';
|
|
7
|
+
import TooltipButton from './TooltipButton.svelte';
|
|
8
|
+
|
|
9
|
+
let { open = $bindable(false) } = $props<{ open?: boolean }>();
|
|
10
|
+
|
|
11
|
+
const provider = $derived(getChatProvider());
|
|
12
|
+
const ctx = $derived(getChatContext());
|
|
13
|
+
|
|
14
|
+
let isPredicting = $state(false);
|
|
15
|
+
let insightText = $state('');
|
|
16
|
+
let abortController: AbortController | null = null;
|
|
17
|
+
let hasFetchedForContext = $state('');
|
|
18
|
+
|
|
19
|
+
// Automatically request insights when the context changes
|
|
20
|
+
$effect(() => {
|
|
21
|
+
if (!open || !provider) return;
|
|
22
|
+
|
|
23
|
+
// Create a context hash to avoid refetching for the same page
|
|
24
|
+
const currentHash = `${ctx.currentResource}-${ctx.currentView}-${ctx.selectedRecordId}`;
|
|
25
|
+
if (hasFetchedForContext === currentHash) return;
|
|
26
|
+
|
|
27
|
+
hasFetchedForContext = currentHash;
|
|
28
|
+
getInsights();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
async function getInsights() {
|
|
32
|
+
if (!provider) return;
|
|
33
|
+
|
|
34
|
+
isPredicting = true;
|
|
35
|
+
insightText = '';
|
|
36
|
+
|
|
37
|
+
if (abortController) abortController.abort();
|
|
38
|
+
abortController = new AbortController();
|
|
39
|
+
|
|
40
|
+
const prompt = `Analyze the current context and provide 3 quick insights or context-aware suggestions for the admin user.
|
|
41
|
+
Resource: ${ctx.currentResource}
|
|
42
|
+
View: ${ctx.currentView}
|
|
43
|
+
Record ID: ${ctx.selectedRecordId || 'None'}
|
|
44
|
+
Keep it concise, professional, and use bullet points.`;
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const result = provider.sendMessage(
|
|
48
|
+
[{ id: 'insights', role: 'system', content: prompt, timestamp: Date.now() }],
|
|
49
|
+
{ signal: abortController.signal }
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
if (Symbol.asyncIterator in result) {
|
|
53
|
+
for await (const chunk of result as AsyncIterable<string>) {
|
|
54
|
+
insightText += chunk;
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
insightText = await result;
|
|
58
|
+
}
|
|
59
|
+
} catch (err: any) {
|
|
60
|
+
if (err.name !== 'AbortError') {
|
|
61
|
+
insightText = 'Failed to generate insights for this context.';
|
|
62
|
+
}
|
|
63
|
+
} finally {
|
|
64
|
+
isPredicting = false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
</script>
|
|
68
|
+
|
|
69
|
+
{#if open}
|
|
70
|
+
<div
|
|
71
|
+
class="fixed right-0 top-14 bottom-0 w-80 bg-card border-l shadow-2xl z-40 flex flex-col"
|
|
72
|
+
transition:slide={{ axis: 'x', duration: 300 }}
|
|
73
|
+
>
|
|
74
|
+
<!-- Header -->
|
|
75
|
+
<div class="h-14 flex items-center justify-between px-4 border-b bg-muted/20">
|
|
76
|
+
<div class="flex items-center gap-2 font-medium">
|
|
77
|
+
<Sparkles class="h-4 w-4 text-primary" />
|
|
78
|
+
Copilot Sidebar
|
|
79
|
+
</div>
|
|
80
|
+
<TooltipButton tooltip="Close panel" variant="ghost" size="icon-sm" onclick={() => open = false}>
|
|
81
|
+
<X class="h-4 w-4" />
|
|
82
|
+
</TooltipButton>
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<!-- Context Meta -->
|
|
86
|
+
<div class="px-4 py-3 border-b bg-muted/10 text-xs text-muted-foreground flex flex-col gap-1">
|
|
87
|
+
<div class="flex items-center gap-2">
|
|
88
|
+
<span class="w-16">Resource:</span>
|
|
89
|
+
<span class="font-mono text-foreground">{ctx.currentResource || 'Dashboard'}</span>
|
|
90
|
+
</div>
|
|
91
|
+
<div class="flex items-center gap-2">
|
|
92
|
+
<span class="w-16">View:</span>
|
|
93
|
+
<span class="font-mono text-foreground capitalize">{ctx.currentView || 'List'}</span>
|
|
94
|
+
</div>
|
|
95
|
+
{#if ctx.selectedRecordId}
|
|
96
|
+
<div class="flex items-center gap-2">
|
|
97
|
+
<span class="w-16">Record ID:</span>
|
|
98
|
+
<span class="font-mono text-foreground">{ctx.selectedRecordId}</span>
|
|
99
|
+
</div>
|
|
100
|
+
{/if}
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<!-- Content Area -->
|
|
104
|
+
<div class="flex-1 overflow-y-auto p-4 space-y-6">
|
|
105
|
+
|
|
106
|
+
<!-- Smart Insights Section -->
|
|
107
|
+
<div>
|
|
108
|
+
<h4 class="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3 flex items-center justify-between">
|
|
109
|
+
Smart Insights
|
|
110
|
+
{#if isPredicting}
|
|
111
|
+
<span class="flex items-center gap-1 text-[10px] text-primary normal-case font-medium">
|
|
112
|
+
<div class="h-1 w-1 bg-primary rounded-full animate-bounce delay-75"></div>
|
|
113
|
+
<div class="h-1 w-1 bg-primary rounded-full animate-bounce delay-150"></div>
|
|
114
|
+
<div class="h-1 w-1 bg-primary rounded-full animate-bounce delay-300"></div>
|
|
115
|
+
</span>
|
|
116
|
+
{:else}
|
|
117
|
+
<Button variant="ghost" size="icon-sm" class="h-5 w-5 hover:bg-muted" onclick={getInsights}>
|
|
118
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/><path d="M16 21v-5h5"/></svg>
|
|
119
|
+
</Button>
|
|
120
|
+
{/if}
|
|
121
|
+
</h4>
|
|
122
|
+
|
|
123
|
+
<div class="text-sm">
|
|
124
|
+
{#if !insightText && !isPredicting}
|
|
125
|
+
<div class="text-muted-foreground italic text-center py-4 bg-muted/30 rounded-lg border border-dashed">
|
|
126
|
+
No insights loaded.
|
|
127
|
+
</div>
|
|
128
|
+
{:else}
|
|
129
|
+
<div class="bg-primary/5 p-3 rounded-xl border border-primary/10 prose prose-sm dark:prose-invert prose-p:my-1 prose-ul:my-1 prose-li:my-0.5">
|
|
130
|
+
<MarkdownRenderer content={insightText} streaming={isPredicting} />
|
|
131
|
+
</div>
|
|
132
|
+
{/if}
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
<!-- Quick Actions (Example static definitions for the copilot) -->
|
|
137
|
+
<div>
|
|
138
|
+
<h4 class="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3">
|
|
139
|
+
Suggested Actions
|
|
140
|
+
</h4>
|
|
141
|
+
<div class="space-y-2">
|
|
142
|
+
{#if ctx.currentView === 'list'}
|
|
143
|
+
<button class="w-full flex items-center gap-2 text-left p-2 rounded-lg hover:bg-muted border border-transparent hover:border-border transition-colors text-sm">
|
|
144
|
+
<div class="bg-background rounded shadow-sm p-1"><Check class="h-3.5 w-3.5 text-muted-foreground" /></div>
|
|
145
|
+
<span>Generate monthly report</span>
|
|
146
|
+
</button>
|
|
147
|
+
<button class="w-full flex items-center gap-2 text-left p-2 rounded-lg hover:bg-muted border border-transparent hover:border-border transition-colors text-sm">
|
|
148
|
+
<div class="bg-background rounded shadow-sm p-1"><Check class="h-3.5 w-3.5 text-muted-foreground" /></div>
|
|
149
|
+
<span>Find anomalies in {ctx.currentResource}</span>
|
|
150
|
+
</button>
|
|
151
|
+
{:else if ctx.currentView === 'edit' || ctx.currentView === 'create'}
|
|
152
|
+
<button class="w-full flex items-center gap-2 text-left p-2 rounded-lg hover:bg-muted border border-transparent hover:border-border transition-colors text-sm">
|
|
153
|
+
<div class="bg-background rounded shadow-sm p-1"><Check class="h-3.5 w-3.5 text-muted-foreground" /></div>
|
|
154
|
+
<span>Auto-fill form from context</span>
|
|
155
|
+
</button>
|
|
156
|
+
{:else}
|
|
157
|
+
<div class="text-muted-foreground text-sm py-2 text-center">No precise actions for this view</div>
|
|
158
|
+
{/if}
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
{/if}
|
|
@@ -34,14 +34,14 @@
|
|
|
34
34
|
const locale = $derived(getLocale());
|
|
35
35
|
|
|
36
36
|
// Auto-trace state changes in dev mode via Svelte 5 $inspect
|
|
37
|
-
if (import.meta.env?.DEV) {
|
|
37
|
+
if ((import.meta as any).env?.DEV) {
|
|
38
38
|
$inspect({ path, theme, colorTheme, locale, resourceCount: resources.length });
|
|
39
39
|
}
|
|
40
40
|
</script>
|
|
41
41
|
|
|
42
42
|
<svelte:window onkeydown={handleKeydown} />
|
|
43
43
|
|
|
44
|
-
{#if import.meta.env
|
|
44
|
+
{#if (import.meta as any).env?.DEV}
|
|
45
45
|
{#if visible}
|
|
46
46
|
<div
|
|
47
47
|
class="fixed bottom-0 right-4 z-[9999] w-[420px] max-w-[95vw] rounded-t-xl border border-b-0 bg-card shadow-2xl text-[0.8125rem] overflow-hidden"
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { getChatProvider } from '@svadmin/core';
|
|
3
|
+
import { Sparkles, RefreshCcw, Loader2 } from 'lucide-svelte';
|
|
4
|
+
import { Button } from './ui/button/index.js';
|
|
5
|
+
import MarkdownRenderer from './MarkdownRenderer.svelte';
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
title?: string;
|
|
9
|
+
context: string;
|
|
10
|
+
autoFetch?: boolean;
|
|
11
|
+
class?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let { title = 'AI Insights', context, autoFetch = true, class: className = '' }: Props = $props();
|
|
15
|
+
|
|
16
|
+
const provider = $derived(getChatProvider());
|
|
17
|
+
|
|
18
|
+
let isPredicting = $state(false);
|
|
19
|
+
let insightText = $state('');
|
|
20
|
+
let abortController: AbortController | null = null;
|
|
21
|
+
let hasFetchedForContext = $state('');
|
|
22
|
+
|
|
23
|
+
$effect(() => {
|
|
24
|
+
if (autoFetch && context && provider && hasFetchedForContext !== context) {
|
|
25
|
+
hasFetchedForContext = context;
|
|
26
|
+
getInsights();
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export async function getInsights() {
|
|
31
|
+
if (!provider || !context) return;
|
|
32
|
+
|
|
33
|
+
isPredicting = true;
|
|
34
|
+
insightText = '';
|
|
35
|
+
|
|
36
|
+
if (abortController) abortController.abort();
|
|
37
|
+
abortController = new AbortController();
|
|
38
|
+
|
|
39
|
+
const prompt = `Based on the following data/context, generate 3 concise, highly professional insights.
|
|
40
|
+
Use bullet points. Do not include introductory text like "Here are the insights".
|
|
41
|
+
|
|
42
|
+
Data Context:
|
|
43
|
+
${context}`;
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const result = provider.sendMessage(
|
|
47
|
+
[{ id: 'insights', role: 'user', content: prompt, timestamp: Date.now() }],
|
|
48
|
+
{ signal: abortController.signal }
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
if (Symbol.asyncIterator in result) {
|
|
52
|
+
for await (const chunk of result as AsyncIterable<string>) {
|
|
53
|
+
insightText += chunk;
|
|
54
|
+
}
|
|
55
|
+
} else {
|
|
56
|
+
insightText = await result;
|
|
57
|
+
}
|
|
58
|
+
} catch (err: any) {
|
|
59
|
+
if (err.name !== 'AbortError') {
|
|
60
|
+
insightText = 'Failed to generate insights.';
|
|
61
|
+
}
|
|
62
|
+
} finally {
|
|
63
|
+
isPredicting = false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
</script>
|
|
67
|
+
|
|
68
|
+
<div class="rounded-xl border bg-card text-card-foreground shadow-sm flex flex-col {className}">
|
|
69
|
+
<div class="flex items-center justify-between p-4 border-b">
|
|
70
|
+
<div class="flex items-center gap-2 font-medium">
|
|
71
|
+
<Sparkles class="h-4 w-4 text-amber-500" />
|
|
72
|
+
{title}
|
|
73
|
+
</div>
|
|
74
|
+
<Button variant="ghost" size="icon-sm" class="h-6 w-6" onclick={getInsights} disabled={isPredicting || !provider}>
|
|
75
|
+
<RefreshCcw class="h-3 w-3 {isPredicting ? 'animate-spin' : ''}" />
|
|
76
|
+
</Button>
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
<div class="p-4 flex-1">
|
|
80
|
+
{#if !provider}
|
|
81
|
+
<div class="text-sm text-muted-foreground italic flex items-center justify-center h-full">
|
|
82
|
+
ChatProvider not configured.
|
|
83
|
+
</div>
|
|
84
|
+
{:else if isPredicting && !insightText}
|
|
85
|
+
<div class="flex items-center justify-center gap-2 text-sm text-muted-foreground h-full py-4">
|
|
86
|
+
<Loader2 class="h-4 w-4 animate-spin" /> Analyzing data...
|
|
87
|
+
</div>
|
|
88
|
+
{:else if !insightText}
|
|
89
|
+
<div class="text-sm text-muted-foreground italic flex items-center justify-center h-full">
|
|
90
|
+
No insights available. Click refresh to analyze.
|
|
91
|
+
</div>
|
|
92
|
+
{:else}
|
|
93
|
+
<div class="text-sm prose prose-sm dark:prose-invert prose-p:my-1 prose-ul:my-1 prose-li:my-0.5">
|
|
94
|
+
<MarkdownRenderer content={insightText} streaming={isPredicting} />
|
|
95
|
+
</div>
|
|
96
|
+
{/if}
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
let collapsed = $state(false);
|
|
63
63
|
</script>
|
|
64
64
|
|
|
65
|
-
<
|
|
65
|
+
<svelte:window onkeydown={(e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); commandOpen = true; } }} />
|
|
66
66
|
{#if loading}
|
|
67
67
|
<div class="flex h-screen" in:fade={{ duration: 150 }}>
|
|
68
68
|
<div class="hidden md:block w-64 bg-sidebar/80 p-4 space-y-4">
|
|
@@ -128,4 +128,3 @@
|
|
|
128
128
|
<ChatDialog />
|
|
129
129
|
<Toast />
|
|
130
130
|
{/if}
|
|
131
|
-
</div>
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// @ts-expect-error - missing types
|
|
3
|
+
import { Marked } from 'marked';
|
|
4
|
+
// @ts-expect-error - missing types
|
|
5
|
+
import { markedHighlight } from 'marked-highlight';
|
|
6
|
+
// @ts-expect-error - missing types
|
|
7
|
+
import hljs from 'highlight.js';
|
|
8
|
+
import 'highlight.js/styles/github-dark.css'; // or your preferred theme
|
|
9
|
+
// @ts-expect-error - missing types
|
|
10
|
+
import DOMPurify from 'isomorphic-dompurify';
|
|
11
|
+
import { Check, Copy } from 'lucide-svelte';
|
|
12
|
+
import { Button } from './ui/button/index.js';
|
|
13
|
+
|
|
14
|
+
interface Props {
|
|
15
|
+
/** The raw markdown string to render */
|
|
16
|
+
content: string;
|
|
17
|
+
/** Whether to enable streaming/typing effect styling */
|
|
18
|
+
streaming?: boolean;
|
|
19
|
+
/** Custom class for the container */
|
|
20
|
+
class?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let { content, streaming = false, class: className = '' }: Props = $props();
|
|
24
|
+
|
|
25
|
+
// Configure marked with syntax highlighting
|
|
26
|
+
const marked = new Marked(
|
|
27
|
+
markedHighlight({
|
|
28
|
+
langPrefix: 'hljs language-',
|
|
29
|
+
highlight(code: string, lang: string) {
|
|
30
|
+
const language = hljs.getLanguage(lang) ? lang : 'plaintext';
|
|
31
|
+
return hljs.highlight(code, { language }).value;
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
// Render HTML safely
|
|
37
|
+
const html = $derived(DOMPurify.sanitize(marked.parse(content || '') as string));
|
|
38
|
+
|
|
39
|
+
// Handle copy code blocks
|
|
40
|
+
let copiedBlock = $state<string | null>(null);
|
|
41
|
+
|
|
42
|
+
function handleCopy(event: MouseEvent) {
|
|
43
|
+
const target = event.target as HTMLElement;
|
|
44
|
+
const button = target.closest('.copy-btn');
|
|
45
|
+
if (!button) return;
|
|
46
|
+
|
|
47
|
+
const pre = button.closest('.code-block-wrapper')?.querySelector('pre');
|
|
48
|
+
if (!pre) return;
|
|
49
|
+
|
|
50
|
+
const code = pre.textContent || '';
|
|
51
|
+
navigator.clipboard.writeText(code);
|
|
52
|
+
|
|
53
|
+
const id = Math.random().toString(36);
|
|
54
|
+
copiedBlock = id;
|
|
55
|
+
button.setAttribute('data-copied-id', id);
|
|
56
|
+
|
|
57
|
+
setTimeout(() => {
|
|
58
|
+
if (copiedBlock === id) copiedBlock = null;
|
|
59
|
+
}, 2000);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Inject copy buttons into the HTML after rendering
|
|
63
|
+
// We do this via action instead of raw string manipulation for safety
|
|
64
|
+
function enhanceCodeBlocks(node: HTMLElement, _: string) {
|
|
65
|
+
return {
|
|
66
|
+
update(newHtml: string) {
|
|
67
|
+
// Find all pre > code blocks that don't have wrappers yet
|
|
68
|
+
const pres = node.querySelectorAll('pre:not(.enhanced)');
|
|
69
|
+
pres.forEach(pre => {
|
|
70
|
+
pre.classList.add('enhanced');
|
|
71
|
+
const wrapper = document.createElement('div');
|
|
72
|
+
wrapper.className = 'code-block-wrapper group relative my-4 rounded-md bg-zinc-950';
|
|
73
|
+
|
|
74
|
+
const header = document.createElement('div');
|
|
75
|
+
header.className = 'flex items-center justify-between px-4 py-2 text-xs text-zinc-400 border-b border-zinc-800';
|
|
76
|
+
|
|
77
|
+
const lang = Array.from(pre.querySelector('code')?.classList || []).find(c => c.startsWith('language-'))?.replace('language-', '') || 'Code';
|
|
78
|
+
header.innerHTML = `
|
|
79
|
+
<span>${lang}</span>
|
|
80
|
+
<button class="copy-btn hover:text-white transition-colors flex items-center gap-1">
|
|
81
|
+
<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>
|
|
82
|
+
</button>
|
|
83
|
+
`;
|
|
84
|
+
|
|
85
|
+
pre.replaceWith(wrapper);
|
|
86
|
+
wrapper.appendChild(header);
|
|
87
|
+
|
|
88
|
+
const scrollContainer = document.createElement('div');
|
|
89
|
+
scrollContainer.className = 'overflow-x-auto p-4';
|
|
90
|
+
scrollContainer.appendChild(pre);
|
|
91
|
+
wrapper.appendChild(scrollContainer);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Add event listener for copy buttons
|
|
95
|
+
node.addEventListener('click', handleCopy);
|
|
96
|
+
},
|
|
97
|
+
destroy() {
|
|
98
|
+
node.removeEventListener('click', handleCopy);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
</script>
|
|
103
|
+
|
|
104
|
+
<div
|
|
105
|
+
class="prose prose-sm dark:prose-invert max-w-none break-words {className}"
|
|
106
|
+
class:streaming={streaming}
|
|
107
|
+
use:enhanceCodeBlocks={html}
|
|
108
|
+
>
|
|
109
|
+
{@html html}
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
<style>
|
|
113
|
+
/* Base Markdown Styles */
|
|
114
|
+
:global(.prose) {
|
|
115
|
+
line-height: 1.6;
|
|
116
|
+
}
|
|
117
|
+
:global(.prose p) {
|
|
118
|
+
margin-top: 0.5em;
|
|
119
|
+
margin-bottom: 0.5em;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/* Streaming cursor effect */
|
|
123
|
+
.streaming > :global(*:last-child)::after {
|
|
124
|
+
content: '▋';
|
|
125
|
+
display: inline-block;
|
|
126
|
+
animation: blink 1s step-start infinite;
|
|
127
|
+
margin-left: 2px;
|
|
128
|
+
vertical-align: baseline;
|
|
129
|
+
color: currentColor;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
@keyframes blink {
|
|
133
|
+
50% { opacity: 0; }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/* Handle copied state for dynamically injected buttons */
|
|
137
|
+
:global(.copy-btn[data-copied-id]) {
|
|
138
|
+
color: #10b981; /* Tailwind emerald-500 */
|
|
139
|
+
}
|
|
140
|
+
:global(.copy-btn[data-copied-id] svg) {
|
|
141
|
+
/* Swap to Check icon */
|
|
142
|
+
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>');
|
|
143
|
+
}
|
|
144
|
+
</style>
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Input } from './ui/input/index.js';
|
|
3
|
+
import type { ChatProvider } from '@svadmin/core';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
/** The bound value of the input */
|
|
7
|
+
value: string;
|
|
8
|
+
/** The chat provider to generate suggestions */
|
|
9
|
+
provider?: ChatProvider;
|
|
10
|
+
/** Context given to the AI to help it predict the text */
|
|
11
|
+
context?: string;
|
|
12
|
+
/** Standard input props */
|
|
13
|
+
placeholder?: string;
|
|
14
|
+
class?: string;
|
|
15
|
+
id?: string;
|
|
16
|
+
disabled?: boolean;
|
|
17
|
+
name?: string;
|
|
18
|
+
type?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let {
|
|
22
|
+
value = $bindable(),
|
|
23
|
+
provider,
|
|
24
|
+
context = '',
|
|
25
|
+
placeholder = '',
|
|
26
|
+
class: className = '',
|
|
27
|
+
id,
|
|
28
|
+
disabled = false,
|
|
29
|
+
name,
|
|
30
|
+
type = 'text'
|
|
31
|
+
}: Props = $props();
|
|
32
|
+
|
|
33
|
+
let suggestion = $state('');
|
|
34
|
+
let isPredicting = $state(false);
|
|
35
|
+
let abortController: AbortController | null = null;
|
|
36
|
+
let predictTimer: ReturnType<typeof setTimeout> | null = null;
|
|
37
|
+
|
|
38
|
+
// The ghost text shows the suggestion, but only the part *after* what the user has typed
|
|
39
|
+
// It only displays if the suggestion actually starts with the current value
|
|
40
|
+
const ghostText = $derived.by(() => {
|
|
41
|
+
if (!suggestion || !value) return '';
|
|
42
|
+
const lowerVal = value.toLowerCase();
|
|
43
|
+
const lowerSugg = suggestion.toLowerCase();
|
|
44
|
+
|
|
45
|
+
if (lowerSugg.startsWith(lowerVal) && suggestion.length > value.length) {
|
|
46
|
+
// Preserve original casing from the suggestion but append only the suffix
|
|
47
|
+
return value + suggestion.slice(value.length);
|
|
48
|
+
}
|
|
49
|
+
return '';
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const suffixText = $derived(ghostText ? ghostText.slice(value.length) : '');
|
|
53
|
+
|
|
54
|
+
async function predict() {
|
|
55
|
+
if (!provider || !value.trim()) {
|
|
56
|
+
suggestion = '';
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
isPredicting = true;
|
|
61
|
+
if (abortController) {
|
|
62
|
+
abortController.abort();
|
|
63
|
+
}
|
|
64
|
+
abortController = new AbortController();
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const prompt = `You are an autocomplete engine. The user is typing: "${value}".
|
|
68
|
+
Context: ${context}.
|
|
69
|
+
Provide ONLY the most likely completion of the exact phrase the user is typing.
|
|
70
|
+
Include the user's input in your returned string.
|
|
71
|
+
Do not include any other text, Markdown formatting, quotes, or explanations.`;
|
|
72
|
+
|
|
73
|
+
const result = provider.sendMessage(
|
|
74
|
+
[{ id: 'prompt', role: 'user', content: prompt, timestamp: Date.now() }],
|
|
75
|
+
{ signal: abortController.signal }
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
suggestion = '';
|
|
79
|
+
|
|
80
|
+
// Handle streaming or Promise responses seamlessly
|
|
81
|
+
if (Symbol.asyncIterator in result) {
|
|
82
|
+
for await (const chunk of result as AsyncIterable<string>) {
|
|
83
|
+
suggestion += chunk;
|
|
84
|
+
}
|
|
85
|
+
} else {
|
|
86
|
+
suggestion = await result;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Clean up the suggestion
|
|
90
|
+
suggestion = suggestion.trim().replace(/^["']|["']$/g, '');
|
|
91
|
+
} catch (err: any) {
|
|
92
|
+
if (err.name !== 'AbortError') {
|
|
93
|
+
suggestion = '';
|
|
94
|
+
}
|
|
95
|
+
} finally {
|
|
96
|
+
isPredicting = false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function handleInput(e: Event) {
|
|
101
|
+
const target = e.target as HTMLInputElement;
|
|
102
|
+
value = target.value;
|
|
103
|
+
|
|
104
|
+
// Reset suggestion immediately if it no longer matches
|
|
105
|
+
if (suggestion && !suggestion.toLowerCase().startsWith(value.toLowerCase())) {
|
|
106
|
+
suggestion = '';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (predictTimer) clearTimeout(predictTimer);
|
|
110
|
+
|
|
111
|
+
// Debounce prediction
|
|
112
|
+
predictTimer = setTimeout(() => {
|
|
113
|
+
predict();
|
|
114
|
+
}, 500);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function handleKeyDown(e: KeyboardEvent) {
|
|
118
|
+
if (e.key === 'Tab' && suffixText) {
|
|
119
|
+
e.preventDefault(); // Prevent standard tab navigation
|
|
120
|
+
value = ghostText;
|
|
121
|
+
suggestion = ''; // Clear suggestion once accepted
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
</script>
|
|
126
|
+
|
|
127
|
+
<div class="relative w-full {className}">
|
|
128
|
+
<!-- Ghost Text Layer (positioned exactly beneath the actual text) -->
|
|
129
|
+
{#if suffixText}
|
|
130
|
+
<div
|
|
131
|
+
class="absolute inset-0 pointer-events-none flex items-center px-3 text-sm font-medium"
|
|
132
|
+
aria-hidden="true"
|
|
133
|
+
>
|
|
134
|
+
<span class="opacity-0">{value}</span>
|
|
135
|
+
<span class="text-muted-foreground/40">{suffixText}</span>
|
|
136
|
+
</div>
|
|
137
|
+
{/if}
|
|
138
|
+
|
|
139
|
+
<!-- Actual Input Layer -->
|
|
140
|
+
<Input
|
|
141
|
+
{id}
|
|
142
|
+
{name}
|
|
143
|
+
{type}
|
|
144
|
+
{disabled}
|
|
145
|
+
{placeholder}
|
|
146
|
+
value={value}
|
|
147
|
+
oninput={handleInput}
|
|
148
|
+
onkeydown={handleKeyDown}
|
|
149
|
+
class="bg-transparent relative z-10 w-full"
|
|
150
|
+
/>
|
|
151
|
+
|
|
152
|
+
<!-- Loading indicator -->
|
|
153
|
+
{#if isPredicting}
|
|
154
|
+
<div class="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
|
|
155
|
+
<div class="h-1.5 w-1.5 rounded-full bg-primary/50 animate-pulse"></div>
|
|
156
|
+
</div>
|
|
157
|
+
{/if}
|
|
158
|
+
</div>
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Mic, MicOff, Loader2 } from 'lucide-svelte';
|
|
3
|
+
import TooltipButton from './TooltipButton.svelte';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
/** Called when a speech recognition result is finalized */
|
|
7
|
+
onresult?: (text: string) => void;
|
|
8
|
+
/** Called when the engine starts listening */
|
|
9
|
+
onstart?: () => void;
|
|
10
|
+
/** Called when the engine stops listening */
|
|
11
|
+
onend?: () => void;
|
|
12
|
+
/** Standard button size */
|
|
13
|
+
size?: 'default' | 'sm' | 'lg' | 'icon' | 'icon-sm';
|
|
14
|
+
/** Custom class for the button */
|
|
15
|
+
class?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let {
|
|
19
|
+
onresult,
|
|
20
|
+
onstart,
|
|
21
|
+
onend,
|
|
22
|
+
size = 'icon',
|
|
23
|
+
class: className = ''
|
|
24
|
+
}: Props = $props();
|
|
25
|
+
|
|
26
|
+
let isListening = $state(false);
|
|
27
|
+
let notSupported = $state(false);
|
|
28
|
+
let recognition: any = null;
|
|
29
|
+
|
|
30
|
+
// Initialize SpeechRecognition on mount
|
|
31
|
+
$effect(() => {
|
|
32
|
+
// @ts-ignore - Browser specific APIs
|
|
33
|
+
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
34
|
+
|
|
35
|
+
if (!SpeechRecognition) {
|
|
36
|
+
notSupported = true;
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
recognition = new SpeechRecognition();
|
|
41
|
+
recognition.continuous = false;
|
|
42
|
+
// Tries to return interim results, but we mainly care about final
|
|
43
|
+
recognition.interimResults = false;
|
|
44
|
+
// Uses the browser locale by default
|
|
45
|
+
recognition.lang = navigator.language || 'en-US';
|
|
46
|
+
|
|
47
|
+
recognition.onstart = () => {
|
|
48
|
+
isListening = true;
|
|
49
|
+
if (onstart) onstart();
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
recognition.onresult = (event: any) => {
|
|
53
|
+
const transcript = Array.from(event.results)
|
|
54
|
+
.map((result: any) => result[0])
|
|
55
|
+
.map((result: any) => result.transcript)
|
|
56
|
+
.join('');
|
|
57
|
+
|
|
58
|
+
if (onresult && transcript) {
|
|
59
|
+
onresult(transcript);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
recognition.onerror = (event: any) => {
|
|
64
|
+
console.warn('SpeechRecognition error:', event.error);
|
|
65
|
+
isListening = false;
|
|
66
|
+
if (onend) onend();
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
recognition.onend = () => {
|
|
70
|
+
isListening = false;
|
|
71
|
+
if (onend) onend();
|
|
72
|
+
};
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
function toggleListening() {
|
|
76
|
+
if (!recognition) return;
|
|
77
|
+
|
|
78
|
+
if (isListening) {
|
|
79
|
+
recognition.stop();
|
|
80
|
+
} else {
|
|
81
|
+
recognition.start();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
</script>
|
|
85
|
+
|
|
86
|
+
<TooltipButton
|
|
87
|
+
tooltip={notSupported ? "Voice input not supported in this browser" : (isListening ? "Stop listening" : "Start voice input")}
|
|
88
|
+
variant={isListening ? "destructive" : "ghost"}
|
|
89
|
+
{size}
|
|
90
|
+
class="{isListening ? 'animate-pulse' : ''} {className}"
|
|
91
|
+
onclick={toggleListening}
|
|
92
|
+
disabled={notSupported}
|
|
93
|
+
>
|
|
94
|
+
{#if isListening}
|
|
95
|
+
<Loader2 class="h-4 w-4 animate-spin -ml-1 mr-1.5 inline-block sm:hidden" />
|
|
96
|
+
<MicOff class="h-4 w-4" />
|
|
97
|
+
{:else}
|
|
98
|
+
<Mic class="h-4 w-4" />
|
|
99
|
+
{/if}
|
|
100
|
+
</TooltipButton>
|
|
@@ -53,11 +53,19 @@
|
|
|
53
53
|
children,
|
|
54
54
|
...restProps
|
|
55
55
|
}: ButtonProps = $props();
|
|
56
|
+
|
|
57
|
+
let anchorRef: HTMLAnchorElement | null = $state(null);
|
|
58
|
+
let buttonRef: HTMLButtonElement | null = $state(null);
|
|
59
|
+
|
|
60
|
+
$effect(() => {
|
|
61
|
+
if (href && anchorRef) ref = anchorRef as any;
|
|
62
|
+
else if (!href && buttonRef) ref = buttonRef as any;
|
|
63
|
+
});
|
|
56
64
|
</script>
|
|
57
65
|
|
|
58
66
|
{#if href}
|
|
59
67
|
<a
|
|
60
|
-
bind:this={
|
|
68
|
+
bind:this={anchorRef}
|
|
61
69
|
data-slot="button"
|
|
62
70
|
class={cn(buttonVariants({ variant, size }), className)}
|
|
63
71
|
href={disabled ? undefined : href}
|
|
@@ -70,7 +78,7 @@
|
|
|
70
78
|
</a>
|
|
71
79
|
{:else}
|
|
72
80
|
<button
|
|
73
|
-
bind:this={
|
|
81
|
+
bind:this={buttonRef}
|
|
74
82
|
data-slot="button"
|
|
75
83
|
class={cn(buttonVariants({ variant, size }), className)}
|
|
76
84
|
{type}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
let { class: className, ...restProps }: HTMLAttributes<HTMLSpanElement> = $props();
|
|
7
7
|
</script>
|
|
8
8
|
|
|
9
|
-
<span aria-hidden data-slot="pagination-ellipsis" class={cn("flex size-9 items-center justify-center", className)} {...restProps}>
|
|
9
|
+
<span aria-hidden="true" data-slot="pagination-ellipsis" class={cn("flex size-9 items-center justify-center", className)} {...restProps}>
|
|
10
10
|
<Ellipsis class="size-4" />
|
|
11
11
|
<span class="sr-only">More pages</span>
|
|
12
12
|
</span>
|
package/src/index.ts
CHANGED
|
@@ -46,7 +46,14 @@ 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
|
-
export { default as ChatDialog } from
|
|
49
|
+
export { default as ChatDialog } from "./components/ChatDialog.svelte";
|
|
50
|
+
export { default as MarkdownRenderer } from "./components/MarkdownRenderer.svelte";
|
|
51
|
+
export { default as SmartSuggest } from "./components/SmartSuggest.svelte";
|
|
52
|
+
export { default as AICommandBar } from "./components/AICommandBar.svelte";
|
|
53
|
+
export { default as CopilotPanel } from "./components/CopilotPanel.svelte";
|
|
54
|
+
export { default as InsightCard } from "./components/InsightCard.svelte";
|
|
55
|
+
export { default as AnomalyBadge } from "./components/AnomalyBadge.svelte";
|
|
56
|
+
export { default as VoiceInput } from "./components/VoiceInput.svelte";
|
|
50
57
|
// Field display components
|
|
51
58
|
export { default as NumberField } from './components/fields/NumberField.svelte';
|
|
52
59
|
export { default as DateField } from './components/fields/DateField.svelte';
|