From 5532342b4b8d28f8b0ed9b0ee2d5d56f1dab5dc1 Mon Sep 17 00:00:00 2001 From: Coleman Irby Date: Thu, 11 Jun 2026 12:26:50 -0500 Subject: [PATCH 01/34] Removed pycache from repo. Added account management screen. Fixed issues with CPU usage by UMAP and uvicorn --- .gitignore | 5 +- app/src/main/index.ts | 36 +- app/src/renderer/src/App.tsx | 58 ++- app/src/renderer/src/views/AccountView.tsx | 360 ++++++++++++++++++ .../__pycache__/roles.cpython-312.pyc | Bin 13723 -> 0 bytes .../__pycache__/server.cpython-312.pyc | Bin 81263 -> 0 bytes app/vectordb/server.py | 28 +- 7 files changed, 467 insertions(+), 20 deletions(-) create mode 100644 app/src/renderer/src/views/AccountView.tsx delete mode 100644 app/vectordb/__pycache__/roles.cpython-312.pyc delete mode 100644 app/vectordb/__pycache__/server.cpython-312.pyc diff --git a/.gitignore b/.gitignore index 289bd78..867523c 100644 --- a/.gitignore +++ b/.gitignore @@ -42,4 +42,7 @@ db/ !**/.yarn/versions **/resources/ **/qdrant_storage -.qdrant-initialized \ No newline at end of file +.qdrant-initialized +**/__pycache__/ +*.pyc +*.pyo \ No newline at end of file diff --git a/app/src/main/index.ts b/app/src/main/index.ts index c37960a..ba81adc 100644 --- a/app/src/main/index.ts +++ b/app/src/main/index.ts @@ -278,6 +278,7 @@ function startQdrant() { fs.mkdirSync(snapshotsDir, { recursive: true }) qdrantProc = spawn(qdrantBin(), [], { + detached: process.platform !== 'win32', env: { ...process.env, QDRANT__STORAGE__STORAGE_PATH: storage, @@ -396,7 +397,7 @@ function startQueryServer() { path.join(root, 'vectordb'), ] - pyProcess = spawn(bin, args, { cwd, env: { ...process.env } }) + pyProcess = spawn(bin, args, { cwd, detached: process.platform !== 'win32', env: { ...process.env } }) pyProcess.stdout?.on('data', (d) => console.log('[py]', d.toString().trimEnd())) pyProcess.stderr?.on('data', (d) => console.error('[py]', d.toString().trimEnd())) @@ -741,19 +742,42 @@ app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit() }) -app.on('before-quit', async () => { - // Kill both child processes; /T on Windows takes their trees with them. +// Tear down the sidecars (Qdrant + Python query server). Both are spawned +// `detached` on POSIX so each leads its own process group — killing the +// negative PID takes the whole group (and any grandchildren) with it. SIGKILL +// can't be trapped, so a busy or mid-graceful-shutdown uvicorn can't linger and +// keep pegging the CPU. Idempotent: the nulled refs make repeat calls +// (before-quit → exit) no-ops. /T on Windows takes the child's tree instead. +function killSidecars(): void { for (const proc of [pyProcess, qdrantProc]) { - if (proc && !proc.killed) { + if (!proc || proc.pid == null || proc.killed) continue + try { if (process.platform === 'win32') { spawn('taskkill', ['/PID', String(proc.pid), '/F', '/T']) } else { - proc.kill() + process.kill(-proc.pid, 'SIGKILL') } + } catch { + try { proc.kill('SIGKILL') } catch { /* already gone */ } } } + pyProcess = null + qdrantProc = null +} + +app.on('before-quit', async () => { + killSidecars() if (rendererServer) { await new Promise((resolve) => rendererServer?.close(() => resolve())) } await providerManager.shutdown() -}) \ No newline at end of file +}) + +// `before-quit` doesn't fire on every exit path: when `electron-vite dev` +// restarts the main process on a file change — or you Ctrl-C the dev server — +// Electron receives a raw signal instead. Without these, each restart orphans +// the previous Qdrant + Python pair, which keep running and pile up until they +// saturate the CPU. `exit` is the last-ditch synchronous safety net. +process.on('exit', killSidecars) +process.on('SIGINT', () => { killSidecars(); process.exit(0) }) +process.on('SIGTERM', () => { killSidecars(); process.exit(0) }) \ No newline at end of file diff --git a/app/src/renderer/src/App.tsx b/app/src/renderer/src/App.tsx index 1e52893..756a33b 100644 --- a/app/src/renderer/src/App.tsx +++ b/app/src/renderer/src/App.tsx @@ -14,8 +14,8 @@ import { useChroma } from './hooks/useChroma' import { StartupView } from './views/StartupView' import { useSessionKernel } from './hooks/useSessionKernel' import { ConnectorsView } from './views/ConnectorsView' +import { AccountView } from './views/AccountView' import type { TasteSignal } from './types' -import { ConnectWallet } from './components/ConnectWallet' import { type SessionEvent } from './kernel/Visualizer' import { useChromaSync } from './hooks/useChromaSync' import KernelDebugHUD from './kernel/HUD' @@ -53,6 +53,7 @@ const MB_USER_AGENT = 'SOND3R/1.0.0 (https://fangorn.network)' const onTrackEndedRef = { current: () => { } } type AnalyzeTab = 'wiki' | 'neighborhood' | 'galaxy' +type DrawerSection = 'kernel' | 'account' | 'connectors' | 'agent' // ─── Root ────────────────────────────────────────────────────────────────────── @@ -132,10 +133,16 @@ function Main() { // ── UI state ────────────────────────────────────────────────────────────── const [showSettings, setShowSettings] = useState(false) + const [settingsSection, setSettingsSection] = useState('kernel') const [nowPlayingOpen, setNowPlayingOpen] = useState(false) const [showGalaxy, setShowGalaxy] = useState(false) const [showLocal, setShowLocal] = useState(false) + const openSettings = useCallback((section: DrawerSection) => { + setSettingsSection(section) + setShowSettings(true) + }, []) + // ── Kernel / chroma ─────────────────────────────────────────────────────── const { @@ -352,6 +359,9 @@ function Main() { wikiNavigate({ kind: 'results', query: q, mode: 'semantic' }) }, [wikiNavigate]) + const accountActive = showSettings && settingsSection === 'account' + const settingsActive = showSettings && settingsSection !== 'account' + // ───────────────────────────────────────────────────────────────────────── // Render // ───────────────────────────────────────────────────────────────────────── @@ -455,8 +465,12 @@ function Main() { {/* Settings icon */} @@ -464,10 +478,17 @@ function Main() { {/* Now-playing dot — only when the dataset is playable */} {canPlay && setNowPlayingOpen(true)} />} - {/* Wallet */} -
- -
+ {/* Account */} + {/* ── Content area ─────────────────────────────────────────────── */} @@ -507,6 +528,8 @@ function Main() { kernelState={kernel.state} entropy={entropy} activeProfileName={activeProfileName} + section={settingsSection} + onSectionChange={setSettingsSection} onLoadKernel={handleLoadKernel} onClose={() => setShowSettings(false)} onOpenGalaxy={() => { setShowGalaxy(true); setShowSettings(false) }} @@ -602,19 +625,31 @@ function NowPlayingDot({ onOpen }: { onOpen: () => void }) { ) } +// ─── AccountIcon ────────────────────────────────────────────────────────────── + +function AccountIcon() { + return ( + + + + + ) +} + // ─── Settings drawer ────────────────────────────────────────────────────────── interface SettingsDrawerProps { kernelState: any entropy: number activeProfileName: string + section: DrawerSection + onSectionChange: (s: DrawerSection) => void onLoadKernel: (snap: KernelSnapshot) => void onClose: () => void onOpenGalaxy: () => void } -function SettingsDrawer({ kernelState, entropy, activeProfileName, onClose, onOpenGalaxy }: SettingsDrawerProps) { - const [section, setSection] = useState<'kernel' | 'connectors' | 'agent'>('kernel') +function SettingsDrawer({ kernelState, entropy, activeProfileName, section, onSectionChange, onClose, onOpenGalaxy }: SettingsDrawerProps) { const vibeLabel = entropy > 0.65 ? { label: 'Wandering', desc: 'Discovery mode — pushing past familiar territory', color: '#5a3d9e' } @@ -628,8 +663,8 @@ function SettingsDrawer({ kernelState, entropy, activeProfileName, onClose, onOp {/* Drawer header */}
- {(['kernel', 'connectors', 'agent'] as const).map(s => ( -
)} + {section === 'account' && } {section === 'connectors' && } {section === 'agent' && }
diff --git a/app/src/renderer/src/views/AccountView.tsx b/app/src/renderer/src/views/AccountView.tsx new file mode 100644 index 0000000..3bcd872 --- /dev/null +++ b/app/src/renderer/src/views/AccountView.tsx @@ -0,0 +1,360 @@ +/** + * AccountView.tsx + * + * Account management — renders inline inside the settings drawer ("account" + * section). Sensitive flows (email update, MFA enrollment, key export, + * funding) all route through Privy's hosted modals; this view is the chrome + * around them plus read-only account/wallet state. + */ + +import { useCallback, useEffect, useState } from 'react' +import { + usePrivy, + useWallets, + useFundWallet, + useMfaEnrollment, + useExportWallet, +} from '@privy-io/react-auth' +import { createPublicClient, http, parseAbi, formatUnits, formatEther } from 'viem' +import { arbitrumSepolia } from 'viem/chains' + +const USDC_ADDRESS = '0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d' +const USDC_ABI = parseAbi(['function balanceOf(address) view returns (uint256)']) + +const publicClient = createPublicClient({ + chain: arbitrumSepolia, + transport: http('https://sepolia-rollup.arbitrum.io/rpc'), +}) + +const MFA_LABELS: Record = { + sms: 'SMS', + totp: 'Authenticator app', + passkey: 'Passkey', +} + +export function AccountView() { + const { + ready, authenticated, user, + login, logout, + linkEmail, updateEmail, + createWallet, + } = usePrivy() + const { wallets } = useWallets() + const { exportWallet } = useExportWallet() + const { showMfaEnrollmentModal } = useMfaEnrollment() + + const embeddedWallet = wallets.find(w => w.walletClientType === 'privy') + const address = (user?.wallet?.address ?? embeddedWallet?.address) as `0x${string}` | undefined + + // ── Balances ─────────────────────────────────────────────────────────────── + + const [usdcBalance, setUsdcBalance] = useState(null) + const [ethBalance, setEthBalance] = useState(null) + + const refreshBalances = useCallback(() => { + if (!address) { setUsdcBalance(null); setEthBalance(null); return } + publicClient.readContract({ + address: USDC_ADDRESS, abi: USDC_ABI, functionName: 'balanceOf', args: [address], + }) + .then(raw => setUsdcBalance(formatUnits(raw, 6))) + .catch(console.error) + publicClient.getBalance({ address }) + .then(raw => setEthBalance(formatEther(raw))) + .catch(console.error) + }, [address]) + + useEffect(() => { refreshBalances() }, [refreshBalances]) + + const { fundWallet } = useFundWallet({ onUserExited: refreshBalances }) + + // ── Copy-to-clipboard flash ──────────────────────────────────────────────── + + const [copied, setCopied] = useState(null) + const copy = useCallback((key: string, value: string) => { + navigator.clipboard.writeText(value) + setCopied(key) + setTimeout(() => setCopied(null), 1500) + }, []) + + // ── Sign-out confirmation ────────────────────────────────────────────────── + + const [confirmLogout, setConfirmLogout] = useState(false) + + // ───────────────────────────────────────────────────────────────────────── + + if (!ready) { + return loading account… + } + + if (!authenticated || !user) { + return ( +
+ + + Connect to manage your email, wallet, and security settings. + + +
+ ) + } + + const mfaMethods = user.mfaMethods ?? [] + + return ( +
+ + + {/* ── Identity ─────────────────────────────────────────────────────── */} +
+ + {user.email ? ( + + ) : ( + + )} + + + copy('did', user.id)} + /> + + + + +
+ + {/* ── Wallet ───────────────────────────────────────────────────────── */} +
: }> + {address ? ( + <> + + copy('addr', address)} + /> + + + + + + + + + + {embeddedWallet && ( + + )} + + + ) : ( + <> + + No wallet linked to this account yet. + + + + + + )} +
+ + {/* ── Security ─────────────────────────────────────────────────────── */} +
0} label={mfaMethods.length > 0 ? 'mfa on' : 'mfa off'} />}> + + 0 + ? mfaMethods.map(m => MFA_LABELS[m] ?? m).join(', ') + : 'not enrolled'} + dim={mfaMethods.length === 0} + /> + + + + +
+ + {/* ── Session ──────────────────────────────────────────────────────── */} +
+ + {confirmLogout ? ( + <> + + + + ) : ( + + )} + +
+
+ ) +} + +// ─── Layout atoms ───────────────────────────────────────────────────────────── + +function PanelHeading({ title, sub }: { title: string; sub: string }) { + return ( +
+

+ {title} +

+

+ {sub} +

+
+ ) +} + +function Section({ label, trailing, children }: { + label: string; trailing?: React.ReactNode; children: React.ReactNode +}) { + return ( +
+
+ {label} + {trailing} +
+ {children} +
+ ) +} + +function Row({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + {label} + + {children} +
+ ) +} + +function Value({ value, mono, dim }: { value: string; mono?: boolean; dim?: boolean }) { + return ( + + {value} + + ) +} + +function ValueWithAction({ value, mono, dim, actionLabel, onAction }: { + value: string; mono?: boolean; dim?: boolean; actionLabel: string; onAction: () => void +}) { + return ( + + + + + ) +} + +function Actions({ noBorder, children }: { noBorder?: boolean; children: React.ReactNode }) { + return ( +
+ {children} +
+ ) +} + +function Dot({ ok, label }: { ok?: boolean; label: string }) { + const color = ok ? 'var(--success)' : 'var(--fg4)' + return ( + + + + {label} + + + ) +} + +function Note({ children, style }: { children: React.ReactNode; style?: React.CSSProperties }) { + return ( +

+ {children} +

+ ) +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function truncateMiddle(s: string, keep: number): string { + if (s.length <= keep * 2 + 1) return s + return `${s.slice(0, keep)}…${s.slice(-Math.max(4, Math.floor(keep / 3)))}` +} diff --git a/app/vectordb/__pycache__/roles.cpython-312.pyc b/app/vectordb/__pycache__/roles.cpython-312.pyc deleted file mode 100644 index 943caac0b781d80e7a3482a2add734c46681bafd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13723 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 diff --git a/app/vectordb/__pycache__/server.cpython-312.pyc b/app/vectordb/__pycache__/server.cpython-312.pyc deleted file mode 100644 index 1edc185eb46fcea76c8b3fa63fd4c90733e62de2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 81263 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 AA^-pY diff --git a/app/vectordb/server.py b/app/vectordb/server.py index d3f7424..5a764c6 100644 --- a/app/vectordb/server.py +++ b/app/vectordb/server.py @@ -1,10 +1,28 @@ import certifi import os + +# ── Native thread governance ──────────────────────────────────────────────── +# numba (pulled in by UMAP), numpy/OpenBLAS, and onnxruntime (via fastembed) +# each start a pool of worker threads that *busy-wait* when idle. After a big +# job like the 860k-point UMAP build finishes, those leftover threads keep every +# core pinned at 100% for the life of the process. Make idle OpenMP threads +# sleep, and cap each pool to ~half the cores so a build can't lock up the whole +# machine. Must run before those libraries load (fastembed below pulls in +# onnxruntime at import time; numpy/numba load lazily on the first map build). +_HALF_CORES = str(max(1, (os.cpu_count() or 4) // 2)) +os.environ.setdefault("OMP_WAIT_POLICY", "PASSIVE") +os.environ.setdefault("OMP_NUM_THREADS", _HALF_CORES) +os.environ.setdefault("OPENBLAS_NUM_THREADS", _HALF_CORES) +os.environ.setdefault("MKL_NUM_THREADS", _HALF_CORES) +os.environ.setdefault("NUMEXPR_NUM_THREADS", _HALF_CORES) +os.environ.setdefault("NUMBA_NUM_THREADS", _HALF_CORES) + import io import sys os.environ['SSL_CERT_FILE'] = certifi.where() import argparse import asyncio +import threading import aiohttp import random import hashlib @@ -377,7 +395,8 @@ def _build_map_sync() -> dict: # TEXT (LEXICAL) SEARCH # --------------------------------------------------------------------------- -_text_index: list[dict] | None = None +_text_index: list[dict] | None = None +_text_index_lock = threading.Lock() def _build_text_index_sync() -> list[dict]: total = _collection_count() @@ -420,8 +439,13 @@ def _score_rec(rec: dict, q_l: str, tokens: list[str]) -> float: def _search_text_sync(q: str, limit: int, owner: str | None) -> list[dict]: global _text_index + # Built lazily on first text search. Guard with a lock + double-check so two + # concurrent searches (each on its own executor thread) don't both scroll the + # full 860k-record collection and build the index twice. if _text_index is None: - _text_index = _build_text_index_sync() + with _text_index_lock: + if _text_index is None: + _text_index = _build_text_index_sync() q_l = q.strip().lower() tokens = [tok for tok in q_l.split() if tok] recs = _text_index if not owner else [r for r in _text_index if r.get("owner") == owner] From 4fe346c565d054e40d1a4f422437ddf7073cd1db Mon Sep 17 00:00:00 2001 From: Coleman Irby Date: Thu, 11 Jun 2026 12:46:42 -0500 Subject: [PATCH 02/34] Add db warmup to loading screen --- app/src/main/index.ts | 10 +++- app/src/renderer/src/views/StartupView.tsx | 5 +- app/vectordb/server.py | 53 ++++++++++++++++++---- 3 files changed, 56 insertions(+), 12 deletions(-) diff --git a/app/src/main/index.ts b/app/src/main/index.ts index ba81adc..b7e00d7 100644 --- a/app/src/main/index.ts +++ b/app/src/main/index.ts @@ -571,7 +571,7 @@ function registerIpcHandlers() { }) ipcMain.handle('backend:is-ready', async () => { - return net.fetch('http://127.0.0.1:8080/health').then(r => r.ok).catch(() => false) + return net.fetch('http://127.0.0.1:8080/ready').then(r => r.ok).catch(() => false) }) } @@ -716,6 +716,14 @@ app.whenReady().then(async () => { await ensureCollection(mainWindow) startQueryServer() await waitForReady('http://127.0.0.1:8080/health') + // Hold the splash through warmup. /health is up as soon as the server binds, + // but its lexical index + embedding model + vector index aren't hot yet — + // /ready flips to 200 only when warmup finishes. Gating here means the user + // can't reach the search box until the very first query is fast, which is + // the whole point of a local-first search. Generous timeout: building the + // 860k-record lexical index can take a bit on a cold disk. + mainWindow.webContents.send('snapshot:status', 'warming') + await waitForReady('http://127.0.0.1:8080/ready', 300000) console.log('[boot] data backend ready') mainWindow.webContents.send('backend:ready') } catch (err) { diff --git a/app/src/renderer/src/views/StartupView.tsx b/app/src/renderer/src/views/StartupView.tsx index eab4a9f..e878c54 100644 --- a/app/src/renderer/src/views/StartupView.tsx +++ b/app/src/renderer/src/views/StartupView.tsx @@ -6,13 +6,14 @@ import { useEffect, useState, ReactNode } from 'react' // recovers it into the local Qdrant instance. We surface download progress as a // determinate bar and the decompress/recover stages as an indeterminate one. -type BootStage = 'init' | 'downloading' | 'decompressing' | 'recovering' | 'ready' | 'error' +type BootStage = 'init' | 'downloading' | 'decompressing' | 'recovering' | 'warming' | 'ready' | 'error' const STAGE_LABEL: Record = { init: 'initializing', downloading: 'fetching catalog from ipfs', decompressing:'decompressing snapshot', recovering: 'loading into vector store', + warming: 'warming search index', ready: 'ready', error: 'connection failed', } @@ -45,7 +46,7 @@ function useBootSequence(): BootState { s.onSnapshotProgress((d) => { setStage('downloading'); setProgress(d) }) s.onSnapshotStatus((status) => { - if (status === 'downloading' || status === 'decompressing' || status === 'recovering') { + if (status === 'downloading' || status === 'decompressing' || status === 'recovering' || status === 'warming') { setStage(status as BootStage) } }) diff --git a/app/vectordb/server.py b/app/vectordb/server.py index 5a764c6..562591d 100644 --- a/app/vectordb/server.py +++ b/app/vectordb/server.py @@ -34,7 +34,7 @@ from qdrant_client import QdrantClient, models as qmodels from fastembed import TextEmbedding -from fastapi import FastAPI, Query, BackgroundTasks, Request +from fastapi import FastAPI, Query, BackgroundTasks, Request, Response from fastapi.responses import StreamingResponse from fastapi.middleware.cors import CORSMiddleware from contextlib import asynccontextmanager @@ -398,6 +398,11 @@ def _build_map_sync() -> dict: _text_index: list[dict] | None = None _text_index_lock = threading.Lock() +# True once background warmup has finished: lexical index built, embedding model +# loaded, and the vector index paged into memory. The /ready boot gate keys off +# this so the loading screen can hold until the very first query is fast. +_warm = False + def _build_text_index_sync() -> list[dict]: total = _collection_count() if total == 0: @@ -437,18 +442,23 @@ def _score_rec(rec: dict, q_l: str, tokens: list[str]) -> float: score += 12 * (sum(1 for tok in tokens if tok in tags) / len(tokens)) return score -def _search_text_sync(q: str, limit: int, owner: str | None) -> list[dict]: +def _ensure_text_index() -> list[dict]: + """Build the lexical index once, lazily, under a lock + double-check so two + concurrent callers (each on its own executor thread) don't both scroll the + full 860k-record collection. Pre-called from warmup so the user's first text + search does only scoring, not the build.""" global _text_index - # Built lazily on first text search. Guard with a lock + double-check so two - # concurrent searches (each on its own executor thread) don't both scroll the - # full 860k-record collection and build the index twice. if _text_index is None: with _text_index_lock: if _text_index is None: _text_index = _build_text_index_sync() + return _text_index + +def _search_text_sync(q: str, limit: int, owner: str | None) -> list[dict]: + index = _ensure_text_index() q_l = q.strip().lower() tokens = [tok for tok in q_l.split() if tok] - recs = _text_index if not owner else [r for r in _text_index if r.get("owner") == owner] + recs = index if not owner else [r for r in index if r.get("owner") == owner] scored = [(s, r) for r in recs if (s := _score_rec(r, q_l, tokens)) > 0] scored.sort(key=lambda x: (-x[0], x[1]["_sort"])) return [{"id": r["id"], "fields": r["fields"], "owner": r.get("owner")} @@ -1056,7 +1066,7 @@ async def _flush(b): @asynccontextmanager async def lifespan(app: FastAPI): - global qdrant_client, embed_engine, vector_dim + global qdrant_client, embed_engine, vector_dim, _warm # `timeout` is the per-request gRPC deadline. The default (5s) is too tight # for a cold first query: against a freshly-recovered 1M-vector collection, # Qdrant has to page the HNSW graph + mmap'd segments into memory before it @@ -1126,14 +1136,24 @@ async def lifespan(app: FastAPI): # critical path means /health comes up immediately while warmup proceeds. if count > 0: asyncio.create_task(_warmup()) + else: + # Nothing to warm (empty / still-seeding collection) — don't gate boot on + # a warmup that will never run. + _warm = True yield async def _warmup() -> None: + global _warm loop = asyncio.get_event_loop() try: - # Force the embedding model to fully initialise. + # Pre-build the lexical index first: it's the first thing most users hit, + # and building it lazily on the first /search/text means an 860k-record + # scroll blocks that query. Doing it here (behind /health, at startup) + # makes the first text search do only scoring. + await loop.run_in_executor(None, _ensure_text_index) + # Force the embedding model to fully initialise (semantic search path). await loop.run_in_executor(None, lambda: _embed_texts(["warmup"])) # Touch the collection so its HNSW graph + segments page into memory. dim = vector_dim or MODEL_DIM_MAP.get(cfg.embedding_model, 768) @@ -1144,9 +1164,13 @@ async def _warmup() -> None: with_payload=False, with_vectors=False, )) - print("[startup] warmup complete — embeddings + vector index hot") + print("[startup] warmup complete — lexical index + embeddings + vector index hot") except Exception as e: print(f"[startup] warmup skipped: {e}") + finally: + # Release the boot gate either way: on success everything's hot; on + # failure the lazy fallbacks still work and we must not hang the splash. + _warm = True app = FastAPI(lifespan=lifespan) @@ -1494,8 +1518,19 @@ async def health(): "map_cached": _map_cache is not None, "map_computing": _map_computing, "text_indexed": _text_index is not None, + "warm": _warm, } +@app.get("/ready") +async def ready(response: Response): + """Boot gate. Returns 200 only once background warmup has finished (lexical + index + embedding model + vector index hot), so the loading screen can hold + until the very first query is fast. 503 while still warming.""" + if _warm: + return {"ready": True} + response.status_code = 503 + return {"ready": False} + @app.post("/reingest") async def reingest(): asyncio.create_task(ingest()) From 7264efb853c77dfc8536f91e9575235c5ce24a9c Mon Sep 17 00:00:00 2001 From: Coleman Irby Date: Thu, 11 Jun 2026 14:02:55 -0500 Subject: [PATCH 03/34] Indices are now built on startup. TODO: caching --- app/src/renderer/src/App.tsx | 8 +++++--- app/vectordb/server.py | 22 +++++++++++++++++++++- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/app/src/renderer/src/App.tsx b/app/src/renderer/src/App.tsx index 756a33b..3f93662 100644 --- a/app/src/renderer/src/App.tsx +++ b/app/src/renderer/src/App.tsx @@ -45,7 +45,6 @@ const BORDER2 = 'rgba(0,0,0,0.07)' const MONO = 'var(--font-mono,"Fragment Mono","DM Mono",monospace)' const SANS = 'var(--font-body,"Geist","Inter",sans-serif)' const DISP = 'var(--font-display,"Bebas Neue",sans-serif)' -const CHROMA_URL = 'http://localhost:8080' const SNAPSHOT_HISTORY_DEPTH = 5 const MB_USER_AGENT = 'SOND3R/1.0.0 (https://fangorn.network)' @@ -324,8 +323,11 @@ function Main() { kernel.embedText(query) .then(vec => applyKernelQuery(Array.from(vec), true)) .catch(e => console.warn('[app] startup embed failed:', e)) - // Warm the UMAP projection cache so the galaxy view loads faster - fetch(`${CHROMA_URL}/catalog/map`).catch(() => {}) + // The UMAP projection (/catalog/map) is deliberately NOT prefetched here: + // building it over 860k points is a multi-minute, CPU-saturating job, and + // running it in the background during normal search pegs the machine. The + // galaxy view (SoundTownView) builds it lazily on open — the only place it's + // actually needed. }, [chromaReady, loading, kernelTopGenres]) // eslint-disable-line react-hooks/exhaustive-deps function buildStartupQuery(topGenres?: string[]): string { diff --git a/app/vectordb/server.py b/app/vectordb/server.py index 562591d..49d473f 100644 --- a/app/vectordb/server.py +++ b/app/vectordb/server.py @@ -27,6 +27,7 @@ import random import hashlib import json +import logging import math import uuid import requests @@ -407,9 +408,12 @@ def _build_text_index_sync() -> list[dict]: total = _collection_count() if total == 0: return [] + print(f"[search/text] building lexical index over {total} records…") _ensure_role_map() out: list[dict] = [] - for pt_id, payload, _ in _scroll_all(with_vectors=False): + # Larger scroll pages → far fewer gRPC round-trips over the full collection, + # which is the slow part of the cold build. + for pt_id, payload, _ in _scroll_all(with_vectors=False, batch=20_000): fields = payload.get("fields", {}) or {} owner = payload.get("owner") subtitle = _role_subtitle(fields) @@ -1067,6 +1071,10 @@ async def _flush(b): @asynccontextmanager async def lifespan(app: FastAPI): global qdrant_client, embed_engine, vector_dim, _warm + # Silence the boot-poll access logs (main polls /ready every ~300ms until the + # warmup below flips it to 200). Done here, after uvicorn has set up its + # loggers, so the filter sticks. + logging.getLogger("uvicorn.access").addFilter(_AccessLogPollFilter()) # `timeout` is the per-request gRPC deadline. The default (5s) is too tight # for a cold first query: against a freshly-recovered 1M-vector collection, # Qdrant has to page the HNSW graph + mmap'd segments into memory before it @@ -1172,6 +1180,18 @@ async def _warmup() -> None: # failure the lazy fallbacks still work and we must not hang the splash. _warm = True + +class _AccessLogPollFilter(logging.Filter): + """Drop uvicorn access-log lines for the boot-gate poll endpoints. While the + splash waits for warmup, main hits /ready every ~300ms — without this the + console fills with '"GET /ready" 503' until the index is hot.""" + _QUIET = ("/ready", "/health") + + def filter(self, record: logging.LogRecord) -> bool: + msg = record.getMessage() + return not any(p in msg for p in self._QUIET) + + app = FastAPI(lifespan=lifespan) app.add_middleware( From 2c2048a364ad90321deffa0006b2ecfb8d37d687 Mon Sep 17 00:00:00 2001 From: Coleman Irby Date: Thu, 11 Jun 2026 15:39:49 -0500 Subject: [PATCH 04/34] Modify wildcard to get feat/whatever branches --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ef0363c..57b00ac 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -3,7 +3,7 @@ name: Build on: push: tags: ['v*'] - branches: ['*'] + branches: ['**'] workflow_dispatch: env: From a065ad6fca350bd0203dc30265cc418b767aa9aa Mon Sep 17 00:00:00 2001 From: Coleman Irby Date: Thu, 11 Jun 2026 16:21:22 -0500 Subject: [PATCH 05/34] Added percentage based progress bar to let users know that indexing is occurring --- app/src/main/index.ts | 28 +++++++++++- app/src/preload/index.d.ts | 1 + app/src/preload/index.ts | 8 ++++ app/src/renderer/src/views/StartupView.tsx | 35 ++++++++++++--- app/vectordb/server.py | 51 ++++++++++++++++++++-- 5 files changed, 112 insertions(+), 11 deletions(-) diff --git a/app/src/main/index.ts b/app/src/main/index.ts index b7e00d7..1b64399 100644 --- a/app/src/main/index.ts +++ b/app/src/main/index.ts @@ -307,6 +307,32 @@ async function waitForReady(url: string, timeoutMs = 60000): Promise { throw new Error(`timed out waiting for ${url}`) } +// Like waitForReady, but forwards the /ready 503 body to the splash so it can +// show real warmup progress (records scanned during the lexical build) instead +// of a blind spinner. Resolves when /ready flips to 200. +async function waitForWarmup(url: string, win: BrowserWindow, timeoutMs = 300000): Promise { + const start = Date.now() + while (Date.now() - start < timeoutMs) { + try { + const r = await net.fetch(url) + if (r.ok) return + if (r.status === 503) { + const body = await r.json().catch(() => null) + if (body && !win.isDestroyed()) { + win.webContents.send('snapshot:warmup', { + phase: typeof body.phase === 'string' ? body.phase : null, + pct: typeof body.pct === 'number' ? body.pct : null, + indexed: typeof body.indexed === 'number' ? body.indexed : 0, + total: typeof body.total === 'number' ? body.total : 0, + }) + } + } + } catch { /* not up yet */ } + await new Promise((r) => setTimeout(r, 300)) + } + throw new Error(`timed out waiting for ${url}`) +} + // Download the gzipped snapshot from IPFS, streaming progress to the window. async function downloadGz(cid: string, dest: string, win: BrowserWindow): Promise { const res = await fetch(`${SNAPSHOT_GATEWAY}/${cid}`) // global Node fetch @@ -723,7 +749,7 @@ app.whenReady().then(async () => { // the whole point of a local-first search. Generous timeout: building the // 860k-record lexical index can take a bit on a cold disk. mainWindow.webContents.send('snapshot:status', 'warming') - await waitForReady('http://127.0.0.1:8080/ready', 300000) + await waitForWarmup('http://127.0.0.1:8080/ready', mainWindow, 300000) console.log('[boot] data backend ready') mainWindow.webContents.send('backend:ready') } catch (err) { diff --git a/app/src/preload/index.d.ts b/app/src/preload/index.d.ts index 863d070..7ef5c06 100644 --- a/app/src/preload/index.d.ts +++ b/app/src/preload/index.d.ts @@ -6,6 +6,7 @@ import { FangornAgentApi } from '.'; export interface Sond3rBootApi { onSnapshotProgress: (cb: (d: { received: number; total: number }) => void) => void onSnapshotStatus: (cb: (s: string) => void) => void + onWarmupProgress: (cb: (d: { phase: string | null; pct: number | null; indexed: number; total: number }) => void) => void onBackendReady: (cb: () => void) => void onBackendError: (cb: (msg: string) => void) => void offBootEvents: () => void diff --git a/app/src/preload/index.ts b/app/src/preload/index.ts index 9e6f3cd..c6d3cc3 100644 --- a/app/src/preload/index.ts +++ b/app/src/preload/index.ts @@ -87,6 +87,13 @@ if (process.contextIsolated) { ipcRenderer.removeAllListeners('snapshot:status') ipcRenderer.on('snapshot:status', (_e, s: string) => cb(s)) }, + // warmup progress: { phase, pct, indexed, total }. pct is a real fraction + // (records scanned / total) during the lexical build, and null for the + // tail steps that have no measurable sub-progress. + onWarmupProgress: (cb: (d: { phase: string | null; pct: number | null; indexed: number; total: number }) => void) => { + ipcRenderer.removeAllListeners('snapshot:warmup') + ipcRenderer.on('snapshot:warmup', (_e, d) => cb(d)) + }, // backend fully up: Qdrant + collection + query server all ready onBackendReady: (cb: () => void) => { ipcRenderer.removeAllListeners('backend:ready') @@ -101,6 +108,7 @@ if (process.contextIsolated) { offBootEvents: () => { ipcRenderer.removeAllListeners('snapshot:progress') ipcRenderer.removeAllListeners('snapshot:status') + ipcRenderer.removeAllListeners('snapshot:warmup') ipcRenderer.removeAllListeners('backend:ready') ipcRenderer.removeAllListeners('backend:error') }, diff --git a/app/src/renderer/src/views/StartupView.tsx b/app/src/renderer/src/views/StartupView.tsx index e878c54..884660b 100644 --- a/app/src/renderer/src/views/StartupView.tsx +++ b/app/src/renderer/src/views/StartupView.tsx @@ -25,18 +25,22 @@ function fmtBytes(n: number): string { return `${n} B` } +interface WarmupState { phase: string | null; pct: number | null; indexed: number; total: number } + interface BootState { stage: BootStage - pct: number | null // download %, null when total is unknown / not downloading + pct: number | null // determinate %, null when there's no real number to show received: number total: number error: string | null done: boolean + warmup: WarmupState } function useBootSequence(): BootState { const [stage, setStage] = useState('init') const [progress, setProgress] = useState<{ received: number; total: number }>({ received: 0, total: 0 }) + const [warmup, setWarmup] = useState({ phase: null, pct: null, indexed: 0, total: 0 }) const [error, setError] = useState(null) useEffect(() => { @@ -50,6 +54,10 @@ function useBootSequence(): BootState { setStage(status as BootStage) } }) + s.onWarmupProgress((d) => { + setWarmup(d) + setStage((cur) => (cur === 'ready' || cur === 'error') ? cur : 'warming') + }) s.onBackendReady(() => setStage('ready')) s.onBackendError((msg) => { setStage('error'); setError(msg) }) @@ -63,11 +71,16 @@ function useBootSequence(): BootState { }, []) const { received, total } = progress + // Determinate % only where it's a real measurement: download bytes, or — during + // warmup — records scanned in the lexical build. The warmup tail steps report + // pct=null, so the bar honestly falls back to an indeterminate sweep for them. const pct = stage === 'downloading' && total > 0 ? Math.min(100, Math.round((received / total) * 100)) + : stage === 'warming' + ? warmup.pct : null - return { stage, pct, received, total, error, done: stage === 'ready' } + return { stage, pct, received, total, error, done: stage === 'ready', warmup } } // ── Main Startup View ────────────────────────────────────────────────────────── @@ -82,7 +95,12 @@ interface StartupViewProps { export function StartupView({ onReady, children }: StartupViewProps) { const [exiting, setExiting] = useState(false) - const { stage, pct, received, total, error, done } = useBootSequence() + const { stage, pct, received, total, error, done, warmup } = useBootSequence() + + // During warmup, name the actual step the server is on ("building text index", + // "loading embedding model", …) rather than a generic label — the phase comes + // straight from the backend so it reflects what's really running. + const label = stage === 'warming' && warmup.phase ? warmup.phase : STAGE_LABEL[stage] // Let the user through once the catalog is ready, or if the boot failed (so a // backend hiccup never hard-locks the splash — the app surfaces its own retry). @@ -136,7 +154,7 @@ export function StartupView({ onReady, children }: StartupViewProps) { {/* Stage label + percent */}
- {STAGE_LABEL[stage]}_ + {label}_ {pct !== null ? `${pct}%` : '— —'} @@ -152,9 +170,14 @@ export function StartupView({ onReady, children }: StartupViewProps) { ))}
- {/* Byte readout */} + {/* Raw count readout — verifiable units behind the bar: bytes while + downloading, records while the lexical index builds. */}
- {stage === 'downloading' && total > 0 ? `${fmtBytes(received)} / ${fmtBytes(total)}` : ' '} + {stage === 'downloading' && total > 0 + ? `${fmtBytes(received)} / ${fmtBytes(total)}` + : stage === 'warming' && warmup.total > 0 + ? `${warmup.indexed.toLocaleString()} / ${warmup.total.toLocaleString()} records` + :' '}
)} diff --git a/app/vectordb/server.py b/app/vectordb/server.py index 49d473f..cb9cd2e 100644 --- a/app/vectordb/server.py +++ b/app/vectordb/server.py @@ -404,10 +404,25 @@ def _build_map_sync() -> dict: # this so the loading screen can hold until the very first query is fast. _warm = False +# Real, not-cosmetic warmup progress for the boot splash. The dominant warmup cost +# is the lexical build, which scans a *known* number of records — so the splash can +# show `_warm_indexed / _warm_total` as a literal count of work completed, not an +# estimate. The two tail steps (embedding-model load, vector-index touch) have no +# measurable sub-progress; `_warm_phase` names the current step so the UI can show +# an honest indeterminate state for those instead of fabricating a percentage. +_warm_phase = "starting" +_warm_indexed = 0 +_warm_total = 0 + def _build_text_index_sync() -> list[dict]: + global _warm_indexed, _warm_total total = _collection_count() if total == 0: return [] + # Publish the denominator before the scroll starts so the splash bar is a real + # fraction (records folded in / total) from the first tick onward. + _warm_total = total + _warm_indexed = 0 print(f"[search/text] building lexical index over {total} records…") _ensure_role_map() out: list[dict] = [] @@ -428,6 +443,11 @@ def _build_text_index_sync() -> list[dict]: "_tags_l": " ".join(t.lower() for t in tags), "_sort": (subtitle.lower(), title.lower()), }) + # Coarse progress tick — every 5k records is smooth enough for the bar + # without churning a global per row over 860k iterations. + if len(out) % 5_000 == 0: + _warm_indexed = len(out) + _warm_indexed = len(out) print(f"[search/text] built lexical index: {len(out)} records") return out @@ -1153,17 +1173,22 @@ async def lifespan(app: FastAPI): async def _warmup() -> None: - global _warm + global _warm, _warm_phase loop = asyncio.get_event_loop() try: # Pre-build the lexical index first: it's the first thing most users hit, # and building it lazily on the first /search/text means an 860k-record # scroll blocks that query. Doing it here (behind /health, at startup) - # makes the first text search do only scoring. + # makes the first text search do only scoring. This is the long phase and + # the one with a real percentage (records scanned / total). + _warm_phase = "building text index" await loop.run_in_executor(None, _ensure_text_index) # Force the embedding model to fully initialise (semantic search path). + # No measurable sub-progress — the UI shows this as an indeterminate step. + _warm_phase = "loading embedding model" await loop.run_in_executor(None, lambda: _embed_texts(["warmup"])) # Touch the collection so its HNSW graph + segments page into memory. + _warm_phase = "warming vector index" dim = vector_dim or MODEL_DIM_MAP.get(cfg.embedding_model, 768) await loop.run_in_executor(None, lambda: qdrant_client.query_points( collection_name=cfg.collection, @@ -1178,6 +1203,7 @@ async def _warmup() -> None: finally: # Release the boot gate either way: on success everything's hot; on # failure the lazy fallbacks still work and we must not hang the splash. + _warm_phase = "ready" _warm = True @@ -1539,17 +1565,34 @@ async def health(): "map_computing": _map_computing, "text_indexed": _text_index is not None, "warm": _warm, + "warm_phase": _warm_phase, + "warm_indexed": _warm_indexed, + "warm_total": _warm_total, } @app.get("/ready") async def ready(response: Response): """Boot gate. Returns 200 only once background warmup has finished (lexical index + embedding model + vector index hot), so the loading screen can hold - until the very first query is fast. 503 while still warming.""" + until the very first query is fast. 503 while still warming. + + The 503 body carries *real* progress: `phase` names the current step and, + during the lexical build, `indexed`/`total`/`pct` report records scanned so + far. `pct` is non-null only while that build runs — the tail steps have no + measurable sub-progress, so the splash shows them as an indeterminate state + rather than a fabricated number.""" if _warm: return {"ready": True} response.status_code = 503 - return {"ready": False} + building = _warm_phase == "building text index" + pct = round(100 * _warm_indexed / _warm_total) if (building and _warm_total) else None + return { + "ready": False, + "phase": _warm_phase, + "indexed": _warm_indexed, + "total": _warm_total, + "pct": pct, + } @app.post("/reingest") async def reingest(): From 8c6223bb1ae8c746169a338013f6f64f8e9716c3 Mon Sep 17 00:00:00 2001 From: Coleman Irby Date: Thu, 11 Jun 2026 19:56:07 -0500 Subject: [PATCH 06/34] Added ability for users to enter app while index is building. Account management now has an advanced section for crypto specific settings and information --- app/.env.example | 6 + app/src/renderer/src/App.css | 12 ++ app/src/renderer/src/App.tsx | 23 +++- .../renderer/src/components/ConnectWallet.tsx | 30 +---- .../renderer/src/components/IndexingBar.tsx | 74 +++++++++++ app/src/renderer/src/hooks/useX402fFetch.ts | 24 ++-- app/src/renderer/src/lib/network.ts | 105 ++++++++++++++++ app/src/renderer/src/main.tsx | 12 +- .../renderer/src/providers/BootProvider.tsx | 116 ++++++++++++++++++ app/src/renderer/src/views/AccountView.tsx | 99 ++++++++++----- app/src/renderer/src/views/StartupView.tsx | 112 ++++------------- app/src/renderer/src/views/TrackWikiView.tsx | 15 +++ 12 files changed, 465 insertions(+), 163 deletions(-) create mode 100644 app/src/renderer/src/components/IndexingBar.tsx create mode 100644 app/src/renderer/src/lib/network.ts create mode 100644 app/src/renderer/src/providers/BootProvider.tsx diff --git a/app/.env.example b/app/.env.example index 83665c9..01e385d 100644 --- a/app/.env.example +++ b/app/.env.example @@ -8,6 +8,12 @@ VITE_GRAPH_API_KEY= VITE_ARBITRUM_SEPOLIA_RPC_URL= +# ── Network / wallet (see src/renderer/src/lib/network.ts) ──────────────────── +# Active chain + USDC contracts. `yarn dev` defaults to arbitrumSepolia (testnet); +# packaged builds default to arbitrum (mainnet). Set to override either way. +# VITE_NETWORK=arbitrumSepolia # or: arbitrum +# VITE_ARBITRUM_RPC_URL= # mainnet RPC (defaults to public arb1 endpoint) + # ── Agent ──────────────────────────────────────────────────────────────────── VITE_USE_AGENT=true diff --git a/app/src/renderer/src/App.css b/app/src/renderer/src/App.css index 86fa46a..bf746ee 100644 --- a/app/src/renderer/src/App.css +++ b/app/src/renderer/src/App.css @@ -564,6 +564,18 @@ textarea { } } +/* Shared with the splash (StartupView). Defined here too so the in-app + IndexingBar — which replaces the search field while the index builds — + animates after the splash unmounts. */ +@keyframes sonderBootSlide { + 0% { left: -32%; } + 100% { left: 100%; } +} +@keyframes sonderBlink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} + .upload-error { font-size: 11px; color: var(--err); diff --git a/app/src/renderer/src/App.tsx b/app/src/renderer/src/App.tsx index 3f93662..02678ff 100644 --- a/app/src/renderer/src/App.tsx +++ b/app/src/renderer/src/App.tsx @@ -12,6 +12,8 @@ import { SpotifyProvider, useSpotifyContext } from './context/SpotifyContext' import { useFangornAgent } from './hooks/useFangornAgent' import { useChroma } from './hooks/useChroma' import { StartupView } from './views/StartupView' +import { BootProvider, useBoot } from './providers/BootProvider' +import { IndexingBar } from './components/IndexingBar' import { useSessionKernel } from './hooks/useSessionKernel' import { ConnectorsView } from './views/ConnectorsView' import { AccountView } from './views/AccountView' @@ -57,6 +59,16 @@ type DrawerSection = 'kernel' | 'account' | 'connectors' | 'agent' // ─── Root ────────────────────────────────────────────────────────────────────── export default function App() { + // BootProvider wraps everything so the backend boot/index subscription is + // shared and survives the splash → app transition (user can enter mid-index). + return ( + + + + ) +} + +function AppRoot() { const [booted, setBooted] = useState(() => localStorage.getItem('booted') === 'true') const { ready, authenticated, login } = usePrivy() @@ -84,6 +96,10 @@ export default function App() { function Main() { const spotify = useSpotify(() => onTrackEndedRef.current()) + // Search is gated until the backend's text index is built; the IndexingBar + // stands in for the search field meanwhile. + const { indexing } = useBoot() + const [sessionHistory, setSessionHistory] = useState([]) const [entropy, setEntropy] = useState(0.2) const kernel = useSessionKernel({ entropy }) @@ -429,8 +445,11 @@ function Main() { })} - {/* Search input — hidden on home (the home page has its own prominent search) */} - {wikiView.kind !== 'home' && ( + {/* Search input — hidden on home (the home page has its own prominent + search). While the index is building, the search is replaced by a + progress bar so it can't be used until queries are fast. */} + {wikiView.kind !== 'home' && indexing && } + {wikiView.kind !== 'home' && !indexing && (
{ } }) const [balance, setBalance] = useState(null) @@ -48,8 +32,6 @@ export function ConnectWallet() { ?? (user as any)?.google?.email ?? (address ? `${address.slice(0, 6)}...${address.slice(-4)}` : '') - const embeddedWallet = wallets.find((w) => w.walletClientType === 'privy') - function handleCopyAddress() { if (!address || hidden) return navigator.clipboard.writeText(address) @@ -60,12 +42,12 @@ export function ConnectWallet() { useEffect(() => { if (!address) { setBalance(null); return } publicClient.readContract({ - address: USDC_ADDRESS, + address: NETWORK.usdc, abi: USDC_ABI, functionName: 'balanceOf', args: [address], }) - .then(raw => setBalance(formatUnits(raw, 6))) + .then(raw => setBalance(formatUnits(raw, USDC_DECIMALS))) .catch(console.error) }, [address]) @@ -77,7 +59,7 @@ export function ConnectWallet() { {hidden ? null : copied ? 'copied!' : label} {balance !== null && !hidden && ( - address && fundWallet({ address })}> + address && fundWallet({ address, options: usdcFundingOptions() })}> $ {Number(balance).toFixed(2)} USDC diff --git a/app/src/renderer/src/components/IndexingBar.tsx b/app/src/renderer/src/components/IndexingBar.tsx new file mode 100644 index 0000000..4b890f2 --- /dev/null +++ b/app/src/renderer/src/components/IndexingBar.tsx @@ -0,0 +1,74 @@ +/** + * IndexingBar.tsx + * + * Shown in place of the search UI while the backend is still building its search + * index (see BootProvider). Two variants: + * - `home` — prominent block that replaces the home page's search box. + * - `header` — compact inline bar that replaces the header search input. + * + * Reuses the splash's `sonderBootSlide` / `sonderBlink` keyframes (now global in + * App.css) so the determinate/indeterminate bar matches the loading screen. + */ + +import { useBoot } from '../providers/BootProvider' + +// Editorial light tokens — match App.tsx / TrackWikiView. +const BG1 = '#f0ece5' +const FG2 = '#4a4440' +const FG3 = '#766e66' +const FG4 = '#a09890' +const ACCENT = '#b83030' +const BORDER2 = 'rgba(0,0,0,0.07)' +const MONO = 'var(--font-mono,"Fragment Mono","DM Mono",monospace)' + +function Bar({ pct, height }: { pct: number | null; height: number }) { + return ( +
+ {pct === null ? ( +
+ ) : ( +
+ )} +
+ ) +} + +export function IndexingBar({ variant = 'home' }: { variant?: 'home' | 'header' }) { + const { pct, label, warmup } = useBoot() + const count = warmup.total > 0 + ? `${warmup.indexed.toLocaleString()} / ${warmup.total.toLocaleString()} records` + : null + + if (variant === 'header') { + return ( +
+ + {label} + +
+ + {pct !== null ? `${pct}%` : '——'} + +
+ ) + } + + return ( +
+
+
+ {label}_ + {pct !== null ? `${pct}%` : '— —'} +
+ + {count && ( +
+ {count} +
+ )} +
+
+ ) +} diff --git a/app/src/renderer/src/hooks/useX402fFetch.ts b/app/src/renderer/src/hooks/useX402fFetch.ts index 911055c..7d53929 100644 --- a/app/src/renderer/src/hooks/useX402fFetch.ts +++ b/app/src/renderer/src/hooks/useX402fFetch.ts @@ -1,17 +1,8 @@ import { FangornX402Middleware } from '@fangorn-network/fetch' -import { FangornConfig } from '@fangorn-network/sdk' import { useWallets } from '@privy-io/react-auth' import { useEffect, useState } from 'react' import { createWalletClient, custom, keccak256, toBytes, type Hex } from 'viem' - - -const MIDDLEWARE_CONFIG = { - usdcContractAddress: '0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d' as const, - usdcDomainName: 'USD Coin', - facilitatorAddress: '0x147c24c5Ea2f1EE1ac42AD16820De23bBba45Ef6' as const, - config: FangornConfig.ArbitrumSepolia, - domain: window.location.host, -} +import { NETWORK } from '../lib/network' export function useFangornMiddleware() { const { wallets } = useWallets() @@ -19,13 +10,16 @@ export function useFangornMiddleware() { useEffect(() => { const wallet = wallets[0] - if (!wallet) { setMiddleware(null); return } + // Fangorn protocol (x402 paywalls) is only deployed where `fangorn` + + // `facilitator` are configured — Arbitrum Sepolia today. + if (!wallet || !NETWORK.fangorn || !NETWORK.facilitator) { setMiddleware(null); return } + const { fangorn, facilitator } = NETWORK wallet.getEthereumProvider() .then(async provider => { const walletClient = createWalletClient({ account: wallet.address as `0x${string}`, - chain: FangornConfig.ArbitrumSepolia.chain, + chain: fangorn.chain, transport: custom(provider), }) as any @@ -53,7 +47,11 @@ export function useFangornMiddleware() { return FangornX402Middleware.create({ walletClient: walletClient as any, // identity, - ...MIDDLEWARE_CONFIG, + usdcContractAddress: NETWORK.usdc, + usdcDomainName: NETWORK.usdcDomainName, + facilitatorAddress: facilitator, + config: fangorn, + domain: window.location.host, }) }) .then(setMiddleware) diff --git a/app/src/renderer/src/lib/network.ts b/app/src/renderer/src/lib/network.ts new file mode 100644 index 0000000..80e4442 --- /dev/null +++ b/app/src/renderer/src/lib/network.ts @@ -0,0 +1,105 @@ +/** + * network.ts + * + * Single source of truth for which chain + token contracts the app talks to. + * + * `yarn dev` (electron-vite dev) sets `import.meta.env.DEV`, so development + * always defaults to **Arbitrum Sepolia** testnet USDC — you never touch + * real-money mainnet contracts while hacking. Production builds default to + * Arbitrum One. Force either with `VITE_NETWORK=arbitrumSepolia | arbitrum`. + * + * Note: the Fangorn protocol (publish / x402) is currently only deployed on + * Arbitrum Sepolia. On mainnet those flows degrade gracefully (`fangorn` / + * `facilitator` are undefined); wallet balances + funding still work. + */ + +import { createPublicClient, http, parseAbi, type Hex } from 'viem' +import { arbitrum, arbitrumSepolia, type Chain } from 'viem/chains' +import { FangornConfig, type AppConfig } from '@fangorn-network/sdk' +import type { FundWalletConfig } from '@privy-io/react-auth' + +/** ERC-20 read ABI shared by USDC balance lookups. */ +export const USDC_ABI = parseAbi(['function balanceOf(address) view returns (uint256)']) + +/** USDC has 6 decimals on every chain Circle deploys to. */ +export const USDC_DECIMALS = 6 + +export type NetworkKey = 'arbitrumSepolia' | 'arbitrum' + +export interface NetworkConfig { + key: NetworkKey + label: string + chain: Chain + testnet: boolean + /** Circle-native USDC contract on this chain. */ + usdc: Hex + /** EIP-712 domain name for USDC — used by x402 payment signing. */ + usdcDomainName: string + /** JSON-RPC endpoint (env override falls back to the public RPC). */ + rpcUrl: string + /** x402 settlement facilitator — only set where Fangorn is deployed. */ + facilitator?: Hex + /** Fangorn protocol registry config — only set where Fangorn is deployed. */ + fangorn?: AppConfig +} + +const env = (import.meta as any).env + +const NETWORKS: Record = { + arbitrumSepolia: { + key: 'arbitrumSepolia', + label: 'Arbitrum Sepolia', + chain: arbitrumSepolia, + testnet: true, + usdc: '0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d', + usdcDomainName: 'USD Coin', + rpcUrl: env.VITE_ARBITRUM_SEPOLIA_RPC_URL || 'https://sepolia-rollup.arbitrum.io/rpc', + facilitator: '0x147c24c5Ea2f1EE1ac42AD16820De23bBba45Ef6', + fangorn: FangornConfig.ArbitrumSepolia, + }, + arbitrum: { + key: 'arbitrum', + label: 'Arbitrum One', + chain: arbitrum, + testnet: false, + // Circle-native USDC on Arbitrum One (not bridged USDC.e). + usdc: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + usdcDomainName: 'USD Coin', + rpcUrl: env.VITE_ARBITRUM_RPC_URL || 'https://arb1.arbitrum.io/rpc', + // Fangorn protocol + x402 facilitator are not yet deployed on mainnet. + }, +} + +function resolveNetwork(): NetworkConfig { + const override = (env.VITE_NETWORK as string | undefined)?.trim() + if (override && override in NETWORKS) return NETWORKS[override as NetworkKey] + // `yarn dev` → import.meta.env.DEV → testnet; packaged builds → mainnet. + return env.DEV ? NETWORKS.arbitrumSepolia : NETWORKS.arbitrum +} + +/** The active network for this build/run. */ +export const NETWORK = resolveNetwork() + +/** Shared read-only client for balance lookups on the active chain. */ +export const publicClient = createPublicClient({ + chain: NETWORK.chain, + transport: http(NETWORK.rpcUrl), +}) + +/** + * Privy on-ramp config that funds USDC on the active chain. Pass to + * `fundWallet({ address, options: usdcFundingOptions() })`. + * + * - Testnet (`yarn dev`): only devs use it, so just open the standard modal + * and let them pick (card on-ramps are sandboxed here anyway). + * - Mainnet: casual users who likely don't hold crypto — open straight to the + * familiar debit/credit-card on-ramp (MoonPay) so "send USD → get USDC" + * is one tap, rather than surfacing exchange/external-wallet options. + * + * @param amount default amount (in USDC) to pre-fill in the funding modal. + */ +export function usdcFundingOptions(amount = '10'): FundWalletConfig { + const base = { chain: { id: NETWORK.chain.id }, asset: 'USDC' as const, amount } + if (NETWORK.testnet) return base + return { ...base, defaultFundingMethod: 'card', card: { preferredProvider: 'moonpay' } } +} diff --git a/app/src/renderer/src/main.tsx b/app/src/renderer/src/main.tsx index 103bb29..39f08ca 100644 --- a/app/src/renderer/src/main.tsx +++ b/app/src/renderer/src/main.tsx @@ -5,8 +5,8 @@ import ReactDOM from 'react-dom/client'; // window.Buffer = Buffer import './index.css'; import { PrivyProvider } from '@privy-io/react-auth'; -import { arbitrumSepolia } from 'viem/chains'; import App from './App'; +import { NETWORK } from './lib/network'; // import * as THREE from 'three' // ;(window as any).THREE = THREE @@ -25,12 +25,14 @@ root.render( }, fundingMethodConfig: { moonpay: { - useSandbox: true, // false for production + // Sandbox on testnet, live on-ramp on mainnet. + useSandbox: NETWORK.testnet, }, }, - // Default chain — Arbitrum Sepolia for fangorn.music - defaultChain: arbitrumSepolia, - supportedChains: [arbitrumSepolia], + // Active chain — Arbitrum Sepolia under `yarn dev`, Arbitrum One in + // packaged builds (override via VITE_NETWORK). See lib/network.ts. + defaultChain: NETWORK.chain, + supportedChains: [NETWORK.chain], appearance: { theme: 'dark', accentColor: '#c7e8b3', diff --git a/app/src/renderer/src/providers/BootProvider.tsx b/app/src/renderer/src/providers/BootProvider.tsx new file mode 100644 index 0000000..d2a41b1 --- /dev/null +++ b/app/src/renderer/src/providers/BootProvider.tsx @@ -0,0 +1,116 @@ +/** + * BootProvider.tsx + * + * Single subscription to the backend boot lifecycle (`window.sond3r`), shared by + * the splash (StartupView) and the running app (IndexingBar). It has to live + * above both because the preload bridge keeps only ONE listener per event + * (`ipcRenderer.removeAllListeners` + `on`), and because warmup keeps streaming + * after the user enters early — the splash unmounting must not tear the + * subscription down. + * + * First run fetches a ~1.5GB catalog snapshot from IPFS, recovers it into the + * local Qdrant instance, then warms the search index. The long warmup phase + * ("building text index") is the point at which we let users into the app while + * the index finishes building in the background. + */ + +import { createContext, useContext, useEffect, useState, type ReactNode } from 'react' + +export type BootStage = + | 'init' | 'downloading' | 'decompressing' | 'recovering' | 'warming' | 'ready' | 'error' + +const STAGE_LABEL: Record = { + init: 'initializing', + downloading: 'fetching catalog from ipfs', + decompressing: 'decompressing snapshot', + recovering: 'loading into vector store', + warming: 'warming search index', + ready: 'ready', + error: 'connection failed', +} + +export interface WarmupState { phase: string | null; pct: number | null; indexed: number; total: number } + +export interface BootState { + stage: BootStage + /** Determinate %, or null when there's no real number to show (indeterminate). */ + pct: number | null + received: number + total: number + error: string | null + warmup: WarmupState + /** Backend fully warmed — /ready returned 200, so the first query is fast. */ + ready: boolean + /** Backend still building its search index; the search UI is gated until done. */ + indexing: boolean + /** Enough has loaded to let the user in — warmup (the text-index build) has begun. */ + canEnter: boolean + /** Human-readable label for the current stage / warmup phase. */ + label: string +} + +function useBootSequence(): BootState { + const [stage, setStage] = useState('init') + const [progress, setProgress] = useState<{ received: number; total: number }>({ received: 0, total: 0 }) + const [warmup, setWarmup] = useState({ phase: null, pct: null, indexed: 0, total: 0 }) + const [error, setError] = useState(null) + + useEffect(() => { + const s = window.sond3r + // No bridge (e.g. a plain browser dev session) → nothing to wait on. + if (!s) { setStage('ready'); return } + + s.onSnapshotProgress((d) => { setStage('downloading'); setProgress(d) }) + s.onSnapshotStatus((status) => { + if (status === 'downloading' || status === 'decompressing' || status === 'recovering' || status === 'warming') { + setStage(status as BootStage) + } + }) + s.onWarmupProgress((d) => { + setWarmup(d) + setStage((cur) => (cur === 'ready' || cur === 'error') ? cur : 'warming') + }) + s.onBackendReady(() => setStage('ready')) + s.onBackendError((msg) => { setStage('error'); setError(msg) }) + + // Cached fast path: the backend may already be up before our listeners + // attached. Reconcile once on mount. + s.isBackendReady() + .then((ready) => { if (ready) setStage((cur) => (cur === 'init' ? 'ready' : cur)) }) + .catch(() => { }) + + return () => s.offBootEvents() + }, []) + + const { received, total } = progress + // Determinate % only where it's a real measurement: download bytes, or — during + // warmup — records scanned in the lexical build. The warmup tail steps report + // pct=null, so the bar honestly falls back to an indeterminate sweep for them. + const pct = stage === 'downloading' && total > 0 + ? Math.min(100, Math.round((received / total) * 100)) + : stage === 'warming' + ? warmup.pct + : null + + const ready = stage === 'ready' + const indexing = !ready && stage !== 'error' + // Let users in once the index build (warmup) has started — they don't need to + // wait for the embedding model + vector index tail to finish. + const canEnter = ready || stage === 'error' || stage === 'warming' + const label = stage === 'warming' && warmup.phase ? warmup.phase : STAGE_LABEL[stage] + + return { stage, pct, received, total, error, warmup, ready, indexing, canEnter, label } +} + +const BootContext = createContext(null) + +export function BootProvider({ children }: { children: ReactNode }) { + const boot = useBootSequence() + return {children} +} + +export function useBoot(): BootState { + const ctx = useContext(BootContext) + if (!ctx) throw new Error('useBoot must be used within ') + return ctx +} diff --git a/app/src/renderer/src/views/AccountView.tsx b/app/src/renderer/src/views/AccountView.tsx index 3bcd872..2ce495a 100644 --- a/app/src/renderer/src/views/AccountView.tsx +++ b/app/src/renderer/src/views/AccountView.tsx @@ -15,16 +15,8 @@ import { useMfaEnrollment, useExportWallet, } from '@privy-io/react-auth' -import { createPublicClient, http, parseAbi, formatUnits, formatEther } from 'viem' -import { arbitrumSepolia } from 'viem/chains' - -const USDC_ADDRESS = '0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d' -const USDC_ABI = parseAbi(['function balanceOf(address) view returns (uint256)']) - -const publicClient = createPublicClient({ - chain: arbitrumSepolia, - transport: http('https://sepolia-rollup.arbitrum.io/rpc'), -}) +import { formatUnits, formatEther } from 'viem' +import { NETWORK, USDC_ABI, USDC_DECIMALS, publicClient, usdcFundingOptions } from '../lib/network' const MFA_LABELS: Record = { sms: 'SMS', @@ -54,9 +46,9 @@ export function AccountView() { const refreshBalances = useCallback(() => { if (!address) { setUsdcBalance(null); setEthBalance(null); return } publicClient.readContract({ - address: USDC_ADDRESS, abi: USDC_ABI, functionName: 'balanceOf', args: [address], + address: NETWORK.usdc, abi: USDC_ABI, functionName: 'balanceOf', args: [address], }) - .then(raw => setUsdcBalance(formatUnits(raw, 6))) + .then(raw => setUsdcBalance(formatUnits(raw, USDC_DECIMALS))) .catch(console.error) publicClient.getBalance({ address }) .then(raw => setEthBalance(formatEther(raw))) @@ -138,32 +130,22 @@ export function AccountView() { {/* ── Wallet ───────────────────────────────────────────────────────── */} + {/* Casual-user view: a plain dollar balance + "add funds". Anything + crypto-flavoured (address, chain, gas, key export) lives under + Advanced below. */}
: }> {address ? ( <> - - copy('addr', address)} - /> - - + - - - - - {embeddedWallet && ( - - )} ) : ( @@ -180,6 +162,35 @@ export function AccountView() { )}
+ {/* ── Advanced (crypto details) ────────────────────────────────────── */} + {/* Collapsed by default on mainnet so casual users aren't shown raw + addresses / gas; expanded on testnet where only devs are looking. */} + {address && ( + + + copy('addr', address)} + /> + + + + + + + + {embeddedWallet && ( + + + + )} + + )} + {/* ── Security ─────────────────────────────────────────────────────── */}
0} label={mfaMethods.length > 0 ? 'mfa on' : 'mfa off'} />}> @@ -264,6 +275,34 @@ function Section({ label, trailing, children }: { ) } +function CollapsibleSection({ label, defaultOpen, trailing, children }: { + label: string; defaultOpen?: boolean; trailing?: React.ReactNode; children: React.ReactNode +}) { + const [open, setOpen] = useState(defaultOpen ?? false) + return ( +
+
setOpen(o => !o)} + style={{ + display: 'flex', alignItems: 'center', justifyContent: 'space-between', + padding: 'var(--sp-3) var(--sp-4)', cursor: 'pointer', userSelect: 'none', + }}> + + + {label} + + {trailing} +
+ {open && children} +
+ ) +} + function Row({ label, children }: { label: string; children: React.ReactNode }) { return (
= { - init: 'initializing', - downloading: 'fetching catalog from ipfs', - decompressing:'decompressing snapshot', - recovering: 'loading into vector store', - warming: 'warming search index', - ready: 'ready', - error: 'connection failed', -} +// The backend boot lifecycle now lives in BootProvider (shared with the running +// app's IndexingBar). This view just renders it: first run fetches a ~1.5GB +// catalog snapshot from IPFS, recovers it into Qdrant, then warms the search +// index. Download progress is a determinate bar; decompress/recover are an +// indeterminate sweep. function fmtBytes(n: number): string { if (n >= 1e9) return (n / 1e9).toFixed(2) + ' GB' @@ -25,64 +15,6 @@ function fmtBytes(n: number): string { return `${n} B` } -interface WarmupState { phase: string | null; pct: number | null; indexed: number; total: number } - -interface BootState { - stage: BootStage - pct: number | null // determinate %, null when there's no real number to show - received: number - total: number - error: string | null - done: boolean - warmup: WarmupState -} - -function useBootSequence(): BootState { - const [stage, setStage] = useState('init') - const [progress, setProgress] = useState<{ received: number; total: number }>({ received: 0, total: 0 }) - const [warmup, setWarmup] = useState({ phase: null, pct: null, indexed: 0, total: 0 }) - const [error, setError] = useState(null) - - useEffect(() => { - const s = window.sond3r - // No bridge (e.g. a plain browser dev session) → nothing to wait on. - if (!s) { setStage('ready'); return } - - s.onSnapshotProgress((d) => { setStage('downloading'); setProgress(d) }) - s.onSnapshotStatus((status) => { - if (status === 'downloading' || status === 'decompressing' || status === 'recovering' || status === 'warming') { - setStage(status as BootStage) - } - }) - s.onWarmupProgress((d) => { - setWarmup(d) - setStage((cur) => (cur === 'ready' || cur === 'error') ? cur : 'warming') - }) - s.onBackendReady(() => setStage('ready')) - s.onBackendError((msg) => { setStage('error'); setError(msg) }) - - // Cached fast path: the backend may already be up (and `backend:ready` may - // have fired) before our listeners attached. Reconcile once on mount. - s.isBackendReady() - .then((ready) => { if (ready) setStage((cur) => (cur === 'init' ? 'ready' : cur)) }) - .catch(() => { }) - - return () => s.offBootEvents() - }, []) - - const { received, total } = progress - // Determinate % only where it's a real measurement: download bytes, or — during - // warmup — records scanned in the lexical build. The warmup tail steps report - // pct=null, so the bar honestly falls back to an indeterminate sweep for them. - const pct = stage === 'downloading' && total > 0 - ? Math.min(100, Math.round((received / total) * 100)) - : stage === 'warming' - ? warmup.pct - : null - - return { stage, pct, received, total, error, done: stage === 'ready', warmup } -} - // ── Main Startup View ────────────────────────────────────────────────────────── // Editorial / wiki aesthetic: paper surface, ink type, one brick-red accent, // sharp corners. The catalog load is shown as a real progress bar rather than a @@ -95,16 +27,13 @@ interface StartupViewProps { export function StartupView({ onReady, children }: StartupViewProps) { const [exiting, setExiting] = useState(false) - const { stage, pct, received, total, error, done, warmup } = useBootSequence() - - // During warmup, name the actual step the server is on ("building text index", - // "loading embedding model", …) rather than a generic label — the phase comes - // straight from the backend so it reflects what's really running. - const label = stage === 'warming' && warmup.phase ? warmup.phase : STAGE_LABEL[stage] + // `label` reflects the real backend step ("building text index", …); `canEnter` + // flips on once the index build starts so we can show Enter early. + const { stage, pct, received, total, error, ready, warmup, canEnter, label } = useBoot() - // Let the user through once the catalog is ready, or if the boot failed (so a - // backend hiccup never hard-locks the splash — the app surfaces its own retry). - const revealed = done || stage === 'error' + // The bar keeps running until the backend is actually ready (or errors) — the + // loading screen looks the same as before for anyone who waits it out. + const revealed = ready || stage === 'error' // The bar shows for the whole boot so there's always a visible indicator // (incl. the init gap before the first event). Indeterminate when we have no @@ -112,8 +41,11 @@ export function StartupView({ onReady, children }: StartupViewProps) { const showBar = !revealed const indeterminate = pct === null + // Enter is offered the moment warmup begins (the "building text index" phase), + // not just at full ready — the user can enter and let the index finish inside + // the app, where IndexingBar takes over the progress display. const handleExit = () => { - if (!revealed || exiting) return + if (!canEnter || exiting) return setExiting(true) setTimeout(onReady, 700) } @@ -189,14 +121,16 @@ export function StartupView({ onReady, children }: StartupViewProps) { )}
- {/* Enter affordance — flat, sharp, ink. Hover inverts to ink fill. */} + {/* Enter affordance — flat, sharp, ink. Hover inverts to ink fill. + Revealed at `canEnter` (warmup started), so users can enter while the + index is still building. */}
diff --git a/app/src/renderer/src/views/TrackWikiView.tsx b/app/src/renderer/src/views/TrackWikiView.tsx index d19d837..e6147a1 100644 --- a/app/src/renderer/src/views/TrackWikiView.tsx +++ b/app/src/renderer/src/views/TrackWikiView.tsx @@ -54,6 +54,8 @@ import { } from '../kernel/neighborhoodAnalysis' import { toRecordVM, type RecordVM } from '../domain/recordVM' import { getCachedSchema, fetchSchema, roleLabel } from '../domain/roles' +import { useBoot } from '../providers/BootProvider' +import { IndexingBar } from '../components/IndexingBar' // Open a URL in the OS default browser via the preload bridge function openExternal(url: string) { @@ -1556,6 +1558,7 @@ function Home({ onNavigate, kernelTopGenres, allGenres, kernelEntropy }: { allGenres?: string[] kernelEntropy?: number }) { + const { indexing } = useBoot() const slot = getTimeSlotMoods() const phrase = kernelPhrase(kernelEntropy) @@ -1568,6 +1571,18 @@ function Home({ onNavigate, kernelTopGenres, allGenres, kernelEntropy }: { const hasKernel = kernelTopGenres && kernelTopGenres.length > 0 + // While the backend is still building its text index, the home page shows + // nothing but the progress bar — no search, tagline, or discovery sections. + if (indexing) { + return ( +
+
+ +
+
+ ) + } + return (
From 659028ae1fbfb0659cfbe3da6f697a35387a0a71 Mon Sep 17 00:00:00 2001 From: Coleman Irby Date: Thu, 11 Jun 2026 22:09:42 -0500 Subject: [PATCH 07/34] Loading screen window drag bug fixed --- app/src/renderer/src/views/StartupView.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/src/renderer/src/views/StartupView.tsx b/app/src/renderer/src/views/StartupView.tsx index 502baa9..0bde3e8 100644 --- a/app/src/renderer/src/views/StartupView.tsx +++ b/app/src/renderer/src/views/StartupView.tsx @@ -63,7 +63,11 @@ export function StartupView({ onReady, children }: StartupViewProps) { zIndex: 9999, background: 'var(--bg)', color: 'var(--fg)', - }}> + // Frameless window: the loading screen has no OS titlebar, so make the + // whole splash a drag region (the main app's header does the same). The + // Enter affordance below opts back out with no-drag so it stays clickable. + WebkitAppRegion: 'drag', + } as React.CSSProperties}> {/* Hairline frame for the editorial "page" feel */}
@@ -132,7 +136,8 @@ export function StartupView({ onReady, children }: StartupViewProps) { transition: 'opacity 0.5s ease, transform 0.5s ease', pointerEvents: canEnter ? 'auto' : 'none', cursor: 'pointer', - }} + WebkitAppRegion: 'no-drag', + } as React.CSSProperties} > {children ?? ( + {/* Reachable while signed out so a wedged login (e.g. a deleted account + whose dead session lingers locally) can be cleared without devtools. */} +
+ +
) } @@ -154,10 +173,23 @@ export function AccountView() { No wallet linked to this account yet. - + {walletError && ( + + Couldn't create wallet: {walletError} + + )} )}
@@ -232,11 +264,75 @@ export function AccountView() { )} +
) } +// ─── Local session reset ────────────────────────────────────────────────────── +// Clears this device's stored Privy session (+ cached app data) and reloads. +// Recovers from a wedged login — e.g. the account was deleted server-side but +// the dead session lingers locally, blocking re-login. Rendered both signed-in +// (Session section) and signed-out (so a wedged login is recoverable in-app, +// without devtools). Main reloads the window, so `resetSession()` typically +// never resolves; the catch only handles the API being unavailable. +function ResetSessionControl() { + const [confirm, setConfirm] = useState(false) + const [resetting, setResetting] = useState(false) + + const run = useCallback(async () => { + setResetting(true) + try { + await window.sond3r?.resetSession() + } catch (e) { + console.error('[session] reset failed:', e) + setResetting(false) + setConfirm(false) + } + }, []) + + return ( + <> + + {confirm ? ( + <> + + + + ) : ( + + )} + + {confirm && ( + + Clears this device’s stored Privy session and cached app data, then + reloads. Use this if login gets stuck (e.g. after deleting an + account). Your account and embedded wallet are recovered on next + sign-in. + + )} + + ) +} + // ─── Layout atoms ───────────────────────────────────────────────────────────── function PanelHeading({ title, sub }: { title: string; sub: string }) { From 5eccbd90096e5d23a8ccc71696aaba04a6526a92 Mon Sep 17 00:00:00 2001 From: Coleman Irby Date: Fri, 12 Jun 2026 17:29:56 -0500 Subject: [PATCH 10/34] Moved from portable signing to NSIS --- app/electron-builder.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/electron-builder.yml b/app/electron-builder.yml index e2088d1..42594df 100644 --- a/app/electron-builder.yml +++ b/app/electron-builder.yml @@ -36,9 +36,8 @@ extraResources: win: executableName: SOND3R target: - - target: portable + - target: nsis arch: [x64] - artifactName: ${name}.${ext} # Azure Trusted Signing. electron-builder installs the `TrustedSigning` # PowerShell module on the runner and signs via `Invoke-TrustedSigning`, # authenticating through Microsoft Entra ID env vars set in CI: @@ -64,10 +63,16 @@ linux: appImage: artifactName: ${name}.${ext} nsis: + oneClick: false + allowToChangeInstallationDirectory: true artifactName: ${name}-${version}-setup.${ext} shortcutName: ${productName} uninstallDisplayName: ${productName} createDesktopShortcut: always + # Keep wallet/account + vector index data when the app is uninstalled. + # (false is the electron-builder default; set explicitly so a future edit + # doesn't silently start wiping users' crypto account data.) + deleteAppDataOnUninstall: false npmRebuild: false nodeGypRebuild: false detectUpdateChannel: false From d17d06706f2460c2a20d3e9c3da5d7ccfd6cfac0 Mon Sep 17 00:00:00 2001 From: Coleman Irby Date: Fri, 12 Jun 2026 21:58:39 -0500 Subject: [PATCH 11/34] Add bug reporting mechanism --- app/.env.example | 6 + app/src/main/bug-report.ts | 216 ++++++++++++++++ app/src/main/index.ts | 4 + app/src/preload/index.d.ts | 3 +- app/src/preload/index.ts | 41 +++ app/src/renderer/global.d.ts | 4 +- app/src/renderer/src/App.tsx | 4 + .../renderer/src/components/BugReportFab.tsx | 48 ++++ .../src/components/BugReportModal.tsx | 235 ++++++++++++++++++ app/src/renderer/src/views/AccountView.tsx | 31 +++ app/src/renderer/src/views/StartupView.tsx | 14 +- app/tools/bug-report-worker/README.md | 60 +++++ app/tools/bug-report-worker/worker.js | 81 ++++++ app/tools/bug-report-worker/wrangler.toml | 8 + 14 files changed, 751 insertions(+), 4 deletions(-) create mode 100644 app/src/main/bug-report.ts create mode 100644 app/src/renderer/src/components/BugReportFab.tsx create mode 100644 app/src/renderer/src/components/BugReportModal.tsx create mode 100644 app/tools/bug-report-worker/README.md create mode 100644 app/tools/bug-report-worker/worker.js create mode 100644 app/tools/bug-report-worker/wrangler.toml diff --git a/app/.env.example b/app/.env.example index 01e385d..99fa8b4 100644 --- a/app/.env.example +++ b/app/.env.example @@ -17,6 +17,12 @@ VITE_ARBITRUM_SEPOLIA_RPC_URL= # ── Agent ──────────────────────────────────────────────────────────────────── VITE_USE_AGENT=true +# ── Bug reports (in-app "report a problem") ────────────────────────────────── +# URL of the serverless proxy that files GitHub issues (it holds the GH token so +# we never ship it in the app — see tools/bug-report-worker/). Leave blank and +# the reporter falls back to opening a prefilled GitHub issue in the browser. +VITE_BUGREPORT_ENDPOINT= + # ── Spotify (playback / OAuth PKCE) ────────────────────────────────────────── VITE_SPOTIFY_CLIENT_ID= VITE_SPOTIFY_CLIENT_SECRET= diff --git a/app/src/main/bug-report.ts b/app/src/main/bug-report.ts new file mode 100644 index 0000000..2164ca7 --- /dev/null +++ b/app/src/main/bug-report.ts @@ -0,0 +1,216 @@ +// ─── Bug reports ────────────────────────────────────────────────────────────── +// In-app "report a problem" plumbing for the early-access preview. The renderer +// collects a short description; main attaches diagnostics (app version, OS, +// recent logs) and files a GitHub issue. +// +// Delivery is deliberately keyless. `.env` ships inside the packaged app and is +// user-editable (see .env.example), so we can't embed a GitHub token. Instead we +// POST to a small serverless proxy that holds the token (VITE_BUGREPORT_ENDPOINT +// → see tools/bug-report-worker). If that endpoint isn't configured or the POST +// fails, we fall back to opening a prefilled GitHub "New issue" page in the +// browser, so the user is never left at a dead end. + +import { app, ipcMain, net, shell } from 'electron' +import os from 'os' +import path from 'path' +import fs from 'fs' + +const REPO = 'fangorn-network/sonder' +const LABELS = ['bug', 'early-access'] + +// VITE_* env is injected into the main process too (electron-vite); same pattern +// as the RPC URL in index.ts. Undefined in dev / before the proxy is deployed. +const ENDPOINT = (import.meta as unknown as { env?: Record }) + .env?.VITE_BUGREPORT_ENDPOINT + +// ── Ring buffer of recent main-process console output ────────────────────────── +// The app already logs richly with `[tag]` prefixes ([boot], [qdrant], [py], …). +// We tee those into a bounded buffer so a report can carry the last moments +// before something went wrong, without writing anything to disk or phoning home. +const MAX_LOG_LINES = 300 +const ring: string[] = [] +let captureInstalled = false + +export function installLogCapture(): void { + if (captureInstalled) return + captureInstalled = true + for (const method of ['log', 'info', 'warn', 'error'] as const) { + const original = console[method].bind(console) + console[method] = (...args: unknown[]) => { + try { + const line = args.map(a => (typeof a === 'string' ? a : safeStr(a))).join(' ') + ring.push(`${new Date().toISOString()} [${method}] ${line}`) + if (ring.length > MAX_LOG_LINES) ring.shift() + } catch { + /* logging must never throw */ + } + original(...args) + } + } +} + +function safeStr(v: unknown): string { + try { + return typeof v === 'object' ? JSON.stringify(v) : String(v) + } catch { + return String(v) + } +} + +// ── Diagnostics ──────────────────────────────────────────────────────────────── +export interface Diagnostics { + appVersion: string + platform: string + arch: string + os: string + electron: string + chrome: string + node: string +} + +export function collectDiagnostics(): Diagnostics { + return { + appVersion: app.getVersion(), + platform: process.platform, + arch: process.arch, + os: `${os.type()} ${os.release()}`, + electron: process.versions.electron, + chrome: process.versions.chrome, + node: process.versions.node, + } +} + +// Last ~8KB of the Python backend's stderr (the one log we already persist). +function pyStderrTail(maxBytes = 8000): string { + try { + const p = path.join(app.getPath('userData'), 'py-stderr.log') + const { size } = fs.statSync(p) + const start = Math.max(0, size - maxBytes) + const fd = fs.openSync(p, 'r') + try { + const buf = Buffer.alloc(size - start) + fs.readSync(fd, buf, 0, buf.length, start) + return buf.toString('utf8') + } finally { + fs.closeSync(fd) + } + } catch { + return '' + } +} + +function collectLogTail(): string { + return [ + '── main process ──', + ring.join('\n') || '(no captured logs)', + '', + '── python backend (py-stderr.log tail) ──', + pyStderrTail() || '(none)', + ].join('\n') +} + +// ── Report assembly ────────────────────────────────────────────────────────────── +export interface BugReportInput { + description: string + expected?: string + email?: string + userId?: string +} + +export interface BugReportResult { + ok: boolean + via?: 'api' | 'browser' + url?: string + error?: string +} + +function buildTitle(description: string): string { + const first = description.trim().split('\n')[0].slice(0, 80) + return first ? `[bug] ${first}` : '[bug] (no description)' +} + +function buildBody(input: BugReportInput, diag: Diagnostics, logTail: string): string { + const lines = [ + '### What happened', + input.description.trim() || '_(none given)_', + '', + ] + if (input.expected?.trim()) { + lines.push('### What I expected', input.expected.trim(), '') + } + lines.push( + '### Environment', + `- App: v${diag.appVersion}`, + `- OS: ${diag.os} (${diag.platform}/${diag.arch})`, + `- Electron ${diag.electron} · Chrome ${diag.chrome} · Node ${diag.node}`, + '', + '### Reporter', + `- Contact: ${input.email?.trim() || '_(not provided)_'}`, + `- Account: ${input.userId || '_(signed out)_'}`, + '', + '### Recent logs', + '```', + logTail, + '```', + '', + '_Filed from the in-app reporter (early access preview)._', + ) + return lines.join('\n') +} + +async function fileViaProxy( + title: string, + body: string, +): Promise { + if (!ENDPOINT) return null + try { + const res = await net.fetch(ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title, body, labels: LABELS }), + }) + if (!res.ok) { + console.error('[bug-report] proxy responded', res.status) + return null + } + const data = (await res.json().catch(() => ({}))) as Record + const url = (data.url ?? data.html_url) as string | undefined + return { ok: true, via: 'api', url } + } catch (e) { + console.error('[bug-report] proxy request failed:', e) + return null + } +} + +// Browser fallback — open GitHub's prefilled "New issue" page. URLs are length +// bounded (encodeURIComponent inflates the payload), so the log tail is trimmed +// hard here; the full tail only travels over the proxy path. +function fileViaBrowser(title: string, body: string): BugReportResult { + const trimmed = + body.length > 4000 ? `${body.slice(0, 4000)}\n\n… (logs truncated — see app for full output)` : body + const url = + `https://github.com/${REPO}/issues/new` + + `?title=${encodeURIComponent(title)}` + + `&body=${encodeURIComponent(trimmed)}` + + `&labels=${encodeURIComponent(LABELS.join(','))}` + shell.openExternal(url) + return { ok: true, via: 'browser' } +} + +export async function submitBugReport(input: BugReportInput): Promise { + if (!input?.description?.trim()) { + return { ok: false, error: 'A description is required.' } + } + const diag = collectDiagnostics() + const body = buildBody(input, diag, collectLogTail()) + const title = buildTitle(input.description) + + const viaProxy = await fileViaProxy(title, body) + if (viaProxy) return viaProxy + return fileViaBrowser(title, body) +} + +export function registerBugReportIpc(): void { + ipcMain.handle('bug:diagnostics', () => collectDiagnostics()) + ipcMain.handle('bug:submit', async (_e, input: BugReportInput) => submitBugReport(input)) +} diff --git a/app/src/main/index.ts b/app/src/main/index.ts index 9920646..918b9fc 100644 --- a/app/src/main/index.ts +++ b/app/src/main/index.ts @@ -20,6 +20,7 @@ import { ToolboxConfigManager } from './agent/toolbox-config-manager' import { registerSpotifyAuth, handleSpotifyCallback } from './spotify/SpotifyAuth' import { registerPlaybackIpc } from './playback/ipc' import { registerLocalMusicIpc } from './local/ipc' +import { installLogCapture, registerBugReportIpc } from './bug-report' // ─── Stream cache ───────────────────────────────────────────────────────────── interface StreamEntry { @@ -614,6 +615,8 @@ function registerIpcHandlers() { ipcMain.handle('backend:is-ready', async () => { return net.fetch('http://127.0.0.1:8080/ready').then(r => r.ok).catch(() => false) }) + + registerBugReportIpc() // bug:submit / bug:diagnostics (in-app problem reporter) } // ─── Arbitrum RPC proxy ─────────────────────────────────────────────────────── @@ -708,6 +711,7 @@ if (!gotLock) { // brought up afterwards, behind a boot screen the renderer renders. app.whenReady().then(async () => { + installLogCapture() // tee console output into the bug-report ring buffer first electronApp.setAppUserModelId('com.electron') startQdrant() diff --git a/app/src/preload/index.d.ts b/app/src/preload/index.d.ts index 96b0622..30a9937 100644 --- a/app/src/preload/index.d.ts +++ b/app/src/preload/index.d.ts @@ -1,5 +1,5 @@ import { ElectronAPI } from '@electron-toolkit/preload' -import { FangornAgentApi } from '.'; +import { FangornAgentApi, BugReportApi } from '.'; // SOND3R backend boot lifecycle — first run fetches + recovers the catalog // snapshot; the renderer's StartupView drives a progress bar off these events. @@ -29,5 +29,6 @@ declare global { } agentAPI: FangornAgentApi; sond3r?: Sond3rBootApi; + bugReport?: BugReportApi; } } \ No newline at end of file diff --git a/app/src/preload/index.ts b/app/src/preload/index.ts index bf72091..6425c8a 100644 --- a/app/src/preload/index.ts +++ b/app/src/preload/index.ts @@ -24,6 +24,40 @@ const localMusic: LocalMusicApi = { scan: (dir?: string) => ipcRenderer.invoke('local:scan', dir), } +// ── In-app bug reporter ──────────────────────────────────────────────── +export interface BugReportInput { + description: string + expected?: string + email?: string + userId?: string +} +export interface BugReportResult { + ok: boolean + /** How it was filed: silently via the proxy, or by opening the browser. */ + via?: 'api' | 'browser' + /** Issue URL when filed via the proxy. */ + url?: string + error?: string +} +export interface BugReportDiagnostics { + appVersion: string + platform: string + arch: string + os: string + electron: string + chrome: string + node: string +} +export interface BugReportApi { + submit(input: BugReportInput): Promise + getDiagnostics(): Promise +} + +const bugReport: BugReportApi = { + submit: (input) => ipcRenderer.invoke('bug:submit', input), + getDiagnostics: () => ipcRenderer.invoke('bug:diagnostics'), +} + // Custom APIs for renderer const api = { @@ -125,6 +159,11 @@ if (process.contextIsolated) { // from the catalog player — see main/local/* and LocalMusicProvider. contextBridge.exposeInMainWorld('localMusic', localMusic) + // ── In-app bug reporter ────────────────────────────────────────── + // Renderer collects a description; main attaches diagnostics + recent + // logs and files a GitHub issue. See main/bug-report.ts. + contextBridge.exposeInMainWorld('bugReport', bugReport) + } catch (error) { console.error(error) } @@ -135,6 +174,8 @@ if (process.contextIsolated) { window.api = api // @ts-ignore (define in dts) window.localMusic = localMusic + // @ts-ignore (define in dts) + window.bugReport = bugReport } diff --git a/app/src/renderer/global.d.ts b/app/src/renderer/global.d.ts index 65094b0..41223f1 100644 --- a/app/src/renderer/global.d.ts +++ b/app/src/renderer/global.d.ts @@ -1,4 +1,4 @@ -import {FangornAgentApi} from "../preload" +import {FangornAgentApi, BugReportApi} from "../preload" import type {Sond3rBootApi} from "../preload/index.d" // src/renderer/src/env.d.ts or global.d.ts @@ -11,5 +11,7 @@ declare global { agentAPI: FangornAgentApi; // SOND3R backend boot lifecycle — drives StartupView's progress bar. sond3r?: Sond3rBootApi; + // In-app bug reporter — see components/BugReportModal.tsx. + bugReport?: BugReportApi; } } \ No newline at end of file diff --git a/app/src/renderer/src/App.tsx b/app/src/renderer/src/App.tsx index 02678ff..0053d33 100644 --- a/app/src/renderer/src/App.tsx +++ b/app/src/renderer/src/App.tsx @@ -17,6 +17,7 @@ import { IndexingBar } from './components/IndexingBar' import { useSessionKernel } from './hooks/useSessionKernel' import { ConnectorsView } from './views/ConnectorsView' import { AccountView } from './views/AccountView' +import { BugReportFab } from './components/BugReportFab' import type { TasteSignal } from './types' import { type SessionEvent } from './kernel/Visualizer' import { useChromaSync } from './hooks/useChromaSync' @@ -615,6 +616,9 @@ function Main() { {/* KernelDebugHUD — Ctrl+K accessible */} + + {/* Bug reporter — bottom-left corner, mirroring the HUD's anchor. */} +
diff --git a/app/src/renderer/src/components/BugReportFab.tsx b/app/src/renderer/src/components/BugReportFab.tsx new file mode 100644 index 0000000..9ce193d --- /dev/null +++ b/app/src/renderer/src/components/BugReportFab.tsx @@ -0,0 +1,48 @@ +/** + * BugReportFab.tsx + * + * "Report a bug" pill — an early-access affordance that owns its own modal + * state. It's placed in two spots: the splash screen (above the footer line) + * and the running app (bottom-left, mirroring the kernel HUD's corner anchor). + * Callers supply positioning via `style`; this component owns the look + modal. + */ + +import { useState } from 'react' +import { BugReportModal } from './BugReportModal' + +export function BugReportFab({ style }: { style?: React.CSSProperties }) { + const [open, setOpen] = useState(false) + return ( + <> + + + {open && setOpen(false)} />} + + + + ) +} diff --git a/app/src/renderer/src/components/BugReportModal.tsx b/app/src/renderer/src/components/BugReportModal.tsx new file mode 100644 index 0000000..fa7ddfd --- /dev/null +++ b/app/src/renderer/src/components/BugReportModal.tsx @@ -0,0 +1,235 @@ +/** + * BugReportModal.tsx + * + * In-app "report a problem" form for the early-access preview. The user writes + * what happened; main (bug-report.ts) attaches diagnostics + recent logs and + * files a GitHub issue — silently via the serverless proxy when configured, or + * by opening a prefilled GitHub issue in the browser as a fallback. + * + * Privy identity (user id + email) is read here and passed along so we can + * follow up; both are optional, so reporting works while signed out too. + */ + +import { useEffect, useState } from 'react' +import { createPortal } from 'react-dom' +import { usePrivy } from '@privy-io/react-auth' +import type { BugReportDiagnostics, BugReportResult } from '../../../preload' + +type Status = 'idle' | 'sending' | 'sent' | 'error' + +export function BugReportModal({ onClose }: { onClose: () => void }) { + const { user } = usePrivy() + + const [description, setDescription] = useState('') + const [expected, setExpected] = useState('') + const [email, setEmail] = useState(user?.email?.address ?? '') + const [showDetails, setShowDetails] = useState(false) + const [diag, setDiag] = useState(null) + + const [status, setStatus] = useState('idle') + const [result, setResult] = useState(null) + const [error, setError] = useState(null) + + // Pull diagnostics up front so the "what we'll include" panel is honest about + // exactly what leaves the machine. + useEffect(() => { + window.bugReport?.getDiagnostics().then(setDiag).catch(() => {}) + }, []) + + // Esc closes (unless mid-send). + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape' && status !== 'sending') onClose() + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [onClose, status]) + + const sending = status === 'sending' + const canSend = description.trim().length > 0 && !sending + + const submit = async () => { + if (!canSend) return + const api = window.bugReport + if (!api) { + setStatus('error') + setError('Reporting is unavailable in this build.') + return + } + setStatus('sending') + setError(null) + try { + const res = await api.submit({ + description, + expected: expected.trim() || undefined, + email: email.trim() || undefined, + userId: user?.id, + }) + if (res.ok) { + setResult(res) + setStatus('sent') + } else { + setError(res.error ?? 'Something went wrong filing the report.') + setStatus('error') + } + } catch (e) { + setError(String(e)) + setStatus('error') + } + } + + return createPortal( +
status !== 'sending' && onClose()} + style={{ + // Above the FAB (10000) and the z-9999 splash, so reports work even + // from the loading screen. + position: 'fixed', inset: 0, zIndex: 10050, + display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24, + background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(3px)', + WebkitAppRegion: 'no-drag', + } as React.CSSProperties} + > +
e.stopPropagation()} + style={{ + width: 'min(460px, 92vw)', maxHeight: '88vh', overflowY: 'auto', + background: 'var(--bg1)', border: '1px solid var(--border)', + padding: 'var(--sp-5)', + }} + > + {/* Header */} +
+
+

+ Report a problem +

+

+ early access · thanks for helping +

+
+ +
+ + {status === 'sent' ? ( + + ) : ( + <> + +