borderless / assets /app.js
spagestic's picture
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
Raw
History Blame Contribute Delete
36.1 kB
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();