diff --git a/apps/desktop/src/features/models/CursorModelEditor.tsx b/apps/desktop/src/features/models/CursorModelEditor.tsx index d01d1d9..90e2ca0 100644 --- a/apps/desktop/src/features/models/CursorModelEditor.tsx +++ b/apps/desktop/src/features/models/CursorModelEditor.tsx @@ -1,3 +1,4 @@ +import { useRef } from "react"; import type { ModelInput, ModelType } from "../../shared/api"; import { defaultCustomHeadersText } from "../../shared/utils/modelDefaults"; import { modelPresets, presetEndpoint, trimTrailingSlash, type ModelPreset } from "../../shared/utils/modelPresets"; @@ -5,7 +6,7 @@ import { Button } from "../../shared/ui/Button"; import { Checkbox } from "../../shared/ui/Checkbox"; import { FormField, SecretTextInput, TextInput } from "../../shared/ui/FormControls"; import { JsonEditor } from "../../shared/ui/JsonEditor"; -import { Combobox, Select } from "../../shared/ui/Select"; +import { Combobox, Select, type ComboboxHandle } from "../../shared/ui/Select"; import { Switch } from "../../shared/ui/Switch"; import { claudeIcon, openAiIcon } from "../../shared/ui/icons"; import { CursorPresetChips } from "./CursorPresetChips"; @@ -55,8 +56,9 @@ export function CursorModelEditor({ draft, modelOptions, discovering, onChange, modelOptions: string[]; discovering: boolean; onChange: (draft: CursorModelDraft) => void; - onDiscover: () => void; + onDiscover: () => Promise; }) { + const modelCombobox = useRef(null); const setModel = (patch: Partial) => onChange({ ...draft, model: { ...draft.model, ...patch } }); const setType = (type: ModelType) => { // 切换协议类型时,若当前地址命中某预设的另一协议端点,自动换到该预设对应协议的端点, @@ -89,6 +91,9 @@ export function CursorModelEditor({ draft, modelOptions, discovering, onChange, .filter((preset) => trimTrailingSlash(presetEndpoint(preset, draft.model.type).baseUrl) === trimTrailingSlash(draft.model.base_url.trim())) .flatMap((preset) => preset.models.map((item) => item.model_id)); const combinedOptions = [...new Set([...modelOptions, ...presetModelOptions])]; + const discoverModels = async () => { + if (await onDiscover()) modelCombobox.current?.openAll(); + }; const applyPreset = (preset: ModelPreset) => { const endpoint = presetEndpoint(preset, draft.model.type); const first = preset.models[0]; @@ -151,7 +156,7 @@ export function CursorModelEditor({ draft, modelOptions, discovering, onChange, setModel({ api_key: event.target.value })} /> - {discovering ? t("获取中…") : t("获取模型")}} onChange={(model_id) => setModel({ model_id, display_name: draft.model.display_name || model_id })} /> + void discoverModels()}>{discovering ? t("获取中…") : t("获取模型")}} onChange={(model_id) => setModel({ model_id, display_name: draft.model.display_name || model_id })} /> setModel({ display_name: event.target.value })} /> setModel({ tooltip_data: event.target.value })} /> diff --git a/apps/desktop/src/features/models/CursorSettingsPage.tsx b/apps/desktop/src/features/models/CursorSettingsPage.tsx index 74fdf52..245d028 100644 --- a/apps/desktop/src/features/models/CursorSettingsPage.tsx +++ b/apps/desktop/src/features/models/CursorSettingsPage.tsx @@ -88,8 +88,8 @@ export function CursorSettingsPage() { anthropicExtraParamsText: JSON.stringify(model.anthropic_extra_params, null, 2), }); }; - const discover = async () => { - if (!draft) return; + const discover = async (): Promise => { + if (!draft) return false; setDiscovering(true); try { const custom_headers = parseHeaders(draft.customHeadersText); @@ -101,8 +101,10 @@ export function CursorSettingsPage() { custom_headers, }); setModelOptions([...new Set(result.models)]); + return true; } catch (cause) { message(errorText(cause)); + return false; } finally { setDiscovering(false); } @@ -338,7 +340,7 @@ export function CursorSettingsPage() { : undefined} busy={cursorBusy || savingAndTesting} onClose={() => { if (editing && editorTesting) void cancelModelTest(editing.model_hash); setDraft(null); setEditing(null); }} onSubmit={() => void save()} submitLabel={t("保存")} secondaryAction={}> {draft && <> - void discover()} /> + } setCaCommand(null)} onConfirm={openCaTerminal}> diff --git a/apps/desktop/src/shared/ui/Select.tsx b/apps/desktop/src/shared/ui/Select.tsx index 623b817..2827ea9 100644 --- a/apps/desktop/src/shared/ui/Select.tsx +++ b/apps/desktop/src/shared/ui/Select.tsx @@ -1,5 +1,5 @@ import { autoUpdate, computePosition, flip, offset, shift, size } from "@floating-ui/dom"; -import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { forwardRef, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { VirtualList } from "../virtual/VirtualList"; import type { VirtualListApi } from "../virtual/virtualTypes"; @@ -57,19 +57,44 @@ export function Select({ value, options, disabled, ariaLabel, onChange }: { valu ; } -export function Combobox({ value, options = [], placeholder, disabled, append, onChange }: { value: string; options?: string[]; placeholder?: string; disabled?: boolean; append?: ReactNode; onChange: (value: string) => void }) { +export type ComboboxHandle = { + openAll: () => void; +}; + +type ComboboxProps = { + value: string; + options?: string[]; + placeholder?: string; + disabled?: boolean; + append?: ReactNode; + onChange: (value: string) => void; +}; + +export const Combobox = forwardRef(function Combobox({ value, options = [], placeholder, disabled, append, onChange }, ref) { const root = useRef(null); const input = useRef(null); const menu = useRef(null); const listApi = useRef(null); + const closeTimer = useRef(null); const menuId = useId(); const [open, setOpen] = useState(false); + const [filtering, setFiltering] = useState(true); const [active, setActive] = useState(-1); const [position, setPosition] = useState({ left: 0, top: 0, width: 0, maxHeight: 280 }); const filtered = useMemo(() => { + if (!filtering) return options; const query = value.trim().toLocaleLowerCase(); return options.filter((option) => !query || option.toLocaleLowerCase().includes(query)); - }, [options, value]); + }, [filtering, options, value]); + + useImperativeHandle(ref, () => ({ + openAll: () => { + if (closeTimer.current !== null) window.clearTimeout(closeTimer.current); + setFiltering(false); + setActive(0); + setOpen(true); + }, + }), []); useLayoutEffect(() => { if (!open || !root.current || !menu.current) return; @@ -79,6 +104,9 @@ export function Combobox({ value, options = [], placeholder, disabled, append, o }).then(({ x, y }) => setPosition((current) => ({ ...current, left: x, top: y })))); }, [open, filtered.length]); + useEffect(() => () => { + if (closeTimer.current !== null) window.clearTimeout(closeTimer.current); + }, []); useEffect(() => { if (!open) return; const outside = (event: PointerEvent) => { if (!root.current?.contains(event.target as Node) && !menu.current?.contains(event.target as Node)) setOpen(false); }; @@ -91,8 +119,11 @@ export function Combobox({ value, options = [], placeholder, disabled, append, o const openMenu = () => { if (disabled || options.length === 0) return; + setFiltering(true); setOpen(true); - const selected = filtered.indexOf(value); + const query = value.trim().toLocaleLowerCase(); + const matchingOptions = options.filter((option) => !query || option.toLocaleLowerCase().includes(query)); + const selected = matchingOptions.indexOf(value); setActive(selected >= 0 ? selected : 0); }; const choose = (option: string) => { onChange(option); setOpen(false); input.current?.focus(); }; @@ -102,7 +133,7 @@ export function Combobox({ value, options = [], placeholder, disabled, append, o setActive((index) => (index < 0 ? 0 : (index + step + filtered.length) % filtered.length)); }; return
- { if (options.length) openMenu(); }} onBlur={() => window.setTimeout(() => setOpen(false), 100)} onChange={(event) => { onChange(event.target.value); setActive(0); if (options.length) setOpen(true); }} onKeyDown={(event) => { + { if (options.length) openMenu(); }} onBlur={() => { closeTimer.current = window.setTimeout(() => setOpen(false), 100); }} onChange={(event) => { setFiltering(true); onChange(event.target.value); setActive(0); if (options.length) setOpen(true); }} onKeyDown={(event) => { if (event.key === "ArrowDown") { event.preventDefault(); move(1); } if (event.key === "ArrowUp") { event.preventDefault(); move(-1); } if (event.key === "Enter" && open && filtered[active]) { event.preventDefault(); choose(filtered[active]); } @@ -115,5 +146,5 @@ export function Combobox({ value, options = [], placeholder, disabled, append, o
, document.body)}
{append}; -} +});