@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.
- package/dist/app.css +0 -51
- package/dist/app.theme.css +0 -51
- package/dist/components/AdminApp.svelte +5 -2
- package/dist/components/AdminApp.svelte.d.ts +3 -0
- package/dist/components/CommandPalette.svelte +3 -1
- package/dist/components/Layout.svelte +20 -12
- package/dist/components/Layout.svelte.d.ts +6 -0
- package/dist/components/MetricStrip.svelte +149 -0
- package/dist/components/MetricStrip.svelte.d.ts +32 -0
- package/dist/components/ui/badge/badge.svelte +5 -0
- package/dist/components/ui/badge/badge.svelte.d.ts +15 -0
- package/dist/components/ui/table/table-cell.svelte +14 -2
- package/dist/components/ui/table/table-cell.svelte.d.ts +4 -1
- package/dist/components/ui/table/table-head.svelte +14 -2
- package/dist/components/ui/table/table-head.svelte.d.ts +4 -1
- package/dist/components/ui/table/table-row.svelte +1 -1
- package/dist/index.d.ts +3 -7
- package/dist/index.js +1 -7
- package/package.json +3 -14
- package/dist/components/AICommandBar.svelte +0 -228
- package/dist/components/AICommandBar.svelte.d.ts +0 -6
- package/dist/components/ChatDialog.svelte +0 -947
- package/dist/components/ChatDialog.svelte.d.ts +0 -24
- package/dist/components/CopilotPanel.svelte +0 -204
- package/dist/components/CopilotPanel.svelte.d.ts +0 -6
- package/dist/components/InsightCard.svelte +0 -139
- package/dist/components/InsightCard.svelte.d.ts +0 -11
- package/dist/components/MarkdownRenderer.svelte +0 -245
- package/dist/components/MarkdownRenderer.svelte.d.ts +0 -11
- package/dist/components/SmartSuggest.svelte +0 -207
- package/dist/components/SmartSuggest.svelte.d.ts +0 -19
- package/dist/components/VoiceInput.svelte +0 -133
- package/dist/components/VoiceInput.svelte.d.ts +0 -15
- 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, "&")
|
|
712
|
-
.replace(/</g, "<")
|
|
713
|
-
.replace(/>/g, ">")
|
|
714
|
-
.replace(/"/g, """)
|
|
715
|
-
.replace(/'/g, "'");
|
|
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}
|