@svadmin/ui 0.19.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/ui",
3
- "version": "0.19.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": [
@@ -30,7 +30,8 @@
30
30
  "marked": "^15.0.0",
31
31
  "marked-highlight": "^2.2.0",
32
32
  "highlight.js": "^11.0.0",
33
- "isomorphic-dompurify": "^2.0.0"
33
+ "isomorphic-dompurify": "^2.0.0",
34
+ "@svadmin/editor": "^0.1.0"
34
35
  },
35
36
  "peerDependenciesMeta": {
36
37
  "marked": {
@@ -44,6 +45,9 @@
44
45
  },
45
46
  "isomorphic-dompurify": {
46
47
  "optional": true
48
+ },
49
+ "@svadmin/editor": {
50
+ "optional": true
47
51
  }
48
52
  },
49
53
  "dependencies": {
@@ -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 ─────────────────────────
@@ -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
- const result = provider.sendMessage(
161
- messagesToSend,
162
- { signal: abortController.signal },
163
- );
164
-
165
- if (result && typeof result === 'object' && Symbol.asyncIterator in result) {
166
- // Streaming response
167
- for await (const chunk of result as AsyncGenerator<string>) {
168
- assistantMsg.content += chunk;
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
- let { open = $bindable(false) } = $props<{ open?: boolean }>();
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
- {t('common.noData')}
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
+
@@ -163,13 +163,43 @@
163
163
  required={field.required}
164
164
  />
165
165
 
166
- {:else if field.type === 'textarea' || field.type === 'richtext'}
166
+ {:else if field.type === 'richtext'}
167
+ {#await import('@svadmin/editor')}
168
+ <Textarea
169
+ id={field.key}
170
+ value={strVal}
171
+ oninput={(e) => onchange((e.target as HTMLTextAreaElement).value)}
172
+ required={field.required}
173
+ rows={10}
174
+ placeholder={t('field.enterValue', { label: field.label })}
175
+ class="resize-y"
176
+ />
177
+ {:then editorMod}
178
+ <editorMod.Editor
179
+ value={strVal}
180
+ placeholder={t('field.enterValue', { label: field.label })}
181
+ preset="full"
182
+ onchange={(html) => onchange(html)}
183
+ />
184
+ {:catch}
185
+ <Textarea
186
+ id={field.key}
187
+ value={strVal}
188
+ oninput={(e) => onchange((e.target as HTMLTextAreaElement).value)}
189
+ required={field.required}
190
+ rows={10}
191
+ placeholder={t('field.enterValue', { label: field.label })}
192
+ class="resize-y"
193
+ />
194
+ {/await}
195
+
196
+ {:else if field.type === 'textarea'}
167
197
  <Textarea
168
198
  id={field.key}
169
199
  value={strVal}
170
200
  oninput={(e) => onchange((e.target as HTMLTextAreaElement).value)}
171
201
  required={field.required}
172
- rows={field.type === 'richtext' ? 10 : 4}
202
+ rows={4}
173
203
  placeholder={t('field.enterValue', { label: field.label })}
174
204
  class="resize-y"
175
205
  />
package/src/index.ts CHANGED
@@ -93,6 +93,9 @@ export { default as RelationField } from './components/fields/RelationField.svel
93
93
  export { default as JsonField } from './components/fields/JsonField.svelte';
94
94
  export { default as RichTextField } from './components/fields/RichTextField.svelte';
95
95
 
96
+ // Rich text editor (requires @svadmin/editor)
97
+ export { Editor as RichTextEditor } from '@svadmin/editor';
98
+
96
99
  // CRUD Buttons
97
100
  export {
98
101
  CreateButton, EditButton, DeleteButton, ShowButton, ListButton,