Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
9.1 kB · 253 lines
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254import { useEffect, useRef, useState, type OctaneNode } from "octane";import { usePreventScroll } from "@octanejs/aria";import { CaretDown, X } from "@octanejs/phosphor-icons";import { Badge } from "octane-kumo/components/badge";import { Button } from "octane-kumo/components/button";import { CommandPalette } from "octane-kumo/components/command-palette";import { Dialog } from "octane-kumo/components/dialog";import { MODEL_CATALOG, MODEL_PROVIDERS, modelLabel, modelPricing, type ModelProvider, type ModelConfiguration,} from "../../shared/model-providers";
const modelChoices = Object.entries(MODEL_CATALOG).flatMap( ([provider, models]) => models.map((model) => ({ value: `${provider}:${model}`, label: modelLabel(provider as ModelProvider, model), pricing: modelPricing(provider as ModelProvider, model), configuration: { provider, model } as ModelConfiguration, })),);
function choiceLabel(value: string) { return value === "default" ? "Installation default" : (modelChoices.find((choice) => choice.value === value)?.label ?? value);}
export function ChatModelPicker({ value, disabled, providerReady, onChange,}: { value: ModelConfiguration | null; disabled: boolean; providerReady: (provider: ModelProvider) => boolean; onChange: (value: ModelConfiguration | null) => void;}) { const [open, setOpen] = useState(false); // Overflow alone does not lock the iOS viewport while the keyboard is open. usePreventScroll({ isDisabled: !open }); const [query, setQuery] = useState(""); const [viewport, setViewport] = useState<{ height: number; top: number; } | null>(null); useEffect(() => { if (!open || !window.visualViewport) return; const visible = window.visualViewport; const update = () => setViewport({ height: visible.height, top: visible.offsetTop }); update(); visible.addEventListener("resize", update); visible.addEventListener("scroll", update); return () => { visible.removeEventListener("resize", update); visible.removeEventListener("scroll", update); }; }, [open]); const select = (configuration: ModelConfiguration | null) => { onChange(configuration); setOpen(false); }; return ( <Dialog.Root open={open} onOpenChange={(next) => { setOpen(next); setQuery(""); }} > <Dialog.Trigger render={<Button variant="ghost" size="sm" />} disabled={disabled} aria-label="Model" className="chat-model-trigger chat-composer-select" > <span className="chat-model-trigger-label"> {value ? value.model.split("/").at(-1) : "Installation default"} </span> <CaretDown size={14} aria-hidden="true" /> </Dialog.Trigger> <Dialog size="lg" className="chat-model-dialog" style={ viewport ? { top: (viewport?.top ?? 0) + 16, maxHeight: Math.min(576, (viewport?.height ?? 608) - 32), } : undefined } > <div className="chat-model-dialog-heading"> <Dialog.Title>Choose a model</Dialog.Title> <Dialog.Close render={<Button variant="ghost" size="sm" />} aria-label="Close model picker" > <X size={18} /> </Dialog.Close> </div> <CommandPalette.Panel items={["default", ...modelChoices.map((choice) => choice.value)]} value={query} onValueChange={setQuery} itemToStringValue={choiceLabel} filter={(value, query) => choiceLabel(value).toLowerCase().includes(query.toLowerCase()) } > <ModelSearchInput onClose={() => setOpen(false)} /> <ModelResults> <CommandPalette.Results> <CommandPalette.Item value="default" onClick={() => select(null)}> Installation default </CommandPalette.Item> {(Object.keys(MODEL_PROVIDERS) as ModelProvider[]) .filter((provider) => modelChoices.some( (choice) => choice.configuration.provider === provider && choice.label.toLowerCase().includes(query.toLowerCase()), ), ) .map((provider) => ( <CommandPalette.Group key={provider}> <CommandPalette.GroupLabel> {MODEL_PROVIDERS[provider].name} {providerReady(provider) ? "" : " · Set up in Settings"} </CommandPalette.GroupLabel> {modelChoices .filter( (choice) => choice.configuration.provider === provider, ) .map((choice) => ( <CommandPalette.Item key={choice.value} value={choice.value} textValue={choice.label} onClick={() => select(choice.configuration)} disabled={!providerReady(provider)} > <div className="chat-model-option"> <span className="chat-model-name"> {choice.configuration.model} </span> {choice.pricing && ( <Badge variant="secondary" className="chat-model-pricing" > {choice.pricing} </Badge> )} </div> </CommandPalette.Item> ))} </CommandPalette.Group> ))} </CommandPalette.Results> <CommandPalette.Empty>No models found.</CommandPalette.Empty> </ModelResults> </CommandPalette.Panel> </Dialog> </Dialog.Root> );}
function ModelResults({ children }: { children: OctaneNode }) { const results = useRef<HTMLDivElement | null>(null); useEffect(() => { const isIOS = /iPad|iPhone|iPod/.test(navigator.platform) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); const list = results.current; if (!isIOS || !list) return; const dismissKeyboard = (event: TouchEvent) => { if (event.touches.length !== 1) return; const dialog = list.closest<HTMLElement>('[role="dialog"]'); const active = document.activeElement; if (active instanceof HTMLInputElement && dialog?.contains(active)) { // Keep focus inside the modal so its focus trap cannot reopen the keyboard. dialog.focus({ preventScroll: true }); } }; list.addEventListener("touchstart", dismissKeyboard, { passive: true }); return () => list.removeEventListener("touchstart", dismissKeyboard); }, []); return ( <CommandPalette.List ref={results} className="chat-model-results"> {children} </CommandPalette.List> );}
function ModelSearchInput({ onClose }: { onClose: () => void }) { const input = useRef<HTMLInputElement | null>(null); useEffect(() => { const field = input.current; const dialog = field?.closest('[role="dialog"]'); if (!field || !dialog) return; // The pinned ARIA adapter loses the active descendant when filtering replaces rows. const syncFocus = () => { const list = dialog.querySelector('[role="listbox"]'); const option = dialog.querySelector( '[role="option"][data-focused="true"]', ); if (list?.id) field.setAttribute("aria-controls", list.id); if (option?.id) field.setAttribute("aria-activedescendant", option.id); else field.removeAttribute("aria-activedescendant"); }; const observer = new MutationObserver(syncFocus); observer.observe(dialog, { subtree: true, childList: true, attributes: true, attributeFilter: ["data-focused"], }); syncFocus(); return () => observer.disconnect(); }, []); return ( <CommandPalette.Input ref={input} autoFocus aria-label="Search models" placeholder="Search models…" className="chat-model-search" onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); onClose(); return; } if (event.key !== "Enter" || event.isComposing) return; const input = event.target as HTMLInputElement; const option = input .closest('[role="dialog"]') ?.querySelector<HTMLElement>('[role="option"][data-focused="true"]'); if (!option || option.getAttribute("aria-disabled") === "true") return; event.preventDefault(); option.click(); }} /> );}