incognitolm commited on
Commit
6cce35a
·
1 Parent(s): 0d361fd

Transition to Storage Bucket Public Folder

Browse files
public/.DS_Store DELETED
Binary file (8.2 kB)
 
public/css/.DS_Store DELETED
Binary file (6.15 kB)
 
public/css/base.css DELETED
@@ -1,200 +0,0 @@
1
- *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
2
-
3
- html, body { height: 100%; overflow: hidden; overscroll-behavior: none; }
4
-
5
- body {
6
- font-family: var(--font-sans);
7
- background: var(--bg);
8
- color: var(--text);
9
- font-size: 15px;
10
- line-height: 1.6;
11
- -webkit-font-smoothing: antialiased;
12
- }
13
-
14
- #app {
15
- display: flex;
16
- height: 100vh;
17
- overflow: hidden;
18
- }
19
-
20
- button { cursor: pointer; border: none; background: none; font: inherit; color: inherit; }
21
- a { color: var(--blue-bright); text-decoration: none; }
22
- a:hover { text-decoration: underline; }
23
- textarea { font: inherit; resize: none; }
24
- input { font: inherit; }
25
-
26
- .hidden { display: none !important; }
27
-
28
- /* Scrollbar — only show inside #chat-messages */
29
- ::-webkit-scrollbar { width: 5px; height: 5px; }
30
- ::-webkit-scrollbar-track { background: transparent; }
31
- ::-webkit-scrollbar-thumb { background: var(--bg-active); border-radius: 99px; }
32
- ::-webkit-scrollbar-thumb:hover { background: var(--border-bright); }
33
-
34
- /* Hide scrollbars globally except inside the chat messages container */
35
- html, body, #app, #main, .sidebar, .sidebar-sessions,
36
- #welcome-view, .welcome-view, #chat-view,
37
- .bottom-input-bar, .bottom-input-wrap,
38
- .bottom-textarea-wrap, .settings-pane {
39
- scrollbar-width: none;
40
- -ms-overflow-style: none;
41
- }
42
- html::-webkit-scrollbar,
43
- body::-webkit-scrollbar,
44
- #app::-webkit-scrollbar,
45
- #main::-webkit-scrollbar,
46
- .sidebar::-webkit-scrollbar,
47
- #welcome-view::-webkit-scrollbar,
48
- .welcome-view::-webkit-scrollbar,
49
- .bottom-input-bar::-webkit-scrollbar,
50
- .bottom-input-wrap::-webkit-scrollbar,
51
- .bottom-textarea-wrap::-webkit-scrollbar {
52
- display: none;
53
- }
54
-
55
- /* Only show scrollbar inside the actual chat messages container */
56
- #chat-messages {
57
- scrollbar-width: thin;
58
- scrollbar-color: var(--bg-active) transparent;
59
- }
60
- #chat-messages::-webkit-scrollbar { width: 5px; height: 5px; display: block; }
61
-
62
- /* Also keep scrollbar in modals and sidebar session list */
63
- .modal-box { scrollbar-width: thin; }
64
- .modal-box::-webkit-scrollbar { display: block; width: 5px; }
65
- .sidebar-sessions { scrollbar-width: thin; }
66
- .sidebar-sessions::-webkit-scrollbar { display: block; width: 4px; }
67
-
68
- /* Focus visible */
69
- :focus-visible { outline: 2px solid var(--blue-bright); outline-offset: 2px; }
70
-
71
- /* Buttons */
72
- .btn-primary {
73
- display: inline-flex; align-items: center; justify-content: center; gap: 6px;
74
- padding: 8px 18px; border-radius: var(--radius-full);
75
- background: var(--yellow); color: #111; font-weight: 600; font-size: 14px;
76
- transition: opacity var(--transition), transform var(--transition);
77
- }
78
- .btn-primary:hover { opacity: 0.88; transform: translateY(-1px); }
79
- .btn-primary:active { transform: translateY(0); }
80
-
81
- .btn-ghost {
82
- display: inline-flex; align-items: center; justify-content: center;
83
- padding: 8px 16px; border-radius: var(--radius-full);
84
- border: 1px solid var(--border-bright);
85
- color: var(--text-dim); font-size: 14px;
86
- transition: background var(--transition), color var(--transition);
87
- }
88
- .btn-ghost:hover { background: var(--bg-hover); color: var(--text); }
89
-
90
- .btn-danger {
91
- display: inline-flex; align-items: center; justify-content: center;
92
- padding: 8px 16px; border-radius: var(--radius-full);
93
- background: rgba(220, 60, 60, 0.12); border: 1px solid rgba(220,60,60,0.3);
94
- color: #f07070; font-size: 14px;
95
- transition: background var(--transition);
96
- }
97
- .btn-danger:hover { background: rgba(220,60,60,0.22); }
98
-
99
- /* Toggle switch */
100
- .toggle-switch {
101
- position: relative; display: inline-flex; align-items: center;
102
- width: 36px; height: 20px; flex-shrink: 0;
103
- }
104
- .toggle-switch input { opacity: 0; width: 0; height: 0; }
105
- .toggle-track {
106
- position: absolute; inset: 0;
107
- background: var(--bg-active); border-radius: var(--radius-full);
108
- transition: background var(--transition);
109
- cursor: pointer;
110
- }
111
- .toggle-thumb {
112
- position: absolute; left: 3px; top: 3px;
113
- width: 14px; height: 14px; border-radius: 50%;
114
- background: white; transition: transform var(--transition);
115
- pointer-events: none;
116
- }
117
- .toggle-switch input:checked + .toggle-track { background: var(--yellow); }
118
- .toggle-switch input:checked ~ .toggle-thumb { transform: translateX(16px); }
119
-
120
- /* Notification */
121
- .notifications {
122
- position: fixed; bottom: 20px; right: 20px;
123
- display: flex; flex-direction: column; gap: 8px;
124
- z-index: var(--z-notify); pointer-events: none;
125
- }
126
- .notification {
127
- display: flex; align-items: flex-start; gap: 10px;
128
- padding: 10px 12px 10px 14px; border-radius: var(--radius-md);
129
- background: var(--bg-raised); border: 1px solid var(--border-bright);
130
- box-shadow: var(--shadow-md);
131
- max-width: 360px; font-size: 13px; color: var(--text);
132
- pointer-events: auto;
133
- animation: slideInRight 0.22s ease;
134
- position: relative;
135
- }
136
- .notification.warning { border-color: rgba(229,200,70,0.4); }
137
- .notification.error { border-color: rgba(220,60,60,0.4); }
138
- .notification.success { border-color: rgba(45,212,166,0.4); }
139
- .notification .notif-close {
140
- position: absolute;
141
- top: 6px;
142
- right: 8px;
143
- color: var(--text-muted); font-size: 15px;
144
- background: none; border: none; cursor: pointer;
145
- line-height: 1; padding: 2px 4px;
146
- border-radius: 4px;
147
- transition: color var(--transition), background var(--transition);
148
- }
149
- .notification .notif-close:hover {
150
- color: var(--text); background: var(--bg-hover);
151
- }
152
-
153
- /* Share banner */
154
- .share-banner {
155
- position: fixed; top: 0; left: 0; right: 0; z-index: 900;
156
- background: linear-gradient(90deg, #1a1b20, #22242b);
157
- border-bottom: 1px solid var(--border-bright);
158
- padding: 10px 20px;
159
- }
160
- .share-banner-inner {
161
- display: flex; align-items: center; gap: 12px; max-width: 800px; margin: 0 auto;
162
- font-size: 14px;
163
- }
164
- .share-icon { font-size: 18px; }
165
-
166
- /* Markdown bullet points — proper indentation */
167
- .msg-assistant ul,
168
- .msg-assistant ol,
169
- .msg-user ul,
170
- .msg-user ol {
171
- padding-left: 1.6em;
172
- margin: 6px 0;
173
- }
174
- .msg-assistant li,
175
- .msg-user li {
176
- margin-bottom: 2px;
177
- padding-left: 0.2em;
178
- }
179
- .msg-assistant ul li::marker,
180
- .msg-user ul li::marker {
181
- color: var(--text-dim);
182
- }
183
-
184
- /* Animations */
185
- @keyframes slideInRight {
186
- from { transform: translateX(20px); opacity: 0; }
187
- to { transform: translateX(0); opacity: 1; }
188
- }
189
- @keyframes fadeIn {
190
- from { opacity: 0; }
191
- to { opacity: 1; }
192
- }
193
- @keyframes slideUp {
194
- from { transform: translateY(12px); opacity: 0; }
195
- to { transform: translateY(0); opacity: 1; }
196
- }
197
- @keyframes shimmer {
198
- 0% { background-position: -200% 0; }
199
- 100% { background-position: 200% 0; }
200
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
public/css/chat.css DELETED
@@ -1,569 +0,0 @@
1
- /* ── Main layout ─────────────────────────────────────────────────────────── */
2
- #main {
3
- flex: 1;
4
- min-width: 0;
5
- display: flex;
6
- flex-direction: column;
7
- height: 100vh;
8
- overflow: hidden;
9
- position: relative;
10
- }
11
-
12
- /* ── Welcome view ────────────────────────────────────────────────────────── */
13
- .welcome-view {
14
- flex: 1;
15
- display: flex;
16
- flex-direction: column;
17
- align-items: center;
18
- justify-content: center;
19
- padding: 40px 24px;
20
- gap: 32px;
21
- min-height: 0;
22
- overflow: hidden;
23
- }
24
-
25
- .welcome-title {
26
- font-size: clamp(26px, 4vw, 44px);
27
- font-weight: 300;
28
- letter-spacing: -0.03em;
29
- color: var(--text);
30
- text-align: center;
31
- }
32
-
33
- .welcome-input-wrap {
34
- width: 100%;
35
- max-width: 760px;
36
- }
37
-
38
- /* ── Chat view ───────────────────────────────────────────────────────────── */
39
- .chat-view {
40
- flex: 1;
41
- min-height: 0;
42
- overflow-y: auto;
43
- overflow-x: hidden;
44
- padding: 24px 0 8px;
45
- display: flex;
46
- flex-direction: column;
47
- }
48
-
49
- .chat-messages {
50
- width: 100%;
51
- max-width: 780px;
52
- margin: 0 auto;
53
- padding: 0 24px;
54
- display: flex;
55
- flex-direction: column;
56
- gap: 4px;
57
- }
58
-
59
- /* ── Message group ───────────────────────────────────────────────────────── */
60
- .msg-group {
61
- display: flex;
62
- flex-direction: column;
63
- gap: 0;
64
- position: relative;
65
- /* Extra bottom space so action buttons don't clip into next group */
66
- margin-bottom: 18px;
67
- }
68
-
69
- /* User bubble — aligned right */
70
- .msg-user {
71
- align-self: flex-end;
72
- max-width: 78%;
73
- padding: 10px 16px;
74
- background: var(--bg-raised);
75
- border: 1px solid var(--border);
76
- border-radius: 18px 18px 4px 18px;
77
- font-size: 15px;
78
- color: var(--text);
79
- word-break: break-word;
80
- position: relative;
81
- }
82
-
83
- /* User bubble editing state */
84
- .msg-user.editing-user {
85
- max-width: 86%;
86
- width: 86%;
87
- align-self: flex-end;
88
- border-radius: var(--radius-lg);
89
- border-color: rgba(74,158,255,0.4);
90
- box-shadow: 0 0 0 2px rgba(74,158,255,0.08);
91
- padding: 12px 14px 10px;
92
- }
93
-
94
- /* Assistant bubble - transparent */
95
- .msg-assistant {
96
- align-self: flex-start;
97
- max-width: 92%;
98
- padding: 4px 0;
99
- font-size: 15px;
100
- color: var(--text);
101
- word-break: break-word;
102
- position: relative;
103
- }
104
-
105
- /* Assistant editing state */
106
- .msg-assistant.editing {
107
- background: rgba(255,255,255,0.04);
108
- border: 1px solid var(--border-bright);
109
- border-radius: var(--radius-lg);
110
- padding: 14px 16px 52px;
111
- width: 100%;
112
- max-width: 100%;
113
- box-shadow: 0 0 0 2px rgba(74,158,255,0.1);
114
- }
115
-
116
- /* ── Version navigator ───────────────────────────────────────────────────── */
117
- /*
118
- * Sits BELOW the user bubble, right-aligned, always visible when present.
119
- * (Version nav is only added to user messages.)
120
- */
121
- .msg-version-nav {
122
- align-self: flex-end;
123
- display: flex;
124
- align-items: center;
125
- gap: 2px;
126
- font-size: 12px;
127
- color: var(--text-muted);
128
- margin-top: 3px;
129
- /* Always visible — no opacity:0 trick since it's structural */
130
- }
131
-
132
- .msg-version-nav button {
133
- display: flex; align-items: center; justify-content: center;
134
- width: 22px; height: 22px; border-radius: 50%;
135
- color: var(--text-muted);
136
- font-size: 16px; line-height: 1;
137
- transition: color var(--transition), background var(--transition);
138
- }
139
- .msg-version-nav button:hover:not(:disabled) { color: var(--text); background: var(--bg-hover); }
140
- .msg-version-nav button:disabled { opacity: 0.3; cursor: default; }
141
-
142
- /* ── Message action buttons ──────────────────────────────────────────────── */
143
- /*
144
- * Action buttons live BELOW their bubble in the flex column.
145
- * They fade in on group hover.
146
- */
147
- .msg-actions {
148
- display: flex;
149
- gap: 2px;
150
- opacity: 0;
151
- transition: opacity var(--transition);
152
- pointer-events: none;
153
- margin-top: 4px;
154
- }
155
-
156
- /* User actions: right-aligned */
157
- .msg-actions-right {
158
- align-self: flex-end;
159
- flex-direction: row-reverse;
160
- }
161
-
162
- /* Assistant actions: left-aligned with a tiny top gap */
163
- .msg-actions-left {
164
- align-self: flex-start;
165
- padding-top: 1px;
166
- }
167
-
168
- .msg-group:hover .msg-actions,
169
- .msg-group:focus-within .msg-actions {
170
- opacity: 1;
171
- pointer-events: auto;
172
- }
173
-
174
- .msg-action-btn {
175
- display: flex; align-items: center; justify-content: center;
176
- width: 26px; height: 26px;
177
- border-radius: var(--radius-sm);
178
- color: var(--text-muted);
179
- transition: color var(--transition), background var(--transition);
180
- }
181
- .msg-action-btn:hover { color: var(--text); background: var(--bg-hover); }
182
-
183
- /* Edit toolbar (assistant bubble) */
184
- .edit-actions {
185
- position: absolute;
186
- bottom: 10px;
187
- right: 12px;
188
- display: flex;
189
- gap: 6px;
190
- }
191
-
192
- /* ── Edit toolbar for user messages ─────────────────────────────────────── */
193
- .edit-toolbar {
194
- display: flex;
195
- align-items: center;
196
- justify-content: space-between;
197
- flex-wrap: wrap;
198
- gap: 6px;
199
- margin-top: 8px;
200
- padding-top: 8px;
201
- border-top: 1px solid var(--border);
202
- }
203
-
204
- .edit-tool-row {
205
- display: flex;
206
- align-items: center;
207
- gap: 3px;
208
- flex-wrap: wrap;
209
- flex: 1;
210
- }
211
-
212
- .edit-tool-btn {
213
- /* Inherits .tool-btn-sm styles */
214
- }
215
-
216
- .edit-attach-btn {
217
- display: flex; align-items: center; justify-content: center;
218
- width: 26px; height: 26px;
219
- border-radius: var(--radius-sm);
220
- color: var(--text-muted);
221
- border: 1px solid var(--border-bright);
222
- transition: color var(--transition), background var(--transition);
223
- flex-shrink: 0;
224
- }
225
- .edit-attach-btn:hover { color: var(--text); background: var(--bg-hover); }
226
-
227
- .edit-btn-row {
228
- display: flex;
229
- align-items: center;
230
- gap: 6px;
231
- flex-shrink: 0;
232
- }
233
-
234
- .edit-send-btn {
235
- display: inline-flex; align-items: center; gap: 5px;
236
- font-size: 12px !important;
237
- padding: 5px 12px !important;
238
- }
239
-
240
- .edit-file-preview-row {
241
- display: none;
242
- flex-wrap: wrap;
243
- gap: 6px;
244
- padding: 4px 0 0;
245
- }
246
-
247
- /* ── Thinking animation ──────────────────────────────────────────────────── */
248
- .msg-thinking {
249
- display: flex; gap: 4px; align-items: center;
250
- padding: 10px 0;
251
- }
252
- .thinking-dot {
253
- width: 6px; height: 6px; border-radius: 50%;
254
- background: var(--text-muted);
255
- animation: thinkBounce 1.4s infinite ease-in-out;
256
- }
257
- .thinking-dot:nth-child(2) { animation-delay: 0.2s; }
258
- .thinking-dot:nth-child(3) { animation-delay: 0.4s; }
259
- @keyframes thinkBounce {
260
- 0%, 80%, 100% { transform: translateY(0); opacity: 0.4; }
261
- 40% { transform: translateY(-6px); opacity: 1; }
262
- }
263
-
264
- /* ── Code blocks ─────────────────────────────────────────────────────────── */
265
- .code-block {
266
- margin: 10px 0;
267
- border-radius: var(--radius-md);
268
- overflow: hidden;
269
- border: 1px solid var(--border);
270
- background: #0d0e11;
271
- }
272
- [data-theme="light"] .code-block { background: #1a1b20; }
273
-
274
- .code-header {
275
- display: flex; align-items: center; justify-content: space-between;
276
- padding: 6px 14px;
277
- background: rgba(255,255,255,0.04);
278
- border-bottom: 1px solid var(--border);
279
- }
280
- .code-lang {
281
- font-size: 12px; font-family: var(--font-mono);
282
- color: var(--text-muted);
283
- }
284
- .code-copy-btn {
285
- font-size: 11px; color: var(--text-muted);
286
- padding: 2px 8px; border-radius: 4px;
287
- cursor: pointer;
288
- transition: color var(--transition), background var(--transition);
289
- }
290
- .code-copy-btn:hover { color: var(--text); background: var(--bg-hover); }
291
-
292
- .code-block pre {
293
- padding: 14px 16px;
294
- overflow-x: auto;
295
- font-size: 13px;
296
- font-family: var(--font-mono);
297
- line-height: 1.6;
298
- color: #e2e8f0;
299
- }
300
-
301
- .code-block pre code,
302
- .code-block code {
303
- background: none !important;
304
- padding: 0 !important;
305
- border-radius: 0 !important;
306
- font-size: inherit !important;
307
- color: inherit !important;
308
- }
309
-
310
- /* Inline code */
311
- .msg-assistant .inline-code,
312
- .msg-user .inline-code,
313
- .msg-assistant > p > code,
314
- .msg-user > p > code {
315
- font-family: var(--font-mono);
316
- font-size: 0.88em;
317
- background: var(--bg-active);
318
- padding: 1px 5px;
319
- border-radius: 4px;
320
- }
321
-
322
- /* Tables */
323
- .msg-assistant table {
324
- border-collapse: collapse;
325
- width: 100%;
326
- margin: 10px 0;
327
- font-size: 14px;
328
- }
329
- .msg-assistant th, .msg-assistant td {
330
- border: 1px solid var(--border-bright);
331
- padding: 7px 12px;
332
- text-align: left;
333
- }
334
- .msg-assistant th { background: var(--bg-raised); font-weight: 600; }
335
- .msg-assistant tr:nth-child(even) { background: rgba(255,255,255,0.02); }
336
-
337
- /* Images/video/audio in chat */
338
- .msg-media {
339
- max-width: 420px;
340
- border-radius: var(--radius-md);
341
- overflow: hidden;
342
- margin: 8px 0;
343
- position: relative;
344
- }
345
- .msg-media img {
346
- display: block; width: 100%;
347
- border-radius: var(--radius-md);
348
- cursor: pointer;
349
- transition: opacity var(--transition);
350
- }
351
- .msg-media img:hover { opacity: 0.9; }
352
- .msg-media video, .msg-media audio {
353
- width: 100%;
354
- border-radius: var(--radius-md);
355
- }
356
- .media-download-btn {
357
- position: absolute; top: 8px; right: 8px;
358
- padding: 4px 10px; border-radius: var(--radius-full);
359
- background: rgba(0,0,0,0.6); color: white; font-size: 11px;
360
- opacity: 0; transition: opacity var(--transition);
361
- }
362
- .msg-media:hover .media-download-btn { opacity: 1; }
363
-
364
- /* Tool call bubble */
365
- .msg-tool-call {
366
- display: inline-flex; align-items: center; gap: 6px;
367
- padding: 4px 10px; border-radius: var(--radius-full);
368
- background: var(--bg-raised); border: 1px solid var(--border);
369
- font-size: 12px; color: var(--text-dim);
370
- cursor: pointer; margin: 4px 0;
371
- transition: border-color var(--transition), color var(--transition);
372
- }
373
- .msg-tool-call:hover { border-color: var(--blue-bright); color: var(--text); }
374
- .msg-tool-call svg { color: var(--yellow); }
375
-
376
- /* Span colors from AI */
377
- span[data-color="green"] { color: #4ade80; }
378
- span[data-color="blue"] { color: #60a5fa; }
379
- span[data-color="red"] { color: #f87171; }
380
- span[data-color="orange"] { color: #fb923c; }
381
- span[data-color="yellow"] { color: #facc15; }
382
- span[data-color="purple"] { color: #c084fc; }
383
- span[data-color="teal"] { color: #2dd4bf; }
384
- span[data-color="gold"] { color: #e5c846; }
385
- span[data-color="coral"] { color: #f97316; }
386
- span[data-color="pink"] { color: #f472b6; }
387
-
388
- /* Pasted content chip */
389
- .pasted-chip {
390
- display: inline-flex; align-items: center; gap: 6px;
391
- padding: 4px 10px; border-radius: var(--radius-full);
392
- background: var(--bg-raised); border: 1px solid var(--border-bright);
393
- font-size: 12px; color: var(--text-dim); cursor: pointer;
394
- margin: 4px 0;
395
- }
396
- .pasted-chip .chip-remove {
397
- width: 14px; height: 14px; border-radius: 50%;
398
- display: flex; align-items: center; justify-content: center;
399
- font-size: 10px; color: var(--text-muted);
400
- transition: background var(--transition);
401
- }
402
- .pasted-chip .chip-remove:hover { background: var(--bg-hover); color: var(--text); }
403
-
404
- /* Attached image thumb */
405
- .attach-thumb {
406
- display: inline-block;
407
- width: 48px; height: 48px;
408
- border-radius: var(--radius-sm); overflow: hidden;
409
- position: relative; cursor: pointer;
410
- }
411
- .attach-thumb img { width: 100%; height: 100%; object-fit: cover; }
412
- .attach-thumb .thumb-remove {
413
- position: absolute; top: -4px; right: -4px;
414
- width: 16px; height: 16px; border-radius: 50%;
415
- background: var(--bg); border: 1px solid var(--border-bright);
416
- display: flex; align-items: center; justify-content: center;
417
- font-size: 9px; color: var(--text);
418
- }
419
-
420
- /* SVG side panel */
421
- #svg-panel {
422
- position: fixed; right: 0; top: 0; bottom: 0;
423
- width: 320px; background: var(--bg-surface);
424
- border-left: 1px solid var(--border-bright);
425
- z-index: 200; display: flex; flex-direction: column;
426
- animation: slideInRight 0.2s ease;
427
- }
428
- #svg-panel-header {
429
- display: flex; align-items: center; justify-content: space-between;
430
- padding: 12px 16px; border-bottom: 1px solid var(--border);
431
- }
432
- #svg-panel-content { flex: 1; overflow: auto; padding: 16px; }
433
- #svg-panel img { max-width: 100%; border-radius: var(--radius-sm); }
434
-
435
- /* Generating animation */
436
- .msg-generating {
437
- animation: generatingGlow 2s ease infinite;
438
- }
439
- @keyframes generatingGlow {
440
- 0%, 100% { opacity: 1; }
441
- 50% { opacity: 0.75; }
442
- }
443
-
444
- /* ══════════════════════════════════════════════════════════════════════════
445
- MOBILE CHAT
446
- ══════════════════════════════════════════════════════════════════════════ */
447
-
448
- @media (max-width: 768px) {
449
-
450
- /* Main: full height, strict no-overflow */
451
- #main {
452
- height: 100dvh;
453
- max-height: 100dvh;
454
- overflow: hidden;
455
- display: flex;
456
- flex-direction: column;
457
- }
458
-
459
- /* Chat view: the only vertical scrollbar */
460
- .chat-view {
461
- flex: 1;
462
- min-height: 0;
463
- overflow-y: auto;
464
- overflow-x: hidden;
465
- -webkit-overflow-scrolling: touch;
466
- overscroll-behavior-y: contain;
467
- /* Account for fixed top bar */
468
- padding-top: 4px;
469
- }
470
-
471
- /* Welcome view: centered, no scroll */
472
- .welcome-view {
473
- flex: 1;
474
- min-height: 0;
475
- overflow: hidden;
476
- display: flex;
477
- flex-direction: column;
478
- align-items: center;
479
- justify-content: center;
480
- padding-top: 0 !important;
481
- padding-bottom: 40px !important;
482
- gap: 20px !important;
483
- }
484
-
485
- .welcome-title {
486
- text-align: center;
487
- font-size: clamp(22px, 6vw, 36px) !important;
488
- }
489
-
490
- /* Messages: no extra horizontal overflow */
491
- .chat-messages {
492
- padding: 0 12px;
493
- max-width: 100%;
494
- /* Ensure nothing bleeds sideways */
495
- overflow-x: hidden;
496
- }
497
-
498
- /* Bubbles */
499
- .msg-user {
500
- max-width: 88%;
501
- font-size: 14px;
502
- padding: 9px 14px;
503
- }
504
-
505
- .msg-user.editing-user {
506
- max-width: 98%;
507
- width: 98%;
508
- }
509
-
510
- .msg-assistant {
511
- max-width: 100%;
512
- font-size: 14px;
513
- }
514
-
515
- /* Code blocks: horizontal scroll only */
516
- .code-block {
517
- overflow: hidden;
518
- }
519
-
520
- .code-block pre {
521
- overflow-x: auto;
522
- overflow-y: hidden;
523
- -webkit-overflow-scrolling: touch;
524
- font-size: 12px;
525
- }
526
-
527
- /* Tables: horizontal scroll only */
528
- .msg-assistant table {
529
- display: block;
530
- overflow-x: auto;
531
- -webkit-overflow-scrolling: touch;
532
- max-width: 100%;
533
- }
534
-
535
- /* Edit toolbar on mobile: stack tool row and button row */
536
- .edit-toolbar {
537
- flex-direction: column;
538
- align-items: stretch;
539
- }
540
-
541
- .edit-tool-row {
542
- flex-wrap: wrap;
543
- }
544
-
545
- /* On mobile, hide tool label text in edit toolbar */
546
- .edit-tool-btn span {
547
- display: none;
548
- }
549
- .edit-tool-btn {
550
- padding: 3px 6px;
551
- min-width: 28px;
552
- justify-content: center;
553
- }
554
-
555
- .edit-btn-row {
556
- justify-content: flex-end;
557
- }
558
-
559
- /* Action buttons: always show on mobile (no hover) */
560
- .msg-actions {
561
- opacity: 1;
562
- pointer-events: auto;
563
- }
564
-
565
- /* Version nav: always visible, compact */
566
- .msg-version-nav {
567
- font-size: 11px;
568
- }
569
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
public/css/input.css DELETED
@@ -1,413 +0,0 @@
1
- /* ── Center (welcome) input ──────────────────────────────────────────────── */
2
- .center-input-container {
3
- display: flex;
4
- flex-direction: column;
5
- background: var(--bg-raised);
6
- border: 1px solid var(--border-bright);
7
- border-radius: var(--radius-xl);
8
- padding: 12px 14px 10px;
9
- gap: 10px;
10
- box-shadow: var(--shadow-md);
11
- transition: border-color var(--transition), box-shadow var(--transition);
12
- }
13
- .center-input-container:focus-within {
14
- border-color: rgba(74,158,255,0.4);
15
- box-shadow: var(--shadow-glow-blue);
16
- }
17
-
18
- .center-textarea {
19
- width: 100%;
20
- background: transparent;
21
- border: none;
22
- outline: none;
23
- color: var(--text);
24
- font-size: 16px;
25
- line-height: 1.55;
26
- min-height: 26px;
27
- max-height: calc(1.55em * 6 + 24px);
28
- overflow-y: auto;
29
- padding: 0 4px;
30
- }
31
- .center-textarea::placeholder { color: var(--text-muted); }
32
-
33
- .center-input-actions {
34
- display: flex;
35
- align-items: center;
36
- gap: 4px;
37
- }
38
-
39
- .tool-btn {
40
- display: flex; align-items: center; justify-content: center;
41
- width: 30px; height: 30px;
42
- border-radius: var(--radius-md);
43
- color: var(--text-muted);
44
- transition: color var(--transition), background var(--transition);
45
- }
46
- .tool-btn:hover { color: var(--text); background: var(--bg-hover); }
47
- .tool-btn.active { color: var(--yellow); background: var(--yellow-glow); }
48
-
49
- .send-btn-center {
50
- display: flex; align-items: center; justify-content: center;
51
- width: 34px; height: 34px; border-radius: 50%;
52
- background: var(--yellow); color: #111;
53
- margin-left: auto;
54
- transition: opacity var(--transition), transform var(--transition);
55
- flex-shrink: 0;
56
- }
57
- .send-btn-center:hover { opacity: 0.88; transform: scale(1.05); }
58
- .send-btn-center:disabled { opacity: 0.4; transform: none; }
59
-
60
- /* Attach btn beside center input */
61
- .attach-btn-wrap {
62
- display: flex;
63
- align-items: flex-end;
64
- }
65
- .attach-btn {
66
- display: flex; align-items: center; justify-content: center;
67
- width: 34px; height: 34px; border-radius: 50%;
68
- color: var(--text-muted);
69
- border: 1px solid var(--border-bright);
70
- transition: color var(--transition), background var(--transition);
71
- }
72
- .attach-btn:hover { color: var(--text); background: var(--bg-hover); }
73
-
74
- /* ── Bottom input bar ────────────────────────────────────────────────────── */
75
- .bottom-input-bar {
76
- flex-shrink: 0;
77
- padding: 0 24px 4px;
78
- }
79
-
80
- .file-preview-row {
81
- display: flex; flex-wrap: wrap; gap: 8px;
82
- padding: 0 0 8px;
83
- max-width: 760px; margin: 0 auto;
84
- }
85
-
86
- .bottom-input-wrap {
87
- display: flex;
88
- align-items: flex-end;
89
- gap: 8px;
90
- max-width: 760px;
91
- margin: 0 auto;
92
- background: var(--bg-raised);
93
- border: 1px solid var(--border-bright);
94
- border-radius: var(--radius-xl);
95
- padding: 8px 10px;
96
- box-shadow: var(--shadow-md);
97
- transition: border-color var(--transition), box-shadow var(--transition);
98
- }
99
- .bottom-input-wrap:focus-within {
100
- border-color: rgba(74,158,255,0.4);
101
- box-shadow: var(--shadow-glow-blue);
102
- }
103
-
104
- .attach-btn-bottom {
105
- flex-shrink: 0;
106
- display: flex; align-items: center; justify-content: center;
107
- width: 34px; height: 34px; border-radius: 50%;
108
- color: var(--text-muted);
109
- border: 1px solid var(--border-bright);
110
- margin-bottom: 4px;
111
- transition: color var(--transition), background var(--transition);
112
- }
113
- .attach-btn-bottom:hover { color: var(--text); background: var(--bg-hover); }
114
-
115
- .bottom-textarea-wrap {
116
- flex: 1; min-width: 0;
117
- display: flex; flex-direction: column; gap: 6px;
118
- }
119
-
120
- .tool-row {
121
- display: flex; gap: 4px;
122
- }
123
-
124
- .tool-btn-sm {
125
- display: inline-flex; align-items: center; gap: 5px;
126
- padding: 3px 9px; border-radius: var(--radius-full);
127
- font-size: 12px; color: var(--text-muted);
128
- border: 1px solid transparent;
129
- transition: color var(--transition), border-color var(--transition), background var(--transition);
130
- }
131
- .tool-btn-sm:hover { color: var(--text); border-color: var(--border-bright); }
132
- .tool-btn-sm.active {
133
- color: var(--yellow);
134
- border-color: rgba(229,200,70,0.3);
135
- background: var(--yellow-glow);
136
- }
137
-
138
- .bottom-textarea {
139
- width: 100%;
140
- background: transparent;
141
- border: none;
142
- outline: none;
143
- color: var(--text);
144
- font-size: 15px;
145
- line-height: 1.55;
146
- min-height: calc(1.55em + 2px);
147
- max-height: calc(1.55em * 6 + 2px);
148
- overflow-y: auto;
149
- padding: 0;
150
- }
151
- .bottom-textarea::placeholder { color: var(--text-muted); }
152
-
153
- .send-btn-bottom {
154
- flex-shrink: 0;
155
- display: flex; align-items: center; justify-content: center;
156
- width: 34px; height: 34px; border-radius: 50%;
157
- background: var(--yellow); color: #111;
158
- margin-bottom: 2px;
159
- transition: opacity var(--transition), transform var(--transition), background var(--transition);
160
- }
161
- .send-btn-bottom:hover { opacity: 0.88; transform: scale(1.05); }
162
- .send-btn-bottom:disabled { opacity: 0.4; transform: none; cursor: not-allowed; }
163
- .send-btn-bottom.stop { background: var(--bg-active); color: var(--text); border: 1px solid var(--border-bright); }
164
-
165
- /* ── Attachment previews inside inputs ───────────────────────────────────── */
166
- .input-file-preview-row {
167
- display: none;
168
- flex-wrap: wrap;
169
- gap: 6px;
170
- padding: 6px 4px 2px;
171
- }
172
-
173
- .center-input-row {
174
- display: flex;
175
- align-items: flex-end;
176
- gap: 6px;
177
- }
178
-
179
- .attach-preview-item {
180
- position: relative;
181
- display: inline-flex;
182
- align-items: center;
183
- flex-shrink: 0;
184
- }
185
-
186
- .attach-preview-item img {
187
- width: 52px;
188
- height: 52px;
189
- object-fit: cover;
190
- border-radius: var(--radius-sm);
191
- display: block;
192
- border: 1px solid var(--border-bright);
193
- }
194
-
195
- .attach-preview-remove {
196
- position: absolute;
197
- top: -5px;
198
- right: -5px;
199
- width: 16px;
200
- height: 16px;
201
- border-radius: 50%;
202
- background: var(--bg-raised);
203
- border: 1px solid var(--border-bright);
204
- display: flex;
205
- align-items: center;
206
- justify-content: center;
207
- font-size: 10px;
208
- color: var(--text);
209
- cursor: pointer;
210
- z-index: 1;
211
- line-height: 1;
212
- }
213
- .attach-preview-remove:hover { background: var(--bg-hover); }
214
-
215
- /* ── File chip (text attachments) ────────────────────────────────────────── */
216
- .file-attachment-chip {
217
- display: inline-flex;
218
- align-items: center;
219
- gap: 7px;
220
- padding: 6px 10px;
221
- border-radius: var(--radius-sm);
222
- background: var(--bg-hover);
223
- border: 1px solid var(--border-bright);
224
- cursor: pointer;
225
- max-width: 180px;
226
- transition: background var(--transition), border-color var(--transition);
227
- font-size: 12px;
228
- color: var(--text-dim);
229
- min-height: 40px;
230
- }
231
- .file-attachment-chip:hover {
232
- background: var(--bg-active);
233
- border-color: rgba(74,158,255,0.4);
234
- color: var(--text);
235
- }
236
- .file-attachment-chip .chip-icon {
237
- font-size: 18px;
238
- flex-shrink: 0;
239
- }
240
- .file-attachment-chip .chip-name {
241
- white-space: nowrap;
242
- overflow: hidden;
243
- text-overflow: ellipsis;
244
- font-weight: 500;
245
- font-size: 12px;
246
- }
247
- .file-attachment-chip .chip-meta {
248
- font-size: 10px;
249
- color: var(--text-muted);
250
- white-space: nowrap;
251
- }
252
-
253
- /* In a sent message — shown inside the message bubble */
254
- .msg-file-attachments {
255
- display: flex;
256
- flex-wrap: wrap;
257
- gap: 6px;
258
- margin-top: 8px;
259
- }
260
-
261
- /* ── TOS banner ──────────────────────────────────────────────────────────── */
262
- #tos-banner {
263
- font-size: 12px;
264
- color: rgba(255,255,255,0.45);
265
- padding: 5px 10px;
266
- width: 100%;
267
- text-align: center;
268
- background-color: var(--bg);
269
- border-top: 1px solid var(--border);
270
- order: 999;
271
- flex-shrink: 0;
272
- }
273
- #tos-banner p { margin: 0; }
274
- #tos-banner a {
275
- color: rgba(255,255,255,0.7);
276
- text-decoration: underline;
277
- }
278
- [data-theme="light"] #tos-banner {
279
- color: rgba(0,0,0,0.4);
280
- }
281
- [data-theme="light"] #tos-banner a {
282
- color: rgba(0,0,0,0.65);
283
- }
284
-
285
- /* ── Mobile: hide tool button text labels, show icons only ───────────────── */
286
- @media (max-width: 600px) {
287
- .tool-btn-sm span {
288
- display: none;
289
- }
290
- .tool-btn-sm {
291
- padding: 3px 6px;
292
- min-width: 28px;
293
- justify-content: center;
294
- }
295
-
296
- /* Adjust TOS banner for mobile */
297
- #tos-banner {
298
- font-size: 10px;
299
- padding: 4px 10px;
300
- }
301
- }
302
-
303
- /* ══════════════════════════════════════════════════════════════════════════
304
- MOBILE INPUT — Top bar chrome, TOS at bottom, no overflow
305
- ══════════════════════════════════════════════════════════════════════════ */
306
-
307
- @media (max-width: 768px) {
308
-
309
- /* ── Mobile top header bar (new chat + sidebar buttons) ── */
310
- #mobile-top-bar {
311
- display: flex;
312
- align-items: center;
313
- justify-content: space-between;
314
- padding: 10px 16px;
315
- background: var(--bg);
316
- border-bottom: 1px solid var(--border);
317
- position: fixed;
318
- top: 0;
319
- left: 0;
320
- right: 0;
321
- height: 52px;
322
- z-index: 90;
323
- transform: translateY(0);
324
- transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
325
- flex-shrink: 0;
326
- }
327
-
328
- #mobile-top-bar.hidden-bar {
329
- transform: translateY(-100%);
330
- }
331
-
332
- #mobile-top-bar .mobile-bar-title {
333
- font-size: 15px;
334
- font-weight: 600;
335
- color: var(--text);
336
- letter-spacing: -0.01em;
337
- }
338
-
339
- #mobile-top-bar .mobile-bar-btn {
340
- display: flex;
341
- align-items: center;
342
- justify-content: center;
343
- width: 36px;
344
- height: 36px;
345
- border-radius: var(--radius-md);
346
- color: var(--text-dim);
347
- transition: background var(--transition), color var(--transition);
348
- }
349
-
350
- #mobile-top-bar .mobile-bar-btn:hover,
351
- #mobile-top-bar .mobile-bar-btn:active {
352
- background: var(--bg-hover);
353
- color: var(--text);
354
- }
355
-
356
- /* ── TOS banner: at bottom of main ── */
357
- #tos-banner {
358
- font-size: 10px;
359
- padding: 4px 14px;
360
- border-top: 1px solid var(--border);
361
- order: 999;
362
- flex-shrink: 0;
363
- }
364
-
365
- /* ── Main area: padded for top bar + TOS ── */
366
- #main {
367
- padding-top: 52px;
368
- }
369
-
370
- /* ── Bottom input bar: sits just above TOS ── */
371
- .bottom-input-bar {
372
- padding: 0 12px 4px !important;
373
- }
374
-
375
- /* ── Welcome view: truly vertically centered ── */
376
- .welcome-view {
377
- padding-top: 0 !important;
378
- padding-bottom: 20px !important;
379
- justify-content: center !important;
380
- align-items: center !important;
381
- height: 100% !important;
382
- gap: 20px !important;
383
- }
384
-
385
- .welcome-title {
386
- text-align: center;
387
- font-size: clamp(22px, 6vw, 36px) !important;
388
- }
389
-
390
- .welcome-input-wrap {
391
- width: 100%;
392
- padding: 0 4px;
393
- }
394
-
395
- /* Input wrap: tighter on mobile */
396
- .bottom-input-wrap,
397
- .center-input-container {
398
- padding: 6px 8px;
399
- }
400
-
401
- /* Prevent any horizontal overflow */
402
- .center-input-container,
403
- .bottom-input-wrap {
404
- max-width: 100%;
405
- }
406
- }
407
-
408
- /* Desktop: never show the mobile top bar */
409
- @media (min-width: 769px) {
410
- #mobile-top-bar {
411
- display: none !important;
412
- }
413
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
public/css/modals.css DELETED
@@ -1,212 +0,0 @@
1
- /* ── Modal overlay ───────────────────────────────────────────────────────── */
2
- .modal-overlay {
3
- position: fixed; inset: 0;
4
- background: rgba(0,0,0,0.6);
5
- backdrop-filter: blur(4px);
6
- z-index: var(--z-modal);
7
- display: flex; align-items: center; justify-content: center;
8
- padding: 24px;
9
- animation: fadeIn 0.16s ease;
10
- }
11
-
12
- .modal-box {
13
- background: var(--bg-surface);
14
- border: 1px solid var(--border-bright);
15
- border-radius: var(--radius-lg);
16
- box-shadow: var(--shadow-lg);
17
- width: 100%; max-width: 560px;
18
- max-height: 88vh; overflow-y: auto;
19
- animation: slideUp 0.2s ease;
20
- position: relative;
21
- }
22
-
23
- .modal-header {
24
- display: flex; align-items: center; justify-content: space-between;
25
- padding: 20px 24px 0;
26
- position: sticky; top: 0;
27
- background: var(--bg-surface);
28
- z-index: 1;
29
- }
30
- .modal-title { font-size: 17px; font-weight: 600; }
31
- .modal-close {
32
- width: 30px; height: 30px;
33
- display: flex; align-items: center; justify-content: center;
34
- border-radius: var(--radius-md); color: var(--text-muted);
35
- transition: color var(--transition), background var(--transition);
36
- }
37
- .modal-close:hover { color: var(--text); background: var(--bg-hover); }
38
-
39
- .modal-body { padding: 20px 24px; }
40
- .modal-footer {
41
- display: flex; justify-content: flex-end; gap: 8px;
42
- padding: 0 24px 20px;
43
- position: sticky; bottom: 0;
44
- background: var(--bg-surface);
45
- }
46
-
47
- /* Wide modal (settings) */
48
- .modal-box.wide { max-width: 720px; }
49
-
50
- /* ── Settings modal tabs ─────────────────────────────────────────────────── */
51
- .settings-tabs {
52
- display: flex; gap: 2px;
53
- border-bottom: 1px solid var(--border);
54
- padding: 0 24px;
55
- margin-bottom: 20px;
56
- }
57
- .settings-tab {
58
- padding: 10px 16px;
59
- font-size: 13px; font-weight: 500; color: var(--text-dim);
60
- border-bottom: 2px solid transparent;
61
- transition: color var(--transition), border-color var(--transition);
62
- cursor: pointer;
63
- }
64
- .settings-tab:hover { color: var(--text); }
65
- .settings-tab.active { color: var(--yellow); border-bottom-color: var(--yellow); }
66
-
67
- .settings-pane { display: none; }
68
- .settings-pane.active { display: block; }
69
-
70
- /* ── Form elements inside modals ─────────────────────────────────────────── */
71
- .form-group {
72
- display: flex; flex-direction: column; gap: 6px;
73
- margin-bottom: 16px;
74
- }
75
- .form-label {
76
- font-size: 13px; font-weight: 500; color: var(--text-dim);
77
- }
78
- .form-input {
79
- padding: 9px 12px;
80
- border-radius: var(--radius-md);
81
- background: var(--input-bg);
82
- border: 1px solid var(--input-border);
83
- color: var(--text); font-size: 14px;
84
- transition: border-color var(--transition), box-shadow var(--transition);
85
- width: 100%;
86
- }
87
- .form-input:focus {
88
- outline: none;
89
- border-color: rgba(74,158,255,0.5);
90
- box-shadow: 0 0 0 3px rgba(74,158,255,0.1);
91
- }
92
- .form-hint {
93
- font-size: 12px; color: var(--text-muted);
94
- }
95
- .form-error {
96
- font-size: 12px; color: #f87171;
97
- }
98
-
99
- /* ── Setting row ─────────────────────────────────────────────────────────── */
100
- .setting-row {
101
- display: flex; align-items: center; justify-content: space-between;
102
- padding: 12px 0;
103
- border-bottom: 1px solid var(--border);
104
- gap: 16px;
105
- }
106
- .setting-row:last-child { border-bottom: none; }
107
- .setting-label { font-size: 14px; font-weight: 500; }
108
- .setting-desc { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
109
-
110
- /* ── Sign in modal ───────────────────────────────────────────────────────── */
111
- .auth-tabs {
112
- display: flex; gap: 0;
113
- border: 1px solid var(--border-bright);
114
- border-radius: var(--radius-md);
115
- overflow: hidden;
116
- margin-bottom: 20px;
117
- }
118
- .auth-tab {
119
- flex: 1; padding: 9px;
120
- text-align: center; font-size: 14px; font-weight: 500;
121
- color: var(--text-dim); cursor: pointer;
122
- transition: background var(--transition), color var(--transition);
123
- }
124
- .auth-tab.active { background: var(--yellow); color: #111; }
125
-
126
- .social-btn {
127
- display: flex; align-items: center; justify-content: center; gap: 10px;
128
- width: 100%; padding: 10px;
129
- border-radius: var(--radius-md);
130
- border: 1px solid var(--border-bright);
131
- background: var(--bg-raised);
132
- font-size: 14px; color: var(--text);
133
- transition: background var(--transition);
134
- }
135
- .social-btn:hover { background: var(--bg-hover); }
136
-
137
- .auth-divider {
138
- display: flex; align-items: center; gap: 12px;
139
- margin: 16px 0;
140
- font-size: 12px; color: var(--text-muted);
141
- }
142
- .auth-divider::before, .auth-divider::after {
143
- content: ''; flex: 1; height: 1px; background: var(--border);
144
- }
145
-
146
- /* ── Device session list ─────────────────────────────────────────────────── */
147
- .device-session-item {
148
- display: flex; align-items: flex-start; gap: 12px;
149
- padding: 12px 0; border-bottom: 1px solid var(--border);
150
- cursor: pointer;
151
- transition: background var(--transition);
152
- }
153
- .device-session-item:hover { background: var(--bg-hover); padding-left: 4px; }
154
- .device-badge {
155
- width: 32px; height: 32px; border-radius: 50%;
156
- background: var(--bg-raised); border: 1px solid var(--border-bright);
157
- display: flex; align-items: center; justify-content: center;
158
- font-size: 14px; flex-shrink: 0;
159
- }
160
- .device-info { flex: 1; min-width: 0; }
161
- .device-name { font-size: 13px; font-weight: 500; }
162
- .device-meta { font-size: 12px; color: var(--text-muted); }
163
- .device-current { font-size: 11px; color: var(--plan-core); }
164
-
165
- /* ── Tool call modal ─────────────────────────────────────────────────────── */
166
- .tool-detail-section { margin-bottom: 14px; }
167
- .tool-detail-label {
168
- font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em;
169
- color: var(--text-muted); margin-bottom: 5px;
170
- }
171
- .tool-detail-content {
172
- background: var(--bg-raised);
173
- border: 1px solid var(--border);
174
- border-radius: var(--radius-sm);
175
- padding: 10px 12px;
176
- font-size: 13px; font-family: var(--font-mono);
177
- white-space: pre-wrap; word-break: break-all;
178
- max-height: 200px; overflow-y: auto;
179
- }
180
-
181
- /* ── Share warning ───────────────────────────────────────────────────────── */
182
- .share-warning {
183
- display: flex; align-items: center; gap: 8px;
184
- padding: 10px 12px; border-radius: var(--radius-md);
185
- background: rgba(229,200,70,0.08);
186
- border: 1px solid rgba(229,200,70,0.25);
187
- margin-bottom: 16px;
188
- font-size: 13px; color: var(--yellow);
189
- }
190
-
191
- /* ── Sign in required modal ──────────────────────────────────────────────── */
192
- .limit-modal-inner {
193
- text-align: center; padding: 10px 0 6px;
194
- }
195
- .limit-icon { font-size: 40px; margin-bottom: 12px; }
196
- .limit-title { font-size: 20px; font-weight: 600; margin-bottom: 6px; }
197
- .limit-desc { font-size: 14px; color: var(--text-dim); margin-bottom: 20px; }
198
-
199
- /* ── Turnstile verification overlay ─────────────────────────────────────── */
200
- .turnstile-overlay { display: flex; align-items: center; justify-content: center; }
201
- .turnstile-box {
202
- background: rgba(17,17,19,0.96);
203
- border-radius: var(--radius-lg);
204
- padding: 20px;
205
- width: 100%; max-width: 420px;
206
- box-shadow: 0 8px 30px rgba(0,0,0,0.6);
207
- text-align: center; color: var(--text);
208
- }
209
- .turnstile-message { margin-bottom: 12px; color: var(--text-dim); font-size: 14px; }
210
-
211
- /* make the rest of the page dim and inert while overlay visible */
212
- .page-faded { filter: blur(2px) brightness(0.6); pointer-events: none; user-select: none; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
public/css/sidebar.css DELETED
@@ -1,404 +0,0 @@
1
- /* ── Sidebar layout ──────────────────────────────────────────────────────── */
2
- .sidebar {
3
- flex-shrink: 0;
4
- width: var(--sidebar-collapsed-width);
5
- height: 100vh;
6
- background: var(--sidebar-bg);
7
- border-right: 1px solid var(--sidebar-border);
8
- display: flex;
9
- flex-direction: column;
10
- transition: width var(--transition-slow);
11
- overflow: hidden;
12
- z-index: var(--z-sidebar);
13
- position: relative;
14
- }
15
-
16
- .sidebar.expanded {
17
- width: var(--sidebar-width);
18
- }
19
-
20
- /* Top icon strip */
21
- .sidebar-top {
22
- display: flex;
23
- align-items: center;
24
- gap: 4px;
25
- padding: 12px 10px;
26
- flex-shrink: 0;
27
- border-bottom: 1px solid var(--sidebar-border);
28
- }
29
-
30
- .sidebar.collapsed .sidebar-top {
31
- flex-direction: column;
32
- padding: 10px 10px;
33
- gap: 6px;
34
- }
35
-
36
- .sidebar.expanded .sidebar-top {
37
- flex-direction: row;
38
- }
39
-
40
- .sidebar-icon-btn {
41
- display: flex; align-items: center; justify-content: center;
42
- width: 34px; height: 34px; border-radius: var(--radius-md);
43
- color: var(--text-dim);
44
- transition: background var(--transition), color var(--transition);
45
- flex-shrink: 0;
46
- }
47
- .sidebar-icon-btn:hover {
48
- background: var(--bg-hover);
49
- color: var(--text);
50
- }
51
-
52
- /* Sessions list */
53
- .sidebar-sessions {
54
- flex: 1;
55
- overflow-y: auto;
56
- overflow-x: hidden;
57
- padding: 6px 6px;
58
- opacity: 0;
59
- pointer-events: none;
60
- transition: opacity var(--transition);
61
- }
62
-
63
- .sidebar.expanded .sidebar-sessions {
64
- opacity: 1;
65
- pointer-events: auto;
66
- }
67
-
68
- .session-list {
69
- display: flex;
70
- flex-direction: column;
71
- gap: 2px;
72
- }
73
-
74
- /* Session item */
75
- .session-item {
76
- display: flex;
77
- align-items: center;
78
- gap: 8px;
79
- padding: 8px 10px;
80
- border-radius: var(--radius-md);
81
- cursor: pointer;
82
- transition: background var(--transition);
83
- position: relative;
84
- min-width: 0;
85
- }
86
-
87
- .session-item:hover { background: var(--bg-hover); }
88
- .session-item.active { background: var(--bg-active); }
89
-
90
- .session-item .session-name {
91
- flex: 1;
92
- font-size: 13px;
93
- color: var(--text);
94
- white-space: nowrap;
95
- overflow: hidden;
96
- text-overflow: ellipsis;
97
- min-width: 0;
98
- cursor: text;
99
- }
100
-
101
- .session-item .session-name[contenteditable="true"] {
102
- background: var(--bg-raised);
103
- border-radius: 4px;
104
- padding: 1px 4px;
105
- outline: 1px solid var(--blue-bright);
106
- white-space: nowrap;
107
- cursor: text;
108
- }
109
-
110
- .session-item .session-menu-btn {
111
- flex-shrink: 0;
112
- width: 24px; height: 24px;
113
- border-radius: var(--radius-sm);
114
- display: flex; align-items: center; justify-content: center;
115
- color: var(--text-muted);
116
- opacity: 0;
117
- transition: opacity var(--transition), background var(--transition), color var(--transition);
118
- font-size: 16px; line-height: 1;
119
- }
120
-
121
- .session-item:hover .session-menu-btn,
122
- .session-item.active .session-menu-btn {
123
- opacity: 1;
124
- }
125
-
126
- .session-item .session-menu-btn:hover {
127
- background: var(--bg-raised);
128
- color: var(--text);
129
- }
130
-
131
- /* Section label */
132
- .session-date-label {
133
- font-size: 11px;
134
- color: var(--text-muted);
135
- padding: 8px 10px 3px;
136
- letter-spacing: 0.04em;
137
- text-transform: uppercase;
138
- }
139
-
140
- /* Bottom account section */
141
- .sidebar-bottom {
142
- flex-shrink: 0;
143
- padding: 8px;
144
- border-top: 1px solid var(--sidebar-border);
145
- overflow: hidden;
146
- }
147
-
148
- .account-section {
149
- display: flex;
150
- align-items: center;
151
- gap: 6px;
152
- }
153
-
154
- .sidebar.collapsed .account-section {
155
- flex-direction: column;
156
- align-items: center;
157
- justify-content: center;
158
- width: 100%;
159
- }
160
-
161
- .sidebar.expanded .account-section {
162
- flex-direction: row;
163
- justify-content: space-between;
164
- }
165
-
166
- .btn-signin {
167
- flex: 1;
168
- padding: 8px 12px;
169
- border-radius: var(--radius-md);
170
- background: var(--yellow);
171
- color: #111;
172
- font-size: 13px;
173
- font-weight: 600;
174
- white-space: nowrap;
175
- overflow: hidden;
176
- opacity: 0;
177
- pointer-events: none;
178
- transition: opacity var(--transition);
179
- display: none;
180
- }
181
-
182
- .sidebar.expanded .btn-signin {
183
- opacity: 1;
184
- pointer-events: auto;
185
- display: block;
186
- }
187
-
188
- /* Collapsed sign-in icon */
189
- .btn-signin-icon {
190
- display: none;
191
- width: 34px; height: 34px;
192
- border-radius: 50%;
193
- background: var(--yellow);
194
- color: #111;
195
- align-items: center; justify-content: center;
196
- flex-shrink: 0;
197
- transition: opacity var(--transition);
198
- }
199
- .sidebar.collapsed .btn-signin-icon {
200
- display: flex;
201
- }
202
- .sidebar.expanded .btn-signin-icon {
203
- display: none;
204
- }
205
-
206
- .user-profile-btn {
207
- display: flex;
208
- align-items: center;
209
- gap: 9px;
210
- min-width: 0;
211
- padding: 6px 8px;
212
- border-radius: var(--radius-md);
213
- transition: background var(--transition);
214
- }
215
- .user-profile-btn:hover { background: var(--bg-hover); }
216
-
217
- .sidebar.collapsed .user-profile-btn {
218
- flex: none;
219
- width: 36px;
220
- height: 36px;
221
- justify-content: center;
222
- gap: 0px !important;
223
- /* padding: 0; */
224
- border-radius: 50%;
225
- }
226
-
227
- .user-avatar {
228
- width: 30px; height: 30px; border-radius: 50%;
229
- background: var(--blue);
230
- display: flex; align-items: center; justify-content: center;
231
- font-size: 13px; font-weight: 600; color: white;
232
- flex-shrink: 0;
233
- transition: background var(--transition);
234
- }
235
- .user-avatar[data-plan="free"] { background: var(--plan-free); }
236
- .user-avatar[data-plan="light"] { background: var(--plan-light); }
237
- .user-avatar[data-plan="core"] { background: var(--plan-core); }
238
- .user-avatar[data-plan="creator"] { background: var(--plan-creator); }
239
- .user-avatar[data-plan="professional"] { background: var(--plan-professional); }
240
-
241
- .user-info {
242
- display: flex; flex-direction: column;
243
- min-width: 0;
244
- opacity: 0; pointer-events: none;
245
- transition: opacity var(--transition);
246
- }
247
-
248
- .sidebar.expanded .user-info {
249
- opacity: 1; pointer-events: auto;
250
- }
251
-
252
- .user-name {
253
- font-size: 13px; font-weight: 500;
254
- white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
255
- }
256
- .user-plan {
257
- font-size: 11px; font-weight: 600;
258
- }
259
- .user-plan[data-plan="free"] { color: var(--plan-free); }
260
- .user-plan[data-plan="light"] { color: var(--plan-light); }
261
- .user-plan[data-plan="core"] { color: var(--plan-core); }
262
- .user-plan[data-plan="creator"] { color: var(--plan-creator); }
263
- .user-plan[data-plan="professional"] { color: var(--plan-professional); }
264
-
265
- /* Context menus */
266
- .context-menu {
267
- position: fixed;
268
- background: var(--bg-raised);
269
- border: 1px solid var(--border-bright);
270
- border-radius: var(--radius-md);
271
- box-shadow: var(--shadow-lg);
272
- z-index: var(--z-menu);
273
- overflow: hidden;
274
- min-width: 170px;
275
- animation: slideUp 0.14s ease;
276
- }
277
-
278
- .context-item {
279
- display: flex; align-items: center; gap: 10px;
280
- padding: 9px 14px;
281
- font-size: 13px; color: var(--text);
282
- cursor: pointer;
283
- transition: background var(--transition);
284
- }
285
- .context-item:hover { background: var(--bg-hover); }
286
- .context-item.danger { color: #f07070; }
287
- .context-item.warning { color: var(--yellow); }
288
- .context-item svg { flex-shrink: 0; color: var(--text-dim); }
289
- .context-item.danger svg { color: #f07070; }
290
- /* ══════════════════════════════════════════════════════════════════════════
291
- /* ══════════════════════════════════════════════════════════════════════════
292
- MOBILE SIDEBAR — Top header bar + full-screen left drawer
293
- ══════════════════════════════════════════════════════════════════════════ */
294
-
295
- @media (max-width: 768px) {
296
-
297
- /* ── Root layout ── */
298
- #app {
299
- flex-direction: column !important;
300
- height: 100dvh;
301
- overflow: hidden;
302
- }
303
-
304
- /* ── Sidebar: hidden off-screen left, slides in as full-screen overlay ── */
305
- .sidebar {
306
- position: fixed !important;
307
- top: 0 !important;
308
- left: 0 !important;
309
- bottom: 0 !important;
310
- width: 100vw !important;
311
- height: 100dvh !important;
312
- max-height: none !important;
313
- border-right: none !important;
314
- border-top: none !important;
315
- flex-direction: column !important;
316
- z-index: var(--z-sidebar);
317
- overflow: hidden;
318
- transform: translateX(-100%);
319
- transition: transform var(--transition-slow);
320
- }
321
-
322
- .sidebar.collapsed {
323
- transform: translateX(-100%);
324
- }
325
-
326
- .sidebar.expanded {
327
- transform: translateX(0);
328
- }
329
-
330
- /* ── Sidebar inner top: header row ── */
331
- .sidebar-top {
332
- flex-direction: row !important;
333
- padding: 14px 16px !important;
334
- gap: 8px !important;
335
- border-bottom: 1px solid var(--sidebar-border) !important;
336
- flex-shrink: 0;
337
- }
338
-
339
- /* Session list: scrollable */
340
- .sidebar-sessions {
341
- opacity: 1 !important;
342
- pointer-events: auto !important;
343
- overflow-y: auto;
344
- flex: 1;
345
- -webkit-overflow-scrolling: touch;
346
- }
347
-
348
- /* Bottom account section: always show */
349
- .sidebar-bottom {
350
- flex-shrink: 0;
351
- border-top: 1px solid var(--sidebar-border);
352
- display: block !important;
353
- }
354
-
355
- .account-section {
356
- flex-direction: row !important;
357
- align-items: center;
358
- }
359
-
360
- .btn-signin {
361
- display: block !important;
362
- opacity: 1 !important;
363
- pointer-events: auto !important;
364
- }
365
-
366
- .btn-signin-icon {
367
- display: none !important;
368
- }
369
-
370
- .user-info {
371
- opacity: 1 !important;
372
- pointer-events: auto !important;
373
- }
374
-
375
- .user-profile-btn {
376
- flex: 1 !important;
377
- width: auto !important;
378
- justify-content: flex-start !important;
379
- border-radius: var(--radius-md) !important;
380
- gap: 9px !important;
381
- }
382
-
383
- /* ── Main: full viewport ── */
384
- #main {
385
- height: 100dvh !important;
386
- width: 100% !important;
387
- overflow: hidden;
388
- }
389
-
390
- /* ── Backdrop ── */
391
- .sidebar-backdrop {
392
- display: none;
393
- position: fixed;
394
- inset: 0;
395
- background: rgba(0, 0, 0, 0.55);
396
- z-index: calc(var(--z-sidebar) - 1);
397
- backdrop-filter: blur(2px);
398
- -webkit-backdrop-filter: blur(2px);
399
- }
400
-
401
- .sidebar-backdrop.visible {
402
- display: block;
403
- }
404
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
public/css/tokens.css DELETED
@@ -1,99 +0,0 @@
1
- :root {
2
- /* Brand palette */
3
- --yellow: #e5c846;
4
- --yellow-dim: #c9af2e;
5
- --yellow-glow: rgba(229, 200, 70, 0.18);
6
- --blue: #3178c6;
7
- --blue-bright: #4a9eff;
8
- --blue-dim: rgba(49, 120, 198, 0.18);
9
- --blue-glow: rgba(74, 158, 255, 0.14);
10
-
11
- /* Neutrals - dark theme defaults */
12
- --bg: #0d0e11;
13
- --bg-surface: #131417;
14
- --bg-raised: #1a1b20;
15
- --bg-hover: #22242b;
16
- --bg-active: #2a2c35;
17
- --border: rgba(255,255,255,0.07);
18
- --border-bright: rgba(255,255,255,0.14);
19
- --text: #e8eaf0;
20
- --text-dim: rgba(232, 234, 240, 0.55);
21
- --text-muted: rgba(232, 234, 240, 0.35);
22
-
23
- /* Sidebar */
24
- --sidebar-width: 260px;
25
- --sidebar-collapsed-width: 56px;
26
- --sidebar-bg: #0f1013;
27
- --sidebar-border: rgba(255,255,255,0.06);
28
-
29
- /* Plan colors */
30
- --plan-free: #8a8fa8;
31
- --plan-light: #4a9eff;
32
- --plan-core: #2dd4a6;
33
- --plan-creator: #a78bfa;
34
- --plan-professional: #e5c846;
35
-
36
- /* Input */
37
- --input-bg: #1a1b20;
38
- --input-border: rgba(255,255,255,0.1);
39
- --input-focus: rgba(74, 158, 255, 0.5);
40
-
41
- /* Radius */
42
- --radius-sm: 8px;
43
- --radius-md: 12px;
44
- --radius-lg: 20px;
45
- --radius-xl: 28px;
46
- --radius-full: 9999px;
47
-
48
- /* Transitions */
49
- --transition: 0.18s cubic-bezier(0.4, 0, 0.2, 1);
50
- --transition-slow: 0.32s cubic-bezier(0.4, 0, 0.2, 1);
51
-
52
- /* Shadows */
53
- --shadow-sm: 0 2px 8px rgba(0,0,0,0.3);
54
- --shadow-md: 0 6px 20px rgba(0,0,0,0.4);
55
- --shadow-lg: 0 16px 48px rgba(0,0,0,0.5);
56
- --shadow-glow-blue: 0 0 0 1px var(--input-focus), 0 0 20px var(--blue-glow);
57
-
58
- /* Fonts */
59
- --font-sans: 'Geist', system-ui, -apple-system, sans-serif;
60
- --font-mono: 'Geist Mono', 'Fira Code', monospace;
61
-
62
- /* Z-layers */
63
- --z-sidebar: 100;
64
- --z-modal: 500;
65
- --z-notify: 600;
66
- --z-menu: 400;
67
- }
68
-
69
- [data-theme="light"] {
70
- --bg: #f4f5f7;
71
- --bg-surface: #ffffff;
72
- --bg-raised: #f0f1f4;
73
- --bg-hover: #e8e9ed;
74
- --bg-active: #dfe0e6;
75
- --border: rgba(0,0,0,0.08);
76
- --border-bright: rgba(0,0,0,0.14);
77
- --text: #1a1b20;
78
- --text-dim: rgba(26, 27, 32, 0.6);
79
- --text-muted: rgba(26, 27, 32, 0.38);
80
- --sidebar-bg: #e8e9ed;
81
- --sidebar-border: rgba(0,0,0,0.07);
82
- --input-bg: #ffffff;
83
- --input-border: rgba(0,0,0,0.12);
84
- --shadow-sm: 0 2px 8px rgba(0,0,0,0.08);
85
- --shadow-md: 0 6px 20px rgba(0,0,0,0.1);
86
- --shadow-lg: 0 16px 48px rgba(0,0,0,0.12);
87
- }
88
-
89
- /* Smooth theme transitions applied to the whole document */
90
- html.theme-transitioning,
91
- html.theme-transitioning *,
92
- html.theme-transitioning *::before,
93
- html.theme-transitioning *::after {
94
- transition:
95
- background-color 0.35s cubic-bezier(0.4,0,0.2,1),
96
- border-color 0.35s cubic-bezier(0.4,0,0.2,1),
97
- color 0.35s cubic-bezier(0.4,0,0.2,1),
98
- box-shadow 0.35s cubic-bezier(0.4,0,0.2,1) !important;
99
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
public/favicon.ico DELETED
Binary file (45.7 kB)
 
public/index.html DELETED
@@ -1,239 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="en" data-theme="dark">
3
- <head>
4
- <meta charset="UTF-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <title>InferencePort AI</title>
7
- <link rel="preconnect" href="https://fonts.googleapis.com" />
8
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
- <link href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet" />
10
- <!-- Inline early theme to prevent flash of wrong theme -->
11
- <script>
12
- (function(){
13
- try {
14
- var t = localStorage.getItem('ipai_theme');
15
- if (t) document.documentElement.setAttribute('data-theme', t === 'light' ? 'light' : 'dark');
16
- } catch(e){}
17
- })();
18
- </script>
19
- <link rel="stylesheet" href="/css/tokens.css" />
20
- <link rel="stylesheet" href="/css/base.css" />
21
- <link rel="stylesheet" href="/css/sidebar.css" />
22
- <link rel="stylesheet" href="/css/chat.css" />
23
- <link rel="stylesheet" href="/css/modals.css" />
24
- <link rel="stylesheet" href="/css/input.css" />
25
- <style>
26
- html, body { overflow: hidden; height: 100%; max-height: 100dvh; }
27
- #app { display: flex; height: 100dvh; overflow: hidden; }
28
- @media (max-width: 768px) {
29
- #main {
30
- height: 100dvh;
31
- max-height: 100dvh;
32
- display: flex;
33
- flex-direction: column;
34
- overflow: hidden;
35
- }
36
- /* Push content below the fixed top bar */
37
- .chat-view, .welcome-view { padding-top: 0; }
38
- }
39
- </style>
40
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" />
41
- <script
42
- src="https://challenges.cloudflare.com/turnstile/v0/api.js"
43
- async
44
- defer
45
- ></script>
46
- <link rel="preconnect" href="https://challenges.cloudflare.com" />
47
- </head>
48
- <body>
49
- <!-- Share import overlay -->
50
- <div id="share-import-banner" class="share-banner hidden">
51
- <div class="share-banner-inner">
52
- <span class="share-icon">🔗</span>
53
- <span id="share-banner-text">Importing shared session…</span>
54
- <button id="share-import-btn" class="btn-primary">Import Session</button>
55
- <button id="share-dismiss-btn" class="btn-ghost">Dismiss</button>
56
- </div>
57
- </div>
58
-
59
- <!-- Mobile top bar (hidden on desktop via CSS) -->
60
- <div id="mobile-top-bar">
61
- <button id="mobile-sidebar-btn" class="mobile-bar-btn" title="Menu">
62
- <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
63
- </button>
64
- <span class="mobile-bar-title">InferencePort AI</span>
65
- <button id="mobile-newchat-btn" class="mobile-bar-btn" title="New Chat">
66
- <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg>
67
- </button>
68
- </div>
69
-
70
- <div id="app">
71
- <!-- Sidebar -->
72
- <aside id="sidebar" class="sidebar collapsed">
73
- <div class="sidebar-top">
74
- <button id="new-chat-btn" class="sidebar-icon-btn" title="New Chat">
75
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg>
76
- </button>
77
- <button id="toggle-sidebar-btn" class="sidebar-icon-btn" title="Toggle sidebar">
78
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/></svg>
79
- </button>
80
- </div>
81
-
82
- <div class="sidebar-sessions" id="sidebar-sessions">
83
- <div class="session-list" id="session-list"></div>
84
- </div>
85
-
86
- <div class="sidebar-bottom" id="sidebar-bottom">
87
- <!-- Shown when not signed in -->
88
- <div id="guest-section" class="account-section">
89
- <!-- Collapsed: icon-only sign-in button -->
90
- <button id="signin-icon-btn" class="btn-signin-icon" title="Sign In">
91
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg>
92
- </button>
93
- <!-- Expanded: full sign-in button -->
94
- <button id="signin-btn" class="btn-signin">Sign In</button>
95
- <button id="settings-btn-guest" class="sidebar-icon-btn" title="Settings">
96
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
97
- </button>
98
- </div>
99
-
100
- <!-- Shown when signed in -->
101
- <div id="user-section" class="account-section hidden">
102
- <button id="user-profile-btn" class="user-profile-btn">
103
- <div class="user-avatar" id="user-avatar">?</div>
104
- <div class="user-info">
105
- <span class="user-name" id="user-name-display">—</span>
106
- <span class="user-plan" id="user-plan-display">Free</span>
107
- </div>
108
- </button>
109
- <button id="settings-btn" class="sidebar-icon-btn" title="Settings">
110
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
111
- </button>
112
- </div>
113
- </div>
114
- </aside>
115
-
116
- <!-- Main chat area -->
117
- <main id="main">
118
- <!-- Terms of service banner -->
119
- <div id="tos-banner">
120
- <p>
121
- By interacting with this website, you agree to
122
- <a href="https://inference.js.org/security.html" target="_blank" rel="noopener noreferrer" style="color: white;">these Terms of Service</a>
123
- and
124
- <a href="https://inference.js.org/security.html" target="_blank" rel="noopener noreferrer" style="color: white;">this privacy policy</a>
125
- </p>
126
- </div>
127
-
128
- <!-- Welcome / new chat state -->
129
- <div id="welcome-view" class="welcome-view">
130
- <h1 class="welcome-title">What's on your mind?</h1>
131
- <div class="welcome-input-wrap">
132
- <div class="center-input-container">
133
- <div id="center-file-preview-row" class="input-file-preview-row"></div>
134
- <div class="center-input-row">
135
- <div class="attach-btn-wrap">
136
- <button id="center-attach-btn" class="attach-btn" title="Attach">
137
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
138
- </button>
139
- </div>
140
- <textarea id="center-input" class="center-textarea" placeholder="Ask anything…" rows="1"></textarea>
141
- <div class="center-input-actions">
142
- <button id="center-tool-search" class="tool-btn" data-tool="webSearch" title="Web Search">
143
- <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
144
- </button>
145
- <button id="center-tool-image" class="tool-btn" data-tool="imageGen" title="Image">
146
- <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
147
- </button>
148
- <button id="center-tool-video" class="tool-btn" data-tool="videoGen" title="Video">
149
- <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2"/></svg>
150
- </button>
151
- <button id="center-tool-audio" class="tool-btn" data-tool="audioGen" title="Audio">
152
- <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
153
- </button>
154
- <button id="center-send-btn" class="send-btn-center">
155
- <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M2 21L23 12 2 3v7l15 2-15 2v7z"/></svg>
156
- </button>
157
- </div>
158
- </div>
159
- </div>
160
- </div>
161
- </div>
162
-
163
- <!-- Active chat view -->
164
- <div id="chat-view" class="chat-view hidden">
165
- <div id="chat-messages" class="chat-messages"></div>
166
- </div>
167
-
168
- <!-- Bottom input (active during chat) -->
169
- <div id="bottom-input-bar" class="bottom-input-bar hidden">
170
- <div class="bottom-input-wrap">
171
- <button id="bottom-attach-btn" class="attach-btn-bottom" title="Attach">
172
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
173
- </button>
174
- <div class="bottom-textarea-wrap">
175
- <div class="tool-row">
176
- <button class="tool-btn-sm active" data-tool="webSearch" title="Web Search">
177
- <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
178
- <span>Search</span>
179
- </button>
180
- <button class="tool-btn-sm" data-tool="imageGen" title="Image">
181
- <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
182
- <span>Image</span>
183
- </button>
184
- <button class="tool-btn-sm" data-tool="videoGen" title="Video">
185
- <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2"/></svg>
186
- <span>Video</span>
187
- </button>
188
- <button class="tool-btn-sm" data-tool="audioGen" title="Audio">
189
- <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
190
- <span>Audio</span>
191
- </button>
192
- </div>
193
- <div id="file-preview-row" class="input-file-preview-row"></div>
194
- <textarea id="bottom-input" class="bottom-textarea" placeholder="Ask anything…" rows="1"></textarea>
195
- </div>
196
- <button id="bottom-send-btn" class="send-btn-bottom">
197
- <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M2 21L23 12 2 3v7l15 2-15 2v7z"/></svg>
198
- </button>
199
- </div>
200
- </div>
201
- </main>
202
- </div>
203
-
204
- <!-- Context Menus -->
205
- <div id="session-context-menu" class="context-menu hidden"></div>
206
- <div id="user-context-menu" class="context-menu hidden"></div>
207
- <div id="attach-context-menu" class="context-menu hidden"></div>
208
-
209
- <!-- Modals -->
210
- <div id="modal-overlay" class="modal-overlay hidden">
211
- <div id="modal-box" class="modal-box"></div>
212
- </div>
213
-
214
- <!-- Turnstile verification overlay (shows until user verifies) -->
215
- <div id="turnstile-overlay" class="modal-overlay turnstile-overlay">
216
- <div class="turnstile-box" id="turnstile-box">
217
- <div class="turnstile-message">Please verify you are human to continue using the chat.</div>
218
- <div id="turnstile-widget">
219
- <div class="cf-turnstile" data-sitekey="0x4AAAAAAC1ZXKIhZ9Kdz8j9" data-callback="onTurnstileSuccess"></div>
220
- </div>
221
- </div>
222
- </div>
223
-
224
- <!-- Notification area -->
225
- <div id="notifications" class="notifications"></div>
226
-
227
- <!-- Hidden file input -->
228
- <input type="file" id="file-input" multiple style="display:none" />
229
- <input type="file" id="image-input" accept="image/*" multiple style="display:none" />
230
-
231
- <script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
232
- <script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
233
- <script src="https://cdn.jsdelivr.net/npm/marked@9.1.6/marked.min.js"></script>
234
- <script src="https://cdn.jsdelivr.net/npm/dompurify@3.1.6/dist/purify.min.js"></script>
235
- <!-- Turnstile widget handler (shows verification overlay) -->
236
- <script type="module" src="/js/turnstile.js"></script>
237
- <script type="module" src="/js/app.js"></script>
238
- </body>
239
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
public/js/.DS_Store DELETED
Binary file (6.15 kB)
 
public/js/app.js DELETED
@@ -1,561 +0,0 @@
1
- // app.js — bootstrap, input handling, sidebar, attach, paste
2
- import { send, on } from './ws.js';
3
- import { isAuthenticated, logout, getTempId, getClientId, onAuthChange } from './auth.js';
4
- import {
5
- createNewSession, showWelcomeScreen,
6
- switchSession, currentSessionId, onSessionChange,
7
- } from './sessions.js';
8
- import { submitMessage, renderSession, setActiveSession, getIsStreaming } from './chat.js';
9
- import { openAuthModal, closeModal, openPasteEditor } from './modals.js';
10
- import { openSettings, applyTheme } from './settings.js';
11
- import { showNotification, autoResize, escHtml } from './ui.js';
12
-
13
- // ── Apply theme immediately from localStorage (no flash) ──────────────────
14
- (function earlyTheme() {
15
- try {
16
- const t = localStorage.getItem('ipai_theme');
17
- if (t) document.documentElement.setAttribute('data-theme', t === 'light' ? 'light' : 'dark');
18
- } catch {}
19
- })();
20
-
21
- // ── TOS banner — push content up so input isn't obscured ─────────────────
22
-
23
- (function fixTosBanner() {
24
- const banner = document.getElementById('tos-banner');
25
- if (!banner) return;
26
-
27
- // Make TOS links open in new tab
28
- banner.querySelectorAll('a').forEach(a => {
29
- a.setAttribute('target', '_blank');
30
- a.setAttribute('rel', 'noopener noreferrer');
31
- });
32
-
33
- const applyPadding = () => {
34
- const isMobile = window.innerWidth <= 768;
35
- if (isMobile) {
36
- const h = banner.offsetHeight || 28;
37
- const welcomeView = document.getElementById('welcome-view');
38
- if (welcomeView) welcomeView.style.paddingBottom = `${h + 8}px`;
39
- } else {
40
- const h = banner.offsetHeight || 30;
41
- document.getElementById('bottom-input-bar')?.style.setProperty('padding-bottom', `${h + 4}px`);
42
- const welcomeView = document.getElementById('welcome-view');
43
- if (welcomeView) welcomeView.style.paddingBottom = `${h + 10}px`;
44
- }
45
- };
46
- requestAnimationFrame(() => { applyPadding(); });
47
- window.addEventListener('resize', applyPadding);
48
- })();
49
-
50
- // ── Sidebar ───────────────────────────────────────────────────────────────
51
-
52
- const sidebar = document.getElementById('sidebar');
53
- const toggleBtn = document.getElementById('toggle-sidebar-btn');
54
-
55
- function expandSidebar() { sidebar?.classList.remove('collapsed'); sidebar?.classList.add('expanded'); }
56
- function collapseSidebar(){ sidebar?.classList.remove('expanded'); sidebar?.classList.add('collapsed'); }
57
- function toggleSidebar() { sidebar?.classList.contains('expanded') ? collapseSidebar() : expandSidebar(); }
58
-
59
- toggleBtn?.addEventListener('click', toggleSidebar);
60
-
61
- // ── Mobile top bar + sidebar logic ───────────────────────────────────────
62
-
63
- (function setupMobile() {
64
- // Create backdrop
65
- const backdrop = document.createElement('div');
66
- backdrop.id = 'sidebar-backdrop';
67
- backdrop.className = 'sidebar-backdrop';
68
- document.body.appendChild(backdrop);
69
-
70
- function openSidebar() {
71
- sidebar?.classList.remove('collapsed'); sidebar?.classList.add('expanded');
72
- backdrop.classList.add('visible');
73
- }
74
- function closeSidebar() {
75
- sidebar?.classList.remove('expanded'); sidebar?.classList.add('collapsed');
76
- backdrop.classList.remove('visible');
77
- }
78
-
79
- backdrop.addEventListener('click', closeSidebar);
80
-
81
- // Wire mobile top bar buttons
82
- document.getElementById('mobile-sidebar-btn')?.addEventListener('click', () => {
83
- sidebar?.classList.contains('expanded') ? closeSidebar() : openSidebar();
84
- });
85
- document.getElementById('mobile-newchat-btn')?.addEventListener('click', () => {
86
- showWelcomeScreen();
87
- closeSidebar();
88
- const ci = document.getElementById('center-input');
89
- if (ci) { ci.value = ''; autoResize(ci, 6); }
90
- const box = document.getElementById('chat-messages');
91
- if (box) box.innerHTML = '';
92
- });
93
-
94
- // Auto-close drawer when a session is switched on mobile
95
- onSessionChange((event) => {
96
- if ((event === 'switched' || event === 'created') && window.innerWidth <= 768) {
97
- closeSidebar();
98
- }
99
- });
100
-
101
- // ── Hide/show top bar on scroll ─────────────────────────────────────────
102
- let lastScrollY = 0;
103
- let ticking = false;
104
- const topBar = document.getElementById('mobile-top-bar');
105
-
106
- function handleChatScroll(e) {
107
- if (window.innerWidth > 768) return;
108
- const el = e.target;
109
- const currentY = el.scrollTop;
110
- if (!ticking) {
111
- requestAnimationFrame(() => {
112
- if (currentY > lastScrollY && currentY > 60) {
113
- topBar?.classList.add('hidden-bar');
114
- } else {
115
- topBar?.classList.remove('hidden-bar');
116
- }
117
- lastScrollY = currentY <= 0 ? 0 : currentY;
118
- ticking = false;
119
- });
120
- ticking = true;
121
- }
122
- }
123
-
124
- document.getElementById('chat-view')?.addEventListener('scroll', handleChatScroll, { passive: true });
125
-
126
- onSessionChange((event, data) => {
127
- if (event === 'switched' && !data) {
128
- topBar?.classList.remove('hidden-bar');
129
- lastScrollY = 0;
130
- }
131
- });
132
- })();
133
-
134
- // ── New chat — just show the welcome screen ───────────────────────────────
135
-
136
- document.getElementById('new-chat-btn')?.addEventListener('click', () => {
137
- showWelcomeScreen();
138
- const ci = document.getElementById('center-input');
139
- if (ci) { ci.value = ''; autoResize(ci, 6); }
140
- const box = document.getElementById('chat-messages');
141
- if (box) box.innerHTML = '';
142
- });
143
-
144
- // ── Session switching ─────────────────────────────────────────────────────
145
-
146
- onSessionChange((event, data) => {
147
- if (event === 'switched') {
148
- setActiveSession(data);
149
- if (!data) {
150
- const box = document.getElementById('chat-messages');
151
- if (box) box.innerHTML = '';
152
- document.getElementById('welcome-view')?.classList.remove('hidden');
153
- document.getElementById('chat-view')?.classList.add('hidden');
154
- document.getElementById('bottom-input-bar')?.classList.add('hidden');
155
- }
156
- }
157
- if (event === 'data') {
158
- if (data.id === currentSessionId) renderSession(data);
159
- }
160
- if (event === 'created') {
161
- setActiveSession(data.id);
162
- switchSession(data.id);
163
- }
164
- });
165
-
166
- // Show welcome screen on login/auth
167
- onAuthChange(({ currentUser }) => {
168
- if (currentUser) {
169
- // After login show welcome screen (sessions will load and may switch to one)
170
- // Only show welcome if we're not already in a chat
171
- const chatView = document.getElementById('chat-view');
172
- if (chatView?.classList.contains('hidden')) {
173
- // Already on welcome, nothing to do
174
- }
175
- }
176
- });
177
-
178
- // ── Center input (welcome view) ───────────────────────────────────────────
179
-
180
- const centerInput = document.getElementById('center-input');
181
- const centerSendBtn = document.getElementById('center-send-btn');
182
-
183
- centerInput?.addEventListener('input', () => autoResize(centerInput, 6));
184
- centerInput?.addEventListener('keydown', e => {
185
- if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); triggerCenterSend(); }
186
- });
187
- centerSendBtn?.addEventListener('click', triggerCenterSend);
188
-
189
- // Center tool buttons
190
- document.querySelectorAll('#center-tool-search, #center-tool-image, #center-tool-video, #center-tool-audio')
191
- .forEach(btn => btn.addEventListener('click', () => btn.classList.toggle('active')));
192
-
193
- function triggerCenterSend() {
194
- const text = centerInput?.value.trim();
195
- const attachments = pendingAttachments.splice(0);
196
- if (!text && attachments.length === 0) return;
197
- if (centerInput) { centerInput.value = ''; autoResize(centerInput, 6); }
198
- clearFilePreviewRow();
199
-
200
- const pendingText = text, pendingAttach = attachments;
201
- const unsub = onSessionChange((ev, s) => {
202
- if (ev !== 'switched' || !s) return;
203
- unsub();
204
- doSend(pendingText, pendingAttach);
205
- });
206
- createNewSession();
207
- }
208
-
209
- // ── Bottom input (active chat) ────────────────────────────────────────────
210
-
211
- const bottomInput = document.getElementById('bottom-input');
212
- const bottomSendBtn = document.getElementById('bottom-send-btn');
213
-
214
- bottomInput?.addEventListener('input', () => autoResize(bottomInput, 6));
215
- bottomInput?.addEventListener('keydown', e => {
216
- if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); triggerBottomSend(); }
217
- });
218
- bottomSendBtn?.addEventListener('click', () => {
219
- if (getIsStreaming()) { send({ type: 'chat:stop', sessionId: currentSessionId }); return; }
220
- triggerBottomSend();
221
- });
222
-
223
- function triggerBottomSend() {
224
- const text = bottomInput?.value.trim();
225
- const attachments = pendingAttachments.splice(0);
226
- if (!text && attachments.length === 0) return;
227
- if (bottomInput) { bottomInput.value = ''; autoResize(bottomInput, 6); }
228
- clearFilePreviewRow();
229
- doSend(text || '', attachments);
230
- }
231
-
232
- document.querySelectorAll('.tool-btn-sm').forEach(btn =>
233
- btn.addEventListener('click', () => btn.classList.toggle('active')));
234
-
235
- // ── Core send ─────────────────────────────────────────────────────────────
236
-
237
- function doSend(text, attachments = []) {
238
- submitMessage(text, attachments);
239
- }
240
-
241
- // ── Attachments ───────────────────────────────────────────────────────────
242
-
243
- let pendingAttachments = [];
244
- const LARGE_PASTE_THRESHOLD = 10000;
245
-
246
- function openAttachMenu(e, triggerEl) {
247
- e.preventDefault(); e.stopPropagation();
248
- const menu = document.getElementById('attach-context-menu');
249
- if (!menu) return;
250
- menu.innerHTML = '';
251
-
252
- for (const item of [
253
- { label: '📄 Upload file', onClick: () => document.getElementById('file-input')?.click() },
254
- { label: '🖼️ Upload image', onClick: () => document.getElementById('image-input')?.click() },
255
- ]) {
256
- const el = document.createElement('div');
257
- el.className = 'context-item'; el.textContent = item.label;
258
- el.addEventListener('click', () => { menu.classList.add('hidden'); item.onClick(); });
259
- menu.appendChild(el);
260
- }
261
-
262
- menu.classList.remove('hidden');
263
- const rect = triggerEl.getBoundingClientRect();
264
- const mh = menu.getBoundingClientRect().height || 80;
265
- menu.style.left = `${Math.max(8, rect.left)}px`;
266
- menu.style.top = `${rect.top - mh - 8}px`;
267
-
268
- setTimeout(() => document.addEventListener('click', () => menu.classList.add('hidden'), { once: true }), 0);
269
- }
270
-
271
- document.getElementById('center-attach-btn')?.addEventListener('click', e =>
272
- openAttachMenu(e, document.getElementById('center-attach-btn')));
273
- document.getElementById('bottom-attach-btn')?.addEventListener('click', e =>
274
- openAttachMenu(e, document.getElementById('bottom-attach-btn')));
275
-
276
- document.getElementById('file-input')?.addEventListener('change', async function() {
277
- for (const file of this.files) {
278
- const text = await file.text();
279
- pendingAttachments.push({ type: 'text', name: file.name, content: text });
280
- }
281
- this.value = '';
282
- renderFilePreviewRow();
283
- });
284
-
285
- document.getElementById('image-input')?.addEventListener('change', async function() {
286
- for (const file of this.files) await addImageFile(file);
287
- this.value = '';
288
- });
289
-
290
- async function addImageFile(file) {
291
- const dataUrl = await new Promise((res, rej) => {
292
- const reader = new FileReader();
293
- reader.onload = () => res(reader.result);
294
- reader.onerror = rej;
295
- reader.readAsDataURL(file);
296
- });
297
- const comma = dataUrl.indexOf(',');
298
- const mimeType = dataUrl.slice(5, dataUrl.indexOf(';'));
299
- const base64 = dataUrl.slice(comma + 1);
300
- pendingAttachments.push({ type: 'image', name: file.name, base64, mimeType });
301
- renderFilePreviewRow();
302
- }
303
-
304
- function buildAttachmentItem(a, i) {
305
- const wrap = document.createElement('div');
306
- wrap.className = 'attach-preview-item';
307
-
308
- if (a.type === 'image') {
309
- const img = document.createElement('img');
310
- img.src = `data:${a.mimeType};base64,${a.base64}`;
311
- img.alt = a.name;
312
- wrap.appendChild(img);
313
- } else {
314
- // File chip — styled container
315
- const chip = document.createElement('div');
316
- chip.className = 'file-attachment-chip';
317
- chip.title = a.name;
318
- const lineCount = (a.content.match(/\n/g) || []).length + 1;
319
- chip.innerHTML = `
320
- <span class="chip-icon">📄</span>
321
- <div style="display:flex;flex-direction:column;min-width:0;">
322
- <span class="chip-name">${escHtml(a.name)}</span>
323
- <span class="chip-meta">${lineCount} line${lineCount !== 1 ? 's' : ''}</span>
324
- </div>`;
325
- chip.addEventListener('click', () => {
326
- import('./modals.js').then(m => m.openFileViewerModal({
327
- name: a.name,
328
- content: a.content,
329
- editable: true,
330
- onSave: (nc) => { pendingAttachments[i].content = nc; }
331
- }));
332
- });
333
- wrap.appendChild(chip);
334
- }
335
-
336
- const rm = document.createElement('button');
337
- rm.className = 'attach-preview-remove';
338
- rm.textContent = '×';
339
- rm.addEventListener('click', e => { e.stopPropagation(); pendingAttachments.splice(i, 1); renderFilePreviewRow(); });
340
- wrap.appendChild(rm);
341
- return wrap;
342
- }
343
-
344
- export function renderFilePreviewRow() {
345
- const centerRow = document.getElementById('center-file-preview-row');
346
- const bottomRow = document.getElementById('file-preview-row');
347
-
348
- [centerRow, bottomRow].forEach(row => {
349
- if (!row) return;
350
- row.innerHTML = '';
351
- if (pendingAttachments.length === 0) { row.style.display = 'none'; return; }
352
- row.style.display = 'flex';
353
- pendingAttachments.forEach((a, i) => row.appendChild(buildAttachmentItem(a, i)));
354
- });
355
- }
356
-
357
- export function clearFilePreviewRow() {
358
- pendingAttachments = [];
359
- ['center-file-preview-row', 'file-preview-row'].forEach(id => {
360
- const row = document.getElementById(id);
361
- if (row) { row.innerHTML = ''; row.style.display = 'none'; }
362
- });
363
- }
364
-
365
- // ── Paste & drag-drop ─────────────────────────────────────────────────────
366
-
367
- function handlePaste(e) {
368
- const clipboardData = e.clipboardData || window.clipboardData;
369
- const items = Array.from(clipboardData?.items || []);
370
-
371
- // 1. Check for Images (Works synchronously)
372
- const imgItem = items.find(i => i.kind === 'file' && i.type.startsWith('image/'));
373
- if (imgItem) {
374
- e.preventDefault();
375
- addImageFile(imgItem.getAsFile());
376
- return;
377
- }
378
-
379
- // 2. Check for Large Text (Must check synchronously to prevent default)
380
- const text = clipboardData.getData('text/plain');
381
- if (text.length > LARGE_PASTE_THRESHOLD) {
382
- e.preventDefault(); // This NOW works because it's called immediately
383
- pendingAttachments.push({
384
- type: 'text',
385
- name: `Pasted Content.txt`,
386
- content: text
387
- });
388
- renderFilePreviewRow();
389
- }
390
- }
391
-
392
- // ── Bullet point auto-convert feature ────────────────────────────────────
393
- // When "- " is typed at the start of a line, convert to "• " (bullet marker)
394
- // and render it visually as a bullet. Backspace after bullet reverts to "- ".
395
-
396
- function setupBulletAutoConvert(textarea) {
397
- if (!textarea) return;
398
-
399
- // Track bullet positions: Map<lineIndex, true>
400
- // We use a marker character in the actual value: '\u2022 ' (bullet)
401
- const BULLET = '\u2022';
402
-
403
- textarea.addEventListener('keydown', (e) => {
404
- const val = textarea.value;
405
- const pos = textarea.selectionStart;
406
-
407
- if (e.key === ' ') {
408
- // Check if the character before cursor is '-' at start of a line
409
- const lineStart = val.lastIndexOf('\n', pos - 1) + 1;
410
- const beforeCursor = val.slice(lineStart, pos);
411
- if (beforeCursor === '-') {
412
- e.preventDefault();
413
- // Replace the '-' with a bullet
414
- const newVal = val.slice(0, lineStart) + BULLET + ' ' + val.slice(pos);
415
- textarea.value = newVal;
416
- const newPos = lineStart + 2;
417
- textarea.setSelectionRange(newPos, newPos);
418
- autoResize(textarea, 6);
419
- return;
420
- }
421
- }
422
-
423
- if (e.key === 'Backspace') {
424
- const lineStart = val.lastIndexOf('\n', pos - 1) + 1;
425
- const beforeCursor = val.slice(lineStart, pos);
426
- // If cursor is right after "• " (bullet + space), revert to "- "
427
- if (beforeCursor === BULLET + ' ') {
428
- e.preventDefault();
429
- const newVal = val.slice(0, lineStart) + '- ' + val.slice(pos);
430
- textarea.value = newVal;
431
- const newPos = lineStart + 2;
432
- textarea.setSelectionRange(newPos, newPos);
433
- autoResize(textarea, 6);
434
- return;
435
- }
436
- // If cursor is right after just the bullet (no space), revert to "-"
437
- if (beforeCursor === BULLET) {
438
- e.preventDefault();
439
- const newVal = val.slice(0, lineStart) + '-' + val.slice(pos);
440
- textarea.value = newVal;
441
- const newPos = lineStart + 1;
442
- textarea.setSelectionRange(newPos, newPos);
443
- autoResize(textarea, 6);
444
- }
445
- }
446
- });
447
- }
448
-
449
- [centerInput, bottomInput].forEach(input => {
450
- input?.addEventListener('paste', handlePaste);
451
- input?.addEventListener('dragover', e => e.preventDefault());
452
- input?.addEventListener('drop', async e => {
453
- e.preventDefault();
454
- for (const file of e.dataTransfer.files)
455
- if (file.type.startsWith('image/')) await addImageFile(file);
456
- });
457
- setupBulletAutoConvert(input);
458
- });
459
-
460
- // ── Auth/settings buttons ─────────────────────────────────────────────────
461
-
462
- document.getElementById('signin-btn')?.addEventListener('click', () => openAuthModal('signin'));
463
- document.getElementById('signin-icon-btn')?.addEventListener('click', () => openAuthModal('signin'));
464
- document.getElementById('settings-btn')?.addEventListener('click', () => openSettings('chat'));
465
- document.getElementById('settings-btn-guest')?.addEventListener('click', () => openSettings('chat'));
466
-
467
- document.getElementById('user-profile-btn')?.addEventListener('click', e => {
468
- const btn = e.currentTarget;
469
- const rect = btn.getBoundingClientRect();
470
- const menu = document.getElementById('user-context-menu');
471
- if (!menu) return;
472
-
473
- const menuItems = [
474
- { label: '⚙️ Settings', onClick: () => openSettings('chat') },
475
- { label: '👤 Account', onClick: () => openSettings('account') },
476
- { label: '💳 Billing Portal', onClick: () => window.open('https://sharktide-lightning.hf.space/portal', '_blank') },
477
- { sep: true },
478
- { label: '🗑️ Clear All Chats', danger: true,
479
- onClick: () => { if (confirm('Delete all chats? This cannot be undone.')) send({ type: 'sessions:deleteAll' }); }},
480
- { sep: true },
481
- { label: '🚪 Sign Out', danger: true, onClick: () => logout() },
482
- ];
483
-
484
- menu.innerHTML = '';
485
- for (const item of menuItems) {
486
- if (item.sep) {
487
- const s = document.createElement('div'); s.style.cssText = 'height:1px;background:var(--border);margin:3px 0;';
488
- menu.appendChild(s); continue;
489
- }
490
- const el = document.createElement('div');
491
- el.className = 'context-item' + (item.danger ? ' danger' : '');
492
- el.textContent = item.label;
493
- el.addEventListener('click', () => { menu.classList.add('hidden'); item.onClick(); });
494
- menu.appendChild(el);
495
- }
496
-
497
- menu.classList.remove('hidden');
498
- const mw = 200;
499
- let left = rect.left, top = rect.top;
500
- const mh = menu.scrollHeight || 200;
501
- top = rect.top - mh - 8;
502
- if (left + mw > window.innerWidth - 8) left = window.innerWidth - mw - 8;
503
- menu.style.left = `${Math.max(8, left)}px`;
504
- menu.style.top = `${Math.max(8, top)}px`;
505
-
506
- setTimeout(() => document.addEventListener('click', () => menu.classList.add('hidden'), { once: true }), 0);
507
- });
508
-
509
- // ── Share import from URL ?share=token ────────────────────────────────────
510
-
511
- function checkShareParam() {
512
- const params = new URLSearchParams(location.search);
513
- const token = params.get('share');
514
- if (!token) return;
515
-
516
- const banner = document.getElementById('share-import-banner');
517
- const bannerText= document.getElementById('share-banner-text');
518
- const importBtn = document.getElementById('share-import-btn');
519
- const dismissBtn= document.getElementById('share-dismiss-btn');
520
-
521
- fetch(`/api/share/${encodeURIComponent(token)}`)
522
- .then(r => r.ok ? r.json() : null)
523
- .then(data => {
524
- if (!data) return;
525
- if (bannerText) bannerText.textContent = `Import shared chat: "${data.name}"?`;
526
- banner?.classList.remove('hidden');
527
- })
528
- .catch(() => {});
529
-
530
- importBtn?.addEventListener('click', () => {
531
- if (!isAuthenticated()) { openAuthModal('signin'); return; }
532
- send({ type: 'sessions:import', token });
533
- banner?.classList.add('hidden');
534
- history.replaceState({}, '', '/');
535
- });
536
- dismissBtn?.addEventListener('click', () => {
537
- banner?.classList.add('hidden');
538
- history.replaceState({}, '', '/');
539
- });
540
- }
541
-
542
- // ── Connection notifications & reconnect handling ─────────────────────────
543
-
544
- let wasDisconnected = false;
545
- on('ws:disconnected', () => {
546
- wasDisconnected = true;
547
- showNotification({ type: 'warning', message: 'Connection lost — reconnecting…', duration: 3000 });
548
- });
549
- on('ws:connected', () => {
550
- if (wasDisconnected) {
551
- showNotification({ type: 'success', message: 'Reconnected', duration: 2000 });
552
- }
553
- wasDisconnected = false;
554
- });
555
-
556
- // ── Init ──────────────────────────────────────────────────────────────────
557
-
558
- document.addEventListener('DOMContentLoaded', () => {
559
- checkShareParam();
560
- // Theme is already applied by earlyTheme() above; don't flash to dark
561
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
public/js/auth.js DELETED
@@ -1,304 +0,0 @@
1
- // auth.js - Authentication state and Supabase integration
2
- import { send, on } from './ws.js';
3
-
4
- const SUPABASE_URL = 'https://dpixehhdbtzsbckfektd.supabase.co';
5
- const SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImRwaXhlaGhkYnR6c2Jja2Zla3RkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjExNDI0MjcsImV4cCI6MjA3NjcxODQyN30.nR1KCSRQj1E_evQWnE2VaZzg7PgLp2kqt4eDKP2PkpE';
6
-
7
- const AUTH_KEY = 'ipai_auth_v1';
8
- const TEMP_ID_KEY = 'ipai_temp_id';
9
- const CLIENT_ID_KEY = 'ipai_client_id';
10
- // Key written by oauth-callback.html so the main tab can pick it up
11
- const OAUTH_PENDING_KEY = 'ipai_oauth_pending';
12
-
13
- export let currentUser = null;
14
- export let userProfile = null;
15
- export let userSettings = null;
16
- export let subscriptionInfo = null;
17
-
18
- const authListeners = new Set();
19
-
20
- export function onAuthChange(fn) { authListeners.add(fn); return () => authListeners.delete(fn); }
21
- export function isAuthenticated() { return !!currentUser; }
22
-
23
- function notifyListeners() {
24
- authListeners.forEach(fn => fn({ currentUser, userProfile, userSettings }));
25
- }
26
-
27
- export function getClientId() {
28
- let id = localStorage.getItem(CLIENT_ID_KEY);
29
- if (!id) { id = `web-${crypto.randomUUID()}`; localStorage.setItem(CLIENT_ID_KEY, id); }
30
- return id;
31
- }
32
- export function getTempId() {
33
- let id = localStorage.getItem(TEMP_ID_KEY);
34
- if (!id) { id = crypto.randomUUID(); localStorage.setItem(TEMP_ID_KEY, id); }
35
- return id;
36
- }
37
- export function saveAuth(data) { localStorage.setItem(AUTH_KEY, JSON.stringify(data)); }
38
- export function loadAuth() { try { return JSON.parse(localStorage.getItem(AUTH_KEY) || 'null'); } catch { return null; } }
39
- export function clearAuth() { localStorage.removeItem(AUTH_KEY); }
40
-
41
- // ── Supabase REST helpers ─────────────────────────────────────────────────
42
-
43
- async function supabaseFetch(path, options = {}) {
44
- const token = options._useToken || SUPABASE_ANON_KEY;
45
- delete options._useToken;
46
- const res = await fetch(`${SUPABASE_URL}${path}`, {
47
- ...options,
48
- headers: {
49
- 'Content-Type': 'application/json',
50
- apikey: SUPABASE_ANON_KEY,
51
- Authorization: `Bearer ${token}`,
52
- ...(options.headers || {}),
53
- },
54
- });
55
- return res.json();
56
- }
57
-
58
- export async function loginWithEmail(email, password) {
59
- const data = await supabaseFetch('/auth/v1/token?grant_type=password', {
60
- method: 'POST', body: JSON.stringify({ email, password }),
61
- });
62
- if (data.error) throw new Error(data.error_description || data.error);
63
- await handleSupabaseSession(data);
64
- return data;
65
- }
66
-
67
- export async function signUpWithEmail(email, password) {
68
- const data = await supabaseFetch('/auth/v1/signup', {
69
- method: 'POST', body: JSON.stringify({ email, password }),
70
- });
71
- if (data.error) throw new Error(data.error_description || data.error);
72
- if (data.access_token) await handleSupabaseSession(data);
73
- return data;
74
- }
75
-
76
- /**
77
- * OAuth login via popup.
78
- * The popup writes the tokens to localStorage under OAUTH_PENDING_KEY,
79
- * then closes itself. This tab listens for the storage event and picks
80
- * them up — no postMessage needed (works even when opener is null, e.g.
81
- * on some mobile browsers or strict sandboxes).
82
- */
83
- export async function loginWithOAuth(provider) {
84
- const redirectTo = encodeURIComponent(`${location.origin}/oauth-callback.html`);
85
- const url = `${SUPABASE_URL}/auth/v1/authorize?provider=${provider}&redirect_to=${redirectTo}`;
86
- window.open(url, '_blank', 'width=520,height=640,noopener,noreferrer');
87
- // The storage listener below (window.addEventListener('storage', ...))
88
- // will fire when the popup writes OAUTH_PENDING_KEY and complete the login.
89
- }
90
-
91
- export async function logout() {
92
- const auth = loadAuth();
93
- if (auth?.access_token) {
94
- try {
95
- await supabaseFetch('/auth/v1/logout', {
96
- method: 'POST', _useToken: auth.access_token,
97
- });
98
- } catch {}
99
- }
100
- send({ type: 'auth:logout' });
101
- clearAuth();
102
- currentUser = null; userProfile = null; userSettings = null; subscriptionInfo = null;
103
- notifyListeners();
104
- updateSidebarProfile();
105
- initAsGuest();
106
- }
107
-
108
- // ── Session handling ──────────────────────────────────────────────────────
109
-
110
- async function handleSupabaseSession(data) {
111
- console.log('[Frontend Auth] handleSupabaseSession called with token:', data.access_token?.slice(0, 20) + '...');
112
- if (!data.access_token) throw new Error('No access token');
113
- saveAuth({ access_token: data.access_token, refresh_token: data.refresh_token, user: data.user });
114
-
115
- return new Promise((resolve, reject) => {
116
- const tempId = getTempId();
117
- const clientId = getClientId();
118
- console.log('[Frontend Auth] Sending auth:login to backend with token:', data.access_token?.slice(0, 20) + '...');
119
- send({ type: 'auth:login', accessToken: data.access_token, refreshToken: data.refresh_token,
120
- tempId, clientId });
121
-
122
- const unsubOk = on('auth:ok', (msg) => {
123
- console.log('[Frontend Auth] Received auth:ok response');
124
- unsubOk(); unsubErr(); applyAuthOk(msg); resolve(msg);
125
- });
126
- const unsubErr = on('auth:error', (msg) => {
127
- console.error('[Frontend Auth] Received auth:error:', msg.message);
128
- unsubOk(); unsubErr(); reject(new Error(msg.message));
129
- });
130
-
131
- setTimeout(() => { unsubOk(); unsubErr(); reject(new Error('Auth timeout')); }, 12000);
132
- });
133
- }
134
-
135
- function applyAuthOk(msg) {
136
- console.log('[Frontend Auth] applyAuthOk called with msg:', msg);
137
- currentUser = { id: msg.userId, email: msg.email };
138
- userProfile = msg.profile;
139
- userSettings = msg.settings;
140
- subscriptionInfo = msg.subscription || null;
141
- console.log('[Frontend Auth] Subscription info set to:', subscriptionInfo);
142
- console.log('[Frontend Auth] subscriptionInfo?.planKey:', subscriptionInfo?.planKey);
143
- console.log('[Frontend Auth] subscriptionInfo?.planName:', subscriptionInfo?.planName);
144
- const auth = loadAuth() || {};
145
- saveAuth({ ...auth, userId: msg.userId, deviceToken: msg.deviceToken });
146
- notifyListeners();
147
- console.log('[Frontend Auth] Calling updateSidebarProfile...');
148
- updateSidebarProfile();
149
- // Apply saved theme
150
- if (msg.settings?.theme) {
151
- import('./settings.js').then(m => m.applyTheme(msg.settings.theme));
152
- }
153
- }
154
-
155
- function initAsGuest() {
156
- send({ type: 'auth:guest', tempId: getTempId() });
157
- }
158
-
159
- // ── WS events ─────────────────────────────────────────────────────────────
160
-
161
- on('auth:newLogin', (msg) => {
162
- import('./ui.js').then(({ showNotification }) => {
163
- showNotification({
164
- type: 'warning',
165
- message: `New login detected from ${msg.ip || 'unknown location'}`,
166
- action: { label: 'View', onClick: () => import('./settings.js').then(m => m.openSettings('account')) },
167
- duration: 8000,
168
- });
169
- });
170
- });
171
-
172
- on('auth:forcedLogout', (msg) => {
173
- import('./ui.js').then(({ showNotification }) => {
174
- showNotification({ type: 'error', message: msg.reason || 'Session revoked', duration: 5000 });
175
- });
176
- setTimeout(() => logout(), 1500);
177
- });
178
-
179
- on('settings:updated', (msg) => {
180
- if (msg.settings) {
181
- userSettings = msg.settings;
182
- import('./settings.js').then(m => m.applyTheme(msg.settings.theme));
183
- }
184
- });
185
-
186
- // ── Reconnect on WS connect ────────────────────────────────────────────────
187
-
188
- on('ws:connected', async () => {
189
- console.log('[Frontend Auth] WS connected event');
190
- const auth = loadAuth();
191
- console.log('[Frontend Auth] Loaded auth:', auth ? 'exists' : 'null');
192
- if (auth?.access_token) {
193
- try {
194
- console.log('[Frontend Auth] Attempting to resume session with existing token');
195
- await handleSupabaseSession(auth);
196
- }
197
- catch (err) {
198
- console.error('[Frontend Auth] Failed to resume session:', err);
199
- clearAuth();
200
- initAsGuest();
201
- }
202
- } else {
203
- console.log('[Frontend Auth] No stored auth, initializing as guest');
204
- initAsGuest();
205
- }
206
- });
207
-
208
- // ── OAuth: localStorage-based token pickup ────────────────────────────────
209
- // Works for both popup and redirect flows.
210
- // The oauth-callback.html page writes { access_token, refresh_token } to
211
- // localStorage[OAUTH_PENDING_KEY] then closes/redirects. The storage event
212
- // fires in all other tabs from the same origin.
213
-
214
- window.addEventListener('storage', async (e) => {
215
- if (e.key !== OAUTH_PENDING_KEY || !e.newValue) return;
216
- console.log('[Frontend Auth] OAuth pending key detected in localStorage');
217
- // Consume immediately so other tabs don't also try to log in
218
- localStorage.removeItem(OAUTH_PENDING_KEY);
219
- let tokens;
220
- try { tokens = JSON.parse(e.newValue); } catch {
221
- console.error('[Frontend Auth] Failed to parse OAuth tokens');
222
- return;
223
- }
224
- if (!tokens?.access_token) {
225
- console.warn('[Frontend Auth] No access token in OAuth response');
226
- return;
227
- }
228
- console.log('[Frontend Auth] Processing OAuth tokens:', tokens.access_token?.slice(0, 20) + '...');
229
- try {
230
- await handleSupabaseSession(tokens);
231
- import('./ui.js').then(({ showNotification }) =>
232
- showNotification({ type: 'success', message: 'Signed in!', duration: 2500 }));
233
- } catch (err) {
234
- console.error('[Frontend Auth] OAuth sign-in error:', err);
235
- import('./ui.js').then(({ showNotification }) =>
236
- showNotification({ type: 'error', message: `Sign-in failed: ${err.message}`, duration: 4000 }));
237
- }
238
- });
239
-
240
- // Also handle same-tab redirect flow (no popup) — ?oauth=1&t=TOKEN&r=REFRESH
241
- (function checkOAuthRedirect() {
242
- const params = new URLSearchParams(location.search);
243
- const t = params.get('t'), r = params.get('r');
244
- console.log('[Frontend Auth] Checking for OAuth redirect params:', t ? 'found token' : 'no token');
245
- if (params.get('oauth') === '1' && t) {
246
- console.log('[Frontend Auth] Processing OAuth redirect with token:', t.slice(0, 20) + '...');
247
- history.replaceState({}, '', '/');
248
- handleSupabaseSession({ access_token: t, refresh_token: r || '' }).catch((err) => {
249
- console.error('[Frontend Auth] OAuth redirect failed:', err);
250
- });
251
- }
252
- })();
253
-
254
- // Legacy postMessage support (kept for backwards compat with old callback pages)
255
- window.addEventListener('message', async (e) => {
256
- if (e.origin !== location.origin) return;
257
- if (e.data?.type !== 'oauth:callback') return;
258
- console.log('[Frontend Auth] postMessage oauth:callback received');
259
- const { access_token, refresh_token } = e.data;
260
- if (!access_token) {
261
- console.warn('[Frontend Auth] No access_token in postMessage oauth:callback');
262
- return;
263
- }
264
- console.log('[Frontend Auth] Processing postMessage tokens:', access_token?.slice(0, 20) + '...');
265
- try { await handleSupabaseSession({ access_token, refresh_token }); }
266
- catch (err) {
267
- console.error('[Frontend Auth] postMessage sign-in error:', err);
268
- import('./ui.js').then(({ showNotification }) =>
269
- showNotification({ type: 'error', message: `Sign-in failed: ${err.message}`, duration: 4000 }));
270
- }
271
- });
272
-
273
- // ── Sidebar profile ───────────────────────────────────────────────────────
274
-
275
- export function updateSidebarProfile() {
276
- console.log('[Frontend Auth] updateSidebarProfile called - currentUser:', currentUser);
277
- const guestEl = document.getElementById('guest-section');
278
- const userEl = document.getElementById('user-section');
279
- const nameEl = document.getElementById('user-name-display');
280
- const planEl = document.getElementById('user-plan-display');
281
- const avatarEl= document.getElementById('user-avatar');
282
-
283
- if (!currentUser) {
284
- console.log('[Frontend Auth] Not logged in, showing guest section');
285
- guestEl?.classList.remove('hidden');
286
- userEl?.classList.add('hidden');
287
- return;
288
- }
289
- guestEl?.classList.add('hidden');
290
- userEl?.classList.remove('hidden');
291
-
292
- const username = userProfile?.username || currentUser.email?.split('@')[0] || '?';
293
- if (nameEl) nameEl.textContent = username;
294
- if (avatarEl) avatarEl.textContent = username[0].toUpperCase();
295
-
296
- const plan = subscriptionInfo?.planKey || 'free';
297
- const planName = subscriptionInfo?.planName || 'Free';
298
- console.log('[Frontend Auth] Setting plan display - plan:', plan, 'planName:', planName, 'subscriptionInfo:', subscriptionInfo);
299
- if (planEl) { planEl.textContent = planName; planEl.setAttribute('data-plan', plan); }
300
- if (avatarEl) { avatarEl.setAttribute('data-plan', plan); }
301
- }
302
-
303
- on('auth:ok', updateSidebarProfile);
304
- on('auth:loggedOut', updateSidebarProfile);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
public/js/chat.js DELETED
@@ -1,1101 +0,0 @@
1
- // chat.js — Chat rendering, streaming, versioning, editing
2
- import { send, on, off } from './ws.js';
3
- import { currentSessionId } from './sessions.js';
4
- import {
5
- renderMarkdown, attachCodeCopyListeners, attachSvgPanelListeners,
6
- escHtml, showNotification, autoResize,
7
- } from './ui.js';
8
- import { renderFilePreviewRow, clearFilePreviewRow } from './app.js';
9
-
10
- let activeSessionId = null;
11
- let isStreaming = false;
12
- let streamingBubble = null;
13
- let streamingText = '';
14
- let streamingSessionId = null; // track which session is streaming
15
- let autoScroll = true;
16
- let pendingAssets = [];
17
-
18
- const BULLET = '\u2022';
19
-
20
- export function setActiveSession(id) {
21
- activeSessionId = id;
22
- // Update send button state based on whether this session is streaming
23
- updateSendBtn(isStreaming && streamingSessionId === id);
24
- }
25
- export function getIsStreaming() { return isStreaming && streamingSessionId === activeSessionId; }
26
-
27
- // ── WebSocket events ──────────────────────────────────────────────────────
28
-
29
- on('sessions:data', (msg) => { if (msg.session.id === activeSessionId) renderSession(msg.session); });
30
- on('chat:start', (msg) => { onChatStart(msg); });
31
- on('chat:token', (msg) => { if (msg.sessionId === activeSessionId) onToken(msg.token); });
32
- on('chat:done', (msg) => { onChatDone(msg); });
33
- on('chat:aborted', (msg) => { onChatAborted(msg); });
34
- on('chat:error', (msg) => { if (msg.sessionId === activeSessionId) onChatError(msg.error); });
35
- on('chat:asset', (msg) => { if (msg.sessionId === activeSessionId) appendAsset(msg.asset); });
36
- on('chat:toolCall', (msg) => { if (msg.sessionId === activeSessionId) handleLiveToolCall(msg.call); });
37
- on('chat:messageEdited', (msg) => { if (msg.sessionId === activeSessionId) renderHistory(msg.history); });
38
- on('chat:versionSelected', (msg) => { if (msg.sessionId === activeSessionId) renderHistory(msg.history); });
39
-
40
- // Reconnect: reload current session instead of resetting to welcome
41
- on('ws:connected', () => {
42
- if (activeSessionId) {
43
- send({ type: 'sessions:get', sessionId: activeSessionId });
44
- }
45
- });
46
-
47
- // ── Tree extraction (mirror of server logic) ──────────────────────────────
48
-
49
- function extractFlatHistoryFromTree(rootMessage) {
50
- if (!rootMessage) return [];
51
- const history = [];
52
- const ensureValidContent = (msg) => {
53
- if (msg.content === undefined || msg.content === null) msg.content = '';
54
- return msg;
55
- };
56
- const extractBranch = (message) => {
57
- history.push(ensureValidContent(message));
58
- const currentVersionIdx = message.currentVersionIdx ?? 0;
59
- const versions = message.versions || [];
60
- if (currentVersionIdx >= versions.length) return;
61
- const currentVersion = versions[currentVersionIdx];
62
- if (!currentVersion) return;
63
- const tail = currentVersion.tail;
64
- if (!Array.isArray(tail) || tail.length === 0) return;
65
- for (const tailMessage of tail) extractBranch(tailMessage);
66
- };
67
- extractBranch(rootMessage);
68
- return history;
69
- }
70
-
71
- // ── Views ─────────────────────────────────────────────────────────────────
72
-
73
- export function renderSession(session) {
74
- if (!session || !session.history?.length) { showWelcome(); return; }
75
- showChat();
76
- const flatHistory = session.history[0]?.versions
77
- ? extractFlatHistoryFromTree(session.history[0])
78
- : session.history;
79
- renderHistory(flatHistory);
80
- }
81
-
82
- function showWelcome() {
83
- document.getElementById('welcome-view')?.classList.remove('hidden');
84
- document.getElementById('chat-view')?.classList.add('hidden');
85
- document.getElementById('bottom-input-bar')?.classList.add('hidden');
86
- }
87
-
88
- function showChat() {
89
- document.getElementById('welcome-view')?.classList.add('hidden');
90
- document.getElementById('chat-view')?.classList.remove('hidden');
91
- document.getElementById('bottom-input-bar')?.classList.remove('hidden');
92
- }
93
-
94
- // ── Full history render ───────────────────────────────────────────────────
95
-
96
- export function renderHistory(history) {
97
- showChat();
98
- const box = document.getElementById('chat-messages');
99
- if (!box) return;
100
- box.innerHTML = '';
101
-
102
- for (let i = 0; i < history.length; i++) {
103
- const msg = history[i];
104
- const cleanMsg = { ...msg, content: stripSessionTag(msg.content) };
105
- if (cleanMsg.role === 'user') appendUserMsg(box, cleanMsg, i);
106
- else if (cleanMsg.role === 'assistant') appendAssistantMsg(box, cleanMsg, i);
107
- else if (cleanMsg.role === 'image') appendMediaMsg(box, 'image', cleanMsg.content);
108
- else if (cleanMsg.role === 'video') appendMediaMsg(box, 'video', cleanMsg.content);
109
- else if (cleanMsg.role === 'audio') appendMediaMsg(box, 'audio', cleanMsg.content);
110
- }
111
-
112
- if (typeof renderMathInElement !== 'undefined') {
113
- try {
114
- renderMathInElement(box, {
115
- delimiters: [
116
- { left: '$$', right: '$$', display: true },
117
- { left: '$', right: '$', display: false },
118
- { left: '\\(', right: '\\)', display: false },
119
- { left: '\\[', right: '\\]', display: true },
120
- ],
121
- throwOnError: false,
122
- });
123
- } catch {}
124
- }
125
-
126
- if (autoScroll) box.scrollTop = box.scrollHeight;
127
- }
128
-
129
- function stripSessionTag(content) {
130
- if (typeof content !== 'string') return content;
131
- return content.replace(/<session_name>[\s\S]*?<\/session_name>/gi, '').trim();
132
- }
133
-
134
- // ── Message renderers ─────────────────────────────────────────────────────
135
-
136
- function appendUserMsg(box, msg, index) {
137
- const wrap = makeWrap(index);
138
- const bubble = document.createElement('div');
139
- bubble.className = 'msg-user';
140
-
141
- const text = msgText(msg.content);
142
- const imgs = msgImages(msg.content);
143
- const files = msgFiles(msg.content);
144
-
145
- // Render text (convert bullet chars back for display)
146
- const displayText = textWithBullets(text);
147
- bubble.innerHTML = renderMarkdown(displayText);
148
- attachCodeCopyListeners(bubble);
149
- attachSvgPanelListeners(bubble);
150
-
151
- // Image attachments
152
- imgs.forEach(src => {
153
- const img = document.createElement('img');
154
- img.src = src; img.alt = 'Attached image';
155
- img.style.cssText = 'max-width:100%;max-height:260px;border-radius:8px;margin-top:8px;display:block;cursor:pointer;';
156
- img.addEventListener('click', () => openImageModal(src));
157
- bubble.appendChild(img);
158
- });
159
-
160
- // File attachments — shown as chips
161
- if (files.length > 0) {
162
- const fileRow = document.createElement('div');
163
- fileRow.className = 'msg-file-attachments';
164
- files.forEach(f => {
165
- const chip = buildFileChipView(f.name, f.content, false);
166
- fileRow.appendChild(chip);
167
- });
168
- bubble.appendChild(fileRow);
169
- }
170
-
171
- wrap.appendChild(bubble);
172
-
173
- // Version navigator below the bubble (only on user messages)
174
- if (msg.versions?.length > 1) {
175
- wrap.appendChild(buildVersionNav(msg, index));
176
- }
177
-
178
- // Action buttons
179
- wrap.appendChild(buildActions([
180
- { icon: copyIcon(), title: 'Copy', fn: () => copyText(text) },
181
- { icon: editIcon(), title: 'Edit', fn: () => startUserEdit(wrap, index, msg, text, files) },
182
- ], 'right'));
183
-
184
- box.appendChild(wrap);
185
- }
186
-
187
- function appendAssistantMsg(box, msg, index) {
188
- const wrap = makeWrap(index);
189
- const bubble = document.createElement('div');
190
- bubble.className = 'msg-assistant';
191
- bubble.innerHTML = renderMarkdown(msg.content || '');
192
- attachCodeCopyListeners(bubble);
193
- attachSvgPanelListeners(bubble);
194
-
195
- if (msg.toolCalls?.length) {
196
- const chipRow = document.createElement('div');
197
- chipRow.style.cssText = 'display:flex;flex-wrap:wrap;gap:4px;margin-top:6px;';
198
- msg.toolCalls.forEach(c => chipRow.appendChild(buildToolChip(c)));
199
- bubble.appendChild(chipRow);
200
- }
201
-
202
- wrap.appendChild(bubble);
203
-
204
- wrap.appendChild(buildActions([
205
- { icon: copyIcon(), title: 'Copy', fn: () => copyText(msg.content || '') },
206
- { icon: editIcon(), title: 'Edit', fn: () => startAssistantEdit(wrap, index, msg) },
207
- ], 'left'));
208
-
209
- box.appendChild(wrap);
210
- }
211
-
212
- function appendMediaMsg(box, type, content) {
213
- const wrap = document.createElement('div');
214
- wrap.className = 'msg-media';
215
-
216
- if (type === 'image') {
217
- const img = document.createElement('img');
218
- img.src = content; img.alt = 'Generated image';
219
- img.addEventListener('click', () => openImageModal(content));
220
- wrap.appendChild(img);
221
- wrap.appendChild(dlBtn(() => dlMedia(content, 'image.png')));
222
- } else if (type === 'video') {
223
- const v = document.createElement('video');
224
- v.src = content; v.controls = true; v.preload = 'metadata';
225
- wrap.appendChild(v);
226
- wrap.appendChild(dlBtn(() => dlMedia(content, 'video.mp4')));
227
- } else if (type === 'audio') {
228
- const a = document.createElement('audio');
229
- a.src = content; a.controls = true; a.preload = 'metadata'; a.style.width = '100%';
230
- wrap.appendChild(a);
231
- const db = dlBtn(() => dlMedia(content, 'audio.mp3'));
232
- db.style.cssText += ';position:static;margin-top:6px;opacity:1;';
233
- wrap.appendChild(db);
234
- }
235
- box.appendChild(wrap);
236
- }
237
-
238
- // ── File content extraction from message ─────────────────────────────────
239
-
240
- /**
241
- * Extract text file attachments embedded in the message content string.
242
- * They're stored as <details><summary>name</summary>\n```\ncontent\n```\n</details>
243
- */
244
- function msgFiles(content) {
245
- if (typeof content !== 'string') return [];
246
- const files = [];
247
- const re = /<details><summary>([^<]+?)<\/summary>\s*```(?:\w*)\n([\s\S]*?)\n```\s*<\/details>/g;
248
- let m;
249
- while ((m = re.exec(content)) !== null) {
250
- files.push({ name: m[1].trim(), content: m[2] });
251
- }
252
- return files;
253
- }
254
-
255
- /**
256
- * Strip the embedded file details blocks from display text so we show
257
- * only the user's written text.
258
- */
259
- function stripFileBlocks(text) {
260
- if (typeof text !== 'string') return text;
261
- return text
262
- .replace(/<details><summary>Attached Files<\/summary>[\s\S]*?<\/details>/g, '')
263
- .replace(/<details><summary>[^<]+?<\/summary>[\s\S]*?<\/details>/g, '')
264
- .trim();
265
- }
266
-
267
- /**
268
- * Build a clickable chip for viewing a text file (read-only in sent messages).
269
- */
270
- function buildFileChipView(name, content, editable = false, onSave) {
271
- const chip = document.createElement('div');
272
- chip.className = 'file-attachment-chip';
273
- chip.title = name;
274
- const lineCount = (content.match(/\n/g) || []).length + 1;
275
- chip.innerHTML = `
276
- <span class="chip-icon">📄</span>
277
- <div style="display:flex;flex-direction:column;min-width:0;">
278
- <span class="chip-name">${escHtml(name)}</span>
279
- <span class="chip-meta">${lineCount} line${lineCount !== 1 ? 's' : ''}</span>
280
- </div>`;
281
- chip.addEventListener('click', () => {
282
- import('./modals.js').then(m => m.openFileViewerModal({
283
- name,
284
- content,
285
- editable,
286
- onSave,
287
- }));
288
- });
289
- return chip;
290
- }
291
-
292
- // ── Builders ──────────────────────────────────────────────────────────────
293
-
294
- function makeWrap(index) {
295
- const w = document.createElement('div');
296
- w.className = 'msg-group'; w.dataset.index = index;
297
- return w;
298
- }
299
-
300
- function buildActions(items, side = 'left') {
301
- const div = document.createElement('div');
302
- div.className = 'msg-actions' + (side === 'right' ? ' msg-actions-right' : ' msg-actions-left');
303
- items.forEach(({ icon, title, fn }) => {
304
- const btn = document.createElement('button');
305
- btn.className = 'msg-action-btn'; btn.title = title;
306
- btn.innerHTML = icon;
307
- btn.addEventListener('click', e => { e.stopPropagation(); fn(); });
308
- div.appendChild(btn);
309
- });
310
- return div;
311
- }
312
-
313
- function buildVersionNav(msg, index) {
314
- const nav = document.createElement('div');
315
- nav.className = 'msg-version-nav';
316
- const total = msg.versions.length;
317
- const cur = (msg.currentVersionIdx ?? 0) + 1;
318
-
319
- const prev = document.createElement('button');
320
- prev.innerHTML = '&#8249;'; prev.title = 'Previous version'; prev.disabled = cur <= 1;
321
- prev.addEventListener('click', () => send({ type: 'chat:selectVersion',
322
- sessionId: activeSessionId, messageIndex: index, versionIdx: (msg.currentVersionIdx ?? 0) - 1 }));
323
-
324
- const lbl = document.createElement('span');
325
- lbl.textContent = `${cur} / ${total}`;
326
- lbl.style.cssText = 'min-width:36px;text-align:center;';
327
-
328
- const next = document.createElement('button');
329
- next.innerHTML = '&#8250;'; next.title = 'Next version'; next.disabled = cur >= total;
330
- next.addEventListener('click', () => send({ type: 'chat:selectVersion',
331
- sessionId: activeSessionId, messageIndex: index, versionIdx: (msg.currentVersionIdx ?? 0) + 1 }));
332
-
333
- nav.appendChild(prev); nav.appendChild(lbl); nav.appendChild(next);
334
- return nav;
335
- }
336
-
337
- function buildToolChip(call) {
338
- const names = { ollama_search: 'Web Search', read_web_page: 'Read Page',
339
- generate_image: 'Image Gen', generate_video: 'Video Gen', generate_audio: 'Audio Gen' };
340
- const icons = { ollama_search: '🔍', read_web_page: '📄',
341
- generate_image: '🖼️', generate_video: '🎬', generate_audio: '🎵' };
342
- const chip = document.createElement('button');
343
- chip.className = 'msg-tool-call';
344
- chip.innerHTML = `<span>${icons[call.name] || '🔧'}</span><span>${escHtml(names[call.name] || call.name)}</span>`;
345
- chip.addEventListener('click', () => import('./modals.js').then(m => m.showToolCallModal(call)));
346
- return chip;
347
- }
348
-
349
- function dlBtn(fn) {
350
- const btn = document.createElement('button');
351
- btn.className = 'media-download-btn'; btn.textContent = 'Download';
352
- btn.addEventListener('click', fn);
353
- return btn;
354
- }
355
-
356
- // ── SVG icons ─────────────────────────────────────────────────────────────
357
-
358
- function copyIcon() {
359
- return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`;
360
- }
361
-
362
- function editIcon() {
363
- return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`;
364
- }
365
-
366
- function searchIcon() {
367
- return `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>`;
368
- }
369
- function imageIcon() {
370
- return `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>`;
371
- }
372
- function videoIcon() {
373
- return `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2"/></svg>`;
374
- }
375
- function audioIcon() {
376
- return `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>`;
377
- }
378
- function attachIcon() {
379
- return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>`;
380
- }
381
- function sendSvg() {
382
- return `<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M2 21L23 12 2 3v7l15 2-15 2v7z"/></svg>`;
383
- }
384
-
385
- // ── Inline editing ────────────────────────────────────────────────────────
386
-
387
- function extractAttachmentsFromContent(content) {
388
- if (!content || typeof content === 'string') {
389
- // Extract from embedded file blocks in text
390
- const files = msgFiles(typeof content === 'string' ? content : '');
391
- return files.map(f => ({ type: 'text', name: f.name, content: f.content }));
392
- }
393
- if (!Array.isArray(content)) return [];
394
- const attachments = [];
395
- content.forEach(item => {
396
- if (item.type === 'image_url' && item.image_url?.url) {
397
- const dataUrl = item.image_url.url;
398
- if (dataUrl.startsWith('data:')) {
399
- const comma = dataUrl.indexOf(',');
400
- if (comma > 0) {
401
- const mimeType = dataUrl.slice(5, dataUrl.indexOf(';'));
402
- const base64 = dataUrl.slice(comma + 1);
403
- const ext = mimeType.split('/')[1] || 'png';
404
- attachments.push({ type: 'image', name: `image.${ext}`, base64, mimeType });
405
- }
406
- }
407
- }
408
- });
409
- // Also extract text files from text part
410
- const textPart = content.find(p => p.type === 'text')?.text || '';
411
- msgFiles(textPart).forEach(f => {
412
- attachments.push({ type: 'text', name: f.name, content: f.content });
413
- });
414
- return attachments;
415
- }
416
-
417
- /**
418
- * startUserEdit now accepts existingFiles extracted from the rendered message.
419
- */
420
- function startUserEdit(wrap, index, msg, originalText, existingFiles = []) {
421
- const bubble = wrap.querySelector('.msg-user'); if (!bubble) return;
422
- const originalHTML = bubble.innerHTML;
423
-
424
- // Get clean text without embedded file blocks
425
- const cleanText = stripFileBlocks(originalText);
426
-
427
- bubble.innerHTML = '';
428
- bubble.classList.add('editing-user');
429
-
430
- // Textarea — show bullet characters correctly
431
- const ta = makeUserEditTextarea(cleanText);
432
-
433
- // Set up bullet auto-convert in the edit textarea
434
- setupEditBulletConvert(ta);
435
-
436
- bubble.appendChild(ta);
437
-
438
- // File preview row
439
- const editAttachments = [];
440
- // Start with existing image attachments
441
- const existingImages = extractAttachmentsFromContent(msg.content).filter(a => a.type === 'image');
442
- editAttachments.push(...existingImages);
443
- // Add existing text file attachments
444
- existingFiles.forEach(f => editAttachments.push({ type: 'text', name: f.name, content: f.content }));
445
-
446
- const filePreviewRow = document.createElement('div');
447
- filePreviewRow.className = 'edit-file-preview-row';
448
- bubble.appendChild(filePreviewRow);
449
-
450
- if (editAttachments.length > 0) renderEditFilePreview(editAttachments, filePreviewRow);
451
-
452
- // Bottom toolbar
453
- const toolbar = document.createElement('div');
454
- toolbar.className = 'edit-toolbar';
455
-
456
- const toolRow = document.createElement('div');
457
- toolRow.className = 'edit-tool-row';
458
-
459
- const toolDefs = [
460
- { key: 'webSearch', label: 'Search', icon: searchIcon() },
461
- { key: 'imageGen', label: 'Image', icon: imageIcon() },
462
- { key: 'videoGen', label: 'Video', icon: videoIcon() },
463
- { key: 'audioGen', label: 'Audio', icon: audioIcon() },
464
- ];
465
-
466
- toolDefs.forEach(({ key, label, icon }) => {
467
- const btn = document.createElement('button');
468
- btn.className = 'tool-btn-sm edit-tool-btn';
469
- btn.dataset.tool = key;
470
- btn.title = label;
471
- btn.innerHTML = `${icon}<span>${label}</span>`;
472
- const mainBtn = document.querySelector(`#bottom-input-bar [data-tool="${key}"]`);
473
- if (mainBtn?.classList.contains('active')) btn.classList.add('active');
474
- btn.addEventListener('click', () => btn.classList.toggle('active'));
475
- toolRow.appendChild(btn);
476
- });
477
-
478
- // Paste image in edit textarea
479
- ta.addEventListener('paste', async (e) => {
480
- const items = e.clipboardData?.items || [];
481
- for (const item of items) {
482
- if (item.type.startsWith('image/')) {
483
- e.preventDefault();
484
- const file = item.getAsFile();
485
- if (file) {
486
- const dataUrl = await new Promise((res, rej) => {
487
- const reader = new FileReader();
488
- reader.onload = () => res(reader.result);
489
- reader.onerror = rej;
490
- reader.readAsDataURL(file);
491
- });
492
- const comma = dataUrl.indexOf(',');
493
- const mimeType = dataUrl.slice(5, dataUrl.indexOf(';'));
494
- const base64 = dataUrl.slice(comma + 1);
495
- editAttachments.push({
496
- type: 'image',
497
- name: file.name || `image.${mimeType.split('/')[1] || 'png'}`,
498
- base64, mimeType,
499
- });
500
- renderEditFilePreview(editAttachments, filePreviewRow);
501
- }
502
- }
503
- }
504
- });
505
-
506
- // Attach button
507
- const attachBtn = document.createElement('button');
508
- attachBtn.className = 'edit-attach-btn';
509
- attachBtn.title = 'Attach file or image';
510
- attachBtn.innerHTML = attachIcon();
511
- attachBtn.addEventListener('click', e => {
512
- e.stopPropagation();
513
- openEditAttachMenu(e, attachBtn, editAttachments, filePreviewRow);
514
- });
515
- toolRow.appendChild(attachBtn);
516
- toolbar.appendChild(toolRow);
517
-
518
- // Cancel + Send
519
- const btnRow = document.createElement('div');
520
- btnRow.className = 'edit-btn-row';
521
-
522
- const cancelBtn = makeBtn('Cancel', 'btn-ghost');
523
- cancelBtn.addEventListener('click', () => {
524
- bubble.classList.remove('editing-user');
525
- bubble.innerHTML = originalHTML;
526
- attachCodeCopyListeners(bubble);
527
- });
528
-
529
- const sendBtn = document.createElement('button');
530
- sendBtn.className = 'btn-primary edit-send-btn';
531
- sendBtn.innerHTML = `${sendSvg()} Send`;
532
- sendBtn.addEventListener('click', () => {
533
- const newContent = ta.value.trim(); if (!newContent && editAttachments.length === 0) return;
534
- sendBtn.disabled = true;
535
-
536
- const tools = {};
537
- toolbar.querySelectorAll('[data-tool]').forEach(b => {
538
- tools[b.dataset.tool] = b.classList.contains('active');
539
- });
540
-
541
- send({
542
- type: 'chat:editMessage',
543
- sessionId: activeSessionId,
544
- messageIndex: index,
545
- newContent: buildEditContent(newContent, editAttachments),
546
- role: 'user',
547
- });
548
-
549
- const handler = (editMsg) => {
550
- if (editMsg.sessionId !== activeSessionId || editMsg.messageIndex !== index) return;
551
- off('chat:messageEdited', handler);
552
- send({ type: 'chat:send', sessionId: activeSessionId, tools });
553
- };
554
- on('chat:messageEdited', handler);
555
- });
556
-
557
- btnRow.appendChild(cancelBtn);
558
- btnRow.appendChild(sendBtn);
559
- toolbar.appendChild(btnRow);
560
-
561
- bubble.appendChild(toolbar);
562
- ta.focus();
563
- ta.setSelectionRange(ta.value.length, ta.value.length);
564
- }
565
-
566
- function setupEditBulletConvert(ta) {
567
- ta.addEventListener('keydown', (e) => {
568
- const val = ta.value;
569
- const pos = ta.selectionStart;
570
-
571
- if (e.key === ' ') {
572
- const lineStart = val.lastIndexOf('\n', pos - 1) + 1;
573
- const beforeCursor = val.slice(lineStart, pos);
574
- if (beforeCursor === '-') {
575
- e.preventDefault();
576
- const newVal = val.slice(0, lineStart) + BULLET + ' ' + val.slice(pos);
577
- ta.value = newVal;
578
- const newPos = lineStart + 2;
579
- ta.setSelectionRange(newPos, newPos);
580
- ta.style.height = 'auto';
581
- ta.style.height = ta.scrollHeight + 'px';
582
- return;
583
- }
584
- }
585
-
586
- if (e.key === 'Backspace') {
587
- const lineStart = val.lastIndexOf('\n', pos - 1) + 1;
588
- const beforeCursor = val.slice(lineStart, pos);
589
- if (beforeCursor === BULLET + ' ') {
590
- e.preventDefault();
591
- const newVal = val.slice(0, lineStart) + '- ' + val.slice(pos);
592
- ta.value = newVal;
593
- const newPos = lineStart + 2;
594
- ta.setSelectionRange(newPos, newPos);
595
- ta.style.height = 'auto';
596
- ta.style.height = ta.scrollHeight + 'px';
597
- return;
598
- }
599
- if (beforeCursor === BULLET) {
600
- e.preventDefault();
601
- const newVal = val.slice(0, lineStart) + '-' + val.slice(pos);
602
- ta.value = newVal;
603
- const newPos = lineStart + 1;
604
- ta.setSelectionRange(newPos, newPos);
605
- ta.style.height = 'auto';
606
- ta.style.height = ta.scrollHeight + 'px';
607
- }
608
- }
609
- });
610
- }
611
-
612
- function buildEditContent(text, attachments) {
613
- const images = attachments.filter(a => a.type === 'image');
614
- const files = attachments.filter(a => a.type === 'text');
615
- let fullText = text;
616
- if (files.length > 0) {
617
- fullText += '\n\n<details><summary>Attached Files</summary>\n';
618
- for (const f of files)
619
- fullText += `\n<details><summary>${f.name}</summary>\n\n\`\`\`\n${f.content}\n\`\`\`\n\n</details>\n`;
620
- fullText += '</details>';
621
- }
622
- if (images.length > 0) {
623
- return [
624
- { type: 'text', text: fullText },
625
- ...images.map(img => ({ type: 'image_url', image_url: { url: `data:${img.mimeType};base64,${img.base64}` } })),
626
- ];
627
- }
628
- return fullText;
629
- }
630
-
631
- function openEditAttachMenu(e, triggerEl, editAttachments, filePreviewRow) {
632
- e.preventDefault();
633
- const fileInput = document.getElementById('file-input');
634
- const imageInput = document.getElementById('image-input');
635
- if (!fileInput || !imageInput) return;
636
-
637
- const menu = document.getElementById('attach-context-menu');
638
- if (!menu) return;
639
- menu.innerHTML = '';
640
-
641
- for (const item of [
642
- {
643
- label: '📄 Upload file',
644
- onClick: () => {
645
- const handler = async function() {
646
- for (const file of fileInput.files) {
647
- const text = await file.text();
648
- editAttachments.push({ type: 'text', name: file.name, content: text });
649
- }
650
- fileInput.value = '';
651
- fileInput.removeEventListener('change', handler);
652
- renderEditFilePreview(editAttachments, filePreviewRow);
653
- };
654
- fileInput.addEventListener('change', handler);
655
- fileInput.click();
656
- },
657
- },
658
- {
659
- label: '🖼️ Upload image',
660
- onClick: () => {
661
- const handler = async function() {
662
- for (const file of imageInput.files) {
663
- const dataUrl = await new Promise((res, rej) => {
664
- const reader = new FileReader();
665
- reader.onload = () => res(reader.result);
666
- reader.onerror = rej;
667
- reader.readAsDataURL(file);
668
- });
669
- const comma = dataUrl.indexOf(',');
670
- const mimeType = dataUrl.slice(5, dataUrl.indexOf(';'));
671
- const base64 = dataUrl.slice(comma + 1);
672
- editAttachments.push({ type: 'image', name: file.name, base64, mimeType });
673
- }
674
- imageInput.value = '';
675
- imageInput.removeEventListener('change', handler);
676
- renderEditFilePreview(editAttachments, filePreviewRow);
677
- };
678
- imageInput.addEventListener('change', handler);
679
- imageInput.click();
680
- },
681
- },
682
- ]) {
683
- const el = document.createElement('div');
684
- el.className = 'context-item'; el.textContent = item.label;
685
- el.addEventListener('click', () => { menu.classList.add('hidden'); item.onClick(); });
686
- menu.appendChild(el);
687
- }
688
-
689
- menu.classList.remove('hidden');
690
- const rect = triggerEl.getBoundingClientRect();
691
- const mh = 80;
692
- menu.style.left = `${Math.max(8, rect.left)}px`;
693
- menu.style.top = `${rect.top - mh - 8}px`;
694
- setTimeout(() => document.addEventListener('click', () => menu.classList.add('hidden'), { once: true }), 0);
695
- }
696
-
697
- function renderEditFilePreview(attachments, row) {
698
- row.innerHTML = '';
699
- if (attachments.length === 0) { row.style.display = 'none'; return; }
700
- row.style.display = 'flex';
701
- row.style.flexWrap = 'wrap';
702
- row.style.gap = '6px';
703
- attachments.forEach((a, i) => {
704
- const wrap = document.createElement('div');
705
- wrap.className = 'attach-preview-item';
706
- if (a.type === 'image') {
707
- const img = document.createElement('img');
708
- img.src = `data:${a.mimeType};base64,${a.base64}`; img.alt = a.name;
709
- wrap.appendChild(img);
710
- } else {
711
- // Use the same nice chip for text files in edit mode
712
- const chip = buildFileChipView(a.name, a.content, true, (nc) => {
713
- attachments[i].content = nc;
714
- });
715
- wrap.appendChild(chip);
716
- }
717
- const rm = document.createElement('button');
718
- rm.className = 'attach-preview-remove'; rm.textContent = '×';
719
- rm.addEventListener('click', e => {
720
- e.stopPropagation();
721
- attachments.splice(i, 1);
722
- renderEditFilePreview(attachments, row);
723
- });
724
- wrap.appendChild(rm);
725
- row.appendChild(wrap);
726
- });
727
- renderFilePreviewRow();
728
- }
729
-
730
- function startAssistantEdit(wrap, index, msg) {
731
- const bubble = wrap.querySelector('.msg-assistant'); if (!bubble) return;
732
- const originalContent = msg.content || '';
733
- const originalHTML = bubble.innerHTML;
734
-
735
- bubble.classList.add('editing');
736
- bubble.innerHTML = '';
737
-
738
- const ta = makeAssistantEditTextarea(originalContent);
739
- bubble.appendChild(ta);
740
-
741
- const btns = document.createElement('div');
742
- btns.className = 'edit-actions';
743
-
744
- const cancelBtn = makeBtn('Cancel', 'btn-ghost');
745
- cancelBtn.addEventListener('click', () => {
746
- bubble.classList.remove('editing');
747
- bubble.innerHTML = originalHTML;
748
- attachCodeCopyListeners(bubble);
749
- attachSvgPanelListeners(bubble);
750
- });
751
-
752
- const saveBtn = makeBtn('Save', 'btn-primary');
753
- saveBtn.addEventListener('click', () => {
754
- const newContent = ta.value.trim(); if (!newContent) return;
755
- saveBtn.disabled = true;
756
- send({ type: 'chat:editMessage', sessionId: activeSessionId,
757
- messageIndex: index, newContent, role: 'assistant' });
758
- });
759
-
760
- btns.appendChild(cancelBtn); btns.appendChild(saveBtn);
761
- bubble.appendChild(btns);
762
- ta.focus();
763
- ta.setSelectionRange(ta.value.length, ta.value.length);
764
- }
765
-
766
- function makeUserEditTextarea(value) {
767
- const ta = document.createElement('textarea');
768
- ta.value = value;
769
- ta.style.cssText = [
770
- 'width:100%',
771
- 'background:transparent',
772
- 'border:none',
773
- 'outline:none',
774
- 'color:var(--text)',
775
- 'font:inherit',
776
- 'font-size:15px',
777
- 'resize:none',
778
- 'line-height:1.6',
779
- 'min-height:26px',
780
- 'overflow-y:hidden',
781
- 'display:block',
782
- ].join(';');
783
- const resize = () => {
784
- ta.style.height = 'auto';
785
- ta.style.height = ta.scrollHeight + 'px';
786
- };
787
- ta.addEventListener('input', resize);
788
- requestAnimationFrame(resize);
789
- return ta;
790
- }
791
-
792
- function makeAssistantEditTextarea(value) {
793
- const ta = document.createElement('textarea');
794
- ta.value = value;
795
- ta.style.cssText = [
796
- 'width:100%',
797
- 'background:transparent',
798
- 'border:none',
799
- 'outline:none',
800
- 'color:var(--text)',
801
- 'font:inherit',
802
- 'font-size:14px',
803
- 'resize:none',
804
- 'line-height:1.6',
805
- 'min-height:120px',
806
- 'overflow-y:auto',
807
- 'display:block',
808
- 'padding-bottom:8px',
809
- ].join(';');
810
- const resize = () => {
811
- ta.style.height = 'auto';
812
- ta.style.height = Math.min(ta.scrollHeight, window.innerHeight * 0.6) + 'px';
813
- };
814
- ta.addEventListener('input', resize);
815
- requestAnimationFrame(resize);
816
- return ta;
817
- }
818
-
819
- function makeBtn(label, cls) {
820
- const btn = document.createElement('button');
821
- btn.textContent = label; btn.className = cls;
822
- btn.style.cssText += ';font-size:12px;padding:5px 12px;';
823
- return btn;
824
- }
825
-
826
- // ── Streaming ─────────────────────────────────────────────────────────────
827
-
828
- function onChatStart(msg) {
829
- const sessionId = msg.sessionId;
830
- isStreaming = true;
831
- streamingSessionId = sessionId;
832
- streamingText = '';
833
- pendingAssets = [];
834
-
835
- if (sessionId !== activeSessionId) {
836
- // Streaming for a background session — just track state, no UI
837
- return;
838
- }
839
-
840
- showChat();
841
- const box = document.getElementById('chat-messages'); if (!box) return;
842
- streamingBubble = document.createElement('div');
843
- streamingBubble.className = 'msg-assistant msg-generating';
844
-
845
- const thinking = document.createElement('div'); thinking.className = 'msg-thinking';
846
- for (let i = 0; i < 3; i++) {
847
- const d = document.createElement('div'); d.className = 'thinking-dot'; thinking.appendChild(d);
848
- }
849
- streamingBubble.appendChild(thinking);
850
- box.appendChild(streamingBubble);
851
- if (autoScroll) box.scrollTop = box.scrollHeight;
852
- updateSendBtn(true);
853
- }
854
-
855
- function onToken(token) {
856
- if (!streamingBubble) return;
857
- streamingText += token;
858
- streamingBubble.querySelector('.msg-thinking')?.remove();
859
- const displayText = stripSessionTag(processDisplay(streamingText));
860
- streamingBubble.innerHTML = renderMarkdown(displayText);
861
- attachCodeCopyListeners(streamingBubble);
862
- if (autoScroll) {
863
- const box = document.getElementById('chat-messages');
864
- if (box) box.scrollTop = box.scrollHeight;
865
- }
866
- }
867
-
868
- function onChatDone(msg) {
869
- const sessionId = msg.sessionId;
870
- if (sessionId === streamingSessionId) {
871
- isStreaming = false;
872
- streamingSessionId = null;
873
- }
874
-
875
- if (sessionId === activeSessionId) {
876
- streamingBubble?.classList.remove('msg-generating');
877
- streamingBubble = null;
878
- streamingText = '';
879
- updateSendBtn(false);
880
- if (msg.history) {
881
- renderHistory(msg.history);
882
- if (pendingAssets.length > 0) {
883
- const box = document.getElementById('chat-messages');
884
- if (box) {
885
- pendingAssets.forEach(asset => appendMediaMsg(box, asset.role, asset.content));
886
- if (autoScroll) box.scrollTop = box.scrollHeight;
887
- }
888
- pendingAssets = [];
889
- }
890
- }
891
- }
892
- }
893
-
894
- function onChatAborted(msg) {
895
- const sessionId = msg.sessionId;
896
- if (sessionId === streamingSessionId) {
897
- isStreaming = false;
898
- streamingSessionId = null;
899
- }
900
-
901
- if (sessionId === activeSessionId) {
902
- if (streamingBubble) {
903
- streamingBubble.classList.remove('msg-generating');
904
- const note = document.createElement('div');
905
- note.style.cssText = 'font-size:12px;color:var(--text-muted);margin-top:6px;';
906
- note.textContent = '⚠ Interrupted';
907
- streamingBubble.appendChild(note);
908
- streamingBubble = null;
909
- }
910
- updateSendBtn(false);
911
- if (msg.history) {
912
- renderHistory(msg.history);
913
- if (pendingAssets.length > 0) {
914
- const box = document.getElementById('chat-messages');
915
- if (box) pendingAssets.forEach(asset => appendMediaMsg(box, asset.role, asset.content));
916
- }
917
- }
918
- pendingAssets = [];
919
- }
920
- }
921
-
922
- function onChatError(err) {
923
- isStreaming = false;
924
- streamingSessionId = null;
925
- if (streamingBubble) {
926
- streamingBubble.classList.remove('msg-generating');
927
- streamingBubble.querySelector('.msg-thinking')?.remove();
928
- const note = document.createElement('div');
929
- note.style.cssText = 'color:#f87171;font-size:13px;margin-top:6px;';
930
- note.textContent = `⚠ Error: ${err}`;
931
- streamingBubble.appendChild(note);
932
- streamingBubble = null;
933
- }
934
- updateSendBtn(false);
935
- }
936
-
937
- function handleLiveToolCall(call) {
938
- if (!streamingBubble) return;
939
- const names = { ollama_search: 'Searching web…', read_web_page: 'Reading page…',
940
- generate_image: 'Generating image…', generate_video: 'Generating video…', generate_audio: 'Generating audio…' };
941
-
942
- if (call.state === 'pending') {
943
- streamingBubble.querySelector('.msg-thinking')?.remove();
944
- if (!streamingBubble.querySelector(`[data-tcid="${call.id}"]`)) {
945
- const badge = document.createElement('div'); badge.className = 'msg-tool-call';
946
- badge.style.pointerEvents = 'none'; badge.setAttribute('data-tcid', call.id);
947
- badge.innerHTML = `<span>🔧</span><span>${names[call.name] || call.name}</span>`;
948
- streamingBubble.appendChild(badge);
949
- }
950
- } else if (call.state === 'resolved' || call.state === 'canceled') {
951
- streamingBubble.querySelector(`[data-tcid="${call.id}"]`)?.remove();
952
- }
953
- }
954
-
955
- function appendAsset(asset) {
956
- const box = document.getElementById('chat-messages'); if (!box) return;
957
- pendingAssets.push(asset);
958
- appendMediaMsg(box, asset.role, asset.content);
959
- if (autoScroll) box.scrollTop = box.scrollHeight;
960
- }
961
-
962
- // ── Submit ────────────────────────────────────────────────────────────────
963
-
964
- export function submitMessage(text, attachments = []) {
965
- if (!text.trim() && attachments.length === 0) return;
966
- if (isStreaming && streamingSessionId === activeSessionId) {
967
- send({ type: 'chat:stop', sessionId: activeSessionId });
968
- return;
969
- }
970
- if (!activeSessionId) return;
971
-
972
- const images = attachments.filter(a => a.type === 'image');
973
- const textFiles= attachments.filter(a => a.type === 'text');
974
-
975
- let fullText = text;
976
- if (textFiles.length > 0) {
977
- fullText += '\n\n<details><summary>Attached Files</summary>\n';
978
- for (const f of textFiles)
979
- fullText += `\n<details><summary>${f.name}</summary>\n\n\`\`\`\n${f.content}\n\`\`\`\n\n</details>\n`;
980
- fullText += '</details>';
981
- }
982
-
983
- let content;
984
- if (images.length > 0) {
985
- content = [
986
- { type: 'text', text: fullText },
987
- ...images.map(img => ({ type: 'image_url', image_url: { url: `data:${img.mimeType};base64,${img.base64}` } })),
988
- ];
989
- } else {
990
- content = fullText;
991
- }
992
-
993
- // Append optimistic user bubble
994
- const box = document.getElementById('chat-messages');
995
- if (box) {
996
- const wrap = makeWrap(-1);
997
- const bubble = document.createElement('div'); bubble.className = 'msg-user';
998
- const displayText = textWithBullets(stripFileBlocks(text));
999
- bubble.innerHTML = renderMarkdown(displayText);
1000
- images.forEach(img => {
1001
- const el = document.createElement('img');
1002
- el.src = `data:${img.mimeType};base64,${img.base64}`;
1003
- el.style.cssText = 'max-width:100%;max-height:200px;border-radius:8px;margin-top:6px;display:block;';
1004
- bubble.appendChild(el);
1005
- });
1006
- if (textFiles.length > 0) {
1007
- const fileRow = document.createElement('div');
1008
- fileRow.className = 'msg-file-attachments';
1009
- textFiles.forEach(f => fileRow.appendChild(buildFileChipView(f.name, f.content, false)));
1010
- bubble.appendChild(fileRow);
1011
- }
1012
- wrap.appendChild(bubble);
1013
- box.appendChild(wrap);
1014
- if (autoScroll) box.scrollTop = box.scrollHeight;
1015
- }
1016
-
1017
- send({ type: 'chat:send', sessionId: activeSessionId, content, tools: getActiveTools(), clientId: localStorage.getItem('ipai_client_id') || '' });
1018
- }
1019
-
1020
- // ── Utils ─────────────────────────────────────────────────────────────────
1021
-
1022
- function getActiveTools() {
1023
- const tools = {};
1024
- const container =
1025
- document.querySelector('#bottom-input-bar:not(.hidden)')?.querySelector('.tool-row') ||
1026
- document.querySelector('.center-input-actions');
1027
-
1028
- if (!container) {console.warn('Tool container not found'); return tools;};
1029
-
1030
- container.querySelectorAll('[data-tool]').forEach(btn => {
1031
- const toolName = btn.dataset.tool;
1032
- if (toolName) tools[toolName] = btn.classList.contains('active');
1033
- });
1034
-
1035
- return tools;
1036
- }
1037
-
1038
- function msgText(content) {
1039
- if (typeof content === 'string') return content;
1040
- return content.filter(p => p.type === 'text').map(p => p.text).join('\n');
1041
- }
1042
- function msgImages(content) {
1043
- if (typeof content === 'string') return [];
1044
- return content.filter(p => p.type === 'image_url').map(p => p.image_url.url);
1045
- }
1046
-
1047
- /**
1048
- * Convert bullet characters (•) back to markdown list items for rendering.
1049
- * This ensures the display text renders as proper bullet points via marked.js
1050
- */
1051
- function textWithBullets(text) {
1052
- if (!text) return text;
1053
- // Convert '• ' at start of line to '* ' for markdown rendering
1054
- return text.replace(/^(\s*)\u2022\s/gm, '$1* ');
1055
- }
1056
-
1057
- function processDisplay(text) {
1058
- let result = '', i = 0;
1059
- while (i < text.length) {
1060
- const start = text.indexOf('```svg', i);
1061
- if (start === -1) { result += text.slice(i); break; }
1062
- result += text.slice(i, start) + '[SVG Image]';
1063
- const end = text.indexOf('```', start + 6);
1064
- if (end === -1) break;
1065
- i = end + 3;
1066
- }
1067
- return result;
1068
- }
1069
-
1070
- function updateSendBtn(streaming) {
1071
- const sessionStreaming = streaming && (streamingSessionId === activeSessionId || streaming === true);
1072
- document.querySelectorAll('#bottom-send-btn, #center-send-btn').forEach(btn => {
1073
- btn.innerHTML = sessionStreaming
1074
- ? '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><rect x="4" y="4" width="16" height="16" rx="3"/></svg>'
1075
- : '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M2 21L23 12 2 3v7l15 2-15 2v7z"/></svg>';
1076
- btn.classList.toggle('stop', !!sessionStreaming);
1077
- });
1078
- }
1079
-
1080
- function copyText(text) {
1081
- navigator.clipboard.writeText(text).then(
1082
- () => showNotification({ type: 'success', message: 'Copied', duration: 1500 }),
1083
- () => showNotification({ type: 'error', message: 'Copy failed', duration: 1500 })
1084
- );
1085
- }
1086
-
1087
- function dlMedia(dataUrl, filename) {
1088
- const a = document.createElement('a');
1089
- a.href = dataUrl; a.download = filename;
1090
- document.body.appendChild(a); a.click(); document.body.removeChild(a);
1091
- }
1092
-
1093
- function openImageModal(src) {
1094
- import('./modals.js').then(m => m.openImageModal(src));
1095
- }
1096
-
1097
- // Scroll tracking
1098
- document.getElementById('chat-view')?.addEventListener('scroll', e => {
1099
- const el = e.target;
1100
- autoScroll = el.scrollHeight - el.scrollTop - el.clientHeight < 60;
1101
- }, true);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
public/js/modals.js DELETED
@@ -1,503 +0,0 @@
1
- // modals.js — All modal dialogs
2
- import { send, on } from './ws.js';
3
- import { escHtml } from './ui.js';
4
- import { isAuthenticated, loginWithEmail, signUpWithEmail, loginWithOAuth, logout, currentUser, userProfile, userSettings } from './auth.js';
5
-
6
- // ── Modal stack support ───────────────────────────────────────────────────
7
- // Primary modal uses #modal-overlay / #modal-box.
8
- // Secondary (stacked) modal creates its own overlay on top.
9
-
10
- let overlay, box;
11
- function getOverlay() { return overlay || (overlay = document.getElementById('modal-overlay')); }
12
- function getBox() { return box || (box = document.getElementById('modal-box')); }
13
-
14
- let secondaryOverlay = null;
15
-
16
- export function openModal(html, opts = {}) {
17
- const o = getOverlay(), b = getBox();
18
- b.className = 'modal-box' + (opts.wide ? ' wide' : '');
19
- b.innerHTML = html;
20
- o.classList.remove('hidden');
21
- if (opts.onOpen) opts.onOpen(b);
22
- o.onclick = (e) => { if (e.target === o) closeModal(); };
23
- document.addEventListener('keydown', escHandler);
24
- }
25
-
26
- const escHandler = (e) => { if (e.key === 'Escape') { if (secondaryOverlay) closeSecondaryModal(); else closeModal(); } };
27
-
28
- export function closeModal() {
29
- if (secondaryOverlay) closeSecondaryModal();
30
- getOverlay().classList.add('hidden');
31
- getBox().innerHTML = '';
32
- document.removeEventListener('keydown', escHandler);
33
- }
34
-
35
- /** Open a secondary (stacked) modal on top of the primary one */
36
- export function openSecondaryModal(html, opts = {}) {
37
- // Remove existing secondary if any
38
- closeSecondaryModal();
39
-
40
- secondaryOverlay = document.createElement('div');
41
- secondaryOverlay.className = 'modal-overlay';
42
- secondaryOverlay.style.zIndex = 'calc(var(--z-modal) + 50)';
43
- secondaryOverlay.style.animation = 'fadeIn 0.16s ease';
44
-
45
- const secBox = document.createElement('div');
46
- secBox.className = 'modal-box' + (opts.wide ? ' wide' : '');
47
- secBox.innerHTML = html;
48
- secondaryOverlay.appendChild(secBox);
49
- document.body.appendChild(secondaryOverlay);
50
-
51
- if (opts.onOpen) opts.onOpen(secBox);
52
-
53
- secondaryOverlay.onclick = (e) => {
54
- if (e.target === secondaryOverlay) closeSecondaryModal();
55
- };
56
- }
57
-
58
- export function closeSecondaryModal() {
59
- if (secondaryOverlay) {
60
- secondaryOverlay.remove();
61
- secondaryOverlay = null;
62
- }
63
- }
64
-
65
- // ── Auth modal ────────────────────────────────────────────────────────────
66
-
67
- export function openAuthModal(initialTab = 'signin') {
68
- openModal(`
69
- <div class="modal-header">
70
- <span class="modal-title">Sign in to InferencePort AI</span>
71
- <button class="modal-close" id="auth-close-btn">×</button>
72
- </div>
73
- <div class="modal-body">
74
- <div class="auth-tabs">
75
- <button class="auth-tab ${initialTab==='signin'?'active':''}" data-tab="signin">Sign In</button>
76
- <button class="auth-tab ${initialTab==='signup'?'active':''}" data-tab="signup">Create Account</button>
77
- </div>
78
-
79
- <div id="auth-signin" style="${initialTab!=='signin'?'display:none':''}">
80
- <div style="display:flex;flex-direction:column;gap:8px;margin-bottom:14px;">
81
- <button class="social-btn" id="github-btn">
82
- <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path fill-rule="evenodd" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82a7.7 7.7 0 012.01-.27c.68 0 1.36.09 2.01.27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>
83
- Continue with GitHub
84
- </button>
85
- <button class="social-btn" id="google-btn">
86
- <svg width="16" height="16" viewBox="0 0 48 48"><path fill="#EA4335" d="M24 9.5c3.54 0 6.02 1.53 7.4 2.8l5.4-5.4C33.52 3.7 29.1 1.5 24 1.5 14.64 1.5 6.58 6.88 2.66 14.7l6.64 5.15C11.2 13.6 17.08 9.5 24 9.5z"/><path fill="#4285F4" d="M46.5 24c0-1.64-.15-3.22-.43-4.74H24v9h12.7c-.55 2.95-2.21 5.45-4.7 7.12l7.23 5.6C43.38 36.9 46.5 31.1 46.5 24z"/><path fill="#FBBC05" d="M9.3 28.85A14.4 14.4 0 0 1 8.5 24c0-1.68.3-3.3.8-4.85l-6.64-5.15A23.96 23.96 0 0 0 1.5 24c0 3.9.94 7.58 2.66 10.7l6.64-5.15z"/><path fill="#34A853" d="M24 46.5c6.48 0 11.92-2.14 15.9-5.82l-7.23-5.6c-2.01 1.35-4.58 2.15-8.67 2.15-6.92 0-12.8-4.1-14.7-10.05l-6.64 5.15C6.58 41.12 14.64 46.5 24 46.5z"/></svg>
87
- Continue with Google
88
- </button>
89
- </div>
90
- <div class="auth-divider">or</div>
91
- <div class="form-group">
92
- <label class="form-label">Email</label>
93
- <input class="form-input" id="signin-email" type="email" placeholder="you@example.com" />
94
- </div>
95
- <div class="form-group">
96
- <label class="form-label">Password</label>
97
- <input class="form-input" id="signin-password" type="password" placeholder="••••••••" />
98
- </div>
99
- <div id="signin-error" class="form-error" style="display:none;margin-bottom:8px;"></div>
100
- <button class="btn-primary" id="signin-submit" style="width:100%;">Sign In</button>
101
- <div style="margin-top:10px;text-align:center;">
102
- <button style="font-size:13px;color:var(--blue-bright);" id="forgot-pw">Forgot password?</button>
103
- </div>
104
- </div>
105
-
106
- <div id="auth-signup" style="${initialTab!=='signup'?'display:none':''}">
107
- <div class="form-group">
108
- <label class="form-label">Email</label>
109
- <input class="form-input" id="signup-email" type="email" placeholder="you@example.com" />
110
- </div>
111
- <div class="form-group">
112
- <label class="form-label">Password</label>
113
- <input class="form-input" id="signup-password" type="password" placeholder="Min 6 characters" />
114
- </div>
115
- <div id="signup-error" class="form-error" style="display:none;margin-bottom:8px;"></div>
116
- <button class="btn-primary" id="signup-submit" style="width:100%;">Create Account</button>
117
- </div>
118
- </div>
119
- `, {
120
- onOpen(b) {
121
- b.querySelector('#auth-close-btn')?.addEventListener('click', closeModal);
122
-
123
- // Tab switching
124
- b.querySelectorAll('.auth-tab').forEach(tab => {
125
- tab.addEventListener('click', () => {
126
- b.querySelectorAll('.auth-tab').forEach(t => t.classList.remove('active'));
127
- tab.classList.add('active');
128
- const name = tab.dataset.tab;
129
- b.querySelector('#auth-signin').style.display = name === 'signin' ? '' : 'none';
130
- b.querySelector('#auth-signup').style.display = name === 'signup' ? '' : 'none';
131
- });
132
- });
133
-
134
- // Sign in
135
- b.querySelector('#signin-submit').addEventListener('click', async () => {
136
- const email = b.querySelector('#signin-email').value.trim();
137
- const pass = b.querySelector('#signin-password').value;
138
- const errEl = b.querySelector('#signin-error');
139
- errEl.style.display = 'none';
140
- try {
141
- await loginWithEmail(email, pass);
142
- closeModal();
143
- } catch (e) {
144
- errEl.textContent = e.message; errEl.style.display = '';
145
- }
146
- });
147
-
148
- // Sign up
149
- b.querySelector('#signup-submit').addEventListener('click', async () => {
150
- const email = b.querySelector('#signup-email').value.trim();
151
- const pass = b.querySelector('#signup-password').value;
152
- const errEl = b.querySelector('#signup-error');
153
- errEl.style.display = 'none';
154
- try {
155
- const result = await signUpWithEmail(email, pass);
156
- if (result.access_token) {
157
- closeModal();
158
- } else {
159
- errEl.textContent = 'Check your email to confirm your account.'; errEl.style.display = '';
160
- }
161
- } catch (e) {
162
- errEl.textContent = e.message; errEl.style.display = '';
163
- }
164
- });
165
-
166
- b.querySelector('#github-btn').addEventListener('click', () => { loginWithOAuth('github'); closeModal(); });
167
- b.querySelector('#google-btn').addEventListener('click', () => { loginWithOAuth('google'); closeModal(); });
168
- b.querySelector('#forgot-pw').addEventListener('click', () => openForgotPasswordModal());
169
-
170
- // Enter key
171
- [['#signin-email','#signin-password','#signin-submit'],
172
- ['#signup-email','#signup-password','#signup-submit']].forEach(([e, p, s]) => {
173
- [e, p].forEach(sel => {
174
- b.querySelector(sel)?.addEventListener('keydown', ev => {
175
- if (ev.key === 'Enter') b.querySelector(s)?.click();
176
- });
177
- });
178
- });
179
- }
180
- });
181
- }
182
-
183
- function openForgotPasswordModal() {
184
- openModal(`
185
- <div class="modal-header">
186
- <span class="modal-title">Reset Password</span>
187
- <button class="modal-close" id="forgot-close-btn">×</button>
188
- </div>
189
- <div class="modal-body">
190
- <div class="form-group">
191
- <label class="form-label">Email</label>
192
- <input class="form-input" id="reset-email" type="email" placeholder="you@example.com" />
193
- </div>
194
- <div id="reset-msg" style="font-size:13px;margin-bottom:8px;display:none;"></div>
195
- </div>
196
- <div class="modal-footer">
197
- <button class="btn-ghost" id="forgot-cancel-btn">Cancel</button>
198
- <button class="btn-primary" id="reset-submit">Send Reset Link</button>
199
- </div>
200
- `, {
201
- onOpen(b) {
202
- b.querySelector('#forgot-close-btn')?.addEventListener('click', closeModal);
203
- b.querySelector('#forgot-cancel-btn')?.addEventListener('click', closeModal);
204
- b.querySelector('#reset-submit').addEventListener('click', async () => {
205
- const email = b.querySelector('#reset-email').value.trim();
206
- const msgEl = b.querySelector('#reset-msg');
207
- const SUPABASE_URL = 'https://dpixehhdbtzsbckfektd.supabase.co';
208
- const SUPABASE_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImRwaXhlaGhkYnR6c2Jja2Zla3RkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjExNDI0MjcsImV4cCI6MjA3NjcxODQyN30.nR1KCSRQj1E_evQWnE2VaZzg7PgLp2kqt4eDKP2PkpE';
209
- try {
210
- await fetch(`${SUPABASE_URL}/auth/v1/recover`, {
211
- method: 'POST', headers: { 'Content-Type':'application/json','apikey':SUPABASE_KEY },
212
- body: JSON.stringify({ email }),
213
- });
214
- msgEl.textContent = 'Reset link sent. Check your email.';
215
- msgEl.style.color = 'var(--plan-core)'; msgEl.style.display = '';
216
- } catch { msgEl.textContent = 'Error. Try again.'; msgEl.style.display = ''; }
217
- });
218
- }
219
- });
220
- }
221
-
222
- // ── Share modal ───────────────────────────────────────────────────────────
223
-
224
- export function showShareModal(sessionId) {
225
- if (!isAuthenticated()) return openAuthModal('signin');
226
-
227
- openModal(`
228
- <div class="modal-header">
229
- <span class="modal-title">Share Chat</span>
230
- <button class="modal-close" id="share-close-btn">×</button>
231
- </div>
232
- <div class="modal-body">
233
- <div class="share-warning">
234
- <span style="font-size:18px">⚠️</span>
235
- <span>You are about to share this whole session. Anyone with the link can import it into their account.</span>
236
- </div>
237
- <div id="share-url-wrap" style="display:none;">
238
- <div class="form-label" style="margin-bottom:6px;">Share link</div>
239
- <div style="display:flex;gap:8px;">
240
- <input class="form-input" id="share-url-input" readonly style="flex:1;" />
241
- <button class="btn-ghost" id="share-copy-btn">Copy</button>
242
- </div>
243
- </div>
244
- <div id="share-loading" style="font-size:13px;color:var(--text-muted);display:none;">Generating link…</div>
245
- </div>
246
- <div class="modal-footer">
247
- <button class="btn-ghost" id="share-close-footer">Close</button>
248
- <button class="btn-primary" id="share-generate-btn">Generate Link</button>
249
- </div>
250
- `, {
251
- onOpen(b) {
252
- b.querySelector('#share-close-btn')?.addEventListener('click', closeModal);
253
- b.querySelector('#share-close-footer')?.addEventListener('click', closeModal);
254
- b.querySelector('#share-generate-btn').addEventListener('click', () => {
255
- b.querySelector('#share-loading').style.display = '';
256
- b.querySelector('#share-generate-btn').disabled = true;
257
- send({ type: 'sessions:share', sessionId });
258
-
259
- on('sessions:shareUrl', function handler(msg) {
260
- if (msg.sessionId !== sessionId) return;
261
- import('./ws.js').then(({ off }) => off('sessions:shareUrl', handler));
262
- b.querySelector('#share-loading').style.display = 'none';
263
- b.querySelector('#share-url-wrap').style.display = '';
264
- const input = b.querySelector('#share-url-input');
265
- input.value = msg.url;
266
-
267
- b.querySelector('#share-copy-btn').addEventListener('click', async () => {
268
- await navigator.clipboard.writeText(msg.url).catch(() => {});
269
- b.querySelector('#share-copy-btn').textContent = 'Copied!';
270
- });
271
- });
272
- });
273
- }
274
- });
275
- }
276
-
277
- // ── Tool call modal ───────────────────────────────────────────────────────
278
-
279
- export function showToolCallModal(call) {
280
- const names = {
281
- ollama_search: 'Web Search', read_web_page: 'Read Web Page',
282
- generate_image: 'Image Generation', generate_video: 'Video Generation', generate_audio: 'Audio Generation',
283
- };
284
- const displayName = names[call.name] || call.name;
285
-
286
- let argsDisplay = call.args || call.arguments || '{}';
287
- if (typeof argsDisplay !== 'string') argsDisplay = JSON.stringify(argsDisplay, null, 2);
288
-
289
- let resultDisplay = call.result || '—';
290
- if (typeof resultDisplay !== 'string') resultDisplay = JSON.stringify(resultDisplay, null, 2);
291
-
292
- openModal(`
293
- <div class="modal-header">
294
- <span class="modal-title">🔧 ${escHtml(displayName)}</span>
295
- <button class="modal-close" id="tool-close-btn">×</button>
296
- </div>
297
- <div class="modal-body">
298
- <div class="tool-detail-section">
299
- <div class="tool-detail-label">Tool</div>
300
- <div class="tool-detail-content" style="font-family:var(--font-sans);">${escHtml(call.name)}</div>
301
- </div>
302
- <div class="tool-detail-section">
303
- <div class="tool-detail-label">Request</div>
304
- <div class="tool-detail-content">${escHtml(argsDisplay)}</div>
305
- </div>
306
- <div class="tool-detail-section">
307
- <div class="tool-detail-label">Response</div>
308
- <div class="tool-detail-content">${escHtml(resultDisplay.slice(0, 4000))}</div>
309
- </div>
310
- </div>
311
- <div class="modal-footer">
312
- <button class="btn-ghost" id="tool-close-footer">Close</button>
313
- </div>
314
- `, {
315
- onOpen(b) {
316
- b.querySelector('#tool-close-btn')?.addEventListener('click', closeModal);
317
- b.querySelector('#tool-close-footer')?.addEventListener('click', closeModal);
318
- }
319
- });
320
- }
321
-
322
- // ── Image modal ───────────────────────────────────────────────────────────
323
-
324
- export function openImageModal(src) {
325
- openModal(`
326
- <div class="modal-header" style="border-bottom:none;">
327
- <span></span>
328
- <button class="modal-close" id="img-close-btn">×</button>
329
- </div>
330
- <div class="modal-body" style="padding-top:0;text-align:center;">
331
- <img src="${escHtml(src)}" style="max-width:100%;max-height:70vh;border-radius:8px;" alt="Image" />
332
- </div>
333
- <div class="modal-footer">
334
- <button class="btn-ghost" id="img-close-footer">Close</button>
335
- <button class="btn-primary" id="img-dl-btn">Download</button>
336
- </div>
337
- `, {
338
- onOpen(b) {
339
- b.querySelector('#img-close-btn')?.addEventListener('click', closeModal);
340
- b.querySelector('#img-close-footer')?.addEventListener('click', closeModal);
341
- b.querySelector('#img-dl-btn').addEventListener('click', () => {
342
- const a = document.createElement('a');
343
- a.href = src; a.download = `image-${Date.now()}.png`;
344
- document.body.appendChild(a); a.click(); document.body.removeChild(a);
345
- });
346
- }
347
- });
348
- }
349
-
350
- // ── File viewer modal ─────────────────────────────────────────────────────
351
-
352
- /**
353
- * Opens a modal to view/edit a text file attachment.
354
- * @param {object} opts - { name, content, editable, onSave }
355
- */
356
- export function openFileViewerModal({ name, content, editable = false, onSave }) {
357
- const title = editable ? `Edit: ${escHtml(name)}` : escHtml(name);
358
- openSecondaryModal(`
359
- <div class="modal-header">
360
- <span class="modal-title" style="font-size:15px;display:flex;align-items:center;gap:8px;">
361
- <span style="font-size:18px;">📄</span>${title}
362
- </span>
363
- <button class="modal-close" id="fv-close-btn">×</button>
364
- </div>
365
- <div class="modal-body" style="padding-top:12px;">
366
- ${editable
367
- ? `<textarea id="fv-editor" style="width:100%;min-height:280px;background:var(--input-bg);border:1px solid var(--input-border);border-radius:var(--radius-md);padding:10px 12px;color:var(--text);font-size:13px;font-family:var(--font-mono);resize:vertical;line-height:1.55;outline:none;">${escHtml(content)}</textarea>`
368
- : `<pre style="background:var(--bg-raised);border:1px solid var(--border);border-radius:var(--radius-md);padding:12px;font-size:12px;font-family:var(--font-mono);white-space:pre-wrap;word-break:break-all;max-height:400px;overflow-y:auto;color:var(--text-dim);">${escHtml(content)}</pre>`
369
- }
370
- </div>
371
- <div class="modal-footer">
372
- <button class="btn-ghost" id="fv-cancel-btn">Cancel</button>
373
- ${editable ? `<button class="btn-primary" id="fv-save-btn">Save</button>` : ''}
374
- </div>
375
- `, {
376
- onOpen(b) {
377
- b.querySelector('#fv-close-btn')?.addEventListener('click', closeSecondaryModal);
378
- b.querySelector('#fv-cancel-btn')?.addEventListener('click', closeSecondaryModal);
379
- if (editable) {
380
- b.querySelector('#fv-save-btn')?.addEventListener('click', () => {
381
- const val = b.querySelector('#fv-editor')?.value ?? '';
382
- onSave?.(val);
383
- closeSecondaryModal();
384
- });
385
- // Focus and auto-resize
386
- const ta = b.querySelector('#fv-editor');
387
- if (ta) {
388
- ta.focus();
389
- ta.addEventListener('input', () => {
390
- ta.style.height = 'auto';
391
- ta.style.height = Math.min(ta.scrollHeight, window.innerHeight * 0.6) + 'px';
392
- });
393
- }
394
- }
395
- }
396
- });
397
- }
398
-
399
- // ── Chat limit modal ──────────────────────────────────────────────────────
400
-
401
- export function openLimitModal() {
402
- openModal(`
403
- <div class="modal-body" style="padding-top:28px;">
404
- <div class="limit-modal-inner">
405
- <div class="limit-icon">💬</div>
406
- <div class="limit-title">Daily limit reached</div>
407
- <div class="limit-desc">Sign in or create a free account to keep chatting.<br>Guest usage resets every 24 hours.</div>
408
- <div style="display:flex;gap:10px;justify-content:center;flex-wrap:wrap;">
409
- <button class="btn-primary" id="limit-signin">Sign In</button>
410
- <button class="btn-ghost" id="limit-signup">Create Account</button>
411
- </div>
412
- </div>
413
- </div>
414
- `, {
415
- onOpen(b) {
416
- b.querySelector('#limit-signin').addEventListener('click', () => { closeModal(); openAuthModal('signin'); });
417
- b.querySelector('#limit-signup').addEventListener('click', () => { closeModal(); openAuthModal('signup'); });
418
- }
419
- });
420
- }
421
-
422
- export function openGuestRateLimitModal() {
423
- openModal(`
424
- <div class="modal-header">
425
- <span class="modal-title">Unusual request activity detected</span>
426
- <button class="modal-close" id="guest-rate-close-btn">×</button>
427
- </div>
428
- <div class="modal-body" style="padding-top:18px;">
429
- <div class="limit-modal-inner">
430
- <div class="limit-title">Please sign in to continue</div>
431
- <div class="limit-desc" style="margin-top:10px;line-height:1.5;">
432
- An unusual amount of signed out requests has come from your device. Please sign in, or if this is an error, contact <a href="mailto:incognito.email.mode@gmail.com" style="color:inherit;text-decoration:underline;">incognito.email.mode@gmail.com</a>.
433
- </div>
434
- <div style="display:flex;gap:10px;justify-content:center;flex-wrap:wrap;margin-top:20px;">
435
- <button class="btn-primary" id="guest-rate-limit-signin">Sign In</button>
436
- <button class="btn-ghost" id="guest-rate-limit-close">Close</button>
437
- </div>
438
- </div>
439
- </div>
440
- `, {
441
- onOpen(b) {
442
- b.querySelector('#guest-rate-close-btn')?.addEventListener('click', closeModal);
443
- b.querySelector('#guest-rate-limit-signin').addEventListener('click', () => { closeModal(); openAuthModal('signin'); });
444
- b.querySelector('#guest-rate-limit-close').addEventListener('click', () => { closeModal(); });
445
- }
446
- });
447
- }
448
-
449
- // ── Device session detail modal ───────────────────────────────────────────
450
- // Now opens as a SECONDARY modal (stacked on top of settings)
451
-
452
- export function openDeviceSessionModal(session, isCurrentSession) {
453
- openSecondaryModal(`
454
- <div class="modal-header">
455
- <span class="modal-title">Session Details</span>
456
- <button class="modal-close" id="dev-sess-close-btn">×</button>
457
- </div>
458
- <div class="modal-body">
459
- <div class="form-group">
460
- <div class="form-label">IP Address</div>
461
- <div style="font-size:14px;">${escHtml(session.ip || 'Unknown')}</div>
462
- </div>
463
- <div class="form-group">
464
- <div class="form-label">Last seen</div>
465
- <div style="font-size:14px;">${escHtml(session.lastSeen ? new Date(session.lastSeen).toLocaleString() : '—')}</div>
466
- </div>
467
- <div class="form-group">
468
- <div class="form-label">First seen</div>
469
- <div style="font-size:14px;">${escHtml(session.createdAt ? new Date(session.createdAt).toLocaleString() : '—')}</div>
470
- </div>
471
- <div class="form-group">
472
- <div class="form-label">User Agent</div>
473
- <div style="font-size:12px;word-break:break-all;color:var(--text-dim);">${escHtml(session.userAgent || 'Unknown')}</div>
474
- </div>
475
- ${isCurrentSession ? '<div style="font-size:12px;color:var(--plan-core);margin-top:4px;">This is your current session.</div>' : ''}
476
- </div>
477
- <div class="modal-footer">
478
- <button class="btn-ghost" id="dev-sess-cancel-btn">Close</button>
479
- ${!isCurrentSession ? `<button class="btn-danger" id="revoke-session-btn">Log Out This Session</button>` : ''}
480
- </div>
481
- `, {
482
- onOpen(b) {
483
- b.querySelector('#dev-sess-close-btn')?.addEventListener('click', closeSecondaryModal);
484
- b.querySelector('#dev-sess-cancel-btn')?.addEventListener('click', closeSecondaryModal);
485
- if (!isCurrentSession) {
486
- b.querySelector('#revoke-session-btn')?.addEventListener('click', () => {
487
- send({ type: 'account:revokeSession', token: session.token });
488
- closeSecondaryModal();
489
- });
490
- }
491
- }
492
- });
493
- }
494
-
495
- // ── Pasted content editor ─────────────────────────────────────────────────
496
-
497
- export function openPasteEditor(content, onSave) {
498
- openFileViewerModal({ name: 'Edit Content', content, editable: true, onSave });
499
- }
500
-
501
- // Auto-handle limit events
502
- on('chat:limitReached', () => openLimitModal());
503
- on('guest:rateLimit', () => openGuestRateLimitModal());
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
public/js/sessions.js DELETED
@@ -1,285 +0,0 @@
1
- // sessions.js - Session list management
2
- import { send, on } from './ws.js';
3
- import { showContextMenu } from './ui.js';
4
- import { showShareModal } from './modals.js';
5
-
6
- export let sessions = [];
7
- export let currentSessionId = null;
8
-
9
- const sessionListeners = new Set();
10
- export function onSessionChange(fn) {
11
- sessionListeners.add(fn);
12
- return () => sessionListeners.delete(fn);
13
- }
14
-
15
- function notify(event, data) {
16
- sessionListeners.forEach(fn => fn(event, data));
17
- }
18
-
19
- // ── Server events ─────────────────────────────────────────────────────────
20
-
21
- on('sessions:list', (msg) => {
22
- sessions = msg.sessions || [];
23
- renderSessions();
24
- });
25
-
26
- on('sessions:created', (msg) => {
27
- const existing = sessions.findIndex(s => s.id === msg.session.id);
28
- if (existing === -1) sessions.unshift(msg.session);
29
- else sessions[existing] = msg.session;
30
- renderSessions();
31
- notify('created', msg.session);
32
- });
33
-
34
- on('sessions:deleted', (msg) => {
35
- sessions = sessions.filter(s => s.id !== msg.sessionId);
36
- if (currentSessionId === msg.sessionId) {
37
- currentSessionId = sessions[0]?.id || null;
38
- notify('switched', currentSessionId);
39
- }
40
- renderSessions();
41
- });
42
-
43
- on('sessions:deletedAll', () => {
44
- sessions = [];
45
- currentSessionId = null;
46
- renderSessions();
47
- notify('switched', null);
48
- });
49
-
50
- on('sessions:renamed', (msg) => {
51
- const s = sessions.find(s => s.id === msg.sessionId);
52
- if (s) s.name = msg.name;
53
- renderSessions();
54
- });
55
-
56
- on('sessions:data', (msg) => {
57
- const existing = sessions.findIndex(s => s.id === msg.session.id);
58
- if (existing >= 0) sessions[existing] = msg.session;
59
- notify('data', msg.session);
60
- });
61
-
62
- on('auth:ok', (msg) => {
63
- sessions = msg.sessions || [];
64
- renderSessions();
65
- // On login, show the most recent session if one exists,
66
- // otherwise show the welcome screen (don't auto-create).
67
- if (sessions.length > 0) {
68
- switchSession(sessions[0].id);
69
- } else {
70
- currentSessionId = null;
71
- notify('switched', null);
72
- }
73
- });
74
-
75
- on('auth:guestOk', (msg) => {
76
- sessions = msg.sessions || [];
77
- renderSessions();
78
- // Show welcome screen; session is created lazily when user sends first message.
79
- if (sessions.length > 0) {
80
- switchSession(sessions[0].id);
81
- } else {
82
- currentSessionId = null;
83
- notify('switched', null);
84
- }
85
- });
86
-
87
- on('chat:done', (msg) => {
88
- const s = sessions.find(s => s.id === msg.sessionId);
89
- if (s) {
90
- s.history = msg.history;
91
- if (msg.name) s.name = msg.name;
92
- sessions.sort((a, b) => {
93
- const aTime = a.history?.at(-1)?.timestamp || a.created;
94
- const bTime = b.history?.at(-1)?.timestamp || b.created;
95
- return bTime - aTime;
96
- });
97
- renderSessions();
98
- }
99
- });
100
-
101
- on('sessions:imported', (msg) => {
102
- sessions.unshift(msg.session);
103
- renderSessions();
104
- switchSession(msg.session.id);
105
- });
106
-
107
- // ── Actions ───────────────────────────────────────────────────────────────
108
-
109
- /**
110
- * createNewSession is now "lazy" for the new-chat button:
111
- * the button just navigates to the welcome screen.
112
- * An actual session is only created when the user sends their first message
113
- * (handled in app.js triggerCenterSend).
114
- *
115
- * Call createNewSession() directly when you really need the session to exist
116
- * immediately (e.g. from triggerCenterSend in app.js).
117
- */
118
- export function showWelcomeScreen() {
119
- currentSessionId = null;
120
- renderSessions(); // deselect active item in sidebar
121
- notify('switched', null); // app.js will show the welcome view
122
- }
123
-
124
- export function createNewSession() {
125
- send({ type: 'sessions:create' });
126
- }
127
-
128
- export function switchSession(id) {
129
- currentSessionId = id;
130
- renderSessions();
131
- send({ type: 'sessions:get', sessionId: id });
132
- notify('switched', id);
133
- }
134
-
135
- export function deleteSession(id) {
136
- send({ type: 'sessions:delete', sessionId: id });
137
- }
138
-
139
- export function deleteAllSessions() {
140
- send({ type: 'sessions:deleteAll' });
141
- }
142
-
143
- export function renameSession(id, name) {
144
- send({ type: 'sessions:rename', sessionId: id, name });
145
- const s = sessions.find(s => s.id === id);
146
- if (s) s.name = name;
147
- renderSessions();
148
- }
149
-
150
- export function requestSessions() {
151
- send({ type: 'sessions:list' });
152
- }
153
-
154
- export function getCurrentSession() {
155
- return sessions.find(s => s.id === currentSessionId) || null;
156
- }
157
-
158
- // ── Render ────────────────────────────────────────────────────────────────
159
-
160
- function renderSessions() {
161
- const list = document.getElementById('session-list');
162
- if (!list) return;
163
-
164
- if (sessions.length === 0) {
165
- list.innerHTML = `<div style="padding:12px 10px;font-size:12px;color:var(--text-muted)">No chats yet</div>`;
166
- return;
167
- }
168
-
169
- // Group by date
170
- const groups = groupByDate(sessions);
171
- let html = '';
172
- for (const [label, group] of groups) {
173
- html += `<div class="session-date-label">${escHtml(label)}</div>`;
174
- for (const s of group) {
175
- const active = s.id === currentSessionId ? ' active' : '';
176
- html += `
177
- <div class="session-item${active}" data-id="${escHtml(s.id)}">
178
- <span class="session-name" data-id="${escHtml(s.id)}">${escHtml(s.name || 'New Chat')}</span>
179
- <button class="session-menu-btn" data-id="${escHtml(s.id)}" title="Options">···</button>
180
- </div>`;
181
- }
182
- }
183
- list.innerHTML = html;
184
-
185
- // Session click
186
- list.querySelectorAll('.session-item').forEach(el => {
187
- el.addEventListener('click', (e) => {
188
- if (e.target.closest('.session-menu-btn')) return;
189
- switchSession(el.dataset.id);
190
- });
191
- });
192
-
193
- // Menu button
194
- list.querySelectorAll('.session-menu-btn').forEach(btn => {
195
- btn.addEventListener('click', (e) => {
196
- e.stopPropagation();
197
- openSessionMenu(e, btn.dataset.id);
198
- });
199
- });
200
-
201
- // Inline rename on name click (double click)
202
- list.querySelectorAll('.session-name').forEach(el => {
203
- el.addEventListener('dblclick', (e) => {
204
- e.stopPropagation();
205
- startInlineRename(el);
206
- });
207
- });
208
- }
209
-
210
- function startInlineRename(el) {
211
- const id = el.dataset.id;
212
- const original = el.textContent;
213
- el.setAttribute('contenteditable', 'true');
214
- el.focus();
215
- document.execCommand('selectAll', false, null);
216
-
217
- const finish = () => {
218
- el.removeAttribute('contenteditable');
219
- const name = el.textContent.trim();
220
- if (name && name !== original) renameSession(id, name);
221
- else el.textContent = original;
222
- };
223
-
224
- el.addEventListener('blur', finish, { once: true });
225
- el.addEventListener('keydown', (e) => {
226
- if (e.key === 'Enter') { e.preventDefault(); el.blur(); }
227
- if (e.key === 'Escape') { el.textContent = original; el.blur(); }
228
- });
229
- }
230
-
231
- function openSessionMenu(e, id) {
232
- const items = [
233
- {
234
- label: 'Share', icon: '🔗',
235
- onClick: () => showShareModal(id),
236
- },
237
- {
238
- label: 'Rename', icon: '✏️',
239
- onClick: () => {
240
- const nameEl = document.querySelector(`.session-name[data-id="${id}"]`);
241
- if (nameEl) startInlineRename(nameEl);
242
- },
243
- },
244
- { separator: true },
245
- {
246
- label: 'Delete', icon: '🗑️', danger: true,
247
- onClick: () => deleteSession(id),
248
- },
249
- {
250
- label: 'Delete All Chats', icon: '⚠️', danger: true,
251
- onClick: () => {
252
- if (confirm('Delete all chats? This cannot be undone.')) deleteAllSessions();
253
- },
254
- },
255
- ];
256
- showContextMenu(e.clientX, e.clientY, items);
257
- }
258
-
259
- function groupByDate(sessions) {
260
- const now = new Date();
261
- const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
262
- const yesterday = today - 86400000;
263
- const week = today - 6 * 86400000;
264
-
265
- const groups = new Map([
266
- ['Today', []],
267
- ['Yesterday', []],
268
- ['This Week', []],
269
- ['Older', []],
270
- ]);
271
-
272
- for (const s of sessions) {
273
- const t = s.created || 0;
274
- if (t >= today) groups.get('Today').push(s);
275
- else if (t >= yesterday) groups.get('Yesterday').push(s);
276
- else if (t >= week) groups.get('This Week').push(s);
277
- else groups.get('Older').push(s);
278
- }
279
-
280
- return [...groups.entries()].filter(([, g]) => g.length > 0);
281
- }
282
-
283
- function escHtml(str) {
284
- return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
285
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
public/js/settings.js DELETED
@@ -1,385 +0,0 @@
1
- // settings.js — Settings modal
2
- import { send, on, off } from './ws.js';
3
- import { openModal, closeModal, openDeviceSessionModal } from './modals.js';
4
- import { isAuthenticated, currentUser, userProfile, userSettings } from './auth.js';
5
- import { deleteAllSessions } from './sessions.js';
6
- import { escHtml, showNotification } from './ui.js';
7
-
8
- const THEME_STORAGE_KEY = 'ipai_theme';
9
-
10
- let currentTheme = null;
11
-
12
- export function applyTheme(theme, animate = true) {
13
- const t = theme === 'light' ? 'light' : 'dark';
14
- if (t === currentTheme) return;
15
- currentTheme = t;
16
- try { localStorage.setItem('ipai_theme', t); } catch {}
17
- if (animate) {
18
- document.documentElement.classList.add('theme-transitioning');
19
- setTimeout(() => document.documentElement.classList.remove('theme-transitioning'), 400);
20
- }
21
- document.documentElement.setAttribute('data-theme', t);
22
- try {
23
- const bc = new BroadcastChannel('ipai_theme');
24
- bc.postMessage({ theme: t });
25
- bc.close();
26
- } catch {}
27
- }
28
-
29
- try {
30
- const bc = new BroadcastChannel('ipai_theme');
31
- bc.onmessage = (e) => {
32
- if (!e.data?.theme) return;
33
- const t = e.data.theme === 'light' ? 'light' : 'dark';
34
- if (t !== currentTheme) {
35
- if (t === 'light') {
36
- document.documentElement.setAttribute('data-theme', 'light');
37
- } else {
38
- document.documentElement.setAttribute('data-theme', 'dark');
39
- }
40
- currentTheme = t;
41
- }
42
- };
43
- } catch {}
44
-
45
- export function openSettings(tab = 'chat') {
46
- // Always fetch fresh settings from server before building the modal
47
- if (isAuthenticated()) {
48
- send({ type: 'settings:get' });
49
- // Wait for the settings response, then open modal with fresh data
50
- const handler = (msg) => {
51
- off('settings:data', handler);
52
- const freshSettings = msg.settings || userSettings || _defaultSettings();
53
- _openSettingsModal(tab, freshSettings);
54
- };
55
- on('settings:data', handler);
56
- // Fallback: if no response in 1.5s, open with cached settings
57
- setTimeout(() => {
58
- off('settings:data', handler);
59
- _openSettingsModal(tab, userSettings || _defaultSettings());
60
- }, 1500);
61
- } else {
62
- // Guest: use localStorage cached settings
63
- const stored = (() => {
64
- try { return JSON.parse(localStorage.getItem('ipai_settings') || '{}'); } catch { return {}; }
65
- })();
66
- _openSettingsModal(tab, { ..._defaultSettings(), ...stored });
67
- }
68
- }
69
-
70
- function _defaultSettings() {
71
- const storedTheme = (() => { try { return localStorage.getItem(THEME_STORAGE_KEY); } catch { return null; } })();
72
- return { theme: storedTheme || 'dark', webSearch: true, imageGen: true, videoGen: true, audioGen: true };
73
- }
74
-
75
- function _openSettingsModal(activeTab, settings) {
76
- openModal(buildSettingsHtml(activeTab, settings), {
77
- wide: true,
78
- onOpen(b) {
79
- setupSettingsTabs(b);
80
- setupChatSettings(b);
81
- if (isAuthenticated()) {
82
- setupAccountSettings(b);
83
- }
84
- b.querySelectorAll('[data-disabled]').forEach(btn => {
85
- btn.disabled = true;
86
- btn.title = 'Coming soon';
87
- btn.style.opacity = '0.45';
88
- });
89
- }
90
- });
91
- }
92
-
93
- function buildSettingsHtml(activeTab, settings) {
94
- const authed = isAuthenticated();
95
- const currentTheme = (() => { try { return localStorage.getItem(THEME_STORAGE_KEY) || settings.theme || 'dark'; } catch { return settings.theme || 'dark'; } })();
96
-
97
- return `
98
- <div class="modal-header">
99
- <span class="modal-title">Settings</span>
100
- <button class="modal-close" id="settings-close">×</button>
101
- </div>
102
- <div class="settings-tabs">
103
- <button class="settings-tab ${activeTab==='chat'?'active':''}" data-tab="chat">Chat</button>
104
- ${authed ? `<button class="settings-tab ${activeTab==='account'?'active':''}" data-tab="account">Account</button>` : ''}
105
- </div>
106
-
107
- <!-- Chat Settings -->
108
- <div class="settings-pane ${activeTab==='chat'?'active':''}" data-pane="chat" style="padding:0 24px 20px;">
109
- <div class="setting-row">
110
- <div>
111
- <div class="setting-label">Theme</div>
112
- <div class="setting-desc">Light or dark interface</div>
113
- </div>
114
- <div style="display:flex;gap:8px;">
115
- <button class="btn-ghost${currentTheme==='light'?' active-theme':''}" data-theme-btn="light" style="font-size:13px;padding:5px 12px;">☀ Light</button>
116
- <button class="btn-ghost${currentTheme!=='light'?' active-theme':''}" data-theme-btn="dark" style="font-size:13px;padding:5px 12px;">🌙 Dark</button>
117
- </div>
118
- </div>
119
-
120
- <div style="margin-top:16px;margin-bottom:8px;font-size:12px;text-transform:uppercase;letter-spacing:0.05em;color:var(--text-muted);">Available Tools</div>
121
- ${buildToolToggle('webSearch', 'Web Search', 'Search the web for current information', settings.webSearch !== false)}
122
- ${buildToolToggle('imageGen', 'Image Generation','Generate images from prompts', settings.imageGen !== false)}
123
- ${buildToolToggle('videoGen', 'Video Generation','Generate videos from prompts', settings.videoGen !== false)}
124
- ${buildToolToggle('audioGen', 'Audio / SFX', 'Generate music and sound effects', settings.audioGen !== false)}
125
- </div>
126
-
127
- ${authed ? buildAccountPane(activeTab) : ''}
128
-
129
- <div class="modal-footer" style="border-top:1px solid var(--border);padding-top:12px;">
130
- <button class="btn-ghost" id="settings-cancel">Cancel</button>
131
- <button class="btn-primary" id="settings-apply">Apply</button>
132
- </div>
133
- `;
134
- }
135
-
136
- function buildToolToggle(key, label, desc, enabled) {
137
- return `
138
- <div class="setting-row">
139
- <div>
140
- <div class="setting-label">${escHtml(label)}</div>
141
- <div class="setting-desc">${escHtml(desc)}</div>
142
- </div>
143
- <label class="toggle-switch" data-toggle="${key}">
144
- <input type="checkbox" ${enabled ? 'checked' : ''} />
145
- <div class="toggle-track"></div>
146
- <div class="toggle-thumb"></div>
147
- </label>
148
- </div>`;
149
- }
150
-
151
- function buildAccountPane(activeTab) {
152
- const u = currentUser;
153
- const p = userProfile;
154
- const email = u?.email || '';
155
- const username = p?.username || '';
156
-
157
- return `
158
- <div class="settings-pane ${activeTab==='account'?'active':''}" data-pane="account" style="padding:0 24px 8px;">
159
- <!-- Username -->
160
- <div class="form-group" style="margin-top:4px;">
161
- <label class="form-label">Username</label>
162
- <div style="display:flex;gap:8px;">
163
- <input class="form-input" id="username-input" value="${escHtml(username)}" placeholder="Choose a username" style="flex:1;" />
164
- <button class="btn-ghost" id="username-save" style="font-size:13px;padding:6px 14px;white-space:nowrap;">Save</button>
165
- </div>
166
- <div id="username-msg" class="form-hint" style="display:none;"></div>
167
- </div>
168
-
169
- <!-- Plan info -->
170
- <div id="plan-section" style="padding:12px;border-radius:var(--radius-md);background:var(--bg-raised);border:1px solid var(--border);margin-bottom:16px;">
171
- <div style="font-size:12px;color:var(--text-muted);margin-bottom:4px;">Current Plan</div>
172
- <div id="plan-name-display" style="font-weight:600;font-size:15px;">Loading…</div>
173
- <button class="btn-ghost" id="billing-portal-btn" style="margin-top:8px;font-size:12px;padding:5px 12px;">Manage Billing ↗</button>
174
- </div>
175
-
176
- <!-- Data management -->
177
- <div style="margin-bottom:8px;font-size:12px;text-transform:uppercase;letter-spacing:0.05em;color:var(--text-muted);">Data</div>
178
- <div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:16px;">
179
- <button class="btn-ghost" data-disabled style="font-size:13px;">Manage Memories</button>
180
- <button class="btn-ghost" data-disabled style="font-size:13px;">Delete All Memories</button>
181
- <button class="btn-danger" id="delete-sessions-btn" style="font-size:13px;">Delete All Chats</button>
182
- </div>
183
-
184
- <!-- Active sessions -->
185
- <div style="margin-bottom:8px;font-size:12px;text-transform:uppercase;letter-spacing:0.05em;color:var(--text-muted);">Devices &amp; Sessions</div>
186
- <div id="device-sessions-list" style="margin-bottom:10px;">
187
- <div style="font-size:13px;color:var(--text-muted);">Loading sessions…</div>
188
- </div>
189
- <button class="btn-danger" id="revoke-all-btn" style="font-size:13px;margin-bottom:16px;">Log Out All Other Devices</button>
190
-
191
- <!-- Delete account -->
192
- <div style="border-top:1px solid var(--border);padding-top:14px;">
193
- <button class="btn-danger" id="delete-account-btn" style="font-size:13px;">Delete Account</button>
194
- </div>
195
- </div>`;
196
- }
197
-
198
- function setupSettingsTabs(b) {
199
- b.querySelector('#settings-close')?.addEventListener('click', closeModal);
200
- b.querySelector('#settings-cancel')?.addEventListener('click', closeModal);
201
-
202
- b.querySelectorAll('.settings-tab').forEach(tab => {
203
- tab.addEventListener('click', () => {
204
- b.querySelectorAll('.settings-tab').forEach(t => t.classList.remove('active'));
205
- b.querySelectorAll('.settings-pane').forEach(p => p.classList.remove('active'));
206
- tab.classList.add('active');
207
- b.querySelector(`[data-pane="${tab.dataset.tab}"]`)?.classList.add('active');
208
- });
209
- });
210
-
211
- b.querySelector('#settings-apply')?.addEventListener('click', () => applySettings(b));
212
- }
213
-
214
- function setupChatSettings(b) {
215
- b.querySelectorAll('[data-theme-btn]').forEach(btn => {
216
- btn.addEventListener('click', () => {
217
- b.querySelectorAll('[data-theme-btn]').forEach(t => t.classList.remove('active-theme'));
218
- btn.classList.add('active-theme');
219
- applyTheme(btn.dataset.themeBtn);
220
- });
221
- });
222
- }
223
-
224
- function setupAccountSettings(b) {
225
- // Username
226
- b.querySelector('#username-save')?.addEventListener('click', async () => {
227
- const val = b.querySelector('#username-input')?.value;
228
- const msgEl = b.querySelector('#username-msg');
229
- send({ type: 'account:setUsername', username: val });
230
- const handler = (msg) => {
231
- off('account:usernameResult', handler);
232
- msgEl.style.display = '';
233
- if (msg.success) {
234
- msgEl.textContent = `Username set to @${msg.username}`;
235
- msgEl.style.color = 'var(--plan-core)';
236
- } else {
237
- msgEl.textContent = msg.error || 'Failed';
238
- msgEl.style.color = '#f87171';
239
- }
240
- };
241
- on('account:usernameResult', handler);
242
- });
243
-
244
- // Billing portal
245
- b.querySelector('#billing-portal-btn')?.addEventListener('click', () => {
246
- window.open('https://sharktide-lightning.hf.space/portal', '_blank');
247
- });
248
-
249
- // Delete all sessions
250
- b.querySelector('#delete-sessions-btn')?.addEventListener('click', () => {
251
- if (confirm('Delete all chats? This cannot be undone.')) {
252
- deleteAllSessions();
253
- closeModal();
254
- }
255
- });
256
-
257
- // Revoke all devices
258
- b.querySelector('#revoke-all-btn')?.addEventListener('click', () => {
259
- if (confirm('Log out all other devices?')) {
260
- send({ type: 'account:revokeAllOthers' });
261
- showNotification({ type: 'success', message: 'Other sessions logged out', duration: 2500 });
262
- }
263
- });
264
-
265
- // Delete account
266
- b.querySelector('#delete-account-btn')?.addEventListener('click', async () => {
267
- if (!confirm('Delete your account permanently? This cannot be undone.')) return;
268
- const auth = JSON.parse(localStorage.getItem('ipai_auth_v1') || '{}');
269
- if (!auth.access_token) return;
270
- const res = await fetch('https://dpixehhdbtzsbckfektd.supabase.co/functions/v1/delete_account', {
271
- method: 'POST', headers: { Authorization: `Bearer ${auth.access_token}` },
272
- });
273
- if (res.ok) {
274
- closeModal();
275
- import('./auth.js').then(a => a.logout());
276
- } else {
277
- const d = await res.json().catch(() => ({}));
278
- showNotification({ type: 'error', message: d.error || 'Delete failed', duration: 4000 });
279
- }
280
- });
281
-
282
- // Load subscription + device sessions
283
- send({ type: 'account:getSubscription' });
284
- send({ type: 'account:getSessions' });
285
-
286
- const subHandler = (msg) => {
287
- off('account:subscription', subHandler);
288
- const planEl = b.querySelector('#plan-name-display');
289
- if (planEl && msg.info) {
290
- const pKey = msg.info.planKey || 'free';
291
- const pName = msg.info.planName || 'Free Tier';
292
- planEl.innerHTML = `<span style="color:var(--plan-${pKey})">${escHtml(pName)}</span>`;
293
- }
294
- };
295
- on('account:subscription', subHandler);
296
-
297
- const sessHandler = (msg) => {
298
- off('account:deviceSessions', sessHandler);
299
- const listEl = b.querySelector('#device-sessions-list');
300
- if (!listEl) return;
301
- const sessions = msg.sessions || [];
302
- const currentToken = msg.currentToken;
303
- if (sessions.length === 0) {
304
- listEl.innerHTML = '<div style="font-size:13px;color:var(--text-muted);">No sessions found.</div>';
305
- return;
306
- }
307
- listEl.innerHTML = sessions.map(s => `
308
- <div class="device-session-item" data-token="${escHtml(s.token)}">
309
- <div class="device-badge">💻</div>
310
- <div class="device-info">
311
- <div class="device-name">${escHtml(s.userAgent?.slice(0,50) || 'Unknown device')}</div>
312
- <div class="device-meta">${escHtml(s.ip || '—')} · Last seen ${escHtml(s.lastSeen ? new Date(s.lastSeen).toLocaleDateString() : '—')}</div>
313
- ${s.token === currentToken ? '<div class="device-current">Current session</div>' : ''}
314
- </div>
315
- </div>`).join('');
316
-
317
- listEl.querySelectorAll('.device-session-item').forEach(el => {
318
- el.addEventListener('click', () => {
319
- const token = el.dataset.token;
320
- const session = sessions.find(s => s.token === token);
321
- if (session) openDeviceSessionModal(session, token === currentToken);
322
- });
323
- });
324
- };
325
- on('account:deviceSessions', sessHandler);
326
- }
327
-
328
- function applySettings(b) {
329
- const theme = b.querySelector('[data-theme-btn].active-theme')?.dataset.themeBtn
330
- || (document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark');
331
-
332
- const tools = {};
333
- b.querySelectorAll('[data-toggle]').forEach(label => {
334
- const key = label.dataset.toggle;
335
- const checked = label.querySelector('input[type="checkbox"]').checked;
336
- tools[key] = checked;
337
- });
338
-
339
- const newSettings = { theme, ...tools };
340
- applyTheme(theme);
341
-
342
- // Sync tool buttons in chat UI
343
- document.querySelectorAll('[data-tool]').forEach(btn => {
344
- const t = btn.dataset.tool;
345
- if (t in tools) {
346
- btn.classList.toggle('active', !!tools[t]);
347
- btn.style.display = '';
348
- }
349
- });
350
-
351
- // Cache for guests
352
- if (!isAuthenticated()) {
353
- try { localStorage.setItem('ipai_settings', JSON.stringify(newSettings)); } catch {}
354
- }
355
-
356
- if (isAuthenticated()) {
357
- send({ type: 'settings:save', settings: newSettings });
358
- }
359
-
360
- closeModal();
361
- }
362
-
363
- (function initThemeEarly() {
364
- try {
365
- const stored = localStorage.getItem('ipai_theme');
366
- if (stored) {
367
- document.documentElement.setAttribute('data-theme', stored);
368
- currentTheme = stored;
369
- }
370
- } catch {}
371
- })();
372
-
373
- // Apply from auth response
374
- on('auth:ok', (msg) => {
375
- if (msg.settings?.theme) applyTheme(msg.settings.theme, true);
376
- });
377
- on('auth:guestOk', () => {
378
- const stored = (() => {
379
- try { return JSON.parse(localStorage.getItem('ipai_settings') || '{}'); } catch { return {}; }
380
- })();
381
- applyTheme(stored.theme || localStorage.getItem(THEME_STORAGE_KEY) || 'dark', false);
382
- });
383
- on('settings:updated', (msg) => {
384
- if (msg.settings?.theme) applyTheme(msg.settings.theme, true);
385
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
public/js/turnstile.js DELETED
@@ -1,84 +0,0 @@
1
- // turnstile.js — show overlay and handle verification
2
- (function() {
3
- let handled = false;
4
- function hasTurnstileCookie() {
5
- return document.cookie.split(';').some(c => c.trim().startsWith('turnstile='));
6
- }
7
-
8
- const overlay = document.getElementById('turnstile-overlay');
9
- const pageRoot = document.getElementById('app') || document.body;
10
-
11
- function hideOverlay() {
12
- if (overlay) overlay.style.display = 'none';
13
- if (pageRoot) pageRoot.classList.remove('page-faded');
14
- }
15
- function showOverlay() {
16
- if (overlay) overlay.style.display = 'flex';
17
- if (pageRoot) pageRoot.classList.add('page-faded');
18
- }
19
-
20
- // Helper: set local cookie so reload won't re-show challenge
21
- function setLocalTurnstileCookie() {
22
- try {
23
- document.cookie = 'turnstile=1; path=/; max-age=' + (24 * 3600);
24
- } catch (e) { /* ignore */ }
25
- }
26
-
27
- // Attempt websocket verify first, then fallback to REST if needed.
28
- async function doWebsocketVerify(token) {
29
- return new Promise(resolve => {
30
- if (!window.ws || !window.ws.send || !window.ws.isConnected || !window.ws.on) return resolve(false);
31
- // If websocket not connected, bail
32
- if (!window.ws.isConnected()) return resolve(false);
33
-
34
- let done = false;
35
- const onOk = () => { if (done) return; done = true; try { unsub(); unsubErr(); } catch {} resolve(true); };
36
- const onErr = () => { if (done) return; done = true; try { unsub(); unsubErr(); } catch {} resolve(false); };
37
-
38
- // Listen for server ack
39
- const unsub = window.ws.on('turnstile:ok', () => { onOk(); });
40
- const unsubErr = window.ws.on('turnstile:error', () => { onErr(); });
41
-
42
- // Send verify message
43
- try { window.ws.send({ type: 'turnstile:verify', token }); }
44
- catch (e) { unsub(); unsubErr(); resolve(false); }
45
-
46
- // Fallback timeout
47
- setTimeout(() => { if (!done) { try { unsub(); unsubErr(); } catch {} resolve(false); } }, 3500);
48
- });
49
- }
50
-
51
- async function doRestVerify(token) {
52
- try {
53
- const r = await fetch('/api/turnstile', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token }) });
54
- if (r.ok) return true;
55
- } catch (e) { console.error('Turnstile REST verify failed', e); }
56
- return false;
57
- }
58
-
59
- // Global callback for Cloudflare Turnstile — ensure single handling
60
- window.onTurnstileSuccess = async function(token) {
61
- if (!token || handled) return; handled = true;
62
-
63
- // Prefer websocket verify for immediate session validation
64
- let ok = await doWebsocketVerify(token);
65
- if (ok) {
66
- setLocalTurnstileCookie();
67
- hideOverlay();
68
- return;
69
- }
70
-
71
- // Fallback to REST verify (sets cookie server-side)
72
- ok = await doRestVerify(token);
73
- if (ok) setLocalTurnstileCookie();
74
- if (ok) hideOverlay();
75
- else {
76
- // If both failed, allow retry by resetting handled after short delay
77
- handled = false;
78
- showOverlay();
79
- }
80
- };
81
-
82
- // Initialize visibility
83
- if (hasTurnstileCookie()) hideOverlay(); else showOverlay();
84
- })();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
public/js/ui.js DELETED
@@ -1,306 +0,0 @@
1
- // ui.js — notifications, context menus, markdown, shared DOM helpers
2
-
3
- // ── Notifications ─────────────────────────────────────────────────────────
4
-
5
- export function showNotification({ type = 'info', message, action, duration = 5000 }) {
6
- const container = document.getElementById('notifications');
7
- if (!container) return;
8
-
9
- const el = document.createElement('div');
10
- el.className = `notification ${type}`;
11
-
12
- const text = document.createElement('span');
13
- text.style.flex = '1';
14
- text.textContent = message;
15
- el.appendChild(text);
16
-
17
- if (action) {
18
- const btn = document.createElement('button');
19
- btn.textContent = action.label;
20
- btn.style.cssText = 'margin-left:10px;color:var(--yellow);font-size:12px;font-weight:600;white-space:nowrap;';
21
- btn.addEventListener('click', () => { action.onClick?.(); el.remove(); });
22
- el.appendChild(btn);
23
- }
24
-
25
- const close = document.createElement('button');
26
- close.className = 'notif-close';
27
- close.textContent = '×';
28
- close.addEventListener('click', () => el.remove());
29
- el.appendChild(close);
30
-
31
- container.appendChild(el);
32
- if (duration > 0) setTimeout(() => el.remove(), duration);
33
- return el;
34
- }
35
-
36
- // ── Context menu ──────────────────────────────────────────────────────────
37
-
38
- let activeMenu = null;
39
- let menuDocHandler = null;
40
-
41
- export function showContextMenu(x, y, items) {
42
- closeContextMenu();
43
-
44
- const menu = document.getElementById('session-context-menu');
45
- menu.innerHTML = '';
46
-
47
- for (const item of items) {
48
- if (item.separator) {
49
- const sep = document.createElement('div');
50
- sep.style.cssText = 'height:1px;background:var(--border);margin:3px 0;';
51
- menu.appendChild(sep);
52
- continue;
53
- }
54
- const el = document.createElement('div');
55
- el.className = 'context-item' + (item.danger ? ' danger' : '') + (item.warning ? ' warning' : '');
56
- if (item.icon) {
57
- const ic = document.createElement('span');
58
- ic.textContent = item.icon;
59
- ic.style.fontSize = '14px';
60
- el.appendChild(ic);
61
- }
62
- const label = document.createElement('span');
63
- label.textContent = item.label;
64
- el.appendChild(label);
65
- el.addEventListener('click', (e) => { e.stopPropagation(); closeContextMenu(); item.onClick?.(); });
66
- menu.appendChild(el);
67
- }
68
-
69
- menu.classList.remove('hidden');
70
- const rect = menu.getBoundingClientRect();
71
- const vw = window.innerWidth, vh = window.innerHeight;
72
- let left = x, top = y;
73
- if (left + rect.width > vw - 8) left = vw - rect.width - 8;
74
- if (top + rect.height > vh - 8) top = y - rect.height;
75
- if (top < 8) top = 8;
76
- menu.style.left = `${left}px`;
77
- menu.style.top = `${top}px`;
78
-
79
- activeMenu = menu;
80
- queueMicrotask(() => {
81
- menuDocHandler = (e) => {
82
- if (!menu.contains(e.target)) closeContextMenu();
83
- };
84
- document.addEventListener('click', menuDocHandler);
85
- document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeContextMenu(); }, { once: true });
86
- });
87
- }
88
-
89
- export function showUserMenu(x, y, items) {
90
- const menu = document.getElementById('user-context-menu');
91
- menu.innerHTML = '';
92
- for (const item of items) {
93
- const el = document.createElement('div');
94
- el.className = 'context-item' + (item.danger ? ' danger' : '');
95
- el.textContent = item.label;
96
- el.addEventListener('click', () => { menu.classList.add('hidden'); item.onClick?.(); });
97
- menu.appendChild(el);
98
- }
99
- menu.classList.remove('hidden');
100
- const vw = window.innerWidth, vh = window.innerHeight;
101
- const rect = menu.getBoundingClientRect();
102
- let left = x, top = y;
103
- if (left + rect.width > vw - 8) left = vw - rect.width - 8;
104
- if (top + rect.height > vh - 8) top = y - rect.height;
105
- menu.style.left = `${left}px`;
106
- menu.style.top = `${top}px`;
107
- setTimeout(() => {
108
- document.addEventListener('click', (e) => {
109
- if (!menu.contains(e.target)) menu.classList.add('hidden');
110
- }, { once: true });
111
- }, 0);
112
- }
113
-
114
- export function closeContextMenu() {
115
- activeMenu?.classList.add('hidden');
116
- activeMenu = null;
117
- if (menuDocHandler) { document.removeEventListener('click', menuDocHandler); menuDocHandler = null; }
118
- }
119
-
120
- // ── Markdown rendering ────────────────────────────────────────────────────
121
-
122
- // Store code snippets by a key so copy button can reference them
123
- const codeStore = new Map();
124
- let codeStoreCounter = 0;
125
-
126
- function buildMarkdownOptions() {
127
- const renderer = new marked.Renderer();
128
-
129
- renderer.code = (code, lang) => {
130
- // Store the raw code and generate a unique key for the copy button
131
- const key = `code-${++codeStoreCounter}`;
132
- codeStore.set(key, typeof code === 'object' ? (code.text || '') : code);
133
-
134
- const rawCode = typeof code === 'object' ? (code.text || '') : code;
135
- const rawLang = typeof code === 'object' ? (code.lang || lang || 'code') : (lang || 'code');
136
-
137
- const escapedCode = escHtml(rawCode);
138
- const displayLang = rawLang || 'code';
139
-
140
- if (rawLang === 'svg') {
141
- return `<div class="svg-render-block" data-svg="${escAttr(rawCode)}">
142
- <img src="data:image/svg+xml,${encodeURIComponent(rawCode)}" style="max-width:100%;cursor:pointer;" alt="SVG">
143
- </div>`;
144
- }
145
- return `<div class="code-block">
146
- <div class="code-header">
147
- <span class="code-lang">${escHtml(displayLang)}</span>
148
- <button class="code-copy-btn" data-code-key="${escAttr(key)}">Copy</button>
149
- </div>
150
- <pre><code class="language-${escHtml(displayLang)}">${escapedCode}</code></pre>
151
- </div>`;
152
- };
153
-
154
- // Override inline code to NOT apply background inside code blocks
155
- // (the .code-block pre code rule in CSS handles this)
156
- renderer.codespan = (code) => {
157
- const raw = typeof code === 'object' ? (code.text || '') : code;
158
- return `<code class="inline-code">${escHtml(raw)}</code>`;
159
- };
160
-
161
- renderer.link = (href, title, text) => {
162
- const hrefStr = typeof href === 'object' ? (href.href || '') : (href || '');
163
- const titleStr = typeof title === 'string' ? title : '';
164
- const textStr = typeof text === 'string' ? text : '';
165
- const safeHref = escHtml(hrefStr);
166
- return `<a href="${safeHref}" target="_blank" rel="noopener noreferrer" title="${escHtml(titleStr)}">${textStr}</a>`;
167
- };
168
-
169
- return { renderer, breaks: true, gfm: true };
170
- }
171
-
172
- export function renderMarkdown(text) {
173
- if (!text) return '';
174
- try {
175
- marked.setOptions(buildMarkdownOptions());
176
- const raw = marked.parse(text);
177
- const clean = DOMPurify.sanitize(raw, {
178
- ALLOWED_TAGS: [
179
- 'p','br','strong','em','del','u','s','h1','h2','h3','h4','h5','h6',
180
- 'ul','ol','li','blockquote','hr','table','thead','tbody','tr','th','td',
181
- 'pre','code','a','img','span','div','details','summary','button',
182
- ],
183
- ALLOWED_ATTR: ['href','src','alt','title','class','data-code','data-code-key','data-svg','data-color',
184
- 'target','rel','style','open','align','type','aria-label'],
185
- ALLOW_DATA_ATTR: true,
186
- });
187
- return clean;
188
- } catch (e) {
189
- return escHtml(text);
190
- }
191
- }
192
-
193
- export function attachCodeCopyListeners(container) {
194
- container.querySelectorAll('.code-copy-btn').forEach(btn => {
195
- // Remove any existing listeners by cloning the button
196
- const fresh = btn.cloneNode(true);
197
- btn.parentNode?.replaceChild(fresh, btn);
198
-
199
- fresh.addEventListener('click', async (e) => {
200
- e.stopPropagation();
201
- // Try data-code-key first (new approach), then data-code (legacy)
202
- const key = fresh.getAttribute('data-code-key');
203
- let code = '';
204
- if (key && codeStore.has(key)) {
205
- code = codeStore.get(key);
206
- } else {
207
- // Fallback: grab text from the sibling <pre><code>
208
- const pre = fresh.closest('.code-block')?.querySelector('pre code');
209
- if (pre) code = pre.textContent || '';
210
- }
211
-
212
- try {
213
- await navigator.clipboard.writeText(code);
214
- fresh.textContent = 'Copied!';
215
- setTimeout(() => fresh.textContent = 'Copy', 1400);
216
- } catch {
217
- // Fallback for environments where clipboard API is unavailable
218
- try {
219
- const ta = document.createElement('textarea');
220
- ta.value = code;
221
- ta.style.cssText = 'position:fixed;opacity:0;';
222
- document.body.appendChild(ta);
223
- ta.select();
224
- document.execCommand('copy');
225
- document.body.removeChild(ta);
226
- fresh.textContent = 'Copied!';
227
- setTimeout(() => fresh.textContent = 'Copy', 1400);
228
- } catch {
229
- fresh.textContent = 'Error';
230
- setTimeout(() => fresh.textContent = 'Copy', 1400);
231
- }
232
- }
233
- });
234
- });
235
- }
236
-
237
- export function attachSvgPanelListeners(container) {
238
- container.querySelectorAll('.svg-render-block img').forEach(img => {
239
- img.addEventListener('click', () => {
240
- const svgCode = img.closest('.svg-render-block')?.getAttribute('data-svg') || '';
241
- openSvgPanel(svgCode);
242
- });
243
- });
244
- }
245
-
246
- function openSvgPanel(svgCode) {
247
- let panel = document.getElementById('svg-panel');
248
- if (!panel) {
249
- panel = document.createElement('div');
250
- panel.id = 'svg-panel';
251
- panel.innerHTML = `
252
- <div id="svg-panel-header">
253
- <span style="font-size:13px;font-weight:600;">SVG Preview</span>
254
- <div style="display:flex;gap:6px">
255
- <button id="svg-toggle-btn" style="font-size:11px;padding:3px 8px;border-radius:4px;border:1px solid var(--border-bright);background:var(--bg-hover)">XML</button>
256
- <button id="svg-close-btn" style="color:var(--text-muted);font-size:18px;padding:0 4px;">×</button>
257
- </div>
258
- </div>
259
- <div id="svg-panel-content"></div>`;
260
- document.body.appendChild(panel);
261
- }
262
- const content = document.getElementById('svg-panel-content');
263
- const toggleBtn = document.getElementById('svg-toggle-btn');
264
- const closeBtn = document.getElementById('svg-close-btn');
265
-
266
- let showingXml = false;
267
- const renderImg = () => {
268
- content.innerHTML = `<img src="data:image/svg+xml,${encodeURIComponent(svgCode)}" style="max-width:100%;" alt="SVG">`;
269
- };
270
- renderImg();
271
-
272
- toggleBtn.onclick = () => {
273
- showingXml = !showingXml;
274
- toggleBtn.textContent = showingXml ? 'Image' : 'XML';
275
- if (showingXml) {
276
- content.innerHTML = `<pre style="font-size:12px;white-space:pre-wrap;word-break:break-all;padding:8px;background:var(--bg-raised);border-radius:6px;">${escHtml(svgCode)}</pre>`;
277
- } else renderImg();
278
- };
279
- closeBtn.onclick = () => panel.remove();
280
- }
281
-
282
- // ── Textarea auto-resize ───────────────────────────────────────────────────
283
-
284
- export function autoResize(textarea, maxLines = 6) {
285
- const lh = parseFloat(getComputedStyle(textarea).lineHeight) || 22;
286
- const pad = parseFloat(getComputedStyle(textarea).paddingTop || '0')
287
- + parseFloat(getComputedStyle(textarea).paddingBottom || '0');
288
- textarea.style.height = 'auto';
289
- const max = lh * maxLines + pad;
290
- textarea.style.height = Math.min(textarea.scrollHeight, max) + 'px';
291
- textarea.style.overflowY = textarea.scrollHeight > max ? 'auto' : 'hidden';
292
- }
293
-
294
- // ── Escape helpers ────────────────────────────────────────────────────────
295
-
296
- export function escHtml(str) {
297
- return String(str)
298
- .replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
299
- .replace(/"/g,'&quot;').replace(/'/g,'&#39;');
300
- }
301
-
302
- export function escAttr(str) {
303
- return String(str).replace(/"/g,'&quot;');
304
- }
305
-
306
- window.ui = { showNotification, showContextMenu, showUserMenu, renderMarkdown };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
public/js/ws.js DELETED
@@ -1,133 +0,0 @@
1
- // ws.js - WebSocket connection manager
2
- const WS_URL = `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/ws`;
3
- const RECONNECT_DELAY_MS = 2000;
4
- const MAX_RECONNECT_DELAY = 30000;
5
-
6
- let ws = null;
7
- let reconnectDelay = RECONNECT_DELAY_MS;
8
- let reconnectTimer = null;
9
- let pendingCallbacks = new Map(); // id -> { resolve, reject, timeout }
10
- let msgId = 0;
11
- const listeners = new Map(); // type -> Set<fn>
12
-
13
- export function send(data) {
14
- if (ws?.readyState === WebSocket.OPEN) {
15
- ws.send(JSON.stringify(data));
16
- return true;
17
- }
18
- return false;
19
- }
20
-
21
- export function request(data, timeoutMs = 15000) {
22
- return new Promise((resolve, reject) => {
23
- const id = `req_${++msgId}`;
24
- const full = { ...data, _reqId: id };
25
- const timer = setTimeout(() => {
26
- pendingCallbacks.delete(id);
27
- reject(new Error('Request timeout'));
28
- }, timeoutMs);
29
- pendingCallbacks.set(id, { resolve, reject, timer });
30
- if (!send(full)) {
31
- clearTimeout(timer);
32
- pendingCallbacks.delete(id);
33
- reject(new Error('WebSocket not connected'));
34
- }
35
- });
36
- }
37
-
38
- export function on(type, fn) {
39
- if (!listeners.has(type)) listeners.set(type, new Set());
40
- listeners.get(type).add(fn);
41
- return () => listeners.get(type)?.delete(fn);
42
- }
43
-
44
- export function off(type, fn) {
45
- listeners.get(type)?.delete(fn);
46
- }
47
-
48
- function emit(type, data) {
49
- listeners.get(type)?.forEach(fn => fn(data));
50
- listeners.get('*')?.forEach(fn => fn({ type, ...data }));
51
- }
52
-
53
-
54
-
55
- function scheduleReconnect() {
56
- clearTimeout(reconnectTimer);
57
- reconnectTimer = setTimeout(() => {
58
- reconnectDelay = Math.min(reconnectDelay * 1.5, MAX_RECONNECT_DELAY);
59
- connectWithPing();
60
- }, reconnectDelay);
61
- }
62
-
63
- export function getReadyState() {
64
- return ws?.readyState ?? WebSocket.CLOSED;
65
- }
66
-
67
- export function isConnected() {
68
- return ws?.readyState === WebSocket.OPEN;
69
- }
70
-
71
- // ── Ping keepalive ────────────────────────────────────────────────────────
72
- // Prevents the WebSocket from being closed by proxies/servers during long
73
- // operations like image/video generation (which can take 30-60+ seconds).
74
- let pingInterval = null;
75
-
76
- function startPing() {
77
- stopPing();
78
- pingInterval = setInterval(() => {
79
- if (ws?.readyState === WebSocket.OPEN) {
80
- try { ws.send(JSON.stringify({ type: 'ping' })); } catch {}
81
- }
82
- }, 20000); // every 20 seconds
83
- }
84
-
85
- function stopPing() {
86
- if (pingInterval) { clearInterval(pingInterval); pingInterval = null; }
87
- }
88
-
89
- // Patch connect to start/stop ping
90
- function connectWithPing() {
91
- if (ws && (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN)) return;
92
-
93
- ws = new WebSocket(WS_URL);
94
-
95
- ws.onopen = () => {
96
- reconnectDelay = RECONNECT_DELAY_MS;
97
- startPing();
98
- emit('ws:connected', {});
99
- };
100
-
101
- ws.onmessage = (event) => {
102
- let msg;
103
- try { msg = JSON.parse(event.data); } catch { return; }
104
- if (!msg?.type) return;
105
- // Silently swallow pong responses
106
- if (msg.type === 'pong') return;
107
-
108
- if (msg._reqId && pendingCallbacks.has(msg._reqId)) {
109
- const cb = pendingCallbacks.get(msg._reqId);
110
- pendingCallbacks.delete(msg._reqId);
111
- clearTimeout(cb.timer);
112
- if (msg.error) cb.reject(new Error(msg.error));
113
- else cb.resolve(msg);
114
- return;
115
- }
116
-
117
- emit(msg.type, msg);
118
- };
119
-
120
- ws.onclose = () => {
121
- stopPing();
122
- emit('ws:disconnected', {});
123
- scheduleReconnect();
124
- };
125
-
126
- ws.onerror = () => {
127
- ws?.close();
128
- };
129
- }
130
-
131
- // Boot
132
- connectWithPing();
133
- window.ws = { send, request, on, off, isConnected, getReadyState };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
public/oauth-callback.html DELETED
@@ -1,79 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8" />
5
- <title>Signing in…</title>
6
- <style>
7
- body { background: #0d0e11; color: #e8eaf0; font-family: system-ui, sans-serif;
8
- display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }
9
- .box { text-align: center; }
10
- .spinner { width: 32px; height: 32px; border: 3px solid rgba(229,200,70,0.2);
11
- border-top-color: #e5c846; border-radius: 50%; animation: spin 0.8s linear infinite;
12
- margin: 0 auto 16px; }
13
- @keyframes spin { to { transform: rotate(360deg); } }
14
- </style>
15
- </head>
16
- <body>
17
- <div class="box">
18
- <div class="spinner"></div>
19
- <p id="msg">Completing sign-in…</p>
20
- </div>
21
- <script>
22
- // ── Extract tokens from URL hash (Supabase implicit flow) or search params ──
23
- function getTokens() {
24
- const hash = new URLSearchParams(location.hash.replace('#', ''));
25
- const search = new URLSearchParams(location.search);
26
- return {
27
- access_token: hash.get('access_token') || search.get('access_token'),
28
- refresh_token: hash.get('refresh_token') || search.get('refresh_token'),
29
- error: hash.get('error') || search.get('error'),
30
- error_desc: hash.get('error_description') || search.get('error_description'),
31
- };
32
- }
33
-
34
- const tokens = getTokens();
35
- const msgEl = document.getElementById('msg');
36
-
37
- if (tokens.error) {
38
- msgEl.textContent = 'Sign-in error: ' + (tokens.error_desc || tokens.error);
39
- } else if (tokens.access_token) {
40
- // ── Primary path: write to localStorage so the main tab picks it up
41
- // via a 'storage' event listener — works even when window.opener is null
42
- // (strict popup policies, mobile browsers, etc.).
43
- const payload = JSON.stringify({
44
- access_token: tokens.access_token,
45
- refresh_token: tokens.refresh_token || '',
46
- });
47
- localStorage.setItem('ipai_oauth_pending', payload);
48
-
49
- // ── Also try postMessage to opener for fastest possible handoff ──────
50
- if (window.opener && !window.opener.closed) {
51
- try {
52
- window.opener.postMessage({
53
- type: 'oauth:callback',
54
- access_token: tokens.access_token,
55
- refresh_token: tokens.refresh_token,
56
- }, location.origin);
57
- } catch (_) { /* cross-origin opener — storage event is enough */ }
58
- }
59
-
60
- msgEl.textContent = 'Sign-in complete! Closing…';
61
- // Give localStorage a moment to propagate, then try to close the popup.
62
- setTimeout(() => {
63
- try { window.close(); } catch (_) { /* ignore */ }
64
- }, 300);
65
- } else {
66
- msgEl.textContent = 'No token received. You can close this window.';
67
- }
68
- </script>
69
- <!-- Cloudflare Turnstile -->
70
- <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
71
- <div id="turnstile-overlay" style="position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.6);z-index:9999;">
72
- <div style="background:#0d0e11;padding:18px;border-radius:10px;text-align:center;max-width:420px;width:90%;">
73
- <div style="color:#d7d7d7;margin-bottom:12px;">Please verify you are human to continue.</div>
74
- <div class="cf-turnstile" data-sitekey="0x4AAAAAAC1ZXKIhZ9Kdz8j9" data-callback="onTurnstileSuccess"></div>
75
- </div>
76
- </div>
77
- <script type="module" src="/js/turnstile.js"></script>
78
- </body>
79
- </html>