@svadmin/ui 0.18.0 → 0.20.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 +24 -2
- package/src/components/AuditLogViewer.svelte +226 -0
- package/src/components/AutoTable.svelte +11 -5
- package/src/components/CanAccess.svelte +1 -1
- package/src/components/ChatDialog.svelte +104 -20
- package/src/components/CommandPalette.svelte +47 -5
- package/src/components/DraggableGrid.svelte +90 -0
- package/src/components/FieldRenderer.svelte +36 -2
- package/src/components/MarkdownRenderer.svelte +0 -4
- package/src/components/PermissionMatrix.svelte +190 -0
- package/src/components/RolesSettings.svelte +144 -0
- package/src/components/SettingsPage.svelte +12 -2
- package/src/components/TaskQueueDrawer.svelte +170 -0
- package/src/components/TenantSwitcher.svelte +90 -0
- package/src/components/buttons/CloneButton.svelte +3 -3
- package/src/components/buttons/CreateButton.svelte +3 -3
- package/src/components/buttons/DeleteButton.svelte +3 -3
- package/src/components/buttons/EditButton.svelte +3 -3
- package/src/components/buttons/ExportButton.svelte +3 -3
- package/src/components/buttons/ImportButton.svelte +3 -3
- package/src/components/buttons/ShowButton.svelte +3 -3
- package/src/components/fields/ArrayField.svelte +86 -0
- package/src/index.ts +14 -0
- package/src/markdown-deps.d.ts +21 -0
- package/src/components/stories/BarChart.stories.ts +0 -43
- package/src/components/stories/LineChart.stories.ts +0 -48
- package/src/components/stories/PieChart.stories.ts +0 -41
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@svadmin/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "Pre-built admin UI components — AdminApp, AutoTable, AutoForm, Sidebar, Layout",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": [
|
|
@@ -26,7 +26,29 @@
|
|
|
26
26
|
"bits-ui": "^2.0.0",
|
|
27
27
|
"tailwind-variants": "^3.0.0",
|
|
28
28
|
"clsx": "^2.0.0",
|
|
29
|
-
"tailwind-merge": "^3.0.0"
|
|
29
|
+
"tailwind-merge": "^3.0.0",
|
|
30
|
+
"marked": "^15.0.0",
|
|
31
|
+
"marked-highlight": "^2.2.0",
|
|
32
|
+
"highlight.js": "^11.0.0",
|
|
33
|
+
"isomorphic-dompurify": "^2.0.0",
|
|
34
|
+
"@svadmin/editor": "^0.1.0"
|
|
35
|
+
},
|
|
36
|
+
"peerDependenciesMeta": {
|
|
37
|
+
"marked": {
|
|
38
|
+
"optional": true
|
|
39
|
+
},
|
|
40
|
+
"marked-highlight": {
|
|
41
|
+
"optional": true
|
|
42
|
+
},
|
|
43
|
+
"highlight.js": {
|
|
44
|
+
"optional": true
|
|
45
|
+
},
|
|
46
|
+
"isomorphic-dompurify": {
|
|
47
|
+
"optional": true
|
|
48
|
+
},
|
|
49
|
+
"@svadmin/editor": {
|
|
50
|
+
"optional": true
|
|
51
|
+
}
|
|
30
52
|
},
|
|
31
53
|
"dependencies": {
|
|
32
54
|
"@lucide/svelte": "^0.577.0",
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { t } from '@svadmin/core/i18n';
|
|
3
|
+
import { getAuthProvider } from '@svadmin/core';
|
|
4
|
+
import type { AuditLog } from '@svadmin/core';
|
|
5
|
+
import { toast } from '@svadmin/core/toast';
|
|
6
|
+
import * as Table from './ui/table/index.js';
|
|
7
|
+
import { Badge } from './ui/badge/index.js';
|
|
8
|
+
import { Button } from './ui/button/index.js';
|
|
9
|
+
import { Input } from './ui/input/index.js';
|
|
10
|
+
import { Loader2, ChevronLeft, ChevronRight, Search, FileSearch, Eye, X } from 'lucide-svelte';
|
|
11
|
+
|
|
12
|
+
const authProvider = getAuthProvider();
|
|
13
|
+
|
|
14
|
+
let logs = $state<AuditLog[]>([]);
|
|
15
|
+
let total = $state(0);
|
|
16
|
+
let page = $state(1);
|
|
17
|
+
let pageSize = 20;
|
|
18
|
+
let loading = $state(true);
|
|
19
|
+
let error = $state<string | null>(null);
|
|
20
|
+
let searchQuery = $state('');
|
|
21
|
+
|
|
22
|
+
// Snapshot drawer
|
|
23
|
+
let drawerOpen = $state(false);
|
|
24
|
+
let drawerLog = $state<AuditLog | null>(null);
|
|
25
|
+
|
|
26
|
+
async function loadLogs() {
|
|
27
|
+
if (!authProvider?.getAuditLogs) {
|
|
28
|
+
error = t('settings.auditNotSupported') ?? 'Audit logs are not supported by the current AuthProvider';
|
|
29
|
+
loading = false;
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
loading = true;
|
|
33
|
+
try {
|
|
34
|
+
const result = await authProvider.getAuditLogs({ page, pageSize });
|
|
35
|
+
logs = result.data;
|
|
36
|
+
total = result.total;
|
|
37
|
+
} catch (e) {
|
|
38
|
+
toast.error((e as Error).message);
|
|
39
|
+
} finally {
|
|
40
|
+
loading = false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
$effect(() => {
|
|
45
|
+
loadLogs();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
let totalPages = $derived(Math.ceil(total / pageSize) || 1);
|
|
49
|
+
|
|
50
|
+
let filteredLogs = $derived(
|
|
51
|
+
searchQuery.trim()
|
|
52
|
+
? logs.filter(l =>
|
|
53
|
+
(l.userName ?? '').toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
54
|
+
l.action.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
55
|
+
(l.resource ?? '').toLowerCase().includes(searchQuery.toLowerCase())
|
|
56
|
+
)
|
|
57
|
+
: logs
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
function openSnapshot(log: AuditLog) {
|
|
61
|
+
drawerLog = log;
|
|
62
|
+
drawerOpen = true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function formatDate(d: string | Date) {
|
|
66
|
+
return new Date(d).toLocaleString();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function getActionColor(action: string): 'default' | 'secondary' | 'destructive' | 'outline' {
|
|
70
|
+
const a = action.toLowerCase();
|
|
71
|
+
if (a.includes('delete') || a.includes('remove')) return 'destructive';
|
|
72
|
+
if (a.includes('create') || a.includes('add')) return 'default';
|
|
73
|
+
if (a.includes('update') || a.includes('edit')) return 'secondary';
|
|
74
|
+
return 'outline';
|
|
75
|
+
}
|
|
76
|
+
</script>
|
|
77
|
+
|
|
78
|
+
<div class="h-full flex flex-col">
|
|
79
|
+
<!-- Header -->
|
|
80
|
+
<div class="mb-6">
|
|
81
|
+
<h2 class="text-xl font-semibold text-foreground flex items-center gap-2">
|
|
82
|
+
<FileSearch class="h-5 w-5 text-muted-foreground" />
|
|
83
|
+
{t('settings.auditLogs') ?? 'Audit Logs'}
|
|
84
|
+
</h2>
|
|
85
|
+
<p class="text-sm text-muted-foreground mt-1">
|
|
86
|
+
{t('settings.auditDescription') ?? 'Track and review all system operations for compliance and debugging.'}
|
|
87
|
+
</p>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
{#if error}
|
|
91
|
+
<div class="p-6 border border-destructive/20 bg-destructive/5 text-destructive rounded-lg text-center">
|
|
92
|
+
<p>{error}</p>
|
|
93
|
+
</div>
|
|
94
|
+
{:else}
|
|
95
|
+
<!-- Search Bar -->
|
|
96
|
+
<div class="mb-4 relative">
|
|
97
|
+
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
98
|
+
<Input
|
|
99
|
+
bind:value={searchQuery}
|
|
100
|
+
placeholder={t('settings.searchLogs') ?? 'Search by user, action, or resource...'}
|
|
101
|
+
class="pl-9"
|
|
102
|
+
/>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
<!-- Table -->
|
|
106
|
+
<div class="flex-1 border rounded-lg bg-card overflow-hidden">
|
|
107
|
+
{#if loading}
|
|
108
|
+
<div class="flex items-center justify-center h-48 text-muted-foreground">
|
|
109
|
+
<Loader2 class="h-5 w-5 animate-spin mr-2" />
|
|
110
|
+
{t('common.loading')}
|
|
111
|
+
</div>
|
|
112
|
+
{:else}
|
|
113
|
+
<div class="overflow-auto">
|
|
114
|
+
<Table.Root>
|
|
115
|
+
<Table.Header>
|
|
116
|
+
<Table.Row>
|
|
117
|
+
<Table.Head class="w-[160px]">{t('settings.auditTime') ?? 'Time'}</Table.Head>
|
|
118
|
+
<Table.Head>{t('settings.auditUser') ?? 'User'}</Table.Head>
|
|
119
|
+
<Table.Head>{t('settings.auditAction') ?? 'Action'}</Table.Head>
|
|
120
|
+
<Table.Head>{t('settings.auditResource') ?? 'Resource'}</Table.Head>
|
|
121
|
+
<Table.Head class="w-[120px]">{t('settings.auditIp') ?? 'IP Address'}</Table.Head>
|
|
122
|
+
<Table.Head class="w-[60px] text-center">{t('common.detail') ?? 'Detail'}</Table.Head>
|
|
123
|
+
</Table.Row>
|
|
124
|
+
</Table.Header>
|
|
125
|
+
<Table.Body>
|
|
126
|
+
{#each filteredLogs as log}
|
|
127
|
+
<Table.Row class="hover:bg-muted/50 transition-colors">
|
|
128
|
+
<Table.Cell class="text-xs text-muted-foreground font-mono">
|
|
129
|
+
{formatDate(log.createdAt)}
|
|
130
|
+
</Table.Cell>
|
|
131
|
+
<Table.Cell class="text-sm font-medium">{log.userName ?? log.userId ?? '—'}</Table.Cell>
|
|
132
|
+
<Table.Cell>
|
|
133
|
+
<Badge variant={getActionColor(log.action)}>{log.action}</Badge>
|
|
134
|
+
</Table.Cell>
|
|
135
|
+
<Table.Cell class="text-sm text-muted-foreground">{log.resource ?? '—'}</Table.Cell>
|
|
136
|
+
<Table.Cell class="text-xs text-muted-foreground font-mono">{log.ipAddress ?? '—'}</Table.Cell>
|
|
137
|
+
<Table.Cell class="text-center">
|
|
138
|
+
{#if log.details}
|
|
139
|
+
<Button variant="ghost" size="icon" class="h-7 w-7" onclick={() => openSnapshot(log)}>
|
|
140
|
+
<Eye class="h-3.5 w-3.5" />
|
|
141
|
+
</Button>
|
|
142
|
+
{/if}
|
|
143
|
+
</Table.Cell>
|
|
144
|
+
</Table.Row>
|
|
145
|
+
{:else}
|
|
146
|
+
<Table.Row>
|
|
147
|
+
<Table.Cell colspan={6} class="h-24 text-center text-muted-foreground">
|
|
148
|
+
{t('common.noData')}
|
|
149
|
+
</Table.Cell>
|
|
150
|
+
</Table.Row>
|
|
151
|
+
{/each}
|
|
152
|
+
</Table.Body>
|
|
153
|
+
</Table.Root>
|
|
154
|
+
</div>
|
|
155
|
+
{/if}
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
<!-- Pagination -->
|
|
159
|
+
<div class="mt-4 flex items-center justify-between text-sm text-muted-foreground">
|
|
160
|
+
<span>{total} {t('settings.auditTotal') ?? 'total records'}</span>
|
|
161
|
+
<div class="flex items-center gap-2">
|
|
162
|
+
<Button
|
|
163
|
+
variant="outline" size="sm"
|
|
164
|
+
disabled={page <= 1}
|
|
165
|
+
onclick={() => { page--; loadLogs(); }}
|
|
166
|
+
>
|
|
167
|
+
<ChevronLeft class="h-4 w-4" />
|
|
168
|
+
</Button>
|
|
169
|
+
<span>{page} / {totalPages}</span>
|
|
170
|
+
<Button
|
|
171
|
+
variant="outline" size="sm"
|
|
172
|
+
disabled={page >= totalPages}
|
|
173
|
+
onclick={() => { page++; loadLogs(); }}
|
|
174
|
+
>
|
|
175
|
+
<ChevronRight class="h-4 w-4" />
|
|
176
|
+
</Button>
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
{/if}
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
<!-- Snapshot Drawer -->
|
|
183
|
+
{#if drawerOpen && drawerLog}
|
|
184
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
185
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
186
|
+
<div class="fixed inset-0 z-[100] flex justify-end bg-black/40 backdrop-blur-sm" onclick={() => drawerOpen = false}>
|
|
187
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
188
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
189
|
+
<div class="bg-card shadow-2xl w-full max-w-2xl h-full flex flex-col animate-in slide-in-from-right duration-300" onclick={(e) => e.stopPropagation()}>
|
|
190
|
+
<!-- Drawer Header -->
|
|
191
|
+
<div class="p-6 border-b border-border flex items-center justify-between bg-muted/30">
|
|
192
|
+
<div>
|
|
193
|
+
<h2 class="text-lg font-bold text-foreground flex items-center gap-2">
|
|
194
|
+
<FileSearch class="h-5 w-5 text-primary" />
|
|
195
|
+
{t('settings.auditSnapshot') ?? 'Change Snapshot'}
|
|
196
|
+
</h2>
|
|
197
|
+
<p class="text-xs text-muted-foreground mt-1">{formatDate(drawerLog.createdAt)} · {drawerLog.userName ?? drawerLog.userId}</p>
|
|
198
|
+
</div>
|
|
199
|
+
<button onclick={() => drawerOpen = false} class="text-muted-foreground hover:text-foreground transition p-2 rounded-lg hover:bg-muted">
|
|
200
|
+
<X class="w-5 h-5" />
|
|
201
|
+
</button>
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
<!-- Meta Tags -->
|
|
205
|
+
<div class="px-6 py-3 border-b border-border flex items-center gap-2 flex-wrap">
|
|
206
|
+
<Badge variant="outline">{drawerLog.action}</Badge>
|
|
207
|
+
{#if drawerLog.resource}
|
|
208
|
+
<Badge variant="secondary">{drawerLog.resource}</Badge>
|
|
209
|
+
{/if}
|
|
210
|
+
{#if drawerLog.ipAddress}
|
|
211
|
+
<Badge variant="outline" class="font-mono text-xs">{drawerLog.ipAddress}</Badge>
|
|
212
|
+
{/if}
|
|
213
|
+
</div>
|
|
214
|
+
|
|
215
|
+
<!-- JSON Diff Viewer -->
|
|
216
|
+
<div class="flex-1 p-6 overflow-y-auto bg-foreground/[0.03] dark:bg-background/50">
|
|
217
|
+
<pre class="whitespace-pre-wrap text-xs font-mono leading-relaxed text-foreground/80 bg-muted/30 rounded-lg p-4 border border-border">{typeof drawerLog.details === 'object' ? JSON.stringify(drawerLog.details, null, 2) : drawerLog.details}</pre>
|
|
218
|
+
</div>
|
|
219
|
+
|
|
220
|
+
<!-- Footer -->
|
|
221
|
+
<div class="p-4 border-t border-border bg-muted/20 text-right text-[11px] text-muted-foreground font-medium">
|
|
222
|
+
System audit snapshot · Read-only
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
{/if}
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { useList, useDelete, getResource } from '@svadmin/core';
|
|
14
14
|
import type { Pagination as PaginationState, Sort, Filter, FieldDefinition } from '@svadmin/core';
|
|
15
15
|
import { navigate } from '@svadmin/core/router';
|
|
16
|
-
import {
|
|
16
|
+
import { useCan, getAccessControlProvider } from '@svadmin/core';
|
|
17
17
|
import { readURLState, writeURLState } from '@svadmin/core';
|
|
18
18
|
import { t } from '@svadmin/core/i18n';
|
|
19
19
|
import { fade } from 'svelte/transition';
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
import ConfirmDialog from './ConfirmDialog.svelte';
|
|
37
37
|
import TooltipButton from './TooltipButton.svelte';
|
|
38
38
|
import InlineEdit from './InlineEdit.svelte';
|
|
39
|
+
import DraggableHeader from './DraggableHeader.svelte';
|
|
39
40
|
import type { Snippet } from 'svelte';
|
|
40
41
|
|
|
41
42
|
// ─── Props with Snippet composability ─────────────────────────
|
|
@@ -136,10 +137,15 @@
|
|
|
136
137
|
const deleteMutation = deleteResult.mutation;
|
|
137
138
|
|
|
138
139
|
// ─── Permissions ──────────────────────────────────────────────
|
|
139
|
-
const
|
|
140
|
-
const
|
|
141
|
-
const
|
|
142
|
-
const
|
|
140
|
+
const acEnabled = $derived(!!getAccessControlProvider());
|
|
141
|
+
const canCreatePerm = useCan(() => ({ resource: resourceName, action: 'create', queryOptions: { enabled: acEnabled } }));
|
|
142
|
+
const canEditPerm = useCan(() => ({ resource: resourceName, action: 'edit', queryOptions: { enabled: acEnabled } }));
|
|
143
|
+
const canDeletePerm = useCan(() => ({ resource: resourceName, action: 'delete', queryOptions: { enabled: acEnabled } }));
|
|
144
|
+
const canExportPerm = useCan(() => ({ resource: resourceName, action: 'export', queryOptions: { enabled: acEnabled } }));
|
|
145
|
+
const canCreate = $derived(canCreatePerm.allowed && resource.canCreate !== false);
|
|
146
|
+
const canEdit = $derived(canEditPerm.allowed && resource.canEdit !== false);
|
|
147
|
+
const canDelete = $derived(canDeletePerm.allowed && resource.canDelete !== false);
|
|
148
|
+
const canExport = $derived(canExportPerm.allowed);
|
|
143
149
|
|
|
144
150
|
// ─── TanStack Table state ────────────────────────────────────
|
|
145
151
|
let sorting = $state<SortingState>(untrack(() =>
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import { getChatProvider, getChatContext, setChatContext } from '@svadmin/core';
|
|
3
|
-
import type { ChatMessage, ChatAction } from '@svadmin/core';
|
|
2
|
+
import { getChatProvider, getChatContext, setChatContext, getAgentProvider, registerApproval, resolveApproval } from '@svadmin/core';
|
|
3
|
+
import type { ChatMessage, ChatAction, AgentEvent } from '@svadmin/core';
|
|
4
4
|
import { t } from '@svadmin/core/i18n';
|
|
5
5
|
import { useParsed } from '@svadmin/core';
|
|
6
6
|
import { fly, fade, scale } from 'svelte/transition';
|
|
7
7
|
import { Button } from './ui/button/index.js';
|
|
8
8
|
import TooltipButton from './TooltipButton.svelte';
|
|
9
|
-
import { MessageCircle, X, Minus, Send, Loader2, Bot, Trash2 } from 'lucide-svelte';
|
|
9
|
+
import { MessageCircle, X, Minus, Send, Loader2, Bot, Trash2, ShieldCheck, ShieldX, Wrench, LayoutDashboard } from 'lucide-svelte';
|
|
10
10
|
|
|
11
11
|
interface Props {
|
|
12
12
|
/** localStorage key for chat history persistence. Set to '' to disable. */
|
|
@@ -37,8 +37,12 @@
|
|
|
37
37
|
let initialized = $state(false);
|
|
38
38
|
|
|
39
39
|
const provider = $derived(getChatProvider());
|
|
40
|
+
const agent = $derived(getAgentProvider());
|
|
40
41
|
const parsed = useParsed();
|
|
41
42
|
|
|
43
|
+
/** Track pending approval requests for UI rendering */
|
|
44
|
+
let pendingApprovalIds = $state<string[]>([]);
|
|
45
|
+
|
|
42
46
|
// ─── Auto-update ChatContext from current route ─────────────
|
|
43
47
|
$effect(() => {
|
|
44
48
|
setChatContext({
|
|
@@ -125,7 +129,7 @@
|
|
|
125
129
|
}
|
|
126
130
|
|
|
127
131
|
async function sendMessage() {
|
|
128
|
-
if (!inputValue.trim() || isStreaming || !provider) return;
|
|
132
|
+
if (!inputValue.trim() || isStreaming || (!provider && !agent)) return;
|
|
129
133
|
|
|
130
134
|
const userMsg: ChatMessage = {
|
|
131
135
|
id: genId(),
|
|
@@ -157,24 +161,93 @@
|
|
|
157
161
|
const allMessages = messages.filter((m) => m.content);
|
|
158
162
|
const messagesToSend = contextMsg ? [contextMsg, ...allMessages] : allMessages;
|
|
159
163
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
164
|
+
if (agent) {
|
|
165
|
+
// ─── AgentProvider path: typed event stream ─────────
|
|
166
|
+
const stream = agent.chat(messagesToSend, {
|
|
167
|
+
signal: abortController.signal,
|
|
168
|
+
context: getChatContext(),
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const collectedActions: ChatAction[] = [];
|
|
172
|
+
|
|
173
|
+
for await (const event of stream) {
|
|
174
|
+
switch (event.type) {
|
|
175
|
+
case 'text':
|
|
176
|
+
assistantMsg.content += event.content;
|
|
177
|
+
messages = [...messages.slice(0, -1), { ...assistantMsg }];
|
|
178
|
+
scrollToBottom();
|
|
179
|
+
break;
|
|
180
|
+
|
|
181
|
+
case 'tool_call':
|
|
182
|
+
assistantMsg.content += `\n🔧 *${event.tool}*(${JSON.stringify(event.args)})`;
|
|
183
|
+
messages = [...messages.slice(0, -1), { ...assistantMsg }];
|
|
184
|
+
scrollToBottom();
|
|
185
|
+
break;
|
|
186
|
+
|
|
187
|
+
case 'tool_result':
|
|
188
|
+
if (event.result.success) {
|
|
189
|
+
assistantMsg.content += `\n✅ ${event.tool} completed`;
|
|
190
|
+
} else {
|
|
191
|
+
assistantMsg.content += `\n❌ ${event.tool} failed: ${event.result.error ?? 'Unknown error'}`;
|
|
192
|
+
}
|
|
193
|
+
messages = [...messages.slice(0, -1), { ...assistantMsg }];
|
|
194
|
+
scrollToBottom();
|
|
195
|
+
break;
|
|
196
|
+
|
|
197
|
+
case 'approval_request': {
|
|
198
|
+
// Register in the core approval system
|
|
199
|
+
const reqId = event.id;
|
|
200
|
+
pendingApprovalIds = [...pendingApprovalIds, reqId];
|
|
201
|
+
|
|
202
|
+
collectedActions.push(
|
|
203
|
+
{
|
|
204
|
+
label: `✅ ${t('common.confirm') || 'Approve'}: ${event.description}`,
|
|
205
|
+
variant: 'default',
|
|
206
|
+
payload: { approvalId: reqId, approved: true },
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
label: `❌ ${t('common.cancel') || 'Reject'}`,
|
|
210
|
+
variant: 'destructive',
|
|
211
|
+
payload: { approvalId: reqId, approved: false },
|
|
212
|
+
},
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
assistantMsg.content += `\n⚠️ **${t('chat.approvalRequired') || 'Approval required'}**: ${event.description}`;
|
|
216
|
+
assistantMsg.actions = [...collectedActions];
|
|
217
|
+
messages = [...messages.slice(0, -1), { ...assistantMsg }];
|
|
218
|
+
scrollToBottom();
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
case 'component':
|
|
223
|
+
assistantMsg.content += `\n📊 [${event.name}]`;
|
|
224
|
+
messages = [...messages.slice(0, -1), { ...assistantMsg }];
|
|
225
|
+
scrollToBottom();
|
|
226
|
+
break;
|
|
227
|
+
|
|
228
|
+
case 'done':
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
} else if (provider) {
|
|
233
|
+
// ─── ChatProvider path: raw text stream ─────────────
|
|
234
|
+
const result = provider.sendMessage(
|
|
235
|
+
messagesToSend,
|
|
236
|
+
{ signal: abortController.signal },
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
if (result && typeof result === 'object' && Symbol.asyncIterator in result) {
|
|
240
|
+
for await (const chunk of result as AsyncGenerator<string>) {
|
|
241
|
+
assistantMsg.content += chunk;
|
|
242
|
+
messages = [...messages.slice(0, -1), { ...assistantMsg }];
|
|
243
|
+
scrollToBottom();
|
|
244
|
+
}
|
|
245
|
+
} else {
|
|
246
|
+
const text = await (result as Promise<string>);
|
|
247
|
+
assistantMsg.content = text;
|
|
169
248
|
messages = [...messages.slice(0, -1), { ...assistantMsg }];
|
|
170
249
|
scrollToBottom();
|
|
171
250
|
}
|
|
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
251
|
}
|
|
179
252
|
} catch (err: unknown) {
|
|
180
253
|
if (err instanceof Error && err.name === 'AbortError') return;
|
|
@@ -228,6 +301,17 @@
|
|
|
228
301
|
}
|
|
229
302
|
|
|
230
303
|
function handleAction(action: ChatAction, msg: ChatMessage) {
|
|
304
|
+
// Handle approval actions from AgentProvider
|
|
305
|
+
if (action.payload?.approvalId) {
|
|
306
|
+
const id = action.payload.approvalId as string;
|
|
307
|
+
const approved = action.payload.approved as boolean;
|
|
308
|
+
resolveApproval(id, approved);
|
|
309
|
+
pendingApprovalIds = pendingApprovalIds.filter(pid => pid !== id);
|
|
310
|
+
// Remove action buttons after resolution
|
|
311
|
+
msg.actions = undefined;
|
|
312
|
+
messages = [...messages];
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
231
315
|
if (onAction) {
|
|
232
316
|
onAction(action, msg);
|
|
233
317
|
}
|
|
@@ -252,7 +336,7 @@
|
|
|
252
336
|
|
|
253
337
|
<svelte:window onkeydown={handleGlobalKeydown} />
|
|
254
338
|
|
|
255
|
-
{#if provider}
|
|
339
|
+
{#if provider || agent}
|
|
256
340
|
<!-- FAB Button -->
|
|
257
341
|
{#if !open}
|
|
258
342
|
<div transition:scale={{ duration: 200 }}>
|
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import { getResources } from '@svadmin/core';
|
|
2
|
+
import { getResources, getAgentProvider, getChatProvider } from '@svadmin/core';
|
|
3
3
|
import { navigate } from '@svadmin/core/router';
|
|
4
4
|
import { t } from '@svadmin/core/i18n';
|
|
5
5
|
import { toggleTheme } from '@svadmin/core';
|
|
6
6
|
import { Command } from 'cmdk-sv';
|
|
7
7
|
import * as Dialog from './ui/dialog/index.js';
|
|
8
|
-
import { Search, LayoutDashboard, Plus, Sun, FileText } from 'lucide-svelte';
|
|
8
|
+
import { Search, LayoutDashboard, Plus, Sun, FileText, Bot, Sparkles } from 'lucide-svelte';
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
interface Props {
|
|
11
|
+
open?: boolean;
|
|
12
|
+
/** Callback fired when user selects "Ask AI" with a query string */
|
|
13
|
+
onAskAI?: (query: string) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let { open = $bindable(false), onAskAI }: Props = $props();
|
|
11
17
|
let searchValue = $state('');
|
|
12
18
|
|
|
13
19
|
const resources = getResources();
|
|
20
|
+
const hasAI = $derived(!!(getAgentProvider() || getChatProvider()));
|
|
14
21
|
|
|
15
22
|
function close() {
|
|
16
23
|
open = false;
|
|
@@ -22,6 +29,14 @@
|
|
|
22
29
|
close();
|
|
23
30
|
}
|
|
24
31
|
|
|
32
|
+
function askAI() {
|
|
33
|
+
const query = searchValue.trim();
|
|
34
|
+
if (onAskAI && query) {
|
|
35
|
+
onAskAI(query);
|
|
36
|
+
}
|
|
37
|
+
close();
|
|
38
|
+
}
|
|
39
|
+
|
|
25
40
|
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';
|
|
26
41
|
</script>
|
|
27
42
|
|
|
@@ -32,16 +47,42 @@
|
|
|
32
47
|
<Search class="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
|
33
48
|
<Command.Input
|
|
34
49
|
bind:value={searchValue}
|
|
35
|
-
placeholder={t('common.search')}
|
|
50
|
+
placeholder={hasAI ? (t('commandPalette.searchOrAsk') || 'Search or ask AI...') : t('common.search')}
|
|
36
51
|
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"
|
|
37
52
|
/>
|
|
38
53
|
<kbd class="ml-2 text-[10px] font-mono font-medium text-muted-foreground bg-muted px-1.5 py-0.5 rounded border border-border">ESC</kbd>
|
|
39
54
|
</div>
|
|
40
55
|
<Command.List class="max-h-[320px] overflow-y-auto overflow-x-hidden p-1">
|
|
41
56
|
<Command.Empty class="py-6 text-center text-sm text-muted-foreground">
|
|
42
|
-
{
|
|
57
|
+
{#if hasAI && searchValue.trim()}
|
|
58
|
+
<button
|
|
59
|
+
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-primary/10 text-primary hover:bg-primary/20 transition-colors cursor-pointer text-sm font-medium"
|
|
60
|
+
onclick={askAI}
|
|
61
|
+
>
|
|
62
|
+
<Sparkles class="h-3.5 w-3.5" />
|
|
63
|
+
{t('commandPalette.askAI') || 'Ask AI'}: "{searchValue.trim()}"
|
|
64
|
+
</button>
|
|
65
|
+
{:else}
|
|
66
|
+
{t('common.noData')}
|
|
67
|
+
{/if}
|
|
43
68
|
</Command.Empty>
|
|
44
69
|
|
|
70
|
+
<!-- AI -->
|
|
71
|
+
{#if hasAI && searchValue.trim()}
|
|
72
|
+
<Command.Group heading="AI">
|
|
73
|
+
<Command.Item
|
|
74
|
+
value="ask-ai-{searchValue}"
|
|
75
|
+
onSelect={askAI}
|
|
76
|
+
class={itemClass}
|
|
77
|
+
>
|
|
78
|
+
<Sparkles class="h-4 w-4 text-primary" />
|
|
79
|
+
<span class="text-primary font-medium">{t('commandPalette.askAI') || 'Ask AI'}</span>
|
|
80
|
+
<span class="text-muted-foreground truncate ml-1">"{searchValue.trim()}"</span>
|
|
81
|
+
</Command.Item>
|
|
82
|
+
</Command.Group>
|
|
83
|
+
<Command.Separator class="mx-1 my-1 h-px bg-border" />
|
|
84
|
+
{/if}
|
|
85
|
+
|
|
45
86
|
<!-- Navigation -->
|
|
46
87
|
<Command.Group heading={t('common.home')}>
|
|
47
88
|
<Command.Item
|
|
@@ -91,3 +132,4 @@
|
|
|
91
132
|
</Command.Root>
|
|
92
133
|
</Dialog.DialogContent>
|
|
93
134
|
</Dialog.Dialog>
|
|
135
|
+
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
|
|
4
|
+
export interface GridModule {
|
|
5
|
+
id: string;
|
|
6
|
+
[key: string]: unknown;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
let {
|
|
10
|
+
modules = $bindable<GridModule[]>([]),
|
|
11
|
+
onOrderSave,
|
|
12
|
+
renderItem,
|
|
13
|
+
class: className = '',
|
|
14
|
+
columns = 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
|
|
15
|
+
gap = 'gap-4',
|
|
16
|
+
}: {
|
|
17
|
+
modules?: GridModule[];
|
|
18
|
+
onOrderSave?: (orderedIds: string[]) => void;
|
|
19
|
+
renderItem: Snippet<[{ module: GridModule; index: number; dragging: boolean }]>;
|
|
20
|
+
class?: string;
|
|
21
|
+
columns?: string;
|
|
22
|
+
gap?: string;
|
|
23
|
+
} = $props();
|
|
24
|
+
|
|
25
|
+
let draggingId = $state<string | null>(null);
|
|
26
|
+
let dragOverId = $state<string | null>(null);
|
|
27
|
+
|
|
28
|
+
function handleDragStart(e: DragEvent, id: string) {
|
|
29
|
+
draggingId = id;
|
|
30
|
+
if (e.dataTransfer) {
|
|
31
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
32
|
+
e.dataTransfer.setData('text/plain', id);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function handleDragOver(e: DragEvent, id: string) {
|
|
37
|
+
e.preventDefault();
|
|
38
|
+
if (draggingId && draggingId !== id) {
|
|
39
|
+
dragOverId = id;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function handleDrop(e: DragEvent, targetId: string) {
|
|
44
|
+
e.preventDefault();
|
|
45
|
+
if (!draggingId || draggingId === targetId) {
|
|
46
|
+
dragOverId = null;
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const sourceIdx = modules.findIndex(m => m.id === draggingId);
|
|
51
|
+
const targetIdx = modules.findIndex(m => m.id === targetId);
|
|
52
|
+
|
|
53
|
+
if (sourceIdx === -1 || targetIdx === -1) {
|
|
54
|
+
dragOverId = null;
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const newModules = [...modules];
|
|
59
|
+
const [dragged] = newModules.splice(sourceIdx, 1);
|
|
60
|
+
newModules.splice(targetIdx, 0, dragged);
|
|
61
|
+
|
|
62
|
+
modules = newModules;
|
|
63
|
+
dragOverId = null;
|
|
64
|
+
onOrderSave?.(modules.map(m => m.id));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function handleDragEnd() {
|
|
68
|
+
draggingId = null;
|
|
69
|
+
dragOverId = null;
|
|
70
|
+
}
|
|
71
|
+
</script>
|
|
72
|
+
|
|
73
|
+
<div class="grid {columns} {gap} {className}">
|
|
74
|
+
{#each modules as mod, i (mod.id)}
|
|
75
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
76
|
+
<div
|
|
77
|
+
draggable="true"
|
|
78
|
+
ondragstart={(e) => handleDragStart(e, mod.id)}
|
|
79
|
+
ondragover={(e) => handleDragOver(e, mod.id)}
|
|
80
|
+
ondrop={(e) => handleDrop(e, mod.id)}
|
|
81
|
+
ondragend={handleDragEnd}
|
|
82
|
+
ondragleave={() => { if (dragOverId === mod.id) dragOverId = null; }}
|
|
83
|
+
class="transition-all duration-200
|
|
84
|
+
{draggingId === mod.id ? 'opacity-40 scale-[0.97] cursor-grabbing' : 'cursor-grab'}
|
|
85
|
+
{dragOverId === mod.id ? 'ring-2 ring-primary/40 ring-offset-2 ring-offset-background rounded-lg scale-[1.02]' : ''}"
|
|
86
|
+
>
|
|
87
|
+
{@render renderItem({ module: mod, index: i, dragging: draggingId === mod.id })}
|
|
88
|
+
</div>
|
|
89
|
+
{/each}
|
|
90
|
+
</div>
|