| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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(/\/+$/, '') |
| |
| |
| |
| 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 { } |
| 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 |
| } |
|
|
| |
| 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' |
| } |
|
|
| |
| 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 { } |
| 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 : [] |
| |
| 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 { |
| |
| |
| if (!isBffSessionEnabled()) localStorage.setItem(LS_CLOUD_TOKEN, data.token) |
| localStorage.setItem(LS_CLOUD_URL, cloudUrl) |
| } catch { } |
| setToken(data.token) |
| setPassword('') |
| } catch { |
| setError('Could not reach OllaBridge Cloud.') |
| } finally { |
| setConnecting(false) |
| } |
| } |
|
|
| function handleDisconnect() { |
| try { localStorage.removeItem(LS_CLOUD_TOKEN) } catch { } |
| setToken('') |
| setDevices([]) |
| setModels([]) |
| } |
|
|
| function useForChat(modelId: string) { |
| setActiveModel(modelId) |
| |
| |
| 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> |
| ) |
| } |
|
|