@svadmin/ui 0.40.6 → 0.41.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/dist/components/AICommandBar.svelte +46 -23
- package/dist/components/AboutSettings.svelte +4 -3
- package/dist/components/AboutSettings.svelte.d.ts +2 -17
- package/dist/components/AdminApp.svelte +92 -20
- package/dist/components/AdminApp.svelte.d.ts +17 -2
- package/dist/components/AuditLogDrawer.svelte +45 -16
- package/dist/components/AuditLogViewer.svelte +32 -14
- package/dist/components/AutoTable.svelte +11 -5
- package/dist/components/CancelTaskButton.svelte +12 -4
- package/dist/components/ChatDialog.svelte +456 -122
- package/dist/components/ChatDialog.svelte.d.ts +9 -1
- package/dist/components/CommandPalette.svelte +2 -2
- package/dist/components/CopilotPanel.svelte +67 -26
- package/dist/components/DevTools.svelte +249 -22
- package/dist/components/InferencerPanel.svelte +25 -5
- package/dist/components/InsightCard.svelte +47 -17
- package/dist/components/Layout.svelte +45 -20
- package/dist/components/LoginPage.svelte +4 -4
- package/dist/components/ProfilePage.svelte +74 -27
- package/dist/components/RetryTaskButton.svelte +12 -4
- package/dist/components/RolesSettings.svelte +50 -21
- package/dist/components/SettingsPage.svelte +4 -4
- package/dist/components/Sidebar.svelte +26 -16
- package/dist/components/SmartSuggest.svelte +71 -32
- package/dist/index.d.ts +1 -1
- package/dist/types.d.ts +11 -0
- package/package.json +2 -2
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
/* eslint-disable
|
|
3
|
-
import {
|
|
4
|
-
import type { ChatMessage, ChatAction } from '@svadmin/core';
|
|
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
5
|
import { useTranslation } from '@svadmin/core/i18n';
|
|
6
6
|
|
|
7
7
|
import { useParsed } from '@svadmin/core';
|
|
8
|
+
import { onDestroy, untrack } from 'svelte';
|
|
9
|
+
import { SvelteMap } from 'svelte/reactivity';
|
|
8
10
|
import { fly, fade, scale } from 'svelte/transition';
|
|
9
11
|
import { Button } from './ui/button/index.js';
|
|
10
12
|
import TooltipButton from './TooltipButton.svelte';
|
|
@@ -15,7 +17,11 @@
|
|
|
15
17
|
interface Props {
|
|
16
18
|
/** Render the launcher inside a layout toolbar instead of as a floating action button. */
|
|
17
19
|
docked?: boolean;
|
|
18
|
-
/**
|
|
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
|
+
*/
|
|
19
25
|
persistKey?: string;
|
|
20
26
|
/** Custom persistence callback — called when messages change */
|
|
21
27
|
onPersist?: (messages: ChatMessage[]) => void;
|
|
@@ -23,15 +29,24 @@
|
|
|
23
29
|
onRestore?: () => ChatMessage[];
|
|
24
30
|
/** Callback when user clicks an action button in an assistant message */
|
|
25
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;
|
|
26
36
|
}
|
|
27
37
|
|
|
28
38
|
const {
|
|
29
39
|
docked = false,
|
|
30
|
-
persistKey
|
|
40
|
+
persistKey,
|
|
31
41
|
onPersist,
|
|
32
42
|
onRestore,
|
|
33
43
|
onAction,
|
|
44
|
+
scope,
|
|
45
|
+
ownerScope,
|
|
34
46
|
}: Props = $props();
|
|
47
|
+
const generatedId = $props.id();
|
|
48
|
+
const generatedScope = `svadmin-chat-${generatedId}`;
|
|
49
|
+
const componentScope = $derived(scope ?? generatedScope);
|
|
35
50
|
|
|
36
51
|
let open = $state(false);
|
|
37
52
|
let minimized = $state(false);
|
|
@@ -40,101 +55,363 @@
|
|
|
40
55
|
let isStreaming = $state(false);
|
|
41
56
|
let messagesContainer: HTMLDivElement | undefined = $state();
|
|
42
57
|
let abortController: AbortController | null = null;
|
|
58
|
+
let runEpoch = 0;
|
|
59
|
+
let messageRevision = 0;
|
|
43
60
|
let messageIdCounter = $state(0);
|
|
44
|
-
let initialized = $state(false);
|
|
45
61
|
|
|
46
|
-
const
|
|
47
|
-
const
|
|
62
|
+
const adminContext = captureAdminContext();
|
|
63
|
+
const provider = $derived(adminContext.chatProvider);
|
|
64
|
+
const agent = $derived(adminContext.agentProvider);
|
|
48
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
|
+
}
|
|
49
111
|
|
|
50
|
-
|
|
51
|
-
|
|
112
|
+
function isCurrentRun(epoch: number, controller: AbortController) {
|
|
113
|
+
return runEpoch === epoch
|
|
114
|
+
&& abortController === controller
|
|
115
|
+
&& !controller.signal.aborted;
|
|
116
|
+
}
|
|
52
117
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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))}`;
|
|
61
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
|
+
}
|
|
62
149
|
|
|
63
150
|
// ─── Listen for cross-component triggers ─────────────────────
|
|
64
151
|
$effect(() => {
|
|
65
152
|
if (typeof window === 'undefined') return;
|
|
66
|
-
const handler = (
|
|
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
|
+
|
|
67
160
|
open = true;
|
|
68
161
|
minimized = false;
|
|
69
|
-
inputValue =
|
|
70
|
-
sendMessage();
|
|
162
|
+
inputValue = query;
|
|
163
|
+
void sendMessage();
|
|
71
164
|
};
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
// ─── Restore persisted history on mount ─────────────────────
|
|
77
|
-
$effect(() => {
|
|
78
|
-
if (initialized) return;
|
|
79
|
-
initialized = true;
|
|
80
|
-
if (onRestore) {
|
|
81
|
-
messages = onRestore();
|
|
82
|
-
} else if (persistKey) {
|
|
83
|
-
try {
|
|
84
|
-
const stored = localStorage.getItem(persistKey);
|
|
85
|
-
if (stored) {
|
|
86
|
-
const parsed = JSON.parse(stored) as ChatMessage[];
|
|
87
|
-
if (Array.isArray(parsed) && parsed.length > 0) {
|
|
88
|
-
messages = parsed;
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
} catch {
|
|
92
|
-
// ignore corrupt data
|
|
93
|
-
}
|
|
94
|
-
}
|
|
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);
|
|
95
168
|
});
|
|
96
169
|
|
|
97
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
|
+
|
|
98
180
|
let persistTimer: ReturnType<typeof setTimeout> | null = null;
|
|
99
|
-
let lastPersistSnapshot:
|
|
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
|
+
}
|
|
100
193
|
|
|
101
|
-
function flushPersist(snapshot:
|
|
102
|
-
if (
|
|
103
|
-
|
|
104
|
-
} else if (
|
|
194
|
+
function flushPersist(snapshot: PersistSnapshot) {
|
|
195
|
+
if (snapshot.sink === 'callback') {
|
|
196
|
+
snapshot.callback(snapshot.messages);
|
|
197
|
+
} else if (snapshot.key) {
|
|
105
198
|
try {
|
|
106
|
-
|
|
199
|
+
snapshot.storage?.setItem(snapshot.key, JSON.stringify(snapshot.messages));
|
|
107
200
|
} catch {
|
|
108
201
|
// storage full or unavailable
|
|
109
202
|
}
|
|
110
203
|
}
|
|
111
204
|
}
|
|
112
205
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
+
}
|
|
116
248
|
|
|
117
249
|
if (persistTimer) clearTimeout(persistTimer);
|
|
118
250
|
lastPersistSnapshot = snapshot;
|
|
119
251
|
persistTimer = setTimeout(() => {
|
|
120
252
|
persistTimer = null;
|
|
121
|
-
|
|
253
|
+
if (!lastPersistSnapshot) return;
|
|
254
|
+
const pendingSnapshot = lastPersistSnapshot;
|
|
255
|
+
lastPersistSnapshot = null;
|
|
256
|
+
flushPersist(pendingSnapshot);
|
|
122
257
|
}, 300);
|
|
258
|
+
}
|
|
123
259
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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,
|
|
129
295
|
};
|
|
130
|
-
}
|
|
296
|
+
}
|
|
131
297
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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;
|
|
136
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,
|
|
137
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();
|
|
138
415
|
});
|
|
139
416
|
|
|
140
417
|
function genId(): string {
|
|
@@ -152,7 +429,7 @@
|
|
|
152
429
|
|
|
153
430
|
/** Build a system message with the current admin context */
|
|
154
431
|
function buildContextSystemMessage(): ChatMessage | null {
|
|
155
|
-
const ctx =
|
|
432
|
+
const ctx = chatContext;
|
|
156
433
|
if (!ctx.currentResource) return null;
|
|
157
434
|
|
|
158
435
|
const parts: string[] = [];
|
|
@@ -179,13 +456,15 @@
|
|
|
179
456
|
timestamp: Date.now(),
|
|
180
457
|
};
|
|
181
458
|
|
|
182
|
-
|
|
459
|
+
replaceMessages([...messages, userMsg]);
|
|
183
460
|
inputValue = '';
|
|
184
461
|
doSend();
|
|
185
462
|
}
|
|
186
463
|
|
|
187
464
|
async function doSend() {
|
|
188
|
-
|
|
465
|
+
const activeAgent = agent;
|
|
466
|
+
const activeChatProvider = provider;
|
|
467
|
+
if (isStreaming || (!activeChatProvider && !activeAgent)) return;
|
|
189
468
|
|
|
190
469
|
scrollToBottom();
|
|
191
470
|
|
|
@@ -196,39 +475,44 @@
|
|
|
196
475
|
content: '',
|
|
197
476
|
timestamp: Date.now(),
|
|
198
477
|
};
|
|
199
|
-
|
|
478
|
+
replaceMessages([...messages, assistantMsg]);
|
|
479
|
+
const epoch = ++runEpoch;
|
|
480
|
+
const controller = new AbortController();
|
|
481
|
+
abortController = controller;
|
|
200
482
|
isStreaming = true;
|
|
201
483
|
scrollToBottom();
|
|
202
484
|
|
|
203
|
-
abortController = new AbortController();
|
|
204
|
-
|
|
205
485
|
try {
|
|
206
486
|
// Build message list with context system message prepended
|
|
207
487
|
const contextMsg = buildContextSystemMessage();
|
|
208
488
|
const allMessages = messages.filter((m) => m.content);
|
|
209
489
|
const messagesToSend = contextMsg ? [contextMsg, ...allMessages] : allMessages;
|
|
210
490
|
|
|
211
|
-
if (
|
|
491
|
+
if (activeAgent) {
|
|
212
492
|
// ─── AgentProvider path: typed event stream ─────────
|
|
213
|
-
const stream =
|
|
214
|
-
signal:
|
|
215
|
-
context:
|
|
493
|
+
const stream = activeAgent.chat(messagesToSend, {
|
|
494
|
+
signal: controller.signal,
|
|
495
|
+
context: chatContext,
|
|
216
496
|
});
|
|
217
497
|
|
|
218
|
-
const collectedActions: ChatAction[] = [];
|
|
219
|
-
|
|
220
498
|
for await (const event of stream) {
|
|
221
|
-
if (!
|
|
499
|
+
if (!isCurrentRun(epoch, controller)) break;
|
|
222
500
|
switch (event.type) {
|
|
223
501
|
case 'text':
|
|
224
502
|
assistantMsg.content += event.content;
|
|
225
|
-
|
|
503
|
+
updateMessageById(assistantMsg.id, (current) => ({
|
|
504
|
+
...current,
|
|
505
|
+
content: assistantMsg.content,
|
|
506
|
+
}));
|
|
226
507
|
scrollToBottom();
|
|
227
508
|
break;
|
|
228
509
|
|
|
229
510
|
case 'tool_call':
|
|
230
511
|
assistantMsg.content += `\n🔧 *${event.tool}*(${JSON.stringify(event.args)})`;
|
|
231
|
-
|
|
512
|
+
updateMessageById(assistantMsg.id, (current) => ({
|
|
513
|
+
...current,
|
|
514
|
+
content: assistantMsg.content,
|
|
515
|
+
}));
|
|
232
516
|
scrollToBottom();
|
|
233
517
|
break;
|
|
234
518
|
|
|
@@ -238,27 +522,28 @@
|
|
|
238
522
|
} else {
|
|
239
523
|
assistantMsg.content += `\n❌ ${event.tool} failed: ${event.result.error ?? 'Unknown error'}`;
|
|
240
524
|
}
|
|
241
|
-
|
|
525
|
+
updateMessageById(assistantMsg.id, (current) => ({
|
|
526
|
+
...current,
|
|
527
|
+
content: assistantMsg.content,
|
|
528
|
+
}));
|
|
242
529
|
scrollToBottom();
|
|
243
530
|
break;
|
|
244
531
|
|
|
245
532
|
case 'approval_request': {
|
|
246
533
|
const reqId = event.id;
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
registerApproval(reqId, (approved) => {
|
|
250
|
-
messages = [...messages, {
|
|
534
|
+
approvalCallbacks.set(reqId, (approved) => {
|
|
535
|
+
replaceMessages([...messages, {
|
|
251
536
|
id: genId(),
|
|
252
537
|
role: 'user',
|
|
253
538
|
content: approved ? `User approved execution of tool '${event.tool}'` : `User rejected execution of tool '${event.tool}'`,
|
|
254
539
|
timestamp: Date.now()
|
|
255
|
-
}];
|
|
256
|
-
if (
|
|
257
|
-
|
|
540
|
+
}]);
|
|
541
|
+
if (activeAgent.approveToolCall) {
|
|
542
|
+
activeAgent.approveToolCall(reqId, approved);
|
|
258
543
|
}
|
|
259
544
|
});
|
|
260
545
|
|
|
261
|
-
|
|
546
|
+
const approvalActions: ChatAction[] = [
|
|
262
547
|
{
|
|
263
548
|
label: `✅ ${i18n.t('common.confirm') || 'Approve'}: ${event.description}`,
|
|
264
549
|
variant: 'default',
|
|
@@ -269,18 +554,29 @@
|
|
|
269
554
|
variant: 'destructive',
|
|
270
555
|
payload: { approvalId: reqId, approved: false },
|
|
271
556
|
},
|
|
272
|
-
|
|
557
|
+
];
|
|
273
558
|
|
|
274
559
|
assistantMsg.content += `\n⚠️ **${i18n.t('chat.approvalRequired') || 'Approval required'}**: ${event.description}`;
|
|
275
|
-
assistantMsg.
|
|
276
|
-
|
|
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
|
+
}));
|
|
277
570
|
scrollToBottom();
|
|
278
571
|
break;
|
|
279
572
|
}
|
|
280
573
|
|
|
281
574
|
case 'component':
|
|
282
575
|
assistantMsg.content += `\n📊 [${event.name}]`;
|
|
283
|
-
|
|
576
|
+
updateMessageById(assistantMsg.id, (current) => ({
|
|
577
|
+
...current,
|
|
578
|
+
content: assistantMsg.content,
|
|
579
|
+
}));
|
|
284
580
|
scrollToBottom();
|
|
285
581
|
break;
|
|
286
582
|
|
|
@@ -288,56 +584,68 @@
|
|
|
288
584
|
break;
|
|
289
585
|
}
|
|
290
586
|
}
|
|
291
|
-
} else if (
|
|
587
|
+
} else if (activeChatProvider) {
|
|
292
588
|
// ─── ChatProvider path: raw text stream ─────────────
|
|
293
|
-
const result =
|
|
589
|
+
const result = activeChatProvider.sendMessage(
|
|
294
590
|
messagesToSend,
|
|
295
|
-
{ signal:
|
|
591
|
+
{ signal: controller.signal },
|
|
296
592
|
);
|
|
297
593
|
|
|
298
594
|
if (result && typeof result === 'object' && Symbol.asyncIterator in result) {
|
|
299
595
|
for await (const chunk of result as AsyncGenerator<string>) {
|
|
300
|
-
if (!
|
|
596
|
+
if (!isCurrentRun(epoch, controller)) break;
|
|
301
597
|
assistantMsg.content += chunk;
|
|
302
|
-
|
|
598
|
+
updateMessageById(assistantMsg.id, (current) => ({
|
|
599
|
+
...current,
|
|
600
|
+
content: assistantMsg.content,
|
|
601
|
+
}));
|
|
303
602
|
scrollToBottom();
|
|
304
603
|
}
|
|
305
604
|
} else {
|
|
306
605
|
const text = await (result as Promise<string>);
|
|
606
|
+
if (!isCurrentRun(epoch, controller)) return;
|
|
307
607
|
assistantMsg.content = text;
|
|
308
|
-
|
|
608
|
+
updateMessageById(assistantMsg.id, (current) => ({
|
|
609
|
+
...current,
|
|
610
|
+
content: assistantMsg.content,
|
|
611
|
+
}));
|
|
309
612
|
scrollToBottom();
|
|
310
613
|
}
|
|
311
614
|
}
|
|
312
615
|
} catch (err: unknown) {
|
|
616
|
+
if (!isCurrentRun(epoch, controller)) return;
|
|
313
617
|
if (err instanceof Error && err.name === 'AbortError') return;
|
|
314
618
|
assistantMsg.content = i18n.t('chat.error') || 'Sorry, something went wrong. Please try again.';
|
|
315
|
-
|
|
619
|
+
updateMessageById(assistantMsg.id, (current) => ({
|
|
620
|
+
...current,
|
|
621
|
+
content: assistantMsg.content,
|
|
622
|
+
}));
|
|
316
623
|
} finally {
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
624
|
+
if (isCurrentRun(epoch, controller)) {
|
|
625
|
+
isStreaming = false;
|
|
626
|
+
abortController = null;
|
|
627
|
+
flushPendingPersist();
|
|
628
|
+
scrollToBottom();
|
|
629
|
+
}
|
|
320
630
|
}
|
|
321
631
|
}
|
|
322
632
|
|
|
323
633
|
function stopStreaming() {
|
|
324
|
-
if (abortController)
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
abortController = null;
|
|
328
|
-
}
|
|
634
|
+
if (!abortController && !isStreaming) return;
|
|
635
|
+
invalidateActiveRun();
|
|
636
|
+
flushPendingPersist();
|
|
329
637
|
}
|
|
330
638
|
|
|
331
639
|
function clearChat() {
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
if (
|
|
340
|
-
try {
|
|
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 */ }
|
|
341
649
|
}
|
|
342
650
|
}
|
|
343
651
|
|
|
@@ -350,6 +658,20 @@
|
|
|
350
658
|
|
|
351
659
|
function handleGlobalKeydown(e: KeyboardEvent) {
|
|
352
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
|
+
|
|
353
675
|
e.preventDefault();
|
|
354
676
|
if (open && !minimized) {
|
|
355
677
|
open = false;
|
|
@@ -365,11 +687,18 @@
|
|
|
365
687
|
if (action.payload?.approvalId) {
|
|
366
688
|
const id = action.payload.approvalId as string;
|
|
367
689
|
const approved = action.payload.approved as boolean;
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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);
|
|
373
702
|
return;
|
|
374
703
|
}
|
|
375
704
|
if (onAction) {
|
|
@@ -416,6 +745,11 @@
|
|
|
416
745
|
<svelte:window onkeydown={handleGlobalKeydown} />
|
|
417
746
|
|
|
418
747
|
{#if provider || agent}
|
|
748
|
+
<div
|
|
749
|
+
class="contents"
|
|
750
|
+
data-svadmin-chat-scope={componentScope}
|
|
751
|
+
data-svadmin-chat-visible={open ? 'true' : 'false'}
|
|
752
|
+
>
|
|
419
753
|
<!-- FAB Button -->
|
|
420
754
|
{#if !open}
|
|
421
755
|
<div transition:scale={{ duration: 200 }}>
|
|
@@ -609,5 +943,5 @@
|
|
|
609
943
|
{/if}
|
|
610
944
|
</div>
|
|
611
945
|
{/if}
|
|
946
|
+
</div>
|
|
612
947
|
{/if}
|
|
613
|
-
|