mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-10 11:47:43 +02:00
[eric] split: extract ChatInput model picker hook + helpers
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
import { useEffect, RefObject } from 'react';
|
||||
|
||||
// Module-level draft store keyed by sessionId; survives unmount/remount and preserves skill pills via innerHTML.
|
||||
const _draftStore = new Map<string, string>();
|
||||
// 200ms debounce coalesces fast typing; innerHTML reads do full DOM serialization.
|
||||
const _draftDebounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const DRAFT_DEBOUNCE_MS = 200;
|
||||
|
||||
export function scheduleDraftSave(ownerId: string, getHtml: () => string) {
|
||||
const existing = _draftDebounceTimers.get(ownerId);
|
||||
if (existing) clearTimeout(existing);
|
||||
_draftDebounceTimers.set(ownerId, setTimeout(() => {
|
||||
_draftDebounceTimers.delete(ownerId);
|
||||
const html = getHtml();
|
||||
if (html && html !== '<br>') _draftStore.set(ownerId, html);
|
||||
else _draftStore.delete(ownerId);
|
||||
}, DRAFT_DEBOUNCE_MS));
|
||||
}
|
||||
|
||||
export function loadDraft(ownerId: string): string | undefined {
|
||||
return _draftStore.get(ownerId);
|
||||
}
|
||||
|
||||
export function deleteDraft(ownerId: string) {
|
||||
_draftStore.delete(ownerId);
|
||||
}
|
||||
|
||||
export function useDraftLoad(editorRef: RefObject<HTMLDivElement>, ownerId: string) {
|
||||
useEffect(() => {
|
||||
const saved = _draftStore.get(ownerId);
|
||||
const editor = editorRef.current;
|
||||
if (saved && editor && !editor.textContent?.trim()) {
|
||||
editor.innerHTML = saved;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(editor);
|
||||
range.collapse(false);
|
||||
const sel = window.getSelection();
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export function formatTokenCount(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
// Path basename that works on both POSIX (/Users/x/file.pdf) and Windows
|
||||
// (C:\Users\x\file.pdf). Splits on either separator; falls back to the
|
||||
// raw path so empty segments don't yield ''.
|
||||
export function basename(p: string): string {
|
||||
if (!p) return '';
|
||||
const parts = p.split(/[\\/]/).filter(Boolean);
|
||||
return parts[parts.length - 1] || p;
|
||||
}
|
||||
|
||||
export function pathTail(p: string, n: number): string {
|
||||
if (!p) return '';
|
||||
const parts = p.split(/[\\/]/).filter(Boolean);
|
||||
return parts.slice(-n).join('/');
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined';
|
||||
import QuestionAnswerOutlinedIcon from '@mui/icons-material/QuestionAnswerOutlined';
|
||||
import MapOutlinedIcon from '@mui/icons-material/MapOutlined';
|
||||
import CategoryOutlinedIcon from '@mui/icons-material/CategoryOutlined';
|
||||
import TuneOutlinedIcon from '@mui/icons-material/TuneOutlined';
|
||||
|
||||
export const ICON_MAP: Record<string, React.ReactNode> = {
|
||||
smart_toy: <SmartToyOutlinedIcon sx={{ fontSize: 14 }} />,
|
||||
question_answer: <QuestionAnswerOutlinedIcon sx={{ fontSize: 14 }} />,
|
||||
map: <MapOutlinedIcon sx={{ fontSize: 14 }} />,
|
||||
category: <CategoryOutlinedIcon sx={{ fontSize: 14 }} />,
|
||||
tune: <TuneOutlinedIcon sx={{ fontSize: 14 }} />,
|
||||
};
|
||||
|
||||
export const FALLBACK_MODE_BASE = { label: 'Agent', icon: ICON_MAP.smart_toy };
|
||||
@@ -0,0 +1,113 @@
|
||||
// Mirrors SubscriptionCard colors in Settings.
|
||||
export const PROVIDER_COLORS: Record<string, string> = {
|
||||
anthropic: '#E8927A',
|
||||
openai: '#74AA9C',
|
||||
google: '#4285F4',
|
||||
gemini: '#4285F4',
|
||||
xai: '#8B949E',
|
||||
meta: '#0866FF',
|
||||
deepseek: '#4D6BFE',
|
||||
mistral: '#FF7000',
|
||||
qwen: '#A974FF',
|
||||
cohere: '#FF7759',
|
||||
openrouter: '#64748B',
|
||||
};
|
||||
|
||||
export const LS_RECENT_MODELS = 'openswarm.picker.recentModels';
|
||||
export const LS_RECENT_SEARCHES = 'openswarm.picker.recentSearches';
|
||||
export const RECENT_MODELS_MAX = 3;
|
||||
export const RECENT_SEARCHES_MAX = 4;
|
||||
export const OR_AUTO_COLLAPSE_THRESHOLD = 12;
|
||||
|
||||
export const LS_FILTERS_EXPANDED = 'openswarm.picker.filtersExpanded';
|
||||
export const LS_COLLAPSED_GROUPS = 'openswarm.picker.collapsedGroups';
|
||||
|
||||
export const CTX_STEPS = [0, 32_000, 128_000, 200_000, 500_000, 1_000_000];
|
||||
export const CTX_LABELS = ['Any', '32K+', '128K+', '200K+', '500K+', '1M+'];
|
||||
export const COST_STEPS = [Infinity, 50, 15, 5, 1, 0];
|
||||
export const COST_LABELS = ['Any', '≤$50/M', '≤$15/M', '≤$5/M', '≤$1/M', 'Free only'];
|
||||
|
||||
export function readLS<T>(key: string, fallback: T): T {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
return raw ? (JSON.parse(raw) as T) : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeLS(key: string, value: unknown) {
|
||||
try { localStorage.setItem(key, JSON.stringify(value)); } catch {}
|
||||
}
|
||||
|
||||
// Heuristic tiering for pre-load FALLBACK_MODELS only; backend provides real tiers post-load.
|
||||
export type Tier = 1 | 2 | 3 | 4 | 5;
|
||||
const clampTier = (n: number): Tier => Math.max(1, Math.min(5, n)) as Tier;
|
||||
|
||||
function _costBucket(out: number): Tier {
|
||||
if (out < 0.5) return 1;
|
||||
if (out < 2) return 2;
|
||||
if (out < 7) return 3;
|
||||
if (out < 25) return 4;
|
||||
return 5;
|
||||
}
|
||||
|
||||
export function tierIntelligence(opt: any): Tier {
|
||||
let tier: number = _costBucket(opt.output_cost_per_1m ?? 0);
|
||||
if (opt.reasoning) tier += 1;
|
||||
return clampTier(tier);
|
||||
}
|
||||
|
||||
export function tierSpeed(opt: any): Tier {
|
||||
let tier: number = 6 - _costBucket(opt.output_cost_per_1m ?? 0);
|
||||
if (opt.reasoning) tier -= 1;
|
||||
const lower = String(opt.label || '').toLowerCase();
|
||||
if (/\b(mini|lite|flash|haiku|nano|small|fast|turbo|micro|tiny)\b/.test(lower)) tier += 1;
|
||||
if (/\b(opus|ultra|max|xlarge|titan)\b/.test(lower)) tier -= 1;
|
||||
return clampTier(tier);
|
||||
}
|
||||
|
||||
export function tierCost(opt: any): Tier {
|
||||
return _costBucket(opt.output_cost_per_1m ?? 0);
|
||||
}
|
||||
|
||||
/** Extract version number from a model label; clamps to <30 to skip param counts like 70B/120B. */
|
||||
function modelVersion(label: string): number {
|
||||
const matches = String(label).matchAll(/(\d+(?:\.\d+)?)/g);
|
||||
let bestVersion = 0;
|
||||
for (const m of matches) {
|
||||
const v = parseFloat(m[1]);
|
||||
if (v >= 0.5 && v < 30 && v > bestVersion) bestVersion = v;
|
||||
}
|
||||
return bestVersion;
|
||||
}
|
||||
|
||||
/** Strip versions and route suffixes so "Claude Sonnet 4.6" and 4.5 share one key. */
|
||||
function modelFamilyKey(label: string): string {
|
||||
return String(label)
|
||||
.toLowerCase()
|
||||
.replace(/\b\d+(?:\.\d+)?\b/g, '')
|
||||
.replace(/\(api key\)/gi, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Sort: intelligence desc, family asc, version desc, label asc. */
|
||||
export function sortModelsForPicker<T extends { label: string }>(models: T[]): T[] {
|
||||
const intelOf = (opt: any): number => {
|
||||
if (Array.isArray(opt.tiers) && opt.tiers.length === 3) return opt.tiers[0];
|
||||
return tierIntelligence(opt);
|
||||
};
|
||||
return [...models].sort((a: any, b: any) => {
|
||||
const intelA = intelOf(a);
|
||||
const intelB = intelOf(b);
|
||||
if (intelA !== intelB) return intelB - intelA;
|
||||
const famA = modelFamilyKey(a.label);
|
||||
const famB = modelFamilyKey(b.label);
|
||||
if (famA !== famB) return famA.localeCompare(famB);
|
||||
const verA = modelVersion(a.label);
|
||||
const verB = modelVersion(b.label);
|
||||
if (verA !== verB) return verB - verA;
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import { ClaudeTokens } from '@/shared/styles/claudeTokens';
|
||||
import { tierIntelligence, tierSpeed, tierCost } from './modelPicker';
|
||||
|
||||
export function useModelTooltip(c: ClaudeTokens) {
|
||||
const buildModelTooltip = useCallback((opt: any): React.ReactNode => {
|
||||
const [intel, speed, cost] = (Array.isArray(opt.tiers) && opt.tiers.length === 3)
|
||||
? opt.tiers
|
||||
: [tierIntelligence(opt), tierSpeed(opt), tierCost(opt)];
|
||||
const billingKind: 'paid' | 'subscription' | 'free' = opt.billing_kind || (opt.is_free ? 'free' : 'paid');
|
||||
const Bars = ({ filled, palette }: { filled: number; palette: string[] }) => {
|
||||
const TOTAL_CELLS = 15;
|
||||
const filledCells = Math.round((filled / 5) * TOTAL_CELLS);
|
||||
return (
|
||||
<Box sx={{ display: 'inline-flex', gap: '1px', alignItems: 'center' }}>
|
||||
{Array.from({ length: TOTAL_CELLS }, (_, i) => {
|
||||
const on = i < filledCells;
|
||||
const colorIdx = on
|
||||
? Math.min(palette.length - 1, Math.floor((i / Math.max(filledCells - 1, 1)) * (palette.length - 1)))
|
||||
: 0;
|
||||
return (
|
||||
<Box
|
||||
key={i}
|
||||
sx={{
|
||||
width: 5, height: 5,
|
||||
bgcolor: on ? palette[colorIdx] : c.border.subtle,
|
||||
opacity: on ? 1 : 0.3,
|
||||
transformOrigin: 'center',
|
||||
animation: on
|
||||
? `pixelPop 0.22s cubic-bezier(0.34, 1.56, 0.64, 1) ${i * 0.018}s both`
|
||||
: 'none',
|
||||
'@keyframes pixelPop': {
|
||||
'0%': { transform: 'scale(0)', opacity: 0 },
|
||||
'60%': { transform: 'scale(1.2)', opacity: 1 },
|
||||
'100%': { transform: 'scale(1)', opacity: 1 },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
const INTEL_PALETTE = ['#6D5BBE', '#8870D5', '#A78BFA', '#BFA3FF', '#D5BFFF'];
|
||||
const SPEED_PALETTE = ['#2DBFAA', '#42D6BF', '#5EEAD4', '#7FF1DF', '#A3F7E9'];
|
||||
const COST_PALETTE = ['#C7752E', '#DD8A3D', '#F59E0B', '#FAB23C', '#FCC773'];
|
||||
const capabilities = [
|
||||
opt.reasoning && 'Reasoning',
|
||||
'Tools',
|
||||
billingKind === 'free' && 'Free tier',
|
||||
billingKind === 'subscription' && 'Subscription',
|
||||
(opt.context_window ?? 0) >= 1_000_000 && '1M+ context',
|
||||
].filter(Boolean).join(' · ');
|
||||
return (
|
||||
<Box sx={{ fontSize: '0.74rem', lineHeight: 1.55, minWidth: 256 }}>
|
||||
<Box sx={{
|
||||
fontWeight: 600, fontSize: '0.85rem', mb: 0.85,
|
||||
color: c.text.primary,
|
||||
letterSpacing: '-0.01em',
|
||||
pb: 0.6,
|
||||
borderBottom: `1px solid ${c.border.subtle}`,
|
||||
}}>
|
||||
{opt.label}
|
||||
</Box>
|
||||
<Box sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'auto 1fr',
|
||||
columnGap: 1.75, rowGap: 0.5,
|
||||
alignItems: 'center',
|
||||
color: c.text.muted,
|
||||
}}>
|
||||
<span>Intelligence</span><Bars filled={intel} palette={INTEL_PALETTE} />
|
||||
<span>Speed</span><Bars filled={speed} palette={SPEED_PALETTE} />
|
||||
{billingKind === 'subscription' ? null : (
|
||||
<>
|
||||
<span>Cost</span>
|
||||
{billingKind === 'free'
|
||||
? <Box component="span" sx={{ color: '#10b981', fontWeight: 600 }}>Free</Box>
|
||||
: <Bars filled={cost} palette={COST_PALETTE} />}
|
||||
</>
|
||||
)}
|
||||
<span>Context</span>
|
||||
<span style={{ fontVariantNumeric: 'tabular-nums', color: c.text.secondary }}>
|
||||
{(opt.context_window ?? 0).toLocaleString()}
|
||||
</span>
|
||||
{billingKind === 'paid' && (opt.input_cost_per_1m || opt.output_cost_per_1m) ? (
|
||||
<>
|
||||
<span>Pricing</span>
|
||||
<span style={{ fontVariantNumeric: 'tabular-nums', color: c.text.secondary }}>
|
||||
${opt.input_cost_per_1m?.toFixed(2)}/M in · ${opt.output_cost_per_1m?.toFixed(2)}/M out
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
{capabilities && (
|
||||
<>
|
||||
<span>Capabilities</span>
|
||||
<span style={{ color: c.text.secondary }}>{capabilities}</span>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}, [c]);
|
||||
|
||||
const tooltipSlotProps = useMemo(() => ({
|
||||
tooltip: {
|
||||
sx: {
|
||||
bgcolor: c.bg.elevated,
|
||||
color: c.text.primary,
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
boxShadow: '0 12px 32px rgba(0, 0, 0, 0.32)',
|
||||
padding: '12px 14px',
|
||||
maxWidth: 340,
|
||||
fontSize: '0.78rem',
|
||||
fontFamily: c.font.sans,
|
||||
},
|
||||
},
|
||||
arrow: { sx: { color: c.bg.elevated, '&:before': { border: `1px solid ${c.border.subtle}` } } },
|
||||
}), [c]);
|
||||
|
||||
return { buildModelTooltip, tooltipSlotProps };
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { API_BASE, getAuthToken } from '@/shared/config';
|
||||
|
||||
/** Handles /context, /compact, /clear; returns true if intercepted so the prompt isn't sent to the agent. */
|
||||
export async function handleSlashCommand(cmd: string, sessionId: string): Promise<boolean> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
const tok = (() => { try { return getAuthToken(); } catch { return ''; } })();
|
||||
if (tok) headers['Authorization'] = `Bearer ${tok}`;
|
||||
if (cmd === '/context') {
|
||||
window.dispatchEvent(new CustomEvent('openswarm:context-drawer', { detail: { sessionId, open: true } }));
|
||||
return true;
|
||||
}
|
||||
// /compact and /clear mount at /api/agents/sessions/{id}/..., not under the agents SubApp.
|
||||
if (cmd === '/compact') {
|
||||
try {
|
||||
await fetch(`${API_BASE}/agents/sessions/${sessionId}/compact`, { method: 'POST', headers });
|
||||
} catch {}
|
||||
return true;
|
||||
}
|
||||
if (cmd === '/clear') {
|
||||
try {
|
||||
await fetch(`${API_BASE}/agents/sessions/${sessionId}/clear`, { method: 'POST', headers });
|
||||
} catch {}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { ContextPath } from '@/app/components/DirectoryBrowser';
|
||||
|
||||
export interface AttachedImage {
|
||||
data: string;
|
||||
media_type: string;
|
||||
preview: string;
|
||||
// Set when preview uses createObjectURL; handleSend reads via FileReader to avoid retaining base64 in memory.
|
||||
_file?: File;
|
||||
}
|
||||
|
||||
export interface ForcedToolGroup {
|
||||
label: string;
|
||||
tools: string[];
|
||||
icon?: React.ReactNode;
|
||||
iconKey?: string;
|
||||
}
|
||||
|
||||
export interface ChatInputHandle {
|
||||
getConfig: () => { prompt: string; contextPaths: ContextPath[]; forcedTools: ForcedToolGroup[] };
|
||||
setContent: (prompt: string, contextPaths?: ContextPath[], forcedTools?: ForcedToolGroup[]) => void;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useAppSelector } from '@/shared/hooks';
|
||||
import { sortModelsForPicker } from './modelPicker';
|
||||
|
||||
const FALLBACK_MODELS = [
|
||||
{ value: 'sonnet', label: 'Claude Sonnet 4.6', context_window: 1_000_000, reasoning: true },
|
||||
{ value: 'opus', label: 'Claude Opus 4.6', context_window: 1_000_000, reasoning: true },
|
||||
{ value: 'haiku', label: 'Claude Haiku 4.5', context_window: 200_000, reasoning: true },
|
||||
];
|
||||
|
||||
export function useChatInputModel(model: string) {
|
||||
const modelsByProvider = useAppSelector((state) => state.models.byProvider);
|
||||
const modelsLoaded = useAppSelector((state) => state.models.loaded);
|
||||
const connectionMode = useAppSelector((state) => state.settings.data.connection_mode);
|
||||
|
||||
const allModelOptions = useMemo(() => {
|
||||
if (!modelsLoaded || Object.keys(modelsByProvider).length === 0) {
|
||||
const key = connectionMode === 'openswarm-pro' ? 'OpenSwarm Pro' : 'Anthropic';
|
||||
return { flat: FALLBACK_MODELS.map(m => ({ ...m, provider: key })), grouped: { [key]: FALLBACK_MODELS } };
|
||||
}
|
||||
const flat: Array<any> = [];
|
||||
const grouped: Record<string, any[]> = {};
|
||||
for (const [prov, models] of Object.entries(modelsByProvider)) {
|
||||
const enriched = models.map((m: any) => ({
|
||||
value: m.value,
|
||||
label: m.label,
|
||||
context_window: m.context_window ?? 200_000,
|
||||
reasoning: !!m.reasoning,
|
||||
input_cost_per_1m: m.input_cost_per_1m ?? 0,
|
||||
output_cost_per_1m: m.output_cost_per_1m ?? 0,
|
||||
is_free: !!m.is_free,
|
||||
max_completion_tokens: m.max_completion_tokens ?? null,
|
||||
tiers: Array.isArray(m.tiers) && m.tiers.length === 3 ? m.tiers : undefined,
|
||||
billing_kind: m.billing_kind,
|
||||
}));
|
||||
grouped[prov] = sortModelsForPicker(enriched);
|
||||
for (const m of enriched) {
|
||||
flat.push({ ...m, provider: prov });
|
||||
}
|
||||
}
|
||||
return { flat, grouped };
|
||||
}, [modelsByProvider, modelsLoaded, connectionMode]);
|
||||
|
||||
const currentModelCtx = useMemo(() => {
|
||||
const m = allModelOptions.flat.find((x: any) => x.value === model) as any;
|
||||
return (m?.context_window as number) || 200_000;
|
||||
}, [allModelOptions.flat, model]);
|
||||
|
||||
const currentModelApi = useMemo<string>(() => {
|
||||
const m = allModelOptions.flat.find((x: any) => x.value === model) as any;
|
||||
return ((m?.api as string) || 'anthropic').toLowerCase();
|
||||
}, [allModelOptions.flat, model]);
|
||||
|
||||
// Mirrors backend agent_manager._resolve_attachments support matrix.
|
||||
// PDFs: Anthropic, Gemini, OpenRouter (file-parser plugin), and
|
||||
// OpenAI direct on GPT-5.x non-Codex (anthropic_proxy bypasses
|
||||
// 9router and POSTs to api.openai.com via anthropic_to_openai.py).
|
||||
// Images: every provider via 9router image_url translation.
|
||||
const isCodexModel = typeof model === 'string' && (model.toLowerCase().includes('codex') || model.toLowerCase().startsWith('cx/'));
|
||||
const pdfSupported = (
|
||||
['anthropic', 'gemini', 'gemini-cli', 'openrouter'].includes(currentModelApi) ||
|
||||
(currentModelApi === 'openai' && !isCodexModel)
|
||||
);
|
||||
const imageSupported = ['anthropic', 'gemini', 'gemini-cli', 'openai', 'openrouter'].includes(currentModelApi);
|
||||
|
||||
return { allModelOptions, currentModelCtx, currentModelApi, pdfSupported, imageSupported };
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
import {
|
||||
LS_RECENT_MODELS,
|
||||
LS_RECENT_SEARCHES,
|
||||
RECENT_MODELS_MAX,
|
||||
RECENT_SEARCHES_MAX,
|
||||
LS_FILTERS_EXPANDED,
|
||||
LS_COLLAPSED_GROUPS,
|
||||
CTX_STEPS,
|
||||
COST_STEPS,
|
||||
readLS,
|
||||
writeLS,
|
||||
} from './modelPicker';
|
||||
|
||||
type CapFilters = { reasoning: boolean; subscription: boolean; apiKey: boolean };
|
||||
|
||||
interface AllModelOptions {
|
||||
flat: Array<any>;
|
||||
grouped: Record<string, any[]>;
|
||||
}
|
||||
|
||||
export function useModelPicker(
|
||||
allModelOptions: AllModelOptions,
|
||||
model: string,
|
||||
modelAnchor: HTMLElement | null,
|
||||
) {
|
||||
const [modelSearch, setModelSearch] = useState('');
|
||||
const modelSearchRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const [recentModels, setRecentModels] = useState<string[]>(
|
||||
() => readLS<string[]>(LS_RECENT_MODELS, []).slice(0, RECENT_MODELS_MAX),
|
||||
);
|
||||
const [recentSearches, setRecentSearches] = useState<string[]>(() => readLS<string[]>(LS_RECENT_SEARCHES, []));
|
||||
const pushRecentModel = useCallback((value: string) => {
|
||||
setRecentModels((prev) => {
|
||||
const next = [value, ...prev.filter((v) => v !== value)].slice(0, RECENT_MODELS_MAX);
|
||||
writeLS(LS_RECENT_MODELS, next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const pushRecentSearch = useCallback((q: string) => {
|
||||
const trimmed = q.trim();
|
||||
if (!trimmed) return;
|
||||
setRecentSearches((prev) => {
|
||||
const next = [trimmed, ...prev.filter((s) => s !== trimmed)].slice(0, RECENT_SEARCHES_MAX);
|
||||
writeLS(LS_RECENT_SEARCHES, next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const [capFilters, setCapFilters] = useState<CapFilters>({
|
||||
reasoning: false, subscription: false, apiKey: false,
|
||||
});
|
||||
|
||||
const [ctxIdx, setCtxIdx] = useState(0);
|
||||
const [costIdx, setCostIdx] = useState(0);
|
||||
|
||||
const [filtersExpanded, setFiltersExpanded] = useState<boolean>(
|
||||
() => readLS<boolean>(LS_FILTERS_EXPANDED, false),
|
||||
);
|
||||
const toggleFilters = useCallback(() => {
|
||||
setFiltersExpanded((prev) => {
|
||||
writeLS(LS_FILTERS_EXPANDED, !prev);
|
||||
return !prev;
|
||||
});
|
||||
}, []);
|
||||
const anyFilterActive = (
|
||||
capFilters.reasoning || capFilters.subscription || capFilters.apiKey
|
||||
|| ctxIdx > 0 || costIdx > 0
|
||||
);
|
||||
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>(
|
||||
() => readLS<Record<string, boolean>>(LS_COLLAPSED_GROUPS, {}),
|
||||
);
|
||||
const toggleGroupCollapse = useCallback((prov: string, currentlyCollapsed: boolean) => {
|
||||
setCollapsedGroups((prev) => {
|
||||
const next = { ...prev, [prov]: !currentlyCollapsed };
|
||||
writeLS(LS_COLLAPSED_GROUPS, next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Keyed by model value so stale probe results don't display.
|
||||
const [probeResult, setProbeResult] = useState<{ value: string; ok: boolean; error?: string; latency_ms?: number } | null>(null);
|
||||
|
||||
const filteredModelGroups = useMemo(() => {
|
||||
const q = modelSearch.trim().toLowerCase();
|
||||
const minCtx = CTX_STEPS[ctxIdx] || 0;
|
||||
const maxCost = COST_STEPS[costIdx];
|
||||
const anyCap = (
|
||||
capFilters.reasoning || capFilters.subscription || capFilters.apiKey
|
||||
|| ctxIdx > 0 || costIdx > 0
|
||||
);
|
||||
const filterFn = (m: any): boolean => {
|
||||
if (capFilters.reasoning && !m.reasoning) return false;
|
||||
if (capFilters.subscription || capFilters.apiKey) {
|
||||
const okSub = capFilters.subscription && m.billing_kind === 'subscription';
|
||||
const okApi = capFilters.apiKey && m.billing_kind === 'api_key';
|
||||
if (!okSub && !okApi) return false;
|
||||
}
|
||||
if (minCtx > 0 && (m.context_window ?? 0) < minCtx) return false;
|
||||
// maxCost=0 ("Free only") passes subscription (free to user); paid/api_key excluded regardless of price.
|
||||
if (maxCost !== Infinity) {
|
||||
|
||||
if (maxCost === 0) {
|
||||
if (m.billing_kind !== 'free' && m.billing_kind !== 'subscription') return false;
|
||||
} else {
|
||||
if (
|
||||
(m.billing_kind === 'paid' || m.billing_kind === 'api_key')
|
||||
&& (m.output_cost_per_1m ?? 0) > maxCost
|
||||
) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
if (!q && !anyCap) return allModelOptions.grouped;
|
||||
const out: Record<string, Array<any>> = {};
|
||||
for (const [prov, models] of Object.entries(allModelOptions.grouped)) {
|
||||
const provLower = prov.toLowerCase();
|
||||
const qMatch = (m: any) =>
|
||||
!q
|
||||
|| m.label.toLowerCase().includes(q)
|
||||
|| m.value.toLowerCase().includes(q)
|
||||
|| provLower.includes(q);
|
||||
const matches = (models as any[]).filter((m) => filterFn(m) && qMatch(m));
|
||||
if (matches.length) out[prov] = matches;
|
||||
}
|
||||
return out;
|
||||
}, [modelSearch, allModelOptions.grouped, capFilters, ctxIdx, costIdx]);
|
||||
|
||||
const pickerSummary = useMemo(() => {
|
||||
let total = 0, free = 0, reasoning = 0, subscription = 0, apiKey = 0, paid = 0, longContext = 0;
|
||||
for (const ms of Object.values(filteredModelGroups)) {
|
||||
for (const m of ms as any[]) {
|
||||
total += 1;
|
||||
if (m.reasoning) reasoning += 1;
|
||||
if ((m.context_window ?? 0) >= 1_000_000) longContext += 1;
|
||||
if (m.billing_kind === 'free') free += 1;
|
||||
else if (m.billing_kind === 'subscription') subscription += 1;
|
||||
else if (m.billing_kind === 'api_key') apiKey += 1;
|
||||
else if (m.billing_kind === 'paid') paid += 1;
|
||||
}
|
||||
}
|
||||
return { total, free, reasoning, subscription, apiKey, paid, longContext };
|
||||
}, [filteredModelGroups]);
|
||||
|
||||
const recentMaterialised = useMemo(() => {
|
||||
const flatByValue = new Map(allModelOptions.flat.map((m) => [m.value, m]));
|
||||
return recentModels
|
||||
.map((v) => flatByValue.get(v))
|
||||
.filter(Boolean) as typeof allModelOptions.flat;
|
||||
}, [recentModels, allModelOptions.flat]);
|
||||
const showRecents = (
|
||||
!modelSearch.trim()
|
||||
&& !capFilters.reasoning && !capFilters.subscription && !capFilters.apiKey
|
||||
&& ctxIdx === 0 && costIdx === 0
|
||||
&& recentMaterialised.length > 0
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (modelAnchor) {
|
||||
const t = setTimeout(() => modelSearchRef.current?.focus(), 30);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
setModelSearch('');
|
||||
}, [modelAnchor]);
|
||||
|
||||
// Debounced 1-token probe surfaces 401/402/etc before send.
|
||||
useEffect(() => {
|
||||
if (!model) return;
|
||||
let cancelled = false;
|
||||
const t = setTimeout(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/agents/probe-model`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model }),
|
||||
});
|
||||
if (cancelled) return;
|
||||
const data = await res.json();
|
||||
setProbeResult({ value: model, ok: !!data.ok, error: data.error, latency_ms: data.latency_ms });
|
||||
} catch {}
|
||||
}, 350);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(t);
|
||||
};
|
||||
}, [model]);
|
||||
|
||||
return {
|
||||
modelSearch, setModelSearch, modelSearchRef,
|
||||
recentModels, recentSearches,
|
||||
pushRecentModel, pushRecentSearch,
|
||||
capFilters, setCapFilters,
|
||||
ctxIdx, setCtxIdx, costIdx, setCostIdx,
|
||||
filtersExpanded, toggleFilters, anyFilterActive,
|
||||
collapsedGroups, toggleGroupCollapse,
|
||||
probeResult,
|
||||
filteredModelGroups, pickerSummary,
|
||||
recentMaterialised, showRecents,
|
||||
};
|
||||
}
|
||||
|
||||
export type ModelPickerState = ReturnType<typeof useModelPicker>;
|
||||
Reference in New Issue
Block a user