@luckydraw/cumulus 0.15.0 → 0.15.2

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.
@@ -3,29 +3,27 @@
3
3
  * Embeddable via <script src="/widget.js" data-api-key="..."></script>
4
4
  * or usable standalone at /chat.
5
5
  *
6
- * < 50KB unminified.
6
+ * < 80KB unminified.
7
7
  */
8
8
  (function () {
9
9
  'use strict';
10
10
 
11
- // ─── Configuration ──────────────────────────────────────────
12
- const script = document.currentScript;
13
- const API_KEY = script?.getAttribute('data-api-key') || '';
14
- const STANDALONE = script?.getAttribute('data-standalone') === 'true';
15
- const WS_URL_OVERRIDE = script?.getAttribute('data-ws-url') || '';
11
+ // ─── Configuration ──────────────────────────────────────────────────────────
12
+ var script = document.currentScript;
13
+ var API_KEY = (script && script.getAttribute('data-api-key')) || '';
14
+ var STANDALONE = (script && script.getAttribute('data-standalone')) === 'true';
15
+ var WS_URL_OVERRIDE = (script && script.getAttribute('data-ws-url')) || '';
16
16
 
17
- // Derive WebSocket URL from script src or page location
18
17
  function getWsUrl() {
19
18
  if (WS_URL_OVERRIDE) return WS_URL_OVERRIDE;
20
- const loc = window.location;
21
- const proto = loc.protocol === 'https:' ? 'wss:' : 'ws:';
22
- return `${proto}//${loc.host}/ws`;
19
+ var loc = window.location;
20
+ var proto = loc.protocol === 'https:' ? 'wss:' : 'ws:';
21
+ return proto + '//' + loc.host + '/ws';
23
22
  }
24
23
 
25
- // Session ID — persisted in localStorage for returning visitors
26
24
  function getSessionId() {
27
- const key = 'cumulus-session-id';
28
- let id = localStorage.getItem(key);
25
+ var key = 'cumulus-session-id';
26
+ var id = localStorage.getItem(key);
29
27
  if (!id) {
30
28
  id = 'web-' + Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
31
29
  localStorage.setItem(key, id);
@@ -33,293 +31,686 @@
33
31
  return id;
34
32
  }
35
33
 
36
- // ─── Styles ─────────────────────────────────────────────────
37
- const STYLES = `
38
- .cumulus-widget * { box-sizing: border-box; margin: 0; padding: 0; }
39
- .cumulus-widget {
40
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
41
- font-size: 14px;
42
- line-height: 1.5;
43
- color: #e0e0e0;
44
- }
45
-
46
- /* Floating bubble (embeddable mode only) */
47
- .cumulus-bubble {
48
- position: fixed;
49
- bottom: 24px;
50
- right: 24px;
51
- width: 56px;
52
- height: 56px;
53
- border-radius: 50%;
54
- background: #6366f1;
55
- border: none;
56
- cursor: pointer;
57
- box-shadow: 0 4px 12px rgba(0,0,0,0.3);
58
- display: flex;
59
- align-items: center;
60
- justify-content: center;
61
- z-index: 10000;
62
- transition: transform 0.15s ease;
63
- }
64
- .cumulus-bubble:hover { transform: scale(1.08); }
65
- .cumulus-bubble svg { width: 28px; height: 28px; fill: white; }
66
-
67
- /* Panel container */
68
- .cumulus-panel {
69
- background: #1a1a2e;
70
- border: 1px solid #2a2a4a;
71
- border-radius: 12px;
72
- display: flex;
73
- flex-direction: column;
74
- overflow: hidden;
75
- }
76
- .cumulus-panel.floating {
77
- position: fixed;
78
- bottom: 92px;
79
- right: 24px;
80
- width: 400px;
81
- height: 560px;
82
- z-index: 10000;
83
- box-shadow: 0 8px 32px rgba(0,0,0,0.5);
84
- }
85
- .cumulus-panel.standalone {
86
- width: 100%;
87
- height: 100vh;
88
- border: none;
89
- border-radius: 0;
90
- }
91
-
92
- /* Header */
93
- .cumulus-header {
94
- display: flex;
95
- align-items: center;
96
- justify-content: space-between;
97
- padding: 12px 16px;
98
- background: #16162b;
99
- border-bottom: 1px solid #2a2a4a;
100
- }
101
- .cumulus-header-title {
102
- font-weight: 600;
103
- font-size: 15px;
104
- color: #f0f0ff;
105
- }
106
- .cumulus-header-status {
107
- font-size: 11px;
108
- display: flex;
109
- align-items: center;
110
- gap: 6px;
111
- }
112
- .cumulus-status-dot {
113
- width: 8px;
114
- height: 8px;
115
- border-radius: 50%;
116
- background: #666;
117
- }
118
- .cumulus-status-dot.connected { background: #22c55e; }
119
- .cumulus-status-dot.connecting { background: #f59e0b; }
120
- .cumulus-status-dot.disconnected { background: #ef4444; }
121
- .cumulus-close-btn {
122
- background: none;
123
- border: none;
124
- color: #888;
125
- cursor: pointer;
126
- font-size: 20px;
127
- padding: 4px;
128
- line-height: 1;
129
- }
130
- .cumulus-close-btn:hover { color: #fff; }
131
-
132
- /* Messages area */
133
- .cumulus-messages {
134
- flex: 1;
135
- overflow-y: auto;
136
- padding: 16px;
137
- display: flex;
138
- flex-direction: column;
139
- gap: 12px;
140
- }
141
- .cumulus-messages::-webkit-scrollbar { width: 6px; }
142
- .cumulus-messages::-webkit-scrollbar-track { background: transparent; }
143
- .cumulus-messages::-webkit-scrollbar-thumb { background: #3a3a5a; border-radius: 3px; }
144
-
145
- /* Message bubbles */
146
- .cumulus-msg {
147
- max-width: 85%;
148
- padding: 10px 14px;
149
- border-radius: 12px;
150
- word-wrap: break-word;
151
- white-space: pre-wrap;
152
- }
153
- .cumulus-msg.user {
154
- align-self: flex-end;
155
- background: #4f46e5;
156
- color: #fff;
157
- border-bottom-right-radius: 4px;
158
- }
159
- .cumulus-msg.assistant {
160
- align-self: flex-start;
161
- background: #2a2a4a;
162
- color: #e0e0e0;
163
- border-bottom-left-radius: 4px;
164
- }
34
+ function getStoredApiKey() {
35
+ return localStorage.getItem('cumulus-api-key') || '';
36
+ }
37
+ function setStoredApiKey(k) {
38
+ localStorage.setItem('cumulus-api-key', k);
39
+ }
40
+ function clearStoredApiKey() {
41
+ localStorage.removeItem('cumulus-api-key');
42
+ }
165
43
 
166
- /* Streaming cursor */
167
- .cumulus-cursor {
168
- display: inline-block;
169
- width: 2px;
170
- height: 1em;
171
- background: #6366f1;
172
- margin-left: 2px;
173
- vertical-align: text-bottom;
174
- animation: cumulus-blink 0.8s step-end infinite;
175
- }
176
- @keyframes cumulus-blink { 50% { opacity: 0; } }
177
-
178
- /* Loading indicator */
179
- .cumulus-loading {
180
- align-self: flex-start;
181
- padding: 10px 14px;
182
- color: #888;
183
- font-style: italic;
184
- }
44
+ // ─── Styles ─────────────────────────────────────────────────────────────────
45
+ var STYLES = [
46
+ '.cumulus-widget * { box-sizing: border-box; margin: 0; padding: 0; }',
47
+
48
+ '.cumulus-widget {',
49
+ ' font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;',
50
+ ' font-size: 14px;',
51
+ ' line-height: 1.6;',
52
+ ' color: #e0e0e0;',
53
+ '}',
54
+
55
+ /* ── Floating bubble ── */
56
+ '.cumulus-bubble {',
57
+ ' position: fixed; bottom: 24px; right: 24px;',
58
+ ' width: 56px; height: 56px;',
59
+ ' border-radius: 50%;',
60
+ ' background: #0066cc;',
61
+ ' border: none; cursor: pointer;',
62
+ ' box-shadow: 0 4px 14px rgba(0,0,0,0.45);',
63
+ ' display: flex; align-items: center; justify-content: center;',
64
+ ' z-index: 10000;',
65
+ ' transition: background 0.15s ease, transform 0.15s ease;',
66
+ '}',
67
+ '.cumulus-bubble:hover { background: #0077ee; transform: scale(1.07); }',
68
+ '.cumulus-bubble svg { width: 26px; height: 26px; fill: white; }',
69
+
70
+ /* ── Panel ── */
71
+ '.cumulus-panel {',
72
+ ' background: #1e1e1e;',
73
+ ' border: 1px solid #3a3a3a;',
74
+ ' display: flex; flex-direction: column; overflow: hidden;',
75
+ '}',
76
+ '.cumulus-panel.floating {',
77
+ ' position: fixed; bottom: 90px; right: 24px;',
78
+ ' width: 420px; height: 580px;',
79
+ ' border-radius: 10px;',
80
+ ' z-index: 10000;',
81
+ ' box-shadow: 0 10px 36px rgba(0,0,0,0.55);',
82
+ '}',
83
+ '.cumulus-panel.standalone {',
84
+ ' width: 100%; height: 100vh;',
85
+ ' border: none; border-radius: 0;',
86
+ '}',
87
+
88
+ /* ── Header ── */
89
+ '.cumulus-header {',
90
+ ' display: flex; align-items: center; justify-content: space-between;',
91
+ ' padding: 0.4em 0.85em;',
92
+ ' background: #2d2d2d;',
93
+ ' border-bottom: 1px solid #3a3a3a;',
94
+ ' flex-shrink: 0;',
95
+ '}',
96
+ '.cumulus-header-title { font-weight: 600; font-size: 15px; color: #e0e0e0; }',
97
+ '.cumulus-header-status { font-size: 11px; display: flex; align-items: center; gap: 6px; color: #aaa; }',
98
+ '.cumulus-status-dot { width: 8px; height: 8px; border-radius: 50%; background: #555; }',
99
+ '.cumulus-status-dot.connected { background: #22c55e; }',
100
+ '.cumulus-status-dot.connecting { background: #f59e0b; }',
101
+ '.cumulus-status-dot.disconnected { background: #ef4444; }',
102
+ '.cumulus-close-btn { background: none; border: none; color: #888; cursor: pointer; font-size: 20px; padding: 2px 4px; line-height: 1; }',
103
+ '.cumulus-close-btn:hover { color: #e0e0e0; }',
104
+
105
+ /* ── Messages scroll area ── */
106
+ '.cumulus-messages {',
107
+ ' flex: 1; overflow-y: auto;',
108
+ ' padding: 1.5em 1em;',
109
+ ' display: flex; flex-direction: column;',
110
+ ' gap: 1.5em;',
111
+ '}',
112
+ '.cumulus-messages::-webkit-scrollbar { width: 6px; }',
113
+ '.cumulus-messages::-webkit-scrollbar-track { background: transparent; }',
114
+ '.cumulus-messages::-webkit-scrollbar-thumb { background: #444; border-radius: 3px; }',
115
+
116
+ /* ── Message inner container (max-width centering) ── */
117
+ '.cumulus-msg-row {',
118
+ ' display: flex; flex-direction: column;',
119
+ ' max-width: 78ch; width: 100%;',
120
+ ' margin: 0 auto;',
121
+ '}',
122
+
123
+ /* ── User messages ── */
124
+ '.cumulus-msg.user {',
125
+ ' align-self: flex-end;',
126
+ ' background: #2a3a4a;',
127
+ ' color: #d4e8f8;',
128
+ ' border: 1px solid #3a5060;',
129
+ ' border-radius: 0.85em 0.85em 0.25em 0.85em;',
130
+ ' padding: 0.55em 0.9em;',
131
+ ' max-width: 42ch;',
132
+ ' word-wrap: break-word;',
133
+ ' white-space: pre-wrap;',
134
+ '}',
135
+ '.cumulus-msg.user.wide { max-width: 100%; }',
136
+
137
+ /* ── User message attachments ── */
138
+ '.cumulus-msg-attachments { display: flex; flex-wrap: wrap; gap: 0.4em; margin-top: 0.4em; }',
139
+ '.cumulus-msg-img { max-width: 16em; max-height: 12em; border-radius: 0.3em; object-fit: cover; border: 1px solid #3a5060; display: block; }',
140
+ '.cumulus-msg-file-badge {',
141
+ ' display: inline-flex; align-items: center; gap: 0.3em;',
142
+ ' background: #1e2a35; border: 1px solid #3a5060; border-radius: 0.3em;',
143
+ ' padding: 0.2em 0.5em; font-size: 0.8em; color: #8ab4cc;',
144
+ ' font-family: "SF Mono", "Fira Code", "Cascadia Code", Consolas, monospace;',
145
+ '}',
146
+
147
+ /* ── Assistant messages ── */
148
+ '.cumulus-msg.assistant {',
149
+ ' align-self: flex-start;',
150
+ ' background: transparent;',
151
+ ' color: #e0e0e0;',
152
+ ' padding: 0.4em 0;',
153
+ ' width: 100%;',
154
+ ' word-wrap: break-word;',
155
+ '}',
156
+
157
+ /* ── Markdown rendered inside assistant messages ── */
158
+ '.cumulus-msg.assistant p { margin: 0.4em 0; }',
159
+ '.cumulus-msg.assistant p:first-child { margin-top: 0; }',
160
+ '.cumulus-msg.assistant p:last-child { margin-bottom: 0; }',
161
+
162
+ '.cumulus-msg a { color: #4da6ff; text-decoration: none; }',
163
+ '.cumulus-msg a:hover { text-decoration: underline; }',
164
+ '.cumulus-msg strong { font-weight: 600; }',
165
+ '.cumulus-msg em { font-style: italic; }',
166
+ '.cumulus-msg del { text-decoration: line-through; color: #888; }',
167
+
168
+ /* Headings */
169
+ '.cumulus-msg h1,.cumulus-msg h2,.cumulus-msg h3,.cumulus-msg h4,.cumulus-msg h5,.cumulus-msg h6 {',
170
+ ' font-weight: 600; margin: 0.7em 0 0.3em; line-height: 1.3; color: #e8e8e8;',
171
+ '}',
172
+ '.cumulus-msg h1 { font-size: 1.55em; border-bottom: 1px solid #3a3a3a; padding-bottom: 0.2em; }',
173
+ '.cumulus-msg h2 { font-size: 1.3em; border-bottom: 1px solid #3a3a3a; padding-bottom: 0.2em; }',
174
+ '.cumulus-msg h3 { font-size: 1.12em; }',
175
+ '.cumulus-msg h4 { font-size: 1em; }',
176
+ '.cumulus-msg h5 { font-size: 0.9em; }',
177
+ '.cumulus-msg h6 { font-size: 0.85em; color: #aaa; }',
178
+
179
+ /* Horizontal rule */
180
+ '.cumulus-msg hr { border: none; border-top: 1px solid #3a3a3a; margin: 0.75em 0; }',
181
+
182
+ /* Blockquote */
183
+ '.cumulus-msg blockquote {',
184
+ ' background: #252525;',
185
+ ' border-left: 0.2em solid #0066cc;',
186
+ ' margin: 0.5em 0;',
187
+ ' padding: 0.4em 0.8em;',
188
+ ' border-radius: 0 0.25em 0.25em 0;',
189
+ ' color: #bbb;',
190
+ '}',
191
+
192
+ /* Lists */
193
+ '.cumulus-msg ul,.cumulus-msg ol { padding-left: 1.5em; margin: 0.4em 0; }',
194
+ '.cumulus-msg li { margin: 0.15em 0; }',
195
+ '.cumulus-msg li > ul,.cumulus-msg li > ol { margin: 0.1em 0; }',
196
+
197
+ /* Inline code */
198
+ '.cumulus-msg code {',
199
+ ' background: #2a2a2a;',
200
+ ' border: 1px solid #3a3a3a;',
201
+ ' border-radius: 0.25em;',
202
+ ' color: #ce9178;',
203
+ ' font-family: "SF Mono", "Fira Code", "Cascadia Code", Consolas, monospace;',
204
+ ' font-size: 0.875em;',
205
+ ' padding: 0.1em 0.35em;',
206
+ '}',
207
+
208
+ /* Code block wrapper */
209
+ '.code-block-wrapper {',
210
+ ' border: 1px solid #3a3a3a;',
211
+ ' border-radius: 0.4em;',
212
+ ' background: #1e1e1e;',
213
+ ' overflow: hidden;',
214
+ ' margin: 0.6em 0;',
215
+ '}',
216
+ '.code-block-header {',
217
+ ' background: #3d3d3d;',
218
+ ' padding: 0.35em 0.85em;',
219
+ ' border-bottom: 1px solid #4a4a4a;',
220
+ ' display: flex; justify-content: space-between; align-items: center;',
221
+ '}',
222
+ '.code-block-language {',
223
+ ' font-family: "SF Mono", "Fira Code", "Cascadia Code", Consolas, monospace;',
224
+ ' font-size: 0.8em; color: #aaa; text-transform: lowercase;',
225
+ '}',
226
+ '.code-block-copy-btn {',
227
+ ' background: transparent;',
228
+ ' border: 1px solid #555;',
229
+ ' border-radius: 0.3em;',
230
+ ' color: #aaa;',
231
+ ' font-size: 0.8em;',
232
+ ' padding: 0.15em 0.55em;',
233
+ ' cursor: pointer;',
234
+ ' font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;',
235
+ '}',
236
+ '.code-block-copy-btn:hover { border-color: #0066cc; color: #ddd; }',
237
+ '.code-block-wrapper pre {',
238
+ ' padding: 1em 1.15em; margin: 0;',
239
+ ' overflow-x: auto;',
240
+ ' font-size: 0.925em; line-height: 1.5;',
241
+ ' font-family: "SF Mono", "Fira Code", "Cascadia Code", Consolas, monospace;',
242
+ '}',
243
+ '.code-block-wrapper pre code {',
244
+ ' background: none; border: none; color: #e0e0e0;',
245
+ ' padding: 0; font-size: inherit;',
246
+ '}',
247
+
248
+ /* Tables */
249
+ '.cumulus-msg table {',
250
+ ' border-collapse: collapse; width: 100%;',
251
+ ' margin: 0.6em 0; font-size: 0.93em;',
252
+ '}',
253
+ '.cumulus-msg th {',
254
+ ' background: #2a2a2a;',
255
+ ' border: 1px solid #3a3a3a;',
256
+ ' padding: 0.4em 0.7em;',
257
+ ' text-align: left; font-weight: 600; color: #ccc;',
258
+ '}',
259
+ '.cumulus-msg td {',
260
+ ' border: 1px solid #3a3a3a;',
261
+ ' padding: 0.35em 0.7em; color: #ddd;',
262
+ '}',
263
+ '.cumulus-msg tr:nth-child(even) td { background: #222; }',
264
+ '.cumulus-msg tr:nth-child(odd) td { background: #1e1e1e; }',
265
+
266
+ /* ── Streaming cursor ── */
267
+ '.cumulus-cursor {',
268
+ ' display: inline-block;',
269
+ ' width: 0.55em; height: 1em;',
270
+ ' background: #0066cc;',
271
+ ' margin-left: 2px;',
272
+ ' vertical-align: text-bottom;',
273
+ ' animation: cumulus-blink 0.8s step-end infinite;',
274
+ '}',
275
+ '@keyframes cumulus-blink { 50% { opacity: 0; } }',
276
+
277
+ /* ── Loading indicator ── */
278
+ '.cumulus-loading {',
279
+ ' color: #666; font-style: italic; padding: 0.4em 0;',
280
+ ' align-self: flex-start;',
281
+ '}',
282
+
283
+ /* ── Empty state ── */
284
+ '.cumulus-empty {',
285
+ ' display: flex; align-items: center; justify-content: center;',
286
+ ' flex: 1; color: #555; font-size: 15px;',
287
+ ' text-align: center; padding: 20px;',
288
+ '}',
289
+
290
+ /* ── Input area ── */
291
+ '.cumulus-input-area {',
292
+ ' display: flex; flex-direction: column;',
293
+ ' padding: 0.6em 0.85em 0.75em;',
294
+ ' background: #2d2d2d;',
295
+ ' border-top: 1px solid #3a3a3a;',
296
+ ' gap: 0.45em;',
297
+ ' flex-shrink: 0;',
298
+ '}',
299
+
300
+ /* Attachment strip */
301
+ '.cumulus-attach-strip {',
302
+ ' display: flex; flex-direction: row;',
303
+ ' gap: 0.5em; overflow-x: auto;',
304
+ ' max-width: 78ch; align-self: center; width: 100%;',
305
+ ' padding-bottom: 0.1em;',
306
+ '}',
307
+ '.cumulus-attach-strip::-webkit-scrollbar { height: 4px; }',
308
+ '.cumulus-attach-strip::-webkit-scrollbar-thumb { background: #444; border-radius: 2px; }',
309
+
310
+ /* Attachment chip */
311
+ '.cumulus-attach-chip {',
312
+ ' position: relative;',
313
+ ' display: flex; flex-direction: column; align-items: center;',
314
+ ' background: #3d3d3d; border: 1px solid #4a4a4a;',
315
+ ' border-radius: 0.4em; padding: 0.3em;',
316
+ ' flex-shrink: 0; width: 5.5em; overflow: hidden;',
317
+ '}',
318
+ '.cumulus-attach-chip-thumb {',
319
+ ' width: 3em; height: 3em; object-fit: cover;',
320
+ ' border-radius: 0.25em; display: block;',
321
+ '}',
322
+ '.cumulus-attach-chip-icon {',
323
+ ' width: 3em; height: 3em;',
324
+ ' background: #2a2a2a; border-radius: 0.25em;',
325
+ ' display: flex; align-items: center; justify-content: center;',
326
+ ' font-family: "SF Mono", "Fira Code", "Cascadia Code", Consolas, monospace;',
327
+ ' font-size: 0.65em; color: #aaa; text-transform: uppercase;',
328
+ '}',
329
+ '.cumulus-attach-chip-name {',
330
+ ' font-size: 0.72em; color: #ccc;',
331
+ ' white-space: nowrap; overflow: hidden; text-overflow: ellipsis;',
332
+ ' width: 100%; text-align: center; margin-top: 0.2em;',
333
+ '}',
334
+ '.cumulus-attach-chip-remove {',
335
+ ' position: absolute; top: 0.1em; right: 0.15em;',
336
+ ' background: none; border: none; color: #888;',
337
+ ' font-size: 0.85em; line-height: 1; cursor: pointer; padding: 0 0.1em;',
338
+ '}',
339
+ '.cumulus-attach-chip-remove:hover { color: #ff6666; }',
340
+
341
+ /* Input row (attach btn + textarea + send btn) */
342
+ '.cumulus-input-row {',
343
+ ' display: flex; flex-direction: row;',
344
+ ' gap: 0.5em; align-items: flex-end;',
345
+ ' max-width: 78ch; align-self: center; width: 100%;',
346
+ '}',
347
+ '.cumulus-attach-btn {',
348
+ ' width: 2.7em; height: 2.7em; flex-shrink: 0;',
349
+ ' background: #3d3d3d; border: 1px solid #4a4a4a;',
350
+ ' border-radius: 0.55em; color: #aaa;',
351
+ ' font-size: 1.4em; line-height: 1; cursor: pointer;',
352
+ ' display: flex; align-items: center; justify-content: center;',
353
+ ' padding: 0;',
354
+ '}',
355
+ '.cumulus-attach-btn:hover { border-color: #0066cc; color: #ddd; }',
356
+
357
+ '.cumulus-input {',
358
+ ' flex: 1;',
359
+ ' background: #3d3d3d;',
360
+ ' border: 1px solid #4a4a4a;',
361
+ ' border-radius: 0.55em;',
362
+ ' color: #e0e0e0;',
363
+ ' padding: 0.55em 0.75em;',
364
+ ' font-size: 14px;',
365
+ ' font-family: inherit;',
366
+ ' resize: none;',
367
+ ' min-height: 2.7em;',
368
+ ' max-height: 120px;',
369
+ ' outline: none;',
370
+ ' line-height: 1.5;',
371
+ '}',
372
+ '.cumulus-input:focus { border-color: #0066cc; }',
373
+ '.cumulus-input::placeholder { color: #666; }',
374
+
375
+ '.cumulus-send-btn {',
376
+ ' background: #0066cc;',
377
+ ' border: none;',
378
+ ' border-radius: 0.55em;',
379
+ ' color: white;',
380
+ ' padding: 0 1em;',
381
+ ' height: 2.7em;',
382
+ ' flex-shrink: 0;',
383
+ ' cursor: pointer;',
384
+ ' font-size: 14px;',
385
+ ' font-weight: 600;',
386
+ ' font-family: inherit;',
387
+ ' white-space: nowrap;',
388
+ '}',
389
+ '.cumulus-send-btn:hover:not(:disabled) { background: #0077ee; }',
390
+ '.cumulus-send-btn:disabled { background: #334455; color: #668; cursor: not-allowed; }',
391
+ '.cumulus-send-btn.stop { background: #8b2222; color: #ffcccc; }',
392
+ '.cumulus-send-btn.stop:hover:not(:disabled) { background: #a02828; }',
393
+
394
+ /* ── Auth panel ── */
395
+ '.cumulus-auth {',
396
+ ' display: flex; flex-direction: column;',
397
+ ' align-items: center; justify-content: center;',
398
+ ' flex: 1; padding: 2em 2em; gap: 1em;',
399
+ '}',
400
+ '.cumulus-auth-title { font-size: 20px; font-weight: 600; color: #e0e0e0; }',
401
+ '.cumulus-auth-subtitle { color: #888; font-size: 13px; text-align: center; max-width: 280px; }',
402
+ '.cumulus-auth-input {',
403
+ ' width: 100%; max-width: 320px;',
404
+ ' background: #3d3d3d; border: 1px solid #4a4a4a;',
405
+ ' border-radius: 0.55em; color: #e0e0e0;',
406
+ ' padding: 0.7em 0.85em; font-size: 14px;',
407
+ ' font-family: "SF Mono", "Fira Code", "Cascadia Code", Consolas, monospace;',
408
+ ' outline: none;',
409
+ '}',
410
+ '.cumulus-auth-input:focus { border-color: #0066cc; }',
411
+ '.cumulus-auth-input::placeholder { color: #555; }',
412
+ '.cumulus-auth-btn {',
413
+ ' background: #0066cc; border: none; border-radius: 0.55em;',
414
+ ' color: white; padding: 0.6em 2em;',
415
+ ' cursor: pointer; font-size: 14px; font-weight: 600; font-family: inherit;',
416
+ '}',
417
+ '.cumulus-auth-btn:hover { background: #0077ee; }',
418
+ '.cumulus-auth-btn:disabled { background: #334455; color: #668; cursor: not-allowed; }',
419
+ '.cumulus-auth-error { color: #ef4444; font-size: 13px; text-align: center; min-height: 1.2em; }',
420
+ '.cumulus-auth-logout {',
421
+ ' background: none; border: none; color: #666;',
422
+ ' cursor: pointer; font-size: 11px; padding: 2px 6px; font-family: inherit;',
423
+ '}',
424
+ '.cumulus-auth-logout:hover { color: #ef4444; }',
425
+ ].join('\n');
426
+
427
+ // ─── HTML Escaping ───────────────────────────────────────────────────────────
428
+ function escapeHtml(text) {
429
+ var div = document.createElement('div');
430
+ div.textContent = text;
431
+ return div.innerHTML;
432
+ }
185
433
 
186
- /* Markdown basics */
187
- .cumulus-msg code {
188
- background: rgba(255,255,255,0.1);
189
- padding: 2px 6px;
190
- border-radius: 4px;
191
- font-family: 'SF Mono', 'Fira Code', monospace;
192
- font-size: 13px;
193
- }
194
- .cumulus-msg pre {
195
- background: #0d0d1a;
196
- padding: 12px;
197
- border-radius: 8px;
198
- overflow-x: auto;
199
- margin: 8px 0;
200
- }
201
- .cumulus-msg pre code {
202
- background: none;
203
- padding: 0;
204
- display: block;
205
- }
206
- .cumulus-msg a { color: #818cf8; }
207
- .cumulus-msg strong { font-weight: 600; }
208
- .cumulus-msg em { font-style: italic; }
209
-
210
- /* Input area */
211
- .cumulus-input-area {
212
- display: flex;
213
- gap: 8px;
214
- padding: 12px 16px;
215
- background: #16162b;
216
- border-top: 1px solid #2a2a4a;
217
- }
218
- .cumulus-input {
219
- flex: 1;
220
- background: #1a1a2e;
221
- border: 1px solid #3a3a5a;
222
- border-radius: 8px;
223
- color: #e0e0e0;
224
- padding: 10px 12px;
225
- font-size: 14px;
226
- font-family: inherit;
227
- resize: none;
228
- min-height: 40px;
229
- max-height: 120px;
230
- outline: none;
231
- }
232
- .cumulus-input:focus { border-color: #6366f1; }
233
- .cumulus-input::placeholder { color: #666; }
234
- .cumulus-send-btn {
235
- background: #6366f1;
236
- border: none;
237
- border-radius: 8px;
238
- color: white;
239
- padding: 0 16px;
240
- cursor: pointer;
241
- font-size: 14px;
242
- font-weight: 500;
243
- white-space: nowrap;
244
- }
245
- .cumulus-send-btn:hover { background: #5558e6; }
246
- .cumulus-send-btn:disabled { opacity: 0.5; cursor: not-allowed; }
247
-
248
- /* Empty state */
249
- .cumulus-empty {
250
- display: flex;
251
- align-items: center;
252
- justify-content: center;
253
- flex: 1;
254
- color: #555;
255
- font-size: 15px;
256
- text-align: center;
257
- padding: 20px;
258
- }
259
- `;
434
+ // ─── Markdown Renderer ───────────────────────────────────────────────────────
435
+ // Uses a placeholder strategy: extract code blocks first, render everything
436
+ // else, then reinsert code blocks. This prevents markdown patterns inside
437
+ // code from being processed.
260
438
 
261
- // ─── Simple Markdown Renderer ───────────────────────────────
262
439
  function renderMarkdown(text) {
263
- let html = escapeHtml(text);
440
+ // Phase 1: Extract fenced code blocks — replace with unique tokens
441
+ var codeBlocks = [];
442
+ var TOKEN_PREFIX = '\x00CODEBLOCK_';
443
+ var processed = text.replace(/```([^\n`]*)\n([\s\S]*?)```/g, function (_, lang, code) {
444
+ var idx = codeBlocks.length;
445
+ var langLabel = lang.trim() || 'text';
446
+ var escapedCode = escapeHtml(code.replace(/\n$/, '')); // trim trailing newline
447
+ var tokenId = 'cb' + idx;
448
+ codeBlocks.push(buildCodeBlock(langLabel, escapedCode, tokenId));
449
+ return TOKEN_PREFIX + idx + '\x00';
450
+ });
264
451
 
265
- // Code blocks (``` ... ```)
266
- html = html.replace(/```(\w*)\n([\s\S]*?)```/g, function (_, lang, code) {
267
- return '<pre><code>' + code + '</code></pre>';
452
+ // Phase 2: Escape HTML in the non-code portions
453
+ // We need to escape line by line between tokens
454
+ var parts = processed.split(/(\x00CODEBLOCK_\d+\x00)/);
455
+ var escapedParts = parts.map(function (part) {
456
+ if (/^\x00CODEBLOCK_\d+\x00$/.test(part)) return part; // keep token as-is
457
+ return escapeHtml(part);
268
458
  });
459
+ var html = escapedParts.join('');
269
460
 
270
- // Inline code
271
- html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
461
+ // Phase 3: Process tables (before other inline markdown)
462
+ html = renderTables(html);
463
+
464
+ // Phase 4: Process block-level markdown elements
465
+ // Horizontal rules
466
+ html = html.replace(/^[ \t]*(---+|___+|\*\*\*+)[ \t]*$/gm, '<hr>');
467
+
468
+ // Headers
469
+ html = html.replace(/^######[ \t]+(.+)$/gm, '<h6>$1</h6>');
470
+ html = html.replace(/^#####[ \t]+(.+)$/gm, '<h5>$1</h5>');
471
+ html = html.replace(/^####[ \t]+(.+)$/gm, '<h4>$1</h4>');
472
+ html = html.replace(/^###[ \t]+(.+)$/gm, '<h3>$1</h3>');
473
+ html = html.replace(/^##[ \t]+(.+)$/gm, '<h2>$1</h2>');
474
+ html = html.replace(/^#[ \t]+(.+)$/gm, '<h1>$1</h1>');
475
+
476
+ // Blockquotes (simple single-level)
477
+ html = renderBlockquotes(html);
272
478
 
273
- // Bold
479
+ // Lists
480
+ html = renderLists(html);
481
+
482
+ // Phase 5: Inline markdown
483
+ // Bold (** and __)
274
484
  html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
485
+ html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
486
+
487
+ // Italic (* and _) — careful not to match inside words for _
488
+ html = html.replace(/\*(?!\s)(.+?)(?<!\s)\*/g, '<em>$1</em>');
489
+ html = html.replace(/(?<!\w)_(?!\s)(.+?)(?<!\s)_(?!\w)/g, '<em>$1</em>');
275
490
 
276
- // Italic
277
- html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
491
+ // Strikethrough
492
+ html = html.replace(/~~(.+?)~~/g, '<del>$1</del>');
493
+
494
+ // Inline code (backtick)
495
+ html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
278
496
 
279
497
  // Links
280
498
  html = html.replace(
281
499
  /\[([^\]]+)\]\(([^)]+)\)/g,
282
- '<a href="$2" target="_blank" rel="noopener">$1</a>'
500
+ '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>'
283
501
  );
284
502
 
285
- // Line breaks
286
- html = html.replace(/\n/g, '<br>');
503
+ // Phase 6: Convert remaining newlines to <br> (skip block-level elements)
504
+ // Wrap sequences of plain text lines in <p> tags
505
+ html = renderParagraphs(html);
506
+
507
+ // Phase 7: Reinsert code blocks
508
+ html = html.replace(/\x00CODEBLOCK_(\d+)\x00/g, function (_, idx) {
509
+ return codeBlocks[parseInt(idx, 10)];
510
+ });
287
511
 
288
512
  return html;
289
513
  }
290
514
 
291
- function escapeHtml(text) {
292
- const div = document.createElement('div');
293
- div.textContent = text;
294
- return div.innerHTML;
515
+ function buildCodeBlock(lang, escapedCode, tokenId) {
516
+ return (
517
+ '<div class="code-block-wrapper">' +
518
+ '<div class="code-block-header">' +
519
+ '<span class="code-block-language">' + escapeHtml(lang) + '</span>' +
520
+ '<button class="code-block-copy-btn" data-copy-target="' + tokenId + '" data-testid="webchat-copy-code">Copy</button>' +
521
+ '</div>' +
522
+ '<pre><code data-code-id="' + tokenId + '">' + escapedCode + '</code></pre>' +
523
+ '</div>'
524
+ );
525
+ }
526
+
527
+ function renderTables(html) {
528
+ // Split on double newlines to find table blocks
529
+ // A table block: lines where most lines start with |
530
+ var lines = html.split('\n');
531
+ var result = [];
532
+ var i = 0;
533
+ while (i < lines.length) {
534
+ var line = lines[i];
535
+ // Check if this line looks like a table row
536
+ if (/^\|.+\|/.test(line.trim())) {
537
+ // Collect contiguous table lines
538
+ var tableLines = [];
539
+ while (i < lines.length && /^\|/.test(lines[i].trim())) {
540
+ tableLines.push(lines[i]);
541
+ i++;
542
+ }
543
+ result.push(buildTable(tableLines));
544
+ } else {
545
+ result.push(line);
546
+ i++;
547
+ }
548
+ }
549
+ return result.join('\n');
550
+ }
551
+
552
+ function buildTable(lines) {
553
+ // lines[0] = header row, lines[1] = separator row (|---|), lines[2+] = data rows
554
+ if (lines.length < 2) return lines.join('\n');
555
+
556
+ var isSepRow = /^\|[\s|:-]+\|$/.test(lines[1].trim());
557
+ if (!isSepRow) return lines.join('\n');
558
+
559
+ var headerCells = parseTableRow(lines[0]);
560
+ var dataRows = lines.slice(2);
561
+
562
+ var thead = '<thead><tr>' + headerCells.map(function (c) {
563
+ return '<th>' + c + '</th>';
564
+ }).join('') + '</tr></thead>';
565
+
566
+ var tbody = '<tbody>' + dataRows.map(function (row) {
567
+ var cells = parseTableRow(row);
568
+ return '<tr>' + cells.map(function (c) {
569
+ return '<td>' + c + '</td>';
570
+ }).join('') + '</tr>';
571
+ }).join('') + '</tbody>';
572
+
573
+ return '<table>' + thead + tbody + '</table>';
574
+ }
575
+
576
+ function parseTableRow(line) {
577
+ // Split on | and trim, filter empty first/last from leading/trailing |
578
+ var parts = line.split('|');
579
+ // Remove first and last if empty (from leading/trailing |)
580
+ if (parts.length > 0 && parts[0].trim() === '') parts = parts.slice(1);
581
+ if (parts.length > 0 && parts[parts.length - 1].trim() === '') parts = parts.slice(0, -1);
582
+ return parts.map(function (c) { return c.trim(); });
583
+ }
584
+
585
+ function renderBlockquotes(html) {
586
+ // Find consecutive lines starting with &gt; (escaped >)
587
+ var lines = html.split('\n');
588
+ var result = [];
589
+ var i = 0;
590
+ while (i < lines.length) {
591
+ if (/^&gt;[ \t]?/.test(lines[i])) {
592
+ var bqLines = [];
593
+ while (i < lines.length && /^&gt;[ \t]?/.test(lines[i])) {
594
+ bqLines.push(lines[i].replace(/^&gt;[ \t]?/, ''));
595
+ i++;
596
+ }
597
+ result.push('<blockquote>' + bqLines.join('<br>') + '</blockquote>');
598
+ } else {
599
+ result.push(lines[i]);
600
+ i++;
601
+ }
602
+ }
603
+ return result.join('\n');
604
+ }
605
+
606
+ function renderLists(html) {
607
+ // Process unordered then ordered lists
608
+ // Match blocks of consecutive list lines
609
+ var lines = html.split('\n');
610
+ var result = [];
611
+ var i = 0;
612
+
613
+ while (i < lines.length) {
614
+ // Unordered list item: "- " or "* " or "+ " (with optional indent)
615
+ if (/^[ \t]*[-*+][ \t]+/.test(lines[i])) {
616
+ var listLines = [];
617
+ while (i < lines.length && /^[ \t]*[-*+][ \t]+/.test(lines[i])) {
618
+ listLines.push(lines[i].replace(/^[ \t]*[-*+][ \t]+/, ''));
619
+ i++;
620
+ }
621
+ result.push('<ul>' + listLines.map(function (l) { return '<li>' + l + '</li>'; }).join('') + '</ul>');
622
+ }
623
+ // Ordered list item: "1. " "2. " etc.
624
+ else if (/^[ \t]*\d+\.[ \t]+/.test(lines[i])) {
625
+ var olLines = [];
626
+ while (i < lines.length && /^[ \t]*\d+\.[ \t]+/.test(lines[i])) {
627
+ olLines.push(lines[i].replace(/^[ \t]*\d+\.[ \t]+/, ''));
628
+ i++;
629
+ }
630
+ result.push('<ol>' + olLines.map(function (l) { return '<li>' + l + '</li>'; }).join('') + '</ol>');
631
+ }
632
+ else {
633
+ result.push(lines[i]);
634
+ i++;
635
+ }
636
+ }
637
+ return result.join('\n');
638
+ }
639
+
640
+ function renderParagraphs(html) {
641
+ // Split on double newlines; wrap plain-text segments in <p> while preserving
642
+ // existing block-level HTML elements.
643
+ // Strategy: split into chunks on blank lines (two+ newlines), then for each
644
+ // chunk: if it starts with a block-level tag, keep as-is; else wrap in <p>.
645
+ var blockTags = /^<(h[1-6]|hr|ul|ol|blockquote|table|div|pre)\b/i;
646
+
647
+ var chunks = html.split(/\n{2,}/);
648
+ var parts = chunks.map(function (chunk) {
649
+ chunk = chunk.trim();
650
+ if (!chunk) return '';
651
+ if (blockTags.test(chunk)) return chunk;
652
+ // It's a text/inline-html chunk — convert single newlines to <br>
653
+ // but only outside of block tags within the chunk
654
+ var inner = chunk.replace(/\n/g, '<br>');
655
+ return '<p>' + inner + '</p>';
656
+ });
657
+
658
+ return parts.filter(function (p) { return p !== ''; }).join('\n');
659
+ }
660
+
661
+ // ─── Attachment helpers ──────────────────────────────────────────────────────
662
+ function fileExtension(name) {
663
+ var parts = name.split('.');
664
+ return parts.length > 1 ? parts[parts.length - 1].slice(0, 5) : 'file';
665
+ }
666
+
667
+ function readFileAsBase64(file) {
668
+ return new Promise(function (resolve, reject) {
669
+ var reader = new FileReader();
670
+ reader.onload = function (e) {
671
+ // result is "data:mime/type;base64,XXXX" — strip the prefix
672
+ var result = e.target.result;
673
+ var comma = result.indexOf(',');
674
+ resolve({ base64: result.slice(comma + 1), mimeType: file.type || 'application/octet-stream', name: file.name });
675
+ };
676
+ reader.onerror = reject;
677
+ reader.readAsDataURL(file);
678
+ });
295
679
  }
296
680
 
297
- // ─── WebSocket Connection ───────────────────────────────────
681
+ function readFileAsDataUrl(file) {
682
+ return new Promise(function (resolve, reject) {
683
+ var reader = new FileReader();
684
+ reader.onload = function (e) { resolve(e.target.result); };
685
+ reader.onerror = reject;
686
+ reader.readAsDataURL(file);
687
+ });
688
+ }
689
+
690
+ // ─── WebSocket Connection ────────────────────────────────────────────────────
298
691
  function createConnection(opts) {
299
- const { wsUrl, apiKey, sessionId, onMessage, onStatus } = opts;
300
- let ws = null;
301
- let reconnectTimer = null;
302
- let reconnectDelay = 1000;
692
+ var wsUrl = opts.wsUrl;
693
+ var apiKey = opts.apiKey;
694
+ var sessionId = opts.sessionId;
695
+ var onMessage = opts.onMessage;
696
+ var onStatus = opts.onStatus;
697
+
698
+ var ws = null;
699
+ var reconnectTimer = null;
700
+ var reconnectDelay = 1000;
701
+ var destroyed = false;
303
702
 
304
703
  function connect() {
704
+ if (destroyed) return;
305
705
  onStatus('connecting');
306
706
  ws = new WebSocket(wsUrl);
307
707
 
308
708
  ws.onopen = function () {
709
+ if (destroyed) { ws.close(); return; }
309
710
  onStatus('connected');
310
711
  reconnectDelay = 1000;
311
-
312
- // Authenticate
313
712
  ws.send(JSON.stringify({ type: 'auth', apiKey: apiKey }));
314
-
315
- // Load history
316
- ws.send(
317
- JSON.stringify({
318
- type: 'history',
319
- threadName: sessionId,
320
- limit: 50,
321
- })
322
- );
713
+ ws.send(JSON.stringify({ type: 'history', threadName: sessionId, limit: 50 }));
323
714
  };
324
715
 
325
716
  ws.onmessage = function (event) {
@@ -332,17 +723,18 @@
332
723
  };
333
724
 
334
725
  ws.onclose = function () {
726
+ if (destroyed) return;
335
727
  onStatus('disconnected');
336
728
  scheduleReconnect();
337
729
  };
338
730
 
339
731
  ws.onerror = function () {
340
- // onclose will fire after this
732
+ // onclose fires after onerror
341
733
  };
342
734
  }
343
735
 
344
736
  function scheduleReconnect() {
345
- if (reconnectTimer) return;
737
+ if (reconnectTimer || destroyed) return;
346
738
  reconnectTimer = setTimeout(function () {
347
739
  reconnectTimer = null;
348
740
  reconnectDelay = Math.min(reconnectDelay * 1.5, 30000);
@@ -357,12 +749,13 @@
357
749
  }
358
750
 
359
751
  function close() {
752
+ destroyed = true;
360
753
  if (reconnectTimer) {
361
754
  clearTimeout(reconnectTimer);
362
755
  reconnectTimer = null;
363
756
  }
364
757
  if (ws) {
365
- ws.onclose = null; // Prevent reconnect
758
+ ws.onclose = null;
366
759
  ws.close();
367
760
  ws = null;
368
761
  }
@@ -372,88 +765,230 @@
372
765
  return { send: send, close: close };
373
766
  }
374
767
 
375
- // ─── Widget UI ──────────────────────────────────────────────
768
+ // ─── Widget ──────────────────────────────────────────────────────────────────
376
769
  function createWidget(opts) {
377
- const { standalone } = opts;
378
- const sessionId = getSessionId();
379
- const wsUrl = getWsUrl();
770
+ var standalone = opts.standalone;
771
+ var sessionId = getSessionId();
772
+ var wsUrl = getWsUrl();
773
+ var activeApiKey = standalone ? getStoredApiKey() : API_KEY;
380
774
 
381
775
  // Inject styles
382
- const styleEl = document.createElement('style');
776
+ var styleEl = document.createElement('style');
383
777
  styleEl.textContent = STYLES;
384
778
  document.head.appendChild(styleEl);
385
779
 
386
- // State
387
- var messages = [];
780
+ // ── State ──
781
+ var messages = []; // { role, content, attachments? }
388
782
  var streaming = false;
783
+ var stopRequested = false;
389
784
  var streamBuffer = '';
390
785
  var connection = null;
391
786
  var connectionStatus = 'disconnected';
787
+ var authenticated = false;
788
+ var pendingAttachments = []; // { base64, mimeType, name, dataUrl, isImage }
392
789
 
393
- // ─── DOM elements ──────────────────────────────
394
- const container = document.createElement('div');
790
+ // ── Root container ──
791
+ var container = document.createElement('div');
395
792
  container.className = 'cumulus-widget';
396
793
 
397
- // Build panel
398
- const panel = document.createElement('div');
794
+ // ── Panel ──
795
+ var panel = document.createElement('div');
399
796
  panel.className = 'cumulus-panel ' + (standalone ? 'standalone' : 'floating');
400
797
  panel.style.display = standalone ? 'flex' : 'none';
798
+ panel.setAttribute('data-testid', 'webchat-panel');
401
799
 
402
- // Header
403
- const header = document.createElement('div');
800
+ // ── Header ──
801
+ var header = document.createElement('div');
404
802
  header.className = 'cumulus-header';
405
803
  header.innerHTML =
406
804
  '<span class="cumulus-header-title">Cumulus</span>' +
407
805
  '<span class="cumulus-header-status">' +
408
- '<span class="cumulus-status-dot" data-testid="webchat-status-dot"></span>' +
409
- '<span data-testid="webchat-status-text">Disconnected</span>' +
806
+ '<span class="cumulus-status-dot" data-testid="webchat-status-dot"></span>' +
807
+ '<span data-testid="webchat-status-text">Disconnected</span>' +
410
808
  '</span>' +
411
809
  (standalone
412
- ? ''
810
+ ? '<button class="cumulus-auth-logout" data-testid="webchat-logout" style="display:none">Logout</button>'
413
811
  : '<button class="cumulus-close-btn" data-testid="webchat-close">&times;</button>');
414
812
  panel.appendChild(header);
415
813
 
416
- // Messages area
417
- const messagesEl = document.createElement('div');
814
+ // ── Auth panel ──
815
+ var authPanel = document.createElement('div');
816
+ authPanel.className = 'cumulus-auth';
817
+ authPanel.setAttribute('data-testid', 'webchat-auth');
818
+ authPanel.innerHTML =
819
+ '<div class="cumulus-auth-title">Cumulus Chat</div>' +
820
+ '<div class="cumulus-auth-subtitle">Enter your API key to connect. The key will be saved in your browser.</div>' +
821
+ '<input class="cumulus-auth-input" data-testid="webchat-auth-input" type="password" placeholder="sk-cumulus-..." autocomplete="off" />' +
822
+ '<button class="cumulus-auth-btn" data-testid="webchat-auth-submit">Connect</button>' +
823
+ '<div class="cumulus-auth-error" data-testid="webchat-auth-error"></div>';
824
+ panel.appendChild(authPanel);
825
+
826
+ // ── Messages area ──
827
+ var messagesEl = document.createElement('div');
418
828
  messagesEl.className = 'cumulus-messages';
419
829
  messagesEl.setAttribute('data-testid', 'webchat-messages');
420
830
  messagesEl.innerHTML = '<div class="cumulus-empty">Send a message to start chatting</div>';
831
+ messagesEl.style.display = 'none';
421
832
  panel.appendChild(messagesEl);
422
833
 
423
- // Input area
424
- const inputArea = document.createElement('div');
834
+ // ── Input area ──
835
+ var inputArea = document.createElement('div');
425
836
  inputArea.className = 'cumulus-input-area';
426
- const input = document.createElement('textarea');
837
+ inputArea.style.display = 'none';
838
+
839
+ // Attachment strip (conditionally shown)
840
+ var attachStrip = document.createElement('div');
841
+ attachStrip.className = 'cumulus-attach-strip';
842
+ attachStrip.setAttribute('data-testid', 'webchat-attach-strip');
843
+ attachStrip.style.display = 'none';
844
+ inputArea.appendChild(attachStrip);
845
+
846
+ // Input row
847
+ var inputRow = document.createElement('div');
848
+ inputRow.className = 'cumulus-input-row';
849
+
850
+ // Hidden file input
851
+ var fileInput = document.createElement('input');
852
+ fileInput.type = 'file';
853
+ fileInput.multiple = true;
854
+ fileInput.accept = 'image/*,.pdf,.txt,.md,.js,.ts,.py,.json,.csv';
855
+ fileInput.style.display = 'none';
856
+ fileInput.setAttribute('data-testid', 'webchat-file-input');
857
+
858
+ // Attach button
859
+ var attachBtn = document.createElement('button');
860
+ attachBtn.className = 'cumulus-attach-btn';
861
+ attachBtn.setAttribute('data-testid', 'webchat-attach-btn');
862
+ attachBtn.setAttribute('title', 'Attach files');
863
+ attachBtn.textContent = '+';
864
+
865
+ // Textarea
866
+ var input = document.createElement('textarea');
427
867
  input.className = 'cumulus-input';
428
868
  input.setAttribute('data-testid', 'webchat-input');
429
- input.placeholder = 'Type a message...';
869
+ input.placeholder = 'Type a message';
430
870
  input.rows = 1;
431
- const sendBtn = document.createElement('button');
871
+
872
+ // Send/Stop button
873
+ var sendBtn = document.createElement('button');
432
874
  sendBtn.className = 'cumulus-send-btn';
433
875
  sendBtn.setAttribute('data-testid', 'webchat-send');
434
876
  sendBtn.textContent = 'Send';
435
- inputArea.appendChild(input);
436
- inputArea.appendChild(sendBtn);
877
+
878
+ inputRow.appendChild(fileInput);
879
+ inputRow.appendChild(attachBtn);
880
+ inputRow.appendChild(input);
881
+ inputRow.appendChild(sendBtn);
882
+ inputArea.appendChild(inputRow);
437
883
  panel.appendChild(inputArea);
438
884
 
439
885
  container.appendChild(panel);
440
886
 
441
- // Floating bubble (embeddable mode only)
887
+ // ── Floating bubble (embedded mode only) ──
442
888
  var bubble = null;
443
889
  if (!standalone) {
444
890
  bubble = document.createElement('button');
445
891
  bubble.className = 'cumulus-bubble';
446
892
  bubble.setAttribute('data-testid', 'webchat-bubble');
893
+ bubble.setAttribute('title', 'Open chat');
447
894
  bubble.innerHTML =
448
895
  '<svg viewBox="0 0 24 24"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12z"/></svg>';
449
896
  container.appendChild(bubble);
450
897
  }
451
898
 
452
- // ─── Rendering ─────────────────────────────────
899
+ // ── View switching ──
900
+ function showAuthView() {
901
+ authenticated = false;
902
+ authPanel.style.display = 'flex';
903
+ messagesEl.style.display = 'none';
904
+ inputArea.style.display = 'none';
905
+ var logoutBtn = panel.querySelector('[data-testid="webchat-logout"]');
906
+ if (logoutBtn) logoutBtn.style.display = 'none';
907
+ var errEl = authPanel.querySelector('[data-testid="webchat-auth-error"]');
908
+ if (errEl) errEl.textContent = '';
909
+ }
910
+
911
+ function showChatView() {
912
+ authenticated = true;
913
+ authPanel.style.display = 'none';
914
+ messagesEl.style.display = 'flex';
915
+ inputArea.style.display = 'flex';
916
+ var logoutBtn = panel.querySelector('[data-testid="webchat-logout"]');
917
+ if (logoutBtn) logoutBtn.style.display = 'inline-block';
918
+ input.focus();
919
+ }
920
+
921
+ // ── Rendering ──
453
922
  function scrollToBottom() {
454
923
  messagesEl.scrollTop = messagesEl.scrollHeight;
455
924
  }
456
925
 
926
+ function isWideUserMessage(content) {
927
+ // Wide if it contains a code fence or has more than 3 lines
928
+ if (/```/.test(content)) return true;
929
+ var lineCount = (content.match(/\n/g) || []).length + 1;
930
+ return lineCount > 3;
931
+ }
932
+
933
+ function buildUserMsgEl(msg) {
934
+ var el = document.createElement('div');
935
+ el.className = 'cumulus-msg user' + (isWideUserMessage(msg.content) ? ' wide' : '');
936
+ el.textContent = msg.content;
937
+
938
+ if (msg.attachments && msg.attachments.length > 0) {
939
+ var attRow = document.createElement('div');
940
+ attRow.className = 'cumulus-msg-attachments';
941
+ msg.attachments.forEach(function (att) {
942
+ if (att.isImage) {
943
+ var img = document.createElement('img');
944
+ img.className = 'cumulus-msg-img';
945
+ img.src = att.dataUrl;
946
+ img.alt = att.name;
947
+ img.setAttribute('data-testid', 'webchat-msg-img');
948
+ attRow.appendChild(img);
949
+ } else {
950
+ var badge = document.createElement('span');
951
+ badge.className = 'cumulus-msg-file-badge';
952
+ badge.setAttribute('data-testid', 'webchat-msg-file');
953
+ badge.textContent = '\uD83D\uDCCE ' + att.name; // 📎
954
+ attRow.appendChild(badge);
955
+ }
956
+ });
957
+ el.appendChild(attRow);
958
+ }
959
+ return el;
960
+ }
961
+
962
+ function buildAssistantMsgEl(content, isStreaming) {
963
+ var el = document.createElement('div');
964
+ el.className = 'cumulus-msg assistant';
965
+ if (isStreaming) el.setAttribute('data-testid', 'webchat-streaming');
966
+ if (content) {
967
+ el.innerHTML = renderMarkdown(content);
968
+ if (isStreaming) {
969
+ el.innerHTML += '<span class="cumulus-cursor"></span>';
970
+ }
971
+ // Wire up copy buttons
972
+ el.querySelectorAll('.code-block-copy-btn').forEach(function (btn) {
973
+ btn.addEventListener('click', function () {
974
+ var targetId = btn.getAttribute('data-copy-target');
975
+ var codeEl = el.querySelector('[data-code-id="' + targetId + '"]');
976
+ if (codeEl && navigator.clipboard) {
977
+ navigator.clipboard.writeText(codeEl.textContent || '').then(function () {
978
+ btn.textContent = 'Copied!';
979
+ setTimeout(function () { btn.textContent = 'Copy'; }, 2000);
980
+ });
981
+ }
982
+ });
983
+ });
984
+ } else if (isStreaming) {
985
+ el.innerHTML =
986
+ '<span style="color:#666;font-style:italic">Thinking\u2026</span>' +
987
+ '<span class="cumulus-cursor"></span>';
988
+ }
989
+ return el;
990
+ }
991
+
457
992
  function renderMessages() {
458
993
  messagesEl.innerHTML = '';
459
994
 
@@ -464,54 +999,132 @@
464
999
 
465
1000
  for (var i = 0; i < messages.length; i++) {
466
1001
  var msg = messages[i];
467
- var el = document.createElement('div');
468
- el.className = 'cumulus-msg ' + msg.role;
469
- if (msg.role === 'assistant') {
470
- el.innerHTML = renderMarkdown(msg.content);
1002
+ var row = document.createElement('div');
1003
+ row.className = 'cumulus-msg-row';
1004
+
1005
+ if (msg.role === 'user') {
1006
+ row.appendChild(buildUserMsgEl(msg));
471
1007
  } else {
472
- el.textContent = msg.content;
1008
+ row.appendChild(buildAssistantMsgEl(msg.content, false));
473
1009
  }
474
- messagesEl.appendChild(el);
1010
+ messagesEl.appendChild(row);
475
1011
  }
476
1012
 
477
- // Streaming indicator
478
1013
  if (streaming) {
479
- var streamEl = document.createElement('div');
480
- streamEl.className = 'cumulus-msg assistant';
481
- streamEl.setAttribute('data-testid', 'webchat-streaming');
482
- if (streamBuffer) {
483
- streamEl.innerHTML =
484
- renderMarkdown(streamBuffer) + '<span class="cumulus-cursor"></span>';
485
- } else {
486
- streamEl.innerHTML =
487
- '<span style="color:#888;font-style:italic">Thinking...</span><span class="cumulus-cursor"></span>';
488
- }
489
- messagesEl.appendChild(streamEl);
1014
+ var row = document.createElement('div');
1015
+ row.className = 'cumulus-msg-row';
1016
+ row.appendChild(buildAssistantMsgEl(streamBuffer, true));
1017
+ messagesEl.appendChild(row);
490
1018
  }
491
1019
 
492
1020
  scrollToBottom();
493
1021
  }
494
1022
 
1023
+ function updateSendBtn() {
1024
+ if (streaming) {
1025
+ sendBtn.textContent = 'Stop';
1026
+ sendBtn.classList.add('stop');
1027
+ sendBtn.disabled = false;
1028
+ } else {
1029
+ sendBtn.textContent = 'Send';
1030
+ sendBtn.classList.remove('stop');
1031
+ sendBtn.disabled = false;
1032
+ }
1033
+ }
1034
+
495
1035
  function updateStatus(status) {
496
1036
  connectionStatus = status;
497
1037
  var dot = panel.querySelector('.cumulus-status-dot');
498
1038
  var text = panel.querySelector('[data-testid="webchat-status-text"]');
499
- if (dot) {
500
- dot.className = 'cumulus-status-dot ' + status;
1039
+ if (dot) dot.className = 'cumulus-status-dot ' + status;
1040
+ if (text) text.textContent = status.charAt(0).toUpperCase() + status.slice(1);
1041
+ }
1042
+
1043
+ // ── Attachment strip ──
1044
+ function renderAttachStrip() {
1045
+ attachStrip.innerHTML = '';
1046
+ if (pendingAttachments.length === 0) {
1047
+ attachStrip.style.display = 'none';
1048
+ return;
501
1049
  }
502
- if (text) {
503
- text.textContent = status.charAt(0).toUpperCase() + status.slice(1);
1050
+ attachStrip.style.display = 'flex';
1051
+ pendingAttachments.forEach(function (att, idx) {
1052
+ var chip = document.createElement('div');
1053
+ chip.className = 'cumulus-attach-chip';
1054
+ chip.setAttribute('data-testid', 'webchat-attach-chip');
1055
+
1056
+ if (att.isImage) {
1057
+ var thumb = document.createElement('img');
1058
+ thumb.className = 'cumulus-attach-chip-thumb';
1059
+ thumb.src = att.dataUrl;
1060
+ thumb.alt = att.name;
1061
+ chip.appendChild(thumb);
1062
+ } else {
1063
+ var icon = document.createElement('div');
1064
+ icon.className = 'cumulus-attach-chip-icon';
1065
+ icon.textContent = fileExtension(att.name);
1066
+ chip.appendChild(icon);
1067
+ }
1068
+
1069
+ var nameEl = document.createElement('div');
1070
+ nameEl.className = 'cumulus-attach-chip-name';
1071
+ nameEl.textContent = att.name;
1072
+ chip.appendChild(nameEl);
1073
+
1074
+ var removeBtn = document.createElement('button');
1075
+ removeBtn.className = 'cumulus-attach-chip-remove';
1076
+ removeBtn.setAttribute('data-testid', 'webchat-attach-remove');
1077
+ removeBtn.textContent = '\xD7'; // ×
1078
+ removeBtn.setAttribute('title', 'Remove attachment');
1079
+ (function (index) {
1080
+ removeBtn.addEventListener('click', function () {
1081
+ pendingAttachments.splice(index, 1);
1082
+ renderAttachStrip();
1083
+ });
1084
+ })(idx);
1085
+
1086
+ chip.appendChild(removeBtn);
1087
+ attachStrip.appendChild(chip);
1088
+ });
1089
+ }
1090
+
1091
+ async function addFilesToPending(files) {
1092
+ for (var i = 0; i < files.length; i++) {
1093
+ var file = files[i];
1094
+ try {
1095
+ var info = await readFileAsBase64(file);
1096
+ var dataUrl = await readFileAsDataUrl(file);
1097
+ pendingAttachments.push({
1098
+ base64: info.base64,
1099
+ mimeType: info.mimeType,
1100
+ name: file.name,
1101
+ dataUrl: dataUrl,
1102
+ isImage: file.type.startsWith('image/'),
1103
+ });
1104
+ } catch (e) {
1105
+ console.error('[Cumulus] Failed to read file:', file.name, e);
1106
+ }
504
1107
  }
1108
+ renderAttachStrip();
505
1109
  }
506
1110
 
507
- // ─── Message handling ──────────────────────────
1111
+ // ── Message handling ──
508
1112
  function handleServerMessage(data) {
509
1113
  switch (data.type) {
510
1114
  case 'auth_ok':
1115
+ showChatView();
511
1116
  break;
512
1117
 
513
1118
  case 'auth_error':
514
1119
  console.error('[Cumulus] Auth failed:', data.error);
1120
+ if (standalone) {
1121
+ clearStoredApiKey();
1122
+ activeApiKey = '';
1123
+ if (connection) { connection.close(); connection = null; }
1124
+ showAuthView();
1125
+ var errEl = authPanel.querySelector('[data-testid="webchat-auth-error"]');
1126
+ if (errEl) errEl.textContent = 'Authentication failed. Check your API key.';
1127
+ }
515
1128
  break;
516
1129
 
517
1130
  case 'history':
@@ -524,57 +1137,103 @@
524
1137
  break;
525
1138
 
526
1139
  case 'token':
1140
+ if (stopRequested) break; // discard after stop
527
1141
  if (!streaming) {
528
1142
  streaming = true;
529
1143
  streamBuffer = '';
1144
+ updateSendBtn();
530
1145
  }
531
1146
  streamBuffer += data.text;
532
1147
  renderMessages();
533
1148
  break;
534
1149
 
535
1150
  case 'segment':
536
- // Tool use/resultcould show in verbose mode later
1151
+ // Tool use/segment info reserved for future verbose display
537
1152
  break;
538
1153
 
539
1154
  case 'done':
1155
+ if (stopRequested) {
1156
+ // Stopped by user — finalize with what we have
1157
+ stopRequested = false;
1158
+ streaming = false;
1159
+ if (streamBuffer) {
1160
+ messages.push({ role: 'assistant', content: streamBuffer });
1161
+ }
1162
+ streamBuffer = '';
1163
+ updateSendBtn();
1164
+ renderMessages();
1165
+ break;
1166
+ }
540
1167
  streaming = false;
541
1168
  messages.push({ role: 'assistant', content: data.response || streamBuffer });
542
1169
  streamBuffer = '';
1170
+ updateSendBtn();
543
1171
  renderMessages();
544
1172
  break;
545
1173
 
546
1174
  case 'error':
1175
+ stopRequested = false;
547
1176
  streaming = false;
548
1177
  if (streamBuffer) {
549
- messages.push({ role: 'assistant', content: streamBuffer + '\n\n[Error: ' + data.error + ']' });
1178
+ messages.push({ role: 'assistant', content: streamBuffer + '\n\n[Error: ' + (data.error || 'Unknown error') + ']' });
550
1179
  } else {
551
- messages.push({ role: 'assistant', content: '[Error: ' + data.error + ']' });
1180
+ messages.push({ role: 'assistant', content: '[Error: ' + (data.error || 'Unknown error') + ']' });
552
1181
  }
553
1182
  streamBuffer = '';
1183
+ updateSendBtn();
554
1184
  renderMessages();
555
1185
  break;
556
1186
  }
557
1187
  }
558
1188
 
559
1189
  function sendMessage() {
1190
+ // If streaming, treat as Stop
1191
+ if (streaming) {
1192
+ stopRequested = true;
1193
+ streaming = false;
1194
+ updateSendBtn();
1195
+ // Finalize current buffer
1196
+ if (streamBuffer) {
1197
+ messages.push({ role: 'assistant', content: streamBuffer + ' [stopped]' });
1198
+ streamBuffer = '';
1199
+ }
1200
+ renderMessages();
1201
+ return;
1202
+ }
1203
+
560
1204
  var text = input.value.trim();
561
- if (!text || streaming) return;
1205
+ if (!text && pendingAttachments.length === 0) return;
1206
+ if (!connection) return;
562
1207
 
563
- messages.push({ role: 'user', content: text });
1208
+ var attachSnapshot = pendingAttachments.slice();
1209
+ var displayText = text || '(attachment)';
1210
+
1211
+ messages.push({ role: 'user', content: displayText, attachments: attachSnapshot });
564
1212
  input.value = '';
565
1213
  input.style.height = 'auto';
1214
+ pendingAttachments = [];
1215
+ renderAttachStrip();
566
1216
  streaming = true;
1217
+ stopRequested = false;
567
1218
  streamBuffer = '';
1219
+ updateSendBtn();
568
1220
  renderMessages();
569
1221
 
570
- connection.send({
1222
+ var imagePayload = attachSnapshot
1223
+ .filter(function (a) { return a.isImage; })
1224
+ .map(function (a) { return { mimeType: a.mimeType, base64: a.base64 }; });
1225
+
1226
+ var payload = {
571
1227
  type: 'message',
572
1228
  threadName: sessionId,
573
- message: text,
574
- });
1229
+ message: text || ' ',
1230
+ };
1231
+ if (imagePayload.length > 0) payload.images = imagePayload;
1232
+
1233
+ connection.send(payload);
575
1234
  }
576
1235
 
577
- // ─── Event listeners ───────────────────────────
1236
+ // ── Event listeners ──
578
1237
  sendBtn.addEventListener('click', sendMessage);
579
1238
 
580
1239
  input.addEventListener('keydown', function (e) {
@@ -584,13 +1243,48 @@
584
1243
  }
585
1244
  });
586
1245
 
587
- // Auto-resize textarea
588
1246
  input.addEventListener('input', function () {
589
1247
  input.style.height = 'auto';
590
1248
  input.style.height = Math.min(input.scrollHeight, 120) + 'px';
591
1249
  });
592
1250
 
593
- // Bubble toggle (embeddable mode)
1251
+ // Paste handler
1252
+ input.addEventListener('paste', function (e) {
1253
+ var items = e.clipboardData && e.clipboardData.items;
1254
+ if (!items) return;
1255
+
1256
+ var hasNonText = false;
1257
+ var filesToAdd = [];
1258
+ for (var i = 0; i < items.length; i++) {
1259
+ var item = items[i];
1260
+ if (item.kind === 'file') {
1261
+ hasNonText = true;
1262
+ var file = item.getAsFile();
1263
+ if (file) filesToAdd.push(file);
1264
+ }
1265
+ }
1266
+
1267
+ if (filesToAdd.length > 0) {
1268
+ e.preventDefault(); // prevent default paste of binary data
1269
+ addFilesToPending(filesToAdd);
1270
+ }
1271
+ // If only text, let default browser paste behavior handle it
1272
+ });
1273
+
1274
+ // File input change
1275
+ fileInput.addEventListener('change', function () {
1276
+ if (fileInput.files && fileInput.files.length > 0) {
1277
+ addFilesToPending(Array.from(fileInput.files));
1278
+ fileInput.value = ''; // reset so same file can be re-added
1279
+ }
1280
+ });
1281
+
1282
+ // Attach button click
1283
+ attachBtn.addEventListener('click', function () {
1284
+ fileInput.click();
1285
+ });
1286
+
1287
+ // Bubble toggle (embedded mode)
594
1288
  if (bubble) {
595
1289
  bubble.addEventListener('click', function () {
596
1290
  var showing = panel.style.display !== 'none';
@@ -604,7 +1298,6 @@
604
1298
  }
605
1299
  });
606
1300
 
607
- // Close button
608
1301
  var closeBtn = panel.querySelector('.cumulus-close-btn');
609
1302
  if (closeBtn) {
610
1303
  closeBtn.addEventListener('click', function () {
@@ -613,23 +1306,66 @@
613
1306
  }
614
1307
  }
615
1308
 
616
- // ─── Connect ───────────────────────────────────
1309
+ // ── Auth panel events ──
1310
+ var authInput = authPanel.querySelector('[data-testid="webchat-auth-input"]');
1311
+ var authSubmitBtn = authPanel.querySelector('[data-testid="webchat-auth-submit"]');
1312
+
1313
+ function submitAuth() {
1314
+ var key = authInput.value.trim();
1315
+ if (!key) return;
1316
+ var errEl = authPanel.querySelector('[data-testid="webchat-auth-error"]');
1317
+ if (errEl) errEl.textContent = '';
1318
+ activeApiKey = key;
1319
+ setStoredApiKey(key);
1320
+ startConnection();
1321
+ }
1322
+
1323
+ authSubmitBtn.addEventListener('click', submitAuth);
1324
+ authInput.addEventListener('keydown', function (e) {
1325
+ if (e.key === 'Enter') { e.preventDefault(); submitAuth(); }
1326
+ });
1327
+
1328
+ // Logout (standalone only)
1329
+ var logoutBtn = panel.querySelector('[data-testid="webchat-logout"]');
1330
+ if (logoutBtn) {
1331
+ logoutBtn.addEventListener('click', function () {
1332
+ clearStoredApiKey();
1333
+ activeApiKey = '';
1334
+ if (connection) { connection.close(); connection = null; }
1335
+ messages = [];
1336
+ streaming = false;
1337
+ stopRequested = false;
1338
+ streamBuffer = '';
1339
+ pendingAttachments = [];
1340
+ renderAttachStrip();
1341
+ updateSendBtn();
1342
+ showAuthView();
1343
+ authInput.value = '';
1344
+ });
1345
+ }
1346
+
1347
+ // ── Connection ──
617
1348
  function startConnection() {
1349
+ if (connection) { connection.close(); connection = null; }
618
1350
  connection = createConnection({
619
1351
  wsUrl: wsUrl,
620
- apiKey: API_KEY,
1352
+ apiKey: activeApiKey,
621
1353
  sessionId: sessionId,
622
1354
  onMessage: handleServerMessage,
623
1355
  onStatus: updateStatus,
624
1356
  });
625
1357
  }
626
1358
 
627
- // ─── Mount ─────────────────────────────────────
1359
+ // ── Mount / Destroy ──
628
1360
  function mount(target) {
629
1361
  (target || document.body).appendChild(container);
630
1362
  if (standalone) {
631
- startConnection();
632
- input.focus();
1363
+ if (activeApiKey) {
1364
+ startConnection();
1365
+ } else {
1366
+ showAuthView();
1367
+ authInput.focus();
1368
+ }
633
1369
  }
634
1370
  }
635
1371
 
@@ -641,11 +1377,9 @@
641
1377
  return { mount: mount, destroy: destroy, send: sendMessage };
642
1378
  }
643
1379
 
644
- // ─── Auto-mount ─────────────────────────────────────────────
1380
+ // ─── Auto-mount ──────────────────────────────────────────────────────────────
645
1381
  if (script) {
646
- var widget = createWidget({
647
- standalone: STANDALONE,
648
- });
1382
+ var widget = createWidget({ standalone: STANDALONE });
649
1383
 
650
1384
  if (document.readyState === 'loading') {
651
1385
  document.addEventListener('DOMContentLoaded', function () {
@@ -655,7 +1389,6 @@
655
1389
  widget.mount();
656
1390
  }
657
1391
 
658
- // Expose for programmatic access
659
1392
  window.CumulusChat = widget;
660
1393
  }
661
1394
  })();