build-small-hackathon/hackathon-advisor
16
1const form = document.querySelector("#turn-form");2const atlasView = document.querySelector("#atlas-view");3const advisorView = document.querySelector("#advisor-view");4const openAdvisorButton = document.querySelector("#open-advisor");5const openAtlasButton = document.querySelector("#open-atlas");6const refreshDashboardButton = document.querySelector("#refresh-dashboard");7const atlasStatusEl = document.querySelector("#atlas-status");8const atlasSearchForm = document.querySelector("#atlas-search-form");9const atlasSearchInput = document.querySelector("#atlas-search");10const atlasSearchClearButton = document.querySelector("#atlas-search-clear");11const atlasSearchSectionEl = document.querySelector("#atlas-search-section");12const atlasSearchSummaryEl = document.querySelector("#atlas-search-summary");13const atlasSearchResultsEl = document.querySelector("#atlas-search-results");14const atlasStatsEl = document.querySelector("#atlas-stats");15const atlasClustersEl = document.querySelector("#atlas-clusters");16const atlasQuestsEl = document.querySelector("#atlas-quests");17const atlasSvgEl = document.querySelector("#atlas-svg");18const atlasDetailEl = document.querySelector("#atlas-detail");19const atlasReportEl = document.querySelector("#atlas-report");20const atlasRefreshProgressEl = document.querySelector("#atlas-refresh-progress");21const input = document.querySelector("#message");22const submit = document.querySelector("#submit");23const ink = document.querySelector("#ink");24const corrections = document.querySelector("#corrections");25const projectsEl = document.querySelector("#projects");26const whitespaceEl = document.querySelector("#whitespace");27const ideasEl = document.querySelector("#ideas");28const goalsEl = document.querySelector("#goals");29const profileEl = document.querySelector("#profile");30const woodMapEl = document.querySelector("#wood-map");31const scoreEl = document.querySelector("#score");32const planEl = document.querySelector("#plan");33const provenanceEl = document.querySelector("#provenance");34const verdictEl = document.querySelector("#verdict");35const overallEl = document.querySelector("#overall");36const sealEl = document.querySelector("#seal");37const sealVerdictEl = document.querySelector("#seal-verdict");38const sealCopyEl = document.querySelector("#seal-copy");39const verdictStampEl = document.querySelector("#verdict-stamp");40const spreadEl = document.querySelector("#spread");41const ideaCountEl = document.querySelector("#idea-count");42const goalCountEl = document.querySelector("#goal-count");43const demoButton = document.querySelector("#load-demo");44const exportButton = document.querySelector("#export-artifact");45const exportNotesButton = document.querySelector("#export-notes");46const exportChapterButton = document.querySelector("#export-chapter");47const resetButton = document.querySelector("#reset-session");48const recordVoiceButton = document.querySelector("#record-voice");49const uploadVoiceButton = document.querySelector("#upload-voice");50const voiceFileInput = document.querySelector("#voice-file");51const turnProgressEl = document.querySelector("#turn-progress");52const turnStageIconEl = document.querySelector("#turn-stage-icon");53const turnStageTextEl = document.querySelector("#turn-stage-text");54const turnTokensEl = document.querySelector("#turn-tokens");55const turnEtaEl = document.querySelector("#turn-eta");56const turnBarFillEl = document.querySelector("#turn-bar-fill");57const toolChipsEl = document.querySelector("#tool-chips");58 59const SESSION_STORAGE_KEY = "hackathon-advisor-session-v2";60const STAGE_ICONS = { planning: "đĒļ", running_tool: "đ§", writing: "âī¸" };61const FIELD_NOTES_FILENAME = "hackathon-advisor-field-notes.md";62const CHAPTER_FILENAME = "hackathon-advisor-chapter.md";63const PNG_EXPORT_LABEL = "PNG";64 65let session = {};66let currentArtifact = null;67let goalOptions = [];68let goalProfiles = [];69let goalProfileById = new Map();70let profileFields = [];71let turnWatchdog = null;72let sawTurnToken = false;73let bootstrapData = null;74let sessionRevision = 0;75let sessionControlsLocked = false;76let voiceBusy = false;77let voiceRecorder = null;78let voiceStream = null;79let voiceChunks = [];80let voiceRecordingState = "idle";81let decodeStartedAt = 0;82let turnProgressTimer = null;83let dashboardData = null;84const SELF_PROJECT_ID = "build-small-hackathon/hackathon-advisor";85let selectedClusterId = "";86let selectedQuestId = "";87let selectedProjectId = "";88let dashboardRefreshTimer = null;89let atlasSearchQuery = "";90let atlasSearchResults = [];91let atlasSearchResultIds = new Set();92let atlasSearchTimer = null;93let atlasSearchController = null;94let atlasSearchUnavailable = false;95let atlasSearchBusy = false;96 97setVoiceRecordingState("idle");98setupViewRouting();99loadDashboard().catch(handleDashboardError);100bootstrap().catch(handleBootstrapError);101 102form.addEventListener("submit", async (event) => {103 event.preventDefault();104 if (sessionControlsLocked || submit.disabled || input.disabled) return;105 const message = input.value.trim();106 if (!message) return;107 await runTurn(message);108});109 110input.addEventListener("keydown", (event) => {111 if (event.key !== "Enter" || event.shiftKey) return;112 event.preventDefault();113 form.requestSubmit();114});115 116document.querySelectorAll(".mobile-nav [data-tab]").forEach((button) => {117 button.addEventListener("click", () => setActiveTab(button.dataset.tab || "page"));118});119 120document.querySelectorAll("[data-command]").forEach((button) => {121 button.addEventListener("click", async () => {122 await runCommand(button.dataset.command || "");123 });124});125 126setupMenus();127 128demoButton.addEventListener("click", async () => {129 await loadDemoSession();130});131 132exportButton.addEventListener("click", () => {133 if (!currentArtifact) return;134 exportArtifact(currentArtifact);135});136 137exportNotesButton.addEventListener("click", () => exportNotes());138 139exportChapterButton.addEventListener("click", () => exportChapter());140 141resetButton.addEventListener("click", () => {142 resetSession();143});144 145openAdvisorButton?.addEventListener("click", () => {146 window.location.hash = "advisor";147});148 149openAtlasButton?.addEventListener("click", () => {150 window.location.hash = "atlas";151});152 153refreshDashboardButton?.addEventListener("click", async () => {154 await startDashboardRefresh();155});156 157atlasSearchForm?.addEventListener("submit", (event) => {158 event.preventDefault();159 runAtlasSearch(atlasSearchInput?.value || "");160});161 162atlasSearchInput?.addEventListener("input", () => {163 scheduleAtlasSearch(atlasSearchInput.value || "");164});165 166atlasSearchClearButton?.addEventListener("click", () => {167 clearAtlasSearch();168});169 170recordVoiceButton.addEventListener("click", async () => {171 await toggleVoiceRecording();172});173 174uploadVoiceButton.addEventListener("click", () => {175 if (uploadVoiceButton.disabled || voiceBusy || sessionControlsLocked || voiceRecordingState !== "idle") return;176 voiceFileInput.click();177});178 179voiceFileInput.addEventListener("change", async () => {180 const file = voiceFileInput.files?.[0] || null;181 voiceFileInput.value = "";182 if (!file) return;183 await transcribeVoiceBlob(file, file.name || "voice-note.audio");184});185 186goalsEl.addEventListener("change", (event) => {187 const target = event.target;188 if (!(target instanceof HTMLInputElement) || !target.dataset.goal) return;189 bumpSessionRevision();190 const checked = new Set(191 Array.from(goalsEl.querySelectorAll("input[data-goal]:checked")).map((input) => input.dataset.goal),192 );193 session.goals = goalOptions.filter((option) => checked.has(option));194 syncCurrentIdeaGoals();195 invalidateCurrentSeal("Goals updated. Press Ink or Plan to refresh the score.");196 saveSession();197 renderGoals(session.goals);198 renderIdeas(session.ideas || []);199});200 201profileEl.addEventListener("input", (event) => {202 const target = event.target;203 if (!(target instanceof HTMLInputElement) || !target.dataset.profileField) return;204 bumpSessionRevision();205 const profile = { ...(session.profile || {}) };206 const value = target.value.trim();207 if (value) {208 profile[target.dataset.profileField] = value;209 } else {210 delete profile[target.dataset.profileField];211 }212 session.profile = profile;213 invalidateCurrentPlan("Profile updated. Press Plan to refresh the build path.");214 saveSession();215});216 217ideasEl.addEventListener("click", (event) => {218 const card = event.target.closest("[data-idea-id]");219 if (!(card instanceof HTMLElement) || !ideasEl.contains(card)) return;220 selectIdea(card.dataset.ideaId || "");221});222 223whitespaceEl.addEventListener("click", async (event) => {224 const card = event.target.closest("[data-gap-prompt]");225 if (!(card instanceof HTMLButtonElement) || !whitespaceEl.contains(card)) return;226 if (card.disabled) return;227 await runTurn(card.dataset.gapPrompt || "");228});229 230function setupViewRouting() {231 window.addEventListener("hashchange", applyCurrentView);232 applyCurrentView();233}234 235function applyCurrentView() {236 const view = window.location.hash.replace(/^#/, "") === "advisor" ? "advisor" : "atlas";237 document.body.dataset.view = view;238 if (atlasView) atlasView.hidden = view !== "atlas";239 if (advisorView) advisorView.hidden = view !== "advisor";240 if (view === "advisor" && input && !sessionControlsLocked) {241 window.setTimeout(() => input.focus(), 30);242 }243}244 245async function loadDashboard() {246 const response = await fetch("/api/dashboard");247 if (!response.ok) throw new Error(`dashboard failed with ${response.status}`);248 const data = await response.json();249 dashboardData = data;250 renderDashboard(data);251 renderRefreshState(data.refresh || {});252 if (data.refresh?.status === "running") scheduleRefreshPoll();253}254 255function handleDashboardError(error) {256 console.error("Atlas could not load.", error);257 dashboardData = null;258 if (atlasStatusEl) atlasStatusEl.textContent = "Atlas could not load.";259 if (atlasSvgEl) atlasSvgEl.innerHTML = "";260 if (atlasStatsEl) atlasStatsEl.innerHTML = "";261 if (atlasDetailEl) atlasDetailEl.innerHTML = `<p>Reload the page to try again.</p>`;262}263 264async function startDashboardRefresh() {265 if (!refreshDashboardButton || refreshDashboardButton.disabled) return;266 refreshDashboardButton.disabled = true;267 if (atlasStatusEl) atlasStatusEl.textContent = "Starting refresh.";268 try {269 const response = await fetch("/api/dashboard/refresh", { method: "POST" });270 const data = await response.json();271 if (!response.ok) throw new Error(data.detail || `refresh failed with ${response.status}`);272 renderRefreshState(data);273 scheduleRefreshPoll();274 } catch (error) {275 console.error("Dashboard refresh could not start.", error);276 if (atlasStatusEl) atlasStatusEl.textContent = "Refresh could not start.";277 if (atlasRefreshProgressEl) atlasRefreshProgressEl.hidden = true;278 refreshDashboardButton.disabled = false;279 }280}281 282function scheduleRefreshPoll() {283 if (dashboardRefreshTimer) window.clearTimeout(dashboardRefreshTimer);284 dashboardRefreshTimer = window.setTimeout(pollDashboardRefresh, 1400);285}286 287async function pollDashboardRefresh() {288 try {289 const response = await fetch("/api/dashboard/refresh");290 if (!response.ok) throw new Error(`refresh status failed with ${response.status}`);291 const state = await response.json();292 renderRefreshState(state);293 if (state.status === "running") {294 scheduleRefreshPoll();295 return;296 }297 if (state.status === "succeeded") {298 await loadDashboard();299 }300 } catch (error) {301 console.error("Dashboard refresh status unavailable.", error);302 if (atlasStatusEl) atlasStatusEl.textContent = "Refresh status unavailable.";303 } finally {304 if (_refreshIsSettled()) refreshDashboardButton.disabled = false;305 }306}307 308function _refreshIsSettled() {309 const status = String(dashboardData?.refresh?.status || "");310 return status !== "running";311}312 313function renderRefreshState(state) {314 if (dashboardData) dashboardData.refresh = state || {};315 const status = String(state?.status || "idle");316 const stage = state?.stage_label || state?.stage || "";317 if (atlasStatusEl) {318 if (status === "running") {319 atlasStatusEl.textContent = stage ? `Refresh running: ${stage}.` : "Refresh running.";320 } else if (status === "succeeded") {321 atlasStatusEl.textContent = `Atlas refreshed: ${state.result?.project_count || "current"} projects mapped.`;322 } else if (status === "failed") {323 if (state.error) console.error("Dashboard refresh failed.", state.error);324 atlasStatusEl.textContent = "Refresh did not complete; current map is unchanged.";325 } else if (dashboardData) {326 atlasStatusEl.textContent = atlasSearchQuery ? atlasSearchStatusCopy() : atlasProvenanceCopy(dashboardData);327 }328 }329 if (atlasRefreshProgressEl) {330 const show = status === "running";331 const cacheCopy = refreshQuestCacheCopy(state?.quest_cache || {});332 atlasRefreshProgressEl.hidden = !show;333 atlasRefreshProgressEl.textContent =334 status === "running"335 ? `${stage || "Working"}${cacheCopy ? ` ¡ ${cacheCopy}` : ""} ¡ run ${state.run_id || ""}`336 : "";337 }338 if (refreshDashboardButton) refreshDashboardButton.disabled = status === "running";339}340 341function refreshQuestCacheCopy(cache) {342 const total = Number(cache.project_count || 0);343 if (!total) return "";344 const hits = Number(cache.hit_count || 0);345 const misses = Number(cache.miss_count || 0);346 const analyzed = Number(cache.analyzed_count || 0);347 const remaining = Number(cache.remaining_count || 0);348 if (!hits && !misses && !analyzed) return "";349 if (remaining > 0) return `${hits} cached, ${analyzed}/${misses} analyzed`;350 return `${hits} cached, ${analyzed} analyzed`;351}352 353function scheduleAtlasSearch(rawQuery) {354 const query = String(rawQuery || "").trim();355 if (atlasSearchTimer) window.clearTimeout(atlasSearchTimer);356 if (!query) {357 clearAtlasSearch();358 return;359 }360 atlasSearchTimer = window.setTimeout(() => runAtlasSearch(query), 260);361}362 363async function runAtlasSearch(rawQuery) {364 const query = String(rawQuery || "").trim();365 if (!query) {366 clearAtlasSearch();367 return;368 }369 atlasSearchQuery = query;370 atlasSearchUnavailable = false;371 atlasSearchBusy = true;372 renderAtlasSearch();373 if (atlasSearchController) atlasSearchController.abort();374 atlasSearchController = new AbortController();375 try {376 const response = await fetch(`/api/dashboard/search?q=${encodeURIComponent(query)}&limit=12`, {377 signal: atlasSearchController.signal,378 });379 if (!response.ok) throw new Error(`search failed with ${response.status}`);380 const payload = await response.json();381 if (query !== String(atlasSearchInput?.value || "").trim()) return;382 atlasSearchResults = payload.results || [];383 atlasSearchResultIds = new Set(atlasSearchResults.map((result) => result.project_id).filter(Boolean));384 atlasSearchUnavailable = false;385 atlasSearchBusy = false;386 if (atlasSearchResults.length) selectedProjectId = atlasSearchResults[0].project_id || selectedProjectId;387 if (dashboardData) renderDashboard(dashboardData);388 } catch (error) {389 if (error.name === "AbortError") return;390 console.error("Atlas search failed.", error);391 atlasSearchResults = [];392 atlasSearchResultIds = new Set();393 atlasSearchUnavailable = true;394 atlasSearchBusy = false;395 if (dashboardData) renderDashboard(dashboardData);396 }397}398 399function clearAtlasSearch() {400 if (atlasSearchTimer) window.clearTimeout(atlasSearchTimer);401 atlasSearchTimer = null;402 if (atlasSearchController) atlasSearchController.abort();403 atlasSearchController = null;404 atlasSearchQuery = "";405 atlasSearchResults = [];406 atlasSearchResultIds = new Set();407 atlasSearchUnavailable = false;408 atlasSearchBusy = false;409 if (atlasSearchInput) atlasSearchInput.value = "";410 if (dashboardData) renderDashboard(dashboardData);411}412 413function atlasSearchStatusCopy() {414 if (!atlasSearchQuery) return dashboardData ? atlasProvenanceCopy(dashboardData) : "";415 if (atlasSearchBusy) return "Searching.";416 if (atlasSearchUnavailable) return "Search unavailable.";417 if (!atlasSearchResults.length) return `No matches for "${atlasSearchQuery}".`;418 return `${atlasSearchResults.length} matches for "${atlasSearchQuery}".`;419}420 421function renderAtlasSearch() {422 if (!atlasSearchSectionEl || !atlasSearchResultsEl || !atlasSearchSummaryEl) return;423 const active = Boolean(atlasSearchQuery);424 atlasSearchSectionEl.hidden = !active;425 if (atlasSearchClearButton) atlasSearchClearButton.hidden = !active;426 if (!active) {427 atlasSearchResultsEl.innerHTML = "";428 atlasSearchSummaryEl.textContent = "";429 return;430 }431 atlasSearchSummaryEl.textContent = atlasSearchStatusCopy();432 atlasSearchResultsEl.innerHTML = "";433 if (atlasSearchUnavailable || !atlasSearchResults.length) return;434 for (const result of atlasSearchResults.slice(0, 8)) {435 atlasSearchResultsEl.append(atlasSearchResultButton(result));436 }437}438 439function atlasSearchResultButton(result) {440 const button = document.createElement("button");441 button.type = "button";442 button.className = `atlas-search-result ${result.project_id === selectedProjectId ? "active" : ""}`;443 const title = result.title || result.project?.title || result.project_id || "Untitled project";444 const terms = (result.matched_terms || []).slice(0, 4).join(", ");445 const snippet = (result.snippets || [])[0];446 const width = Math.max(8, Math.min(100, Number(result.score || 0) * 100)).toFixed(0);447 button.innerHTML = `448 <strong>${escapeHtml(title)}</strong>449 <span class="atlas-search-meta">${escapeHtml(terms || "Related project")}</span>450 <span class="atlas-search-score" aria-hidden="true"><i style="width: ${width}%"></i></span>451 ${452 snippet453 ? `<span class="atlas-search-snippet">${escapeHtml(snippet.source)}: ${escapeHtml(snippet.text)}</span>`454 : ""455 }456 `;457 button.addEventListener("click", () => {458 selectedProjectId = result.project_id || selectedProjectId;459 if (dashboardData) renderDashboard(dashboardData);460 });461 return button;462}463 464function renderDashboard(data) {465 if (!data?.points?.length) {466 handleDashboardError(new Error("empty dashboard payload"));467 return;468 }469 if (!selectedProjectId) {470 const selfPoint = data.points.find((point) => point.id === SELF_PROJECT_ID);471 selectedProjectId = selfPoint?.id || mostLikedPoint(data.points)?.id || data.points[0].id;472 }473 renderAtlasStats(data);474 renderAtlasClusters(data);475 renderAtlasQuests(data);476 renderAtlasSvg(data);477 renderAtlasDetail(currentAtlasPoint(data));478 renderAtlasReport(data);479 renderAtlasSearch();480 renderChatActionChip(); // keep the chat's applied-filter chip in sync with manual clicks481 if (atlasStatusEl) atlasStatusEl.textContent = atlasSearchQuery ? atlasSearchStatusCopy() : atlasProvenanceCopy(data);482}483 484function atlasProvenanceCopy(data) {485 const count = Number(data.project_count || data.points?.length || 0);486 const updated = shortDate(data.provenance?.snapshot_generated_at || data.generated_at);487 return `${count} projects mapped ¡ ${data.layout?.algorithm || "layout"} ¡ updated ${updated}`;488}489 490function renderAtlasStats(data) {491 if (!atlasStatsEl) return;492 const analyzed = data.quest_report?.status === "analyzed";493 const questCount = (data.quest_report?.quests || []).filter((quest) => Number(quest.project_count || 0) > 0).length;494 atlasStatsEl.innerHTML = `495 <div class="atlas-stat"><strong>${Number(data.project_count || 0)}</strong><span>Projects</span></div>496 <div class="atlas-stat"><strong>${Number(data.clusters?.length || 0)}</strong><span>Clusters</span></div>497 <div class="atlas-stat"><strong>${Number(data.links?.length || 0)}</strong><span>Near links</span></div>498 <div class="atlas-stat"><strong>${analyzed ? questCount : "..."}</strong><span>Quest groups</span></div>499 `;500}501 502function renderAtlasClusters(data) {503 if (!atlasClustersEl) return;504 const allActive = !selectedClusterId;505 atlasClustersEl.innerHTML = "";506 atlasClustersEl.append(507 atlasFilterButton({508 label: "All clusters",509 meta: `${data.project_count || data.points.length} projects`,510 active: allActive,511 onClick: () => {512 selectedClusterId = "";513 renderDashboard(data);514 },515 }),516 );517 for (const cluster of data.clusters || []) {518 atlasClustersEl.append(519 atlasFilterButton({520 label: cluster.label || cluster.id,521 meta: `${cluster.project_count || 0} projects`,522 active: selectedClusterId === cluster.id,523 onClick: () => {524 selectedClusterId = selectedClusterId === cluster.id ? "" : cluster.id;525 renderDashboard(data);526 },527 }),528 );529 }530}531 532function renderAtlasQuests(data) {533 if (!atlasQuestsEl) return;534 const quests = data.quest_report?.quests || [];535 const analyzed = data.quest_report?.status === "analyzed";536 atlasQuestsEl.innerHTML = "";537 atlasQuestsEl.append(538 atlasFilterButton({539 label: "All quests",540 meta: analyzed ? "No quest filter" : "Refresh to analyze",541 active: !selectedQuestId,542 onClick: () => {543 selectedQuestId = "";544 renderDashboard(data);545 },546 }),547 );548 for (const quest of quests) {549 atlasQuestsEl.append(550 atlasFilterButton({551 label: quest.label || quest.id,552 meta: analyzed ? `${quest.project_count || 0} projects` : "Not analyzed",553 active: selectedQuestId === quest.id,554 onClick: () => {555 selectedQuestId = selectedQuestId === quest.id ? "" : quest.id;556 renderDashboard(data);557 },558 }),559 );560 }561}562 563function atlasFilterButton({ label, meta, active, onClick }) {564 const button = document.createElement("button");565 button.type = "button";566 button.className = `atlas-filter ${active ? "active" : ""}`;567 button.innerHTML = `<strong>${escapeHtml(label)}</strong><span>${escapeHtml(meta || "")}</span>`;568 button.addEventListener("click", onClick);569 return button;570}571 572function renderAtlasSvg(data) {573 if (!atlasSvgEl) return;574 atlasSvgEl.innerHTML = "";575 const pointsById = new Map((data.points || []).map((point) => [point.id, point]));576 const visible = new Set(visibleAtlasPoints(data).map((point) => point.id));577 const clusterIndex = new Map((data.clusters || []).map((cluster, index) => [cluster.id, index]));578 579 for (const link of data.links || []) {580 const source = pointsById.get(link.source);581 const target = pointsById.get(link.target);582 if (!source || !target) continue;583 const line = svgEl("line");584 line.setAttribute("x1", source.x);585 line.setAttribute("y1", source.y);586 line.setAttribute("x2", target.x);587 line.setAttribute("y2", target.y);588 line.setAttribute("class", `atlas-link ${visible.has(source.id) && visible.has(target.id) ? "" : "dim"}`);589 atlasSvgEl.append(line);590 }591 592 for (const point of data.points || []) {593 if (!atlasSearchResultIds.has(point.id)) continue;594 const ring = svgEl("circle");595 ring.setAttribute("cx", point.x);596 ring.setAttribute("cy", point.y);597 ring.setAttribute("r", (atlasPointRadiusNumber(point) + 0.62).toFixed(3));598 ring.setAttribute("class", `atlas-search-ring ${visible.has(point.id) ? "" : "dim"}`);599 atlasSvgEl.append(ring);600 }601 602 for (const point of data.points || []) {603 const circle = svgEl("circle");604 circle.setAttribute("cx", point.x);605 circle.setAttribute("cy", point.y);606 circle.setAttribute("r", atlasPointRadius(point));607 circle.setAttribute("fill", atlasColor(clusterIndex.get(point.cluster_id) || 0));608 circle.setAttribute(609 "class",610 `atlas-dot ${visible.has(point.id) ? "" : "dim"} ${point.id === selectedProjectId ? "selected" : ""} ${611 atlasSearchResultIds.has(point.id) ? "search-match" : ""612 }`,613 );614 circle.setAttribute("tabindex", "0");615 circle.setAttribute("role", "button");616 circle.setAttribute("aria-label", point.title || point.id);617 circle.addEventListener("mouseenter", () => renderAtlasDetail(point));618 circle.addEventListener("focus", () => renderAtlasDetail(point));619 circle.addEventListener("click", () => {620 selectedProjectId = point.id;621 renderDashboard(data);622 });623 circle.append(svgTitle(point.title || point.id));624 atlasSvgEl.append(circle);625 }626 627 for (const point of labelAtlasPoints(data)) {628 const text = svgEl("text");629 text.setAttribute("x", boundedPercent(point.x + 1.4));630 text.setAttribute("y", boundedPercent(point.y - 1.1));631 text.setAttribute("class", "atlas-label");632 text.textContent = atlasShortTitle(point.title || point.id);633 atlasSvgEl.append(text);634 }635}636 637function visibleAtlasPoints(data) {638 return (data.points || []).filter((point) => {639 const clusterMatch = !selectedClusterId || point.cluster_id === selectedClusterId;640 const questMatch = !selectedQuestId || (point.quest_ids || []).includes(selectedQuestId);641 const searchMatch = !atlasSearchQuery || atlasSearchResultIds.has(point.id);642 return clusterMatch && questMatch && searchMatch;643 });644}645 646function labelAtlasPoints(data) {647 if (atlasSearchQuery && atlasSearchResults.length) {648 const pointsById = new Map((data.points || []).map((point) => [point.id, point]));649 const visibleIds = new Set(visibleAtlasPoints(data).map((point) => point.id));650 return atlasSearchResults651 .map((result) => pointsById.get(result.project_id))652 .filter(Boolean)653 .filter((point) => visibleIds.has(point.id))654 .slice(0, 16);655 }656 const visible = visibleAtlasPoints(data);657 return [...visible].sort((left, right) => Number(right.likes || 0) - Number(left.likes || 0)).slice(0, 12);658}659 660function currentAtlasPoint(data) {661 return (data.points || []).find((point) => point.id === selectedProjectId) || mostLikedPoint(data.points || []);662}663 664function mostLikedPoint(points) {665 return [...(points || [])].sort((left, right) => Number(right.likes || 0) - Number(left.likes || 0))[0] || null;666}667 668function renderAtlasDetail(point) {669 if (!atlasDetailEl) return;670 if (!point) {671 atlasDetailEl.innerHTML = `<p>Select a project dot to inspect its cluster and quest matches.</p>`;672 return;673 }674 const quests = (point.quest_matches || [])675 .map((match) => {676 const confidence = (Number(match.confidence) * 100).toFixed(0);677 const label = atlasQuestLabel(match.quest);678 const hint = questBadgeHint(match, label, confidence);679 return (680 `<span title="${escapeAttribute(hint)}" aria-label="${escapeAttribute(hint)}">` +681 `${escapeHtml(label)} ${confidence}%</span>`682 );683 })684 .join("");685 const tags = [...(point.models || []).slice(0, 3), ...visibleProjectTags(point.tags || []).slice(0, 3)]686 .map((tag) => `<span>${escapeHtml(tag)}</span>`)687 .join("");688 atlasDetailEl.innerHTML = `689 <h2>${escapeHtml(point.title || "Untitled project")}</h2>690 ${point.summary ? `<p>${escapeHtml(point.summary)}</p>` : `<p>${escapeHtml(point.id || "")}</p>`}691 <p>${Number(point.likes || 0)} likes ¡ ${escapeHtml(point.sdk || "unknown sdk")}</p>692 <p><a href="${escapeAttribute(point.url || "#")}" target="_blank" rel="noreferrer">Open Space</a></p>693 <div class="atlas-tags">${quests || `<span>Quest analysis pending</span>`}</div>694 <div class="atlas-tags">${tags}</div>695 `;696}697 698function questBadgeHint(match, label, confidence) {699 const evidence = String(match?.evidence || "").trim();700 const source = questEvidenceSourceLabel(match?.source);701 const parts = [`${label} ${confidence}% confidence`];702 if (evidence) parts.push(`${source}: ${evidence}`);703 return parts.join(". ");704}705 706function questEvidenceSourceLabel(source) {707 const normalized = String(source || "").trim().toLowerCase();708 if (normalized === "readme") return "README evidence";709 if (normalized === "app_file") return "App file evidence";710 return "Evidence";711}712 713function visibleProjectTags(tags) {714 return (tags || []).filter((tag) => !String(tag || "").toLowerCase().startsWith("region:"));715}716 717function renderAtlasReport(data) {718 if (!atlasReportEl) return;719 const cluster = selectedClusterId720 ? (data.clusters || []).find((item) => item.id === selectedClusterId)721 : (data.clusters || [])[0];722 if (!cluster) {723 atlasReportEl.innerHTML = `<p>No cluster report is available.</p>`;724 return;725 }726 const projects = (cluster.representative_projects || [])727 .map(728 (project) =>729 `<p><a href="${escapeAttribute(project.url || "#")}" target="_blank" rel="noreferrer">` +730 `${escapeHtml(project.title || project.id)}</a></p>`,731 )732 .join("");733 atlasReportEl.innerHTML = `734 <h2>${escapeHtml(cluster.label || cluster.id)}</h2>735 <p>${Number(cluster.project_count || 0)} projects ¡ ${escapeHtml(736 (cluster.keywords || []).join(", ") || "mixed signals",737 )}</p>738 ${projects}739 `;740}741 742function svgEl(tagName) {743 return document.createElementNS("http://www.w3.org/2000/svg", tagName);744}745 746function svgTitle(text) {747 const title = svgEl("title");748 title.textContent = text;749 return title;750}751 752function atlasColor(index) {753 const palette = [754 "#9a2b22",755 "#b07d12",756 "#2f6b41",757 "#6f4b1d",758 "#3f8453",759 "#74201b",760 "#8a714c",761 "#d8a226",762 "#5d4528",763 "#7c6849",764 ];765 return palette[index % palette.length];766}767 768function atlasQuestLabel(questId) {769 const quest = (dashboardData?.quest_report?.quests || []).find((item) => item.id === questId);770 return quest?.label || questId;771}772 773function atlasPointRadius(point) {774 return atlasPointRadiusNumber(point).toFixed(3);775}776 777function atlasPointRadiusNumber(point) {778 return 0.62 + Math.min(0.72, Math.sqrt(Number(point.likes || 0)) * 0.12);779}780 781function atlasShortTitle(title) {782 const cleaned = String(title || "").trim();783 return cleaned.length > 22 ? `${cleaned.slice(0, 20).trim()}...` : cleaned;784}785 786async function runTurn(message) {787 if (sessionControlsLocked) return false;788 bumpSessionRevision();789 setActiveTab("page");790 input.value = "";791 submit.disabled = true;792 setCommandDisabled(true);793 setSessionControlsDisabled(true);794 ink.classList.remove("bleed", "gold");795 corrections.textContent = "";796 planEl.innerHTML = "";797 delete session.ui_status;798 resetTurnProgress();799 startTurnWatchdog();800 801 let completed = false;802 try {803 const response = await fetch("/api/agent-turn", {804 method: "POST",805 headers: { "Content-Type": "application/json" },806 body: JSON.stringify({807 message,808 session_json: JSON.stringify(session),809 }),810 });811 if (!response.ok) throw new Error(`advisor failed with ${response.status}`);812 if (!response.body) throw new Error("advisor stream was empty");813 814 for await (const raw of readNdjson(response.body)) {815 handleEvent(JSON.parse(raw));816 }817 completed = true;818 } catch (error) {819 clearTurnWatchdog();820 ink.textContent = `The advisor could not answer: ${error.message}`;821 ink.classList.remove("thinking");822 ink.classList.add("bleed");823 } finally {824 clearTurnWatchdog();825 hideTurnProgress();826 submit.disabled = false;827 setSessionControlsDisabled(false);828 setCommandDisabled(false);829 input.focus();830 }831 return completed;832}833 834async function* readNdjson(stream) {835 const reader = stream.getReader();836 const decoder = new TextDecoder();837 let buffer = "";838 839 while (true) {840 const { value, done } = await reader.read();841 if (done) break;842 buffer += decoder.decode(value, { stream: true });843 let newlineIndex = buffer.indexOf("\n");844 while (newlineIndex >= 0) {845 const line = buffer.slice(0, newlineIndex).trim();846 buffer = buffer.slice(newlineIndex + 1);847 if (line) yield line;848 newlineIndex = buffer.indexOf("\n");849 }850 }851 852 buffer += decoder.decode();853 const finalLine = buffer.trim();854 if (finalLine) yield finalLine;855}856 857async function runCommand(command) {858 if (!command) return;859 const draft = input.value.trim();860 if (draft) {861 const savedDraft = await runTurn(draft);862 if (!savedDraft) return;863 }864 await runTurn(command);865}866 867async function toggleVoiceRecording() {868 if (voiceRecordingState === "recording" && voiceRecorder?.state === "recording") {869 stopVoiceRecording();870 return;871 }872 if (voiceRecordingState !== "idle") return;873 await startVoiceRecording();874}875 876async function startVoiceRecording() {877 if (!bootstrapData || sessionControlsLocked || voiceBusy || voiceRecordingState !== "idle") return;878 if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) {879 setSessionStatus("Voice recording is not available in this browser. Upload a voice note instead.");880 return;881 }882 setVoiceRecordingState("starting");883 submit.disabled = true;884 setCommandDisabled(true);885 try {886 voiceStream = await navigator.mediaDevices.getUserMedia({ audio: true });887 voiceChunks = [];888 const mimeType = recordingMimeType();889 voiceRecorder = new MediaRecorder(voiceStream, mimeType ? { mimeType } : undefined);890 voiceRecorder.addEventListener("dataavailable", (event) => {891 if (event.data?.size) voiceChunks.push(event.data);892 });893 voiceRecorder.addEventListener("stop", () => {894 const recorderMimeType = voiceRecorder?.mimeType || mimeType || "audio/webm";895 const recordedChunks = voiceChunks;896 stopVoiceStream();897 const extension = recorderMimeType.includes("mp4")898 ? "m4a"899 : recorderMimeType.includes("ogg")900 ? "ogg"901 : "webm";902 const blob = new Blob(recordedChunks, { type: recorderMimeType });903 voiceRecorder = null;904 voiceChunks = [];905 if (!blob.size) {906 setVoiceRecordingState("idle");907 submit.disabled = false;908 setCommandDisabled(false);909 setSessionStatus("Voice note is empty.");910 return;911 }912 setVoiceRecordingState("transcribing");913 transcribeVoiceBlob(blob, `recorded-idea.${extension}`);914 });915 voiceRecorder.start();916 setVoiceRecordingState("recording");917 setSessionStatus("Listening. Press Stop when your idea is ready.");918 } catch (error) {919 stopVoiceStream();920 voiceRecorder = null;921 voiceChunks = [];922 setVoiceRecordingState("idle");923 submit.disabled = false;924 setCommandDisabled(false);925 setSessionStatus(`Voice recording could not start: ${error.message}`);926 }927}928 929function stopVoiceRecording() {930 if (!voiceRecorder || voiceRecorder.state !== "recording") return;931 setVoiceRecordingState("stopping");932 setSessionStatus("Stopping recording.");933 try {934 voiceRecorder.stop();935 } catch (error) {936 stopVoiceStream();937 voiceRecorder = null;938 voiceChunks = [];939 setVoiceRecordingState("idle");940 submit.disabled = false;941 setCommandDisabled(false);942 setSessionStatus(`Voice recording could not stop: ${error.message}`);943 }944}945 946function recordingMimeType() {947 const candidates = ["audio/webm;codecs=opus", "audio/webm", "audio/ogg;codecs=opus", "audio/mp4"];948 return candidates.find((type) => MediaRecorder.isTypeSupported(type)) || "";949}950 951function stopVoiceStream() {952 if (!voiceStream) return;953 voiceStream.getTracks().forEach((track) => track.stop());954 voiceStream = null;955}956 957async function transcribeVoiceBlob(blob, filename) {958 if (sessionControlsLocked || voiceBusy) return false;959 if (voiceRecordingState !== "idle" && voiceRecordingState !== "transcribing") return false;960 if (!blob?.size) {961 setSessionStatus("Voice note is empty.");962 return false;963 }964 const revision = bumpSessionRevision();965 voiceBusy = true;966 setVoiceRecordingState("transcribing");967 submit.disabled = true;968 input.disabled = true;969 setCommandDisabled(true);970 setSessionControlsDisabled(true);971 setSessionStatus("Transcribing voice note.");972 try {973 const formData = new FormData();974 formData.append("audio", blob, filename || "voice-note.audio");975 const response = await fetch("/api/transcribe", {976 method: "POST",977 body: formData,978 });979 if (!response.ok) throw new Error(`voice note failed with ${response.status}`);980 const data = await response.json();981 const transcript = String(data.transcript || "").trim();982 if (!transcript) throw new Error("empty transcript");983 if (!isCurrentSessionRevision(revision)) return false;984 input.value = mergeDraftWithTranscript(input.value, transcript);985 session.ui_status = "Voice note transcribed. Edit the draft or press Ink.";986 corrections.textContent = session.ui_status;987 saveSession();988 return true;989 } catch (error) {990 if (isCurrentSessionRevision(revision)) setSessionStatus(`Voice note could not be transcribed: ${error.message}`);991 return false;992 } finally {993 voiceBusy = false;994 setVoiceRecordingState("idle");995 if (isCurrentSessionRevision(revision)) {996 submit.disabled = false;997 input.disabled = false;998 setSessionControlsDisabled(false);999 setCommandDisabled(false);1000 input.focus();1001 }1002 }1003}1004 1005function mergeDraftWithTranscript(draft, transcript) {1006 const current = String(draft || "").trim();1007 return current ? `${current}\n${transcript}` : transcript;1008}1009 1010async function bootstrap() {1011 const response = await fetch("/api/bootstrap");1012 if (!response.ok) throw new Error(`project index failed with ${response.status}`);1013 const data = await response.json();1014 bootstrapData = data;1015 const rawProfiles = Array.isArray(data.goal_profiles) ? data.goal_profiles : [];1016 const rawOptions = Array.isArray(data.goal_options) ? data.goal_options : [];1017 goalProfiles = normalizeGoalProfiles(rawProfiles, rawOptions);1018 goalOptions = goalProfiles.map((goal) => goal.id);1019 goalProfileById = new Map(goalProfiles.map((goal) => [goal.id, goal]));1020 profileFields = data.profile_fields || [];1021 session = normalizeSession(readSavedSession(), defaultSession(data));1022 renderProvenance(data);1023 renderGoals(session.goals);1024 renderProfile(session.profile);1025 renderRestoredSession(data);1026 renderWhitespace(data.whitespace || []);1027 setVoiceRecordingState("idle");1028}1029 1030function handleBootstrapError(error) {1031 bootstrapData = null;1032 currentArtifact = null;1033 session = {};1034 submit.disabled = true;1035 input.disabled = true;1036 setCommandDisabled(true);1037 setSessionControlsDisabled(true);1038 ink.textContent = `The project index could not be opened: ${error.message}`;1039 ink.classList.remove("thinking", "gold");1040 ink.classList.add("bleed");1041 corrections.textContent = "Reload the page to try again.";1042 provenanceEl.textContent = "index unavailable";1043 renderScore(null);1044 setVerdictDisplay("INDEX CLOSED", 0, null);1045 renderWoodMap(null);1046 renderGoals([]);1047 renderProfile({});1048 renderIdeas([]);1049 renderProjects([]);1050 renderWhitespace([]);1051 renderPlan([]);1052}1053 1054function defaultSession(data = bootstrapData) {1055 return {1056 profile: {},1057 goals: data?.default_goals || goalOptions.slice(0, 3),1058 };1059}1060 1061function setActiveTab(tab) {1062 if (!spreadEl) return;1063 const next = ["page", "proof", "almanac"].includes(tab) ? tab : "page";1064 spreadEl.dataset.tab = next;1065 document.querySelectorAll(".mobile-nav [data-tab]").forEach((button) => {1066 button.classList.toggle("active", button.dataset.tab === next);1067 });1068}1069 1070function setVerdictDisplay(verdict = "READY", overall = 0, score = null) {1071 const text = String(verdict || "READY");1072 const isEcho = text.startsWith("ECHO");1073 const isUnwritten = text.startsWith("UNWRITTEN");1074 const numericOverall = Number(overall || score?.overall || 0);1075 1076 verdictEl.textContent = text;1077 overallEl.textContent = numericOverall.toFixed(1);1078 sealEl.classList.toggle("echo", isEcho);1079 sealEl.classList.toggle("unwritten", isUnwritten);1080 1081 sealVerdictEl.textContent = text;1082 sealVerdictEl.classList.toggle("echo", isEcho);1083 sealVerdictEl.classList.toggle("unwritten", isUnwritten);1084 sealVerdictEl.classList.toggle("ready", !isEcho && !isUnwritten);1085 1086 verdictStampEl.classList.toggle("verdict-echo", isEcho);1087 verdictStampEl.classList.toggle("verdict-unwritten", isUnwritten);1088 verdictStampEl.classList.toggle("verdict-ready", !isEcho && !isUnwritten);1089 1090 if (!score) {1091 sealCopyEl.textContent = text === "INDEX CLOSED" ? "The project map did not load." : "No idea has been scored yet.";1092 } else if (isEcho) {1093 sealCopyEl.textContent = "Nearby projects already cover parts of this idea.";1094 } else {1095 sealCopyEl.textContent = "This idea sits in a quieter part of the current map.";1096 }1097}1098 1099function bumpSessionRevision() {1100 sessionRevision += 1;1101 return sessionRevision;1102}1103 1104function isCurrentSessionRevision(revision) {1105 return revision === sessionRevision;1106}1107 1108function restoreExportButtonLabels() {1109 setActionButtonLabel(exportNotesButton, "Notes");1110 setActionButtonLabel(exportChapterButton, "Chapter");1111 setActionButtonLabel(exportButton, PNG_EXPORT_LABEL);1112}1113 1114function actionButtonLabel(button) {1115 return button?.dataset.actionLabel || button?.textContent.trim() || "";1116}1117 1118function setActionButtonLabel(button, label) {1119 if (!button) return;1120 button.dataset.actionLabel = label;1121 const textNode = Array.from(button.childNodes).find(1122 (node) => node.nodeType === Node.TEXT_NODE && node.textContent.trim(),1123 );1124 if (textNode) {1125 textNode.textContent = ` ${label}`;1126 } else {1127 button.append(document.createTextNode(` ${label}`));1128 }1129}1130 1131function setSessionControlsDisabled(disabled) {1132 sessionControlsLocked = disabled;1133 goalsEl.querySelectorAll("input[data-goal]").forEach((target) => {1134 target.disabled = disabled;1135 });1136 profileEl.querySelectorAll("input[data-profile-field]").forEach((field) => {1137 field.disabled = disabled;1138 });1139 ideasEl.querySelectorAll("button[data-idea-id]").forEach((idea) => {1140 idea.disabled = disabled;1141 });1142 whitespaceEl.querySelectorAll("button[data-gap-prompt]").forEach((gap) => {1143 gap.disabled = disabled;1144 });1145 setVoiceControlsDisabled(disabled);1146}1147 1148function setVoiceControlsDisabled(disabled) {1149 const recording = voiceRecordingState === "recording" && voiceRecorder?.state === "recording";1150 const lockedForState = ["starting", "stopping", "transcribing"].includes(voiceRecordingState);1151 recordVoiceButton.disabled = !bootstrapData || voiceBusy || lockedForState || (disabled && !recording);1152 uploadVoiceButton.disabled = !bootstrapData || voiceBusy || disabled || voiceRecordingState !== "idle";1153}1154 1155function setVoiceRecordingState(state) {1156 voiceRecordingState = state;1157 recordVoiceButton.dataset.voiceState = state;1158 recordVoiceButton.classList.toggle("recording", state === "recording");1159 recordVoiceButton.setAttribute("aria-pressed", state === "recording" ? "true" : "false");1160 const labels = {1161 idle: "Speak",1162 starting: "Starting...",1163 recording: "Stop",1164 stopping: "Stopping...",1165 transcribing: "Hearing...",1166 };1167 setActionButtonLabel(recordVoiceButton, labels[state] || "Speak");1168 setVoiceControlsDisabled(sessionControlsLocked);1169}1170 1171function resetSession() {1172 if (!bootstrapData) return;1173 bumpSessionRevision();1174 clearTurnWatchdog();1175 clearSavedSession();1176 session = defaultSession(bootstrapData);1177 currentArtifact = null;1178 submit.disabled = false;1179 input.disabled = false;1180 setSessionControlsDisabled(false);1181 input.value = "";1182 ink.textContent = "The book is open. Describe an idea to start a new page.";1183 ink.classList.remove("thinking", "bleed", "gold");1184 corrections.textContent = "Session reset.";1185 renderGoals(session.goals);1186 renderProfile(session.profile);1187 renderScore(null);1188 setVerdictDisplay("READY", 0, null);1189 renderWoodMap(null);1190 renderIdeas([]);1191 renderPlan([]);1192 renderProjects([], "Score an idea to see nearby echoes.");1193 renderWhitespace(bootstrapData.whitespace || []);1194 restoreExportButtonLabels();1195 setCommandDisabled(false);1196 saveSession();1197 input.focus();1198}1199 1200async function loadDemoSession() {