mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-10-03 18:23:51 +08:00
fix(desktop): show discovered model options
This commit is contained in:
@@ -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}>
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user