HomePilot / frontend /src /ui /components /OllaBridgeModels.tsx
HomePilot Deploy Bot
chore(hf): sync HomePilot to HF Space
6fc5166
Raw
History Blame Contribute Delete
15.8 kB
/**
* OllaBridgeModels — the "OllaBridge" provider class in Model Management.
*
* A special provider whose models come from the user's LINKED HomePilot /
* OllaBridge nodes (e.g. a home PC with a high-end GPU running the OllaBridge
* connector). Signed in with an OllaBridge account, this panel:
*
* 1. Lists the user's linked devices (online state, GPU name, VRAM) from
* Cloud `GET /v1/devices`.
* 2. Syncs the models those nodes advertise via Cloud
* `GET /ollama/v1/models` (entries sourced from the relay are the
* user's own shared devices — model sync, no manual entry).
* 3. One tap "Use for Chat" routes inference through the Cloud relay to the
* user's own GPU node: it configures provider=openai_compat with
* base_url={cloud}/ollama/v1 and the picked model; the chat request then
* carries the user's Cloud token as provider_api_key so the relay knows
* whose node to use.
*
* Fully additive and self-contained: talks to the Cloud directly from the
* browser (CORS is open on the Cloud), stores the token only in
* localStorage, and hands the provider switch to App via a CustomEvent —
* zero prop-drilling into the existing Models plumbing.
*/
import React, { useCallback, useEffect, useState } from 'react'
import { isBffSessionEnabled } from '../account/featureFlags'
import { Cloud, Cpu, Loader2, LogOut, RefreshCw, Zap } from 'lucide-react'
const LS_CLOUD_TOKEN = 'homepilot_cloud_token'
const LS_CLOUD_URL = 'homepilot_cloud_url'
export function getCloudUrl(): string {
try {
const saved = (localStorage.getItem(LS_CLOUD_URL) || '').trim().replace(/\/+$/, '')
// Drop known-obsolete cloud URLs so the new default/env value takes over,
// while preserving an intentional self-hosted URL. The raw Hugging Face
// origin is superseded by the branded app.ollabridge.com host.
const legacyUrls = new Set([
'https://ruslanmv-ollabridge.hf.space',
'https://ruslanmv-ollabridge-cloud.hf.space',
])
if (saved && legacyUrls.has(saved)) {
localStorage.removeItem(LS_CLOUD_URL)
} else if (saved) {
return saved
}
} catch { /* ignore */ }
const env: Record<string, string | undefined> =
((import.meta as unknown as { env?: Record<string, string | undefined> }).env) || {}
return ((env.VITE_OLLABRIDGE_CLOUD_URL || '').trim() || 'https://app.ollabridge.com').replace(/\/+$/, '')
}
export function getCloudToken(): string {
try { return (localStorage.getItem(LS_CLOUD_TOKEN) || '').trim() } catch { return '' }
}
interface DeviceInfo {
id: string
name: string
platform?: string | null
online: boolean
gpu_name?: string | null
vram_mb?: number | null
}
interface CloudModel {
id: string
owned_by?: string
source?: string
device_id?: string
device_name?: string
}
/** Loose model-type guess from the model id — drives badges + per-tab filter. */
function guessType(id: string): string {
const s = id.toLowerCase()
if (/(llava|vision|-vl|minicpm-v|moondream)/.test(s)) return 'multimodal'
if (/(sdxl|flux|stable-diffusion|sd15|sd3|pony|dreamshaper)/.test(s)) return 'image'
if (/(svd|ltx|wan|video)/.test(s)) return 'video'
if (/(upscal|esrgan|enhance|restore|gfpgan|codeformer)/.test(s)) return 'enhance'
if (/lora/.test(s)) return 'LoRA'
return 'chat'
}
/** Does a model's guessed type belong under the given Model Type tab? */
function matchesTab(filterType: string | undefined, guessed: string): boolean {
if (!filterType) return true
switch (filterType) {
case 'chat': return guessed === 'chat'
case 'multimodal': return guessed === 'multimodal'
case 'image':
case 'edit': return guessed === 'image'
case 'video': return guessed === 'video'
case 'enhance': return guessed === 'enhance'
case 'lora': return guessed === 'LoRA'
case 'addons': return true
default: return true
}
}
const TAB_LABEL: Record<string, string> = {
chat: 'chat', multimodal: 'multimodal', image: 'image', edit: 'edit',
video: 'video', enhance: 'enhance', lora: 'LoRA', addons: 'add-on',
}
export default function OllaBridgeModels({ filterType }: { filterType?: string } = {}) {
const cloudUrl = getCloudUrl()
const [token, setToken] = useState(getCloudToken())
const [devices, setDevices] = useState<DeviceInfo[]>([])
const [models, setModels] = useState<CloudModel[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [connecting, setConnecting] = useState(false)
const [activeModel, setActiveModel] = useState(
() => { try { return localStorage.getItem('homepilot_model_chat') || '' } catch { return '' } },
)
const refresh = useCallback(async (tok: string) => {
if (!tok) return
setLoading(true)
setError('')
try {
const auth = { Authorization: `Bearer ${tok}` }
const [devRes, modRes] = await Promise.all([
fetch(`${cloudUrl}/v1/devices`, { headers: auth }),
fetch(`${cloudUrl}/ollama/v1/models`, { headers: auth }),
])
if (devRes.status === 401 || modRes.status === 401) {
setError('OllaBridge session expired — reconnect to refresh your nodes.')
setToken('')
try { localStorage.removeItem(LS_CLOUD_TOKEN) } catch { /* ignore */ }
return
}
const devs = devRes.ok ? await devRes.json() : []
setDevices(Array.isArray(devs) ? devs : [])
const mods = modRes.ok ? await modRes.json() : { data: [] }
const list: CloudModel[] = Array.isArray(mods?.data) ? mods.data : []
// Own-node models first (relay/shared_device), then the rest.
list.sort((a, b) => Number(b.source === 'shared_device') - Number(a.source === 'shared_device'))
setModels(list)
} catch {
setError('Could not reach OllaBridge Cloud.')
} finally {
setLoading(false)
}
}, [cloudUrl])
useEffect(() => { if (token) void refresh(token) }, [token, refresh])
async function handleConnect(e: React.FormEvent) {
e.preventDefault()
setConnecting(true)
setError('')
try {
const r = await fetch(`${cloudUrl}/v1/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.trim(), password }),
})
const data = await r.json().catch(() => ({}))
if (!r.ok || !data.token) {
setError(data.detail || 'Invalid OllaBridge email or password')
return
}
try {
// Batch 7 (BFF): the token lives in this component's memory for the
// session; when BFF is on we no longer persist it in browser storage.
if (!isBffSessionEnabled()) localStorage.setItem(LS_CLOUD_TOKEN, data.token)
localStorage.setItem(LS_CLOUD_URL, cloudUrl)
} catch { /* ignore */ }
setToken(data.token)
setPassword('')
} catch {
setError('Could not reach OllaBridge Cloud.')
} finally {
setConnecting(false)
}
}
function handleDisconnect() {
try { localStorage.removeItem(LS_CLOUD_TOKEN) } catch { /* ignore */ }
setToken('')
setDevices([])
setModels([])
}
function useForChat(modelId: string) {
setActiveModel(modelId)
// App.tsx listens for this and flips chat provider to the OllaBridge relay
// (openai_compat @ {cloud}/ollama/v1) + persists the choice.
window.dispatchEvent(new CustomEvent('homepilot:use-gpu-node', {
detail: { baseUrl: `${cloudUrl}/ollama/v1`, model: modelId },
}))
}
const onlineCount = devices.filter((d) => d.online).length
return (
<section className="rounded-2xl border border-white/10 bg-gradient-to-br from-[#101018] to-[#0b0b12] p-4 sm:p-5 mb-6">
{/* Header */}
<div className="flex flex-wrap items-center justify-between gap-3 mb-3">
<div className="flex items-center gap-2.5 min-w-0">
<span className="h-9 w-9 rounded-xl grid place-items-center bg-gradient-to-br from-cyan-500/20 to-violet-500/20 border border-white/10 shrink-0">
<Cloud size={17} className="text-cyan-300" />
</span>
<div className="min-w-0">
<h3 className="text-sm font-bold text-white leading-tight">OllaBridge — your remote HomePilot nodes</h3>
<p className="text-xs text-white/45 truncate">
Models synced from linked machines; inference runs on your own GPU through the relay.
</p>
</div>
</div>
{token && (
<div className="flex items-center gap-2 shrink-0">
<button
type="button"
onClick={() => refresh(token)}
className="h-9 px-3 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-xs text-white/70 hover:text-white inline-flex items-center gap-1.5 transition-colors"
>
{loading ? <Loader2 size={13} className="animate-spin" /> : <RefreshCw size={13} />} Sync
</button>
<button
type="button"
onClick={handleDisconnect}
className="h-9 px-3 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-xs text-white/50 hover:text-white inline-flex items-center gap-1.5 transition-colors"
title="Disconnect OllaBridge"
>
<LogOut size={13} />
</button>
</div>
)}
</div>
{error && (
<div className="mb-3 px-3 py-2 rounded-xl bg-red-500/10 border border-red-500/20 text-red-300 text-xs" role="alert">
{error}
</div>
)}
{!token ? (
/* Connect card */
<form onSubmit={handleConnect} className="grid gap-2.5 sm:grid-cols-[1fr_1fr_auto]">
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="OllaBridge email"
autoComplete="email"
className="h-11 rounded-xl bg-black/40 border border-white/10 px-3 text-sm text-white placeholder:text-white/30 outline-none focus:border-cyan-400/50"
/>
<input
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
autoComplete="current-password"
className="h-11 rounded-xl bg-black/40 border border-white/10 px-3 text-sm text-white placeholder:text-white/30 outline-none focus:border-cyan-400/50"
/>
<button
type="submit"
disabled={connecting}
className="h-11 px-4 rounded-xl bg-gradient-to-r from-cyan-500 to-violet-500 text-white text-sm font-bold disabled:opacity-60"
>
{connecting ? 'Connecting…' : 'Connect OllaBridge'}
</button>
<p className="sm:col-span-3 text-[11px] text-white/35">
Sign in with your OllaBridge account to sync models from your linked HomePilot machines.
Your token stays in this browser only.
</p>
</form>
) : (
<>
{/* Devices strip */}
<div className="flex gap-2 overflow-x-auto pb-1 mb-3">
{devices.length === 0 && !loading && (
<p className="text-xs text-white/40 py-1">
No linked machines yet. Pair a PC at{' '}
<a href={`${cloudUrl}/link`} target="_blank" rel="noopener noreferrer" className="text-violet-300 hover:text-white underline">
{cloudUrl.replace(/^https?:\/\//, '')}/link
</a>{' '}— its models will appear here automatically.
</p>
)}
{devices.map((d) => (
<div
key={d.id}
className="shrink-0 flex items-center gap-2 rounded-xl border border-white/10 bg-black/30 px-3 py-2"
>
<span className={`w-2 h-2 rounded-full ${d.online ? 'bg-emerald-400' : 'bg-white/25'}`} />
<Cpu size={14} className="text-white/50" />
<div className="leading-tight">
<div className="text-xs font-semibold text-white">{d.name}</div>
<div className="text-[10px] text-white/40">
{d.gpu_name || d.platform || 'node'}
{d.vram_mb ? ` · ${Math.round(d.vram_mb / 1024)} GB VRAM` : ''}
{d.online ? ' · online' : ' · offline'}
</div>
</div>
</div>
))}
</div>
{/* Synced models — filtered to the active Model Type tab. */}
{(() => {
const shown = models.filter((m) => matchesTab(filterType, guessType(m.id)))
if (shown.length === 0) {
if (loading || devices.length === 0) return null
const lbl = filterType ? (TAB_LABEL[filterType] || filterType) : ''
return (
<p className="text-xs text-white/40 py-1">
No {lbl} models advertised by your nodes yet. Pull {lbl} models on your linked
HomePilot and they’ll sync here automatically.
</p>
)
}
return (
<div className="grid gap-1.5">
{shown.slice(0, 40).map((m) => {
const own = m.source === 'shared_device'
const type = guessType(m.id)
const isActive = activeModel === m.id
return (
<div
key={`${m.id}-${m.device_id || m.source || ''}`}
className="flex items-center gap-2.5 rounded-xl border border-white/[0.07] bg-white/[0.02] hover:bg-white/[0.045] px-3 py-2 transition-colors"
>
<span className="flex-1 min-w-0">
<span className="block text-[13px] text-white/85 truncate font-medium">{m.id}</span>
<span className="block text-[10px] text-white/35 truncate">
{own ? `your node${m.device_name ? ` · ${m.device_name}` : ''}` : (m.owned_by || m.source || 'cloud')} · {type}
</span>
</span>
{own && (
<span className="shrink-0 text-[9px] font-bold uppercase tracking-wide px-1.5 py-0.5 rounded-md bg-emerald-500/15 text-emerald-300 border border-emerald-500/25">
GPU node
</span>
)}
{(type === 'chat' || type === 'multimodal') ? (
<button
type="button"
onClick={() => useForChat(m.id)}
className={[
'shrink-0 h-8 px-3 rounded-lg text-xs font-semibold inline-flex items-center gap-1.5 transition-colors',
isActive
? 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/30'
: 'bg-white/5 hover:bg-white/10 border border-white/10 text-white/70 hover:text-white',
].join(' ')}
>
<Zap size={12} /> {isActive ? 'Active' : 'Use for Chat'}
</button>
) : (
<span className="shrink-0 text-[10px] text-white/40 px-2 py-1 rounded-md bg-white/5 border border-white/10">Synced</span>
)}
</div>
)
})}
</div>
)
})()}
</>
)}
</section>
)
}