@runtypelabs/persona 1.36.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/README.md +1080 -0
- package/dist/index.cjs +140 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +2626 -0
- package/dist/index.d.ts +2626 -0
- package/dist/index.global.js +1843 -0
- package/dist/index.global.js.map +1 -0
- package/dist/index.js +140 -0
- package/dist/index.js.map +1 -0
- package/dist/install.global.js +2 -0
- package/dist/install.global.js.map +1 -0
- package/dist/widget.css +1627 -0
- package/package.json +79 -0
- package/src/@types/idiomorph.d.ts +37 -0
- package/src/client.test.ts +387 -0
- package/src/client.ts +1589 -0
- package/src/components/composer-builder.ts +530 -0
- package/src/components/feedback.ts +379 -0
- package/src/components/forms.ts +170 -0
- package/src/components/header-builder.ts +455 -0
- package/src/components/header-layouts.ts +303 -0
- package/src/components/launcher.ts +193 -0
- package/src/components/message-bubble.ts +528 -0
- package/src/components/messages.ts +54 -0
- package/src/components/panel.ts +204 -0
- package/src/components/reasoning-bubble.ts +144 -0
- package/src/components/registry.ts +87 -0
- package/src/components/suggestions.ts +97 -0
- package/src/components/tool-bubble.ts +288 -0
- package/src/defaults.ts +321 -0
- package/src/index.ts +175 -0
- package/src/install.ts +284 -0
- package/src/plugins/registry.ts +77 -0
- package/src/plugins/types.ts +95 -0
- package/src/postprocessors.ts +194 -0
- package/src/runtime/init.ts +162 -0
- package/src/session.ts +376 -0
- package/src/styles/tailwind.css +20 -0
- package/src/styles/widget.css +1627 -0
- package/src/types.ts +1635 -0
- package/src/ui.ts +3341 -0
- package/src/utils/actions.ts +227 -0
- package/src/utils/attachment-manager.ts +384 -0
- package/src/utils/code-generators.test.ts +500 -0
- package/src/utils/code-generators.ts +1806 -0
- package/src/utils/component-middleware.ts +137 -0
- package/src/utils/component-parser.ts +119 -0
- package/src/utils/constants.ts +16 -0
- package/src/utils/content.ts +306 -0
- package/src/utils/dom.ts +25 -0
- package/src/utils/events.ts +41 -0
- package/src/utils/formatting.test.ts +166 -0
- package/src/utils/formatting.ts +470 -0
- package/src/utils/icons.ts +92 -0
- package/src/utils/message-id.ts +37 -0
- package/src/utils/morph.ts +36 -0
- package/src/utils/positioning.ts +17 -0
- package/src/utils/storage.ts +72 -0
- package/src/utils/theme.ts +105 -0
- package/src/widget.css +1 -0
- package/widget.css +1 -0
package/src/ui.ts
ADDED
|
@@ -0,0 +1,3341 @@
|
|
|
1
|
+
import { escapeHtml, createMarkdownProcessorFromConfig } from "./postprocessors";
|
|
2
|
+
import { AgentWidgetSession, AgentWidgetSessionStatus } from "./session";
|
|
3
|
+
import {
|
|
4
|
+
AgentWidgetConfig,
|
|
5
|
+
AgentWidgetMessage,
|
|
6
|
+
AgentWidgetEvent,
|
|
7
|
+
AgentWidgetStorageAdapter,
|
|
8
|
+
AgentWidgetStoredState,
|
|
9
|
+
AgentWidgetControllerEventMap,
|
|
10
|
+
AgentWidgetVoiceStateEvent,
|
|
11
|
+
AgentWidgetStateEvent,
|
|
12
|
+
AgentWidgetStateSnapshot,
|
|
13
|
+
WidgetLayoutSlot,
|
|
14
|
+
SlotRenderer,
|
|
15
|
+
AgentWidgetMessageFeedback,
|
|
16
|
+
ContentPart
|
|
17
|
+
} from "./types";
|
|
18
|
+
import { AttachmentManager } from "./utils/attachment-manager";
|
|
19
|
+
import { createTextPart, ALL_SUPPORTED_MIME_TYPES } from "./utils/content";
|
|
20
|
+
import { applyThemeVariables, createThemeObserver } from "./utils/theme";
|
|
21
|
+
import { renderLucideIcon } from "./utils/icons";
|
|
22
|
+
import { createElement } from "./utils/dom";
|
|
23
|
+
import { morphMessages } from "./utils/morph";
|
|
24
|
+
import { statusCopy } from "./utils/constants";
|
|
25
|
+
import { createLauncherButton } from "./components/launcher";
|
|
26
|
+
import { createWrapper, buildPanel, buildHeader, buildComposer, attachHeaderToContainer } from "./components/panel";
|
|
27
|
+
import { buildHeaderWithLayout } from "./components/header-layouts";
|
|
28
|
+
import { positionMap } from "./utils/positioning";
|
|
29
|
+
import type { HeaderElements, ComposerElements } from "./components/panel";
|
|
30
|
+
import { MessageTransform, MessageActionCallbacks } from "./components/message-bubble";
|
|
31
|
+
import { createStandardBubble, createTypingIndicator } from "./components/message-bubble";
|
|
32
|
+
import { createReasoningBubble, reasoningExpansionState, updateReasoningBubbleUI } from "./components/reasoning-bubble";
|
|
33
|
+
import { createToolBubble, toolExpansionState, updateToolBubbleUI } from "./components/tool-bubble";
|
|
34
|
+
import { createSuggestions } from "./components/suggestions";
|
|
35
|
+
import { enhanceWithForms } from "./components/forms";
|
|
36
|
+
import { pluginRegistry } from "./plugins/registry";
|
|
37
|
+
import { mergeWithDefaults } from "./defaults";
|
|
38
|
+
import { createEventBus } from "./utils/events";
|
|
39
|
+
import {
|
|
40
|
+
createActionManager,
|
|
41
|
+
defaultActionHandlers,
|
|
42
|
+
defaultJsonActionParser
|
|
43
|
+
} from "./utils/actions";
|
|
44
|
+
import { createLocalStorageAdapter } from "./utils/storage";
|
|
45
|
+
import { componentRegistry } from "./components/registry";
|
|
46
|
+
import {
|
|
47
|
+
renderComponentDirective,
|
|
48
|
+
extractComponentDirectiveFromMessage,
|
|
49
|
+
hasComponentDirective
|
|
50
|
+
} from "./utils/component-middleware";
|
|
51
|
+
import {
|
|
52
|
+
createCSATFeedback,
|
|
53
|
+
createNPSFeedback,
|
|
54
|
+
type CSATFeedbackOptions,
|
|
55
|
+
type NPSFeedbackOptions
|
|
56
|
+
} from "./components/feedback";
|
|
57
|
+
|
|
58
|
+
// Default localStorage key for chat history (automatically cleared on clear chat)
|
|
59
|
+
const DEFAULT_CHAT_HISTORY_STORAGE_KEY = "persona-chat-history";
|
|
60
|
+
const VOICE_STATE_RESTORE_WINDOW = 30 * 1000;
|
|
61
|
+
|
|
62
|
+
const ensureRecord = (value: unknown): Record<string, unknown> => {
|
|
63
|
+
if (!value || typeof value !== "object") {
|
|
64
|
+
return {};
|
|
65
|
+
}
|
|
66
|
+
return { ...(value as Record<string, unknown>) };
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const stripStreamingFromMessages = (messages: AgentWidgetMessage[]) =>
|
|
70
|
+
messages.map((message) => ({
|
|
71
|
+
...message,
|
|
72
|
+
streaming: false
|
|
73
|
+
}));
|
|
74
|
+
|
|
75
|
+
type Controller = {
|
|
76
|
+
update: (config: AgentWidgetConfig) => void;
|
|
77
|
+
destroy: () => void;
|
|
78
|
+
open: () => void;
|
|
79
|
+
close: () => void;
|
|
80
|
+
toggle: () => void;
|
|
81
|
+
clearChat: () => void;
|
|
82
|
+
setMessage: (message: string) => boolean;
|
|
83
|
+
submitMessage: (message?: string) => boolean;
|
|
84
|
+
startVoiceRecognition: () => boolean;
|
|
85
|
+
stopVoiceRecognition: () => boolean;
|
|
86
|
+
injectTestMessage: (event: AgentWidgetEvent) => void;
|
|
87
|
+
getMessages: () => AgentWidgetMessage[];
|
|
88
|
+
getStatus: () => AgentWidgetSessionStatus;
|
|
89
|
+
getPersistentMetadata: () => Record<string, unknown>;
|
|
90
|
+
updatePersistentMetadata: (
|
|
91
|
+
updater: (prev: Record<string, unknown>) => Record<string, unknown>
|
|
92
|
+
) => void;
|
|
93
|
+
on: <K extends keyof AgentWidgetControllerEventMap>(
|
|
94
|
+
event: K,
|
|
95
|
+
handler: (payload: AgentWidgetControllerEventMap[K]) => void
|
|
96
|
+
) => () => void;
|
|
97
|
+
off: <K extends keyof AgentWidgetControllerEventMap>(
|
|
98
|
+
event: K,
|
|
99
|
+
handler: (payload: AgentWidgetControllerEventMap[K]) => void
|
|
100
|
+
) => void;
|
|
101
|
+
// State query methods
|
|
102
|
+
isOpen: () => boolean;
|
|
103
|
+
isVoiceActive: () => boolean;
|
|
104
|
+
getState: () => AgentWidgetStateSnapshot;
|
|
105
|
+
// Feedback methods (CSAT/NPS)
|
|
106
|
+
showCSATFeedback: (options?: Partial<CSATFeedbackOptions>) => void;
|
|
107
|
+
showNPSFeedback: (options?: Partial<NPSFeedbackOptions>) => void;
|
|
108
|
+
submitCSATFeedback: (rating: number, comment?: string) => Promise<void>;
|
|
109
|
+
submitNPSFeedback: (rating: number, comment?: string) => Promise<void>;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const buildPostprocessor = (
|
|
113
|
+
cfg: AgentWidgetConfig | undefined,
|
|
114
|
+
actionManager?: ReturnType<typeof createActionManager>
|
|
115
|
+
): MessageTransform => {
|
|
116
|
+
// Create markdown processor from config if markdown config is provided
|
|
117
|
+
// This allows users to enable markdown rendering via config.markdown
|
|
118
|
+
const markdownProcessor = cfg?.markdown
|
|
119
|
+
? createMarkdownProcessorFromConfig(cfg.markdown)
|
|
120
|
+
: null;
|
|
121
|
+
|
|
122
|
+
return (context) => {
|
|
123
|
+
let nextText = context.text ?? "";
|
|
124
|
+
const rawPayload = context.message.rawContent ?? null;
|
|
125
|
+
|
|
126
|
+
if (actionManager) {
|
|
127
|
+
const actionResult = actionManager.process({
|
|
128
|
+
text: nextText,
|
|
129
|
+
raw: rawPayload ?? nextText,
|
|
130
|
+
message: context.message,
|
|
131
|
+
streaming: context.streaming
|
|
132
|
+
});
|
|
133
|
+
if (actionResult !== null) {
|
|
134
|
+
nextText = actionResult.text;
|
|
135
|
+
// Mark message as non-persistable if persist is false
|
|
136
|
+
if (!actionResult.persist) {
|
|
137
|
+
(context.message as any).__skipPersist = true;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Priority: postprocessMessage > markdown config > escapeHtml
|
|
143
|
+
if (cfg?.postprocessMessage) {
|
|
144
|
+
return cfg.postprocessMessage({
|
|
145
|
+
...context,
|
|
146
|
+
text: nextText,
|
|
147
|
+
raw: rawPayload ?? context.text ?? ""
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Use markdown processor if markdown config is provided
|
|
152
|
+
if (markdownProcessor) {
|
|
153
|
+
return markdownProcessor(nextText);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return escapeHtml(nextText);
|
|
157
|
+
};
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
export const createAgentExperience = (
|
|
161
|
+
mount: HTMLElement,
|
|
162
|
+
initialConfig?: AgentWidgetConfig,
|
|
163
|
+
runtimeOptions?: { debugTools?: boolean }
|
|
164
|
+
): Controller => {
|
|
165
|
+
// Tailwind config uses important: "#persona-root", so ensure mount has this ID
|
|
166
|
+
if (!mount.id || mount.id !== "persona-root") {
|
|
167
|
+
mount.id = "persona-root";
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let config = mergeWithDefaults(initialConfig) as AgentWidgetConfig;
|
|
171
|
+
// Note: applyThemeVariables is called after applyFullHeightStyles() below
|
|
172
|
+
// because applyFullHeightStyles resets mount.style.cssText
|
|
173
|
+
|
|
174
|
+
// Get plugins for this instance
|
|
175
|
+
const plugins = pluginRegistry.getForInstance(config.plugins);
|
|
176
|
+
|
|
177
|
+
// Register components from config
|
|
178
|
+
if (config.components) {
|
|
179
|
+
componentRegistry.registerAll(config.components);
|
|
180
|
+
}
|
|
181
|
+
const eventBus = createEventBus<AgentWidgetControllerEventMap>();
|
|
182
|
+
|
|
183
|
+
const storageAdapter: AgentWidgetStorageAdapter =
|
|
184
|
+
config.storageAdapter ?? createLocalStorageAdapter();
|
|
185
|
+
let persistentMetadata: Record<string, unknown> = {};
|
|
186
|
+
let pendingStoredState: Promise<AgentWidgetStoredState | null> | null = null;
|
|
187
|
+
|
|
188
|
+
if (storageAdapter?.load) {
|
|
189
|
+
try {
|
|
190
|
+
const storedState = storageAdapter.load();
|
|
191
|
+
if (storedState && typeof (storedState as Promise<any>).then === "function") {
|
|
192
|
+
pendingStoredState = storedState as Promise<AgentWidgetStoredState | null>;
|
|
193
|
+
} else if (storedState) {
|
|
194
|
+
const immediateState = storedState as AgentWidgetStoredState;
|
|
195
|
+
if (immediateState.metadata) {
|
|
196
|
+
persistentMetadata = ensureRecord(immediateState.metadata);
|
|
197
|
+
}
|
|
198
|
+
if (immediateState.messages?.length) {
|
|
199
|
+
config = { ...config, initialMessages: immediateState.messages };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
} catch (error) {
|
|
203
|
+
if (typeof console !== "undefined") {
|
|
204
|
+
// eslint-disable-next-line no-console
|
|
205
|
+
console.error("[AgentWidget] Failed to load stored state:", error);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const getSessionMetadata = () => persistentMetadata;
|
|
211
|
+
const updateSessionMetadata = (
|
|
212
|
+
updater: (prev: Record<string, unknown>) => Record<string, unknown>
|
|
213
|
+
) => {
|
|
214
|
+
const next = updater({ ...persistentMetadata }) ?? {};
|
|
215
|
+
persistentMetadata = next;
|
|
216
|
+
persistState();
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const resolvedActionParsers =
|
|
220
|
+
config.actionParsers && config.actionParsers.length
|
|
221
|
+
? config.actionParsers
|
|
222
|
+
: [defaultJsonActionParser];
|
|
223
|
+
|
|
224
|
+
const resolvedActionHandlers =
|
|
225
|
+
config.actionHandlers && config.actionHandlers.length
|
|
226
|
+
? config.actionHandlers
|
|
227
|
+
: [defaultActionHandlers.message, defaultActionHandlers.messageAndClick];
|
|
228
|
+
|
|
229
|
+
let actionManager = createActionManager({
|
|
230
|
+
parsers: resolvedActionParsers,
|
|
231
|
+
handlers: resolvedActionHandlers,
|
|
232
|
+
getSessionMetadata,
|
|
233
|
+
updateSessionMetadata,
|
|
234
|
+
emit: eventBus.emit,
|
|
235
|
+
documentRef: typeof document !== "undefined" ? document : null
|
|
236
|
+
});
|
|
237
|
+
actionManager.syncFromMetadata();
|
|
238
|
+
|
|
239
|
+
let launcherEnabled = config.launcher?.enabled ?? true;
|
|
240
|
+
let autoExpand = config.launcher?.autoExpand ?? false;
|
|
241
|
+
let prevAutoExpand = autoExpand;
|
|
242
|
+
let prevLauncherEnabled = launcherEnabled;
|
|
243
|
+
let prevHeaderLayout = config.layout?.header?.layout;
|
|
244
|
+
let open = launcherEnabled ? autoExpand : true;
|
|
245
|
+
let postprocess = buildPostprocessor(config, actionManager);
|
|
246
|
+
let showReasoning = config.features?.showReasoning ?? true;
|
|
247
|
+
let showToolCalls = config.features?.showToolCalls ?? true;
|
|
248
|
+
|
|
249
|
+
// Create message action callbacks that emit events and optionally send to API
|
|
250
|
+
const messageActionCallbacks: MessageActionCallbacks = {
|
|
251
|
+
onCopy: (message: AgentWidgetMessage) => {
|
|
252
|
+
eventBus.emit("message:copy", message);
|
|
253
|
+
// Send copy feedback to API if in client token mode
|
|
254
|
+
if (session?.isClientTokenMode()) {
|
|
255
|
+
session.submitMessageFeedback(message.id, 'copy').catch((error) => {
|
|
256
|
+
if (config.debug) {
|
|
257
|
+
// eslint-disable-next-line no-console
|
|
258
|
+
console.error("[AgentWidget] Failed to submit copy feedback:", error);
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
// Call user-provided callback
|
|
263
|
+
config.messageActions?.onCopy?.(message);
|
|
264
|
+
},
|
|
265
|
+
onFeedback: (feedback: AgentWidgetMessageFeedback) => {
|
|
266
|
+
eventBus.emit("message:feedback", feedback);
|
|
267
|
+
// Send feedback to API if in client token mode
|
|
268
|
+
if (session?.isClientTokenMode()) {
|
|
269
|
+
session.submitMessageFeedback(feedback.messageId, feedback.type).catch((error) => {
|
|
270
|
+
if (config.debug) {
|
|
271
|
+
// eslint-disable-next-line no-console
|
|
272
|
+
console.error("[AgentWidget] Failed to submit feedback:", error);
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
// Call user-provided callback
|
|
277
|
+
config.messageActions?.onFeedback?.(feedback);
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
// Get status indicator config
|
|
282
|
+
const statusConfig = config.statusIndicator ?? {};
|
|
283
|
+
const getStatusText = (status: AgentWidgetSessionStatus): string => {
|
|
284
|
+
if (status === "idle") return statusConfig.idleText ?? statusCopy.idle;
|
|
285
|
+
if (status === "connecting") return statusConfig.connectingText ?? statusCopy.connecting;
|
|
286
|
+
if (status === "connected") return statusConfig.connectedText ?? statusCopy.connected;
|
|
287
|
+
if (status === "error") return statusConfig.errorText ?? statusCopy.error;
|
|
288
|
+
return statusCopy[status];
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const { wrapper, panel } = createWrapper(config);
|
|
292
|
+
const panelElements = buildPanel(config, launcherEnabled);
|
|
293
|
+
let {
|
|
294
|
+
container,
|
|
295
|
+
body,
|
|
296
|
+
messagesWrapper,
|
|
297
|
+
suggestions,
|
|
298
|
+
textarea,
|
|
299
|
+
sendButton,
|
|
300
|
+
sendButtonWrapper,
|
|
301
|
+
composerForm,
|
|
302
|
+
statusText,
|
|
303
|
+
introTitle,
|
|
304
|
+
introSubtitle,
|
|
305
|
+
closeButton,
|
|
306
|
+
iconHolder,
|
|
307
|
+
headerTitle,
|
|
308
|
+
headerSubtitle,
|
|
309
|
+
header,
|
|
310
|
+
footer,
|
|
311
|
+
actionsRow,
|
|
312
|
+
leftActions,
|
|
313
|
+
rightActions
|
|
314
|
+
} = panelElements;
|
|
315
|
+
|
|
316
|
+
// Use mutable references for mic button so we can update them dynamically
|
|
317
|
+
let micButton: HTMLButtonElement | null = panelElements.micButton;
|
|
318
|
+
let micButtonWrapper: HTMLElement | null = panelElements.micButtonWrapper;
|
|
319
|
+
|
|
320
|
+
// Use mutable references for attachment elements so we can create them dynamically
|
|
321
|
+
let attachmentButton: HTMLButtonElement | null = panelElements.attachmentButton;
|
|
322
|
+
let attachmentButtonWrapper: HTMLElement | null = panelElements.attachmentButtonWrapper;
|
|
323
|
+
let attachmentInput: HTMLInputElement | null = panelElements.attachmentInput;
|
|
324
|
+
let attachmentPreviewsContainer: HTMLElement | null = panelElements.attachmentPreviewsContainer;
|
|
325
|
+
|
|
326
|
+
// Initialize attachment manager if attachments are enabled
|
|
327
|
+
let attachmentManager: AttachmentManager | null = null;
|
|
328
|
+
if (config.attachments?.enabled && attachmentInput && attachmentPreviewsContainer) {
|
|
329
|
+
attachmentManager = AttachmentManager.fromConfig(config.attachments);
|
|
330
|
+
attachmentManager.setPreviewsContainer(attachmentPreviewsContainer);
|
|
331
|
+
|
|
332
|
+
// Wire up file input change event
|
|
333
|
+
attachmentInput.addEventListener("change", (e) => {
|
|
334
|
+
const target = e.target as HTMLInputElement;
|
|
335
|
+
attachmentManager?.handleFileSelect(target.files);
|
|
336
|
+
// Reset input so same file can be selected again
|
|
337
|
+
target.value = "";
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Plugin hook: renderHeader - allow plugins to provide custom header
|
|
342
|
+
const headerPlugin = plugins.find(p => p.renderHeader);
|
|
343
|
+
if (headerPlugin?.renderHeader) {
|
|
344
|
+
const customHeader = headerPlugin.renderHeader({
|
|
345
|
+
config,
|
|
346
|
+
defaultRenderer: () => {
|
|
347
|
+
const headerElements = buildHeader({ config, showClose: launcherEnabled });
|
|
348
|
+
attachHeaderToContainer(container, headerElements, config);
|
|
349
|
+
return headerElements.header;
|
|
350
|
+
},
|
|
351
|
+
onClose: () => setOpenState(false, "user")
|
|
352
|
+
});
|
|
353
|
+
if (customHeader) {
|
|
354
|
+
// Replace the default header with custom header
|
|
355
|
+
const existingHeader = container.querySelector('.tvw-border-b-cw-divider');
|
|
356
|
+
if (existingHeader) {
|
|
357
|
+
existingHeader.replaceWith(customHeader);
|
|
358
|
+
header = customHeader;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Plugin hook: renderComposer - allow plugins to provide custom composer
|
|
364
|
+
const composerPlugin = plugins.find(p => p.renderComposer);
|
|
365
|
+
if (composerPlugin?.renderComposer) {
|
|
366
|
+
const customComposer = composerPlugin.renderComposer({
|
|
367
|
+
config,
|
|
368
|
+
defaultRenderer: () => {
|
|
369
|
+
const composerElements = buildComposer({ config });
|
|
370
|
+
return composerElements.footer;
|
|
371
|
+
},
|
|
372
|
+
onSubmit: (text: string) => {
|
|
373
|
+
if (session && !session.isStreaming()) {
|
|
374
|
+
session.sendMessage(text);
|
|
375
|
+
}
|
|
376
|
+
},
|
|
377
|
+
disabled: false
|
|
378
|
+
});
|
|
379
|
+
if (customComposer) {
|
|
380
|
+
// Replace the default footer with custom composer
|
|
381
|
+
footer.replaceWith(customComposer);
|
|
382
|
+
footer = customComposer;
|
|
383
|
+
// Note: When using custom composer, textarea/sendButton/etc may not exist
|
|
384
|
+
// The plugin is responsible for providing its own submit handling
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Slot system: allow custom content injection into specific regions
|
|
389
|
+
const renderSlots = () => {
|
|
390
|
+
const slots = config.layout?.slots ?? {};
|
|
391
|
+
|
|
392
|
+
// Helper to get default slot content
|
|
393
|
+
const getDefaultSlotContent = (slot: WidgetLayoutSlot): HTMLElement | null => {
|
|
394
|
+
switch (slot) {
|
|
395
|
+
case "body-top":
|
|
396
|
+
// Default: the intro card
|
|
397
|
+
return container.querySelector(".tvw-rounded-2xl.tvw-bg-cw-surface.tvw-p-6") as HTMLElement || null;
|
|
398
|
+
case "messages":
|
|
399
|
+
return messagesWrapper;
|
|
400
|
+
case "footer-top":
|
|
401
|
+
return suggestions;
|
|
402
|
+
case "composer":
|
|
403
|
+
return composerForm;
|
|
404
|
+
case "footer-bottom":
|
|
405
|
+
return statusText;
|
|
406
|
+
default:
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
// Helper to insert content into slot region
|
|
412
|
+
const insertSlotContent = (slot: WidgetLayoutSlot, element: HTMLElement) => {
|
|
413
|
+
switch (slot) {
|
|
414
|
+
case "header-left":
|
|
415
|
+
case "header-center":
|
|
416
|
+
case "header-right":
|
|
417
|
+
// Header slots - prepend/append to header
|
|
418
|
+
if (slot === "header-left") {
|
|
419
|
+
header.insertBefore(element, header.firstChild);
|
|
420
|
+
} else if (slot === "header-right") {
|
|
421
|
+
header.appendChild(element);
|
|
422
|
+
} else {
|
|
423
|
+
// header-center: insert after icon/title
|
|
424
|
+
const titleSection = header.querySelector(".tvw-flex-col");
|
|
425
|
+
if (titleSection) {
|
|
426
|
+
titleSection.parentNode?.insertBefore(element, titleSection.nextSibling);
|
|
427
|
+
} else {
|
|
428
|
+
header.appendChild(element);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
break;
|
|
432
|
+
case "body-top":
|
|
433
|
+
// Replace or prepend to body
|
|
434
|
+
const introCard = body.querySelector(".tvw-rounded-2xl.tvw-bg-cw-surface.tvw-p-6");
|
|
435
|
+
if (introCard) {
|
|
436
|
+
introCard.replaceWith(element);
|
|
437
|
+
} else {
|
|
438
|
+
body.insertBefore(element, body.firstChild);
|
|
439
|
+
}
|
|
440
|
+
break;
|
|
441
|
+
case "body-bottom":
|
|
442
|
+
// Append after messages wrapper
|
|
443
|
+
body.appendChild(element);
|
|
444
|
+
break;
|
|
445
|
+
case "footer-top":
|
|
446
|
+
// Replace suggestions area
|
|
447
|
+
suggestions.replaceWith(element);
|
|
448
|
+
break;
|
|
449
|
+
case "footer-bottom":
|
|
450
|
+
// Replace or append after status text
|
|
451
|
+
statusText.replaceWith(element);
|
|
452
|
+
break;
|
|
453
|
+
default:
|
|
454
|
+
// For other slots, just append to appropriate container
|
|
455
|
+
break;
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
// Process each configured slot
|
|
460
|
+
for (const [slotName, renderer] of Object.entries(slots) as [WidgetLayoutSlot, SlotRenderer][]) {
|
|
461
|
+
if (renderer) {
|
|
462
|
+
try {
|
|
463
|
+
const slotElement = renderer({
|
|
464
|
+
config,
|
|
465
|
+
defaultContent: () => getDefaultSlotContent(slotName)
|
|
466
|
+
});
|
|
467
|
+
if (slotElement) {
|
|
468
|
+
insertSlotContent(slotName, slotElement);
|
|
469
|
+
}
|
|
470
|
+
} catch (error) {
|
|
471
|
+
if (typeof console !== "undefined") {
|
|
472
|
+
// eslint-disable-next-line no-console
|
|
473
|
+
console.error(`[AgentWidget] Error rendering slot "${slotName}":`, error);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
// Render custom slots
|
|
481
|
+
renderSlots();
|
|
482
|
+
|
|
483
|
+
// Add event delegation for reasoning and tool bubble expansion
|
|
484
|
+
// This handles clicks even after idiomorph morphs the DOM
|
|
485
|
+
const handleBubbleExpansion = (event: Event) => {
|
|
486
|
+
const target = event.target as HTMLElement;
|
|
487
|
+
|
|
488
|
+
// Check if the click/keypress is on an expand header button
|
|
489
|
+
const headerButton = target.closest('button[data-expand-header="true"]') as HTMLElement;
|
|
490
|
+
if (!headerButton) return;
|
|
491
|
+
|
|
492
|
+
// Find the parent bubble element
|
|
493
|
+
const bubble = headerButton.closest('.vanilla-reasoning-bubble, .vanilla-tool-bubble') as HTMLElement;
|
|
494
|
+
if (!bubble) return;
|
|
495
|
+
|
|
496
|
+
// Get message ID from bubble
|
|
497
|
+
const messageId = bubble.getAttribute('data-message-id');
|
|
498
|
+
if (!messageId) return;
|
|
499
|
+
|
|
500
|
+
const bubbleType = headerButton.getAttribute('data-bubble-type');
|
|
501
|
+
|
|
502
|
+
// Toggle expansion state
|
|
503
|
+
if (bubbleType === 'reasoning') {
|
|
504
|
+
if (reasoningExpansionState.has(messageId)) {
|
|
505
|
+
reasoningExpansionState.delete(messageId);
|
|
506
|
+
} else {
|
|
507
|
+
reasoningExpansionState.add(messageId);
|
|
508
|
+
}
|
|
509
|
+
updateReasoningBubbleUI(messageId, bubble);
|
|
510
|
+
} else if (bubbleType === 'tool') {
|
|
511
|
+
if (toolExpansionState.has(messageId)) {
|
|
512
|
+
toolExpansionState.delete(messageId);
|
|
513
|
+
} else {
|
|
514
|
+
toolExpansionState.add(messageId);
|
|
515
|
+
}
|
|
516
|
+
updateToolBubbleUI(messageId, bubble, config);
|
|
517
|
+
}
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
// Attach event listeners to messagesWrapper for event delegation
|
|
521
|
+
messagesWrapper.addEventListener('pointerdown', (event) => {
|
|
522
|
+
const target = event.target as HTMLElement;
|
|
523
|
+
if (target.closest('button[data-expand-header="true"]')) {
|
|
524
|
+
event.preventDefault();
|
|
525
|
+
handleBubbleExpansion(event);
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
messagesWrapper.addEventListener('keydown', (event) => {
|
|
530
|
+
const target = event.target as HTMLElement;
|
|
531
|
+
if ((event.key === 'Enter' || event.key === ' ') && target.closest('button[data-expand-header="true"]')) {
|
|
532
|
+
event.preventDefault();
|
|
533
|
+
handleBubbleExpansion(event);
|
|
534
|
+
}
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
panel.appendChild(container);
|
|
538
|
+
mount.appendChild(wrapper);
|
|
539
|
+
|
|
540
|
+
// Apply full-height and sidebar styles if enabled
|
|
541
|
+
// This ensures the widget fills its container height with proper flex layout
|
|
542
|
+
const applyFullHeightStyles = () => {
|
|
543
|
+
const sidebarMode = config.launcher?.sidebarMode ?? false;
|
|
544
|
+
const fullHeight = sidebarMode || (config.launcher?.fullHeight ?? false);
|
|
545
|
+
const theme = config.theme ?? {};
|
|
546
|
+
|
|
547
|
+
// Determine panel styling based on mode, with theme overrides
|
|
548
|
+
const position = config.launcher?.position ?? 'bottom-left';
|
|
549
|
+
const isLeftSidebar = position === 'bottom-left' || position === 'top-left';
|
|
550
|
+
|
|
551
|
+
// Default values based on mode
|
|
552
|
+
const defaultPanelBorder = sidebarMode ? 'none' : '1px solid var(--tvw-cw-border)';
|
|
553
|
+
const defaultPanelShadow = sidebarMode
|
|
554
|
+
? (isLeftSidebar ? '2px 0 12px rgba(0, 0, 0, 0.08)' : '-2px 0 12px rgba(0, 0, 0, 0.08)')
|
|
555
|
+
: '0 25px 50px -12px rgba(0, 0, 0, 0.25)';
|
|
556
|
+
const defaultPanelBorderRadius = sidebarMode ? '0' : '16px';
|
|
557
|
+
|
|
558
|
+
// Apply theme overrides or defaults
|
|
559
|
+
const panelBorder = theme.panelBorder ?? defaultPanelBorder;
|
|
560
|
+
const panelShadow = theme.panelShadow ?? defaultPanelShadow;
|
|
561
|
+
const panelBorderRadius = theme.panelBorderRadius ?? defaultPanelBorderRadius;
|
|
562
|
+
|
|
563
|
+
// Reset all inline styles first to handle mode toggling
|
|
564
|
+
// This ensures styles don't persist when switching between modes
|
|
565
|
+
mount.style.cssText = '';
|
|
566
|
+
wrapper.style.cssText = '';
|
|
567
|
+
panel.style.cssText = '';
|
|
568
|
+
container.style.cssText = '';
|
|
569
|
+
body.style.cssText = '';
|
|
570
|
+
footer.style.cssText = '';
|
|
571
|
+
|
|
572
|
+
// Re-apply panel width/maxWidth from initial setup
|
|
573
|
+
const launcherWidth = config?.launcher?.width ?? config?.launcherWidth;
|
|
574
|
+
const width = launcherWidth ?? "min(400px, calc(100vw - 24px))";
|
|
575
|
+
if (!sidebarMode) {
|
|
576
|
+
panel.style.width = width;
|
|
577
|
+
panel.style.maxWidth = width;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Apply panel styling
|
|
581
|
+
// Box-shadow is applied to panel (parent) instead of container to avoid
|
|
582
|
+
// rendering artifacts when container has overflow:hidden + border-radius
|
|
583
|
+
// Panel also gets border-radius to make the shadow follow the rounded corners
|
|
584
|
+
panel.style.boxShadow = panelShadow;
|
|
585
|
+
panel.style.borderRadius = panelBorderRadius;
|
|
586
|
+
container.style.border = panelBorder;
|
|
587
|
+
container.style.borderRadius = panelBorderRadius;
|
|
588
|
+
|
|
589
|
+
// Check if this is inline embed mode (launcher disabled) vs launcher mode
|
|
590
|
+
const isInlineEmbed = config.launcher?.enabled === false;
|
|
591
|
+
|
|
592
|
+
if (fullHeight) {
|
|
593
|
+
// Mount container
|
|
594
|
+
mount.style.display = 'flex';
|
|
595
|
+
mount.style.flexDirection = 'column';
|
|
596
|
+
mount.style.height = '100%';
|
|
597
|
+
mount.style.minHeight = '0';
|
|
598
|
+
|
|
599
|
+
// Wrapper
|
|
600
|
+
// - Inline embed: needs overflow:hidden to contain the flex layout
|
|
601
|
+
// - Launcher mode: no overflow:hidden to allow panel's box-shadow to render fully
|
|
602
|
+
wrapper.style.display = 'flex';
|
|
603
|
+
wrapper.style.flexDirection = 'column';
|
|
604
|
+
wrapper.style.flex = '1 1 0%';
|
|
605
|
+
wrapper.style.minHeight = '0';
|
|
606
|
+
wrapper.style.maxHeight = '100%';
|
|
607
|
+
wrapper.style.height = '100%';
|
|
608
|
+
if (isInlineEmbed) {
|
|
609
|
+
wrapper.style.overflow = 'hidden';
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Panel
|
|
613
|
+
panel.style.display = 'flex';
|
|
614
|
+
panel.style.flexDirection = 'column';
|
|
615
|
+
panel.style.flex = '1 1 0%';
|
|
616
|
+
panel.style.minHeight = '0';
|
|
617
|
+
panel.style.maxHeight = '100%';
|
|
618
|
+
panel.style.height = '100%';
|
|
619
|
+
panel.style.overflow = 'hidden';
|
|
620
|
+
|
|
621
|
+
// Main container
|
|
622
|
+
container.style.display = 'flex';
|
|
623
|
+
container.style.flexDirection = 'column';
|
|
624
|
+
container.style.flex = '1 1 0%';
|
|
625
|
+
container.style.minHeight = '0';
|
|
626
|
+
container.style.maxHeight = '100%';
|
|
627
|
+
container.style.overflow = 'hidden';
|
|
628
|
+
|
|
629
|
+
// Body (scrollable messages area)
|
|
630
|
+
body.style.flex = '1 1 0%';
|
|
631
|
+
body.style.minHeight = '0';
|
|
632
|
+
body.style.overflowY = 'auto';
|
|
633
|
+
|
|
634
|
+
// Footer (composer) - should not shrink
|
|
635
|
+
footer.style.flexShrink = '0';
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// Handle positioning classes based on mode
|
|
639
|
+
// First remove all position classes to reset state
|
|
640
|
+
wrapper.classList.remove(
|
|
641
|
+
'tvw-bottom-6', 'tvw-right-6', 'tvw-left-6', 'tvw-top-6',
|
|
642
|
+
'tvw-bottom-4', 'tvw-right-4', 'tvw-left-4', 'tvw-top-4'
|
|
643
|
+
);
|
|
644
|
+
|
|
645
|
+
if (!sidebarMode && !isInlineEmbed) {
|
|
646
|
+
// Restore positioning classes when not in sidebar mode (launcher mode only)
|
|
647
|
+
const positionClasses = positionMap[position as keyof typeof positionMap] ?? positionMap['bottom-right'];
|
|
648
|
+
positionClasses.split(' ').forEach(cls => wrapper.classList.add(cls));
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Apply sidebar-specific styles
|
|
652
|
+
if (sidebarMode) {
|
|
653
|
+
const sidebarWidth = config.launcher?.sidebarWidth ?? '420px';
|
|
654
|
+
|
|
655
|
+
// Wrapper - fixed position, flush with edges
|
|
656
|
+
wrapper.style.cssText = `
|
|
657
|
+
position: fixed !important;
|
|
658
|
+
top: 0 !important;
|
|
659
|
+
bottom: 0 !important;
|
|
660
|
+
width: ${sidebarWidth} !important;
|
|
661
|
+
height: 100vh !important;
|
|
662
|
+
max-height: 100vh !important;
|
|
663
|
+
margin: 0 !important;
|
|
664
|
+
padding: 0 !important;
|
|
665
|
+
display: flex !important;
|
|
666
|
+
flex-direction: column !important;
|
|
667
|
+
${isLeftSidebar ? 'left: 0 !important; right: auto !important;' : 'left: auto !important; right: 0 !important;'}
|
|
668
|
+
`;
|
|
669
|
+
|
|
670
|
+
// Panel - fill wrapper (override inline width/max-width from panel.ts)
|
|
671
|
+
// Box-shadow is on panel to avoid rendering artifacts with container's overflow:hidden
|
|
672
|
+
// Border-radius on panel ensures shadow follows rounded corners
|
|
673
|
+
panel.style.cssText = `
|
|
674
|
+
position: relative !important;
|
|
675
|
+
display: flex !important;
|
|
676
|
+
flex-direction: column !important;
|
|
677
|
+
flex: 1 1 0% !important;
|
|
678
|
+
width: 100% !important;
|
|
679
|
+
max-width: 100% !important;
|
|
680
|
+
height: 100% !important;
|
|
681
|
+
min-height: 0 !important;
|
|
682
|
+
margin: 0 !important;
|
|
683
|
+
padding: 0 !important;
|
|
684
|
+
box-shadow: ${panelShadow} !important;
|
|
685
|
+
border-radius: ${panelBorderRadius} !important;
|
|
686
|
+
`;
|
|
687
|
+
// Force override any inline width/maxWidth that may be set elsewhere
|
|
688
|
+
panel.style.setProperty('width', '100%', 'important');
|
|
689
|
+
panel.style.setProperty('max-width', '100%', 'important');
|
|
690
|
+
|
|
691
|
+
// Container - apply configurable styles with sidebar layout
|
|
692
|
+
// Note: box-shadow is on panel, not container
|
|
693
|
+
container.style.cssText = `
|
|
694
|
+
display: flex !important;
|
|
695
|
+
flex-direction: column !important;
|
|
696
|
+
flex: 1 1 0% !important;
|
|
697
|
+
width: 100% !important;
|
|
698
|
+
height: 100% !important;
|
|
699
|
+
min-height: 0 !important;
|
|
700
|
+
max-height: 100% !important;
|
|
701
|
+
overflow: hidden !important;
|
|
702
|
+
border-radius: ${panelBorderRadius} !important;
|
|
703
|
+
border: ${panelBorder} !important;
|
|
704
|
+
`;
|
|
705
|
+
|
|
706
|
+
// Remove footer border in sidebar mode
|
|
707
|
+
footer.style.cssText = `
|
|
708
|
+
flex-shrink: 0 !important;
|
|
709
|
+
border-top: none !important;
|
|
710
|
+
padding: 8px 16px 12px 16px !important;
|
|
711
|
+
`;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Apply max-height constraints to wrapper to prevent expanding past viewport top
|
|
715
|
+
// Use both -moz-available (Firefox) and stretch (standard) for cross-browser support
|
|
716
|
+
// Append to cssText to allow multiple fallback values for the same property
|
|
717
|
+
// Only apply to launcher mode (not sidebar or inline embed)
|
|
718
|
+
if (!isInlineEmbed) {
|
|
719
|
+
const maxHeightStyles = 'max-height: -moz-available !important; max-height: stretch !important;';
|
|
720
|
+
const paddingStyles = sidebarMode ? '' : 'padding-top: 1.25em !important;';
|
|
721
|
+
wrapper.style.cssText += maxHeightStyles + paddingStyles;
|
|
722
|
+
}
|
|
723
|
+
};
|
|
724
|
+
applyFullHeightStyles();
|
|
725
|
+
// Apply theme variables after applyFullHeightStyles since it resets mount.style.cssText
|
|
726
|
+
applyThemeVariables(mount, config);
|
|
727
|
+
|
|
728
|
+
const destroyCallbacks: Array<() => void> = [];
|
|
729
|
+
|
|
730
|
+
// Set up theme observer for auto color scheme detection
|
|
731
|
+
let cleanupThemeObserver: (() => void) | null = null;
|
|
732
|
+
const setupThemeObserver = () => {
|
|
733
|
+
// Clean up existing observer if any
|
|
734
|
+
if (cleanupThemeObserver) {
|
|
735
|
+
cleanupThemeObserver();
|
|
736
|
+
cleanupThemeObserver = null;
|
|
737
|
+
}
|
|
738
|
+
// Set up new observer if colorScheme is 'auto'
|
|
739
|
+
if (config.colorScheme === 'auto') {
|
|
740
|
+
cleanupThemeObserver = createThemeObserver(() => {
|
|
741
|
+
// Re-apply theme when color scheme changes
|
|
742
|
+
applyThemeVariables(mount, config);
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
};
|
|
746
|
+
setupThemeObserver();
|
|
747
|
+
destroyCallbacks.push(() => {
|
|
748
|
+
if (cleanupThemeObserver) {
|
|
749
|
+
cleanupThemeObserver();
|
|
750
|
+
cleanupThemeObserver = null;
|
|
751
|
+
}
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
const suggestionsManager = createSuggestions(suggestions);
|
|
755
|
+
let closeHandler: (() => void) | null = null;
|
|
756
|
+
let session: AgentWidgetSession;
|
|
757
|
+
let isStreaming = false;
|
|
758
|
+
let shouldAutoScroll = true;
|
|
759
|
+
let lastScrollTop = 0;
|
|
760
|
+
let lastAutoScrollTime = 0;
|
|
761
|
+
let scrollRAF: number | null = null;
|
|
762
|
+
let isAutoScrollBlocked = false;
|
|
763
|
+
let blockUntilTime = 0;
|
|
764
|
+
let isAutoScrolling = false;
|
|
765
|
+
|
|
766
|
+
const AUTO_SCROLL_THROTTLE = 125;
|
|
767
|
+
const AUTO_SCROLL_BLOCK_TIME = 2000;
|
|
768
|
+
const USER_SCROLL_THRESHOLD = 5;
|
|
769
|
+
const BOTTOM_THRESHOLD = 50;
|
|
770
|
+
const messageState = new Map<
|
|
771
|
+
string,
|
|
772
|
+
{ streaming?: boolean; role: AgentWidgetMessage["role"] }
|
|
773
|
+
>();
|
|
774
|
+
const voiceState = {
|
|
775
|
+
active: false,
|
|
776
|
+
manuallyDeactivated: false,
|
|
777
|
+
lastUserMessageWasVoice: false
|
|
778
|
+
};
|
|
779
|
+
const voiceAutoResumeMode = config.voiceRecognition?.autoResume ?? false;
|
|
780
|
+
const emitVoiceState = (source: AgentWidgetVoiceStateEvent["source"]) => {
|
|
781
|
+
eventBus.emit("voice:state", {
|
|
782
|
+
active: voiceState.active,
|
|
783
|
+
source,
|
|
784
|
+
timestamp: Date.now()
|
|
785
|
+
});
|
|
786
|
+
};
|
|
787
|
+
const persistVoiceMetadata = () => {
|
|
788
|
+
updateSessionMetadata((prev) => ({
|
|
789
|
+
...prev,
|
|
790
|
+
voiceState: {
|
|
791
|
+
active: voiceState.active,
|
|
792
|
+
timestamp: Date.now(),
|
|
793
|
+
manuallyDeactivated: voiceState.manuallyDeactivated
|
|
794
|
+
}
|
|
795
|
+
}));
|
|
796
|
+
};
|
|
797
|
+
const maybeRestoreVoiceFromMetadata = () => {
|
|
798
|
+
if (config.voiceRecognition?.enabled === false) return;
|
|
799
|
+
const rawVoiceState = ensureRecord((persistentMetadata as any).voiceState);
|
|
800
|
+
const wasActive = Boolean(rawVoiceState.active);
|
|
801
|
+
const timestamp = Number(rawVoiceState.timestamp ?? 0);
|
|
802
|
+
voiceState.manuallyDeactivated = Boolean(rawVoiceState.manuallyDeactivated);
|
|
803
|
+
if (wasActive && Date.now() - timestamp < VOICE_STATE_RESTORE_WINDOW) {
|
|
804
|
+
setTimeout(() => {
|
|
805
|
+
if (!voiceState.active) {
|
|
806
|
+
voiceState.manuallyDeactivated = false;
|
|
807
|
+
startVoiceRecognition("restore");
|
|
808
|
+
}
|
|
809
|
+
}, 1000);
|
|
810
|
+
}
|
|
811
|
+
};
|
|
812
|
+
|
|
813
|
+
const getMessagesForPersistence = () =>
|
|
814
|
+
session
|
|
815
|
+
? stripStreamingFromMessages(session.getMessages()).filter(msg => !(msg as any).__skipPersist)
|
|
816
|
+
: [];
|
|
817
|
+
|
|
818
|
+
function persistState(messagesOverride?: AgentWidgetMessage[]) {
|
|
819
|
+
if (!storageAdapter?.save) return;
|
|
820
|
+
|
|
821
|
+
// Allow saving even if session doesn't exist yet (for metadata during init)
|
|
822
|
+
const messages = messagesOverride
|
|
823
|
+
? stripStreamingFromMessages(messagesOverride)
|
|
824
|
+
: session
|
|
825
|
+
? getMessagesForPersistence()
|
|
826
|
+
: [];
|
|
827
|
+
|
|
828
|
+
const payload = {
|
|
829
|
+
messages,
|
|
830
|
+
metadata: persistentMetadata
|
|
831
|
+
};
|
|
832
|
+
try {
|
|
833
|
+
const result = storageAdapter.save(payload);
|
|
834
|
+
if (result instanceof Promise) {
|
|
835
|
+
result.catch((error) => {
|
|
836
|
+
if (typeof console !== "undefined") {
|
|
837
|
+
// eslint-disable-next-line no-console
|
|
838
|
+
console.error("[AgentWidget] Failed to persist state:", error);
|
|
839
|
+
}
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
} catch (error) {
|
|
843
|
+
if (typeof console !== "undefined") {
|
|
844
|
+
// eslint-disable-next-line no-console
|
|
845
|
+
console.error("[AgentWidget] Failed to persist state:", error);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
const scheduleAutoScroll = (force = false) => {
|
|
851
|
+
if (!shouldAutoScroll) return;
|
|
852
|
+
|
|
853
|
+
const now = Date.now();
|
|
854
|
+
|
|
855
|
+
if (isAutoScrollBlocked && now < blockUntilTime) {
|
|
856
|
+
if (!force) return;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
if (isAutoScrollBlocked && now >= blockUntilTime) {
|
|
860
|
+
isAutoScrollBlocked = false;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
if (!force && !isStreaming) return;
|
|
864
|
+
|
|
865
|
+
if (now - lastAutoScrollTime < AUTO_SCROLL_THROTTLE) return;
|
|
866
|
+
lastAutoScrollTime = now;
|
|
867
|
+
|
|
868
|
+
if (scrollRAF) {
|
|
869
|
+
cancelAnimationFrame(scrollRAF);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
scrollRAF = requestAnimationFrame(() => {
|
|
873
|
+
if (isAutoScrollBlocked || !shouldAutoScroll) return;
|
|
874
|
+
isAutoScrolling = true;
|
|
875
|
+
body.scrollTop = body.scrollHeight;
|
|
876
|
+
lastScrollTop = body.scrollTop;
|
|
877
|
+
requestAnimationFrame(() => {
|
|
878
|
+
isAutoScrolling = false;
|
|
879
|
+
});
|
|
880
|
+
scrollRAF = null;
|
|
881
|
+
});
|
|
882
|
+
};
|
|
883
|
+
|
|
884
|
+
// Track ongoing smooth scroll animation
|
|
885
|
+
let smoothScrollRAF: number | null = null;
|
|
886
|
+
|
|
887
|
+
// Get the scrollable container using its unique ID
|
|
888
|
+
const getScrollableContainer = (): HTMLElement => {
|
|
889
|
+
// Use the unique ID for reliable selection
|
|
890
|
+
const scrollable = wrapper.querySelector('#persona-scroll-container') as HTMLElement;
|
|
891
|
+
// Fallback to body if ID not found (shouldn't happen, but safe fallback)
|
|
892
|
+
return scrollable || body;
|
|
893
|
+
};
|
|
894
|
+
|
|
895
|
+
// Custom smooth scroll animation with easing
|
|
896
|
+
const smoothScrollToBottom = (element: HTMLElement, duration = 500) => {
|
|
897
|
+
const start = element.scrollTop;
|
|
898
|
+
const clientHeight = element.clientHeight;
|
|
899
|
+
// Recalculate target dynamically to handle layout changes
|
|
900
|
+
let target = element.scrollHeight;
|
|
901
|
+
let distance = target - start;
|
|
902
|
+
|
|
903
|
+
// Check if already at bottom: scrollTop + clientHeight should be >= scrollHeight
|
|
904
|
+
// Add a small threshold (2px) to account for rounding/subpixel differences
|
|
905
|
+
const isAtBottom = start + clientHeight >= target - 2;
|
|
906
|
+
|
|
907
|
+
// If already at bottom or very close, skip animation to prevent glitch
|
|
908
|
+
if (isAtBottom || Math.abs(distance) < 5) {
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// Cancel any ongoing smooth scroll animation
|
|
913
|
+
if (smoothScrollRAF !== null) {
|
|
914
|
+
cancelAnimationFrame(smoothScrollRAF);
|
|
915
|
+
smoothScrollRAF = null;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const startTime = performance.now();
|
|
919
|
+
|
|
920
|
+
// Easing function: ease-out cubic for smooth deceleration
|
|
921
|
+
const easeOutCubic = (t: number): number => {
|
|
922
|
+
return 1 - Math.pow(1 - t, 3);
|
|
923
|
+
};
|
|
924
|
+
|
|
925
|
+
const animate = (currentTime: number) => {
|
|
926
|
+
// Recalculate target each frame in case scrollHeight changed
|
|
927
|
+
const currentTarget = element.scrollHeight;
|
|
928
|
+
if (currentTarget !== target) {
|
|
929
|
+
target = currentTarget;
|
|
930
|
+
distance = target - start;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
const elapsed = currentTime - startTime;
|
|
934
|
+
const progress = Math.min(elapsed / duration, 1);
|
|
935
|
+
const eased = easeOutCubic(progress);
|
|
936
|
+
|
|
937
|
+
const currentScroll = start + distance * eased;
|
|
938
|
+
element.scrollTop = currentScroll;
|
|
939
|
+
|
|
940
|
+
if (progress < 1) {
|
|
941
|
+
smoothScrollRAF = requestAnimationFrame(animate);
|
|
942
|
+
} else {
|
|
943
|
+
// Ensure we end exactly at the target
|
|
944
|
+
element.scrollTop = element.scrollHeight;
|
|
945
|
+
smoothScrollRAF = null;
|
|
946
|
+
}
|
|
947
|
+
};
|
|
948
|
+
|
|
949
|
+
smoothScrollRAF = requestAnimationFrame(animate);
|
|
950
|
+
};
|
|
951
|
+
|
|
952
|
+
const trackMessages = (messages: AgentWidgetMessage[]) => {
|
|
953
|
+
const nextState = new Map<
|
|
954
|
+
string,
|
|
955
|
+
{ streaming?: boolean; role: AgentWidgetMessage["role"] }
|
|
956
|
+
>();
|
|
957
|
+
|
|
958
|
+
messages.forEach((message) => {
|
|
959
|
+
const previous = messageState.get(message.id);
|
|
960
|
+
nextState.set(message.id, {
|
|
961
|
+
streaming: message.streaming,
|
|
962
|
+
role: message.role
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
if (!previous && message.role === "assistant") {
|
|
966
|
+
eventBus.emit("assistant:message", message);
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
if (
|
|
970
|
+
message.role === "assistant" &&
|
|
971
|
+
previous?.streaming &&
|
|
972
|
+
message.streaming === false
|
|
973
|
+
) {
|
|
974
|
+
eventBus.emit("assistant:complete", message);
|
|
975
|
+
}
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
messageState.clear();
|
|
979
|
+
nextState.forEach((value, key) => {
|
|
980
|
+
messageState.set(key, value);
|
|
981
|
+
});
|
|
982
|
+
};
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
// Message rendering with plugin support (implementation)
|
|
986
|
+
const renderMessagesWithPluginsImpl = (
|
|
987
|
+
container: HTMLElement,
|
|
988
|
+
messages: AgentWidgetMessage[],
|
|
989
|
+
transform: MessageTransform
|
|
990
|
+
) => {
|
|
991
|
+
// Build new content in a temporary container for morphing
|
|
992
|
+
const tempContainer = document.createElement("div");
|
|
993
|
+
|
|
994
|
+
messages.forEach((message) => {
|
|
995
|
+
let bubble: HTMLElement | null = null;
|
|
996
|
+
|
|
997
|
+
// Try plugins first
|
|
998
|
+
const matchingPlugin = plugins.find((p) => {
|
|
999
|
+
if (message.variant === "reasoning" && p.renderReasoning) {
|
|
1000
|
+
return true;
|
|
1001
|
+
}
|
|
1002
|
+
if (message.variant === "tool" && p.renderToolCall) {
|
|
1003
|
+
return true;
|
|
1004
|
+
}
|
|
1005
|
+
if (!message.variant && p.renderMessage) {
|
|
1006
|
+
return true;
|
|
1007
|
+
}
|
|
1008
|
+
return false;
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
// Get message layout config
|
|
1012
|
+
const messageLayoutConfig = config.layout?.messages;
|
|
1013
|
+
|
|
1014
|
+
if (matchingPlugin) {
|
|
1015
|
+
if (message.variant === "reasoning" && message.reasoning && matchingPlugin.renderReasoning) {
|
|
1016
|
+
if (!showReasoning) return;
|
|
1017
|
+
bubble = matchingPlugin.renderReasoning({
|
|
1018
|
+
message,
|
|
1019
|
+
defaultRenderer: () => createReasoningBubble(message),
|
|
1020
|
+
config
|
|
1021
|
+
});
|
|
1022
|
+
} else if (message.variant === "tool" && message.toolCall && matchingPlugin.renderToolCall) {
|
|
1023
|
+
if (!showToolCalls) return;
|
|
1024
|
+
bubble = matchingPlugin.renderToolCall({
|
|
1025
|
+
message,
|
|
1026
|
+
defaultRenderer: () => createToolBubble(message, config),
|
|
1027
|
+
config
|
|
1028
|
+
});
|
|
1029
|
+
} else if (matchingPlugin.renderMessage) {
|
|
1030
|
+
bubble = matchingPlugin.renderMessage({
|
|
1031
|
+
message,
|
|
1032
|
+
defaultRenderer: () => {
|
|
1033
|
+
const b = createStandardBubble(
|
|
1034
|
+
message,
|
|
1035
|
+
transform,
|
|
1036
|
+
messageLayoutConfig,
|
|
1037
|
+
config.messageActions,
|
|
1038
|
+
messageActionCallbacks
|
|
1039
|
+
);
|
|
1040
|
+
if (message.role !== "user") {
|
|
1041
|
+
enhanceWithForms(b, message, config, session);
|
|
1042
|
+
}
|
|
1043
|
+
return b;
|
|
1044
|
+
},
|
|
1045
|
+
config
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// Check for component directive if no plugin handled it
|
|
1051
|
+
if (!bubble && message.role === "assistant" && !message.variant) {
|
|
1052
|
+
const enableComponentStreaming = config.enableComponentStreaming !== false; // Default to true
|
|
1053
|
+
if (enableComponentStreaming && hasComponentDirective(message)) {
|
|
1054
|
+
const directive = extractComponentDirectiveFromMessage(message);
|
|
1055
|
+
if (directive) {
|
|
1056
|
+
const componentBubble = renderComponentDirective(directive, {
|
|
1057
|
+
config,
|
|
1058
|
+
message,
|
|
1059
|
+
transform
|
|
1060
|
+
});
|
|
1061
|
+
if (componentBubble) {
|
|
1062
|
+
// Wrap component in standard bubble styling
|
|
1063
|
+
const componentWrapper = document.createElement("div");
|
|
1064
|
+
componentWrapper.className = [
|
|
1065
|
+
"vanilla-message-bubble",
|
|
1066
|
+
"tvw-max-w-[85%]",
|
|
1067
|
+
"tvw-rounded-2xl",
|
|
1068
|
+
"tvw-bg-cw-surface",
|
|
1069
|
+
"tvw-border",
|
|
1070
|
+
"tvw-border-cw-message-border",
|
|
1071
|
+
"tvw-p-4"
|
|
1072
|
+
].join(" ");
|
|
1073
|
+
// Set id for idiomorph matching
|
|
1074
|
+
componentWrapper.id = `bubble-${message.id}`;
|
|
1075
|
+
componentWrapper.setAttribute("data-message-id", message.id);
|
|
1076
|
+
|
|
1077
|
+
// Add text content above component if present (combined text+component response)
|
|
1078
|
+
if (message.content && message.content.trim()) {
|
|
1079
|
+
const textDiv = document.createElement("div");
|
|
1080
|
+
textDiv.className = "tvw-mb-3 tvw-text-sm tvw-leading-relaxed";
|
|
1081
|
+
textDiv.innerHTML = transform({
|
|
1082
|
+
text: message.content,
|
|
1083
|
+
message,
|
|
1084
|
+
streaming: Boolean(message.streaming),
|
|
1085
|
+
raw: message.rawContent
|
|
1086
|
+
});
|
|
1087
|
+
componentWrapper.appendChild(textDiv);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
componentWrapper.appendChild(componentBubble);
|
|
1091
|
+
bubble = componentWrapper;
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// Fallback to default rendering if plugin returned null or no plugin matched
|
|
1098
|
+
if (!bubble) {
|
|
1099
|
+
if (message.variant === "reasoning" && message.reasoning) {
|
|
1100
|
+
if (!showReasoning) return;
|
|
1101
|
+
bubble = createReasoningBubble(message);
|
|
1102
|
+
} else if (message.variant === "tool" && message.toolCall) {
|
|
1103
|
+
if (!showToolCalls) return;
|
|
1104
|
+
bubble = createToolBubble(message, config);
|
|
1105
|
+
} else {
|
|
1106
|
+
// Check for custom message renderers in layout config
|
|
1107
|
+
const messageLayoutConfig = config.layout?.messages;
|
|
1108
|
+
if (messageLayoutConfig?.renderUserMessage && message.role === "user") {
|
|
1109
|
+
bubble = messageLayoutConfig.renderUserMessage({
|
|
1110
|
+
message,
|
|
1111
|
+
config,
|
|
1112
|
+
streaming: Boolean(message.streaming)
|
|
1113
|
+
});
|
|
1114
|
+
} else if (messageLayoutConfig?.renderAssistantMessage && message.role === "assistant") {
|
|
1115
|
+
bubble = messageLayoutConfig.renderAssistantMessage({
|
|
1116
|
+
message,
|
|
1117
|
+
config,
|
|
1118
|
+
streaming: Boolean(message.streaming)
|
|
1119
|
+
});
|
|
1120
|
+
} else {
|
|
1121
|
+
bubble = createStandardBubble(
|
|
1122
|
+
message,
|
|
1123
|
+
transform,
|
|
1124
|
+
messageLayoutConfig,
|
|
1125
|
+
config.messageActions,
|
|
1126
|
+
messageActionCallbacks
|
|
1127
|
+
);
|
|
1128
|
+
}
|
|
1129
|
+
if (message.role !== "user" && bubble) {
|
|
1130
|
+
enhanceWithForms(bubble, message, config, session);
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
const wrapper = document.createElement("div");
|
|
1136
|
+
wrapper.className = "tvw-flex";
|
|
1137
|
+
// Set id for idiomorph matching
|
|
1138
|
+
wrapper.id = `wrapper-${message.id}`;
|
|
1139
|
+
wrapper.setAttribute("data-wrapper-id", message.id);
|
|
1140
|
+
if (message.role === "user") {
|
|
1141
|
+
wrapper.classList.add("tvw-justify-end");
|
|
1142
|
+
}
|
|
1143
|
+
wrapper.appendChild(bubble);
|
|
1144
|
+
tempContainer.appendChild(wrapper);
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
// Add standalone typing indicator only if streaming but no assistant message is streaming yet
|
|
1148
|
+
// (This shows while waiting for the stream to start)
|
|
1149
|
+
// Check for ANY streaming assistant message, even if empty (to avoid duplicate bubbles)
|
|
1150
|
+
const hasStreamingAssistantMessage = messages.some(
|
|
1151
|
+
(msg) => msg.role === "assistant" && msg.streaming
|
|
1152
|
+
);
|
|
1153
|
+
|
|
1154
|
+
// Also check if there's a recently completed assistant message (streaming just ended)
|
|
1155
|
+
// This prevents flicker when the message completes but isStreaming hasn't updated yet
|
|
1156
|
+
const lastMessage = messages[messages.length - 1];
|
|
1157
|
+
const hasRecentAssistantResponse = lastMessage?.role === "assistant" && !lastMessage.streaming;
|
|
1158
|
+
|
|
1159
|
+
if (isStreaming && messages.some((msg) => msg.role === "user") && !hasStreamingAssistantMessage && !hasRecentAssistantResponse) {
|
|
1160
|
+
const typingIndicator = createTypingIndicator();
|
|
1161
|
+
|
|
1162
|
+
// Create a bubble wrapper for the typing indicator (similar to assistant messages)
|
|
1163
|
+
const typingBubble = document.createElement("div");
|
|
1164
|
+
typingBubble.className = [
|
|
1165
|
+
"tvw-max-w-[85%]",
|
|
1166
|
+
"tvw-rounded-2xl",
|
|
1167
|
+
"tvw-text-sm",
|
|
1168
|
+
"tvw-leading-relaxed",
|
|
1169
|
+
"tvw-shadow-sm",
|
|
1170
|
+
"tvw-bg-cw-surface",
|
|
1171
|
+
"tvw-border",
|
|
1172
|
+
"tvw-border-cw-message-border",
|
|
1173
|
+
"tvw-text-cw-primary",
|
|
1174
|
+
"tvw-px-5",
|
|
1175
|
+
"tvw-py-3"
|
|
1176
|
+
].join(" ");
|
|
1177
|
+
typingBubble.setAttribute("data-typing-indicator", "true");
|
|
1178
|
+
|
|
1179
|
+
typingBubble.appendChild(typingIndicator);
|
|
1180
|
+
|
|
1181
|
+
const typingWrapper = document.createElement("div");
|
|
1182
|
+
typingWrapper.className = "tvw-flex";
|
|
1183
|
+
// Set id for idiomorph matching
|
|
1184
|
+
typingWrapper.id = "wrapper-typing-indicator";
|
|
1185
|
+
typingWrapper.setAttribute("data-wrapper-id", "typing-indicator");
|
|
1186
|
+
typingWrapper.appendChild(typingBubble);
|
|
1187
|
+
tempContainer.appendChild(typingWrapper);
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
// Use idiomorph to morph the container contents
|
|
1191
|
+
morphMessages(container, tempContainer);
|
|
1192
|
+
// Defer scroll to next frame for smoother animation and to prevent jolt
|
|
1193
|
+
// This allows the browser to update layout (e.g., typing indicator removal) before scrolling
|
|
1194
|
+
// Use double RAF to ensure layout has fully settled before starting scroll animation
|
|
1195
|
+
// Get the scrollable container using its unique ID (#persona-scroll-container)
|
|
1196
|
+
requestAnimationFrame(() => {
|
|
1197
|
+
requestAnimationFrame(() => {
|
|
1198
|
+
const scrollableContainer = getScrollableContainer();
|
|
1199
|
+
smoothScrollToBottom(scrollableContainer);
|
|
1200
|
+
});
|
|
1201
|
+
});
|
|
1202
|
+
};
|
|
1203
|
+
|
|
1204
|
+
// Alias for clarity - the implementation handles flicker prevention via typing indicator logic
|
|
1205
|
+
const renderMessagesWithPlugins = renderMessagesWithPluginsImpl;
|
|
1206
|
+
|
|
1207
|
+
const updateOpenState = () => {
|
|
1208
|
+
if (!launcherEnabled) return;
|
|
1209
|
+
if (open) {
|
|
1210
|
+
wrapper.classList.remove("tvw-pointer-events-none", "tvw-opacity-0");
|
|
1211
|
+
panel.classList.remove("tvw-scale-95", "tvw-opacity-0");
|
|
1212
|
+
panel.classList.add("tvw-scale-100", "tvw-opacity-100");
|
|
1213
|
+
// Hide launcher button when widget is open
|
|
1214
|
+
if (launcherButtonInstance) {
|
|
1215
|
+
launcherButtonInstance.element.style.display = "none";
|
|
1216
|
+
} else if (customLauncherElement) {
|
|
1217
|
+
customLauncherElement.style.display = "none";
|
|
1218
|
+
}
|
|
1219
|
+
} else {
|
|
1220
|
+
wrapper.classList.add("tvw-pointer-events-none", "tvw-opacity-0");
|
|
1221
|
+
panel.classList.remove("tvw-scale-100", "tvw-opacity-100");
|
|
1222
|
+
panel.classList.add("tvw-scale-95", "tvw-opacity-0");
|
|
1223
|
+
// Show launcher button when widget is closed
|
|
1224
|
+
if (launcherButtonInstance) {
|
|
1225
|
+
launcherButtonInstance.element.style.display = "";
|
|
1226
|
+
} else if (customLauncherElement) {
|
|
1227
|
+
customLauncherElement.style.display = "";
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
};
|
|
1231
|
+
|
|
1232
|
+
const setOpenState = (nextOpen: boolean, source: "user" | "auto" | "api" | "system" = "user") => {
|
|
1233
|
+
if (!launcherEnabled) return;
|
|
1234
|
+
if (open === nextOpen) return;
|
|
1235
|
+
|
|
1236
|
+
const prevOpen = open;
|
|
1237
|
+
open = nextOpen;
|
|
1238
|
+
updateOpenState();
|
|
1239
|
+
|
|
1240
|
+
if (open) {
|
|
1241
|
+
recalcPanelHeight();
|
|
1242
|
+
scheduleAutoScroll(true);
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
// Emit widget state events
|
|
1246
|
+
const stateEvent: AgentWidgetStateEvent = {
|
|
1247
|
+
open,
|
|
1248
|
+
source,
|
|
1249
|
+
timestamp: Date.now()
|
|
1250
|
+
};
|
|
1251
|
+
|
|
1252
|
+
if (open && !prevOpen) {
|
|
1253
|
+
eventBus.emit("widget:opened", stateEvent);
|
|
1254
|
+
} else if (!open && prevOpen) {
|
|
1255
|
+
eventBus.emit("widget:closed", stateEvent);
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
// Emit general state snapshot
|
|
1259
|
+
eventBus.emit("widget:state", {
|
|
1260
|
+
open,
|
|
1261
|
+
launcherEnabled,
|
|
1262
|
+
voiceActive: voiceState.active,
|
|
1263
|
+
streaming: session.isStreaming()
|
|
1264
|
+
});
|
|
1265
|
+
};
|
|
1266
|
+
|
|
1267
|
+
const setComposerDisabled = (disabled: boolean) => {
|
|
1268
|
+
// Keep textarea always enabled so users can type while streaming
|
|
1269
|
+
// Only disable submit controls to prevent sending during streaming
|
|
1270
|
+
sendButton.disabled = disabled;
|
|
1271
|
+
if (micButton) {
|
|
1272
|
+
micButton.disabled = disabled;
|
|
1273
|
+
}
|
|
1274
|
+
suggestionsManager.buttons.forEach((btn) => {
|
|
1275
|
+
btn.disabled = disabled;
|
|
1276
|
+
});
|
|
1277
|
+
};
|
|
1278
|
+
|
|
1279
|
+
const updateCopy = () => {
|
|
1280
|
+
introTitle.textContent = config.copy?.welcomeTitle ?? "Hello 👋";
|
|
1281
|
+
introSubtitle.textContent =
|
|
1282
|
+
config.copy?.welcomeSubtitle ??
|
|
1283
|
+
"Ask anything about your account or products.";
|
|
1284
|
+
textarea.placeholder = config.copy?.inputPlaceholder ?? "How can I help...";
|
|
1285
|
+
|
|
1286
|
+
// Only update send button text if NOT using icon mode
|
|
1287
|
+
const useIcon = config.sendButton?.useIcon ?? false;
|
|
1288
|
+
if (!useIcon) {
|
|
1289
|
+
sendButton.textContent = config.copy?.sendButtonLabel ?? "Send";
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
// Update textarea font family and weight
|
|
1293
|
+
const fontFamily = config.theme?.inputFontFamily ?? "sans-serif";
|
|
1294
|
+
const fontWeight = config.theme?.inputFontWeight ?? "400";
|
|
1295
|
+
|
|
1296
|
+
const getFontFamilyValue = (family: "sans-serif" | "serif" | "mono"): string => {
|
|
1297
|
+
switch (family) {
|
|
1298
|
+
case "serif":
|
|
1299
|
+
return 'Georgia, "Times New Roman", Times, serif';
|
|
1300
|
+
case "mono":
|
|
1301
|
+
return '"Courier New", Courier, "Lucida Console", Monaco, monospace';
|
|
1302
|
+
case "sans-serif":
|
|
1303
|
+
default:
|
|
1304
|
+
return '-apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif';
|
|
1305
|
+
}
|
|
1306
|
+
};
|
|
1307
|
+
|
|
1308
|
+
textarea.style.fontFamily = getFontFamilyValue(fontFamily);
|
|
1309
|
+
textarea.style.fontWeight = fontWeight;
|
|
1310
|
+
};
|
|
1311
|
+
|
|
1312
|
+
// Add session ID persistence callbacks for client token mode
|
|
1313
|
+
// These allow the widget to resume conversations by passing session_id to /client/init
|
|
1314
|
+
if (config.clientToken) {
|
|
1315
|
+
config = {
|
|
1316
|
+
...config,
|
|
1317
|
+
getStoredSessionId: () => {
|
|
1318
|
+
const storedId = persistentMetadata['session_id'];
|
|
1319
|
+
return typeof storedId === 'string' ? storedId : null;
|
|
1320
|
+
},
|
|
1321
|
+
setStoredSessionId: (sessionId: string) => {
|
|
1322
|
+
updateSessionMetadata((prev) => ({
|
|
1323
|
+
...prev,
|
|
1324
|
+
session_id: sessionId,
|
|
1325
|
+
}));
|
|
1326
|
+
},
|
|
1327
|
+
};
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
session = new AgentWidgetSession(config, {
|
|
1331
|
+
onMessagesChanged(messages) {
|
|
1332
|
+
renderMessagesWithPlugins(messagesWrapper, messages, postprocess);
|
|
1333
|
+
// Re-render suggestions to hide them after first user message
|
|
1334
|
+
// Pass messages directly to avoid calling session.getMessages() during construction
|
|
1335
|
+
if (session) {
|
|
1336
|
+
const hasUserMessage = messages.some((msg) => msg.role === "user");
|
|
1337
|
+
if (hasUserMessage) {
|
|
1338
|
+
// Hide suggestions if user message exists
|
|
1339
|
+
suggestionsManager.render([], session, textarea, messages);
|
|
1340
|
+
} else {
|
|
1341
|
+
// Show suggestions if no user message yet
|
|
1342
|
+
suggestionsManager.render(config.suggestionChips, session, textarea, messages, config.suggestionChipsConfig);
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
scheduleAutoScroll(!isStreaming);
|
|
1346
|
+
trackMessages(messages);
|
|
1347
|
+
|
|
1348
|
+
const lastUserMessage = [...messages]
|
|
1349
|
+
.reverse()
|
|
1350
|
+
.find((msg) => msg.role === "user");
|
|
1351
|
+
voiceState.lastUserMessageWasVoice = Boolean(lastUserMessage?.viaVoice);
|
|
1352
|
+
persistState(messages);
|
|
1353
|
+
},
|
|
1354
|
+
onStatusChanged(status) {
|
|
1355
|
+
const currentStatusConfig = config.statusIndicator ?? {};
|
|
1356
|
+
const getCurrentStatusText = (status: AgentWidgetSessionStatus): string => {
|
|
1357
|
+
if (status === "idle") return currentStatusConfig.idleText ?? statusCopy.idle;
|
|
1358
|
+
if (status === "connecting") return currentStatusConfig.connectingText ?? statusCopy.connecting;
|
|
1359
|
+
if (status === "connected") return currentStatusConfig.connectedText ?? statusCopy.connected;
|
|
1360
|
+
if (status === "error") return currentStatusConfig.errorText ?? statusCopy.error;
|
|
1361
|
+
return statusCopy[status];
|
|
1362
|
+
};
|
|
1363
|
+
statusText.textContent = getCurrentStatusText(status);
|
|
1364
|
+
},
|
|
1365
|
+
onStreamingChanged(streaming) {
|
|
1366
|
+
isStreaming = streaming;
|
|
1367
|
+
setComposerDisabled(streaming);
|
|
1368
|
+
// Re-render messages to show/hide typing indicator
|
|
1369
|
+
if (session) {
|
|
1370
|
+
renderMessagesWithPlugins(messagesWrapper, session.getMessages(), postprocess);
|
|
1371
|
+
}
|
|
1372
|
+
if (!streaming) {
|
|
1373
|
+
scheduleAutoScroll(true);
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
});
|
|
1377
|
+
|
|
1378
|
+
if (pendingStoredState) {
|
|
1379
|
+
pendingStoredState
|
|
1380
|
+
.then((state) => {
|
|
1381
|
+
if (!state) return;
|
|
1382
|
+
if (state.metadata) {
|
|
1383
|
+
persistentMetadata = ensureRecord(state.metadata);
|
|
1384
|
+
actionManager.syncFromMetadata();
|
|
1385
|
+
}
|
|
1386
|
+
if (state.messages?.length) {
|
|
1387
|
+
session.hydrateMessages(state.messages);
|
|
1388
|
+
}
|
|
1389
|
+
})
|
|
1390
|
+
.catch((error) => {
|
|
1391
|
+
if (typeof console !== "undefined") {
|
|
1392
|
+
// eslint-disable-next-line no-console
|
|
1393
|
+
console.error("[AgentWidget] Failed to hydrate stored state:", error);
|
|
1394
|
+
}
|
|
1395
|
+
});
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
const handleSubmit = (event: Event) => {
|
|
1399
|
+
event.preventDefault();
|
|
1400
|
+
const value = textarea.value.trim();
|
|
1401
|
+
const hasAttachments = attachmentManager?.hasAttachments() ?? false;
|
|
1402
|
+
|
|
1403
|
+
// Must have text or attachments to send
|
|
1404
|
+
if (!value && !hasAttachments) return;
|
|
1405
|
+
|
|
1406
|
+
// Build content parts if there are attachments
|
|
1407
|
+
let contentParts: ContentPart[] | undefined;
|
|
1408
|
+
if (hasAttachments) {
|
|
1409
|
+
contentParts = [];
|
|
1410
|
+
// Add image parts first
|
|
1411
|
+
contentParts.push(...attachmentManager!.getContentParts());
|
|
1412
|
+
// Add text part if there's text
|
|
1413
|
+
if (value) {
|
|
1414
|
+
contentParts.push(createTextPart(value));
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
textarea.value = "";
|
|
1419
|
+
textarea.style.height = "auto"; // Reset height after clearing
|
|
1420
|
+
|
|
1421
|
+
// Send message with optional content parts
|
|
1422
|
+
session.sendMessage(value, { contentParts });
|
|
1423
|
+
|
|
1424
|
+
// Clear attachments after sending
|
|
1425
|
+
if (hasAttachments) {
|
|
1426
|
+
attachmentManager!.clearAttachments();
|
|
1427
|
+
}
|
|
1428
|
+
};
|
|
1429
|
+
|
|
1430
|
+
const handleInputEnter = (event: KeyboardEvent) => {
|
|
1431
|
+
if (event.key === "Enter" && !event.shiftKey) {
|
|
1432
|
+
event.preventDefault();
|
|
1433
|
+
sendButton.click();
|
|
1434
|
+
}
|
|
1435
|
+
};
|
|
1436
|
+
|
|
1437
|
+
// Voice recognition state and logic
|
|
1438
|
+
let speechRecognition: any = null;
|
|
1439
|
+
let isRecording = false;
|
|
1440
|
+
let pauseTimer: number | null = null;
|
|
1441
|
+
let originalMicStyles: {
|
|
1442
|
+
backgroundColor: string;
|
|
1443
|
+
color: string;
|
|
1444
|
+
borderColor: string;
|
|
1445
|
+
} | null = null;
|
|
1446
|
+
|
|
1447
|
+
const getSpeechRecognitionClass = (): any => {
|
|
1448
|
+
if (typeof window === 'undefined') return null;
|
|
1449
|
+
return (window as any).webkitSpeechRecognition || (window as any).SpeechRecognition || null;
|
|
1450
|
+
};
|
|
1451
|
+
|
|
1452
|
+
const startVoiceRecognition = (
|
|
1453
|
+
source: AgentWidgetVoiceStateEvent["source"] = "user"
|
|
1454
|
+
) => {
|
|
1455
|
+
if (isRecording || session.isStreaming()) return;
|
|
1456
|
+
|
|
1457
|
+
const SpeechRecognitionClass = getSpeechRecognitionClass();
|
|
1458
|
+
if (!SpeechRecognitionClass) return;
|
|
1459
|
+
|
|
1460
|
+
speechRecognition = new SpeechRecognitionClass();
|
|
1461
|
+
const voiceConfig = config.voiceRecognition ?? {};
|
|
1462
|
+
const pauseDuration = voiceConfig.pauseDuration ?? 2000;
|
|
1463
|
+
|
|
1464
|
+
speechRecognition.continuous = true;
|
|
1465
|
+
speechRecognition.interimResults = true;
|
|
1466
|
+
speechRecognition.lang = 'en-US';
|
|
1467
|
+
|
|
1468
|
+
// Store the initial text that was in the textarea
|
|
1469
|
+
const initialText = textarea.value;
|
|
1470
|
+
|
|
1471
|
+
speechRecognition.onresult = (event: any) => {
|
|
1472
|
+
// Build the complete transcript from all results
|
|
1473
|
+
let fullTranscript = "";
|
|
1474
|
+
let interimTranscript = "";
|
|
1475
|
+
|
|
1476
|
+
// Process all results from the beginning
|
|
1477
|
+
for (let i = 0; i < event.results.length; i++) {
|
|
1478
|
+
const result = event.results[i];
|
|
1479
|
+
const transcript = result[0].transcript;
|
|
1480
|
+
|
|
1481
|
+
if (result.isFinal) {
|
|
1482
|
+
fullTranscript += transcript + " ";
|
|
1483
|
+
} else {
|
|
1484
|
+
// Only take the last interim result
|
|
1485
|
+
interimTranscript = transcript;
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
// Update textarea with initial text + full transcript + interim
|
|
1490
|
+
const newValue = initialText + fullTranscript + interimTranscript;
|
|
1491
|
+
textarea.value = newValue;
|
|
1492
|
+
|
|
1493
|
+
// Reset pause timer on each result
|
|
1494
|
+
if (pauseTimer) {
|
|
1495
|
+
clearTimeout(pauseTimer);
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
// Set timer to auto-submit after pause when we have any speech
|
|
1499
|
+
if (fullTranscript || interimTranscript) {
|
|
1500
|
+
pauseTimer = window.setTimeout(() => {
|
|
1501
|
+
const finalValue = textarea.value.trim();
|
|
1502
|
+
if (finalValue && speechRecognition && isRecording) {
|
|
1503
|
+
stopVoiceRecognition();
|
|
1504
|
+
textarea.value = "";
|
|
1505
|
+
textarea.style.height = "auto"; // Reset height after clearing
|
|
1506
|
+
session.sendMessage(finalValue, { viaVoice: true });
|
|
1507
|
+
}
|
|
1508
|
+
}, pauseDuration);
|
|
1509
|
+
}
|
|
1510
|
+
};
|
|
1511
|
+
|
|
1512
|
+
speechRecognition.onerror = (event: any) => {
|
|
1513
|
+
// Don't stop on "no-speech" error, just ignore it
|
|
1514
|
+
if (event.error !== 'no-speech') {
|
|
1515
|
+
stopVoiceRecognition();
|
|
1516
|
+
}
|
|
1517
|
+
};
|
|
1518
|
+
|
|
1519
|
+
speechRecognition.onend = () => {
|
|
1520
|
+
// If recognition ended naturally (not manually stopped), submit if there's text
|
|
1521
|
+
if (isRecording) {
|
|
1522
|
+
const finalValue = textarea.value.trim();
|
|
1523
|
+
if (finalValue && finalValue !== initialText.trim()) {
|
|
1524
|
+
textarea.value = "";
|
|
1525
|
+
textarea.style.height = "auto"; // Reset height after clearing
|
|
1526
|
+
session.sendMessage(finalValue, { viaVoice: true });
|
|
1527
|
+
}
|
|
1528
|
+
stopVoiceRecognition();
|
|
1529
|
+
}
|
|
1530
|
+
};
|
|
1531
|
+
|
|
1532
|
+
try {
|
|
1533
|
+
speechRecognition.start();
|
|
1534
|
+
isRecording = true;
|
|
1535
|
+
voiceState.active = true;
|
|
1536
|
+
if (source !== "system") {
|
|
1537
|
+
voiceState.manuallyDeactivated = false;
|
|
1538
|
+
}
|
|
1539
|
+
emitVoiceState(source);
|
|
1540
|
+
persistVoiceMetadata();
|
|
1541
|
+
if (micButton) {
|
|
1542
|
+
// Store original styles
|
|
1543
|
+
originalMicStyles = {
|
|
1544
|
+
backgroundColor: micButton.style.backgroundColor,
|
|
1545
|
+
color: micButton.style.color,
|
|
1546
|
+
borderColor: micButton.style.borderColor
|
|
1547
|
+
};
|
|
1548
|
+
|
|
1549
|
+
// Apply recording state styles from config
|
|
1550
|
+
const voiceConfig = config.voiceRecognition ?? {};
|
|
1551
|
+
const recordingBackgroundColor = voiceConfig.recordingBackgroundColor ?? "#ef4444";
|
|
1552
|
+
const recordingIconColor = voiceConfig.recordingIconColor;
|
|
1553
|
+
const recordingBorderColor = voiceConfig.recordingBorderColor;
|
|
1554
|
+
|
|
1555
|
+
micButton.classList.add("tvw-voice-recording");
|
|
1556
|
+
micButton.style.backgroundColor = recordingBackgroundColor;
|
|
1557
|
+
|
|
1558
|
+
if (recordingIconColor) {
|
|
1559
|
+
micButton.style.color = recordingIconColor;
|
|
1560
|
+
// Update SVG stroke color if present
|
|
1561
|
+
const svg = micButton.querySelector("svg");
|
|
1562
|
+
if (svg) {
|
|
1563
|
+
svg.setAttribute("stroke", recordingIconColor);
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
if (recordingBorderColor) {
|
|
1568
|
+
micButton.style.borderColor = recordingBorderColor;
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
micButton.setAttribute("aria-label", "Stop voice recognition");
|
|
1572
|
+
}
|
|
1573
|
+
} catch (error) {
|
|
1574
|
+
stopVoiceRecognition("system");
|
|
1575
|
+
}
|
|
1576
|
+
};
|
|
1577
|
+
|
|
1578
|
+
const stopVoiceRecognition = (
|
|
1579
|
+
source: AgentWidgetVoiceStateEvent["source"] = "user"
|
|
1580
|
+
) => {
|
|
1581
|
+
if (!isRecording) return;
|
|
1582
|
+
|
|
1583
|
+
isRecording = false;
|
|
1584
|
+
if (pauseTimer) {
|
|
1585
|
+
clearTimeout(pauseTimer);
|
|
1586
|
+
pauseTimer = null;
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
if (speechRecognition) {
|
|
1590
|
+
try {
|
|
1591
|
+
speechRecognition.stop();
|
|
1592
|
+
} catch (error) {
|
|
1593
|
+
// Ignore errors when stopping
|
|
1594
|
+
}
|
|
1595
|
+
speechRecognition = null;
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
voiceState.active = false;
|
|
1599
|
+
emitVoiceState(source);
|
|
1600
|
+
persistVoiceMetadata();
|
|
1601
|
+
|
|
1602
|
+
if (micButton) {
|
|
1603
|
+
micButton.classList.remove("tvw-voice-recording");
|
|
1604
|
+
|
|
1605
|
+
// Restore original styles
|
|
1606
|
+
if (originalMicStyles) {
|
|
1607
|
+
micButton.style.backgroundColor = originalMicStyles.backgroundColor;
|
|
1608
|
+
micButton.style.color = originalMicStyles.color;
|
|
1609
|
+
micButton.style.borderColor = originalMicStyles.borderColor;
|
|
1610
|
+
|
|
1611
|
+
// Restore SVG stroke color if present
|
|
1612
|
+
const svg = micButton.querySelector("svg");
|
|
1613
|
+
if (svg) {
|
|
1614
|
+
svg.setAttribute("stroke", originalMicStyles.color || "currentColor");
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
originalMicStyles = null;
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
micButton.setAttribute("aria-label", "Start voice recognition");
|
|
1621
|
+
}
|
|
1622
|
+
};
|
|
1623
|
+
|
|
1624
|
+
// Function to create mic button dynamically
|
|
1625
|
+
const createMicButton = (voiceConfig: AgentWidgetConfig['voiceRecognition'], sendButtonConfig: AgentWidgetConfig['sendButton']): { micButton: HTMLButtonElement; micButtonWrapper: HTMLElement } | null => {
|
|
1626
|
+
const hasSpeechRecognition =
|
|
1627
|
+
typeof window !== 'undefined' &&
|
|
1628
|
+
(typeof (window as any).webkitSpeechRecognition !== 'undefined' ||
|
|
1629
|
+
typeof (window as any).SpeechRecognition !== 'undefined');
|
|
1630
|
+
|
|
1631
|
+
if (!hasSpeechRecognition) return null;
|
|
1632
|
+
|
|
1633
|
+
const micButtonWrapper = createElement("div", "tvw-send-button-wrapper");
|
|
1634
|
+
const micButton = createElement(
|
|
1635
|
+
"button",
|
|
1636
|
+
"tvw-rounded-button tvw-flex tvw-items-center tvw-justify-center disabled:tvw-opacity-50 tvw-cursor-pointer"
|
|
1637
|
+
) as HTMLButtonElement;
|
|
1638
|
+
|
|
1639
|
+
micButton.type = "button";
|
|
1640
|
+
micButton.setAttribute("aria-label", "Start voice recognition");
|
|
1641
|
+
|
|
1642
|
+
const micIconName = voiceConfig?.iconName ?? "mic";
|
|
1643
|
+
const buttonSize = sendButtonConfig?.size ?? "40px";
|
|
1644
|
+
const micIconSize = voiceConfig?.iconSize ?? buttonSize;
|
|
1645
|
+
const micIconSizeNum = parseFloat(micIconSize) || 24;
|
|
1646
|
+
|
|
1647
|
+
// Use dedicated colors from voice recognition config, fallback to send button colors
|
|
1648
|
+
const backgroundColor = voiceConfig?.backgroundColor ?? sendButtonConfig?.backgroundColor;
|
|
1649
|
+
const iconColor = voiceConfig?.iconColor ?? sendButtonConfig?.textColor;
|
|
1650
|
+
|
|
1651
|
+
micButton.style.width = micIconSize;
|
|
1652
|
+
micButton.style.height = micIconSize;
|
|
1653
|
+
micButton.style.minWidth = micIconSize;
|
|
1654
|
+
micButton.style.minHeight = micIconSize;
|
|
1655
|
+
micButton.style.fontSize = "18px";
|
|
1656
|
+
micButton.style.lineHeight = "1";
|
|
1657
|
+
|
|
1658
|
+
// Use Lucide mic icon with configured color (stroke width 1.5 for minimalist outline style)
|
|
1659
|
+
const iconColorValue = iconColor || "currentColor";
|
|
1660
|
+
const micIconSvg = renderLucideIcon(micIconName, micIconSizeNum, iconColorValue, 1.5);
|
|
1661
|
+
if (micIconSvg) {
|
|
1662
|
+
micButton.appendChild(micIconSvg);
|
|
1663
|
+
micButton.style.color = iconColorValue;
|
|
1664
|
+
} else {
|
|
1665
|
+
// Fallback to text if icon fails
|
|
1666
|
+
micButton.textContent = "🎤";
|
|
1667
|
+
micButton.style.color = iconColorValue;
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
// Apply background color
|
|
1671
|
+
if (backgroundColor) {
|
|
1672
|
+
micButton.style.backgroundColor = backgroundColor;
|
|
1673
|
+
} else {
|
|
1674
|
+
micButton.classList.add("tvw-bg-cw-primary");
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
// Apply icon/text color
|
|
1678
|
+
if (iconColor) {
|
|
1679
|
+
micButton.style.color = iconColor;
|
|
1680
|
+
} else if (!iconColor && !sendButtonConfig?.textColor) {
|
|
1681
|
+
micButton.classList.add("tvw-text-white");
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
// Apply border styling
|
|
1685
|
+
if (voiceConfig?.borderWidth) {
|
|
1686
|
+
micButton.style.borderWidth = voiceConfig.borderWidth;
|
|
1687
|
+
micButton.style.borderStyle = "solid";
|
|
1688
|
+
}
|
|
1689
|
+
if (voiceConfig?.borderColor) {
|
|
1690
|
+
micButton.style.borderColor = voiceConfig.borderColor;
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
// Apply padding styling
|
|
1694
|
+
if (voiceConfig?.paddingX) {
|
|
1695
|
+
micButton.style.paddingLeft = voiceConfig.paddingX;
|
|
1696
|
+
micButton.style.paddingRight = voiceConfig.paddingX;
|
|
1697
|
+
}
|
|
1698
|
+
if (voiceConfig?.paddingY) {
|
|
1699
|
+
micButton.style.paddingTop = voiceConfig.paddingY;
|
|
1700
|
+
micButton.style.paddingBottom = voiceConfig.paddingY;
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
micButtonWrapper.appendChild(micButton);
|
|
1704
|
+
|
|
1705
|
+
// Add tooltip if enabled
|
|
1706
|
+
const tooltipText = voiceConfig?.tooltipText ?? "Start voice recognition";
|
|
1707
|
+
const showTooltip = voiceConfig?.showTooltip ?? false;
|
|
1708
|
+
if (showTooltip && tooltipText) {
|
|
1709
|
+
const tooltip = createElement("div", "tvw-send-button-tooltip");
|
|
1710
|
+
tooltip.textContent = tooltipText;
|
|
1711
|
+
micButtonWrapper.appendChild(tooltip);
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
return { micButton, micButtonWrapper };
|
|
1715
|
+
};
|
|
1716
|
+
|
|
1717
|
+
// Wire up mic button click handler
|
|
1718
|
+
const handleMicButtonClick = () => {
|
|
1719
|
+
if (isRecording) {
|
|
1720
|
+
// Stop recording and submit
|
|
1721
|
+
const finalValue = textarea.value.trim();
|
|
1722
|
+
voiceState.manuallyDeactivated = true;
|
|
1723
|
+
persistVoiceMetadata();
|
|
1724
|
+
stopVoiceRecognition("user");
|
|
1725
|
+
if (finalValue) {
|
|
1726
|
+
textarea.value = "";
|
|
1727
|
+
textarea.style.height = "auto"; // Reset height after clearing
|
|
1728
|
+
session.sendMessage(finalValue);
|
|
1729
|
+
}
|
|
1730
|
+
} else {
|
|
1731
|
+
// Start recording
|
|
1732
|
+
voiceState.manuallyDeactivated = false;
|
|
1733
|
+
persistVoiceMetadata();
|
|
1734
|
+
startVoiceRecognition("user");
|
|
1735
|
+
}
|
|
1736
|
+
};
|
|
1737
|
+
|
|
1738
|
+
if (micButton) {
|
|
1739
|
+
micButton.addEventListener("click", handleMicButtonClick);
|
|
1740
|
+
|
|
1741
|
+
destroyCallbacks.push(() => {
|
|
1742
|
+
stopVoiceRecognition("system");
|
|
1743
|
+
if (micButton) {
|
|
1744
|
+
micButton.removeEventListener("click", handleMicButtonClick);
|
|
1745
|
+
}
|
|
1746
|
+
});
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
const autoResumeUnsub = eventBus.on("assistant:complete", () => {
|
|
1750
|
+
if (!voiceAutoResumeMode) return;
|
|
1751
|
+
if (voiceState.active || voiceState.manuallyDeactivated) return;
|
|
1752
|
+
if (voiceAutoResumeMode === "assistant" && !voiceState.lastUserMessageWasVoice) {
|
|
1753
|
+
return;
|
|
1754
|
+
}
|
|
1755
|
+
setTimeout(() => {
|
|
1756
|
+
if (!voiceState.active && !voiceState.manuallyDeactivated) {
|
|
1757
|
+
startVoiceRecognition("auto");
|
|
1758
|
+
}
|
|
1759
|
+
}, 600);
|
|
1760
|
+
});
|
|
1761
|
+
destroyCallbacks.push(autoResumeUnsub);
|
|
1762
|
+
|
|
1763
|
+
const toggleOpen = () => {
|
|
1764
|
+
setOpenState(!open, "user");
|
|
1765
|
+
};
|
|
1766
|
+
|
|
1767
|
+
// Plugin hook: renderLauncher - allow plugins to provide custom launcher
|
|
1768
|
+
let launcherButtonInstance: ReturnType<typeof createLauncherButton> | null = null;
|
|
1769
|
+
let customLauncherElement: HTMLElement | null = null;
|
|
1770
|
+
|
|
1771
|
+
if (launcherEnabled) {
|
|
1772
|
+
const launcherPlugin = plugins.find(p => p.renderLauncher);
|
|
1773
|
+
if (launcherPlugin?.renderLauncher) {
|
|
1774
|
+
const customLauncher = launcherPlugin.renderLauncher({
|
|
1775
|
+
config,
|
|
1776
|
+
defaultRenderer: () => {
|
|
1777
|
+
const btn = createLauncherButton(config, toggleOpen);
|
|
1778
|
+
return btn.element;
|
|
1779
|
+
},
|
|
1780
|
+
onToggle: toggleOpen
|
|
1781
|
+
});
|
|
1782
|
+
if (customLauncher) {
|
|
1783
|
+
customLauncherElement = customLauncher;
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
// Use custom launcher if provided, otherwise use default
|
|
1788
|
+
if (!customLauncherElement) {
|
|
1789
|
+
launcherButtonInstance = createLauncherButton(config, toggleOpen);
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
if (launcherButtonInstance) {
|
|
1794
|
+
mount.appendChild(launcherButtonInstance.element);
|
|
1795
|
+
} else if (customLauncherElement) {
|
|
1796
|
+
mount.appendChild(customLauncherElement);
|
|
1797
|
+
}
|
|
1798
|
+
updateOpenState();
|
|
1799
|
+
suggestionsManager.render(config.suggestionChips, session, textarea, undefined, config.suggestionChipsConfig);
|
|
1800
|
+
updateCopy();
|
|
1801
|
+
setComposerDisabled(session.isStreaming());
|
|
1802
|
+
scheduleAutoScroll(true);
|
|
1803
|
+
maybeRestoreVoiceFromMetadata();
|
|
1804
|
+
|
|
1805
|
+
const recalcPanelHeight = () => {
|
|
1806
|
+
const sidebarMode = config.launcher?.sidebarMode ?? false;
|
|
1807
|
+
const fullHeight = sidebarMode || (config.launcher?.fullHeight ?? false);
|
|
1808
|
+
|
|
1809
|
+
if (!launcherEnabled) {
|
|
1810
|
+
panel.style.height = "";
|
|
1811
|
+
panel.style.width = "";
|
|
1812
|
+
return;
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
// In sidebar/fullHeight mode, don't override the width - it's handled by applyFullHeightStyles
|
|
1816
|
+
if (!sidebarMode) {
|
|
1817
|
+
const launcherWidth = config?.launcher?.width ?? config?.launcherWidth;
|
|
1818
|
+
const width = launcherWidth ?? "min(400px, calc(100vw - 24px))";
|
|
1819
|
+
panel.style.width = width;
|
|
1820
|
+
panel.style.maxWidth = width;
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
// In fullHeight mode, don't set a fixed height
|
|
1824
|
+
if (!fullHeight) {
|
|
1825
|
+
const viewportHeight = window.innerHeight;
|
|
1826
|
+
const verticalMargin = 64; // leave space for launcher's offset
|
|
1827
|
+
const heightOffset = config.launcher?.heightOffset ?? 0;
|
|
1828
|
+
const available = Math.max(200, viewportHeight - verticalMargin);
|
|
1829
|
+
const clamped = Math.min(640, available);
|
|
1830
|
+
const finalHeight = Math.max(200, clamped - heightOffset);
|
|
1831
|
+
panel.style.height = `${finalHeight}px`;
|
|
1832
|
+
}
|
|
1833
|
+
};
|
|
1834
|
+
|
|
1835
|
+
recalcPanelHeight();
|
|
1836
|
+
window.addEventListener("resize", recalcPanelHeight);
|
|
1837
|
+
destroyCallbacks.push(() => window.removeEventListener("resize", recalcPanelHeight));
|
|
1838
|
+
|
|
1839
|
+
lastScrollTop = body.scrollTop;
|
|
1840
|
+
|
|
1841
|
+
const handleScroll = () => {
|
|
1842
|
+
const scrollTop = body.scrollTop;
|
|
1843
|
+
const scrollHeight = body.scrollHeight;
|
|
1844
|
+
const clientHeight = body.clientHeight;
|
|
1845
|
+
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
|
|
1846
|
+
const delta = Math.abs(scrollTop - lastScrollTop);
|
|
1847
|
+
lastScrollTop = scrollTop;
|
|
1848
|
+
|
|
1849
|
+
if (isAutoScrolling) return;
|
|
1850
|
+
if (delta <= USER_SCROLL_THRESHOLD) return;
|
|
1851
|
+
|
|
1852
|
+
if (!shouldAutoScroll && distanceFromBottom < BOTTOM_THRESHOLD) {
|
|
1853
|
+
isAutoScrollBlocked = false;
|
|
1854
|
+
shouldAutoScroll = true;
|
|
1855
|
+
return;
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
if (shouldAutoScroll && distanceFromBottom > BOTTOM_THRESHOLD) {
|
|
1859
|
+
isAutoScrollBlocked = true;
|
|
1860
|
+
blockUntilTime = Date.now() + AUTO_SCROLL_BLOCK_TIME;
|
|
1861
|
+
shouldAutoScroll = false;
|
|
1862
|
+
}
|
|
1863
|
+
};
|
|
1864
|
+
|
|
1865
|
+
body.addEventListener("scroll", handleScroll, { passive: true });
|
|
1866
|
+
destroyCallbacks.push(() => body.removeEventListener("scroll", handleScroll));
|
|
1867
|
+
destroyCallbacks.push(() => {
|
|
1868
|
+
if (scrollRAF) cancelAnimationFrame(scrollRAF);
|
|
1869
|
+
});
|
|
1870
|
+
|
|
1871
|
+
const refreshCloseButton = () => {
|
|
1872
|
+
if (!closeButton) return;
|
|
1873
|
+
if (closeHandler) {
|
|
1874
|
+
closeButton.removeEventListener("click", closeHandler);
|
|
1875
|
+
closeHandler = null;
|
|
1876
|
+
}
|
|
1877
|
+
if (launcherEnabled) {
|
|
1878
|
+
closeButton.style.display = "";
|
|
1879
|
+
closeHandler = () => {
|
|
1880
|
+
open = false;
|
|
1881
|
+
updateOpenState();
|
|
1882
|
+
};
|
|
1883
|
+
closeButton.addEventListener("click", closeHandler);
|
|
1884
|
+
} else {
|
|
1885
|
+
closeButton.style.display = "none";
|
|
1886
|
+
}
|
|
1887
|
+
};
|
|
1888
|
+
|
|
1889
|
+
refreshCloseButton();
|
|
1890
|
+
|
|
1891
|
+
// Setup clear chat button click handler
|
|
1892
|
+
const setupClearChatButton = () => {
|
|
1893
|
+
const { clearChatButton } = panelElements;
|
|
1894
|
+
if (!clearChatButton) return;
|
|
1895
|
+
|
|
1896
|
+
clearChatButton.addEventListener("click", () => {
|
|
1897
|
+
// Clear messages in session (this will trigger onMessagesChanged which re-renders)
|
|
1898
|
+
session.clearMessages();
|
|
1899
|
+
|
|
1900
|
+
// Always clear the default localStorage key
|
|
1901
|
+
try {
|
|
1902
|
+
localStorage.removeItem(DEFAULT_CHAT_HISTORY_STORAGE_KEY);
|
|
1903
|
+
if (config.debug) {
|
|
1904
|
+
console.log(`[AgentWidget] Cleared default localStorage key: ${DEFAULT_CHAT_HISTORY_STORAGE_KEY}`);
|
|
1905
|
+
}
|
|
1906
|
+
} catch (error) {
|
|
1907
|
+
console.error("[AgentWidget] Failed to clear default localStorage:", error);
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
// Also clear custom localStorage key if configured
|
|
1911
|
+
if (config.clearChatHistoryStorageKey && config.clearChatHistoryStorageKey !== DEFAULT_CHAT_HISTORY_STORAGE_KEY) {
|
|
1912
|
+
try {
|
|
1913
|
+
localStorage.removeItem(config.clearChatHistoryStorageKey);
|
|
1914
|
+
if (config.debug) {
|
|
1915
|
+
console.log(`[AgentWidget] Cleared custom localStorage key: ${config.clearChatHistoryStorageKey}`);
|
|
1916
|
+
}
|
|
1917
|
+
} catch (error) {
|
|
1918
|
+
console.error("[AgentWidget] Failed to clear custom localStorage:", error);
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
// Dispatch custom event for external handlers (e.g., localStorage clearing in examples)
|
|
1923
|
+
const clearEvent = new CustomEvent("persona:clear-chat", {
|
|
1924
|
+
detail: { timestamp: new Date().toISOString() }
|
|
1925
|
+
});
|
|
1926
|
+
window.dispatchEvent(clearEvent);
|
|
1927
|
+
|
|
1928
|
+
if (storageAdapter?.clear) {
|
|
1929
|
+
try {
|
|
1930
|
+
const result = storageAdapter.clear();
|
|
1931
|
+
if (result instanceof Promise) {
|
|
1932
|
+
result.catch((error) => {
|
|
1933
|
+
if (typeof console !== "undefined") {
|
|
1934
|
+
// eslint-disable-next-line no-console
|
|
1935
|
+
console.error("[AgentWidget] Failed to clear storage adapter:", error);
|
|
1936
|
+
}
|
|
1937
|
+
});
|
|
1938
|
+
}
|
|
1939
|
+
} catch (error) {
|
|
1940
|
+
if (typeof console !== "undefined") {
|
|
1941
|
+
// eslint-disable-next-line no-console
|
|
1942
|
+
console.error("[AgentWidget] Failed to clear storage adapter:", error);
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
persistentMetadata = {};
|
|
1947
|
+
actionManager.syncFromMetadata();
|
|
1948
|
+
});
|
|
1949
|
+
};
|
|
1950
|
+
|
|
1951
|
+
setupClearChatButton();
|
|
1952
|
+
|
|
1953
|
+
composerForm.addEventListener("submit", handleSubmit);
|
|
1954
|
+
textarea.addEventListener("keydown", handleInputEnter);
|
|
1955
|
+
|
|
1956
|
+
destroyCallbacks.push(() => {
|
|
1957
|
+
composerForm.removeEventListener("submit", handleSubmit);
|
|
1958
|
+
textarea.removeEventListener("keydown", handleInputEnter);
|
|
1959
|
+
});
|
|
1960
|
+
|
|
1961
|
+
destroyCallbacks.push(() => {
|
|
1962
|
+
session.cancel();
|
|
1963
|
+
});
|
|
1964
|
+
|
|
1965
|
+
if (launcherButtonInstance) {
|
|
1966
|
+
destroyCallbacks.push(() => {
|
|
1967
|
+
launcherButtonInstance?.destroy();
|
|
1968
|
+
});
|
|
1969
|
+
} else if (customLauncherElement) {
|
|
1970
|
+
destroyCallbacks.push(() => {
|
|
1971
|
+
customLauncherElement?.remove();
|
|
1972
|
+
});
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
const controller: Controller = {
|
|
1976
|
+
update(nextConfig: AgentWidgetConfig) {
|
|
1977
|
+
const previousToolCallConfig = config.toolCall;
|
|
1978
|
+
const previousColorScheme = config.colorScheme;
|
|
1979
|
+
config = { ...config, ...nextConfig };
|
|
1980
|
+
// applyFullHeightStyles resets mount.style.cssText, so call it before applyThemeVariables
|
|
1981
|
+
applyFullHeightStyles();
|
|
1982
|
+
applyThemeVariables(mount, config);
|
|
1983
|
+
|
|
1984
|
+
// Re-setup theme observer if colorScheme changed
|
|
1985
|
+
if (config.colorScheme !== previousColorScheme) {
|
|
1986
|
+
setupThemeObserver();
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
// Update plugins
|
|
1990
|
+
const newPlugins = pluginRegistry.getForInstance(config.plugins);
|
|
1991
|
+
plugins.length = 0;
|
|
1992
|
+
plugins.push(...newPlugins);
|
|
1993
|
+
|
|
1994
|
+
launcherEnabled = config.launcher?.enabled ?? true;
|
|
1995
|
+
autoExpand = config.launcher?.autoExpand ?? false;
|
|
1996
|
+
showReasoning = config.features?.showReasoning ?? true;
|
|
1997
|
+
showToolCalls = config.features?.showToolCalls ?? true;
|
|
1998
|
+
|
|
1999
|
+
if (config.launcher?.enabled === false && launcherButtonInstance) {
|
|
2000
|
+
launcherButtonInstance.destroy();
|
|
2001
|
+
launcherButtonInstance = null;
|
|
2002
|
+
}
|
|
2003
|
+
if (config.launcher?.enabled === false && customLauncherElement) {
|
|
2004
|
+
customLauncherElement.remove();
|
|
2005
|
+
customLauncherElement = null;
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
if (config.launcher?.enabled !== false && !launcherButtonInstance && !customLauncherElement) {
|
|
2009
|
+
// Check for launcher plugin when re-enabling
|
|
2010
|
+
const launcherPlugin = plugins.find(p => p.renderLauncher);
|
|
2011
|
+
if (launcherPlugin?.renderLauncher) {
|
|
2012
|
+
const customLauncher = launcherPlugin.renderLauncher({
|
|
2013
|
+
config,
|
|
2014
|
+
defaultRenderer: () => {
|
|
2015
|
+
const btn = createLauncherButton(config, toggleOpen);
|
|
2016
|
+
return btn.element;
|
|
2017
|
+
},
|
|
2018
|
+
onToggle: toggleOpen
|
|
2019
|
+
});
|
|
2020
|
+
if (customLauncher) {
|
|
2021
|
+
customLauncherElement = customLauncher;
|
|
2022
|
+
mount.appendChild(customLauncherElement);
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
if (!customLauncherElement) {
|
|
2026
|
+
launcherButtonInstance = createLauncherButton(config, toggleOpen);
|
|
2027
|
+
mount.appendChild(launcherButtonInstance.element);
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
if (launcherButtonInstance) {
|
|
2032
|
+
launcherButtonInstance.update(config);
|
|
2033
|
+
}
|
|
2034
|
+
// Note: Custom launcher updates are handled by the plugin's own logic
|
|
2035
|
+
|
|
2036
|
+
// Update panel header title and subtitle
|
|
2037
|
+
if (headerTitle && config.launcher?.title !== undefined) {
|
|
2038
|
+
headerTitle.textContent = config.launcher.title;
|
|
2039
|
+
}
|
|
2040
|
+
if (headerSubtitle && config.launcher?.subtitle !== undefined) {
|
|
2041
|
+
headerSubtitle.textContent = config.launcher.subtitle;
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
// Update header layout if it changed
|
|
2045
|
+
const headerLayoutConfig = config.layout?.header;
|
|
2046
|
+
const headerLayoutChanged = headerLayoutConfig?.layout !== prevHeaderLayout;
|
|
2047
|
+
|
|
2048
|
+
if (headerLayoutChanged && header) {
|
|
2049
|
+
// Rebuild header with new layout
|
|
2050
|
+
const newHeaderElements = headerLayoutConfig
|
|
2051
|
+
? buildHeaderWithLayout(config, headerLayoutConfig, {
|
|
2052
|
+
showClose: launcherEnabled,
|
|
2053
|
+
onClose: () => setOpenState(false, "user")
|
|
2054
|
+
})
|
|
2055
|
+
: buildHeader({
|
|
2056
|
+
config,
|
|
2057
|
+
showClose: launcherEnabled,
|
|
2058
|
+
onClose: () => setOpenState(false, "user")
|
|
2059
|
+
});
|
|
2060
|
+
|
|
2061
|
+
// Replace the old header with the new one
|
|
2062
|
+
header.replaceWith(newHeaderElements.header);
|
|
2063
|
+
|
|
2064
|
+
// Update references
|
|
2065
|
+
header = newHeaderElements.header;
|
|
2066
|
+
iconHolder = newHeaderElements.iconHolder;
|
|
2067
|
+
headerTitle = newHeaderElements.headerTitle;
|
|
2068
|
+
headerSubtitle = newHeaderElements.headerSubtitle;
|
|
2069
|
+
closeButton = newHeaderElements.closeButton;
|
|
2070
|
+
|
|
2071
|
+
prevHeaderLayout = headerLayoutConfig?.layout;
|
|
2072
|
+
} else if (headerLayoutConfig) {
|
|
2073
|
+
// Apply visibility settings without rebuilding
|
|
2074
|
+
if (iconHolder) {
|
|
2075
|
+
iconHolder.style.display = headerLayoutConfig.showIcon === false ? "none" : "";
|
|
2076
|
+
}
|
|
2077
|
+
if (headerTitle) {
|
|
2078
|
+
headerTitle.style.display = headerLayoutConfig.showTitle === false ? "none" : "";
|
|
2079
|
+
}
|
|
2080
|
+
if (headerSubtitle) {
|
|
2081
|
+
headerSubtitle.style.display = headerLayoutConfig.showSubtitle === false ? "none" : "";
|
|
2082
|
+
}
|
|
2083
|
+
if (closeButton) {
|
|
2084
|
+
closeButton.style.display = headerLayoutConfig.showCloseButton === false ? "none" : "";
|
|
2085
|
+
}
|
|
2086
|
+
if (panelElements.clearChatButtonWrapper) {
|
|
2087
|
+
// showClearChat explicitly controls visibility when set
|
|
2088
|
+
const showClearChat = headerLayoutConfig.showClearChat;
|
|
2089
|
+
if (showClearChat !== undefined) {
|
|
2090
|
+
panelElements.clearChatButtonWrapper.style.display = showClearChat ? "" : "none";
|
|
2091
|
+
// When clear chat is hidden, close button needs ml-auto to stay right-aligned
|
|
2092
|
+
const { closeButtonWrapper } = panelElements;
|
|
2093
|
+
if (closeButtonWrapper && !closeButtonWrapper.classList.contains("tvw-absolute")) {
|
|
2094
|
+
if (showClearChat) {
|
|
2095
|
+
closeButtonWrapper.classList.remove("tvw-ml-auto");
|
|
2096
|
+
} else {
|
|
2097
|
+
closeButtonWrapper.classList.add("tvw-ml-auto");
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
// Update header visibility based on layout.showHeader
|
|
2105
|
+
const showHeader = config.layout?.showHeader !== false; // default to true
|
|
2106
|
+
if (header) {
|
|
2107
|
+
header.style.display = showHeader ? "" : "none";
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
// Update footer visibility based on layout.showFooter
|
|
2111
|
+
const showFooter = config.layout?.showFooter !== false; // default to true
|
|
2112
|
+
if (footer) {
|
|
2113
|
+
footer.style.display = showFooter ? "" : "none";
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
// Only update open state if launcher enabled state changed or autoExpand value changed
|
|
2117
|
+
const launcherEnabledChanged = launcherEnabled !== prevLauncherEnabled;
|
|
2118
|
+
const autoExpandChanged = autoExpand !== prevAutoExpand;
|
|
2119
|
+
|
|
2120
|
+
if (launcherEnabledChanged) {
|
|
2121
|
+
// Launcher was enabled/disabled - update state accordingly
|
|
2122
|
+
if (!launcherEnabled) {
|
|
2123
|
+
// When launcher is disabled, always keep panel open
|
|
2124
|
+
open = true;
|
|
2125
|
+
updateOpenState();
|
|
2126
|
+
} else {
|
|
2127
|
+
// Launcher was just enabled - respect autoExpand setting
|
|
2128
|
+
setOpenState(autoExpand, "auto");
|
|
2129
|
+
}
|
|
2130
|
+
} else if (autoExpandChanged) {
|
|
2131
|
+
// autoExpand value changed - update state to match
|
|
2132
|
+
setOpenState(autoExpand, "auto");
|
|
2133
|
+
}
|
|
2134
|
+
// Otherwise, preserve current open state (user may have manually opened/closed)
|
|
2135
|
+
|
|
2136
|
+
// Update previous values for next comparison
|
|
2137
|
+
prevAutoExpand = autoExpand;
|
|
2138
|
+
prevLauncherEnabled = launcherEnabled;
|
|
2139
|
+
recalcPanelHeight();
|
|
2140
|
+
refreshCloseButton();
|
|
2141
|
+
|
|
2142
|
+
// Re-render messages if toolCall config changed (to apply new styles)
|
|
2143
|
+
const toolCallConfigChanged = JSON.stringify(nextConfig.toolCall) !== JSON.stringify(previousToolCallConfig);
|
|
2144
|
+
if (toolCallConfigChanged && session) {
|
|
2145
|
+
renderMessagesWithPlugins(messagesWrapper, session.getMessages(), postprocess);
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
// Update panel icon sizes
|
|
2149
|
+
const launcher = config.launcher ?? {};
|
|
2150
|
+
const headerIconHidden = launcher.headerIconHidden ?? false;
|
|
2151
|
+
const layoutShowIcon = config.layout?.header?.showIcon;
|
|
2152
|
+
// Hide icon if either headerIconHidden is true OR layout.header.showIcon is false
|
|
2153
|
+
const shouldHideIcon = headerIconHidden || layoutShowIcon === false;
|
|
2154
|
+
const headerIconName = launcher.headerIconName;
|
|
2155
|
+
const headerIconSize = launcher.headerIconSize ?? "48px";
|
|
2156
|
+
|
|
2157
|
+
if (iconHolder) {
|
|
2158
|
+
const headerEl = container.querySelector(".tvw-border-b-cw-divider");
|
|
2159
|
+
const headerCopy = headerEl?.querySelector(".tvw-flex-col");
|
|
2160
|
+
|
|
2161
|
+
// Handle hide/show
|
|
2162
|
+
if (shouldHideIcon) {
|
|
2163
|
+
// Hide iconHolder
|
|
2164
|
+
iconHolder.style.display = "none";
|
|
2165
|
+
// Ensure headerCopy is still in header
|
|
2166
|
+
if (headerEl && headerCopy && !headerEl.contains(headerCopy)) {
|
|
2167
|
+
headerEl.insertBefore(headerCopy, headerEl.firstChild);
|
|
2168
|
+
}
|
|
2169
|
+
} else {
|
|
2170
|
+
// Show iconHolder
|
|
2171
|
+
iconHolder.style.display = "";
|
|
2172
|
+
iconHolder.style.height = headerIconSize;
|
|
2173
|
+
iconHolder.style.width = headerIconSize;
|
|
2174
|
+
|
|
2175
|
+
// Ensure iconHolder is before headerCopy in header
|
|
2176
|
+
if (headerEl && headerCopy) {
|
|
2177
|
+
if (!headerEl.contains(iconHolder)) {
|
|
2178
|
+
headerEl.insertBefore(iconHolder, headerCopy);
|
|
2179
|
+
} else if (iconHolder.nextSibling !== headerCopy) {
|
|
2180
|
+
// Reorder if needed
|
|
2181
|
+
iconHolder.remove();
|
|
2182
|
+
headerEl.insertBefore(iconHolder, headerCopy);
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
// Update icon content based on priority: Lucide icon > iconUrl > agentIconText
|
|
2187
|
+
if (headerIconName) {
|
|
2188
|
+
// Use Lucide icon
|
|
2189
|
+
const iconSize = parseFloat(headerIconSize) || 24;
|
|
2190
|
+
const iconSvg = renderLucideIcon(headerIconName, iconSize * 0.6, "#ffffff", 2);
|
|
2191
|
+
if (iconSvg) {
|
|
2192
|
+
iconHolder.replaceChildren(iconSvg);
|
|
2193
|
+
} else {
|
|
2194
|
+
// Fallback to agentIconText if Lucide icon fails
|
|
2195
|
+
iconHolder.textContent = launcher.agentIconText ?? "💬";
|
|
2196
|
+
}
|
|
2197
|
+
} else if (launcher.iconUrl) {
|
|
2198
|
+
// Use image URL
|
|
2199
|
+
const img = iconHolder.querySelector("img");
|
|
2200
|
+
if (img) {
|
|
2201
|
+
img.src = launcher.iconUrl;
|
|
2202
|
+
img.style.height = headerIconSize;
|
|
2203
|
+
img.style.width = headerIconSize;
|
|
2204
|
+
} else {
|
|
2205
|
+
// Create new img if it doesn't exist
|
|
2206
|
+
const newImg = document.createElement("img");
|
|
2207
|
+
newImg.src = launcher.iconUrl;
|
|
2208
|
+
newImg.alt = "";
|
|
2209
|
+
newImg.className = "tvw-rounded-xl tvw-object-cover";
|
|
2210
|
+
newImg.style.height = headerIconSize;
|
|
2211
|
+
newImg.style.width = headerIconSize;
|
|
2212
|
+
iconHolder.replaceChildren(newImg);
|
|
2213
|
+
}
|
|
2214
|
+
} else {
|
|
2215
|
+
// Use text/emoji - clear any SVG or img first
|
|
2216
|
+
const existingSvg = iconHolder.querySelector("svg");
|
|
2217
|
+
const existingImg = iconHolder.querySelector("img");
|
|
2218
|
+
if (existingSvg || existingImg) {
|
|
2219
|
+
iconHolder.replaceChildren();
|
|
2220
|
+
}
|
|
2221
|
+
iconHolder.textContent = launcher.agentIconText ?? "💬";
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
// Update image size if present
|
|
2225
|
+
const img = iconHolder.querySelector("img");
|
|
2226
|
+
if (img) {
|
|
2227
|
+
img.style.height = headerIconSize;
|
|
2228
|
+
img.style.width = headerIconSize;
|
|
2229
|
+
}
|
|
2230
|
+
}
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
// Handle title/subtitle visibility from layout config
|
|
2234
|
+
const layoutShowTitle = config.layout?.header?.showTitle;
|
|
2235
|
+
const layoutShowSubtitle = config.layout?.header?.showSubtitle;
|
|
2236
|
+
if (headerTitle) {
|
|
2237
|
+
headerTitle.style.display = layoutShowTitle === false ? "none" : "";
|
|
2238
|
+
}
|
|
2239
|
+
if (headerSubtitle) {
|
|
2240
|
+
headerSubtitle.style.display = layoutShowSubtitle === false ? "none" : "";
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
if (closeButton) {
|
|
2244
|
+
// Handle close button visibility from layout config
|
|
2245
|
+
const layoutShowCloseButton = config.layout?.header?.showCloseButton;
|
|
2246
|
+
if (layoutShowCloseButton === false) {
|
|
2247
|
+
closeButton.style.display = "none";
|
|
2248
|
+
} else {
|
|
2249
|
+
closeButton.style.display = "";
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
const closeButtonSize = launcher.closeButtonSize ?? "32px";
|
|
2253
|
+
const closeButtonPlacement = launcher.closeButtonPlacement ?? "inline";
|
|
2254
|
+
closeButton.style.height = closeButtonSize;
|
|
2255
|
+
closeButton.style.width = closeButtonSize;
|
|
2256
|
+
|
|
2257
|
+
// Update placement if changed - move the wrapper (not just the button) to preserve tooltip
|
|
2258
|
+
const { closeButtonWrapper } = panelElements;
|
|
2259
|
+
const isTopRight = closeButtonPlacement === "top-right";
|
|
2260
|
+
const currentlyTopRight = closeButtonWrapper?.classList.contains("tvw-absolute");
|
|
2261
|
+
|
|
2262
|
+
if (closeButtonWrapper && isTopRight !== currentlyTopRight) {
|
|
2263
|
+
// Placement changed - need to move wrapper and update classes
|
|
2264
|
+
closeButtonWrapper.remove();
|
|
2265
|
+
|
|
2266
|
+
// Update wrapper classes
|
|
2267
|
+
if (isTopRight) {
|
|
2268
|
+
closeButtonWrapper.className = "tvw-absolute tvw-top-4 tvw-right-4 tvw-z-50";
|
|
2269
|
+
container.style.position = "relative";
|
|
2270
|
+
container.appendChild(closeButtonWrapper);
|
|
2271
|
+
} else {
|
|
2272
|
+
// Check if clear chat is inline to determine if we need ml-auto
|
|
2273
|
+
const clearChatPlacement = launcher.clearChat?.placement ?? "inline";
|
|
2274
|
+
const clearChatEnabled = launcher.clearChat?.enabled ?? true;
|
|
2275
|
+
closeButtonWrapper.className = (clearChatEnabled && clearChatPlacement === "inline") ? "" : "tvw-ml-auto";
|
|
2276
|
+
// Find header element
|
|
2277
|
+
const header = container.querySelector(".tvw-border-b-cw-divider");
|
|
2278
|
+
if (header) {
|
|
2279
|
+
header.appendChild(closeButtonWrapper);
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
// Apply close button styling from config
|
|
2285
|
+
if (launcher.closeButtonColor) {
|
|
2286
|
+
closeButton.style.color = launcher.closeButtonColor;
|
|
2287
|
+
closeButton.classList.remove("tvw-text-cw-muted");
|
|
2288
|
+
} else {
|
|
2289
|
+
closeButton.style.color = "";
|
|
2290
|
+
closeButton.classList.add("tvw-text-cw-muted");
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2293
|
+
if (launcher.closeButtonBackgroundColor) {
|
|
2294
|
+
closeButton.style.backgroundColor = launcher.closeButtonBackgroundColor;
|
|
2295
|
+
closeButton.classList.remove("hover:tvw-bg-gray-100");
|
|
2296
|
+
} else {
|
|
2297
|
+
closeButton.style.backgroundColor = "";
|
|
2298
|
+
closeButton.classList.add("hover:tvw-bg-gray-100");
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
// Apply border if width and/or color are provided
|
|
2302
|
+
if (launcher.closeButtonBorderWidth || launcher.closeButtonBorderColor) {
|
|
2303
|
+
const borderWidth = launcher.closeButtonBorderWidth || "0px";
|
|
2304
|
+
const borderColor = launcher.closeButtonBorderColor || "transparent";
|
|
2305
|
+
closeButton.style.border = `${borderWidth} solid ${borderColor}`;
|
|
2306
|
+
closeButton.classList.remove("tvw-border-none");
|
|
2307
|
+
} else {
|
|
2308
|
+
closeButton.style.border = "";
|
|
2309
|
+
closeButton.classList.add("tvw-border-none");
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2312
|
+
if (launcher.closeButtonBorderRadius) {
|
|
2313
|
+
closeButton.style.borderRadius = launcher.closeButtonBorderRadius;
|
|
2314
|
+
closeButton.classList.remove("tvw-rounded-full");
|
|
2315
|
+
} else {
|
|
2316
|
+
closeButton.style.borderRadius = "";
|
|
2317
|
+
closeButton.classList.add("tvw-rounded-full");
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
// Update padding
|
|
2321
|
+
if (launcher.closeButtonPaddingX) {
|
|
2322
|
+
closeButton.style.paddingLeft = launcher.closeButtonPaddingX;
|
|
2323
|
+
closeButton.style.paddingRight = launcher.closeButtonPaddingX;
|
|
2324
|
+
} else {
|
|
2325
|
+
closeButton.style.paddingLeft = "";
|
|
2326
|
+
closeButton.style.paddingRight = "";
|
|
2327
|
+
}
|
|
2328
|
+
if (launcher.closeButtonPaddingY) {
|
|
2329
|
+
closeButton.style.paddingTop = launcher.closeButtonPaddingY;
|
|
2330
|
+
closeButton.style.paddingBottom = launcher.closeButtonPaddingY;
|
|
2331
|
+
} else {
|
|
2332
|
+
closeButton.style.paddingTop = "";
|
|
2333
|
+
closeButton.style.paddingBottom = "";
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
// Update icon
|
|
2337
|
+
const closeButtonIconName = launcher.closeButtonIconName ?? "x";
|
|
2338
|
+
const closeButtonIconText = launcher.closeButtonIconText ?? "×";
|
|
2339
|
+
|
|
2340
|
+
// Clear existing content and render new icon
|
|
2341
|
+
closeButton.innerHTML = "";
|
|
2342
|
+
const iconSvg = renderLucideIcon(closeButtonIconName, "20px", launcher.closeButtonColor || "", 2);
|
|
2343
|
+
if (iconSvg) {
|
|
2344
|
+
closeButton.appendChild(iconSvg);
|
|
2345
|
+
} else {
|
|
2346
|
+
closeButton.textContent = closeButtonIconText;
|
|
2347
|
+
}
|
|
2348
|
+
|
|
2349
|
+
// Update tooltip
|
|
2350
|
+
const closeButtonTooltipText = launcher.closeButtonTooltipText ?? "Close chat";
|
|
2351
|
+
const closeButtonShowTooltip = launcher.closeButtonShowTooltip ?? true;
|
|
2352
|
+
|
|
2353
|
+
closeButton.setAttribute("aria-label", closeButtonTooltipText);
|
|
2354
|
+
|
|
2355
|
+
if (closeButtonWrapper) {
|
|
2356
|
+
// Clean up old tooltip event listeners if they exist
|
|
2357
|
+
if ((closeButtonWrapper as any)._cleanupTooltip) {
|
|
2358
|
+
(closeButtonWrapper as any)._cleanupTooltip();
|
|
2359
|
+
delete (closeButtonWrapper as any)._cleanupTooltip;
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
// Set up new portaled tooltip with event listeners
|
|
2363
|
+
if (closeButtonShowTooltip && closeButtonTooltipText) {
|
|
2364
|
+
let portaledTooltip: HTMLElement | null = null;
|
|
2365
|
+
|
|
2366
|
+
const showTooltip = () => {
|
|
2367
|
+
if (portaledTooltip || !closeButton) return; // Already showing or button doesn't exist
|
|
2368
|
+
|
|
2369
|
+
// Create tooltip element
|
|
2370
|
+
portaledTooltip = createElement("div", "tvw-clear-chat-tooltip");
|
|
2371
|
+
portaledTooltip.textContent = closeButtonTooltipText;
|
|
2372
|
+
|
|
2373
|
+
// Add arrow
|
|
2374
|
+
const arrow = createElement("div");
|
|
2375
|
+
arrow.className = "tvw-clear-chat-tooltip-arrow";
|
|
2376
|
+
portaledTooltip.appendChild(arrow);
|
|
2377
|
+
|
|
2378
|
+
// Get button position
|
|
2379
|
+
const buttonRect = closeButton.getBoundingClientRect();
|
|
2380
|
+
|
|
2381
|
+
// Position tooltip above button
|
|
2382
|
+
portaledTooltip.style.position = "fixed";
|
|
2383
|
+
portaledTooltip.style.left = `${buttonRect.left + buttonRect.width / 2}px`;
|
|
2384
|
+
portaledTooltip.style.top = `${buttonRect.top - 8}px`;
|
|
2385
|
+
portaledTooltip.style.transform = "translate(-50%, -100%)";
|
|
2386
|
+
|
|
2387
|
+
// Append to body
|
|
2388
|
+
document.body.appendChild(portaledTooltip);
|
|
2389
|
+
};
|
|
2390
|
+
|
|
2391
|
+
const hideTooltip = () => {
|
|
2392
|
+
if (portaledTooltip && portaledTooltip.parentNode) {
|
|
2393
|
+
portaledTooltip.parentNode.removeChild(portaledTooltip);
|
|
2394
|
+
portaledTooltip = null;
|
|
2395
|
+
}
|
|
2396
|
+
};
|
|
2397
|
+
|
|
2398
|
+
// Add event listeners
|
|
2399
|
+
closeButtonWrapper.addEventListener("mouseenter", showTooltip);
|
|
2400
|
+
closeButtonWrapper.addEventListener("mouseleave", hideTooltip);
|
|
2401
|
+
closeButton.addEventListener("focus", showTooltip);
|
|
2402
|
+
closeButton.addEventListener("blur", hideTooltip);
|
|
2403
|
+
|
|
2404
|
+
// Store cleanup function on the wrapper for later use
|
|
2405
|
+
(closeButtonWrapper as any)._cleanupTooltip = () => {
|
|
2406
|
+
hideTooltip();
|
|
2407
|
+
if (closeButtonWrapper) {
|
|
2408
|
+
closeButtonWrapper.removeEventListener("mouseenter", showTooltip);
|
|
2409
|
+
closeButtonWrapper.removeEventListener("mouseleave", hideTooltip);
|
|
2410
|
+
}
|
|
2411
|
+
if (closeButton) {
|
|
2412
|
+
closeButton.removeEventListener("focus", showTooltip);
|
|
2413
|
+
closeButton.removeEventListener("blur", hideTooltip);
|
|
2414
|
+
}
|
|
2415
|
+
};
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
// Update clear chat button styling from config
|
|
2421
|
+
const { clearChatButton, clearChatButtonWrapper } = panelElements;
|
|
2422
|
+
if (clearChatButton) {
|
|
2423
|
+
const clearChatConfig = launcher.clearChat ?? {};
|
|
2424
|
+
const clearChatEnabled = clearChatConfig.enabled ?? true;
|
|
2425
|
+
const layoutShowClearChat = config.layout?.header?.showClearChat;
|
|
2426
|
+
// layout.header.showClearChat takes precedence if explicitly set
|
|
2427
|
+
// Otherwise fall back to launcher.clearChat.enabled
|
|
2428
|
+
const shouldShowClearChat = layoutShowClearChat !== undefined
|
|
2429
|
+
? layoutShowClearChat
|
|
2430
|
+
: clearChatEnabled;
|
|
2431
|
+
const clearChatPlacement = clearChatConfig.placement ?? "inline";
|
|
2432
|
+
|
|
2433
|
+
// Show/hide button based on layout config (primary) or launcher config (fallback)
|
|
2434
|
+
if (clearChatButtonWrapper) {
|
|
2435
|
+
clearChatButtonWrapper.style.display = shouldShowClearChat ? "" : "none";
|
|
2436
|
+
|
|
2437
|
+
// When clear chat is hidden, close button needs ml-auto to stay right-aligned
|
|
2438
|
+
const { closeButtonWrapper } = panelElements;
|
|
2439
|
+
if (closeButtonWrapper && !closeButtonWrapper.classList.contains("tvw-absolute")) {
|
|
2440
|
+
if (shouldShowClearChat) {
|
|
2441
|
+
closeButtonWrapper.classList.remove("tvw-ml-auto");
|
|
2442
|
+
} else {
|
|
2443
|
+
closeButtonWrapper.classList.add("tvw-ml-auto");
|
|
2444
|
+
}
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
// Update placement if changed
|
|
2448
|
+
const isTopRight = clearChatPlacement === "top-right";
|
|
2449
|
+
const currentlyTopRight = clearChatButtonWrapper.classList.contains("tvw-absolute");
|
|
2450
|
+
|
|
2451
|
+
if (isTopRight !== currentlyTopRight && shouldShowClearChat) {
|
|
2452
|
+
clearChatButtonWrapper.remove();
|
|
2453
|
+
|
|
2454
|
+
if (isTopRight) {
|
|
2455
|
+
// Don't use tvw-clear-chat-button-wrapper class for top-right mode as its
|
|
2456
|
+
// display: inline-flex causes alignment issues with the close button
|
|
2457
|
+
clearChatButtonWrapper.className = "tvw-absolute tvw-top-4 tvw-z-50";
|
|
2458
|
+
// Position to the left of the close button (which is at right: 1rem/16px)
|
|
2459
|
+
// Close button is ~32px wide, plus small gap = 48px from right
|
|
2460
|
+
clearChatButtonWrapper.style.right = "48px";
|
|
2461
|
+
container.style.position = "relative";
|
|
2462
|
+
container.appendChild(clearChatButtonWrapper);
|
|
2463
|
+
} else {
|
|
2464
|
+
clearChatButtonWrapper.className = "tvw-relative tvw-ml-auto tvw-clear-chat-button-wrapper";
|
|
2465
|
+
// Clear the inline right style when switching back to inline mode
|
|
2466
|
+
clearChatButtonWrapper.style.right = "";
|
|
2467
|
+
// Find header and insert before close button
|
|
2468
|
+
const header = container.querySelector(".tvw-border-b-cw-divider");
|
|
2469
|
+
const closeButtonWrapperEl = panelElements.closeButtonWrapper;
|
|
2470
|
+
if (header && closeButtonWrapperEl && closeButtonWrapperEl.parentElement === header) {
|
|
2471
|
+
header.insertBefore(clearChatButtonWrapper, closeButtonWrapperEl);
|
|
2472
|
+
} else if (header) {
|
|
2473
|
+
header.appendChild(clearChatButtonWrapper);
|
|
2474
|
+
}
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
// Also update close button's ml-auto class based on clear chat position
|
|
2478
|
+
const closeButtonWrapperEl = panelElements.closeButtonWrapper;
|
|
2479
|
+
if (closeButtonWrapperEl && !closeButtonWrapperEl.classList.contains("tvw-absolute")) {
|
|
2480
|
+
if (isTopRight) {
|
|
2481
|
+
// Clear chat moved to top-right, close needs ml-auto
|
|
2482
|
+
closeButtonWrapperEl.classList.add("tvw-ml-auto");
|
|
2483
|
+
} else {
|
|
2484
|
+
// Clear chat is inline, close doesn't need ml-auto
|
|
2485
|
+
closeButtonWrapperEl.classList.remove("tvw-ml-auto");
|
|
2486
|
+
}
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
if (shouldShowClearChat) {
|
|
2492
|
+
// Update size
|
|
2493
|
+
const clearChatSize = clearChatConfig.size ?? "32px";
|
|
2494
|
+
clearChatButton.style.height = clearChatSize;
|
|
2495
|
+
clearChatButton.style.width = clearChatSize;
|
|
2496
|
+
|
|
2497
|
+
// Update icon
|
|
2498
|
+
const clearChatIconName = clearChatConfig.iconName ?? "refresh-cw";
|
|
2499
|
+
const clearChatIconColor = clearChatConfig.iconColor ?? "";
|
|
2500
|
+
|
|
2501
|
+
// Clear existing icon and render new one
|
|
2502
|
+
clearChatButton.innerHTML = "";
|
|
2503
|
+
const iconSvg = renderLucideIcon(clearChatIconName, "20px", clearChatIconColor || "", 2);
|
|
2504
|
+
if (iconSvg) {
|
|
2505
|
+
clearChatButton.appendChild(iconSvg);
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2508
|
+
// Update icon color
|
|
2509
|
+
if (clearChatIconColor) {
|
|
2510
|
+
clearChatButton.style.color = clearChatIconColor;
|
|
2511
|
+
clearChatButton.classList.remove("tvw-text-cw-muted");
|
|
2512
|
+
} else {
|
|
2513
|
+
clearChatButton.style.color = "";
|
|
2514
|
+
clearChatButton.classList.add("tvw-text-cw-muted");
|
|
2515
|
+
}
|
|
2516
|
+
|
|
2517
|
+
// Update background color
|
|
2518
|
+
if (clearChatConfig.backgroundColor) {
|
|
2519
|
+
clearChatButton.style.backgroundColor = clearChatConfig.backgroundColor;
|
|
2520
|
+
clearChatButton.classList.remove("hover:tvw-bg-gray-100");
|
|
2521
|
+
} else {
|
|
2522
|
+
clearChatButton.style.backgroundColor = "";
|
|
2523
|
+
clearChatButton.classList.add("hover:tvw-bg-gray-100");
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2526
|
+
// Update border
|
|
2527
|
+
if (clearChatConfig.borderWidth || clearChatConfig.borderColor) {
|
|
2528
|
+
const borderWidth = clearChatConfig.borderWidth || "0px";
|
|
2529
|
+
const borderColor = clearChatConfig.borderColor || "transparent";
|
|
2530
|
+
clearChatButton.style.border = `${borderWidth} solid ${borderColor}`;
|
|
2531
|
+
clearChatButton.classList.remove("tvw-border-none");
|
|
2532
|
+
} else {
|
|
2533
|
+
clearChatButton.style.border = "";
|
|
2534
|
+
clearChatButton.classList.add("tvw-border-none");
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2537
|
+
// Update border radius
|
|
2538
|
+
if (clearChatConfig.borderRadius) {
|
|
2539
|
+
clearChatButton.style.borderRadius = clearChatConfig.borderRadius;
|
|
2540
|
+
clearChatButton.classList.remove("tvw-rounded-full");
|
|
2541
|
+
} else {
|
|
2542
|
+
clearChatButton.style.borderRadius = "";
|
|
2543
|
+
clearChatButton.classList.add("tvw-rounded-full");
|
|
2544
|
+
}
|
|
2545
|
+
|
|
2546
|
+
// Update padding
|
|
2547
|
+
if (clearChatConfig.paddingX) {
|
|
2548
|
+
clearChatButton.style.paddingLeft = clearChatConfig.paddingX;
|
|
2549
|
+
clearChatButton.style.paddingRight = clearChatConfig.paddingX;
|
|
2550
|
+
} else {
|
|
2551
|
+
clearChatButton.style.paddingLeft = "";
|
|
2552
|
+
clearChatButton.style.paddingRight = "";
|
|
2553
|
+
}
|
|
2554
|
+
if (clearChatConfig.paddingY) {
|
|
2555
|
+
clearChatButton.style.paddingTop = clearChatConfig.paddingY;
|
|
2556
|
+
clearChatButton.style.paddingBottom = clearChatConfig.paddingY;
|
|
2557
|
+
} else {
|
|
2558
|
+
clearChatButton.style.paddingTop = "";
|
|
2559
|
+
clearChatButton.style.paddingBottom = "";
|
|
2560
|
+
}
|
|
2561
|
+
|
|
2562
|
+
const clearChatTooltipText = clearChatConfig.tooltipText ?? "Clear chat";
|
|
2563
|
+
const clearChatShowTooltip = clearChatConfig.showTooltip ?? true;
|
|
2564
|
+
|
|
2565
|
+
clearChatButton.setAttribute("aria-label", clearChatTooltipText);
|
|
2566
|
+
|
|
2567
|
+
if (clearChatButtonWrapper) {
|
|
2568
|
+
// Clean up old tooltip event listeners if they exist
|
|
2569
|
+
if ((clearChatButtonWrapper as any)._cleanupTooltip) {
|
|
2570
|
+
(clearChatButtonWrapper as any)._cleanupTooltip();
|
|
2571
|
+
delete (clearChatButtonWrapper as any)._cleanupTooltip;
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
// Set up new portaled tooltip with event listeners
|
|
2575
|
+
if (clearChatShowTooltip && clearChatTooltipText) {
|
|
2576
|
+
let portaledTooltip: HTMLElement | null = null;
|
|
2577
|
+
|
|
2578
|
+
const showTooltip = () => {
|
|
2579
|
+
if (portaledTooltip || !clearChatButton) return; // Already showing or button doesn't exist
|
|
2580
|
+
|
|
2581
|
+
// Create tooltip element
|
|
2582
|
+
portaledTooltip = createElement("div", "tvw-clear-chat-tooltip");
|
|
2583
|
+
portaledTooltip.textContent = clearChatTooltipText;
|
|
2584
|
+
|
|
2585
|
+
// Add arrow
|
|
2586
|
+
const arrow = createElement("div");
|
|
2587
|
+
arrow.className = "tvw-clear-chat-tooltip-arrow";
|
|
2588
|
+
portaledTooltip.appendChild(arrow);
|
|
2589
|
+
|
|
2590
|
+
// Get button position
|
|
2591
|
+
const buttonRect = clearChatButton.getBoundingClientRect();
|
|
2592
|
+
|
|
2593
|
+
// Position tooltip above button
|
|
2594
|
+
portaledTooltip.style.position = "fixed";
|
|
2595
|
+
portaledTooltip.style.left = `${buttonRect.left + buttonRect.width / 2}px`;
|
|
2596
|
+
portaledTooltip.style.top = `${buttonRect.top - 8}px`;
|
|
2597
|
+
portaledTooltip.style.transform = "translate(-50%, -100%)";
|
|
2598
|
+
|
|
2599
|
+
// Append to body
|
|
2600
|
+
document.body.appendChild(portaledTooltip);
|
|
2601
|
+
};
|
|
2602
|
+
|
|
2603
|
+
const hideTooltip = () => {
|
|
2604
|
+
if (portaledTooltip && portaledTooltip.parentNode) {
|
|
2605
|
+
portaledTooltip.parentNode.removeChild(portaledTooltip);
|
|
2606
|
+
portaledTooltip = null;
|
|
2607
|
+
}
|
|
2608
|
+
};
|
|
2609
|
+
|
|
2610
|
+
// Add event listeners
|
|
2611
|
+
clearChatButtonWrapper.addEventListener("mouseenter", showTooltip);
|
|
2612
|
+
clearChatButtonWrapper.addEventListener("mouseleave", hideTooltip);
|
|
2613
|
+
clearChatButton.addEventListener("focus", showTooltip);
|
|
2614
|
+
clearChatButton.addEventListener("blur", hideTooltip);
|
|
2615
|
+
|
|
2616
|
+
// Store cleanup function on the button for later use
|
|
2617
|
+
(clearChatButtonWrapper as any)._cleanupTooltip = () => {
|
|
2618
|
+
hideTooltip();
|
|
2619
|
+
if (clearChatButtonWrapper) {
|
|
2620
|
+
clearChatButtonWrapper.removeEventListener("mouseenter", showTooltip);
|
|
2621
|
+
clearChatButtonWrapper.removeEventListener("mouseleave", hideTooltip);
|
|
2622
|
+
}
|
|
2623
|
+
if (clearChatButton) {
|
|
2624
|
+
clearChatButton.removeEventListener("focus", showTooltip);
|
|
2625
|
+
clearChatButton.removeEventListener("blur", hideTooltip);
|
|
2626
|
+
}
|
|
2627
|
+
};
|
|
2628
|
+
}
|
|
2629
|
+
}
|
|
2630
|
+
}
|
|
2631
|
+
}
|
|
2632
|
+
|
|
2633
|
+
const nextParsers =
|
|
2634
|
+
config.actionParsers && config.actionParsers.length
|
|
2635
|
+
? config.actionParsers
|
|
2636
|
+
: [defaultJsonActionParser];
|
|
2637
|
+
const nextHandlers =
|
|
2638
|
+
config.actionHandlers && config.actionHandlers.length
|
|
2639
|
+
? config.actionHandlers
|
|
2640
|
+
: [defaultActionHandlers.message, defaultActionHandlers.messageAndClick];
|
|
2641
|
+
|
|
2642
|
+
actionManager = createActionManager({
|
|
2643
|
+
parsers: nextParsers,
|
|
2644
|
+
handlers: nextHandlers,
|
|
2645
|
+
getSessionMetadata,
|
|
2646
|
+
updateSessionMetadata,
|
|
2647
|
+
emit: eventBus.emit,
|
|
2648
|
+
documentRef: typeof document !== "undefined" ? document : null
|
|
2649
|
+
});
|
|
2650
|
+
|
|
2651
|
+
postprocess = buildPostprocessor(config, actionManager);
|
|
2652
|
+
session.updateConfig(config);
|
|
2653
|
+
renderMessagesWithPlugins(
|
|
2654
|
+
messagesWrapper,
|
|
2655
|
+
session.getMessages(),
|
|
2656
|
+
postprocess
|
|
2657
|
+
);
|
|
2658
|
+
suggestionsManager.render(config.suggestionChips, session, textarea, undefined, config.suggestionChipsConfig);
|
|
2659
|
+
updateCopy();
|
|
2660
|
+
setComposerDisabled(session.isStreaming());
|
|
2661
|
+
|
|
2662
|
+
// Update voice recognition mic button visibility
|
|
2663
|
+
const voiceRecognitionEnabled = config.voiceRecognition?.enabled === true;
|
|
2664
|
+
const hasSpeechRecognition =
|
|
2665
|
+
typeof window !== 'undefined' &&
|
|
2666
|
+
(typeof (window as any).webkitSpeechRecognition !== 'undefined' ||
|
|
2667
|
+
typeof (window as any).SpeechRecognition !== 'undefined');
|
|
2668
|
+
|
|
2669
|
+
if (voiceRecognitionEnabled && hasSpeechRecognition) {
|
|
2670
|
+
// Create or update mic button
|
|
2671
|
+
if (!micButton || !micButtonWrapper) {
|
|
2672
|
+
// Create new mic button
|
|
2673
|
+
const micButtonResult = createMicButton(config.voiceRecognition, config.sendButton);
|
|
2674
|
+
if (micButtonResult) {
|
|
2675
|
+
// Update the mutable references
|
|
2676
|
+
micButton = micButtonResult.micButton;
|
|
2677
|
+
micButtonWrapper = micButtonResult.micButtonWrapper;
|
|
2678
|
+
|
|
2679
|
+
// Insert into right actions before send button wrapper
|
|
2680
|
+
rightActions.insertBefore(micButtonWrapper, sendButtonWrapper);
|
|
2681
|
+
|
|
2682
|
+
// Wire up click handler
|
|
2683
|
+
micButton.addEventListener("click", handleMicButtonClick);
|
|
2684
|
+
|
|
2685
|
+
// Set disabled state
|
|
2686
|
+
micButton.disabled = session.isStreaming();
|
|
2687
|
+
}
|
|
2688
|
+
} else {
|
|
2689
|
+
// Update existing mic button with new config
|
|
2690
|
+
const voiceConfig = config.voiceRecognition ?? {};
|
|
2691
|
+
const sendButtonConfig = config.sendButton ?? {};
|
|
2692
|
+
|
|
2693
|
+
// Update icon name and size
|
|
2694
|
+
const micIconName = voiceConfig.iconName ?? "mic";
|
|
2695
|
+
const buttonSize = sendButtonConfig.size ?? "40px";
|
|
2696
|
+
const micIconSize = voiceConfig.iconSize ?? buttonSize;
|
|
2697
|
+
const micIconSizeNum = parseFloat(micIconSize) || 24;
|
|
2698
|
+
|
|
2699
|
+
micButton.style.width = micIconSize;
|
|
2700
|
+
micButton.style.height = micIconSize;
|
|
2701
|
+
micButton.style.minWidth = micIconSize;
|
|
2702
|
+
micButton.style.minHeight = micIconSize;
|
|
2703
|
+
|
|
2704
|
+
// Update icon
|
|
2705
|
+
const iconColor = voiceConfig.iconColor ?? sendButtonConfig.textColor ?? "currentColor";
|
|
2706
|
+
micButton.innerHTML = "";
|
|
2707
|
+
const micIconSvg = renderLucideIcon(micIconName, micIconSizeNum, iconColor, 2);
|
|
2708
|
+
if (micIconSvg) {
|
|
2709
|
+
micButton.appendChild(micIconSvg);
|
|
2710
|
+
} else {
|
|
2711
|
+
micButton.textContent = "🎤";
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2714
|
+
// Update colors
|
|
2715
|
+
const backgroundColor = voiceConfig.backgroundColor ?? sendButtonConfig.backgroundColor;
|
|
2716
|
+
if (backgroundColor) {
|
|
2717
|
+
micButton.style.backgroundColor = backgroundColor;
|
|
2718
|
+
micButton.classList.remove("tvw-bg-cw-primary");
|
|
2719
|
+
} else {
|
|
2720
|
+
micButton.style.backgroundColor = "";
|
|
2721
|
+
micButton.classList.add("tvw-bg-cw-primary");
|
|
2722
|
+
}
|
|
2723
|
+
|
|
2724
|
+
if (iconColor) {
|
|
2725
|
+
micButton.style.color = iconColor;
|
|
2726
|
+
micButton.classList.remove("tvw-text-white");
|
|
2727
|
+
} else if (!iconColor && !sendButtonConfig.textColor) {
|
|
2728
|
+
micButton.style.color = "";
|
|
2729
|
+
micButton.classList.add("tvw-text-white");
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2732
|
+
// Update border styling
|
|
2733
|
+
if (voiceConfig.borderWidth) {
|
|
2734
|
+
micButton.style.borderWidth = voiceConfig.borderWidth;
|
|
2735
|
+
micButton.style.borderStyle = "solid";
|
|
2736
|
+
} else {
|
|
2737
|
+
micButton.style.borderWidth = "";
|
|
2738
|
+
micButton.style.borderStyle = "";
|
|
2739
|
+
}
|
|
2740
|
+
if (voiceConfig.borderColor) {
|
|
2741
|
+
micButton.style.borderColor = voiceConfig.borderColor;
|
|
2742
|
+
} else {
|
|
2743
|
+
micButton.style.borderColor = "";
|
|
2744
|
+
}
|
|
2745
|
+
|
|
2746
|
+
// Update padding styling
|
|
2747
|
+
if (voiceConfig.paddingX) {
|
|
2748
|
+
micButton.style.paddingLeft = voiceConfig.paddingX;
|
|
2749
|
+
micButton.style.paddingRight = voiceConfig.paddingX;
|
|
2750
|
+
} else {
|
|
2751
|
+
micButton.style.paddingLeft = "";
|
|
2752
|
+
micButton.style.paddingRight = "";
|
|
2753
|
+
}
|
|
2754
|
+
if (voiceConfig.paddingY) {
|
|
2755
|
+
micButton.style.paddingTop = voiceConfig.paddingY;
|
|
2756
|
+
micButton.style.paddingBottom = voiceConfig.paddingY;
|
|
2757
|
+
} else {
|
|
2758
|
+
micButton.style.paddingTop = "";
|
|
2759
|
+
micButton.style.paddingBottom = "";
|
|
2760
|
+
}
|
|
2761
|
+
|
|
2762
|
+
// Update tooltip
|
|
2763
|
+
const tooltip = micButtonWrapper?.querySelector(".tvw-send-button-tooltip") as HTMLElement | null;
|
|
2764
|
+
const tooltipText = voiceConfig.tooltipText ?? "Start voice recognition";
|
|
2765
|
+
const showTooltip = voiceConfig.showTooltip ?? false;
|
|
2766
|
+
if (showTooltip && tooltipText) {
|
|
2767
|
+
if (!tooltip) {
|
|
2768
|
+
// Create tooltip if it doesn't exist
|
|
2769
|
+
const newTooltip = document.createElement("div");
|
|
2770
|
+
newTooltip.className = "tvw-send-button-tooltip";
|
|
2771
|
+
newTooltip.textContent = tooltipText;
|
|
2772
|
+
micButtonWrapper?.insertBefore(newTooltip, micButton);
|
|
2773
|
+
} else {
|
|
2774
|
+
tooltip.textContent = tooltipText;
|
|
2775
|
+
tooltip.style.display = "";
|
|
2776
|
+
}
|
|
2777
|
+
} else if (tooltip) {
|
|
2778
|
+
// Hide tooltip if disabled
|
|
2779
|
+
tooltip.style.display = "none";
|
|
2780
|
+
}
|
|
2781
|
+
|
|
2782
|
+
// Show and update disabled state
|
|
2783
|
+
micButtonWrapper.style.display = "";
|
|
2784
|
+
micButton.disabled = session.isStreaming();
|
|
2785
|
+
}
|
|
2786
|
+
} else {
|
|
2787
|
+
// Hide mic button
|
|
2788
|
+
if (micButton && micButtonWrapper) {
|
|
2789
|
+
micButtonWrapper.style.display = "none";
|
|
2790
|
+
// Stop any active recording if disabling
|
|
2791
|
+
if (isRecording) {
|
|
2792
|
+
stopVoiceRecognition();
|
|
2793
|
+
}
|
|
2794
|
+
}
|
|
2795
|
+
}
|
|
2796
|
+
|
|
2797
|
+
// Update attachment button visibility based on attachments config
|
|
2798
|
+
const attachmentsEnabled = config.attachments?.enabled === true;
|
|
2799
|
+
if (attachmentsEnabled) {
|
|
2800
|
+
// Create or show attachment button
|
|
2801
|
+
if (!attachmentButtonWrapper || !attachmentButton) {
|
|
2802
|
+
// Need to create the attachment elements dynamically
|
|
2803
|
+
const attachmentsConfig = config.attachments ?? {};
|
|
2804
|
+
const sendButtonConfig = config.sendButton ?? {};
|
|
2805
|
+
const buttonSize = sendButtonConfig.size ?? "40px";
|
|
2806
|
+
|
|
2807
|
+
// Create previews container if not exists
|
|
2808
|
+
if (!attachmentPreviewsContainer) {
|
|
2809
|
+
attachmentPreviewsContainer = createElement("div", "tvw-attachment-previews tvw-flex tvw-flex-wrap tvw-gap-2 tvw-mb-2");
|
|
2810
|
+
attachmentPreviewsContainer.style.display = "none";
|
|
2811
|
+
composerForm.insertBefore(attachmentPreviewsContainer, textarea);
|
|
2812
|
+
}
|
|
2813
|
+
|
|
2814
|
+
// Create file input if not exists
|
|
2815
|
+
if (!attachmentInput) {
|
|
2816
|
+
attachmentInput = document.createElement("input");
|
|
2817
|
+
attachmentInput.type = "file";
|
|
2818
|
+
attachmentInput.accept = (attachmentsConfig.allowedTypes ?? ALL_SUPPORTED_MIME_TYPES).join(",");
|
|
2819
|
+
attachmentInput.multiple = (attachmentsConfig.maxFiles ?? 4) > 1;
|
|
2820
|
+
attachmentInput.style.display = "none";
|
|
2821
|
+
attachmentInput.setAttribute("aria-label", "Attach files");
|
|
2822
|
+
composerForm.insertBefore(attachmentInput, textarea);
|
|
2823
|
+
}
|
|
2824
|
+
|
|
2825
|
+
// Create attachment button wrapper
|
|
2826
|
+
attachmentButtonWrapper = createElement("div", "tvw-send-button-wrapper");
|
|
2827
|
+
|
|
2828
|
+
// Create attachment button
|
|
2829
|
+
attachmentButton = createElement(
|
|
2830
|
+
"button",
|
|
2831
|
+
"tvw-rounded-button tvw-flex tvw-items-center tvw-justify-center disabled:tvw-opacity-50 tvw-cursor-pointer tvw-attachment-button"
|
|
2832
|
+
) as HTMLButtonElement;
|
|
2833
|
+
attachmentButton.type = "button";
|
|
2834
|
+
attachmentButton.setAttribute("aria-label", attachmentsConfig.buttonTooltipText ?? "Attach file");
|
|
2835
|
+
|
|
2836
|
+
// Default to paperclip icon
|
|
2837
|
+
const attachIconName = attachmentsConfig.buttonIconName ?? "paperclip";
|
|
2838
|
+
const attachIconSize = buttonSize;
|
|
2839
|
+
const buttonSizeNum = parseFloat(attachIconSize) || 40;
|
|
2840
|
+
// Icon should be ~60% of button size to match other icons visually
|
|
2841
|
+
const attachIconSizeNum = Math.round(buttonSizeNum * 0.6);
|
|
2842
|
+
|
|
2843
|
+
attachmentButton.style.width = attachIconSize;
|
|
2844
|
+
attachmentButton.style.height = attachIconSize;
|
|
2845
|
+
attachmentButton.style.minWidth = attachIconSize;
|
|
2846
|
+
attachmentButton.style.minHeight = attachIconSize;
|
|
2847
|
+
attachmentButton.style.fontSize = "18px";
|
|
2848
|
+
attachmentButton.style.lineHeight = "1";
|
|
2849
|
+
attachmentButton.style.backgroundColor = "transparent";
|
|
2850
|
+
attachmentButton.style.color = "var(--cw-primary, #111827)";
|
|
2851
|
+
attachmentButton.style.border = "none";
|
|
2852
|
+
attachmentButton.style.borderRadius = "6px";
|
|
2853
|
+
attachmentButton.style.transition = "background-color 0.15s ease";
|
|
2854
|
+
|
|
2855
|
+
// Add hover effect via mouseenter/mouseleave
|
|
2856
|
+
attachmentButton.addEventListener("mouseenter", () => {
|
|
2857
|
+
attachmentButton!.style.backgroundColor = "rgba(0, 0, 0, 0.05)";
|
|
2858
|
+
});
|
|
2859
|
+
attachmentButton.addEventListener("mouseleave", () => {
|
|
2860
|
+
attachmentButton!.style.backgroundColor = "transparent";
|
|
2861
|
+
});
|
|
2862
|
+
|
|
2863
|
+
const attachIconSvg = renderLucideIcon(attachIconName, attachIconSizeNum, "currentColor", 1.5);
|
|
2864
|
+
if (attachIconSvg) {
|
|
2865
|
+
attachmentButton.appendChild(attachIconSvg);
|
|
2866
|
+
} else {
|
|
2867
|
+
attachmentButton.textContent = "📎";
|
|
2868
|
+
}
|
|
2869
|
+
|
|
2870
|
+
attachmentButton.addEventListener("click", (e) => {
|
|
2871
|
+
e.preventDefault();
|
|
2872
|
+
attachmentInput?.click();
|
|
2873
|
+
});
|
|
2874
|
+
|
|
2875
|
+
attachmentButtonWrapper.appendChild(attachmentButton);
|
|
2876
|
+
|
|
2877
|
+
// Add tooltip
|
|
2878
|
+
const attachTooltipText = attachmentsConfig.buttonTooltipText ?? "Attach file";
|
|
2879
|
+
const tooltip = createElement("div", "tvw-send-button-tooltip");
|
|
2880
|
+
tooltip.textContent = attachTooltipText;
|
|
2881
|
+
attachmentButtonWrapper.appendChild(tooltip);
|
|
2882
|
+
|
|
2883
|
+
// Insert into left actions container
|
|
2884
|
+
leftActions.append(attachmentButtonWrapper);
|
|
2885
|
+
|
|
2886
|
+
// Initialize attachment manager
|
|
2887
|
+
if (!attachmentManager && attachmentInput && attachmentPreviewsContainer) {
|
|
2888
|
+
attachmentManager = AttachmentManager.fromConfig(attachmentsConfig);
|
|
2889
|
+
attachmentManager.setPreviewsContainer(attachmentPreviewsContainer);
|
|
2890
|
+
|
|
2891
|
+
attachmentInput.addEventListener("change", async () => {
|
|
2892
|
+
if (attachmentManager && attachmentInput?.files) {
|
|
2893
|
+
await attachmentManager.handleFileSelect(attachmentInput.files);
|
|
2894
|
+
attachmentInput.value = "";
|
|
2895
|
+
}
|
|
2896
|
+
});
|
|
2897
|
+
}
|
|
2898
|
+
} else {
|
|
2899
|
+
// Show existing attachment button and update config
|
|
2900
|
+
attachmentButtonWrapper.style.display = "";
|
|
2901
|
+
|
|
2902
|
+
// Update file input accept attribute when config changes
|
|
2903
|
+
const attachmentsConfig = config.attachments ?? {};
|
|
2904
|
+
if (attachmentInput) {
|
|
2905
|
+
attachmentInput.accept = (attachmentsConfig.allowedTypes ?? ALL_SUPPORTED_MIME_TYPES).join(",");
|
|
2906
|
+
attachmentInput.multiple = (attachmentsConfig.maxFiles ?? 4) > 1;
|
|
2907
|
+
}
|
|
2908
|
+
|
|
2909
|
+
// Update attachment manager config
|
|
2910
|
+
if (attachmentManager) {
|
|
2911
|
+
attachmentManager.updateConfig({
|
|
2912
|
+
allowedTypes: attachmentsConfig.allowedTypes,
|
|
2913
|
+
maxFileSize: attachmentsConfig.maxFileSize,
|
|
2914
|
+
maxFiles: attachmentsConfig.maxFiles
|
|
2915
|
+
});
|
|
2916
|
+
}
|
|
2917
|
+
}
|
|
2918
|
+
} else {
|
|
2919
|
+
// Hide attachment button if disabled
|
|
2920
|
+
if (attachmentButtonWrapper) {
|
|
2921
|
+
attachmentButtonWrapper.style.display = "none";
|
|
2922
|
+
}
|
|
2923
|
+
// Clear any pending attachments
|
|
2924
|
+
if (attachmentManager) {
|
|
2925
|
+
attachmentManager.clearAttachments();
|
|
2926
|
+
}
|
|
2927
|
+
}
|
|
2928
|
+
|
|
2929
|
+
// Update send button styling
|
|
2930
|
+
const sendButtonConfig = config.sendButton ?? {};
|
|
2931
|
+
const useIcon = sendButtonConfig.useIcon ?? false;
|
|
2932
|
+
const iconText = sendButtonConfig.iconText ?? "↑";
|
|
2933
|
+
const iconName = sendButtonConfig.iconName;
|
|
2934
|
+
const tooltipText = sendButtonConfig.tooltipText ?? "Send message";
|
|
2935
|
+
const showTooltip = sendButtonConfig.showTooltip ?? false;
|
|
2936
|
+
const buttonSize = sendButtonConfig.size ?? "40px";
|
|
2937
|
+
const backgroundColor = sendButtonConfig.backgroundColor;
|
|
2938
|
+
const textColor = sendButtonConfig.textColor;
|
|
2939
|
+
|
|
2940
|
+
// Update button content and styling based on mode
|
|
2941
|
+
if (useIcon) {
|
|
2942
|
+
// Icon mode: circular button
|
|
2943
|
+
sendButton.style.width = buttonSize;
|
|
2944
|
+
sendButton.style.height = buttonSize;
|
|
2945
|
+
sendButton.style.minWidth = buttonSize;
|
|
2946
|
+
sendButton.style.minHeight = buttonSize;
|
|
2947
|
+
sendButton.style.fontSize = "18px";
|
|
2948
|
+
sendButton.style.lineHeight = "1";
|
|
2949
|
+
|
|
2950
|
+
// Clear existing content
|
|
2951
|
+
sendButton.innerHTML = "";
|
|
2952
|
+
|
|
2953
|
+
// Use Lucide icon if iconName is provided, otherwise fall back to iconText
|
|
2954
|
+
if (iconName) {
|
|
2955
|
+
const iconSize = parseFloat(buttonSize) || 24;
|
|
2956
|
+
const iconColor = textColor && typeof textColor === 'string' && textColor.trim() ? textColor.trim() : "currentColor";
|
|
2957
|
+
const iconSvg = renderLucideIcon(iconName, iconSize, iconColor, 2);
|
|
2958
|
+
if (iconSvg) {
|
|
2959
|
+
sendButton.appendChild(iconSvg);
|
|
2960
|
+
sendButton.style.color = iconColor;
|
|
2961
|
+
} else {
|
|
2962
|
+
// Fallback to text if icon fails to render
|
|
2963
|
+
sendButton.textContent = iconText;
|
|
2964
|
+
if (textColor) {
|
|
2965
|
+
sendButton.style.color = textColor;
|
|
2966
|
+
} else {
|
|
2967
|
+
sendButton.classList.add("tvw-text-white");
|
|
2968
|
+
}
|
|
2969
|
+
}
|
|
2970
|
+
} else {
|
|
2971
|
+
sendButton.textContent = iconText;
|
|
2972
|
+
if (textColor) {
|
|
2973
|
+
sendButton.style.color = textColor;
|
|
2974
|
+
} else {
|
|
2975
|
+
sendButton.classList.add("tvw-text-white");
|
|
2976
|
+
}
|
|
2977
|
+
}
|
|
2978
|
+
|
|
2979
|
+
// Update classes
|
|
2980
|
+
sendButton.className = "tvw-rounded-button tvw-flex tvw-items-center tvw-justify-center disabled:tvw-opacity-50 tvw-cursor-pointer";
|
|
2981
|
+
|
|
2982
|
+
if (backgroundColor) {
|
|
2983
|
+
sendButton.style.backgroundColor = backgroundColor;
|
|
2984
|
+
sendButton.classList.remove("tvw-bg-cw-primary");
|
|
2985
|
+
} else {
|
|
2986
|
+
sendButton.classList.add("tvw-bg-cw-primary");
|
|
2987
|
+
}
|
|
2988
|
+
} else {
|
|
2989
|
+
// Text mode: existing behavior
|
|
2990
|
+
sendButton.textContent = config.copy?.sendButtonLabel ?? "Send";
|
|
2991
|
+
sendButton.style.width = "";
|
|
2992
|
+
sendButton.style.height = "";
|
|
2993
|
+
sendButton.style.minWidth = "";
|
|
2994
|
+
sendButton.style.minHeight = "";
|
|
2995
|
+
sendButton.style.fontSize = "";
|
|
2996
|
+
sendButton.style.lineHeight = "";
|
|
2997
|
+
|
|
2998
|
+
// Update classes
|
|
2999
|
+
sendButton.className = "tvw-rounded-button tvw-bg-cw-accent tvw-px-4 tvw-py-2 tvw-text-sm tvw-font-semibold tvw-text-white disabled:tvw-opacity-50 tvw-cursor-pointer";
|
|
3000
|
+
|
|
3001
|
+
if (backgroundColor) {
|
|
3002
|
+
sendButton.style.backgroundColor = backgroundColor;
|
|
3003
|
+
sendButton.classList.remove("tvw-bg-cw-accent");
|
|
3004
|
+
} else {
|
|
3005
|
+
sendButton.classList.add("tvw-bg-cw-accent");
|
|
3006
|
+
}
|
|
3007
|
+
|
|
3008
|
+
if (textColor) {
|
|
3009
|
+
sendButton.style.color = textColor;
|
|
3010
|
+
} else {
|
|
3011
|
+
sendButton.classList.add("tvw-text-white");
|
|
3012
|
+
}
|
|
3013
|
+
}
|
|
3014
|
+
|
|
3015
|
+
// Apply border styling
|
|
3016
|
+
if (sendButtonConfig.borderWidth) {
|
|
3017
|
+
sendButton.style.borderWidth = sendButtonConfig.borderWidth;
|
|
3018
|
+
sendButton.style.borderStyle = "solid";
|
|
3019
|
+
} else {
|
|
3020
|
+
sendButton.style.borderWidth = "";
|
|
3021
|
+
sendButton.style.borderStyle = "";
|
|
3022
|
+
}
|
|
3023
|
+
if (sendButtonConfig.borderColor) {
|
|
3024
|
+
sendButton.style.borderColor = sendButtonConfig.borderColor;
|
|
3025
|
+
} else {
|
|
3026
|
+
sendButton.style.borderColor = "";
|
|
3027
|
+
}
|
|
3028
|
+
|
|
3029
|
+
// Apply padding styling (works in both icon and text mode)
|
|
3030
|
+
if (sendButtonConfig.paddingX) {
|
|
3031
|
+
sendButton.style.paddingLeft = sendButtonConfig.paddingX;
|
|
3032
|
+
sendButton.style.paddingRight = sendButtonConfig.paddingX;
|
|
3033
|
+
} else {
|
|
3034
|
+
sendButton.style.paddingLeft = "";
|
|
3035
|
+
sendButton.style.paddingRight = "";
|
|
3036
|
+
}
|
|
3037
|
+
if (sendButtonConfig.paddingY) {
|
|
3038
|
+
sendButton.style.paddingTop = sendButtonConfig.paddingY;
|
|
3039
|
+
sendButton.style.paddingBottom = sendButtonConfig.paddingY;
|
|
3040
|
+
} else {
|
|
3041
|
+
sendButton.style.paddingTop = "";
|
|
3042
|
+
sendButton.style.paddingBottom = "";
|
|
3043
|
+
}
|
|
3044
|
+
|
|
3045
|
+
// Update tooltip
|
|
3046
|
+
const tooltip = sendButtonWrapper?.querySelector(".tvw-send-button-tooltip") as HTMLElement | null;
|
|
3047
|
+
if (showTooltip && tooltipText) {
|
|
3048
|
+
if (!tooltip) {
|
|
3049
|
+
// Create tooltip if it doesn't exist
|
|
3050
|
+
const newTooltip = document.createElement("div");
|
|
3051
|
+
newTooltip.className = "tvw-send-button-tooltip";
|
|
3052
|
+
newTooltip.textContent = tooltipText;
|
|
3053
|
+
sendButtonWrapper?.insertBefore(newTooltip, sendButton);
|
|
3054
|
+
} else {
|
|
3055
|
+
tooltip.textContent = tooltipText;
|
|
3056
|
+
tooltip.style.display = "";
|
|
3057
|
+
}
|
|
3058
|
+
} else if (tooltip) {
|
|
3059
|
+
tooltip.style.display = "none";
|
|
3060
|
+
}
|
|
3061
|
+
|
|
3062
|
+
// Update status indicator visibility and text
|
|
3063
|
+
const statusIndicatorConfig = config.statusIndicator ?? {};
|
|
3064
|
+
const isVisible = statusIndicatorConfig.visible ?? true;
|
|
3065
|
+
statusText.style.display = isVisible ? "" : "none";
|
|
3066
|
+
|
|
3067
|
+
// Update status text if status is currently set
|
|
3068
|
+
if (session) {
|
|
3069
|
+
const currentStatus = session.getStatus();
|
|
3070
|
+
const getCurrentStatusText = (status: AgentWidgetSessionStatus): string => {
|
|
3071
|
+
if (status === "idle") return statusIndicatorConfig.idleText ?? statusCopy.idle;
|
|
3072
|
+
if (status === "connecting") return statusIndicatorConfig.connectingText ?? statusCopy.connecting;
|
|
3073
|
+
if (status === "connected") return statusIndicatorConfig.connectedText ?? statusCopy.connected;
|
|
3074
|
+
if (status === "error") return statusIndicatorConfig.errorText ?? statusCopy.error;
|
|
3075
|
+
return statusCopy[status];
|
|
3076
|
+
};
|
|
3077
|
+
statusText.textContent = getCurrentStatusText(currentStatus);
|
|
3078
|
+
}
|
|
3079
|
+
},
|
|
3080
|
+
open() {
|
|
3081
|
+
if (!launcherEnabled) return;
|
|
3082
|
+
setOpenState(true, "api");
|
|
3083
|
+
},
|
|
3084
|
+
close() {
|
|
3085
|
+
if (!launcherEnabled) return;
|
|
3086
|
+
setOpenState(false, "api");
|
|
3087
|
+
},
|
|
3088
|
+
toggle() {
|
|
3089
|
+
if (!launcherEnabled) return;
|
|
3090
|
+
setOpenState(!open, "api");
|
|
3091
|
+
},
|
|
3092
|
+
clearChat() {
|
|
3093
|
+
// Clear messages in session (this will trigger onMessagesChanged which re-renders)
|
|
3094
|
+
session.clearMessages();
|
|
3095
|
+
|
|
3096
|
+
// Always clear the default localStorage key
|
|
3097
|
+
try {
|
|
3098
|
+
localStorage.removeItem(DEFAULT_CHAT_HISTORY_STORAGE_KEY);
|
|
3099
|
+
if (config.debug) {
|
|
3100
|
+
console.log(`[AgentWidget] Cleared default localStorage key: ${DEFAULT_CHAT_HISTORY_STORAGE_KEY}`);
|
|
3101
|
+
}
|
|
3102
|
+
} catch (error) {
|
|
3103
|
+
console.error("[AgentWidget] Failed to clear default localStorage:", error);
|
|
3104
|
+
}
|
|
3105
|
+
|
|
3106
|
+
// Also clear custom localStorage key if configured
|
|
3107
|
+
if (config.clearChatHistoryStorageKey && config.clearChatHistoryStorageKey !== DEFAULT_CHAT_HISTORY_STORAGE_KEY) {
|
|
3108
|
+
try {
|
|
3109
|
+
localStorage.removeItem(config.clearChatHistoryStorageKey);
|
|
3110
|
+
if (config.debug) {
|
|
3111
|
+
console.log(`[AgentWidget] Cleared custom localStorage key: ${config.clearChatHistoryStorageKey}`);
|
|
3112
|
+
}
|
|
3113
|
+
} catch (error) {
|
|
3114
|
+
console.error("[AgentWidget] Failed to clear custom localStorage:", error);
|
|
3115
|
+
}
|
|
3116
|
+
}
|
|
3117
|
+
|
|
3118
|
+
// Dispatch custom event for external handlers (e.g., localStorage clearing in examples)
|
|
3119
|
+
const clearEvent = new CustomEvent("persona:clear-chat", {
|
|
3120
|
+
detail: { timestamp: new Date().toISOString() }
|
|
3121
|
+
});
|
|
3122
|
+
window.dispatchEvent(clearEvent);
|
|
3123
|
+
|
|
3124
|
+
if (storageAdapter?.clear) {
|
|
3125
|
+
try {
|
|
3126
|
+
const result = storageAdapter.clear();
|
|
3127
|
+
if (result instanceof Promise) {
|
|
3128
|
+
result.catch((error) => {
|
|
3129
|
+
if (typeof console !== "undefined") {
|
|
3130
|
+
// eslint-disable-next-line no-console
|
|
3131
|
+
console.error("[AgentWidget] Failed to clear storage adapter:", error);
|
|
3132
|
+
}
|
|
3133
|
+
});
|
|
3134
|
+
}
|
|
3135
|
+
} catch (error) {
|
|
3136
|
+
if (typeof console !== "undefined") {
|
|
3137
|
+
// eslint-disable-next-line no-console
|
|
3138
|
+
console.error("[AgentWidget] Failed to clear storage adapter:", error);
|
|
3139
|
+
}
|
|
3140
|
+
}
|
|
3141
|
+
}
|
|
3142
|
+
persistentMetadata = {};
|
|
3143
|
+
actionManager.syncFromMetadata();
|
|
3144
|
+
},
|
|
3145
|
+
setMessage(message: string): boolean {
|
|
3146
|
+
if (!textarea) return false;
|
|
3147
|
+
if (session.isStreaming()) return false;
|
|
3148
|
+
|
|
3149
|
+
// Auto-open widget if closed and launcher is enabled
|
|
3150
|
+
if (!open && launcherEnabled) {
|
|
3151
|
+
setOpenState(true, "system");
|
|
3152
|
+
}
|
|
3153
|
+
|
|
3154
|
+
textarea.value = message;
|
|
3155
|
+
// Trigger input event for any listeners
|
|
3156
|
+
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
|
3157
|
+
return true;
|
|
3158
|
+
},
|
|
3159
|
+
submitMessage(message?: string): boolean {
|
|
3160
|
+
if (session.isStreaming()) return false;
|
|
3161
|
+
|
|
3162
|
+
const valueToSubmit = message?.trim() || textarea.value.trim();
|
|
3163
|
+
if (!valueToSubmit) return false;
|
|
3164
|
+
|
|
3165
|
+
// Auto-open widget if closed and launcher is enabled
|
|
3166
|
+
if (!open && launcherEnabled) {
|
|
3167
|
+
setOpenState(true, "system");
|
|
3168
|
+
}
|
|
3169
|
+
|
|
3170
|
+
textarea.value = "";
|
|
3171
|
+
textarea.style.height = "auto"; // Reset height after clearing
|
|
3172
|
+
session.sendMessage(valueToSubmit);
|
|
3173
|
+
return true;
|
|
3174
|
+
},
|
|
3175
|
+
startVoiceRecognition(): boolean {
|
|
3176
|
+
if (isRecording || session.isStreaming()) return false;
|
|
3177
|
+
|
|
3178
|
+
const SpeechRecognitionClass = getSpeechRecognitionClass();
|
|
3179
|
+
if (!SpeechRecognitionClass) return false;
|
|
3180
|
+
|
|
3181
|
+
// Auto-open widget if closed and launcher is enabled
|
|
3182
|
+
if (!open && launcherEnabled) {
|
|
3183
|
+
setOpenState(true, "system");
|
|
3184
|
+
}
|
|
3185
|
+
|
|
3186
|
+
voiceState.manuallyDeactivated = false;
|
|
3187
|
+
persistVoiceMetadata();
|
|
3188
|
+
startVoiceRecognition("user");
|
|
3189
|
+
return true;
|
|
3190
|
+
},
|
|
3191
|
+
stopVoiceRecognition(): boolean {
|
|
3192
|
+
if (!isRecording) return false;
|
|
3193
|
+
|
|
3194
|
+
voiceState.manuallyDeactivated = true;
|
|
3195
|
+
persistVoiceMetadata();
|
|
3196
|
+
stopVoiceRecognition("user");
|
|
3197
|
+
return true;
|
|
3198
|
+
},
|
|
3199
|
+
injectTestMessage(event: AgentWidgetEvent) {
|
|
3200
|
+
// Auto-open widget if closed and launcher is enabled
|
|
3201
|
+
if (!open && launcherEnabled) {
|
|
3202
|
+
setOpenState(true, "system");
|
|
3203
|
+
}
|
|
3204
|
+
session.injectTestEvent(event);
|
|
3205
|
+
},
|
|
3206
|
+
getMessages() {
|
|
3207
|
+
return session.getMessages();
|
|
3208
|
+
},
|
|
3209
|
+
getStatus() {
|
|
3210
|
+
return session.getStatus();
|
|
3211
|
+
},
|
|
3212
|
+
getPersistentMetadata() {
|
|
3213
|
+
return { ...persistentMetadata };
|
|
3214
|
+
},
|
|
3215
|
+
updatePersistentMetadata(
|
|
3216
|
+
updater: (prev: Record<string, unknown>) => Record<string, unknown>
|
|
3217
|
+
) {
|
|
3218
|
+
updateSessionMetadata(updater);
|
|
3219
|
+
},
|
|
3220
|
+
on(event, handler) {
|
|
3221
|
+
return eventBus.on(event, handler);
|
|
3222
|
+
},
|
|
3223
|
+
off(event, handler) {
|
|
3224
|
+
eventBus.off(event, handler);
|
|
3225
|
+
},
|
|
3226
|
+
// State query methods
|
|
3227
|
+
isOpen(): boolean {
|
|
3228
|
+
return launcherEnabled && open;
|
|
3229
|
+
},
|
|
3230
|
+
isVoiceActive(): boolean {
|
|
3231
|
+
return voiceState.active;
|
|
3232
|
+
},
|
|
3233
|
+
getState(): AgentWidgetStateSnapshot {
|
|
3234
|
+
return {
|
|
3235
|
+
open: launcherEnabled && open,
|
|
3236
|
+
launcherEnabled,
|
|
3237
|
+
voiceActive: voiceState.active,
|
|
3238
|
+
streaming: session.isStreaming()
|
|
3239
|
+
};
|
|
3240
|
+
},
|
|
3241
|
+
// Feedback methods (CSAT/NPS)
|
|
3242
|
+
showCSATFeedback(options?: Partial<CSATFeedbackOptions>) {
|
|
3243
|
+
// Auto-open widget if closed and launcher is enabled
|
|
3244
|
+
if (!open && launcherEnabled) {
|
|
3245
|
+
setOpenState(true, "system");
|
|
3246
|
+
}
|
|
3247
|
+
|
|
3248
|
+
// Remove any existing feedback forms
|
|
3249
|
+
const existingFeedback = messagesWrapper.querySelector('.tvw-feedback-container');
|
|
3250
|
+
if (existingFeedback) {
|
|
3251
|
+
existingFeedback.remove();
|
|
3252
|
+
}
|
|
3253
|
+
|
|
3254
|
+
const feedbackEl = createCSATFeedback({
|
|
3255
|
+
onSubmit: async (rating, comment) => {
|
|
3256
|
+
if (session.isClientTokenMode()) {
|
|
3257
|
+
await session.submitCSATFeedback(rating, comment);
|
|
3258
|
+
}
|
|
3259
|
+
options?.onSubmit?.(rating, comment);
|
|
3260
|
+
},
|
|
3261
|
+
onDismiss: options?.onDismiss,
|
|
3262
|
+
...options,
|
|
3263
|
+
});
|
|
3264
|
+
|
|
3265
|
+
// Append to messages area at the bottom
|
|
3266
|
+
messagesWrapper.appendChild(feedbackEl);
|
|
3267
|
+
feedbackEl.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
|
3268
|
+
},
|
|
3269
|
+
showNPSFeedback(options?: Partial<NPSFeedbackOptions>) {
|
|
3270
|
+
// Auto-open widget if closed and launcher is enabled
|
|
3271
|
+
if (!open && launcherEnabled) {
|
|
3272
|
+
setOpenState(true, "system");
|
|
3273
|
+
}
|
|
3274
|
+
|
|
3275
|
+
// Remove any existing feedback forms
|
|
3276
|
+
const existingFeedback = messagesWrapper.querySelector('.tvw-feedback-container');
|
|
3277
|
+
if (existingFeedback) {
|
|
3278
|
+
existingFeedback.remove();
|
|
3279
|
+
}
|
|
3280
|
+
|
|
3281
|
+
const feedbackEl = createNPSFeedback({
|
|
3282
|
+
onSubmit: async (rating, comment) => {
|
|
3283
|
+
if (session.isClientTokenMode()) {
|
|
3284
|
+
await session.submitNPSFeedback(rating, comment);
|
|
3285
|
+
}
|
|
3286
|
+
options?.onSubmit?.(rating, comment);
|
|
3287
|
+
},
|
|
3288
|
+
onDismiss: options?.onDismiss,
|
|
3289
|
+
...options,
|
|
3290
|
+
});
|
|
3291
|
+
|
|
3292
|
+
// Append to messages area at the bottom
|
|
3293
|
+
messagesWrapper.appendChild(feedbackEl);
|
|
3294
|
+
feedbackEl.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
|
3295
|
+
},
|
|
3296
|
+
async submitCSATFeedback(rating: number, comment?: string): Promise<void> {
|
|
3297
|
+
return session.submitCSATFeedback(rating, comment);
|
|
3298
|
+
},
|
|
3299
|
+
async submitNPSFeedback(rating: number, comment?: string): Promise<void> {
|
|
3300
|
+
return session.submitNPSFeedback(rating, comment);
|
|
3301
|
+
},
|
|
3302
|
+
destroy() {
|
|
3303
|
+
destroyCallbacks.forEach((cb) => cb());
|
|
3304
|
+
wrapper.remove();
|
|
3305
|
+
launcherButtonInstance?.destroy();
|
|
3306
|
+
customLauncherElement?.remove();
|
|
3307
|
+
if (closeHandler) {
|
|
3308
|
+
closeButton.removeEventListener("click", closeHandler);
|
|
3309
|
+
}
|
|
3310
|
+
}
|
|
3311
|
+
};
|
|
3312
|
+
|
|
3313
|
+
const shouldExposeDebugApi =
|
|
3314
|
+
(runtimeOptions?.debugTools ?? false) || Boolean(config.debug);
|
|
3315
|
+
|
|
3316
|
+
if (shouldExposeDebugApi && typeof window !== "undefined") {
|
|
3317
|
+
const previousDebug = (window as any).AgentWidgetBrowser;
|
|
3318
|
+
const debugApi = {
|
|
3319
|
+
controller,
|
|
3320
|
+
getMessages: controller.getMessages,
|
|
3321
|
+
getStatus: controller.getStatus,
|
|
3322
|
+
getMetadata: controller.getPersistentMetadata,
|
|
3323
|
+
updateMetadata: controller.updatePersistentMetadata,
|
|
3324
|
+
clearHistory: () => controller.clearChat(),
|
|
3325
|
+
setVoiceActive: (active: boolean) =>
|
|
3326
|
+
active
|
|
3327
|
+
? controller.startVoiceRecognition()
|
|
3328
|
+
: controller.stopVoiceRecognition()
|
|
3329
|
+
};
|
|
3330
|
+
(window as any).AgentWidgetBrowser = debugApi;
|
|
3331
|
+
destroyCallbacks.push(() => {
|
|
3332
|
+
if ((window as any).AgentWidgetBrowser === debugApi) {
|
|
3333
|
+
(window as any).AgentWidgetBrowser = previousDebug;
|
|
3334
|
+
}
|
|
3335
|
+
});
|
|
3336
|
+
}
|
|
3337
|
+
|
|
3338
|
+
return controller;
|
|
3339
|
+
};
|
|
3340
|
+
|
|
3341
|
+
export type AgentWidgetController = Controller;
|