/** * Model Catalog view — providers with their models and the default marker: * * left: every configured model (provider-grouped), its highlight synced * both ways with the center column — scrolling the center moves the * highlight (scrollspy), clicking an entry jumps the center to that * model; * center: one section per model with ping and session creation actions. * * All data goes through the channel layer — `IModelCatalog` + * `IModelService` for the list — over the `/api/v1/debug` RPC surface. * There is no live event push; the queries refresh on a slow poll. */ import { IAgentProfileService } from '@moonshot-ai/agent-core-v2/agent/profile/profile'; import { ISessionManager } from '@moonshot-ai/agent-core-v2/app/sessionManager/sessionManager'; import type { TokenUsage } from '@moonshot-ai/agent-core-v2/human/llm/usage'; import { IModelCatalog, type ModelCatalogItem, type ModelPingResult, type ProviderCatalogItem, } from '@moonshot-ai/agent-core-v2/llm-adapter/model/catalog'; import { IModelService } from '@moonshot-ai/agent-core-v2/llm-adapter/model/model'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect, useRef, useState } from 'react'; import { useConnection } from '../connection'; import { ActionButton, Badge, ErrorLine, errorMessage } from '../ui'; interface FlatEntry { readonly item: ModelCatalogItem; readonly provider?: ProviderCatalogItem; } export function ModelCatalogView({ onOpenSession, }: { readonly onOpenSession: (sessionId: string) => void; }) { const { klient } = useConnection(); const queryClient = useQueryClient(); const providers = useQuery({ queryKey: ['modelCatalog', 'providers'], queryFn: () => klient.core(IModelCatalog).listProviders(), refetchInterval: 15_000, }); const models = useQuery({ queryKey: ['modelCatalog', 'models'], queryFn: () => klient.core(IModelCatalog).listModels(), refetchInterval: 15_000, }); // Raw records carry the structured `providerId` grouping fallback. const records = useQuery({ queryKey: ['modelCatalog', 'records'], queryFn: () => klient.core(IModelService).list(), refetchInterval: 15_000, }); const providerList = providers.data ?? []; const items = models.data ?? []; const recordMap = records.data ?? {}; // Flatten the provider grouping into one ordered model list (listed // providers first, then models whose group matches no listed provider). const groupKeyOf = (item: ModelCatalogItem): string => { const record = recordMap[item.model]; return record?.providerId ?? record?.provider ?? item.provider; }; const byGroup = new Map(); for (const item of items) { const key = groupKeyOf(item); const group = byGroup.get(key) ?? []; group.push(item); byGroup.set(key, group); } const listedIds = new Set(providerList.map((p) => p.id)); const extraGroups = [...byGroup.keys()].filter((key) => !listedIds.has(key)); const flatEntries: FlatEntry[] = [ ...providerList.flatMap((provider) => (byGroup.get(provider.id) ?? []).map((item) => ({ item, provider }) as const), ), ...extraGroups.flatMap((key) => (byGroup.get(key) ?? []).map((item) => ({ item }) as const)), ]; // --- two-way sync between the left list and the center scroll ---------- const [activeId, setActiveId] = useState(null); const scrollRef = useRef(null); const listRef = useRef(null); const sectionRefs = useRef(new Map()); const itemRefs = useRef(new Map()); // Default the highlight to the first model once data arrives, and follow // the list when the highlighted model disappears (config changed). useEffect(() => { if (flatEntries.length === 0) return; if (activeId === null || !flatEntries.some((entry) => entry.item.model === activeId)) { setActiveId(flatEntries[0]!.item.model); } }, [activeId, flatEntries]); // Center scroll → left highlight (scrollspy). Computed synchronously — a // rAF throttle would stall in background tabs and lag real scrolling; the // walk over the entry list is trivial at this scale. const handleCenterScroll = () => { const container = scrollRef.current; if (container === null) return; const top = container.scrollTop + 12; let current: string | null = null; for (const entry of flatEntries) { const el = sectionRefs.current.get(entry.item.model); if (el === undefined) continue; if (el.offsetTop <= top) current = entry.item.model; else break; } if (current !== null) setActiveId((prev) => (prev === current ? prev : current)); }; // Left highlight → keep the highlighted entry visible inside the left list // (manual scroll: scrollIntoView would also move the center column). useEffect(() => { if (activeId === null) return; const list = listRef.current; const el = itemRefs.current.get(activeId); if (list === null || el === undefined) return; const top = el.offsetTop; const bottom = top + el.offsetHeight; if (top < list.scrollTop) list.scrollTop = top; else if (bottom > list.scrollTop + list.clientHeight) { list.scrollTop = bottom - list.clientHeight; } }, [activeId]); const jumpTo = (modelId: string) => { setActiveId(modelId); // 'instant', not 'smooth': smooth scrolling is frame-driven and stalls in // occluded tabs, leaving the center column stranded mid-jump. sectionRefs.current.get(modelId)?.scrollIntoView({ behavior: 'instant', block: 'start' }); }; const loading = providers.isLoading || models.isLoading || records.isLoading; const error = providers.error ?? models.error ?? records.error; return (
Model Catalog {providerList.length} providers · {items.length} models
queryClient.invalidateQueries({ queryKey: ['modelCatalog'] })}> Refresh
{error !== null ? (
) : null} {loading ?
loading…
: null} {!loading && flatEntries.length === 0 ? (
no providers or models configured
) : null}
{/* left: the model list */}
{/* center: one section per model */}
{flatEntries.map((entry) => ( { if (el === null) sectionRefs.current.delete(entry.item.model); else sectionRefs.current.set(entry.item.model, el); }} /> ))}
); } // --------------------------------------------------------------------------- // left column // --------------------------------------------------------------------------- function LeftList({ entries, activeId, onJump, itemRefs, }: { readonly entries: readonly FlatEntry[]; readonly activeId: string | null; readonly onJump: (modelId: string) => void; readonly itemRefs: React.RefObject>; }) { let lastGroup: string | undefined; return (
{entries.map((entry) => { const groupId = entry.provider?.id ?? '(no provider)'; const header = groupId !== lastGroup ? groupId : undefined; lastGroup = groupId; const isDefault = entry.provider?.default_model === entry.item.model; const active = entry.item.model === activeId; return (
{header !== undefined ? (
{header} {entry.provider !== undefined ? ( {entry.provider.status} ) : null}
) : null}
{ if (el === null) itemRefs.current.delete(entry.item.model); else itemRefs.current.set(entry.item.model, el); }} className={`cursor-pointer px-3 py-1.5 ${ active ? 'bg-sky-950/60' : 'hover:bg-neutral-800/60' }`} onClick={() => { onJump(entry.item.model); }} >
{entry.item.model} {isDefault ? default : null}
{entry.item.display_name !== undefined && entry.item.display_name !== entry.item.model ? (
{entry.item.display_name}
) : null}
); })}
); } // --------------------------------------------------------------------------- // center column // --------------------------------------------------------------------------- function ModelSection({ entry, onOpenSession, registerRef, }: { readonly entry: FlatEntry; readonly onOpenSession: (sessionId: string) => void; readonly registerRef: (el: HTMLElement | null) => void; }) { const { klient, baseUrl, config } = useConnection(); const { item, provider } = entry; const [ping, setPing] = useState< | { readonly status: 'idle' | 'running' } | { readonly status: 'done'; readonly result: ModelPingResult } >({ status: 'idle' }); const [creating, setCreating] = useState(false); const [sessionError, setSessionError] = useState(null); const runPing = async () => { setPing({ status: 'running' }); try { const result = await klient.core(IModelCatalog).ping(item.model); setPing({ status: 'done', result }); } catch (error) { setPing({ status: 'done', result: { ok: false, durationMs: 0, error: errorMessage(error) }, }); } }; // Session creation itself is v1 REST (klient is v2-only); resume + model // selection then go through the channel layer before opening the chat tab. const createSession = async () => { const cwd = window.prompt('Working directory for the new session:', ''); if (cwd === null || cwd.trim() === '') return; setCreating(true); setSessionError(null); try { const headers: Record = { 'content-type': 'application/json' }; if (config.token.trim() !== '') headers['authorization'] = `Bearer ${config.token.trim()}`; const res = await fetch(`${baseUrl}/api/v1/sessions`, { method: 'POST', headers, body: JSON.stringify({ metadata: { cwd: cwd.trim() } }), }); const envelope = (await res.json()) as { code: number; msg: string; data: { id: string } }; if (envelope.code !== 0) throw new Error(envelope.msg); const sessionId = envelope.data.id; await klient.core(ISessionManager).resume(sessionId); await klient .session(sessionId) .agent('main') .service(IAgentProfileService) .setModel(item.model); onOpenSession(sessionId); } catch (error) { setSessionError(error); } finally { setCreating(false); } }; return (
{item.model} {provider?.default_model === item.model ? default : null} {provider?.id ?? '(no provider)'} {ping.status === 'running' ? 'pinging…' : 'Ping'} {creating ? 'creating…' : '+ Session'}
{(item.capabilities ?? []).map((capability) => ( {capability} ))} {item.default_effort !== undefined ? ( effort: {item.default_effort} ) : null} {formatContextSize(item.max_context_size)}
{ping.status === 'done' ? (
{ping.result.ok ? ( <> pong · {ping.result.durationMs}ms {ping.result.text !== undefined && ping.result.text !== '' ? ( {ping.result.text} ) : null} {ping.result.usage !== undefined ? ( {usageLine(ping.result.usage)} ) : null} {ping.result.finishReason !== undefined ? ( {ping.result.finishReason} ) : null} ) : ( <> ping failed · {ping.result.durationMs}ms {ping.result.error} )}
) : null} {sessionError !== null ? : null}
); } function usageLine(usage: TokenUsage): string { const input = usage.inputOther + usage.inputCacheRead + usage.inputCacheCreation; return `in ${input} · out ${usage.output}`; } function formatContextSize(size: number): string { if (size <= 0) return '—'; if (size >= 1_000_000) { const millions = size / 1_000_000; return `${millions.toFixed(Number.isInteger(millions) ? 0 : 1)}M ctx`; } if (size >= 1_000) return `${Math.round(size / 1_000)}k ctx`; return `${size} ctx`; }