vanilla-agent 0.1.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.
@@ -0,0 +1,116 @@
1
+ import { createChatExperience, ChatWidgetController } from "../ui";
2
+ import { ChatWidgetConfig, ChatWidgetInitOptions } from "../types";
3
+
4
+ const ensureTarget = (target: string | HTMLElement): HTMLElement => {
5
+ if (typeof window === "undefined" || typeof document === "undefined") {
6
+ throw new Error("Chat widget can only be mounted in a browser environment");
7
+ }
8
+
9
+ if (typeof target === "string") {
10
+ const element = document.querySelector<HTMLElement>(target);
11
+ if (!element) {
12
+ throw new Error(`Chat widget target "${target}" was not found`);
13
+ }
14
+ return element;
15
+ }
16
+
17
+ return target;
18
+ };
19
+
20
+ const widgetCssHref = (): string | null => {
21
+ try {
22
+ // This works in ESM builds but not in IIFE builds
23
+ if (typeof import.meta !== "undefined" && import.meta.url) {
24
+ return new URL("../widget.css", import.meta.url).href;
25
+ }
26
+ } catch {
27
+ // Fallback for IIFE builds where CSS should be loaded separately
28
+ }
29
+ return null;
30
+ };
31
+
32
+ const mountStyles = (root: ShadowRoot | HTMLElement) => {
33
+ const href = widgetCssHref();
34
+
35
+ if (root instanceof ShadowRoot) {
36
+ // For shadow DOM, we need to load CSS into the shadow root
37
+ if (href) {
38
+ const link = document.createElement("link");
39
+ link.rel = "stylesheet";
40
+ link.href = href;
41
+ link.setAttribute("data-vanilla-agent", "true");
42
+ root.insertBefore(link, root.firstChild);
43
+ }
44
+ // If href is null (IIFE build), CSS should already be loaded globally
45
+ } else {
46
+ // For non-shadow DOM, check if CSS is already loaded
47
+ const existing = document.head.querySelector<HTMLLinkElement>(
48
+ "link[data-vanilla-agent]"
49
+ );
50
+ if (!existing) {
51
+ if (href) {
52
+ // ESM build - load CSS dynamically
53
+ const link = document.createElement("link");
54
+ link.rel = "stylesheet";
55
+ link.href = href;
56
+ link.setAttribute("data-vanilla-agent", "true");
57
+ document.head.appendChild(link);
58
+ }
59
+ // IIFE build - CSS should be loaded via <link> tag before script
60
+ // If not found, we'll assume it's loaded globally or warn in dev
61
+ }
62
+ }
63
+ };
64
+
65
+ export type ChatWidgetInitHandle = ChatWidgetController & { host: HTMLElement };
66
+
67
+ export const initChatWidget = (
68
+ options: ChatWidgetInitOptions
69
+ ): ChatWidgetInitHandle => {
70
+ const target = ensureTarget(options.target);
71
+ const host = document.createElement("div");
72
+ host.className = "vanilla-agent-host";
73
+ target.appendChild(host);
74
+
75
+ const useShadow = options.useShadowDom !== false;
76
+ let mount: HTMLElement;
77
+ let root: ShadowRoot | HTMLElement;
78
+
79
+ if (useShadow) {
80
+ const shadowRoot = host.attachShadow({ mode: "open" });
81
+ root = shadowRoot;
82
+ mount = document.createElement("div");
83
+ mount.id = "vanilla-agent-root";
84
+ shadowRoot.appendChild(mount);
85
+ mountStyles(shadowRoot);
86
+ } else {
87
+ root = host;
88
+ mount = document.createElement("div");
89
+ mount.id = "vanilla-agent-root";
90
+ host.appendChild(mount);
91
+ mountStyles(host);
92
+ }
93
+
94
+ let controller = createChatExperience(mount, options.config);
95
+ options.onReady?.();
96
+
97
+ return {
98
+ host,
99
+ update(nextConfig: ChatWidgetConfig) {
100
+ controller.update(nextConfig);
101
+ },
102
+ open() {
103
+ controller.open();
104
+ },
105
+ close() {
106
+ controller.close();
107
+ },
108
+ toggle() {
109
+ controller.toggle();
110
+ },
111
+ destroy() {
112
+ controller.destroy();
113
+ host.remove();
114
+ }
115
+ };
116
+ };
package/src/session.ts ADDED
@@ -0,0 +1,206 @@
1
+ import { ChatWidgetClient } from "./client";
2
+ import {
3
+ ChatWidgetConfig,
4
+ ChatWidgetEvent,
5
+ ChatWidgetMessage
6
+ } from "./types";
7
+
8
+ export type ChatWidgetSessionStatus =
9
+ | "idle"
10
+ | "connecting"
11
+ | "connected"
12
+ | "error";
13
+
14
+ type SessionCallbacks = {
15
+ onMessagesChanged: (messages: ChatWidgetMessage[]) => void;
16
+ onStatusChanged: (status: ChatWidgetSessionStatus) => void;
17
+ onStreamingChanged: (streaming: boolean) => void;
18
+ onError?: (error: Error) => void;
19
+ };
20
+
21
+ export class ChatWidgetSession {
22
+ private client: ChatWidgetClient;
23
+ private messages: ChatWidgetMessage[];
24
+ private status: ChatWidgetSessionStatus = "idle";
25
+ private streaming = false;
26
+ private abortController: AbortController | null = null;
27
+ private sequenceCounter = Date.now();
28
+
29
+ constructor(
30
+ private config: ChatWidgetConfig = {},
31
+ private callbacks: SessionCallbacks
32
+ ) {
33
+ this.messages = [...(config.initialMessages ?? [])].map((message) => ({
34
+ ...message,
35
+ sequence: message.sequence ?? this.nextSequence()
36
+ }));
37
+ this.messages = this.sortMessages(this.messages);
38
+ this.client = new ChatWidgetClient(config);
39
+
40
+ if (this.messages.length) {
41
+ this.callbacks.onMessagesChanged([...this.messages]);
42
+ }
43
+ this.callbacks.onStatusChanged(this.status);
44
+ }
45
+
46
+ public updateConfig(next: ChatWidgetConfig) {
47
+ this.config = { ...this.config, ...next };
48
+ this.client = new ChatWidgetClient(this.config);
49
+ }
50
+
51
+ public getMessages() {
52
+ return [...this.messages];
53
+ }
54
+
55
+ public getStatus() {
56
+ return this.status;
57
+ }
58
+
59
+ public isStreaming() {
60
+ return this.streaming;
61
+ }
62
+
63
+ public async sendMessage(rawInput: string) {
64
+ const input = rawInput.trim();
65
+ if (!input) return;
66
+
67
+ this.abortController?.abort();
68
+
69
+ const userMessage: ChatWidgetMessage = {
70
+ id: `user-${Date.now()}`,
71
+ role: "user",
72
+ content: input,
73
+ createdAt: new Date().toISOString(),
74
+ sequence: this.nextSequence()
75
+ };
76
+
77
+ this.appendMessage(userMessage);
78
+ this.setStreaming(true);
79
+
80
+ const controller = new AbortController();
81
+ this.abortController = controller;
82
+
83
+ const snapshot = [...this.messages];
84
+
85
+ try {
86
+ await this.client.dispatch(
87
+ {
88
+ messages: snapshot,
89
+ signal: controller.signal
90
+ },
91
+ this.handleEvent
92
+ );
93
+ } catch (error) {
94
+ const fallback: ChatWidgetMessage = {
95
+ id: `assistant-${Date.now()}`,
96
+ role: "assistant",
97
+ createdAt: new Date().toISOString(),
98
+ content:
99
+ "It looks like the proxy isn't returning a real response yet. Here's a sample message so you can continue testing locally.",
100
+ sequence: this.nextSequence()
101
+ };
102
+
103
+ this.appendMessage(fallback);
104
+ this.setStatus("idle");
105
+ this.setStreaming(false);
106
+ this.abortController = null;
107
+ if (error instanceof Error) {
108
+ this.callbacks.onError?.(error);
109
+ } else {
110
+ this.callbacks.onError?.(new Error(String(error)));
111
+ }
112
+ }
113
+ }
114
+
115
+ public cancel() {
116
+ this.abortController?.abort();
117
+ this.abortController = null;
118
+ this.setStreaming(false);
119
+ this.setStatus("idle");
120
+ }
121
+
122
+ private handleEvent = (event: ChatWidgetEvent) => {
123
+ if (event.type === "message") {
124
+ this.upsertMessage(event.message);
125
+ } else if (event.type === "status") {
126
+ this.setStatus(event.status);
127
+ if (event.status === "connecting") {
128
+ this.setStreaming(true);
129
+ } else if (event.status === "idle" || event.status === "error") {
130
+ this.setStreaming(false);
131
+ this.abortController = null;
132
+ }
133
+ } else if (event.type === "error") {
134
+ this.setStatus("error");
135
+ this.setStreaming(false);
136
+ this.abortController = null;
137
+ this.callbacks.onError?.(event.error);
138
+ }
139
+ };
140
+
141
+ private setStatus(status: ChatWidgetSessionStatus) {
142
+ if (this.status === status) return;
143
+ this.status = status;
144
+ this.callbacks.onStatusChanged(status);
145
+ }
146
+
147
+ private setStreaming(streaming: boolean) {
148
+ if (this.streaming === streaming) return;
149
+ this.streaming = streaming;
150
+ this.callbacks.onStreamingChanged(streaming);
151
+ }
152
+
153
+ private appendMessage(message: ChatWidgetMessage) {
154
+ const withSequence = this.ensureSequence(message);
155
+ this.messages = this.sortMessages([...this.messages, withSequence]);
156
+ this.callbacks.onMessagesChanged([...this.messages]);
157
+ }
158
+
159
+ private upsertMessage(message: ChatWidgetMessage) {
160
+ const withSequence = this.ensureSequence(message);
161
+ const index = this.messages.findIndex((m) => m.id === withSequence.id);
162
+ if (index === -1) {
163
+ this.appendMessage(withSequence);
164
+ return;
165
+ }
166
+
167
+ this.messages = this.messages.map((existing, idx) =>
168
+ idx === index ? { ...existing, ...withSequence } : existing
169
+ );
170
+ this.messages = this.sortMessages(this.messages);
171
+ this.callbacks.onMessagesChanged([...this.messages]);
172
+ }
173
+
174
+ private ensureSequence(message: ChatWidgetMessage): ChatWidgetMessage {
175
+ if (message.sequence !== undefined) {
176
+ return { ...message };
177
+ }
178
+ return {
179
+ ...message,
180
+ sequence: this.nextSequence()
181
+ };
182
+ }
183
+
184
+ private nextSequence() {
185
+ return this.sequenceCounter++;
186
+ }
187
+
188
+ private sortMessages(messages: ChatWidgetMessage[]) {
189
+ return [...messages].sort((a, b) => {
190
+ // Sort by createdAt timestamp first (chronological order)
191
+ const timeA = new Date(a.createdAt).getTime();
192
+ const timeB = new Date(b.createdAt).getTime();
193
+ if (!Number.isNaN(timeA) && !Number.isNaN(timeB) && timeA !== timeB) {
194
+ return timeA - timeB;
195
+ }
196
+
197
+ // Fall back to sequence if timestamps are equal or invalid
198
+ const seqA = a.sequence ?? 0;
199
+ const seqB = b.sequence ?? 0;
200
+ if (seqA !== seqB) return seqA - seqB;
201
+
202
+ // Final fallback to ID
203
+ return a.id.localeCompare(b.id);
204
+ });
205
+ }
206
+ }
@@ -0,0 +1,19 @@
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ @layer base {
6
+ :root {
7
+ --travrse-primary: #111827;
8
+ --travrse-secondary: #4338ca;
9
+ --travrse-surface: #ffffff;
10
+ --travrse-muted: #6b7280;
11
+ --travrse-accent: #1d4ed8;
12
+ }
13
+ }
14
+
15
+ @layer components {
16
+ .tvw-widget-shadow {
17
+ box-shadow: 0 25px 50px -12px rgba(15, 23, 42, 0.35);
18
+ }
19
+ }