Refactor ZeroGPU handling and update documentation. Reduced default BORDERLESS_GPU_DURATION to 60 seconds in .env.example and README.md. Enhanced error messaging in app.js and gradio_api.js for better user feedback on quota issues. Updated GPU_DURATION logic in config.py to enforce minimum and maximum limits.
fbe6753 | 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(); | |