incognitolm commited on
Commit ·
6cce35a
1
Parent(s): 0d361fd
Transition to Storage Bucket Public Folder
Browse files- public/.DS_Store +0 -0
- public/css/.DS_Store +0 -0
- public/css/base.css +0 -200
- public/css/chat.css +0 -569
- public/css/input.css +0 -413
- public/css/modals.css +0 -212
- public/css/sidebar.css +0 -404
- public/css/tokens.css +0 -99
- public/favicon.ico +0 -0
- public/index.html +0 -239
- public/js/.DS_Store +0 -0
- public/js/app.js +0 -561
- public/js/auth.js +0 -304
- public/js/chat.js +0 -1101
- public/js/modals.js +0 -503
- public/js/sessions.js +0 -285
- public/js/settings.js +0 -385
- public/js/turnstile.js +0 -84
- public/js/ui.js +0 -306
- public/js/ws.js +0 -133
- public/oauth-callback.html +0 -79
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 = '‹'; 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 = '›'; 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
| 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 & 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,'&').replace(/</g,'<').replace(/>/g,'>')
|
| 299 |
-
.replace(/"/g,'"').replace(/'/g,''');
|
| 300 |
-
}
|
| 301 |
-
|
| 302 |
-
export function escAttr(str) {
|
| 303 |
-
return String(str).replace(/"/g,'"');
|
| 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>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|