fix(desktop): show discovered model options

This commit is contained in:
leokun
2026-09-04 16:17:56 +08:00
parent 3aae326914
commit 242861436d
3 changed files with 50 additions and 12 deletions
@@ -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<boolean>;
}) {
const modelCombobox = useRef<ComboboxHandle>(null);
const setModel = (patch: Partial<ModelInput>) => 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,
</div>
<FormField label="API Key" hint={t("访问模型服务所需的密钥。")}> <SecretTextInput placeholder="sk-xxxxxx" autoComplete="off" value={draft.model.api_key} onChange={(event) => setModel({ api_key: event.target.value })} /></FormField>
<FormField label={t("模型名称")} hint={t("可以直接输入模型标识,也可以读取接口返回的模型列表。")}> <Combobox value={draft.model.model_id} options={combinedOptions} placeholder="gpt-5" append={<Button className={styles.discoverButton} disabled={discovering || !canDiscover} onClick={onDiscover}>{discovering ? t("获取中…") : t("获取模型")}</Button>} onChange={(model_id) => setModel({ model_id, display_name: draft.model.display_name || model_id })} /></FormField>
<FormField label={t("模型名称")} hint={t("可以直接输入模型标识,也可以读取接口返回的模型列表。")}><Combobox ref={modelCombobox} value={draft.model.model_id} options={combinedOptions} placeholder="gpt-5" append={<Button className={styles.discoverButton} disabled={discovering || !canDiscover} onClick={() => void discoverModels()}>{discovering ? t("获取中…") : t("获取模型")}</Button>} onChange={(model_id) => setModel({ model_id, display_name: draft.model.display_name || model_id })} /></FormField>
<FormField label={t("显示名称")} hint={t("仅用于界面展示,不会改变发送给模型服务的模型名称。")}> <TextInput placeholder={t("例如:主力模型")} value={draft.model.display_name} onChange={(event) => setModel({ display_name: event.target.value })} /></FormField>
<FormField className={styles.fullWidth} label={t("备注")} hint={t("显示在 Cursor 模型说明中。")}> <TextInput placeholder={t("请输入模型备注")} value={draft.model.tooltip_data} onChange={(event) => setModel({ tooltip_data: event.target.value })} /></FormField>
@@ -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<boolean> => {
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() {
</ConfirmDialog>
<Modal fullHeight open={draft !== null} title={editing ? t("编辑模型") : t("添加模型")} banner={draft && (editorTesting || editorTestState) ? <CursorModelTestResult state={editorTestState} testing={editorTesting} /> : undefined} busy={cursorBusy || savingAndTesting} onClose={() => { if (editing && editorTesting) void cancelModelTest(editing.model_hash); setDraft(null); setEditing(null); }} onSubmit={() => void save()} submitLabel={t("保存")} secondaryAction={<button type="button" className={controls.secondary} disabled={cursorBusy || savingAndTesting} onClick={() => void (editorTesting && editing ? cancelModelTest(editing.model_hash) : saveAndTest())}>{savingAndTesting ? t("处理中…") : editorTesting ? t("取消测试") : t("保存并测试")}</button>}>
{draft && <>
<CursorModelEditor draft={draft} modelOptions={modelOptions} discovering={discovering} onChange={setDraft} onDiscover={() => void discover()} />
<CursorModelEditor draft={draft} modelOptions={modelOptions} discovering={discovering} onChange={setDraft} onDiscover={discover} />
</>}
</Modal>
<ConfirmDialog open={caCommand !== null} title={t("安装本地 CA")} cancelLabel={t("关闭")} confirmLabel={t("打开终端")} onCancel={() => setCaCommand(null)} onConfirm={openCaTerminal}>
+37 -6
View File
@@ -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<ComboboxHandle, ComboboxProps>(function Combobox({ value, options = [], placeholder, disabled, append, onChange }, ref) {
const root = useRef<HTMLDivElement>(null);
const input = useRef<HTMLInputElement>(null);
const menu = useRef<HTMLDivElement>(null);
const listApi = useRef<VirtualListApi | null>(null);
const closeTimer = useRef<number | null>(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 <div className={styles.comboRow}><div ref={root} className={styles.combo}>
<input ref={input} value={value} placeholder={placeholder} disabled={disabled} role="combobox" aria-haspopup="listbox" aria-controls={open ? menuId : undefined} aria-expanded={open} aria-autocomplete="list" onFocus={() => { 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) => {
<input ref={input} value={value} placeholder={placeholder} disabled={disabled} role="combobox" aria-haspopup="listbox" aria-controls={open ? menuId : undefined} aria-expanded={open} aria-autocomplete="list" onFocus={() => { 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
</VirtualList>
</div>, document.body)}
</div>{append}</div>;
}
});