vanilla-agent 1.9.0 → 1.11.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 +303 -8
- package/dist/index.cjs +46 -7
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +581 -1
- package/dist/index.d.ts +581 -1
- package/dist/index.global.js +69 -30
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +46 -7
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +141 -12
- package/src/components/composer-builder.ts +366 -0
- package/src/components/forms.ts +1 -0
- package/src/components/header-builder.ts +454 -0
- package/src/components/header-layouts.ts +303 -0
- package/src/components/message-bubble.ts +251 -34
- package/src/components/panel.ts +46 -684
- package/src/components/registry.ts +87 -0
- package/src/defaults.ts +49 -1
- package/src/index.ts +64 -2
- package/src/plugins/registry.ts +1 -0
- package/src/plugins/types.ts +1 -0
- package/src/runtime/init.ts +26 -0
- package/src/types.ts +381 -0
- package/src/ui.ts +521 -40
- package/src/utils/component-middleware.ts +137 -0
- package/src/utils/component-parser.ts +119 -0
- package/src/utils/constants.ts +1 -0
- package/src/utils/dom.ts +1 -0
- package/src/utils/formatting.ts +33 -8
- package/src/utils/positioning.ts +1 -0
- package/src/utils/theme.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vanilla-agent",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.0",
|
|
4
4
|
"description": "Themeable, plugable streaming agent widget for websites, in plain JS with support for voice input and reasoning / tool output.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.cjs",
|
package/src/client.ts
CHANGED
|
@@ -5,7 +5,11 @@ import {
|
|
|
5
5
|
AgentWidgetStreamParser,
|
|
6
6
|
AgentWidgetContextProvider,
|
|
7
7
|
AgentWidgetRequestMiddleware,
|
|
8
|
-
AgentWidgetRequestPayload
|
|
8
|
+
AgentWidgetRequestPayload,
|
|
9
|
+
AgentWidgetCustomFetch,
|
|
10
|
+
AgentWidgetSSEEventParser,
|
|
11
|
+
AgentWidgetHeadersFunction,
|
|
12
|
+
AgentWidgetSSEEventResult
|
|
9
13
|
} from "./types";
|
|
10
14
|
import {
|
|
11
15
|
extractTextFromJson,
|
|
@@ -48,6 +52,9 @@ export class AgentWidgetClient {
|
|
|
48
52
|
private readonly createStreamParser: () => AgentWidgetStreamParser;
|
|
49
53
|
private readonly contextProviders: AgentWidgetContextProvider[];
|
|
50
54
|
private readonly requestMiddleware?: AgentWidgetRequestMiddleware;
|
|
55
|
+
private readonly customFetch?: AgentWidgetCustomFetch;
|
|
56
|
+
private readonly parseSSEEvent?: AgentWidgetSSEEventParser;
|
|
57
|
+
private readonly getHeaders?: AgentWidgetHeadersFunction;
|
|
51
58
|
|
|
52
59
|
constructor(private config: AgentWidgetConfig = {}) {
|
|
53
60
|
this.apiUrl = config.apiUrl ?? DEFAULT_ENDPOINT;
|
|
@@ -60,6 +67,9 @@ export class AgentWidgetClient {
|
|
|
60
67
|
this.createStreamParser = config.streamParser ?? getParserFromType(config.parserType);
|
|
61
68
|
this.contextProviders = config.contextProviders ?? [];
|
|
62
69
|
this.requestMiddleware = config.requestMiddleware;
|
|
70
|
+
this.customFetch = config.customFetch;
|
|
71
|
+
this.parseSSEEvent = config.parseSSEEvent;
|
|
72
|
+
this.getHeaders = config.getHeaders;
|
|
63
73
|
}
|
|
64
74
|
|
|
65
75
|
public async dispatch(options: DispatchOptions, onEvent: SSEHandler) {
|
|
@@ -70,19 +80,54 @@ export class AgentWidgetClient {
|
|
|
70
80
|
|
|
71
81
|
onEvent({ type: "status", status: "connecting" });
|
|
72
82
|
|
|
73
|
-
const
|
|
83
|
+
const payload = await this.buildPayload(options.messages);
|
|
74
84
|
|
|
75
85
|
if (this.debug) {
|
|
76
86
|
// eslint-disable-next-line no-console
|
|
77
|
-
console.debug("[AgentWidgetClient] dispatch
|
|
87
|
+
console.debug("[AgentWidgetClient] dispatch payload", payload);
|
|
78
88
|
}
|
|
79
89
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
90
|
+
// Build headers - merge static headers with dynamic headers if provided
|
|
91
|
+
let headers = { ...this.headers };
|
|
92
|
+
if (this.getHeaders) {
|
|
93
|
+
try {
|
|
94
|
+
const dynamicHeaders = await this.getHeaders();
|
|
95
|
+
headers = { ...headers, ...dynamicHeaders };
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (typeof console !== "undefined") {
|
|
98
|
+
// eslint-disable-next-line no-console
|
|
99
|
+
console.error("[AgentWidget] getHeaders error:", error);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Use customFetch if provided, otherwise use default fetch
|
|
105
|
+
let response: Response;
|
|
106
|
+
if (this.customFetch) {
|
|
107
|
+
try {
|
|
108
|
+
response = await this.customFetch(
|
|
109
|
+
this.apiUrl,
|
|
110
|
+
{
|
|
111
|
+
method: "POST",
|
|
112
|
+
headers,
|
|
113
|
+
body: JSON.stringify(payload),
|
|
114
|
+
signal: controller.signal
|
|
115
|
+
},
|
|
116
|
+
payload
|
|
117
|
+
);
|
|
118
|
+
} catch (error) {
|
|
119
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
120
|
+
onEvent({ type: "error", error: err });
|
|
121
|
+
throw err;
|
|
122
|
+
}
|
|
123
|
+
} else {
|
|
124
|
+
response = await fetch(this.apiUrl, {
|
|
125
|
+
method: "POST",
|
|
126
|
+
headers,
|
|
127
|
+
body: JSON.stringify(payload),
|
|
128
|
+
signal: controller.signal
|
|
129
|
+
});
|
|
130
|
+
}
|
|
86
131
|
|
|
87
132
|
if (!response.ok || !response.body) {
|
|
88
133
|
const error = new Error(
|
|
@@ -112,7 +157,7 @@ export class AgentWidgetClient {
|
|
|
112
157
|
})
|
|
113
158
|
.map((message) => ({
|
|
114
159
|
role: message.role,
|
|
115
|
-
content: message.content,
|
|
160
|
+
content: message.rawContent || message.content,
|
|
116
161
|
createdAt: message.createdAt
|
|
117
162
|
}));
|
|
118
163
|
|
|
@@ -167,6 +212,70 @@ export class AgentWidgetClient {
|
|
|
167
212
|
return payload;
|
|
168
213
|
}
|
|
169
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Handle custom SSE event parsing via parseSSEEvent callback
|
|
217
|
+
* Returns true if event was handled, false otherwise
|
|
218
|
+
*/
|
|
219
|
+
private async handleCustomSSEEvent(
|
|
220
|
+
payload: unknown,
|
|
221
|
+
onEvent: SSEHandler,
|
|
222
|
+
assistantMessageRef: { current: AgentWidgetMessage | null },
|
|
223
|
+
emitMessage: (msg: AgentWidgetMessage) => void,
|
|
224
|
+
nextSequence: () => number
|
|
225
|
+
): Promise<boolean> {
|
|
226
|
+
if (!this.parseSSEEvent) return false;
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
const result = await this.parseSSEEvent(payload);
|
|
230
|
+
if (result === null) return false; // Event should be ignored
|
|
231
|
+
|
|
232
|
+
const ensureAssistant = () => {
|
|
233
|
+
if (assistantMessageRef.current) return assistantMessageRef.current;
|
|
234
|
+
const msg: AgentWidgetMessage = {
|
|
235
|
+
id: `assistant-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
|
236
|
+
role: "assistant",
|
|
237
|
+
content: "",
|
|
238
|
+
createdAt: new Date().toISOString(),
|
|
239
|
+
streaming: true,
|
|
240
|
+
variant: "assistant",
|
|
241
|
+
sequence: nextSequence()
|
|
242
|
+
};
|
|
243
|
+
assistantMessageRef.current = msg;
|
|
244
|
+
emitMessage(msg);
|
|
245
|
+
return msg;
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
if (result.text !== undefined) {
|
|
249
|
+
const assistant = ensureAssistant();
|
|
250
|
+
assistant.content += result.text;
|
|
251
|
+
emitMessage(assistant);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (result.done) {
|
|
255
|
+
if (assistantMessageRef.current) {
|
|
256
|
+
assistantMessageRef.current.streaming = false;
|
|
257
|
+
emitMessage(assistantMessageRef.current);
|
|
258
|
+
}
|
|
259
|
+
onEvent({ type: "status", status: "idle" });
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (result.error) {
|
|
263
|
+
onEvent({
|
|
264
|
+
type: "error",
|
|
265
|
+
error: new Error(result.error)
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return true; // Event was handled
|
|
270
|
+
} catch (error) {
|
|
271
|
+
if (typeof console !== "undefined") {
|
|
272
|
+
// eslint-disable-next-line no-console
|
|
273
|
+
console.error("[AgentWidget] parseSSEEvent error:", error);
|
|
274
|
+
}
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
170
279
|
private async streamResponse(
|
|
171
280
|
body: ReadableStream<Uint8Array>,
|
|
172
281
|
onEvent: SSEHandler
|
|
@@ -215,6 +324,8 @@ export class AgentWidgetClient {
|
|
|
215
324
|
};
|
|
216
325
|
|
|
217
326
|
let assistantMessage: AgentWidgetMessage | null = null;
|
|
327
|
+
// Reference to track assistant message for custom event handler
|
|
328
|
+
const assistantMessageRef = { current: null as AgentWidgetMessage | null };
|
|
218
329
|
const reasoningMessages = new Map<string, AgentWidgetMessage>();
|
|
219
330
|
const toolMessages = new Map<string, AgentWidgetMessage>();
|
|
220
331
|
const reasoningContext = {
|
|
@@ -265,7 +376,6 @@ export class AgentWidgetClient {
|
|
|
265
376
|
content: "",
|
|
266
377
|
createdAt: new Date().toISOString(),
|
|
267
378
|
streaming: true,
|
|
268
|
-
variant: "assistant",
|
|
269
379
|
sequence: nextSequence()
|
|
270
380
|
};
|
|
271
381
|
emitMessage(assistantMessage);
|
|
@@ -471,6 +581,24 @@ export class AgentWidgetClient {
|
|
|
471
581
|
const payloadType =
|
|
472
582
|
eventType !== "message" ? eventType : payload.type ?? "message";
|
|
473
583
|
|
|
584
|
+
// If custom SSE event parser is provided, try it first
|
|
585
|
+
if (this.parseSSEEvent) {
|
|
586
|
+
// Keep assistant message ref in sync
|
|
587
|
+
assistantMessageRef.current = assistantMessage;
|
|
588
|
+
const handled = await this.handleCustomSSEEvent(
|
|
589
|
+
payload,
|
|
590
|
+
onEvent,
|
|
591
|
+
assistantMessageRef,
|
|
592
|
+
emitMessage,
|
|
593
|
+
nextSequence
|
|
594
|
+
);
|
|
595
|
+
// Update assistantMessage from ref (in case it was created)
|
|
596
|
+
if (assistantMessageRef.current && !assistantMessage) {
|
|
597
|
+
assistantMessage = assistantMessageRef.current;
|
|
598
|
+
}
|
|
599
|
+
if (handled) continue; // Skip default handling if custom handler processed it
|
|
600
|
+
}
|
|
601
|
+
|
|
474
602
|
if (payloadType === "reason_start") {
|
|
475
603
|
const reasoningId =
|
|
476
604
|
resolveReasoningId(payload, true) ?? `reason-${nextSequence()}`;
|
|
@@ -635,7 +763,8 @@ export class AgentWidgetClient {
|
|
|
635
763
|
continue;
|
|
636
764
|
}
|
|
637
765
|
const assistant = ensureAssistantMessage();
|
|
638
|
-
|
|
766
|
+
// Support various field names: text, delta, content, chunk (Travrse uses 'chunk')
|
|
767
|
+
const chunk = payload.text ?? payload.delta ?? payload.content ?? payload.chunk ?? "";
|
|
639
768
|
if (chunk) {
|
|
640
769
|
// Accumulate raw content for structured format parsing
|
|
641
770
|
const rawBuffer = rawContentBuffers.get(assistant.id) ?? "";
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import { createElement } from "../utils/dom";
|
|
2
|
+
import { renderLucideIcon } from "../utils/icons";
|
|
3
|
+
import { AgentWidgetConfig } from "../types";
|
|
4
|
+
|
|
5
|
+
export interface ComposerElements {
|
|
6
|
+
footer: HTMLElement;
|
|
7
|
+
suggestions: HTMLElement;
|
|
8
|
+
composerForm: HTMLFormElement;
|
|
9
|
+
textarea: HTMLTextAreaElement;
|
|
10
|
+
sendButton: HTMLButtonElement;
|
|
11
|
+
sendButtonWrapper: HTMLElement;
|
|
12
|
+
micButton: HTMLButtonElement | null;
|
|
13
|
+
micButtonWrapper: HTMLElement | null;
|
|
14
|
+
statusText: HTMLElement;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ComposerBuildContext {
|
|
18
|
+
config?: AgentWidgetConfig;
|
|
19
|
+
onSubmit?: (text: string) => void;
|
|
20
|
+
disabled?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Helper to get font family CSS value from config preset
|
|
25
|
+
*/
|
|
26
|
+
const getFontFamilyValue = (
|
|
27
|
+
family: "sans-serif" | "serif" | "mono"
|
|
28
|
+
): string => {
|
|
29
|
+
switch (family) {
|
|
30
|
+
case "serif":
|
|
31
|
+
return 'Georgia, "Times New Roman", Times, serif';
|
|
32
|
+
case "mono":
|
|
33
|
+
return '"Courier New", Courier, "Lucida Console", Monaco, monospace';
|
|
34
|
+
case "sans-serif":
|
|
35
|
+
default:
|
|
36
|
+
return '-apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif';
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Build the composer/footer section of the panel.
|
|
42
|
+
* Extracted for reuse and plugin override support.
|
|
43
|
+
*/
|
|
44
|
+
export const buildComposer = (context: ComposerBuildContext): ComposerElements => {
|
|
45
|
+
const { config } = context;
|
|
46
|
+
|
|
47
|
+
const footer = createElement(
|
|
48
|
+
"div",
|
|
49
|
+
"tvw-border-t-cw-divider tvw-bg-cw-surface tvw-px-6 tvw-py-4"
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const suggestions = createElement(
|
|
53
|
+
"div",
|
|
54
|
+
"tvw-mb-3 tvw-flex tvw-flex-wrap tvw-gap-2"
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
// Determine gap based on voice recognition
|
|
58
|
+
const voiceRecognitionEnabledForGap =
|
|
59
|
+
config?.voiceRecognition?.enabled === true;
|
|
60
|
+
const hasSpeechRecognitionForGap =
|
|
61
|
+
typeof window !== "undefined" &&
|
|
62
|
+
(typeof (window as any).webkitSpeechRecognition !== "undefined" ||
|
|
63
|
+
typeof (window as any).SpeechRecognition !== "undefined");
|
|
64
|
+
const shouldUseSmallGap =
|
|
65
|
+
voiceRecognitionEnabledForGap && hasSpeechRecognitionForGap;
|
|
66
|
+
const gapClass = shouldUseSmallGap ? "tvw-gap-1" : "tvw-gap-3";
|
|
67
|
+
|
|
68
|
+
const composerForm = createElement(
|
|
69
|
+
"form",
|
|
70
|
+
`tvw-flex tvw-items-end ${gapClass} tvw-rounded-2xl tvw-border tvw-border-gray-200 tvw-bg-cw-input-background tvw-px-4 tvw-py-3`
|
|
71
|
+
) as HTMLFormElement;
|
|
72
|
+
// Prevent form from getting focus styles
|
|
73
|
+
composerForm.style.outline = "none";
|
|
74
|
+
|
|
75
|
+
const textarea = createElement("textarea") as HTMLTextAreaElement;
|
|
76
|
+
textarea.placeholder = config?.copy?.inputPlaceholder ?? "Type your message…";
|
|
77
|
+
textarea.className =
|
|
78
|
+
"tvw-min-h-[48px] tvw-flex-1 tvw-resize-none tvw-border-none tvw-bg-transparent tvw-text-sm tvw-text-cw-primary focus:tvw-outline-none focus:tvw-border-none";
|
|
79
|
+
textarea.rows = 1;
|
|
80
|
+
|
|
81
|
+
// Apply font family and weight from config
|
|
82
|
+
const fontFamily = config?.theme?.inputFontFamily ?? "sans-serif";
|
|
83
|
+
const fontWeight = config?.theme?.inputFontWeight ?? "400";
|
|
84
|
+
|
|
85
|
+
textarea.style.fontFamily = getFontFamilyValue(fontFamily);
|
|
86
|
+
textarea.style.fontWeight = fontWeight;
|
|
87
|
+
|
|
88
|
+
// Explicitly remove border and outline on focus to prevent browser defaults
|
|
89
|
+
textarea.style.border = "none";
|
|
90
|
+
textarea.style.outline = "none";
|
|
91
|
+
textarea.style.borderWidth = "0";
|
|
92
|
+
textarea.style.borderStyle = "none";
|
|
93
|
+
textarea.style.borderColor = "transparent";
|
|
94
|
+
textarea.addEventListener("focus", () => {
|
|
95
|
+
textarea.style.border = "none";
|
|
96
|
+
textarea.style.outline = "none";
|
|
97
|
+
textarea.style.borderWidth = "0";
|
|
98
|
+
textarea.style.borderStyle = "none";
|
|
99
|
+
textarea.style.borderColor = "transparent";
|
|
100
|
+
textarea.style.boxShadow = "none";
|
|
101
|
+
});
|
|
102
|
+
textarea.addEventListener("blur", () => {
|
|
103
|
+
textarea.style.border = "none";
|
|
104
|
+
textarea.style.outline = "none";
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// Send button configuration
|
|
108
|
+
const sendButtonConfig = config?.sendButton ?? {};
|
|
109
|
+
const useIcon = sendButtonConfig.useIcon ?? false;
|
|
110
|
+
const iconText = sendButtonConfig.iconText ?? "↑";
|
|
111
|
+
const iconName = sendButtonConfig.iconName;
|
|
112
|
+
const tooltipText = sendButtonConfig.tooltipText ?? "Send message";
|
|
113
|
+
const showTooltip = sendButtonConfig.showTooltip ?? false;
|
|
114
|
+
const buttonSize = sendButtonConfig.size ?? "40px";
|
|
115
|
+
const backgroundColor = sendButtonConfig.backgroundColor;
|
|
116
|
+
const textColor = sendButtonConfig.textColor;
|
|
117
|
+
|
|
118
|
+
// Create wrapper for tooltip positioning
|
|
119
|
+
const sendButtonWrapper = createElement("div", "tvw-send-button-wrapper");
|
|
120
|
+
|
|
121
|
+
const sendButton = createElement(
|
|
122
|
+
"button",
|
|
123
|
+
useIcon
|
|
124
|
+
? "tvw-rounded-button tvw-flex tvw-items-center tvw-justify-center disabled:tvw-opacity-50 tvw-cursor-pointer"
|
|
125
|
+
: "tvw-rounded-button tvw-bg-cw-accent tvw-px-4 tvw-py-2 tvw-text-sm tvw-font-semibold disabled:tvw-opacity-50 tvw-cursor-pointer"
|
|
126
|
+
) as HTMLButtonElement;
|
|
127
|
+
|
|
128
|
+
sendButton.type = "submit";
|
|
129
|
+
|
|
130
|
+
if (useIcon) {
|
|
131
|
+
// Icon mode: circular button
|
|
132
|
+
sendButton.style.width = buttonSize;
|
|
133
|
+
sendButton.style.height = buttonSize;
|
|
134
|
+
sendButton.style.minWidth = buttonSize;
|
|
135
|
+
sendButton.style.minHeight = buttonSize;
|
|
136
|
+
sendButton.style.fontSize = "18px";
|
|
137
|
+
sendButton.style.lineHeight = "1";
|
|
138
|
+
|
|
139
|
+
// Clear any existing content
|
|
140
|
+
sendButton.innerHTML = "";
|
|
141
|
+
|
|
142
|
+
// Use Lucide icon if iconName is provided, otherwise fall back to iconText
|
|
143
|
+
if (iconName) {
|
|
144
|
+
const iconSize = parseFloat(buttonSize) || 24;
|
|
145
|
+
const iconColor =
|
|
146
|
+
textColor && typeof textColor === "string" && textColor.trim()
|
|
147
|
+
? textColor.trim()
|
|
148
|
+
: "currentColor";
|
|
149
|
+
const iconSvg = renderLucideIcon(iconName, iconSize, iconColor, 2);
|
|
150
|
+
if (iconSvg) {
|
|
151
|
+
sendButton.appendChild(iconSvg);
|
|
152
|
+
sendButton.style.color = iconColor;
|
|
153
|
+
} else {
|
|
154
|
+
// Fallback to text if icon fails to render
|
|
155
|
+
sendButton.textContent = iconText;
|
|
156
|
+
if (textColor) {
|
|
157
|
+
sendButton.style.color = textColor;
|
|
158
|
+
} else {
|
|
159
|
+
sendButton.classList.add("tvw-text-white");
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
} else {
|
|
163
|
+
sendButton.textContent = iconText;
|
|
164
|
+
if (textColor) {
|
|
165
|
+
sendButton.style.color = textColor;
|
|
166
|
+
} else {
|
|
167
|
+
sendButton.classList.add("tvw-text-white");
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (backgroundColor) {
|
|
172
|
+
sendButton.style.backgroundColor = backgroundColor;
|
|
173
|
+
} else {
|
|
174
|
+
sendButton.classList.add("tvw-bg-cw-primary");
|
|
175
|
+
}
|
|
176
|
+
} else {
|
|
177
|
+
// Text mode: existing behavior
|
|
178
|
+
sendButton.textContent = config?.copy?.sendButtonLabel ?? "Send";
|
|
179
|
+
if (textColor) {
|
|
180
|
+
sendButton.style.color = textColor;
|
|
181
|
+
} else {
|
|
182
|
+
sendButton.classList.add("tvw-text-white");
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Apply existing styling from config
|
|
187
|
+
if (sendButtonConfig.borderWidth) {
|
|
188
|
+
sendButton.style.borderWidth = sendButtonConfig.borderWidth;
|
|
189
|
+
sendButton.style.borderStyle = "solid";
|
|
190
|
+
}
|
|
191
|
+
if (sendButtonConfig.borderColor) {
|
|
192
|
+
sendButton.style.borderColor = sendButtonConfig.borderColor;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Apply padding styling (works in both icon and text mode)
|
|
196
|
+
if (sendButtonConfig.paddingX) {
|
|
197
|
+
sendButton.style.paddingLeft = sendButtonConfig.paddingX;
|
|
198
|
+
sendButton.style.paddingRight = sendButtonConfig.paddingX;
|
|
199
|
+
} else {
|
|
200
|
+
sendButton.style.paddingLeft = "";
|
|
201
|
+
sendButton.style.paddingRight = "";
|
|
202
|
+
}
|
|
203
|
+
if (sendButtonConfig.paddingY) {
|
|
204
|
+
sendButton.style.paddingTop = sendButtonConfig.paddingY;
|
|
205
|
+
sendButton.style.paddingBottom = sendButtonConfig.paddingY;
|
|
206
|
+
} else {
|
|
207
|
+
sendButton.style.paddingTop = "";
|
|
208
|
+
sendButton.style.paddingBottom = "";
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Add tooltip if enabled
|
|
212
|
+
if (showTooltip && tooltipText) {
|
|
213
|
+
const tooltip = createElement("div", "tvw-send-button-tooltip");
|
|
214
|
+
tooltip.textContent = tooltipText;
|
|
215
|
+
sendButtonWrapper.appendChild(tooltip);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
sendButtonWrapper.appendChild(sendButton);
|
|
219
|
+
|
|
220
|
+
// Voice recognition mic button
|
|
221
|
+
const voiceRecognitionConfig = config?.voiceRecognition ?? {};
|
|
222
|
+
const voiceRecognitionEnabled = voiceRecognitionConfig.enabled === true;
|
|
223
|
+
let micButton: HTMLButtonElement | null = null;
|
|
224
|
+
let micButtonWrapper: HTMLElement | null = null;
|
|
225
|
+
|
|
226
|
+
// Check browser support for speech recognition
|
|
227
|
+
const hasSpeechRecognition =
|
|
228
|
+
typeof window !== "undefined" &&
|
|
229
|
+
(typeof (window as any).webkitSpeechRecognition !== "undefined" ||
|
|
230
|
+
typeof (window as any).SpeechRecognition !== "undefined");
|
|
231
|
+
|
|
232
|
+
if (voiceRecognitionEnabled && hasSpeechRecognition) {
|
|
233
|
+
micButtonWrapper = createElement("div", "tvw-send-button-wrapper");
|
|
234
|
+
micButton = createElement(
|
|
235
|
+
"button",
|
|
236
|
+
"tvw-rounded-button tvw-flex tvw-items-center tvw-justify-center disabled:tvw-opacity-50 tvw-cursor-pointer"
|
|
237
|
+
) as HTMLButtonElement;
|
|
238
|
+
|
|
239
|
+
micButton.type = "button";
|
|
240
|
+
micButton.setAttribute("aria-label", "Start voice recognition");
|
|
241
|
+
|
|
242
|
+
const micIconName = voiceRecognitionConfig.iconName ?? "mic";
|
|
243
|
+
const micIconSize = voiceRecognitionConfig.iconSize ?? buttonSize;
|
|
244
|
+
const micIconSizeNum = parseFloat(micIconSize) || 24;
|
|
245
|
+
|
|
246
|
+
// Use dedicated colors from voice recognition config, fallback to send button colors
|
|
247
|
+
const micBackgroundColor =
|
|
248
|
+
voiceRecognitionConfig.backgroundColor ?? backgroundColor;
|
|
249
|
+
const micIconColor = voiceRecognitionConfig.iconColor ?? textColor;
|
|
250
|
+
|
|
251
|
+
micButton.style.width = micIconSize;
|
|
252
|
+
micButton.style.height = micIconSize;
|
|
253
|
+
micButton.style.minWidth = micIconSize;
|
|
254
|
+
micButton.style.minHeight = micIconSize;
|
|
255
|
+
micButton.style.fontSize = "18px";
|
|
256
|
+
micButton.style.lineHeight = "1";
|
|
257
|
+
|
|
258
|
+
// Use Lucide mic icon with configured color (stroke width 1.5 for minimalist outline style)
|
|
259
|
+
const iconColorValue = micIconColor || "currentColor";
|
|
260
|
+
const micIconSvg = renderLucideIcon(
|
|
261
|
+
micIconName,
|
|
262
|
+
micIconSizeNum,
|
|
263
|
+
iconColorValue,
|
|
264
|
+
1.5
|
|
265
|
+
);
|
|
266
|
+
if (micIconSvg) {
|
|
267
|
+
micButton.appendChild(micIconSvg);
|
|
268
|
+
micButton.style.color = iconColorValue;
|
|
269
|
+
} else {
|
|
270
|
+
// Fallback to text if icon fails
|
|
271
|
+
micButton.textContent = "🎤";
|
|
272
|
+
micButton.style.color = iconColorValue;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Apply background color
|
|
276
|
+
if (micBackgroundColor) {
|
|
277
|
+
micButton.style.backgroundColor = micBackgroundColor;
|
|
278
|
+
} else {
|
|
279
|
+
micButton.classList.add("tvw-bg-cw-primary");
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Apply icon/text color
|
|
283
|
+
if (micIconColor) {
|
|
284
|
+
micButton.style.color = micIconColor;
|
|
285
|
+
} else if (!micIconColor && !textColor) {
|
|
286
|
+
micButton.classList.add("tvw-text-white");
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Apply border styling
|
|
290
|
+
if (voiceRecognitionConfig.borderWidth) {
|
|
291
|
+
micButton.style.borderWidth = voiceRecognitionConfig.borderWidth;
|
|
292
|
+
micButton.style.borderStyle = "solid";
|
|
293
|
+
}
|
|
294
|
+
if (voiceRecognitionConfig.borderColor) {
|
|
295
|
+
micButton.style.borderColor = voiceRecognitionConfig.borderColor;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Apply padding styling
|
|
299
|
+
if (voiceRecognitionConfig.paddingX) {
|
|
300
|
+
micButton.style.paddingLeft = voiceRecognitionConfig.paddingX;
|
|
301
|
+
micButton.style.paddingRight = voiceRecognitionConfig.paddingX;
|
|
302
|
+
}
|
|
303
|
+
if (voiceRecognitionConfig.paddingY) {
|
|
304
|
+
micButton.style.paddingTop = voiceRecognitionConfig.paddingY;
|
|
305
|
+
micButton.style.paddingBottom = voiceRecognitionConfig.paddingY;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
micButtonWrapper.appendChild(micButton);
|
|
309
|
+
|
|
310
|
+
// Add tooltip if enabled
|
|
311
|
+
const micTooltipText =
|
|
312
|
+
voiceRecognitionConfig.tooltipText ?? "Start voice recognition";
|
|
313
|
+
const showMicTooltip = voiceRecognitionConfig.showTooltip ?? false;
|
|
314
|
+
if (showMicTooltip && micTooltipText) {
|
|
315
|
+
const tooltip = createElement("div", "tvw-send-button-tooltip");
|
|
316
|
+
tooltip.textContent = micTooltipText;
|
|
317
|
+
micButtonWrapper.appendChild(tooltip);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Focus textarea when composer form container is clicked
|
|
322
|
+
composerForm.addEventListener("click", (e) => {
|
|
323
|
+
// Don't focus if clicking on the send button, mic button, or their wrappers
|
|
324
|
+
if (
|
|
325
|
+
e.target !== sendButton &&
|
|
326
|
+
e.target !== sendButtonWrapper &&
|
|
327
|
+
e.target !== micButton &&
|
|
328
|
+
e.target !== micButtonWrapper
|
|
329
|
+
) {
|
|
330
|
+
textarea.focus();
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// Append elements: textarea, mic button (if exists), send button
|
|
335
|
+
composerForm.append(textarea);
|
|
336
|
+
if (micButtonWrapper) {
|
|
337
|
+
composerForm.append(micButtonWrapper);
|
|
338
|
+
}
|
|
339
|
+
composerForm.append(sendButtonWrapper);
|
|
340
|
+
|
|
341
|
+
const statusText = createElement(
|
|
342
|
+
"div",
|
|
343
|
+
"tvw-mt-2 tvw-text-right tvw-text-xs tvw-text-cw-muted"
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
// Apply status indicator config
|
|
347
|
+
const statusConfig = config?.statusIndicator ?? {};
|
|
348
|
+
const isVisible = statusConfig.visible ?? true;
|
|
349
|
+
statusText.style.display = isVisible ? "" : "none";
|
|
350
|
+
statusText.textContent = statusConfig.idleText ?? "Online";
|
|
351
|
+
|
|
352
|
+
footer.append(suggestions, composerForm, statusText);
|
|
353
|
+
|
|
354
|
+
return {
|
|
355
|
+
footer,
|
|
356
|
+
suggestions,
|
|
357
|
+
composerForm,
|
|
358
|
+
textarea,
|
|
359
|
+
sendButton,
|
|
360
|
+
sendButtonWrapper,
|
|
361
|
+
micButton,
|
|
362
|
+
micButtonWrapper,
|
|
363
|
+
statusText
|
|
364
|
+
};
|
|
365
|
+
};
|
|
366
|
+
|
package/src/components/forms.ts
CHANGED