@svadmin/ui 0.65.1 → 0.67.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.
Files changed (34) hide show
  1. package/dist/app.css +0 -51
  2. package/dist/app.theme.css +0 -51
  3. package/dist/components/AdminApp.svelte +5 -2
  4. package/dist/components/AdminApp.svelte.d.ts +3 -0
  5. package/dist/components/CommandPalette.svelte +3 -1
  6. package/dist/components/Layout.svelte +20 -12
  7. package/dist/components/Layout.svelte.d.ts +6 -0
  8. package/dist/components/MetricStrip.svelte +149 -0
  9. package/dist/components/MetricStrip.svelte.d.ts +32 -0
  10. package/dist/components/ui/badge/badge.svelte +5 -0
  11. package/dist/components/ui/badge/badge.svelte.d.ts +15 -0
  12. package/dist/components/ui/table/table-cell.svelte +14 -2
  13. package/dist/components/ui/table/table-cell.svelte.d.ts +4 -1
  14. package/dist/components/ui/table/table-head.svelte +14 -2
  15. package/dist/components/ui/table/table-head.svelte.d.ts +4 -1
  16. package/dist/components/ui/table/table-row.svelte +1 -1
  17. package/dist/index.d.ts +3 -7
  18. package/dist/index.js +1 -7
  19. package/package.json +3 -14
  20. package/dist/components/AICommandBar.svelte +0 -228
  21. package/dist/components/AICommandBar.svelte.d.ts +0 -6
  22. package/dist/components/ChatDialog.svelte +0 -947
  23. package/dist/components/ChatDialog.svelte.d.ts +0 -24
  24. package/dist/components/CopilotPanel.svelte +0 -204
  25. package/dist/components/CopilotPanel.svelte.d.ts +0 -6
  26. package/dist/components/InsightCard.svelte +0 -139
  27. package/dist/components/InsightCard.svelte.d.ts +0 -11
  28. package/dist/components/MarkdownRenderer.svelte +0 -245
  29. package/dist/components/MarkdownRenderer.svelte.d.ts +0 -11
  30. package/dist/components/SmartSuggest.svelte +0 -207
  31. package/dist/components/SmartSuggest.svelte.d.ts +0 -19
  32. package/dist/components/VoiceInput.svelte +0 -133
  33. package/dist/components/VoiceInput.svelte.d.ts +0 -15
  34. package/dist/markdown-deps.d.ts +0 -21
@@ -1,947 +0,0 @@
1
- <script lang="ts">
2
- /* eslint-disable svelte/no-at-html-tags */
3
- import { captureAdminContext } from '@svadmin/core';
4
- import type { AgentProvider, ChatMessage, ChatAction, ChatContext, ChatProvider } from '@svadmin/core';
5
- import { useTranslation } from '@svadmin/core/i18n';
6
-
7
- import { useParsed } from '@svadmin/core';
8
- import { onDestroy, untrack } from 'svelte';
9
- import { SvelteMap } from 'svelte/reactivity';
10
- import { fly, fade, scale } from 'svelte/transition';
11
- import { Button } from './ui/button/index.js';
12
- import TooltipButton from './TooltipButton.svelte';
13
- import { MessageCircle, X, Minus, Send, Loader2, Bot, Trash2 } from '@lucide/svelte';
14
-
15
- const i18n = useTranslation();
16
-
17
- interface Props {
18
- /** Render the launcher inside a layout toolbar instead of as a floating action button. */
19
- docked?: boolean;
20
- /**
21
- * Panel suffix for chat history persistence. Both default and explicit
22
- * suffixes are scoped by backend API + typed tenant identity.
23
- * Set to '' to disable persistence.
24
- */
25
- persistKey?: string;
26
- /** Custom persistence callback — called when messages change */
27
- onPersist?: (messages: ChatMessage[]) => void;
28
- /** Custom restore callback — called on mount to load history */
29
- onRestore?: () => ChatMessage[];
30
- /** Callback when user clicks an action button in an assistant message */
31
- onAction?: (action: ChatAction, message: ChatMessage) => void;
32
- /** Stable target for tree-scoped ask-ai events. */
33
- scope?: string;
34
- /** Owning Layout scope used to arbitrate global keyboard shortcuts. */
35
- ownerScope?: string;
36
- }
37
-
38
- const {
39
- docked = false,
40
- persistKey,
41
- onPersist,
42
- onRestore,
43
- onAction,
44
- scope,
45
- ownerScope,
46
- }: Props = $props();
47
- const generatedId = $props.id();
48
- const generatedScope = `svadmin-chat-${generatedId}`;
49
- const componentScope = $derived(scope ?? generatedScope);
50
-
51
- let open = $state(false);
52
- let minimized = $state(false);
53
- let inputValue = $state('');
54
- let messages = $state<ChatMessage[]>([]);
55
- let isStreaming = $state(false);
56
- let messagesContainer: HTMLDivElement | undefined = $state();
57
- let abortController: AbortController | null = null;
58
- let runEpoch = 0;
59
- let messageRevision = 0;
60
- let messageIdCounter = $state(0);
61
-
62
- const adminContext = captureAdminContext();
63
- const provider = $derived(adminContext.chatProvider);
64
- const agent = $derived(adminContext.agentProvider);
65
- const parsed = useParsed();
66
- const chatContext = $derived.by((): ChatContext => ({
67
- currentResource: parsed.resource,
68
- selectedRecordId: parsed.id,
69
- currentView: parsed.action as ChatContext['currentView'],
70
- pathname: `/${parsed.resource ?? ''}${parsed.action ? '/' + parsed.action : ''}${parsed.id ? '/' + parsed.id : ''}`,
71
- }));
72
-
73
- const approvalCallbacks = new SvelteMap<string, (approved: boolean) => void>();
74
-
75
- function stripApprovalActions(persist: boolean) {
76
- let changed = false;
77
- const nextMessages = messages.map((message) => {
78
- if (!message.actions?.some((action) => (
79
- action.payload != null
80
- && Object.prototype.hasOwnProperty.call(action.payload, 'approvalId')
81
- ))) return message;
82
- changed = true;
83
- const remainingActions = message.actions.filter((action) => (
84
- action.payload == null
85
- || !Object.prototype.hasOwnProperty.call(action.payload, 'approvalId')
86
- ));
87
- return {
88
- ...message,
89
- actions: remainingActions.length > 0 ? remainingActions : undefined,
90
- };
91
- });
92
- if (!changed) return;
93
- replaceMessages(nextMessages, persist);
94
- if (persist) {
95
- // User-triggered revocation must survive a remount immediately.
96
- flushPendingPersist();
97
- }
98
- }
99
-
100
- function invalidateActiveRun({
101
- persistApprovalRevocation = true,
102
- }: { persistApprovalRevocation?: boolean } = {}) {
103
- runEpoch++;
104
- const controller = abortController;
105
- abortController = null;
106
- isStreaming = false;
107
- controller?.abort();
108
- approvalCallbacks.clear();
109
- stripApprovalActions(persistApprovalRevocation);
110
- }
111
-
112
- function isCurrentRun(epoch: number, controller: AbortController) {
113
- return runEpoch === epoch
114
- && abortController === controller
115
- && !controller.signal.aborted;
116
- }
117
-
118
- let previousRunScope: {
119
- tenantIdentity: string | number | undefined;
120
- chatProvider: ChatProvider | null;
121
- agentProvider: AgentProvider | null;
122
- } | undefined;
123
-
124
- const historyScopeToken = $derived.by(() => {
125
- let apiUrl = 'unconfigured';
126
- try {
127
- apiUrl = adminContext.getDataProvider().getApiUrl();
128
- } catch {
129
- // Standalone legacy consumers may configure chat without a DataProvider.
130
- }
131
- const tenantIdentity = adminContext.tenantCacheKey?.__svadminTenant;
132
- const typedTenant = tenantIdentity === undefined
133
- ? null
134
- : [typeof tenantIdentity, String(tenantIdentity)];
135
- const baseKey = `svadmin-chat:${encodeURIComponent(JSON.stringify([apiUrl, typedTenant]))}`;
136
- return persistKey === undefined
137
- ? baseKey
138
- : `${baseKey}:${encodeURIComponent(JSON.stringify(persistKey))}`;
139
- });
140
- const resolvedPersistKey = $derived(persistKey === '' ? '' : historyScopeToken);
141
-
142
- interface AskAIEventDetail {
143
- query: string;
144
- /** ChatDialog's data-svadmin-chat-scope value. */
145
- scope?: string;
146
- /** Compatibility alias for scope. */
147
- target?: string;
148
- }
149
-
150
- // ─── Listen for cross-component triggers ─────────────────────
151
- $effect(() => {
152
- if (typeof window === 'undefined') return;
153
- const handler = (event: CustomEvent<string | AskAIEventDetail>) => {
154
- const detail = event.detail;
155
- if (typeof detail === 'string') return;
156
- const query = detail?.query;
157
- const target = detail?.scope ?? detail?.target;
158
- if (!query?.trim() || target !== componentScope) return;
159
-
160
- open = true;
161
- minimized = false;
162
- inputValue = query;
163
- void sendMessage();
164
- };
165
- const eventHandler = handler as unknown as (event: globalThis.Event) => void;
166
- window.addEventListener('svadmin:ask-ai', eventHandler);
167
- return () => window.removeEventListener('svadmin:ask-ai', eventHandler);
168
- });
169
-
170
- // ─── Persist on change (debounced) ──────────────────────────
171
- interface PersistSnapshotBase {
172
- key: string;
173
- messages: ChatMessage[];
174
- }
175
- type PersistSnapshot = PersistSnapshotBase & (
176
- | { sink: 'callback'; callback: (messages: ChatMessage[]) => void }
177
- | { sink: 'storage'; storage: Storage | null }
178
- );
179
-
180
- let persistTimer: ReturnType<typeof setTimeout> | null = null;
181
- let lastPersistSnapshot: PersistSnapshot | null = null;
182
- let restoredHistoryScope = $state<string | null>(null);
183
- let restoredPersistKey = $state<string | null>(null);
184
-
185
- function getChatStorage(): Storage | null {
186
- if (typeof window === 'undefined') return null;
187
- try {
188
- return window.localStorage;
189
- } catch {
190
- return null;
191
- }
192
- }
193
-
194
- function flushPersist(snapshot: PersistSnapshot) {
195
- if (snapshot.sink === 'callback') {
196
- snapshot.callback(snapshot.messages);
197
- } else if (snapshot.key) {
198
- try {
199
- snapshot.storage?.setItem(snapshot.key, JSON.stringify(snapshot.messages));
200
- } catch {
201
- // storage full or unavailable
202
- }
203
- }
204
- }
205
-
206
- function flushPendingPersist() {
207
- if (persistTimer) {
208
- clearTimeout(persistTimer);
209
- persistTimer = null;
210
- }
211
- if (lastPersistSnapshot) {
212
- const snapshot = lastPersistSnapshot;
213
- lastPersistSnapshot = null;
214
- flushPersist(snapshot);
215
- }
216
- }
217
-
218
- function cancelPendingPersist() {
219
- if (persistTimer) {
220
- clearTimeout(persistTimer);
221
- persistTimer = null;
222
- }
223
- lastPersistSnapshot = null;
224
- }
225
-
226
- function schedulePersist(nextMessages: ChatMessage[]) {
227
- const callback = onPersist;
228
- let snapshot: PersistSnapshot;
229
- if (callback) {
230
- // Custom persistence owns its lifecycle and must not lose early input
231
- // while the storage-backed restore path is still becoming ready.
232
- snapshot = {
233
- sink: 'callback',
234
- key: restoredPersistKey ?? resolvedPersistKey,
235
- messages: nextMessages,
236
- callback,
237
- };
238
- } else {
239
- const restoredKey = restoredPersistKey;
240
- if (!restoredKey || restoredKey !== resolvedPersistKey) return;
241
- snapshot = {
242
- sink: 'storage',
243
- key: restoredKey,
244
- messages: nextMessages,
245
- storage: getChatStorage(),
246
- };
247
- }
248
-
249
- if (persistTimer) clearTimeout(persistTimer);
250
- lastPersistSnapshot = snapshot;
251
- persistTimer = setTimeout(() => {
252
- persistTimer = null;
253
- if (!lastPersistSnapshot) return;
254
- const pendingSnapshot = lastPersistSnapshot;
255
- lastPersistSnapshot = null;
256
- flushPersist(pendingSnapshot);
257
- }, 300);
258
- }
259
-
260
- function replaceMessages(nextMessages: ChatMessage[], persist = true) {
261
- messageRevision++;
262
- messages = nextMessages;
263
- if (persist) schedulePersist(nextMessages);
264
- }
265
-
266
- function updateMessageById(
267
- messageId: string,
268
- update: (current: ChatMessage) => ChatMessage,
269
- ) {
270
- const index = messages.findIndex((message) => message.id === messageId);
271
- if (index < 0) return;
272
- const nextMessages = [...messages];
273
- nextMessages[index] = update(messages[index]);
274
- replaceMessages(nextMessages);
275
- }
276
-
277
- function parseStoredMessage(value: unknown): ChatMessage | null {
278
- if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
279
- const candidate = value as Record<string, unknown>;
280
- if (
281
- typeof candidate.id !== 'string'
282
- || (candidate.role !== 'user' && candidate.role !== 'assistant')
283
- || typeof candidate.content !== 'string'
284
- || typeof candidate.timestamp !== 'number'
285
- || !Number.isFinite(candidate.timestamp)
286
- ) return null;
287
-
288
- // Persisted actions are capability-bearing UI state. Their callbacks do
289
- // not survive a reload, so never revive them from mutable localStorage.
290
- return {
291
- id: candidate.id,
292
- role: candidate.role,
293
- content: candidate.content,
294
- timestamp: candidate.timestamp,
295
- };
296
- }
297
-
298
- function parseStoredMessages(value: unknown): ChatMessage[] {
299
- if (!Array.isArray(value)) return [];
300
- const seenIds = new Set<string>();
301
- const restoredMessages: ChatMessage[] = [];
302
- for (const candidate of value) {
303
- const message = parseStoredMessage(candidate);
304
- if (!message || seenIds.has(message.id)) continue;
305
- seenIds.add(message.id);
306
- restoredMessages.push(message);
307
- }
308
- return restoredMessages;
309
- }
310
-
311
- function readStoredMessages(key: string): { found: boolean; messages: ChatMessage[] } {
312
- const storage = getChatStorage();
313
- if (!key || !storage) return { found: false, messages: [] };
314
- try {
315
- const stored = storage.getItem(key);
316
- if (stored === null) return { found: false, messages: [] };
317
- const restored = JSON.parse(stored) as unknown;
318
- return {
319
- found: true,
320
- messages: parseStoredMessages(restored),
321
- };
322
- } catch {
323
- return { found: true, messages: [] };
324
- }
325
- }
326
-
327
- function finishRestore(
328
- scopeToken: string,
329
- key: string,
330
- restoredMessages: ChatMessage[],
331
- persistRestoredMessages = false,
332
- ) {
333
- if (historyScopeToken !== scopeToken || resolvedPersistKey !== key) return;
334
- replaceMessages(restoredMessages, false);
335
- restoredHistoryScope = scopeToken;
336
- restoredPersistKey = key;
337
- if (persistRestoredMessages) schedulePersist(restoredMessages);
338
- }
339
-
340
- // ─── Restore persisted history for the current tenant/tree ──
341
- $effect.pre(() => {
342
- const scopeToken = historyScopeToken;
343
- const key = resolvedPersistKey;
344
- return untrack(() => {
345
- const previousScope = restoredHistoryScope;
346
- if (previousScope === scopeToken) return;
347
- if (previousScope !== null) flushPendingPersist();
348
- invalidateActiveRun({ persistApprovalRevocation: false });
349
- restoredHistoryScope = null;
350
- restoredPersistKey = null;
351
- if (onRestore) {
352
- finishRestore(scopeToken, key, onRestore(), true);
353
- return;
354
- }
355
-
356
- const current = readStoredMessages(key);
357
- finishRestore(scopeToken, key, current.messages);
358
- const legacyKey = persistKey === undefined ? 'svadmin-chat' : persistKey;
359
- if (
360
- current.found
361
- || !key
362
- || !legacyKey
363
- || legacyKey === key
364
- || adminContext.tenantCacheKey !== undefined
365
- ) return;
366
-
367
- // Defer migration so an immediate unmount cannot persist an empty scoped
368
- // key. The shared historical default is consumed only by a single dialog;
369
- // explicit historical keys already identify their panel.
370
- const initialRevision = messageRevision;
371
- const migrationTimer = setTimeout(() => {
372
- if (
373
- historyScopeToken !== scopeToken
374
- || resolvedPersistKey !== key
375
- || restoredHistoryScope !== scopeToken
376
- || restoredPersistKey !== key
377
- || messageRevision !== initialRevision
378
- ) return;
379
- if (
380
- persistKey === undefined
381
- && document.querySelectorAll('[data-svadmin-chat-scope]').length !== 1
382
- ) return;
383
- const legacy = readStoredMessages(legacyKey);
384
- if (!legacy.found || legacy.messages.length === 0) return;
385
- finishRestore(scopeToken, key, legacy.messages, true);
386
- }, 0);
387
- return () => clearTimeout(migrationTimer);
388
- });
389
- });
390
-
391
- // Provider identity can change without changing the persistence scope. Keep
392
- // an in-flight run and its approval capabilities bound to their origin tree.
393
- $effect.pre(() => {
394
- const nextRunScope = {
395
- tenantIdentity: adminContext.tenantCacheKey?.__svadminTenant,
396
- chatProvider: provider,
397
- agentProvider: agent,
398
- };
399
- untrack(() => {
400
- const previous = previousRunScope;
401
- previousRunScope = nextRunScope;
402
- if (!previous) return;
403
- if (
404
- Object.is(previous.tenantIdentity, nextRunScope.tenantIdentity)
405
- && previous.chatProvider === nextRunScope.chatProvider
406
- && previous.agentProvider === nextRunScope.agentProvider
407
- ) return;
408
- invalidateActiveRun();
409
- });
410
- });
411
-
412
- onDestroy(() => {
413
- invalidateActiveRun();
414
- flushPendingPersist();
415
- });
416
-
417
- function genId(): string {
418
- messageIdCounter++;
419
- return `msg-${Date.now()}-${messageIdCounter}`;
420
- }
421
-
422
- function scrollToBottom() {
423
- requestAnimationFrame(() => {
424
- if (messagesContainer) {
425
- messagesContainer.scrollTop = messagesContainer.scrollHeight;
426
- }
427
- });
428
- }
429
-
430
- /** Build a system message with the current admin context */
431
- function buildContextSystemMessage(): ChatMessage | null {
432
- const ctx = chatContext;
433
- if (!ctx.currentResource) return null;
434
-
435
- const parts: string[] = [];
436
- parts.push(`Resource: ${ctx.currentResource}`);
437
- if (ctx.currentView) parts.push(`View: ${ctx.currentView}`);
438
- if (ctx.selectedRecordId) parts.push(`Selected Record ID: ${ctx.selectedRecordId}`);
439
- if (ctx.pathname) parts.push(`Path: ${ctx.pathname}`);
440
-
441
- return {
442
- id: 'ctx-system',
443
- role: 'system',
444
- content: `[Admin Context] ${parts.join(' | ')}`,
445
- timestamp: Date.now(),
446
- };
447
- }
448
-
449
- async function sendMessage() {
450
- if (!inputValue.trim() || isStreaming || (!provider && !agent)) return;
451
-
452
- const userMsg: ChatMessage = {
453
- id: genId(),
454
- role: 'user',
455
- content: inputValue.trim(),
456
- timestamp: Date.now(),
457
- };
458
-
459
- replaceMessages([...messages, userMsg]);
460
- inputValue = '';
461
- doSend();
462
- }
463
-
464
- async function doSend() {
465
- const activeAgent = agent;
466
- const activeChatProvider = provider;
467
- if (isStreaming || (!activeChatProvider && !activeAgent)) return;
468
-
469
- scrollToBottom();
470
-
471
- // Create placeholder assistant message
472
- const assistantMsg: ChatMessage = {
473
- id: genId(),
474
- role: 'assistant',
475
- content: '',
476
- timestamp: Date.now(),
477
- };
478
- replaceMessages([...messages, assistantMsg]);
479
- const epoch = ++runEpoch;
480
- const controller = new AbortController();
481
- abortController = controller;
482
- isStreaming = true;
483
- scrollToBottom();
484
-
485
- try {
486
- // Build message list with context system message prepended
487
- const contextMsg = buildContextSystemMessage();
488
- const allMessages = messages.filter((m) => m.content);
489
- const messagesToSend = contextMsg ? [contextMsg, ...allMessages] : allMessages;
490
-
491
- if (activeAgent) {
492
- // ─── AgentProvider path: typed event stream ─────────
493
- const stream = activeAgent.chat(messagesToSend, {
494
- signal: controller.signal,
495
- context: chatContext,
496
- });
497
-
498
- for await (const event of stream) {
499
- if (!isCurrentRun(epoch, controller)) break;
500
- switch (event.type) {
501
- case 'text':
502
- assistantMsg.content += event.content;
503
- updateMessageById(assistantMsg.id, (current) => ({
504
- ...current,
505
- content: assistantMsg.content,
506
- }));
507
- scrollToBottom();
508
- break;
509
-
510
- case 'tool_call':
511
- assistantMsg.content += `\n🔧 *${event.tool}*(${JSON.stringify(event.args)})`;
512
- updateMessageById(assistantMsg.id, (current) => ({
513
- ...current,
514
- content: assistantMsg.content,
515
- }));
516
- scrollToBottom();
517
- break;
518
-
519
- case 'tool_result':
520
- if (event.result.success) {
521
- assistantMsg.content += `\n✅ ${event.tool} completed`;
522
- } else {
523
- assistantMsg.content += `\n❌ ${event.tool} failed: ${event.result.error ?? 'Unknown error'}`;
524
- }
525
- updateMessageById(assistantMsg.id, (current) => ({
526
- ...current,
527
- content: assistantMsg.content,
528
- }));
529
- scrollToBottom();
530
- break;
531
-
532
- case 'approval_request': {
533
- const reqId = event.id;
534
- approvalCallbacks.set(reqId, (approved) => {
535
- replaceMessages([...messages, {
536
- id: genId(),
537
- role: 'user',
538
- content: approved ? `User approved execution of tool '${event.tool}'` : `User rejected execution of tool '${event.tool}'`,
539
- timestamp: Date.now()
540
- }]);
541
- if (activeAgent.approveToolCall) {
542
- activeAgent.approveToolCall(reqId, approved);
543
- }
544
- });
545
-
546
- const approvalActions: ChatAction[] = [
547
- {
548
- label: `✅ ${i18n.t('common.confirm') || 'Approve'}: ${event.description}`,
549
- variant: 'default',
550
- payload: { approvalId: reqId, approved: true },
551
- },
552
- {
553
- label: `❌ ${i18n.t('common.cancel') || 'Reject'}`,
554
- variant: 'destructive',
555
- payload: { approvalId: reqId, approved: false },
556
- },
557
- ];
558
-
559
- assistantMsg.content += `\n⚠️ **${i18n.t('chat.approvalRequired') || 'Approval required'}**: ${event.description}`;
560
- updateMessageById(assistantMsg.id, (current) => ({
561
- ...current,
562
- content: assistantMsg.content,
563
- actions: [
564
- ...(current.actions ?? []).filter((candidate) => (
565
- candidate.payload?.approvalId !== reqId
566
- )),
567
- ...approvalActions,
568
- ],
569
- }));
570
- scrollToBottom();
571
- break;
572
- }
573
-
574
- case 'component':
575
- assistantMsg.content += `\n📊 [${event.name}]`;
576
- updateMessageById(assistantMsg.id, (current) => ({
577
- ...current,
578
- content: assistantMsg.content,
579
- }));
580
- scrollToBottom();
581
- break;
582
-
583
- case 'done':
584
- break;
585
- }
586
- }
587
- } else if (activeChatProvider) {
588
- // ─── ChatProvider path: raw text stream ─────────────
589
- const result = activeChatProvider.sendMessage(
590
- messagesToSend,
591
- { signal: controller.signal },
592
- );
593
-
594
- if (result && typeof result === 'object' && Symbol.asyncIterator in result) {
595
- for await (const chunk of result as AsyncGenerator<string>) {
596
- if (!isCurrentRun(epoch, controller)) break;
597
- assistantMsg.content += chunk;
598
- updateMessageById(assistantMsg.id, (current) => ({
599
- ...current,
600
- content: assistantMsg.content,
601
- }));
602
- scrollToBottom();
603
- }
604
- } else {
605
- const text = await (result as Promise<string>);
606
- if (!isCurrentRun(epoch, controller)) return;
607
- assistantMsg.content = text;
608
- updateMessageById(assistantMsg.id, (current) => ({
609
- ...current,
610
- content: assistantMsg.content,
611
- }));
612
- scrollToBottom();
613
- }
614
- }
615
- } catch (err: unknown) {
616
- if (!isCurrentRun(epoch, controller)) return;
617
- if (err instanceof Error && err.name === 'AbortError') return;
618
- assistantMsg.content = i18n.t('chat.error') || 'Sorry, something went wrong. Please try again.';
619
- updateMessageById(assistantMsg.id, (current) => ({
620
- ...current,
621
- content: assistantMsg.content,
622
- }));
623
- } finally {
624
- if (isCurrentRun(epoch, controller)) {
625
- isStreaming = false;
626
- abortController = null;
627
- flushPendingPersist();
628
- scrollToBottom();
629
- }
630
- }
631
- }
632
-
633
- function stopStreaming() {
634
- if (!abortController && !isStreaming) return;
635
- invalidateActiveRun();
636
- flushPendingPersist();
637
- }
638
-
639
- function clearChat() {
640
- invalidateActiveRun();
641
- cancelPendingPersist();
642
- replaceMessages([], false);
643
- const key = restoredPersistKey ?? resolvedPersistKey;
644
- const callback = onPersist;
645
- if (callback) {
646
- flushPersist({ sink: 'callback', key, messages: [], callback });
647
- } else if (key) {
648
- try { getChatStorage()?.removeItem(key); } catch { /* noop */ }
649
- }
650
- }
651
-
652
- function handleKeydown(e: KeyboardEvent) {
653
- if (e.key === 'Enter' && !e.shiftKey) {
654
- e.preventDefault();
655
- sendMessage();
656
- }
657
- }
658
-
659
- function handleGlobalKeydown(e: KeyboardEvent) {
660
- if (e.ctrlKey && e.shiftKey && e.key === 'L') {
661
- const eventTarget = e.target instanceof HTMLElement ? e.target : document.activeElement as HTMLElement | null;
662
- const eventLayoutScope = eventTarget
663
- ?.closest<HTMLElement>('[data-svadmin-layout-scope]')
664
- ?.dataset.svadminLayoutScope;
665
- const layoutCount = document.querySelectorAll('[data-svadmin-layout-scope]').length;
666
- const activeChatScope = eventTarget
667
- ?.closest<HTMLElement>('[data-svadmin-chat-scope]')
668
- ?.dataset.svadminChatScope;
669
- const chatCount = document.querySelectorAll('[data-svadmin-chat-scope]').length;
670
- const ownsShortcut = ownerScope
671
- ? (eventLayoutScope ? eventLayoutScope === ownerScope : layoutCount === 1)
672
- : (activeChatScope ? activeChatScope === componentScope : chatCount === 1);
673
- if (!ownsShortcut) return;
674
-
675
- e.preventDefault();
676
- if (open && !minimized) {
677
- open = false;
678
- } else {
679
- open = true;
680
- minimized = false;
681
- }
682
- }
683
- }
684
-
685
- function handleAction(action: ChatAction, msg: ChatMessage) {
686
- // Handle approval actions from AgentProvider
687
- if (action.payload?.approvalId) {
688
- const id = action.payload.approvalId as string;
689
- const approved = action.payload.approved as boolean;
690
- const resolve = approvalCallbacks.get(id);
691
- approvalCallbacks.delete(id);
692
- updateMessageById(msg.id, (current) => {
693
- const remainingActions = current.actions?.filter((candidate) => (
694
- candidate.payload?.approvalId !== id
695
- ));
696
- return {
697
- ...current,
698
- actions: remainingActions?.length ? remainingActions : undefined,
699
- };
700
- });
701
- resolve?.(approved);
702
- return;
703
- }
704
- if (onAction) {
705
- onAction(action, msg);
706
- }
707
- }
708
-
709
- function escapeHtml(unsafe: string) {
710
- return unsafe
711
- .replace(/&/g, "&amp;")
712
- .replace(/</g, "&lt;")
713
- .replace(/>/g, "&gt;")
714
- .replace(/"/g, "&quot;")
715
- .replace(/'/g, "&#039;");
716
- }
717
-
718
- /** Simple markdown→html: bold, italic, code blocks, inline code, line breaks */
719
- function renderMarkdown(text: string): string {
720
- const escaped = escapeHtml(text);
721
- const codeBlocks: string[] = [];
722
- let processed = escaped.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => {
723
- const idx = codeBlocks.length;
724
- codeBlocks.push(`<pre class="chat-code-block"><code>${code}</code></pre>`);
725
- return `\x00CODEBLOCK${idx}\x00`;
726
- });
727
- processed = processed
728
- .replace(/`([^`]+)`/g, '<code class="chat-inline-code">$1</code>')
729
- .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
730
- .replace(/\*(.+?)\*/g, '<em>$1</em>')
731
- .replace(/\n/g, '<br>');
732
- for (let i = 0; i < codeBlocks.length; i++) {
733
- processed = processed.replace(`\x00CODEBLOCK${i}\x00`, codeBlocks[i]);
734
- }
735
- return processed;
736
- }
737
-
738
- const suggestions = $derived([
739
- i18n.t('chat.suggestion1') || 'How do I create a new resource?',
740
- i18n.t('chat.suggestion2') || 'Explain the data model',
741
- i18n.t('chat.suggestion3') || 'Help me write a filter query',
742
- ]);
743
- </script>
744
-
745
- <svelte:window onkeydown={handleGlobalKeydown} />
746
-
747
- {#if provider || agent}
748
- <div
749
- class="contents"
750
- data-svadmin-chat-scope={componentScope}
751
- data-svadmin-chat-visible={open ? 'true' : 'false'}
752
- >
753
- <!-- FAB Button -->
754
- {#if !open}
755
- <div transition:scale={{ duration: 200 }}>
756
- <TooltipButton
757
- tooltip={i18n.t('chat.title') || 'AI Assistant'}
758
- variant="default"
759
- size="icon"
760
- class="{docked ? 'relative h-9 w-9 rounded-md shadow-sm' : 'fixed bottom-4 right-4 h-12 w-12 rounded-full shadow-xl hover:shadow-2xl hover:scale-110 sm:bottom-6 sm:right-6'} z-[9998] transition-all bg-primary text-primary-foreground"
761
- onclick={() => { open = true; minimized = false; }}
762
- >
763
- <MessageCircle class="h-5 w-5" />
764
- {#if messages.length > 0}
765
- <span class="absolute -top-1 -right-1 h-4 w-4 rounded-full bg-destructive text-[10px] font-bold text-destructive-foreground flex items-center justify-center">
766
- {messages.filter(m => m.role === 'assistant').length}
767
- </span>
768
- {/if}
769
- </TooltipButton>
770
- </div>
771
- {/if}
772
-
773
- <!-- Chat Panel -->
774
- {#if open}
775
- <div
776
- class="fixed z-[9998] flex flex-col rounded-2xl border bg-card shadow-2xl overflow-hidden bottom-3 right-3 sm:bottom-6 sm:right-6"
777
- class:w-[calc(100vw-1.5rem)]={!minimized}
778
- class:sm:w-[400px]={!minimized}
779
- class:h-[calc(100dvh-6rem)]={!minimized}
780
- class:sm:h-[560px]={!minimized}
781
- class:w-[280px]={minimized}
782
- class:h-auto={minimized}
783
- transition:fly={{ y: 300, duration: 250 }}
784
- >
785
- <!-- Header -->
786
- <div class="flex items-center justify-between px-4 py-3 bg-primary text-primary-foreground shrink-0">
787
- <div class="flex items-center gap-2.5">
788
- <div class="flex items-center justify-center h-7 w-7 rounded-full bg-primary-foreground/20">
789
- <Bot class="h-4 w-4" />
790
- </div>
791
- <div>
792
- <h3 class="text-sm font-semibold leading-none">
793
- {i18n.t('chat.title') || 'AI Assistant'}
794
- </h3>
795
- {#if isStreaming}
796
- <p class="text-[10px] opacity-80 mt-0.5">{i18n.t('chat.typing') || 'Typing...'}</p>
797
- {/if}
798
- </div>
799
- </div>
800
- <div class="flex items-center gap-0.5">
801
- {#if messages.length > 0}
802
- <TooltipButton
803
- tooltip={i18n.t('chat.clear') || 'Clear'}
804
- variant="ghost"
805
- size="icon"
806
- class="h-7 w-7 text-primary-foreground/70 hover:text-primary-foreground hover:bg-primary-foreground/10"
807
- onclick={clearChat}
808
- >
809
- <Trash2 class="h-3.5 w-3.5" />
810
- </TooltipButton>
811
- {/if}
812
- <TooltipButton
813
- tooltip={minimized ? (i18n.t('common.expand') || 'Expand') : (i18n.t('common.collapse') || 'Minimize')}
814
- variant="ghost"
815
- size="icon"
816
- class="h-7 w-7 text-primary-foreground/70 hover:text-primary-foreground hover:bg-primary-foreground/10"
817
- onclick={() => minimized = !minimized}
818
- >
819
- <Minus class="h-3.5 w-3.5" />
820
- </TooltipButton>
821
- <TooltipButton
822
- tooltip={i18n.t('common.close') || 'Close'}
823
- variant="ghost"
824
- size="icon"
825
- class="h-7 w-7 text-primary-foreground/70 hover:text-primary-foreground hover:bg-primary-foreground/10"
826
- onclick={() => open = false}
827
- >
828
- <X class="h-3.5 w-3.5" />
829
- </TooltipButton>
830
- </div>
831
- </div>
832
-
833
- {#if !minimized}
834
- <!-- Messages -->
835
- <div
836
- bind:this={messagesContainer}
837
- class="flex-1 overflow-y-auto p-4 space-y-3 scroll-smooth"
838
- >
839
- {#if messages.length === 0}
840
- <!-- Welcome state -->
841
- <div class="flex flex-col items-center justify-center h-full text-center px-4" in:fade={{ duration: 200 }}>
842
- <div class="h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center mb-4">
843
- <Bot class="h-6 w-6 text-primary" />
844
- </div>
845
- <h4 class="text-sm font-semibold text-foreground mb-1">
846
- {i18n.t('chat.welcome') || 'How can I help?'}
847
- </h4>
848
- <p class="text-xs text-muted-foreground mb-4">
849
- {i18n.t('chat.welcomeDesc') || 'Ask me anything about your admin panel.'}
850
- </p>
851
- <div class="flex flex-col gap-2 w-full max-w-[240px]">
852
- {#each suggestions as suggestion, _i (_i)}
853
- <button
854
- class="text-left text-xs px-3 py-2 rounded-lg border border-border hover:bg-accent hover:text-accent-foreground transition-colors cursor-pointer"
855
- onclick={() => { inputValue = suggestion; }}
856
- >
857
- {suggestion}
858
- </button>
859
- {/each}
860
- </div>
861
- </div>
862
- {:else}
863
- {#each messages as msg (msg.id)}
864
- <div
865
- class="flex {msg.role === 'user' ? 'justify-end' : 'justify-start'}"
866
- in:fly={{ y: 10, duration: 150 }}
867
- >
868
- <div
869
- class="max-w-[85%] rounded-2xl px-3.5 py-2.5 text-sm leading-relaxed {msg.role === 'user'
870
- ? 'bg-primary text-primary-foreground rounded-br-md'
871
- : 'bg-muted text-foreground rounded-bl-md'}"
872
- >
873
- {#if msg.role === 'assistant'}
874
- {#if msg.content}
875
- <div class="chat-markdown">{@html renderMarkdown(msg.content)}</div>
876
- <!-- Action Buttons -->
877
- {#if msg.actions && msg.actions.length > 0}
878
- <div class="flex flex-wrap gap-1.5 mt-2 pt-2 border-t border-border/50">
879
- {#each msg.actions as action, _i (_i)}
880
- <Button
881
- variant={action.variant ?? 'outline'}
882
- size="sm"
883
- class="h-7 text-xs"
884
- onclick={() => handleAction(action, msg)}
885
- >
886
- {action.label}
887
- </Button>
888
- {/each}
889
- </div>
890
- {/if}
891
- {:else}
892
- <div class="flex items-center gap-1.5 text-muted-foreground">
893
- <Loader2 class="h-3.5 w-3.5 animate-spin" />
894
- <span class="text-xs">{i18n.t('chat.thinking') || 'Thinking...'}</span>
895
- </div>
896
- {/if}
897
- {:else}
898
- {msg.content}
899
- {/if}
900
- </div>
901
- </div>
902
- {/each}
903
- {/if}
904
- </div>
905
-
906
- <!-- Input area -->
907
- <div class="shrink-0 border-t bg-card p-3">
908
- <div class="flex items-end gap-2">
909
- <textarea
910
- bind:value={inputValue}
911
- onkeydown={handleKeydown}
912
- placeholder={i18n.t('chat.placeholder') || 'Type a message...'}
913
- rows={1}
914
- disabled={isStreaming}
915
- class="flex-1 resize-none rounded-xl border border-input bg-background px-3 py-2.5 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1 disabled:opacity-50 max-h-[100px] min-h-[40px]"
916
- ></textarea>
917
- {#if isStreaming}
918
- <Button
919
- variant="destructive"
920
- size="icon"
921
- class="h-10 w-10 rounded-xl shrink-0"
922
- onclick={stopStreaming}
923
- >
924
- <X class="h-4 w-4" />
925
- </Button>
926
- {:else}
927
- <Button
928
- variant="default"
929
- size="icon"
930
- class="h-10 w-10 rounded-xl shrink-0"
931
- onclick={sendMessage}
932
- disabled={!inputValue.trim()}
933
- >
934
- <Send class="h-4 w-4" />
935
- </Button>
936
- {/if}
937
- </div>
938
- <p class="text-[10px] text-muted-foreground mt-1.5 text-center">
939
- <kbd class="px-1 py-0.5 rounded border border-border bg-muted text-[9px] font-mono">Ctrl+Shift+L</kbd>
940
- {i18n.t('chat.shortcutHint') || 'to toggle'}
941
- </p>
942
- </div>
943
- {/if}
944
- </div>
945
- {/if}
946
- </div>
947
- {/if}