incognitolm commited on
Commit Β·
fec84f7
1
Parent(s): aefe3d6
Updates
Browse files- public/js/app.js +18 -16
- public/js/auth.js +50 -17
- public/js/sessions.js +27 -6
- public/oauth-callback.html +45 -20
- server/chatStream.js +34 -29
- server/sessionStore.js +29 -22
public/js/app.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
| 1 |
// app.js β bootstrap, input handling, sidebar, attach, paste
|
| 2 |
import { send, on } from './ws.js';
|
| 3 |
import { isAuthenticated, logout, getTempId, getClientId } from './auth.js';
|
| 4 |
-
import {
|
|
|
|
|
|
|
|
|
|
| 5 |
import { submitMessage, renderSession, setActiveSession, getIsStreaming } from './chat.js';
|
| 6 |
import { openAuthModal, closeModal, openPasteEditor } from './modals.js';
|
| 7 |
import { openSettings, applyTheme } from './settings.js';
|
|
@@ -17,13 +20,15 @@ function collapseSidebar(){ sidebar?.classList.remove('expanded'); sidebar?.clas
|
|
| 17 |
function toggleSidebar() { sidebar?.classList.contains('expanded') ? collapseSidebar() : expandSidebar(); }
|
| 18 |
|
| 19 |
toggleBtn?.addEventListener('click', toggleSidebar);
|
| 20 |
-
// Always start collapsed β user opens manually
|
| 21 |
|
| 22 |
-
// ββ New chat βββ
|
| 23 |
|
| 24 |
document.getElementById('new-chat-btn')?.addEventListener('click', () => {
|
| 25 |
-
|
| 26 |
if (window.innerWidth < 768) collapseSidebar();
|
|
|
|
|
|
|
|
|
|
| 27 |
});
|
| 28 |
|
| 29 |
// ββ Session switching βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -71,18 +76,15 @@ function triggerCenterSend() {
|
|
| 71 |
if (centerInput) { centerInput.value = ''; autoResize(centerInput, 6); }
|
| 72 |
clearFilePreviewRow();
|
| 73 |
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
} else {
|
| 84 |
-
doSend(text, attachments);
|
| 85 |
-
}
|
| 86 |
}
|
| 87 |
|
| 88 |
// ββ Bottom input (active chat) ββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 1 |
// app.js β bootstrap, input handling, sidebar, attach, paste
|
| 2 |
import { send, on } from './ws.js';
|
| 3 |
import { isAuthenticated, logout, getTempId, getClientId } 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';
|
|
|
|
| 20 |
function toggleSidebar() { sidebar?.classList.contains('expanded') ? collapseSidebar() : expandSidebar(); }
|
| 21 |
|
| 22 |
toggleBtn?.addEventListener('click', toggleSidebar);
|
|
|
|
| 23 |
|
| 24 |
+
// ββ New chat β just show the welcome screen; don't create a session yet βββ
|
| 25 |
|
| 26 |
document.getElementById('new-chat-btn')?.addEventListener('click', () => {
|
| 27 |
+
showWelcomeScreen();
|
| 28 |
if (window.innerWidth < 768) collapseSidebar();
|
| 29 |
+
// Clear the center input so the user starts fresh
|
| 30 |
+
const ci = document.getElementById('center-input');
|
| 31 |
+
if (ci) { ci.value = ''; autoResize(ci, 6); }
|
| 32 |
});
|
| 33 |
|
| 34 |
// ββ Session switching βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 76 |
if (centerInput) { centerInput.value = ''; autoResize(centerInput, 6); }
|
| 77 |
clearFilePreviewRow();
|
| 78 |
|
| 79 |
+
// Always create a new session when sending from the welcome screen,
|
| 80 |
+
// then wait for 'switched' before submitting.
|
| 81 |
+
const pendingText = text, pendingAttach = attachments;
|
| 82 |
+
const unsub = onSessionChange((ev, s) => {
|
| 83 |
+
if (ev !== 'switched' || !s) return;
|
| 84 |
+
unsub();
|
| 85 |
+
doSend(pendingText, pendingAttach);
|
| 86 |
+
});
|
| 87 |
+
createNewSession();
|
|
|
|
|
|
|
|
|
|
| 88 |
}
|
| 89 |
|
| 90 |
// ββ Bottom input (active chat) ββββββββββββββββββββββββββββββββββββββββββββ
|
public/js/auth.js
CHANGED
|
@@ -4,9 +4,11 @@ import { send, on } from './ws.js';
|
|
| 4 |
const SUPABASE_URL = 'https://dpixehhdbtzsbckfektd.supabase.co';
|
| 5 |
const SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImRwaXhlaGhkYnR6c2Jja2Zla3RkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjExNDI0MjcsImV4cCI6MjA3NjcxODQyN30.nR1KCSRQj1E_evQWnE2VaZzg7PgLp2kqt4eDKP2PkpE';
|
| 6 |
|
| 7 |
-
const AUTH_KEY
|
| 8 |
-
const TEMP_ID_KEY
|
| 9 |
-
const CLIENT_ID_KEY= 'ipai_client_id';
|
|
|
|
|
|
|
| 10 |
|
| 11 |
export let currentUser = null;
|
| 12 |
export let userProfile = null;
|
|
@@ -71,10 +73,19 @@ export async function signUpWithEmail(email, password) {
|
|
| 71 |
return data;
|
| 72 |
}
|
| 73 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
export async function loginWithOAuth(provider) {
|
| 75 |
const redirectTo = encodeURIComponent(`${location.origin}/oauth-callback.html`);
|
| 76 |
-
const url = `${SUPABASE_URL}/auth/v1/authorize?
|
| 77 |
-
window.open(url, '_blank', 'width=520,height=640,noopener');
|
|
|
|
|
|
|
| 78 |
}
|
| 79 |
|
| 80 |
export async function logout() {
|
|
@@ -101,7 +112,7 @@ async function handleSupabaseSession(data) {
|
|
| 101 |
saveAuth({ access_token: data.access_token, refresh_token: data.refresh_token, user: data.user });
|
| 102 |
|
| 103 |
return new Promise((resolve, reject) => {
|
| 104 |
-
const tempId
|
| 105 |
const clientId = getClientId();
|
| 106 |
send({ type: 'auth:login', accessToken: data.access_token, refreshToken: data.refresh_token,
|
| 107 |
tempId, clientId });
|
|
@@ -170,21 +181,30 @@ on('ws:connected', async () => {
|
|
| 170 |
}
|
| 171 |
});
|
| 172 |
|
| 173 |
-
// ββ OAuth
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
import('./ui.js').then(({ showNotification }) =>
|
| 183 |
showNotification({ type: 'error', message: `Sign-in failed: ${err.message}`, duration: 4000 }));
|
| 184 |
}
|
| 185 |
});
|
| 186 |
|
| 187 |
-
// Also handle
|
| 188 |
(function checkOAuthRedirect() {
|
| 189 |
const params = new URLSearchParams(location.search);
|
| 190 |
const t = params.get('t'), r = params.get('r');
|
|
@@ -194,6 +214,19 @@ window.addEventListener('message', async (e) => {
|
|
| 194 |
}
|
| 195 |
})();
|
| 196 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
// ββ Sidebar profile ββββββββββββββββββββββββββββοΏ½οΏ½ββββββββββββββββββββββββββ
|
| 198 |
|
| 199 |
export function updateSidebarProfile() {
|
|
@@ -221,4 +254,4 @@ export function updateSidebarProfile() {
|
|
| 221 |
}
|
| 222 |
|
| 223 |
on('auth:ok', updateSidebarProfile);
|
| 224 |
-
on('auth:loggedOut', updateSidebarProfile);
|
|
|
|
| 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;
|
|
|
|
| 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() {
|
|
|
|
| 112 |
saveAuth({ access_token: data.access_token, refresh_token: data.refresh_token, user: data.user });
|
| 113 |
|
| 114 |
return new Promise((resolve, reject) => {
|
| 115 |
+
const tempId = getTempId();
|
| 116 |
const clientId = getClientId();
|
| 117 |
send({ type: 'auth:login', accessToken: data.access_token, refreshToken: data.refresh_token,
|
| 118 |
tempId, clientId });
|
|
|
|
| 181 |
}
|
| 182 |
});
|
| 183 |
|
| 184 |
+
// ββ OAuth: localStorage-based token pickup ββββββββββββββββββββββββββββββββ
|
| 185 |
+
// Works for both popup and redirect flows.
|
| 186 |
+
// The oauth-callback.html page writes { access_token, refresh_token } to
|
| 187 |
+
// localStorage[OAUTH_PENDING_KEY] then closes/redirects. The storage event
|
| 188 |
+
// fires in all other tabs from the same origin.
|
| 189 |
+
|
| 190 |
+
window.addEventListener('storage', async (e) => {
|
| 191 |
+
if (e.key !== OAUTH_PENDING_KEY || !e.newValue) return;
|
| 192 |
+
// Consume immediately so other tabs don't also try to log in
|
| 193 |
+
localStorage.removeItem(OAUTH_PENDING_KEY);
|
| 194 |
+
let tokens;
|
| 195 |
+
try { tokens = JSON.parse(e.newValue); } catch { return; }
|
| 196 |
+
if (!tokens?.access_token) return;
|
| 197 |
+
try {
|
| 198 |
+
await handleSupabaseSession(tokens);
|
| 199 |
+
import('./ui.js').then(({ showNotification }) =>
|
| 200 |
+
showNotification({ type: 'success', message: 'Signed in!', duration: 2500 }));
|
| 201 |
+
} catch (err) {
|
| 202 |
import('./ui.js').then(({ showNotification }) =>
|
| 203 |
showNotification({ type: 'error', message: `Sign-in failed: ${err.message}`, duration: 4000 }));
|
| 204 |
}
|
| 205 |
});
|
| 206 |
|
| 207 |
+
// Also handle same-tab redirect flow (no popup) β ?oauth=1&t=TOKEN&r=REFRESH
|
| 208 |
(function checkOAuthRedirect() {
|
| 209 |
const params = new URLSearchParams(location.search);
|
| 210 |
const t = params.get('t'), r = params.get('r');
|
|
|
|
| 214 |
}
|
| 215 |
})();
|
| 216 |
|
| 217 |
+
// Legacy postMessage support (kept for backwards compat with old callback pages)
|
| 218 |
+
window.addEventListener('message', async (e) => {
|
| 219 |
+
if (e.origin !== location.origin) return;
|
| 220 |
+
if (e.data?.type !== 'oauth:callback') return;
|
| 221 |
+
const { access_token, refresh_token } = e.data;
|
| 222 |
+
if (!access_token) return;
|
| 223 |
+
try { await handleSupabaseSession({ access_token, refresh_token }); }
|
| 224 |
+
catch (err) {
|
| 225 |
+
import('./ui.js').then(({ showNotification }) =>
|
| 226 |
+
showNotification({ type: 'error', message: `Sign-in failed: ${err.message}`, duration: 4000 }));
|
| 227 |
+
}
|
| 228 |
+
});
|
| 229 |
+
|
| 230 |
// ββ Sidebar profile ββββββββββββββββββββββββββββοΏ½οΏ½ββββββββββββββββββββββββββ
|
| 231 |
|
| 232 |
export function updateSidebarProfile() {
|
|
|
|
| 254 |
}
|
| 255 |
|
| 256 |
on('auth:ok', updateSidebarProfile);
|
| 257 |
+
on('auth:loggedOut', updateSidebarProfile);
|
public/js/sessions.js
CHANGED
|
@@ -62,18 +62,25 @@ on('sessions:data', (msg) => {
|
|
| 62 |
on('auth:ok', (msg) => {
|
| 63 |
sessions = msg.sessions || [];
|
| 64 |
renderSessions();
|
| 65 |
-
|
|
|
|
|
|
|
| 66 |
switchSession(sessions[0].id);
|
|
|
|
|
|
|
|
|
|
| 67 |
}
|
| 68 |
});
|
| 69 |
|
| 70 |
on('auth:guestOk', (msg) => {
|
| 71 |
sessions = msg.sessions || [];
|
| 72 |
renderSessions();
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
} else {
|
| 76 |
switchSession(sessions[0].id);
|
|
|
|
|
|
|
|
|
|
| 77 |
}
|
| 78 |
});
|
| 79 |
|
|
@@ -99,6 +106,21 @@ on('sessions:imported', (msg) => {
|
|
| 99 |
|
| 100 |
// ββ Actions βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 101 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
export function createNewSession() {
|
| 103 |
send({ type: 'sessions:create' });
|
| 104 |
}
|
|
@@ -207,7 +229,6 @@ function startInlineRename(el) {
|
|
| 207 |
}
|
| 208 |
|
| 209 |
function openSessionMenu(e, id) {
|
| 210 |
-
const session = sessions.find(s => s.id === id);
|
| 211 |
const items = [
|
| 212 |
{
|
| 213 |
label: 'Share', icon: 'π',
|
|
@@ -261,4 +282,4 @@ function groupByDate(sessions) {
|
|
| 261 |
|
| 262 |
function escHtml(str) {
|
| 263 |
return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
| 264 |
-
}
|
|
|
|
| 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 |
|
|
|
|
| 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 |
}
|
|
|
|
| 229 |
}
|
| 230 |
|
| 231 |
function openSessionMenu(e, id) {
|
|
|
|
| 232 |
const items = [
|
| 233 |
{
|
| 234 |
label: 'Share', icon: 'π',
|
|
|
|
| 282 |
|
| 283 |
function escHtml(str) {
|
| 284 |
return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
| 285 |
+
}
|
public/oauth-callback.html
CHANGED
|
@@ -16,42 +16,67 @@
|
|
| 16 |
<body>
|
| 17 |
<div class="box">
|
| 18 |
<div class="spinner"></div>
|
| 19 |
-
<p>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')
|
| 28 |
-
refresh_token: hash.get('refresh_token')
|
| 29 |
-
error: hash.get('error')
|
| 30 |
-
error_desc: hash.get('error_description')
|
| 31 |
};
|
| 32 |
}
|
| 33 |
|
| 34 |
const tokens = getTokens();
|
|
|
|
| 35 |
|
| 36 |
if (tokens.error) {
|
| 37 |
-
|
| 38 |
} else if (tokens.access_token) {
|
| 39 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
if (window.opener && !window.opener.closed) {
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
// No opener β redirect to main page with params
|
| 49 |
-
const p = new URLSearchParams({ oauth: '1', t: tokens.access_token, r: tokens.refresh_token || '' });
|
| 50 |
-
location.replace('/?' + p.toString());
|
| 51 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
} else {
|
| 53 |
-
|
| 54 |
}
|
| 55 |
</script>
|
| 56 |
</body>
|
| 57 |
-
</html>
|
|
|
|
| 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 close.
|
| 62 |
+
setTimeout(() => {
|
| 63 |
+
// If we're in a popup, close it.
|
| 64 |
+
if (window.opener !== null || window.history.length <= 1) {
|
| 65 |
+
window.close();
|
| 66 |
+
}
|
| 67 |
+
// If close() didn't work (e.g. tab opened directly), redirect home.
|
| 68 |
+
setTimeout(() => {
|
| 69 |
+
const p = new URLSearchParams({
|
| 70 |
+
oauth: '1',
|
| 71 |
+
t: tokens.access_token,
|
| 72 |
+
r: tokens.refresh_token || '',
|
| 73 |
+
});
|
| 74 |
+
location.replace('/?' + p.toString());
|
| 75 |
+
}, 800);
|
| 76 |
+
}, 300);
|
| 77 |
} else {
|
| 78 |
+
msgEl.textContent = 'No token received. You can close this window.';
|
| 79 |
}
|
| 80 |
</script>
|
| 81 |
</body>
|
| 82 |
+
</html>
|
server/chatStream.js
CHANGED
|
@@ -1,38 +1,43 @@
|
|
| 1 |
import OpenAI from "openai";
|
| 2 |
-
import { Client } from "@gradio/client";
|
| 3 |
import { LIGHTNING_BASE } from "./config.js";
|
| 4 |
|
| 5 |
-
// ββ
|
| 6 |
-
//
|
| 7 |
-
//
|
| 8 |
-
//
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
gradioConnecting = Client.connect("incognitolm/Web-Search")
|
| 16 |
-
.then(c => { gradioClient = c; gradioConnecting = null; return c; })
|
| 17 |
-
.catch(e => { gradioConnecting = null; throw e; });
|
| 18 |
-
return gradioConnecting;
|
| 19 |
-
}
|
| 20 |
|
| 21 |
async function gradioSearch(query) {
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
}
|
|
|
|
| 36 |
}
|
| 37 |
|
| 38 |
const SYSTEM_PROMPT =
|
|
|
|
| 1 |
import OpenAI from "openai";
|
|
|
|
| 2 |
import { LIGHTNING_BASE } from "./config.js";
|
| 3 |
|
| 4 |
+
// ββ Web Search via direct HTTP (no Gradio WebSocket) ββββββββββββββββββββββ
|
| 5 |
+
// The Gradio @gradio/client uses its own persistent WebSocket connection.
|
| 6 |
+
// Running it inside a Node WS server caused the server's WS events to be
|
| 7 |
+
// corrupted, showing "connection lost" to the browser and breaking all
|
| 8 |
+
// subsequent messages. We now call the Gradio Space's HTTP /run/predict
|
| 9 |
+
// endpoint directly instead, which is stateless and has no side effects.
|
| 10 |
+
|
| 11 |
+
const SEARCH_SPACE_HTTP = "https://incognitolm-web-search.hf.space";
|
| 12 |
+
// Some Spaces expose /api/predict or /run/predict depending on SDK version.
|
| 13 |
+
// We try /run/predict first (newer), fall back to /api/predict.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
async function gradioSearch(query) {
|
| 16 |
+
const body = JSON.stringify({ data: [query] });
|
| 17 |
+
const headers = { "Content-Type": "application/json" };
|
| 18 |
+
|
| 19 |
+
// Try the modern endpoint first
|
| 20 |
+
for (const path of ["/run/predict", "/api/predict"]) {
|
| 21 |
+
try {
|
| 22 |
+
const res = await fetch(`${SEARCH_SPACE_HTTP}${path}`, {
|
| 23 |
+
method: "POST",
|
| 24 |
+
headers,
|
| 25 |
+
body,
|
| 26 |
+
// Give the search up to 30 s before giving up
|
| 27 |
+
signal: AbortSignal.timeout(30_000),
|
| 28 |
+
});
|
| 29 |
+
if (!res.ok) continue;
|
| 30 |
+
const json = await res.json();
|
| 31 |
+
// Gradio returns { data: [...] }
|
| 32 |
+
const raw = Array.isArray(json.data) ? json.data[0] : json.data;
|
| 33 |
+
if (!raw) throw new Error("Empty response from search endpoint");
|
| 34 |
+
return typeof raw === "string" ? raw : JSON.stringify(raw);
|
| 35 |
+
} catch (err) {
|
| 36 |
+
// Try next path
|
| 37 |
+
if (path === "/api/predict") throw err; // last option, rethrow
|
| 38 |
+
}
|
| 39 |
}
|
| 40 |
+
throw new Error("All search endpoints failed");
|
| 41 |
}
|
| 42 |
|
| 43 |
const SYSTEM_PROMPT =
|
server/sessionStore.js
CHANGED
|
@@ -51,16 +51,37 @@ export const sessionStore = {
|
|
| 51 |
},
|
| 52 |
deleteTempSession(t, id) { tempStore.get(t)?.sessions.delete(id); },
|
| 53 |
deleteTempAll(t) { tempStore.get(t)?.sessions.clear(); },
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
async transferTempToUser(tempId, userId, accessToken) {
|
| 55 |
-
const d = tempStore.get(tempId);
|
| 56 |
-
|
|
|
|
|
|
|
| 57 |
const user = this._ensureUser(userId);
|
|
|
|
| 58 |
for (const s of d.sessions.values()) {
|
| 59 |
-
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
}
|
| 62 |
-
tempStore.delete(tempId);
|
| 63 |
},
|
|
|
|
| 64 |
// ββ USERS ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 65 |
_ensureUser(uid) {
|
| 66 |
if (!userCache.has(uid)) userCache.set(uid, { sessions: new Map(), online: new Set() });
|
|
@@ -93,28 +114,19 @@ export const sessionStore = {
|
|
| 93 |
return s;
|
| 94 |
},
|
| 95 |
async deleteUserSession(userId, accessToken, id) {
|
| 96 |
-
// β Remove the .catch() from the query chain
|
| 97 |
-
// β‘ Use try/catch to surface any unexpected errors
|
| 98 |
try {
|
| 99 |
-
// Remove the session from the inβmemory cache
|
| 100 |
userCache.get(userId)?.sessions.delete(id);
|
| 101 |
-
|
| 102 |
-
// Perform the actual DB delete
|
| 103 |
const { error } = await userClient(accessToken)
|
| 104 |
.from('web_sessions')
|
| 105 |
.delete()
|
| 106 |
.eq('id', id)
|
| 107 |
.eq('user_id', userId);
|
| 108 |
-
|
| 109 |
-
if (error) {
|
| 110 |
-
console.error('Supabase delete error:', error.message);
|
| 111 |
-
}
|
| 112 |
} catch (ex) {
|
| 113 |
console.error('Unexpected deleteUserSession error:', ex);
|
| 114 |
}
|
| 115 |
},
|
| 116 |
async deleteAllUserSessions(userId, accessToken) {
|
| 117 |
-
// Clear the inβmemory store first
|
| 118 |
const u = userCache.get(userId);
|
| 119 |
if (u) {
|
| 120 |
u.sessions.clear();
|
|
@@ -122,17 +134,12 @@ export const sessionStore = {
|
|
| 122 |
console.log('No user for ' + userId);
|
| 123 |
return null;
|
| 124 |
}
|
| 125 |
-
|
| 126 |
-
// Delete everything from Supabase
|
| 127 |
try {
|
| 128 |
const { error } = await userClient(accessToken)
|
| 129 |
.from('web_sessions')
|
| 130 |
.delete()
|
| 131 |
.eq('user_id', userId);
|
| 132 |
-
|
| 133 |
-
if (error) {
|
| 134 |
-
console.error('Supabase bulk delete error:', error.message);
|
| 135 |
-
}
|
| 136 |
} catch (ex) {
|
| 137 |
console.error('Unexpected deleteAllUserSessions error:', ex);
|
| 138 |
}
|
|
@@ -190,4 +197,4 @@ export const deviceSessionStore = {
|
|
| 190 |
const s = devSessions.get(token); if (!s || !s.active) return null;
|
| 191 |
s.lastSeen = new Date().toISOString(); return s;
|
| 192 |
},
|
| 193 |
-
};
|
|
|
|
| 51 |
},
|
| 52 |
deleteTempSession(t, id) { tempStore.get(t)?.sessions.delete(id); },
|
| 53 |
deleteTempAll(t) { tempStore.get(t)?.sessions.clear(); },
|
| 54 |
+
|
| 55 |
+
/**
|
| 56 |
+
* Copy temp sessions into the user's account on login.
|
| 57 |
+
* We intentionally do NOT delete from tempStore so the guest session
|
| 58 |
+
* remains usable if the user logs out again (and so the WS client's
|
| 59 |
+
* tempId still resolves while the tab is open).
|
| 60 |
+
* Sessions that already exist in the user account (same id) are skipped
|
| 61 |
+
* to avoid overwriting newer server data.
|
| 62 |
+
*/
|
| 63 |
async transferTempToUser(tempId, userId, accessToken) {
|
| 64 |
+
const d = tempStore.get(tempId);
|
| 65 |
+
if (!d || !d.sessions.size) return;
|
| 66 |
+
|
| 67 |
+
const uc = userClient(accessToken);
|
| 68 |
const user = this._ensureUser(userId);
|
| 69 |
+
|
| 70 |
for (const s of d.sessions.values()) {
|
| 71 |
+
// Skip sessions that are empty (never actually used)
|
| 72 |
+
if (!s.history || s.history.length === 0) continue;
|
| 73 |
+
|
| 74 |
+
// Skip if the user already has a session with the same id
|
| 75 |
+
if (user.sessions.has(s.id)) continue;
|
| 76 |
+
|
| 77 |
+
// Deep-clone so mutations to the user copy don't affect temp copy
|
| 78 |
+
const copy = JSON.parse(JSON.stringify(s));
|
| 79 |
+
user.sessions.set(copy.id, copy);
|
| 80 |
+
await this._persist(uc, userId, copy).catch(err =>
|
| 81 |
+
console.error('transferTempToUser persist error:', err.message));
|
| 82 |
}
|
|
|
|
| 83 |
},
|
| 84 |
+
|
| 85 |
// ββ USERS ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 86 |
_ensureUser(uid) {
|
| 87 |
if (!userCache.has(uid)) userCache.set(uid, { sessions: new Map(), online: new Set() });
|
|
|
|
| 114 |
return s;
|
| 115 |
},
|
| 116 |
async deleteUserSession(userId, accessToken, id) {
|
|
|
|
|
|
|
| 117 |
try {
|
|
|
|
| 118 |
userCache.get(userId)?.sessions.delete(id);
|
|
|
|
|
|
|
| 119 |
const { error } = await userClient(accessToken)
|
| 120 |
.from('web_sessions')
|
| 121 |
.delete()
|
| 122 |
.eq('id', id)
|
| 123 |
.eq('user_id', userId);
|
| 124 |
+
if (error) console.error('Supabase delete error:', error.message);
|
|
|
|
|
|
|
|
|
|
| 125 |
} catch (ex) {
|
| 126 |
console.error('Unexpected deleteUserSession error:', ex);
|
| 127 |
}
|
| 128 |
},
|
| 129 |
async deleteAllUserSessions(userId, accessToken) {
|
|
|
|
| 130 |
const u = userCache.get(userId);
|
| 131 |
if (u) {
|
| 132 |
u.sessions.clear();
|
|
|
|
| 134 |
console.log('No user for ' + userId);
|
| 135 |
return null;
|
| 136 |
}
|
|
|
|
|
|
|
| 137 |
try {
|
| 138 |
const { error } = await userClient(accessToken)
|
| 139 |
.from('web_sessions')
|
| 140 |
.delete()
|
| 141 |
.eq('user_id', userId);
|
| 142 |
+
if (error) console.error('Supabase bulk delete error:', error.message);
|
|
|
|
|
|
|
|
|
|
| 143 |
} catch (ex) {
|
| 144 |
console.error('Unexpected deleteAllUserSessions error:', ex);
|
| 145 |
}
|
|
|
|
| 197 |
const s = devSessions.get(token); if (!s || !s.active) return null;
|
| 198 |
s.lastSeen = new Date().toISOString(); return s;
|
| 199 |
},
|
| 200 |
+
};
|