import { gradioPredict, gradioStream } from "/assets/gradio_api.js?v=2"; import { ensureMarkdownTools, renderMarkdownInto, splitThinkingContent, } from "/assets/markdown.js?v=2"; const CHAT_SESSIONS_KEY = "borderless-chat-sessions"; const REQUIRED_FIELDS = [ "current-country", "residence-status", "education", "occupation", "experience", "budget", "family", "timeline", "goals", ]; function unwrapResult(result) { if (result && Array.isArray(result.data)) { return result.data.length === 1 ? result.data[0] : result.data; } return result; } function formatAgentError(error) { const raw = error?.message || String(error || ""); let text = raw; if (raw.startsWith("{")) { try { const parsed = JSON.parse(raw); if (typeof parsed?.error === "string") { text = parsed.error; } } catch { // Keep raw message when payload is not JSON. } } if (/ZeroGPU quota exceeded/i.test(text)) { const resetMatch = text.match(/Try again in ([^.]+)/i); const reset = resetMatch ? resetMatch[1].trim() : null; return [ "ZeroGPU daily quota is too low for this chat run.", reset ? `Quota resets in ${reset}.` : null, "Sign in with Hugging Face for more quota.", "If you own this Space, lower BORDERLESS_GPU_DURATION (try 45–60) in Settings → Secrets.", ] .filter(Boolean) .join(" "); } return text; } const state = { sessionId: crypto.randomUUID(), history: [], globeState: emptyGlobeState(), choices: null, busy: false, view: "form", activeResearchTaskId: null, sessionAutoTitle: null, }; const els = { formView: document.getElementById("form-view"), chatView: document.getElementById("chat-view"), formStatus: document.getElementById("form-status"), statusBanner: document.getElementById("status-banner"), chatMessages: document.getElementById("chat-messages"), chatInput: document.getElementById("chat-input"), chatSend: document.getElementById("chat-send"), intakeForm: document.getElementById("intake-form"), createPrompt: document.getElementById("create-prompt"), personaList: document.getElementById("persona-list"), authLogin: document.getElementById("auth-login"), authLogout: document.getElementById("auth-logout"), newChat: document.getElementById("new-chat"), historyOpen: document.getElementById("history-open"), historyDialog: document.getElementById("history-dialog"), historyClose: document.getElementById("history-close"), historyList: document.getElementById("history-list"), }; function emptyGlobeState() { return { version: 0, details_version: 0, markers: [], highlights: [], fly_to: null, country_details: {}, }; } function authRedirectTarget() { return encodeURIComponent(window.location.pathname + window.location.search); } function updateAuthUI({ logged_in: loggedIn, username }) { const target = authRedirectTarget(); if (loggedIn) { els.authLogin.hidden = true; els.authLogout.hidden = false; els.authLogout.textContent = username ? `Log out (${username})` : "Log out"; els.authLogout.href = `/logout?_target_url=${target}`; } else { els.authLogin.hidden = false; els.authLogout.hidden = true; els.authLogin.href = `/login/huggingface?_target_url=${target}`; } } async function loadAuthStatus() { try { const response = await fetch("/api/auth/status", { credentials: "include" }); if (!response.ok) { updateAuthUI({ logged_in: false }); return; } updateAuthUI(await response.json()); } catch { updateAuthUI({ logged_in: false }); } } function activeStatusBanner() { return state.view === "form" ? els.formStatus : els.statusBanner; } function setStatus(message) { const banner = activeStatusBanner(); const inactive = state.view === "form" ? els.statusBanner : els.formStatus; if (!message) { banner.classList.remove("visible"); banner.textContent = ""; inactive.classList.remove("visible"); inactive.textContent = ""; return; } banner.textContent = message; banner.classList.add("visible"); inactive.classList.remove("visible"); inactive.textContent = ""; } function showChatView() { state.view = "chat"; els.formView.classList.remove("is-active"); els.chatView.classList.add("is-active"); setStatus(""); } function showFormView() { state.view = "form"; els.chatView.classList.remove("is-active"); els.formView.classList.add("is-active"); setStatus(""); } function resetForm() { for (const id of REQUIRED_FIELDS) { const element = document.getElementById(id); if (element.tagName === "TEXTAREA") { element.value = ""; } else { element.value = ""; } setFieldInvalid(id, false); } } function sessionTitle(history) { const firstUserMessage = history.find( (message) => message.role === "user" && message.content, ); if (!firstUserMessage) { return "Untitled chat"; } const text = String(firstUserMessage.content).trim().replace(/\s+/g, " "); return text.length > 72 ? `${text.slice(0, 69)}...` : text; } function formatSessionDate(timestamp) { return new Date(timestamp).toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit", }); } function loadSessions() { try { const sessions = JSON.parse(localStorage.getItem(CHAT_SESSIONS_KEY) || "[]"); return Array.isArray(sessions) ? sessions : []; } catch { return []; } } function saveSessions(sessions) { localStorage.setItem(CHAT_SESSIONS_KEY, JSON.stringify(sessions)); } function persistActiveSession() { if (!state.history.length) { return; } const sessions = loadSessions(); const now = Date.now(); const index = sessions.findIndex((session) => session.id === state.sessionId); const existing = index >= 0 ? sessions[index] : null; const title = existing?.titleManuallySet ? existing.title : existing?.title ?? state.sessionAutoTitle ?? sessionTitle(state.history); const payload = { id: state.sessionId, title, updatedAt: now, history: state.history, globeState: state.globeState, }; if (index >= 0) { sessions[index] = { ...sessions[index], ...payload }; } else { sessions.unshift({ ...payload, createdAt: now }); } sessions.sort((left, right) => right.updatedAt - left.updatedAt); saveSessions(sessions); } function updateSessionTitle(sessionId, title) { const trimmed = String(title || "").trim(); if (!trimmed) { return false; } const sessions = loadSessions(); const index = sessions.findIndex((session) => session.id === sessionId); if (index < 0) { return false; } sessions[index] = { ...sessions[index], title: trimmed, titleManuallySet: true, updatedAt: Date.now(), }; sessions.sort((left, right) => right.updatedAt - left.updatedAt); saveSessions(sessions); if (!els.historyDialog.hidden) { renderHistoryList(); } return true; } function deleteSession(sessionId) { const sessions = loadSessions().filter((session) => session.id !== sessionId); saveSessions(sessions); if (sessionId === state.sessionId) { state.sessionId = crypto.randomUUID(); state.history = []; state.globeState = emptyGlobeState(); state.sessionAutoTitle = null; resetForm(); els.chatInput.value = ""; renderMessages(); applyGlobeState(state.globeState); showFormView(); } renderHistoryList(); } function startSessionRename(session, titleElement) { const previousTitle = session.title || "Untitled chat"; const input = document.createElement("input"); input.type = "text"; input.className = "history-item-rename-input"; input.value = previousTitle; input.setAttribute("aria-label", "Chat title"); titleElement.replaceWith(input); input.focus(); input.select(); let finished = false; function finish(save) { if (finished) { return; } finished = true; if (save) { const newTitle = input.value.trim(); if (newTitle && newTitle !== previousTitle) { if (updateSessionTitle(session.id, newTitle)) { return; } } } renderHistoryList(); } input.addEventListener("keydown", (event) => { event.stopPropagation(); if (event.key === "Enter") { event.preventDefault(); finish(true); } else if (event.key === "Escape") { event.preventDefault(); finish(false); } }); input.addEventListener("blur", () => finish(true)); } function openHistoryDialog() { renderHistoryList(); els.historyDialog.hidden = false; } function closeHistoryDialog() { els.historyDialog.hidden = true; } function renderHistoryList() { const sessions = loadSessions(); els.historyList.innerHTML = ""; if (!sessions.length) { const empty = document.createElement("p"); empty.className = "history-list-empty"; empty.textContent = "No saved chats yet."; els.historyList.appendChild(empty); return; } for (const session of sessions) { const row = document.createElement("div"); row.className = "history-item-row"; if (session.id === state.sessionId) { row.classList.add("is-active"); } const mainButton = document.createElement("button"); mainButton.type = "button"; mainButton.className = "history-item-main"; const title = document.createElement("span"); title.className = "history-item-title"; title.textContent = session.title || "Untitled chat"; const meta = document.createElement("span"); meta.className = "history-item-meta"; meta.textContent = formatSessionDate(session.updatedAt || session.createdAt); mainButton.appendChild(title); mainButton.appendChild(meta); mainButton.addEventListener("click", () => loadSession(session.id)); const actions = document.createElement("div"); actions.className = "history-item-actions"; const renameButton = document.createElement("button"); renameButton.type = "button"; renameButton.className = "history-item-action"; renameButton.textContent = "Rename"; renameButton.setAttribute("aria-label", "Rename chat"); renameButton.addEventListener("click", (event) => { event.stopPropagation(); startSessionRename(session, title); }); const deleteButton = document.createElement("button"); deleteButton.type = "button"; deleteButton.className = "history-item-action history-item-action-delete"; deleteButton.textContent = "Delete"; deleteButton.setAttribute("aria-label", "Delete chat"); deleteButton.addEventListener("click", (event) => { event.stopPropagation(); if (confirm("Delete this chat? This cannot be undone.")) { deleteSession(session.id); } }); actions.appendChild(renameButton); actions.appendChild(deleteButton); row.appendChild(mainButton); row.appendChild(actions); els.historyList.appendChild(row); } } function loadSession(sessionId) { const session = loadSessions().find((entry) => entry.id === sessionId); if (!session) { return; } if (state.history.length && state.sessionId !== sessionId) { persistActiveSession(); } state.sessionId = session.id; state.history = session.history || []; state.globeState = session.globeState || emptyGlobeState(); state.sessionAutoTitle = session.titleManuallySet ? null : session.title || null; els.chatInput.value = ""; renderMessages(); applyGlobeState(state.globeState); showChatView(); closeHistoryDialog(); } function startNewChat() { if (state.history.length) { persistActiveSession(); } state.sessionId = crypto.randomUUID(); state.history = []; state.globeState = emptyGlobeState(); state.sessionAutoTitle = null; resetForm(); els.chatInput.value = ""; renderMessages(); applyGlobeState(state.globeState); showFormView(); closeHistoryDialog(); } function setBusy(busy) { state.busy = busy; els.chatSend.disabled = busy; els.createPrompt.disabled = busy; } function fillSelect(select, options, { multiple = false, empty = true } = {}) { select.innerHTML = ""; if (empty) { const option = document.createElement("option"); option.value = ""; option.textContent = multiple ? "Select one or more" : "Select one"; select.appendChild(option); } for (const value of options) { const option = document.createElement("option"); option.value = value; option.textContent = value; select.appendChild(option); } select.multiple = multiple; } function selectedValues(select) { if (select.multiple) { return Array.from(select.selectedOptions) .map((option) => option.value) .filter(Boolean); } const value = select.value; return value || null; } function setSelectValue(id, value) { const select = document.getElementById(id); const text = String(value || "").trim(); if (!text) { select.value = ""; return; } let option = Array.from(select.options).find((entry) => entry.value === text); if (!option) { option = document.createElement("option"); option.value = text; option.textContent = text; select.appendChild(option); } select.value = text; setFieldInvalid(id, false); } function fillPersonaForm(persona) { const profile = persona.profile || {}; setSelectValue("current-country", profile.current_country); setSelectValue("residence-status", profile.residence_status); setSelectValue("education", profile.education); setSelectValue("occupation", profile.occupation); setSelectValue("experience", profile.experience); setSelectValue("budget", profile.budget); setSelectValue("family", profile.family); setSelectValue("timeline", profile.timeline); document.getElementById("goals").value = String(profile.goals || "").trim(); setFieldInvalid("goals", false); setStatus(""); } function fieldValue(id) { const element = document.getElementById(id); if (element.tagName === "TEXTAREA") { return element.value.trim(); } return selectedValues(element); } function setFieldInvalid(id, invalid) { const element = document.getElementById(id); element.classList.toggle("invalid", invalid); } function validateForm() { let valid = true; for (const id of REQUIRED_FIELDS) { const empty = !fieldValue(id); setFieldInvalid(id, empty); if (empty) { valid = false; } } if (!valid) { setStatus("Please fill in all required fields before submitting."); } return valid; } function clearFieldValidation() { for (const id of REQUIRED_FIELDS) { setFieldInvalid(id, false); } } function shouldRenderMarkdown(message, isTool) { if (message.role !== "assistant") { return false; } const metadata = message.metadata || {}; if (metadata.markdown) { return true; } return !isTool; } async function renderThinkingBlock(parent, thinkingText, options = {}) { const text = String(thinkingText || "").trim(); if (!text) { return null; } const wrapper = document.createElement("div"); wrapper.className = "thinking-block-wrap"; if (options.label) { const label = document.createElement("div"); label.className = "thinking-block-label"; label.textContent = options.label; wrapper.appendChild(label); } const block = document.createElement("div"); block.className = "thinking-block markdown-body"; await renderMarkdownInto(block, text); wrapper.appendChild(block); parent.appendChild(wrapper); return wrapper; } async function renderAssistantContent(parent, message, isTool) { const metadata = message.metadata || {}; const { thinking, answer } = splitThinkingContent(message.content || "", { thinking: metadata.thinking, }); parent.replaceChildren(); if (thinking) { await renderThinkingBlock(parent, thinking, { label: metadata.display === "thinking" ? null : "Thinking", }); } if (answer) { const body = document.createElement("div"); body.className = "chat-message-body"; if (shouldRenderMarkdown(message, isTool)) { body.classList.add("markdown-body"); try { await renderMarkdownInto(body, answer); } catch { body.textContent = answer; } } else { body.textContent = answer; } parent.appendChild(body); return; } if (thinking && metadata.display === "thinking") { return; } if (!thinking && !answer) { const body = document.createElement("div"); body.className = "chat-message-body"; body.textContent = message.content || ""; parent.appendChild(body); } } async function renderMessageBody(body, message, isTool) { await renderAssistantContent(body, message, isTool); } function appendToolLogSection(parent, label, value) { const section = document.createElement("div"); section.className = "tool-log-section"; const heading = document.createElement("strong"); heading.textContent = label; section.appendChild(heading); const pre = document.createElement("pre"); pre.textContent = typeof value === "string" ? value : JSON.stringify(value, null, 2); section.appendChild(pre); parent.appendChild(section); } function appendToolRawLog(parent, metadata) { const log = metadata.log || {}; const hasArguments = log.arguments !== undefined && log.arguments !== null; const hasResult = log.result !== undefined && log.result !== null; if (!hasArguments && !hasResult) { return; } const rawDetails = document.createElement("details"); rawDetails.className = "tool-log-raw"; const rawSummary = document.createElement("summary"); rawSummary.textContent = "Technical details"; rawDetails.appendChild(rawSummary); if (hasArguments) { appendToolLogSection(rawDetails, "Arguments", log.arguments); } if (hasResult) { appendToolLogSection(rawDetails, "Result", log.result); } parent.appendChild(rawDetails); } function toolSummarySuffix(metadata) { if (metadata.status === "pending") { return " (running…)"; } if (metadata.duration) { return ` (${metadata.duration.toFixed(1)}s)`; } return ""; } function resolvePlanTodos(metadata) { if (Array.isArray(metadata.plan_todos) && metadata.plan_todos.length) { return metadata.plan_todos; } const legacy = metadata.log?.result; return Array.isArray(legacy) ? legacy : []; } function renderPlanTodoList(parent, todos) { const list = document.createElement("div"); list.className = "plan-todo-list"; for (const todo of todos) { const item = document.createElement("div"); item.className = "plan-todo-item"; const country = document.createElement("div"); country.className = "plan-todo-country"; country.textContent = todo.country || todo.title || "Country"; item.appendChild(country); const methods = document.createElement("div"); methods.className = "plan-todo-methods"; methods.textContent = todo.methods || todo.description || ""; if (methods.textContent) { item.appendChild(methods); } list.appendChild(item); } parent.appendChild(list); } async function renderPlanMessage(node, message, metadata) { const details = document.createElement("details"); details.className = "tool-log plan-message"; details.open = true; const summary = document.createElement("summary"); summary.textContent = metadata.title || "Research plan"; details.appendChild(summary); const todos = resolvePlanTodos(metadata); if (todos.length) { renderPlanTodoList(details, todos); } else if (message.content) { const fallback = document.createElement("div"); fallback.className = "tool-compact-detail"; fallback.textContent = message.content; details.appendChild(fallback); } node.appendChild(details); } async function renderToolMessage(node, message, metadata, options = {}) { const isThinking = metadata.display === "thinking" || metadata.title === "Thinking"; if (isThinking) { node.classList.add("thinking"); const header = document.createElement("div"); header.className = "thinking-message-header"; header.textContent = metadata.title || "Thinking"; node.appendChild(header); const panel = document.createElement("div"); panel.className = "thinking-message"; await renderAssistantContent(panel, message, true); node.appendChild(panel); return; } if (metadata.display === "plan") { await renderPlanMessage(node, message, metadata); return; } const isCompact = Boolean(options.compact || metadata.compact); if (metadata.log || isCompact) { const details = document.createElement("details"); details.className = `tool-log${isCompact ? " tool-log-compact" : ""}`; if (isCompact) { details.open = true; } const summary = document.createElement("summary"); summary.className = "tool-compact-summary"; summary.textContent = `${metadata.title || "Tool"}${toolSummarySuffix(metadata)}`; details.appendChild(summary); if (message.content) { const summaryText = document.createElement("div"); summaryText.className = isCompact ? "tool-compact-detail" : "tool-summary-text"; if (metadata.markdown) { summaryText.classList.add("markdown-body"); await renderMarkdownInto(summaryText, message.content); } else { summaryText.textContent = message.content; } details.appendChild(summaryText); } if (metadata.log) { if (isCompact) { appendToolRawLog(details, metadata); } else { if (metadata.log.arguments) { appendToolLogSection(details, "Arguments", metadata.log.arguments); } if (metadata.log.result !== undefined) { appendToolLogSection(details, "Result", metadata.log.result); } } } node.appendChild(details); return; } const title = document.createElement("div"); title.className = "tool-title"; title.textContent = metadata.title || "Tool"; node.appendChild(title); const body = document.createElement("div"); body.className = "chat-message-body"; body.textContent = message.content || ""; node.appendChild(body); } function isResearchMessage(message) { return Boolean(message?.metadata?.research_task); } function researchTaskDisplay(task) { const country = String(task?.country || "").trim(); const methods = String(task?.methods || "").trim(); if (country && methods) { return { country, methods }; } const title = String(task?.title || "").trim(); if (title.includes(" — ")) { const parts = title.split(" — "); return { country: parts[0].trim(), methods: parts.slice(1).join(" — ").trim() }; } if (title.includes(" - ")) { const parts = title.split(" - "); return { country: parts[0].trim(), methods: parts.slice(1).join(" - ").trim() }; } return { country: title || "Research", methods: "" }; } function researchTaskKey(task) { const id = task?.id; if (id !== undefined && id !== null) { return String(id); } return researchTaskDisplay(task).country || "research"; } function collectResearchMessages() { return state.history.filter(isResearchMessage); } function groupResearchMessages(messages) { const groups = new Map(); for (const message of messages) { const task = message.metadata?.research_task || {}; const key = researchTaskKey(task); if (!groups.has(key)) { groups.set(key, { key, task, messages: [], hasFinding: false, hasPending: false, }); } const group = groups.get(key); group.messages.push(message); group.hasFinding = group.hasFinding || Boolean(message.metadata?.research_finding); group.hasPending = group.hasPending || message.metadata?.status === "pending"; } return Array.from(groups.values()).sort((left, right) => { const leftId = Number(left.task?.id); const rightId = Number(right.task?.id); if (Number.isFinite(leftId) && Number.isFinite(rightId)) { return leftId - rightId; } return String(researchTaskDisplay(left.task).country).localeCompare( String(researchTaskDisplay(right.task).country), ); }); } function researchGroupStatus(group) { if (group.hasFinding) { return "done"; } if (group.hasPending) { return "running"; } return "working"; } function defaultResearchTask(groups) { const active = groups.find((group) => group.key === state.activeResearchTaskId); if (active) { return active; } return ( groups.find((group) => researchGroupStatus(group) !== "done") || groups[0] ); } async function renderResearchMessage(parent, message, task) { const node = document.createElement("div"); const metadata = message.metadata || {}; node.className = `research-event ${metadata.status === "pending" ? "pending" : ""}`; if (metadata.log) { await renderToolMessage(node, message, metadata, { compact: true }); } else { const body = document.createElement("div"); body.className = "assistant-message-content"; await renderMessageBody(body, message, false); node.appendChild(body); } parent.appendChild(node); } function buildResearchTab(group, activeKey) { const status = researchGroupStatus(group); const { country, methods } = researchTaskDisplay(group.task); const button = document.createElement("button"); button.type = "button"; button.className = `research-carousel-tab ${group.key === activeKey ? "is-active" : ""}`; button.dataset.status = status; button.dataset.groupKey = group.key; const titleEl = document.createElement("span"); titleEl.className = "research-carousel-tab-title"; titleEl.textContent = country; button.appendChild(titleEl); if (methods) { const subtitle = document.createElement("span"); subtitle.className = "research-carousel-tab-subtitle"; subtitle.textContent = methods; button.appendChild(subtitle); } button.addEventListener("click", () => { state.activeResearchTaskId = group.key; const panel = els.chatMessages.querySelector(".research-carousel"); if (panel) { void updateResearchCarousel(panel, collectResearchMessages(), group.key); } else { renderMessages(); } }); return button; } async function fillResearchCarouselBody(body, group) { body.replaceChildren(); const { country, methods } = researchTaskDisplay(group.task); const statusLabel = researchGroupStatus(group); const activeStatus = document.createElement("div"); activeStatus.className = "research-carousel-active-status"; activeStatus.textContent = methods ? `${country} · ${methods} · ${statusLabel}` : `${country} · ${statusLabel}`; body.appendChild(activeStatus); for (const message of group.messages) { await renderResearchMessage(body, message, group.task); } } async function updateResearchCarousel(panel, researchMessages, activeKey) { const groups = groupResearchMessages(researchMessages); const active = groups.find((group) => group.key === activeKey) || defaultResearchTask(groups); if (!active) { return; } const progress = panel.querySelector(".research-carousel-progress"); if (progress) { const doneCount = groups.filter((group) => researchGroupStatus(group) === "done").length; progress.textContent = `${doneCount}/${groups.length} complete`; } const tabs = panel.querySelector(".research-carousel-tabs"); if (tabs) { tabs.replaceChildren(); for (const group of groups) { tabs.appendChild(buildResearchTab(group, active.key)); } } const body = panel.querySelector(".research-carousel-body"); if (body) { await fillResearchCarouselBody(body, active); } } async function renderResearchCarousel(messages) { const groups = groupResearchMessages(messages); const active = defaultResearchTask(groups); if (!active) { return null; } const panel = document.createElement("div"); panel.className = "chat-message assistant research-carousel"; const header = document.createElement("div"); header.className = "research-carousel-header"; const title = document.createElement("div"); title.className = "research-carousel-title"; title.textContent = "Parallel country research"; const progress = document.createElement("div"); progress.className = "research-carousel-progress"; const doneCount = groups.filter((group) => researchGroupStatus(group) === "done").length; progress.textContent = `${doneCount}/${groups.length} complete`; header.appendChild(title); header.appendChild(progress); panel.appendChild(header); const tabs = document.createElement("div"); tabs.className = "research-carousel-tabs"; for (const group of groups) { tabs.appendChild(buildResearchTab(group, active.key)); } panel.appendChild(tabs); const body = document.createElement("div"); body.className = "research-carousel-body"; await fillResearchCarouselBody(body, active); panel.appendChild(body); return panel; } async function renderMessages() { const chatEl = els.chatMessages; const wasNearBottom = chatEl.scrollHeight - chatEl.scrollTop - chatEl.clientHeight < 80; const previousScrollTop = chatEl.scrollTop; chatEl.innerHTML = ""; for (let index = 0; index < state.history.length; index += 1) { const message = state.history[index]; if (isResearchMessage(message)) { const researchMessages = []; while (index < state.history.length && isResearchMessage(state.history[index])) { researchMessages.push(state.history[index]); index += 1; } index -= 1; const carousel = await renderResearchCarousel(researchMessages); if (carousel) { els.chatMessages.appendChild(carousel); } continue; } const node = document.createElement("div"); const metadata = message.metadata || {}; const isTool = Boolean(metadata.title || metadata.status); node.className = `chat-message ${message.role}${isTool ? " tool" : ""}`; if (metadata.status === "pending") { node.classList.add("pending"); } if (isTool) { if (metadata.display === "plan") { await renderPlanMessage(node, message, metadata); } else { await renderToolMessage(node, message, metadata); } } else { const body = document.createElement("div"); body.className = "assistant-message-content"; await renderMessageBody(body, message, isTool); node.appendChild(body); } els.chatMessages.appendChild(node); } if (wasNearBottom) { chatEl.scrollTop = chatEl.scrollHeight; } else { chatEl.scrollTop = previousScrollTop; } } function applyGlobeState(globeState) { state.globeState = globeState; window.BorderlessGlobe?.applyState(globeState); } function formPayload() { return { current_country: selectedValues(document.getElementById("current-country")), residence_status: selectedValues(document.getElementById("residence-status")), education: selectedValues(document.getElementById("education")), occupation: selectedValues(document.getElementById("occupation")), experience: selectedValues(document.getElementById("experience")), budget: selectedValues(document.getElementById("budget")), family: selectedValues(document.getElementById("family")), timeline: selectedValues(document.getElementById("timeline")), goals: document.getElementById("goals").value.trim(), }; } async function loadChoices() { let choices = null; try { const response = await fetch("/api/intake_choices", { credentials: "include" }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } choices = await response.json(); } catch (restError) { const result = await gradioPredict("/get_intake_choices", {}); choices = unwrapResult(result); } state.choices = choices; fillSelect(document.getElementById("current-country"), choices.countries); fillSelect(document.getElementById("residence-status"), choices.residence_status); fillSelect(document.getElementById("education"), choices.education); fillSelect(document.getElementById("occupation"), choices.occupation); fillSelect(document.getElementById("experience"), choices.experience); fillSelect(document.getElementById("budget"), choices.budget); fillSelect(document.getElementById("family"), choices.family); fillSelect(document.getElementById("timeline"), choices.timeline); els.personaList.innerHTML = ""; for (const persona of choices.personas || []) { const button = document.createElement("button"); button.type = "button"; button.textContent = persona.label; button.addEventListener("click", () => fillPersonaForm(persona)); els.personaList.appendChild(button); } } async function runChat(message) { const priorHistory = state.history; state.history = [...priorHistory, { role: "user", content: message }]; await renderMessages(); await gradioStream( "/chat", { message, history: priorHistory, globe_state: state.globeState, }, async (chunk) => { if (!chunk || typeof chunk !== "object" || Array.isArray(chunk)) { return; } if (Array.isArray(chunk.history)) { state.history = chunk.history; } if (chunk.globe_state) { applyGlobeState(chunk.globe_state); } await renderMessages(); }, ); persistActiveSession(); } async function sendChatMessage(message) { setBusy(true); setStatus("Researching pathways..."); try { await runChat(message); setStatus(""); } catch (error) { setStatus(`Chat failed: ${formatAgentError(error)}`); throw error; } finally { setBusy(false); } } async function submitForm(event) { event.preventDefault(); if (state.busy || !validateForm()) { return; } showChatView(); setBusy(true); setStatus("Building research prompt..."); try { const result = await gradioPredict("/build_research_prompt", formPayload()); const payload = unwrapResult(result) || {}; const message = typeof payload === "string" ? payload : String(payload.text || ""); const title = typeof payload === "object" && payload !== null && !Array.isArray(payload) ? String(payload.title || "") : ""; state.sessionAutoTitle = title || null; if (!message) { setStatus("Could not build prompt."); return; } clearFieldValidation(); setStatus("Researching pathways..."); await runChat(message); setStatus(""); } catch (error) { setStatus(`Submission failed: ${formatAgentError(error)}`); } finally { setBusy(false); } } async function sendChat() { const message = els.chatInput.value.trim(); if (!message || state.busy) { return; } els.chatInput.value = ""; try { await sendChatMessage(message); } catch { els.chatInput.value = message; } } for (const id of REQUIRED_FIELDS) { const element = document.getElementById(id); element.addEventListener("input", () => setFieldInvalid(id, false)); element.addEventListener("change", () => setFieldInvalid(id, false)); } els.intakeForm.addEventListener("submit", submitForm); els.chatSend.addEventListener("click", sendChat); els.newChat.addEventListener("click", startNewChat); els.historyOpen.addEventListener("click", openHistoryDialog); els.historyClose.addEventListener("click", closeHistoryDialog); els.historyDialog .querySelector("[data-history-close]") .addEventListener("click", closeHistoryDialog); document.addEventListener("keydown", (event) => { if (event.key === "Escape" && !els.historyDialog.hidden) { closeHistoryDialog(); } }); els.chatInput.addEventListener("keydown", (event) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); sendChat(); } }); await loadAuthStatus(); try { await loadChoices(); } catch (error) { setStatus(`Could not load form options: ${error.message || error}`); } await renderMessages();