spagestic commited on
Commit
eb5680f
·
1 Parent(s): a95a8b3

Add Gradio API integration and refactor app.js. Introduced gradioPredict function for API calls, enhancing chat functionality and session management.

Browse files
Files changed (2) hide show
  1. assets/app.js +605 -574
  2. assets/gradio_api.js +52 -0
assets/app.js CHANGED
@@ -1,574 +1,605 @@
1
- import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
2
- import { marked } from "https://cdn.jsdelivr.net/npm/marked@15.0.7/lib/marked.esm.js";
3
- import DOMPurify from "https://cdn.jsdelivr.net/npm/dompurify@3.2.4/dist/purify.es.min.js";
4
-
5
- const client = await Client.connect(window.location.origin);
6
-
7
- marked.setOptions({
8
- breaks: true,
9
- gfm: true,
10
- });
11
-
12
- const CHAT_SESSIONS_KEY = "borderless-chat-sessions";
13
-
14
- const REQUIRED_FIELDS = [
15
- "current-country",
16
- "residence-status",
17
- "education",
18
- "occupation",
19
- "experience",
20
- "budget",
21
- "family",
22
- "timeline",
23
- "goals",
24
- ];
25
-
26
- function unwrapResult(result) {
27
- if (result && Array.isArray(result.data)) {
28
- return result.data.length === 1 ? result.data[0] : result.data;
29
- }
30
- return result;
31
- }
32
-
33
- const state = {
34
- sessionId: crypto.randomUUID(),
35
- history: [],
36
- globeState: emptyGlobeState(),
37
- choices: null,
38
- busy: false,
39
- view: "form",
40
- };
41
-
42
- const els = {
43
- formView: document.getElementById("form-view"),
44
- chatView: document.getElementById("chat-view"),
45
- formStatus: document.getElementById("form-status"),
46
- statusBanner: document.getElementById("status-banner"),
47
- chatMessages: document.getElementById("chat-messages"),
48
- chatInput: document.getElementById("chat-input"),
49
- chatSend: document.getElementById("chat-send"),
50
- intakeForm: document.getElementById("intake-form"),
51
- createPrompt: document.getElementById("create-prompt"),
52
- personaList: document.getElementById("persona-list"),
53
- authLogin: document.getElementById("auth-login"),
54
- authLogout: document.getElementById("auth-logout"),
55
- newChat: document.getElementById("new-chat"),
56
- historyOpen: document.getElementById("history-open"),
57
- historyDialog: document.getElementById("history-dialog"),
58
- historyClose: document.getElementById("history-close"),
59
- historyList: document.getElementById("history-list"),
60
- };
61
-
62
- function emptyGlobeState() {
63
- return { version: 0, markers: [], highlights: [], fly_to: null };
64
- }
65
-
66
- function authRedirectTarget() {
67
- return encodeURIComponent(window.location.pathname + window.location.search);
68
- }
69
-
70
- function updateAuthUI({ logged_in: loggedIn, username }) {
71
- const target = authRedirectTarget();
72
- if (loggedIn) {
73
- els.authLogin.hidden = true;
74
- els.authLogout.hidden = false;
75
- els.authLogout.textContent = username ? `Log out (${username})` : "Log out";
76
- els.authLogout.href = `/logout?_target_url=${target}`;
77
- } else {
78
- els.authLogin.hidden = false;
79
- els.authLogout.hidden = true;
80
- els.authLogin.href = `/login/huggingface?_target_url=${target}`;
81
- }
82
- }
83
-
84
- async function loadAuthStatus() {
85
- try {
86
- const response = await fetch("/api/auth/status", { credentials: "include" });
87
- if (!response.ok) {
88
- updateAuthUI({ logged_in: false });
89
- return;
90
- }
91
- updateAuthUI(await response.json());
92
- } catch {
93
- updateAuthUI({ logged_in: false });
94
- }
95
- }
96
-
97
- function activeStatusBanner() {
98
- return state.view === "form" ? els.formStatus : els.statusBanner;
99
- }
100
-
101
- function setStatus(message) {
102
- const banner = activeStatusBanner();
103
- const inactive =
104
- state.view === "form" ? els.statusBanner : els.formStatus;
105
-
106
- if (!message) {
107
- banner.classList.remove("visible");
108
- banner.textContent = "";
109
- inactive.classList.remove("visible");
110
- inactive.textContent = "";
111
- return;
112
- }
113
- banner.textContent = message;
114
- banner.classList.add("visible");
115
- inactive.classList.remove("visible");
116
- inactive.textContent = "";
117
- }
118
-
119
- function showChatView() {
120
- state.view = "chat";
121
- els.formView.classList.remove("is-active");
122
- els.chatView.classList.add("is-active");
123
- setStatus("");
124
- }
125
-
126
- function showFormView() {
127
- state.view = "form";
128
- els.chatView.classList.remove("is-active");
129
- els.formView.classList.add("is-active");
130
- setStatus("");
131
- }
132
-
133
- function resetForm() {
134
- for (const id of REQUIRED_FIELDS) {
135
- const element = document.getElementById(id);
136
- if (element.tagName === "TEXTAREA") {
137
- element.value = "";
138
- } else {
139
- element.value = "";
140
- }
141
- setFieldInvalid(id, false);
142
- }
143
- }
144
-
145
- function sessionTitle(history) {
146
- const firstUserMessage = history.find(
147
- (message) => message.role === "user" && message.content,
148
- );
149
- if (!firstUserMessage) {
150
- return "Untitled chat";
151
- }
152
- const text = String(firstUserMessage.content).trim().replace(/\s+/g, " ");
153
- return text.length > 72 ? `${text.slice(0, 69)}...` : text;
154
- }
155
-
156
- function formatSessionDate(timestamp) {
157
- return new Date(timestamp).toLocaleString(undefined, {
158
- month: "short",
159
- day: "numeric",
160
- hour: "numeric",
161
- minute: "2-digit",
162
- });
163
- }
164
-
165
- function loadSessions() {
166
- try {
167
- const sessions = JSON.parse(localStorage.getItem(CHAT_SESSIONS_KEY) || "[]");
168
- return Array.isArray(sessions) ? sessions : [];
169
- } catch {
170
- return [];
171
- }
172
- }
173
-
174
- function saveSessions(sessions) {
175
- localStorage.setItem(CHAT_SESSIONS_KEY, JSON.stringify(sessions));
176
- }
177
-
178
- function persistActiveSession() {
179
- if (!state.history.length) {
180
- return;
181
- }
182
-
183
- const sessions = loadSessions();
184
- const now = Date.now();
185
- const payload = {
186
- id: state.sessionId,
187
- title: sessionTitle(state.history),
188
- updatedAt: now,
189
- history: state.history,
190
- globeState: state.globeState,
191
- };
192
-
193
- const index = sessions.findIndex((session) => session.id === state.sessionId);
194
- if (index >= 0) {
195
- sessions[index] = { ...sessions[index], ...payload };
196
- } else {
197
- sessions.unshift({ ...payload, createdAt: now });
198
- }
199
-
200
- sessions.sort((left, right) => right.updatedAt - left.updatedAt);
201
- saveSessions(sessions);
202
- }
203
-
204
- function openHistoryDialog() {
205
- renderHistoryList();
206
- els.historyDialog.hidden = false;
207
- }
208
-
209
- function closeHistoryDialog() {
210
- els.historyDialog.hidden = true;
211
- }
212
-
213
- function renderHistoryList() {
214
- const sessions = loadSessions();
215
- els.historyList.innerHTML = "";
216
-
217
- if (!sessions.length) {
218
- const empty = document.createElement("p");
219
- empty.className = "history-list-empty";
220
- empty.textContent = "No saved chats yet.";
221
- els.historyList.appendChild(empty);
222
- return;
223
- }
224
-
225
- for (const session of sessions) {
226
- const button = document.createElement("button");
227
- button.type = "button";
228
- button.className = "history-item";
229
- if (session.id === state.sessionId) {
230
- button.classList.add("is-active");
231
- }
232
-
233
- const title = document.createElement("span");
234
- title.className = "history-item-title";
235
- title.textContent = session.title || "Untitled chat";
236
-
237
- const meta = document.createElement("span");
238
- meta.className = "history-item-meta";
239
- meta.textContent = formatSessionDate(session.updatedAt || session.createdAt);
240
-
241
- button.appendChild(title);
242
- button.appendChild(meta);
243
- button.addEventListener("click", () => loadSession(session.id));
244
- els.historyList.appendChild(button);
245
- }
246
- }
247
-
248
- function loadSession(sessionId) {
249
- const session = loadSessions().find((entry) => entry.id === sessionId);
250
- if (!session) {
251
- return;
252
- }
253
-
254
- if (state.history.length && state.sessionId !== sessionId) {
255
- persistActiveSession();
256
- }
257
-
258
- state.sessionId = session.id;
259
- state.history = session.history || [];
260
- state.globeState = session.globeState || emptyGlobeState();
261
- els.chatInput.value = "";
262
- renderMessages();
263
- applyGlobeState(state.globeState);
264
- showChatView();
265
- closeHistoryDialog();
266
- }
267
-
268
- function startNewChat() {
269
- if (state.history.length) {
270
- persistActiveSession();
271
- }
272
-
273
- state.sessionId = crypto.randomUUID();
274
- state.history = [];
275
- state.globeState = emptyGlobeState();
276
- resetForm();
277
- els.chatInput.value = "";
278
- renderMessages();
279
- applyGlobeState(state.globeState);
280
- showFormView();
281
- closeHistoryDialog();
282
- }
283
-
284
- function setBusy(busy) {
285
- state.busy = busy;
286
- els.chatSend.disabled = busy;
287
- els.createPrompt.disabled = busy;
288
- }
289
-
290
- function fillSelect(select, options, { multiple = false, empty = true } = {}) {
291
- select.innerHTML = "";
292
- if (empty) {
293
- const option = document.createElement("option");
294
- option.value = "";
295
- option.textContent = multiple ? "Select one or more" : "Select one";
296
- select.appendChild(option);
297
- }
298
- for (const value of options) {
299
- const option = document.createElement("option");
300
- option.value = value;
301
- option.textContent = value;
302
- select.appendChild(option);
303
- }
304
- select.multiple = multiple;
305
- }
306
-
307
- function selectedValues(select) {
308
- if (select.multiple) {
309
- return Array.from(select.selectedOptions)
310
- .map((option) => option.value)
311
- .filter(Boolean);
312
- }
313
- const value = select.value;
314
- return value || null;
315
- }
316
-
317
- function setSelectValue(id, value) {
318
- const select = document.getElementById(id);
319
- const text = String(value || "").trim();
320
- if (!text) {
321
- select.value = "";
322
- return;
323
- }
324
-
325
- let option = Array.from(select.options).find((entry) => entry.value === text);
326
- if (!option) {
327
- option = document.createElement("option");
328
- option.value = text;
329
- option.textContent = text;
330
- select.appendChild(option);
331
- }
332
- select.value = text;
333
- setFieldInvalid(id, false);
334
- }
335
-
336
- function fillPersonaForm(persona) {
337
- const profile = persona.profile || {};
338
- setSelectValue("current-country", profile.current_country);
339
- setSelectValue("residence-status", profile.residence_status);
340
- setSelectValue("education", profile.education);
341
- setSelectValue("occupation", profile.occupation);
342
- setSelectValue("experience", profile.experience);
343
- setSelectValue("budget", profile.budget);
344
- setSelectValue("family", profile.family);
345
- setSelectValue("timeline", profile.timeline);
346
- document.getElementById("goals").value = String(profile.goals || "").trim();
347
- setFieldInvalid("goals", false);
348
- setStatus("");
349
- }
350
-
351
- function fieldValue(id) {
352
- const element = document.getElementById(id);
353
- if (element.tagName === "TEXTAREA") {
354
- return element.value.trim();
355
- }
356
- return selectedValues(element);
357
- }
358
-
359
- function setFieldInvalid(id, invalid) {
360
- const element = document.getElementById(id);
361
- element.classList.toggle("invalid", invalid);
362
- }
363
-
364
- function validateForm() {
365
- let valid = true;
366
- for (const id of REQUIRED_FIELDS) {
367
- const empty = !fieldValue(id);
368
- setFieldInvalid(id, empty);
369
- if (empty) {
370
- valid = false;
371
- }
372
- }
373
- if (!valid) {
374
- setStatus("Please fill in all required fields before submitting.");
375
- }
376
- return valid;
377
- }
378
-
379
- function clearFieldValidation() {
380
- for (const id of REQUIRED_FIELDS) {
381
- setFieldInvalid(id, false);
382
- }
383
- }
384
-
385
- function shouldRenderMarkdown(message, isTool) {
386
- return message.role === "assistant" && !isTool;
387
- }
388
-
389
- function renderMessageBody(body, message, isTool) {
390
- const content = message.content || "";
391
- body.className = "chat-message-body";
392
-
393
- if (!shouldRenderMarkdown(message, isTool)) {
394
- body.textContent = content;
395
- return;
396
- }
397
-
398
- body.classList.add("markdown-body");
399
- const html = marked.parse(content);
400
- body.innerHTML = DOMPurify.sanitize(html, {
401
- USE_PROFILES: { html: true },
402
- });
403
-
404
- for (const link of body.querySelectorAll("a")) {
405
- link.target = "_blank";
406
- link.rel = "noopener noreferrer";
407
- }
408
- }
409
-
410
- function renderMessages() {
411
- els.chatMessages.innerHTML = "";
412
- for (const message of state.history) {
413
- const node = document.createElement("div");
414
- const metadata = message.metadata || {};
415
- const isTool = Boolean(metadata.title || metadata.status);
416
- node.className = `chat-message ${message.role}${isTool ? " tool" : ""}`;
417
- if (metadata.status === "pending") {
418
- node.classList.add("pending");
419
- }
420
- if (isTool) {
421
- const title = document.createElement("div");
422
- title.className = "tool-title";
423
- title.textContent = metadata.title || "Tool";
424
- node.appendChild(title);
425
- }
426
- const body = document.createElement("div");
427
- renderMessageBody(body, message, isTool);
428
- node.appendChild(body);
429
- els.chatMessages.appendChild(node);
430
- }
431
- els.chatMessages.scrollTop = els.chatMessages.scrollHeight;
432
- }
433
-
434
- function applyGlobeState(globeState) {
435
- state.globeState = globeState;
436
- window.BorderlessGlobe?.applyState(globeState);
437
- }
438
-
439
- function formPayload() {
440
- return {
441
- current_country: selectedValues(document.getElementById("current-country")),
442
- residence_status: selectedValues(document.getElementById("residence-status")),
443
- education: selectedValues(document.getElementById("education")),
444
- occupation: selectedValues(document.getElementById("occupation")),
445
- experience: selectedValues(document.getElementById("experience")),
446
- budget: selectedValues(document.getElementById("budget")),
447
- family: selectedValues(document.getElementById("family")),
448
- timeline: selectedValues(document.getElementById("timeline")),
449
- goals: document.getElementById("goals").value.trim(),
450
- };
451
- }
452
-
453
- async function loadChoices() {
454
- const result = await client.predict("/get_intake_choices", {});
455
- const choices = unwrapResult(result);
456
- state.choices = choices;
457
-
458
- fillSelect(document.getElementById("current-country"), choices.countries);
459
- fillSelect(document.getElementById("residence-status"), choices.residence_status);
460
- fillSelect(document.getElementById("education"), choices.education);
461
- fillSelect(document.getElementById("occupation"), choices.occupation);
462
- fillSelect(document.getElementById("experience"), choices.experience);
463
- fillSelect(document.getElementById("budget"), choices.budget);
464
- fillSelect(document.getElementById("family"), choices.family);
465
- fillSelect(document.getElementById("timeline"), choices.timeline);
466
-
467
- els.personaList.innerHTML = "";
468
- for (const persona of choices.personas || []) {
469
- const button = document.createElement("button");
470
- button.type = "button";
471
- button.textContent = persona.label;
472
- button.addEventListener("click", () => fillPersonaForm(persona));
473
- els.personaList.appendChild(button);
474
- }
475
- }
476
-
477
- async function runChat(message) {
478
- const result = await client.predict("/chat", {
479
- message,
480
- history: state.history,
481
- globe_state: state.globeState,
482
- });
483
- const payload = unwrapResult(result);
484
- state.history = payload.history || state.history;
485
- renderMessages();
486
- if (payload.globe_state) {
487
- applyGlobeState(payload.globe_state);
488
- }
489
- persistActiveSession();
490
- }
491
-
492
- async function sendChatMessage(message) {
493
- setBusy(true);
494
- setStatus("Researching pathways...");
495
- try {
496
- await runChat(message);
497
- setStatus("");
498
- } catch (error) {
499
- setStatus(`Chat failed: ${error.message || error}`);
500
- throw error;
501
- } finally {
502
- setBusy(false);
503
- }
504
- }
505
-
506
- async function submitForm(event) {
507
- event.preventDefault();
508
- if (state.busy || !validateForm()) {
509
- return;
510
- }
511
-
512
- showChatView();
513
- setBusy(true);
514
- setStatus("Building research prompt...");
515
- try {
516
- const result = await client.predict("/build_research_prompt", formPayload());
517
- const message = unwrapResult(result) || "";
518
- if (!message) {
519
- setStatus("Could not build prompt.");
520
- return;
521
- }
522
- clearFieldValidation();
523
- setStatus("Researching pathways...");
524
- await runChat(message);
525
- setStatus("");
526
- } catch (error) {
527
- setStatus(`Submission failed: ${error.message || error}`);
528
- } finally {
529
- setBusy(false);
530
- }
531
- }
532
-
533
- async function sendChat() {
534
- const message = els.chatInput.value.trim();
535
- if (!message || state.busy) {
536
- return;
537
- }
538
-
539
- els.chatInput.value = "";
540
- try {
541
- await sendChatMessage(message);
542
- } catch {
543
- els.chatInput.value = message;
544
- }
545
- }
546
-
547
- for (const id of REQUIRED_FIELDS) {
548
- const element = document.getElementById(id);
549
- element.addEventListener("input", () => setFieldInvalid(id, false));
550
- element.addEventListener("change", () => setFieldInvalid(id, false));
551
- }
552
-
553
- els.intakeForm.addEventListener("submit", submitForm);
554
- els.chatSend.addEventListener("click", sendChat);
555
- els.newChat.addEventListener("click", startNewChat);
556
- els.historyOpen.addEventListener("click", openHistoryDialog);
557
- els.historyClose.addEventListener("click", closeHistoryDialog);
558
- els.historyDialog
559
- .querySelector("[data-history-close]")
560
- .addEventListener("click", closeHistoryDialog);
561
- document.addEventListener("keydown", (event) => {
562
- if (event.key === "Escape" && !els.historyDialog.hidden) {
563
- closeHistoryDialog();
564
- }
565
- });
566
- els.chatInput.addEventListener("keydown", (event) => {
567
- if (event.key === "Enter" && !event.shiftKey) {
568
- event.preventDefault();
569
- sendChat();
570
- }
571
- });
572
-
573
- await Promise.all([loadChoices(), loadAuthStatus()]);
574
- renderMessages();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { gradioPredict } from "/assets/gradio_api.js?v=2";
2
+
3
+ let markdownTools = null;
4
+
5
+ async function ensureMarkdownTools() {
6
+ if (markdownTools) {
7
+ return markdownTools;
8
+ }
9
+ const [{ marked }, { default: DOMPurify }] = await Promise.all([
10
+ import("https://cdn.jsdelivr.net/npm/marked@15.0.7/lib/marked.esm.js"),
11
+ import("https://cdn.jsdelivr.net/npm/dompurify@3.2.4/dist/purify.es.min.js"),
12
+ ]);
13
+ marked.setOptions({
14
+ breaks: true,
15
+ gfm: true,
16
+ });
17
+ markdownTools = { marked, DOMPurify };
18
+ return markdownTools;
19
+ }
20
+
21
+ const CHAT_SESSIONS_KEY = "borderless-chat-sessions";
22
+
23
+ const REQUIRED_FIELDS = [
24
+ "current-country",
25
+ "residence-status",
26
+ "education",
27
+ "occupation",
28
+ "experience",
29
+ "budget",
30
+ "family",
31
+ "timeline",
32
+ "goals",
33
+ ];
34
+
35
+ function unwrapResult(result) {
36
+ if (result && Array.isArray(result.data)) {
37
+ return result.data.length === 1 ? result.data[0] : result.data;
38
+ }
39
+ return result;
40
+ }
41
+
42
+ const state = {
43
+ sessionId: crypto.randomUUID(),
44
+ history: [],
45
+ globeState: emptyGlobeState(),
46
+ choices: null,
47
+ busy: false,
48
+ view: "form",
49
+ };
50
+
51
+ const els = {
52
+ formView: document.getElementById("form-view"),
53
+ chatView: document.getElementById("chat-view"),
54
+ formStatus: document.getElementById("form-status"),
55
+ statusBanner: document.getElementById("status-banner"),
56
+ chatMessages: document.getElementById("chat-messages"),
57
+ chatInput: document.getElementById("chat-input"),
58
+ chatSend: document.getElementById("chat-send"),
59
+ intakeForm: document.getElementById("intake-form"),
60
+ createPrompt: document.getElementById("create-prompt"),
61
+ personaList: document.getElementById("persona-list"),
62
+ authLogin: document.getElementById("auth-login"),
63
+ authLogout: document.getElementById("auth-logout"),
64
+ newChat: document.getElementById("new-chat"),
65
+ historyOpen: document.getElementById("history-open"),
66
+ historyDialog: document.getElementById("history-dialog"),
67
+ historyClose: document.getElementById("history-close"),
68
+ historyList: document.getElementById("history-list"),
69
+ };
70
+
71
+ function emptyGlobeState() {
72
+ return { version: 0, markers: [], highlights: [], fly_to: null };
73
+ }
74
+
75
+ function authRedirectTarget() {
76
+ return encodeURIComponent(window.location.pathname + window.location.search);
77
+ }
78
+
79
+ function updateAuthUI({ logged_in: loggedIn, username }) {
80
+ const target = authRedirectTarget();
81
+ if (loggedIn) {
82
+ els.authLogin.hidden = true;
83
+ els.authLogout.hidden = false;
84
+ els.authLogout.textContent = username ? `Log out (${username})` : "Log out";
85
+ els.authLogout.href = `/logout?_target_url=${target}`;
86
+ } else {
87
+ els.authLogin.hidden = false;
88
+ els.authLogout.hidden = true;
89
+ els.authLogin.href = `/login/huggingface?_target_url=${target}`;
90
+ }
91
+ }
92
+
93
+ async function loadAuthStatus() {
94
+ try {
95
+ const response = await fetch("/api/auth/status", { credentials: "include" });
96
+ if (!response.ok) {
97
+ updateAuthUI({ logged_in: false });
98
+ return;
99
+ }
100
+ updateAuthUI(await response.json());
101
+ } catch {
102
+ updateAuthUI({ logged_in: false });
103
+ }
104
+ }
105
+
106
+ function activeStatusBanner() {
107
+ return state.view === "form" ? els.formStatus : els.statusBanner;
108
+ }
109
+
110
+ function setStatus(message) {
111
+ const banner = activeStatusBanner();
112
+ const inactive =
113
+ state.view === "form" ? els.statusBanner : els.formStatus;
114
+
115
+ if (!message) {
116
+ banner.classList.remove("visible");
117
+ banner.textContent = "";
118
+ inactive.classList.remove("visible");
119
+ inactive.textContent = "";
120
+ return;
121
+ }
122
+ banner.textContent = message;
123
+ banner.classList.add("visible");
124
+ inactive.classList.remove("visible");
125
+ inactive.textContent = "";
126
+ }
127
+
128
+ function showChatView() {
129
+ state.view = "chat";
130
+ els.formView.classList.remove("is-active");
131
+ els.chatView.classList.add("is-active");
132
+ setStatus("");
133
+ }
134
+
135
+ function showFormView() {
136
+ state.view = "form";
137
+ els.chatView.classList.remove("is-active");
138
+ els.formView.classList.add("is-active");
139
+ setStatus("");
140
+ }
141
+
142
+ function resetForm() {
143
+ for (const id of REQUIRED_FIELDS) {
144
+ const element = document.getElementById(id);
145
+ if (element.tagName === "TEXTAREA") {
146
+ element.value = "";
147
+ } else {
148
+ element.value = "";
149
+ }
150
+ setFieldInvalid(id, false);
151
+ }
152
+ }
153
+
154
+ function sessionTitle(history) {
155
+ const firstUserMessage = history.find(
156
+ (message) => message.role === "user" && message.content,
157
+ );
158
+ if (!firstUserMessage) {
159
+ return "Untitled chat";
160
+ }
161
+ const text = String(firstUserMessage.content).trim().replace(/\s+/g, " ");
162
+ return text.length > 72 ? `${text.slice(0, 69)}...` : text;
163
+ }
164
+
165
+ function formatSessionDate(timestamp) {
166
+ return new Date(timestamp).toLocaleString(undefined, {
167
+ month: "short",
168
+ day: "numeric",
169
+ hour: "numeric",
170
+ minute: "2-digit",
171
+ });
172
+ }
173
+
174
+ function loadSessions() {
175
+ try {
176
+ const sessions = JSON.parse(localStorage.getItem(CHAT_SESSIONS_KEY) || "[]");
177
+ return Array.isArray(sessions) ? sessions : [];
178
+ } catch {
179
+ return [];
180
+ }
181
+ }
182
+
183
+ function saveSessions(sessions) {
184
+ localStorage.setItem(CHAT_SESSIONS_KEY, JSON.stringify(sessions));
185
+ }
186
+
187
+ function persistActiveSession() {
188
+ if (!state.history.length) {
189
+ return;
190
+ }
191
+
192
+ const sessions = loadSessions();
193
+ const now = Date.now();
194
+ const payload = {
195
+ id: state.sessionId,
196
+ title: sessionTitle(state.history),
197
+ updatedAt: now,
198
+ history: state.history,
199
+ globeState: state.globeState,
200
+ };
201
+
202
+ const index = sessions.findIndex((session) => session.id === state.sessionId);
203
+ if (index >= 0) {
204
+ sessions[index] = { ...sessions[index], ...payload };
205
+ } else {
206
+ sessions.unshift({ ...payload, createdAt: now });
207
+ }
208
+
209
+ sessions.sort((left, right) => right.updatedAt - left.updatedAt);
210
+ saveSessions(sessions);
211
+ }
212
+
213
+ function openHistoryDialog() {
214
+ renderHistoryList();
215
+ els.historyDialog.hidden = false;
216
+ }
217
+
218
+ function closeHistoryDialog() {
219
+ els.historyDialog.hidden = true;
220
+ }
221
+
222
+ function renderHistoryList() {
223
+ const sessions = loadSessions();
224
+ els.historyList.innerHTML = "";
225
+
226
+ if (!sessions.length) {
227
+ const empty = document.createElement("p");
228
+ empty.className = "history-list-empty";
229
+ empty.textContent = "No saved chats yet.";
230
+ els.historyList.appendChild(empty);
231
+ return;
232
+ }
233
+
234
+ for (const session of sessions) {
235
+ const button = document.createElement("button");
236
+ button.type = "button";
237
+ button.className = "history-item";
238
+ if (session.id === state.sessionId) {
239
+ button.classList.add("is-active");
240
+ }
241
+
242
+ const title = document.createElement("span");
243
+ title.className = "history-item-title";
244
+ title.textContent = session.title || "Untitled chat";
245
+
246
+ const meta = document.createElement("span");
247
+ meta.className = "history-item-meta";
248
+ meta.textContent = formatSessionDate(session.updatedAt || session.createdAt);
249
+
250
+ button.appendChild(title);
251
+ button.appendChild(meta);
252
+ button.addEventListener("click", () => loadSession(session.id));
253
+ els.historyList.appendChild(button);
254
+ }
255
+ }
256
+
257
+ function loadSession(sessionId) {
258
+ const session = loadSessions().find((entry) => entry.id === sessionId);
259
+ if (!session) {
260
+ return;
261
+ }
262
+
263
+ if (state.history.length && state.sessionId !== sessionId) {
264
+ persistActiveSession();
265
+ }
266
+
267
+ state.sessionId = session.id;
268
+ state.history = session.history || [];
269
+ state.globeState = session.globeState || emptyGlobeState();
270
+ els.chatInput.value = "";
271
+ renderMessages();
272
+ applyGlobeState(state.globeState);
273
+ showChatView();
274
+ closeHistoryDialog();
275
+ }
276
+
277
+ function startNewChat() {
278
+ if (state.history.length) {
279
+ persistActiveSession();
280
+ }
281
+
282
+ state.sessionId = crypto.randomUUID();
283
+ state.history = [];
284
+ state.globeState = emptyGlobeState();
285
+ resetForm();
286
+ els.chatInput.value = "";
287
+ renderMessages();
288
+ applyGlobeState(state.globeState);
289
+ showFormView();
290
+ closeHistoryDialog();
291
+ }
292
+
293
+ function setBusy(busy) {
294
+ state.busy = busy;
295
+ els.chatSend.disabled = busy;
296
+ els.createPrompt.disabled = busy;
297
+ }
298
+
299
+ function fillSelect(select, options, { multiple = false, empty = true } = {}) {
300
+ select.innerHTML = "";
301
+ if (empty) {
302
+ const option = document.createElement("option");
303
+ option.value = "";
304
+ option.textContent = multiple ? "Select one or more" : "Select one";
305
+ select.appendChild(option);
306
+ }
307
+ for (const value of options) {
308
+ const option = document.createElement("option");
309
+ option.value = value;
310
+ option.textContent = value;
311
+ select.appendChild(option);
312
+ }
313
+ select.multiple = multiple;
314
+ }
315
+
316
+ function selectedValues(select) {
317
+ if (select.multiple) {
318
+ return Array.from(select.selectedOptions)
319
+ .map((option) => option.value)
320
+ .filter(Boolean);
321
+ }
322
+ const value = select.value;
323
+ return value || null;
324
+ }
325
+
326
+ function setSelectValue(id, value) {
327
+ const select = document.getElementById(id);
328
+ const text = String(value || "").trim();
329
+ if (!text) {
330
+ select.value = "";
331
+ return;
332
+ }
333
+
334
+ let option = Array.from(select.options).find((entry) => entry.value === text);
335
+ if (!option) {
336
+ option = document.createElement("option");
337
+ option.value = text;
338
+ option.textContent = text;
339
+ select.appendChild(option);
340
+ }
341
+ select.value = text;
342
+ setFieldInvalid(id, false);
343
+ }
344
+
345
+ function fillPersonaForm(persona) {
346
+ const profile = persona.profile || {};
347
+ setSelectValue("current-country", profile.current_country);
348
+ setSelectValue("residence-status", profile.residence_status);
349
+ setSelectValue("education", profile.education);
350
+ setSelectValue("occupation", profile.occupation);
351
+ setSelectValue("experience", profile.experience);
352
+ setSelectValue("budget", profile.budget);
353
+ setSelectValue("family", profile.family);
354
+ setSelectValue("timeline", profile.timeline);
355
+ document.getElementById("goals").value = String(profile.goals || "").trim();
356
+ setFieldInvalid("goals", false);
357
+ setStatus("");
358
+ }
359
+
360
+ function fieldValue(id) {
361
+ const element = document.getElementById(id);
362
+ if (element.tagName === "TEXTAREA") {
363
+ return element.value.trim();
364
+ }
365
+ return selectedValues(element);
366
+ }
367
+
368
+ function setFieldInvalid(id, invalid) {
369
+ const element = document.getElementById(id);
370
+ element.classList.toggle("invalid", invalid);
371
+ }
372
+
373
+ function validateForm() {
374
+ let valid = true;
375
+ for (const id of REQUIRED_FIELDS) {
376
+ const empty = !fieldValue(id);
377
+ setFieldInvalid(id, empty);
378
+ if (empty) {
379
+ valid = false;
380
+ }
381
+ }
382
+ if (!valid) {
383
+ setStatus("Please fill in all required fields before submitting.");
384
+ }
385
+ return valid;
386
+ }
387
+
388
+ function clearFieldValidation() {
389
+ for (const id of REQUIRED_FIELDS) {
390
+ setFieldInvalid(id, false);
391
+ }
392
+ }
393
+
394
+ function shouldRenderMarkdown(message, isTool) {
395
+ return message.role === "assistant" && !isTool;
396
+ }
397
+
398
+ async function renderMessageBody(body, message, isTool) {
399
+ const content = message.content || "";
400
+ body.className = "chat-message-body";
401
+
402
+ if (!shouldRenderMarkdown(message, isTool)) {
403
+ body.textContent = content;
404
+ return;
405
+ }
406
+
407
+ body.classList.add("markdown-body");
408
+ try {
409
+ const { marked, DOMPurify } = await ensureMarkdownTools();
410
+ const html = marked.parse(content);
411
+ body.innerHTML = DOMPurify.sanitize(html, {
412
+ USE_PROFILES: { html: true },
413
+ });
414
+ for (const link of body.querySelectorAll("a")) {
415
+ link.target = "_blank";
416
+ link.rel = "noopener noreferrer";
417
+ }
418
+ } catch {
419
+ body.textContent = content;
420
+ }
421
+ }
422
+
423
+ async function renderMessages() {
424
+ els.chatMessages.innerHTML = "";
425
+ for (const message of state.history) {
426
+ const node = document.createElement("div");
427
+ const metadata = message.metadata || {};
428
+ const isTool = Boolean(metadata.title || metadata.status);
429
+ node.className = `chat-message ${message.role}${isTool ? " tool" : ""}`;
430
+ if (metadata.status === "pending") {
431
+ node.classList.add("pending");
432
+ }
433
+ if (isTool) {
434
+ const title = document.createElement("div");
435
+ title.className = "tool-title";
436
+ title.textContent = metadata.title || "Tool";
437
+ node.appendChild(title);
438
+ }
439
+ const body = document.createElement("div");
440
+ await renderMessageBody(body, message, isTool);
441
+ node.appendChild(body);
442
+ els.chatMessages.appendChild(node);
443
+ }
444
+ els.chatMessages.scrollTop = els.chatMessages.scrollHeight;
445
+ }
446
+
447
+ function applyGlobeState(globeState) {
448
+ state.globeState = globeState;
449
+ window.BorderlessGlobe?.applyState(globeState);
450
+ }
451
+
452
+ function formPayload() {
453
+ return {
454
+ current_country: selectedValues(document.getElementById("current-country")),
455
+ residence_status: selectedValues(document.getElementById("residence-status")),
456
+ education: selectedValues(document.getElementById("education")),
457
+ occupation: selectedValues(document.getElementById("occupation")),
458
+ experience: selectedValues(document.getElementById("experience")),
459
+ budget: selectedValues(document.getElementById("budget")),
460
+ family: selectedValues(document.getElementById("family")),
461
+ timeline: selectedValues(document.getElementById("timeline")),
462
+ goals: document.getElementById("goals").value.trim(),
463
+ };
464
+ }
465
+
466
+ async function loadChoices() {
467
+ let choices = null;
468
+
469
+ try {
470
+ const response = await fetch("/api/intake_choices", { credentials: "include" });
471
+ if (!response.ok) {
472
+ throw new Error(`HTTP ${response.status}`);
473
+ }
474
+ choices = await response.json();
475
+ } catch (restError) {
476
+ const result = await gradioPredict("/get_intake_choices", {});
477
+ choices = unwrapResult(result);
478
+ }
479
+
480
+ state.choices = choices;
481
+
482
+ fillSelect(document.getElementById("current-country"), choices.countries);
483
+ fillSelect(document.getElementById("residence-status"), choices.residence_status);
484
+ fillSelect(document.getElementById("education"), choices.education);
485
+ fillSelect(document.getElementById("occupation"), choices.occupation);
486
+ fillSelect(document.getElementById("experience"), choices.experience);
487
+ fillSelect(document.getElementById("budget"), choices.budget);
488
+ fillSelect(document.getElementById("family"), choices.family);
489
+ fillSelect(document.getElementById("timeline"), choices.timeline);
490
+
491
+ els.personaList.innerHTML = "";
492
+ for (const persona of choices.personas || []) {
493
+ const button = document.createElement("button");
494
+ button.type = "button";
495
+ button.textContent = persona.label;
496
+ button.addEventListener("click", () => fillPersonaForm(persona));
497
+ els.personaList.appendChild(button);
498
+ }
499
+ }
500
+
501
+ async function runChat(message) {
502
+ const result = await gradioPredict("/chat", {
503
+ message,
504
+ history: state.history,
505
+ globe_state: state.globeState,
506
+ });
507
+ const payload = unwrapResult(result);
508
+ state.history = payload.history || state.history;
509
+ await renderMessages();
510
+ if (payload.globe_state) {
511
+ applyGlobeState(payload.globe_state);
512
+ }
513
+ persistActiveSession();
514
+ }
515
+
516
+ async function sendChatMessage(message) {
517
+ setBusy(true);
518
+ setStatus("Researching pathways...");
519
+ try {
520
+ await runChat(message);
521
+ setStatus("");
522
+ } catch (error) {
523
+ setStatus(`Chat failed: ${error.message || error}`);
524
+ throw error;
525
+ } finally {
526
+ setBusy(false);
527
+ }
528
+ }
529
+
530
+ async function submitForm(event) {
531
+ event.preventDefault();
532
+ if (state.busy || !validateForm()) {
533
+ return;
534
+ }
535
+
536
+ showChatView();
537
+ setBusy(true);
538
+ setStatus("Building research prompt...");
539
+ try {
540
+ const result = await gradioPredict("/build_research_prompt", formPayload());
541
+ const message = unwrapResult(result) || "";
542
+ if (!message) {
543
+ setStatus("Could not build prompt.");
544
+ return;
545
+ }
546
+ clearFieldValidation();
547
+ setStatus("Researching pathways...");
548
+ await runChat(message);
549
+ setStatus("");
550
+ } catch (error) {
551
+ setStatus(`Submission failed: ${error.message || error}`);
552
+ } finally {
553
+ setBusy(false);
554
+ }
555
+ }
556
+
557
+ async function sendChat() {
558
+ const message = els.chatInput.value.trim();
559
+ if (!message || state.busy) {
560
+ return;
561
+ }
562
+
563
+ els.chatInput.value = "";
564
+ try {
565
+ await sendChatMessage(message);
566
+ } catch {
567
+ els.chatInput.value = message;
568
+ }
569
+ }
570
+
571
+ for (const id of REQUIRED_FIELDS) {
572
+ const element = document.getElementById(id);
573
+ element.addEventListener("input", () => setFieldInvalid(id, false));
574
+ element.addEventListener("change", () => setFieldInvalid(id, false));
575
+ }
576
+
577
+ els.intakeForm.addEventListener("submit", submitForm);
578
+ els.chatSend.addEventListener("click", sendChat);
579
+ els.newChat.addEventListener("click", startNewChat);
580
+ els.historyOpen.addEventListener("click", openHistoryDialog);
581
+ els.historyClose.addEventListener("click", closeHistoryDialog);
582
+ els.historyDialog
583
+ .querySelector("[data-history-close]")
584
+ .addEventListener("click", closeHistoryDialog);
585
+ document.addEventListener("keydown", (event) => {
586
+ if (event.key === "Escape" && !els.historyDialog.hidden) {
587
+ closeHistoryDialog();
588
+ }
589
+ });
590
+ els.chatInput.addEventListener("keydown", (event) => {
591
+ if (event.key === "Enter" && !event.shiftKey) {
592
+ event.preventDefault();
593
+ sendChat();
594
+ }
595
+ });
596
+
597
+ await loadAuthStatus();
598
+
599
+ try {
600
+ await loadChoices();
601
+ } catch (error) {
602
+ setStatus(`Could not load form options: ${error.message || error}`);
603
+ }
604
+
605
+ await renderMessages();
assets/gradio_api.js ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export async function gradioPredict(apiName, payload = {}) {
2
+ const route = apiName.replace(/^\//, "");
3
+ const start = await fetch(`/gradio_api/call/v2/${route}`, {
4
+ method: "POST",
5
+ headers: { "Content-Type": "application/json" },
6
+ body: JSON.stringify(payload),
7
+ credentials: "include",
8
+ });
9
+ if (!start.ok) {
10
+ throw new Error(`Gradio API failed to start (${start.status})`);
11
+ }
12
+
13
+ const { event_id: eventId } = await start.json();
14
+ if (!eventId) {
15
+ throw new Error("Gradio API did not return an event id");
16
+ }
17
+
18
+ const stream = await fetch(`/gradio_api/call/${route}/${eventId}`, {
19
+ credentials: "include",
20
+ });
21
+ if (!stream.ok) {
22
+ throw new Error(`Gradio API stream failed (${stream.status})`);
23
+ }
24
+
25
+ const body = await stream.text();
26
+ let resultData = null;
27
+ let errorMessage = null;
28
+
29
+ for (const line of body.split("\n")) {
30
+ if (line.startsWith("event: error")) {
31
+ errorMessage = "Gradio API returned an error";
32
+ continue;
33
+ }
34
+ if (!line.startsWith("data: ")) {
35
+ continue;
36
+ }
37
+ try {
38
+ resultData = JSON.parse(line.slice(6));
39
+ } catch {
40
+ // Ignore malformed SSE chunks and keep the last valid payload.
41
+ }
42
+ }
43
+
44
+ if (errorMessage) {
45
+ throw new Error(errorMessage);
46
+ }
47
+ if (resultData === null) {
48
+ throw new Error("Gradio API returned no data");
49
+ }
50
+
51
+ return { data: resultData };
52
+ }