mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-10-03 18:23:51 +08:00
feat: update desktop settings and server compatibility
This commit is contained in:
@@ -13,6 +13,8 @@ import { SettingsPage } from "./features/settings/SettingsPage";
|
||||
import { useAppStore } from "./shared/store/appStore";
|
||||
import { updateStore } from "./shared/store/updateStore";
|
||||
|
||||
const AUTO_UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1_000;
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<>
|
||||
@@ -39,6 +41,7 @@ export function App() {
|
||||
function AppMessages() {
|
||||
const { error } = useAppStore();
|
||||
const previousError = useRef<string | null>(null);
|
||||
const lastAutomaticUpdateCheckAt = useRef(0);
|
||||
const showMessage = useMessage();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -47,12 +50,36 @@ function AppMessages() {
|
||||
}, [error, showMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
void updateStore.check().then((version) => {
|
||||
if (!version) return;
|
||||
showMessage(t("发现新版本 {version},可在设置中安装", { version }), { duration: 6_000 });
|
||||
}).catch(() => {
|
||||
// Startup checks are best-effort; manual checks in Settings report errors.
|
||||
});
|
||||
let disposed = false;
|
||||
const checkAutomatically = () => {
|
||||
const now = Date.now();
|
||||
if (now - lastAutomaticUpdateCheckAt.current < AUTO_UPDATE_CHECK_INTERVAL_MS) return;
|
||||
lastAutomaticUpdateCheckAt.current = now;
|
||||
const previousVersion = updateStore.getSnapshot().availableVersion;
|
||||
void updateStore.check().then((version) => {
|
||||
if (disposed || !version || version === previousVersion) return;
|
||||
showMessage(t("发现新版本 {version},可在设置中安装", { version }), { duration: 6_000 });
|
||||
}).catch(() => {
|
||||
if (!disposed) lastAutomaticUpdateCheckAt.current = 0;
|
||||
// Automatic checks are best-effort; manual checks in Settings report errors.
|
||||
});
|
||||
};
|
||||
const checkWhenVisible = () => {
|
||||
if (document.visibilityState === "visible") checkAutomatically();
|
||||
};
|
||||
|
||||
checkAutomatically();
|
||||
window.addEventListener("focus", checkAutomatically);
|
||||
window.addEventListener("online", checkAutomatically);
|
||||
document.addEventListener("visibilitychange", checkWhenVisible);
|
||||
const timer = window.setInterval(checkAutomatically, AUTO_UPDATE_CHECK_INTERVAL_MS);
|
||||
return () => {
|
||||
disposed = true;
|
||||
window.removeEventListener("focus", checkAutomatically);
|
||||
window.removeEventListener("online", checkAutomatically);
|
||||
document.removeEventListener("visibilitychange", checkWhenVisible);
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [showMessage]);
|
||||
|
||||
return <MessageProvider />;
|
||||
|
||||
@@ -73,12 +73,13 @@ const calls: LlmCall[] = Array.from({ length: 24 }, (_, index) => {
|
||||
};
|
||||
});
|
||||
|
||||
const harnessStatus: CursorHarnessStatus = {
|
||||
let harnessStatus: CursorHarnessStatus = {
|
||||
platform: "macos",
|
||||
ca: "ready",
|
||||
configured_models: models.length,
|
||||
enabled_models: models.length,
|
||||
integration: "enabled",
|
||||
settings_applied: true,
|
||||
proxy_url: "http://127.0.0.1:54321",
|
||||
ca_install_command: null,
|
||||
};
|
||||
@@ -126,7 +127,17 @@ export function installDemoApi() {
|
||||
if (path === "/llm-calls") return json(calls);
|
||||
if (path.startsWith("/llm-calls/")) return json(createCallDetail(path.slice("/llm-calls/".length)));
|
||||
if (path === "/harness/cursor/status") return json(harnessStatus);
|
||||
if (path === "/harness/cursor/ca/initialize" || path === "/harness/cursor/enabled") return json(harnessStatus);
|
||||
if (path === "/harness/cursor/ca/initialize") return json(harnessStatus);
|
||||
if (path === "/harness/cursor/enabled") {
|
||||
const enabled = Boolean((body as { enabled?: unknown } | null)?.enabled);
|
||||
harnessStatus = {
|
||||
...harnessStatus,
|
||||
integration: enabled ? "enabled" : "disabled",
|
||||
settings_applied: enabled,
|
||||
proxy_url: enabled ? "http://127.0.0.1:54321" : null,
|
||||
};
|
||||
return json(harnessStatus);
|
||||
}
|
||||
if (path === "/settings/observability" && method === "GET") return json({ detailed });
|
||||
if (path === "/settings/observability") {
|
||||
detailed = Boolean((body as { detailed?: unknown } | null)?.detailed);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, configuredPluginModels, type Overview } from "../../shared/api";
|
||||
import { api, pluginText, type Overview } from "../../shared/api";
|
||||
import { ContributionCalendarChart } from "./charts/ContributionCalendarChart";
|
||||
import { DailyTokenUsageChart } from "./charts/DailyTokenUsageChart";
|
||||
import { HomeMetrics } from "./metrics/HomeMetrics";
|
||||
@@ -9,7 +9,9 @@ import { OverviewTimeRangeFilter, type OverviewRangePreset } from "./overview/Ov
|
||||
import { PageActions } from "../../shell/PageActions";
|
||||
import { appStore, useAppStore } from "../../shared/store/appStore";
|
||||
import { formatTimeInput, parseTimeInput } from "../../shared/utils/parseTimeInput";
|
||||
import { modelProviderName } from "../../shared/utils/modelProvider";
|
||||
import { claudeIcon, flatColorOrganizationIcon, openAiIcon } from "../../shared/ui/icons";
|
||||
import { useI18n } from "../../i18n/store";
|
||||
|
||||
type TimeRange = { startMs: number; endMs: number };
|
||||
|
||||
@@ -31,6 +33,7 @@ function presetRange(preset: Exclude<OverviewRangePreset, "custom">, now = new D
|
||||
|
||||
export function HomePage() {
|
||||
const { overview, busy, models, plugins } = useAppStore();
|
||||
const { locale } = useI18n();
|
||||
const [preset, setPreset] = useState<OverviewRangePreset>("month");
|
||||
const [customRange, setCustomRange] = useState<TimeRange | null>(null);
|
||||
const [customOpen, setCustomOpen] = useState(false);
|
||||
@@ -105,13 +108,18 @@ export function HomePage() {
|
||||
...models.map((model) => ({
|
||||
value: model.model_hash,
|
||||
label: model.display_name,
|
||||
group: modelProviderName(model),
|
||||
icon: iconFor(model.type),
|
||||
})),
|
||||
...configuredPluginModels(plugins).map((model) => ({
|
||||
value: model.id,
|
||||
label: model.displayName,
|
||||
icon: flatColorOrganizationIcon,
|
||||
})),
|
||||
...plugins.flatMap((plugin) => plugin.providers.flatMap((provider) =>
|
||||
provider.configured ? provider.models.map((model) => ({
|
||||
value: model.id,
|
||||
label: model.displayName,
|
||||
group: pluginText(provider.displayName, locale) || model.pluginName,
|
||||
iconSrc: model.icon || undefined,
|
||||
icon: model.icon ? undefined : flatColorOrganizationIcon,
|
||||
})) : [],
|
||||
)),
|
||||
];
|
||||
const sections: VirtualPageSection[] = [
|
||||
{
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createPortal } from "react-dom";
|
||||
import { parseTimeInput } from "../../../shared/utils/parseTimeInput";
|
||||
import controls from "../../../shared/ui/Controls.module.scss";
|
||||
import { Icon } from "../../../shared/ui/Icon";
|
||||
import { MultiSelect, type MultiSelectOption } from "../../../shared/ui/MultiSelect";
|
||||
import { ModelSelect, type ModelSelectOption } from "../../../shared/ui/ModelSelect";
|
||||
import { TooltipTrigger } from "../../../shared/ui/TooltipTrigger";
|
||||
import { refreshIcon } from "../../../shared/ui/icons";
|
||||
import styles from "./OverviewTimeRangeFilter.module.scss";
|
||||
@@ -16,7 +16,7 @@ export function OverviewTimeRangeFilter({ value, customOpen, customStart, custom
|
||||
customOpen: boolean;
|
||||
customStart: string;
|
||||
customEnd: string;
|
||||
modelOptions: MultiSelectOption[];
|
||||
modelOptions: ModelSelectOption[];
|
||||
selectedModels: string[];
|
||||
busy: boolean;
|
||||
onSelect: (value: Exclude<OverviewRangePreset, "custom">) => void;
|
||||
@@ -105,7 +105,7 @@ export function OverviewTimeRangeFilter({ value, customOpen, customStart, custom
|
||||
>
|
||||
<label><span>{t("开始时间")}</span><input type="text" placeholder={t("如:2026-08-23 09:00、1小时前")} value={customStart} onChange={(event) => onCustomStartChange(event.target.value)} /></label>
|
||||
<label><span>{t("结束时间")}</span><input type="text" placeholder={t("如:现在、2026-08-23 18:00")} value={customEnd} onChange={(event) => onCustomEndChange(event.target.value)} /></label>
|
||||
<div className={styles.filterRow}><MultiSelect label={t("模型")} value={selectedModels} options={modelOptions} onChange={onSelectedModelsChange} /></div>
|
||||
<div className={styles.filterRow}><ModelSelect mode="multiple" label={t("模型")} value={selectedModels} options={modelOptions} onChange={onSelectedModelsChange} /></div>
|
||||
<div className={styles.popoverActions}>
|
||||
<button type="button" className={controls.secondary} onClick={() => onCustomOpenChange(false)}>{t("取消")}</button>
|
||||
<button type="button" className={controls.primary} disabled={!customValid} onClick={onCustomApply}>{t("应用")}</button>
|
||||
|
||||
@@ -4,7 +4,7 @@ import Sortable from "sortablejs";
|
||||
import type { Model, PluginModelDescriptor } from "../../shared/api";
|
||||
import { Card } from "../../shared/ui/Card";
|
||||
import { Icon } from "../../shared/ui/Icon";
|
||||
import { chevronDownIcon, chevronRightIcon, claudeIcon, dragIcon, flatColorOrganizationIcon, openAiIcon } from "../../shared/ui/icons";
|
||||
import { chevronDownIcon, chevronRightIcon, claudeIcon, dragIcon, editIcon, flatColorOrganizationIcon, openAiIcon } from "../../shared/ui/icons";
|
||||
import { TruncatedButton } from "../../shared/ui/TruncatedButton";
|
||||
import { CursorModelTestResult, type CursorModelTestState } from "./CursorModelTestResult";
|
||||
import styles from "./CursorSettings.module.scss";
|
||||
@@ -61,6 +61,7 @@ export function CursorModelCards(props: CursorModelCardsProps) {
|
||||
key={group.key}
|
||||
label={group.label}
|
||||
icon={group.icon}
|
||||
defaultOpen={false}
|
||||
onSettings={props.grouping === "provider" ? () => props.onGroupSettings(group) : undefined}
|
||||
>
|
||||
{group.models.map((model) => <ModelListRow
|
||||
@@ -79,9 +80,10 @@ export function CursorModelCards(props: CursorModelCardsProps) {
|
||||
return <div className={styles.modelGroups}>
|
||||
{builtins}
|
||||
{pluginGroups(props.pluginModels).map((group) => <CollapsibleGroup
|
||||
key={group.pluginId}
|
||||
key={`${props.grouping}:${group.pluginId}`}
|
||||
label={group.pluginName}
|
||||
iconSrc={group.icon}
|
||||
defaultOpen={props.grouping === "flat"}
|
||||
>
|
||||
{group.models.map((model) => <PluginModelRow
|
||||
key={model.id}
|
||||
@@ -109,14 +111,15 @@ function pluginGroups(models: PluginModelDescriptor[]) {
|
||||
return groups;
|
||||
}
|
||||
|
||||
function CollapsibleGroup({ label, icon, iconSrc, onSettings, children }: {
|
||||
function CollapsibleGroup({ label, icon, iconSrc, defaultOpen = true, onSettings, children }: {
|
||||
label: string;
|
||||
icon?: IconifyIcon;
|
||||
iconSrc?: string;
|
||||
defaultOpen?: boolean;
|
||||
onSettings?: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
return <Card className={styles.groupCard}>
|
||||
<div className={styles.groupHeader}>
|
||||
<button
|
||||
@@ -129,7 +132,10 @@ function CollapsibleGroup({ label, icon, iconSrc, onSettings, children }: {
|
||||
{iconSrc && <Icon src={iconSrc} size="1.1em" />}
|
||||
<span className={styles.groupLabel}>{label}</span>
|
||||
</button>
|
||||
{onSettings && <button type="button" className={styles.groupSettings} onClick={onSettings}>{t("分组设置")}</button>}
|
||||
{onSettings && <button type="button" className={styles.groupSettings} onClick={onSettings}>
|
||||
<Icon icon={editIcon} size="1em" />
|
||||
{t("分组设置")}
|
||||
</button>}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.groupChevron}
|
||||
|
||||
@@ -5,6 +5,20 @@
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.takeoverActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.takeoverStatus {
|
||||
min-width: 39px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.groupActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -79,6 +93,9 @@
|
||||
gap: 8px;
|
||||
}
|
||||
.groupSettings {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 2px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
background: none;
|
||||
|
||||
@@ -13,6 +13,7 @@ import { FormField, SecretTextInput, TextInput } from "../../shared/ui/FormContr
|
||||
import controls from "../../shared/ui/Controls.module.scss";
|
||||
import { Icon } from "../../shared/ui/Icon";
|
||||
import { Modal } from "../../shared/ui/Modal";
|
||||
import { Switch } from "../../shared/ui/Switch";
|
||||
import { TooltipTrigger } from "../../shared/ui/TooltipTrigger";
|
||||
import { addIcon } from "../../shared/ui/icons";
|
||||
import { useMessage } from "../../shared/ui/message";
|
||||
@@ -30,6 +31,7 @@ export function CursorSettingsPage() {
|
||||
const [caCommand, setCaCommand] = useState<string | null>(null);
|
||||
const [waitingForCaRefresh, setWaitingForCaRefresh] = useState(false);
|
||||
const [deleting, setDeleting] = useState<Model | null>(null);
|
||||
const [confirmDisableTakeover, setConfirmDisableTakeover] = useState(false);
|
||||
const [testingModelHashes, setTestingModelHashes] = useState<Set<string>>(() => new Set());
|
||||
const [modelTestResults, setModelTestResults] = useState<Map<string, CursorModelTestState>>(() => new Map());
|
||||
const [savingAndTesting, setSavingAndTesting] = useState(false);
|
||||
@@ -42,6 +44,8 @@ export function CursorSettingsPage() {
|
||||
const [groupSettingsBusy, setGroupSettingsBusy] = useState(false);
|
||||
const activeModelTests = useRef(new Map<string, { testId: string; controller: AbortController; cancelling: boolean }>());
|
||||
const caReady = cursorHarness?.ca === "ready";
|
||||
const cursorTakenOver = cursorHarness?.settings_applied ?? false;
|
||||
const takeoverLabel = cursorTakenOver ? t("关闭接管Cursor") : t("开启接管Cursor");
|
||||
const pluginModels = configuredPluginModels(plugins);
|
||||
const testTargets = [
|
||||
...models.map((model) => ({ model_hash: model.model_hash, display_name: model.display_name })),
|
||||
@@ -123,11 +127,10 @@ export function CursorSettingsPage() {
|
||||
const active = activeModelTests.current.get(modelHash);
|
||||
if (!active || active.cancelling) return;
|
||||
active.cancelling = true;
|
||||
active.controller.abort();
|
||||
try {
|
||||
await api.cancelModelTest(modelHash, active.testId);
|
||||
active.controller.abort();
|
||||
} catch (cause) {
|
||||
active.cancelling = false;
|
||||
message(t("取消测试失败:{error}", { error: errorText(cause) }), { duration: 5000 });
|
||||
}
|
||||
};
|
||||
@@ -296,16 +299,43 @@ export function CursorSettingsPage() {
|
||||
: Math.max(380, activeGroups.reduce((height, group) => height + 60 + group.models.length * 56, 0) + Math.max(0, activeGroups.length - 1) * 20 + pluginSectionHeight);
|
||||
|
||||
return <>
|
||||
{testTargets.length > 0 && <PageActions position="left">
|
||||
<div className={styles.groupActions} role="group" aria-label={t("操作")}>
|
||||
<button type="button" aria-pressed={grouping === "flat"} onClick={() => setGrouping("flat")}>{t("默认平铺")}</button>
|
||||
{canGroupByProvider && <button type="button" aria-pressed={grouping === "provider"} onClick={() => setGrouping("provider")}>{t("按供应商")}</button>}
|
||||
{canGroupByType && <button type="button" aria-pressed={grouping === "type"} onClick={() => setGrouping("type")}>{t("按类型")}</button>}
|
||||
<button type="button" disabled={cursorBusy || (!batchTesting && testingModelHashes.size > 0)} onClick={() => void (batchTesting ? cancelAllModelTests() : testAllModels())}>{batchTesting ? t("取消全部测试") : t("一键测试")}</button>
|
||||
<PageActions position="left">
|
||||
<div className={styles.takeoverActions}>
|
||||
<span className={styles.takeoverStatus}>{cursorTakenOver ? t("已接管") : t("未接管")}</span>
|
||||
<TooltipTrigger label={takeoverLabel}>
|
||||
<Switch
|
||||
checked={cursorTakenOver}
|
||||
disabled={cursorBusy || (!cursorTakenOver && !caReady)}
|
||||
label={takeoverLabel}
|
||||
onChange={(enabled) => {
|
||||
if (enabled) void appStore.setCursorEnabled(true);
|
||||
else setConfirmDisableTakeover(true);
|
||||
}}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
{testTargets.length > 0 && <div className={styles.groupActions} role="group" aria-label={t("操作")}>
|
||||
<button type="button" aria-pressed={grouping === "flat"} onClick={() => setGrouping("flat")}>{t("默认平铺")}</button>
|
||||
{canGroupByProvider && <button type="button" aria-pressed={grouping === "provider"} onClick={() => setGrouping("provider")}>{t("按供应商")}</button>}
|
||||
{canGroupByType && <button type="button" aria-pressed={grouping === "type"} onClick={() => setGrouping("type")}>{t("按类型")}</button>}
|
||||
<button type="button" disabled={cursorBusy || (!batchTesting && testingModelHashes.size > 0)} onClick={() => void (batchTesting ? cancelAllModelTests() : testAllModels())}>{batchTesting ? t("取消全部测试") : t("一键测试")}</button>
|
||||
</div>}
|
||||
</div>
|
||||
</PageActions>}
|
||||
</PageActions>
|
||||
<PageActions><TooltipTrigger label={caReady ? t("添加模型") : t("请先初始化 CA")}><button className={controls.iconButton} aria-label={t("添加模型")} disabled={!caReady || cursorBusy} onClick={openNew}><Icon icon={addIcon} size="1.1em" /></button></TooltipTrigger></PageActions>
|
||||
<PageContent title="Cursor" sections={[{ key: "cursor-settings", estimatedHeight: estimatedModelHeight, content }]} />
|
||||
<ConfirmDialog
|
||||
open={confirmDisableTakeover}
|
||||
title={t("关闭接管Cursor?")}
|
||||
cancelLabel={t("取消")}
|
||||
confirmLabel={t("关闭接管")}
|
||||
onCancel={() => setConfirmDisableTakeover(false)}
|
||||
onConfirm={() => {
|
||||
setConfirmDisableTakeover(false);
|
||||
void appStore.setCursorEnabled(false);
|
||||
}}
|
||||
>
|
||||
<p>{t("关闭后将移除 Cursor 本地代理配置。如果你需要登陆官方账号,通常不需要关闭操作,推荐直接登陆你的账号即可(byok模型与官方账号的模型已支持无缝衔接),是否继续关闭并清理代理?")}</p>
|
||||
</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()} />
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.row {
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 16px;
|
||||
|
||||
> div:first-child {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
small {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
@@ -18,8 +18,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
.details {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.select {
|
||||
min-width: 280px;
|
||||
width: min(320px, 50%);
|
||||
}
|
||||
|
||||
.value {
|
||||
min-width: 0;
|
||||
max-width: 70%;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
overflow-wrap: anywhere;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.actionGroup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.textButton {
|
||||
@@ -51,3 +70,7 @@
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.select { width: 68%; }
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { api, type CommitSettingsView } from "../../shared/api";
|
||||
import { api, pluginText, type CommitSettingsView } from "../../shared/api";
|
||||
import { useI18n } from "../../i18n/store";
|
||||
import { useAppStore } from "../../shared/store/appStore";
|
||||
import { Button } from "../../shared/ui/Button";
|
||||
import { Modal } from "../../shared/ui/Modal";
|
||||
import { Select } from "../../shared/ui/Select";
|
||||
import { ModelSelect, type ModelSelectOption } from "../../shared/ui/ModelSelect";
|
||||
import { claudeIcon, flatColorOrganizationIcon, openAiIcon } from "../../shared/ui/icons";
|
||||
import { TitledCard } from "../../shared/ui/TitledCard";
|
||||
import { useMessage } from "../../shared/ui/message";
|
||||
import controls from "../../shared/ui/Controls.module.scss";
|
||||
import { modelProviderName } from "../../shared/utils/modelProvider";
|
||||
import styles from "./CommitSettingsCard.module.scss";
|
||||
|
||||
function errorText(cause: unknown) {
|
||||
@@ -13,20 +17,44 @@ function errorText(cause: unknown) {
|
||||
}
|
||||
|
||||
export function CommitSettingsCard() {
|
||||
const { models } = useAppStore();
|
||||
const { models, plugins } = useAppStore();
|
||||
const { locale } = useI18n();
|
||||
const message = useMessage();
|
||||
const [view, setView] = useState<CommitSettingsView | null>(null);
|
||||
const [modelDraft, setModelDraft] = useState("");
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [savingModel, setSavingModel] = useState(false);
|
||||
const [promptOpen, setPromptOpen] = useState(false);
|
||||
const [promptDraft, setPromptDraft] = useState("");
|
||||
const [savingPrompt, setSavingPrompt] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void api.commitSettings().then(setView).catch((cause) => message(errorText(cause)));
|
||||
}, [message]);
|
||||
let active = true;
|
||||
void (async () => {
|
||||
try {
|
||||
let loaded = await api.commitSettings(locale);
|
||||
if (!loaded.prompt.trim() && loaded.prompt_locale !== locale) {
|
||||
loaded = await api.setCommitSettings({
|
||||
model_id: loaded.model_id,
|
||||
prompt: "",
|
||||
prompt_locale: locale,
|
||||
});
|
||||
}
|
||||
if (active) {
|
||||
setView(loaded);
|
||||
setModelDraft(loaded.model_id);
|
||||
}
|
||||
} catch (cause) {
|
||||
if (active) message(errorText(cause));
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [locale, message]);
|
||||
|
||||
const modelOptions = useMemo(() => {
|
||||
const options: Array<{ value: string; label: string }> = [{ value: "", label: t("直连") }];
|
||||
const options: ModelSelectOption[] = [{ value: "", label: t("直连"), group: "Cursor" }];
|
||||
const seen = new Set<string>();
|
||||
for (const model of models) {
|
||||
seen.add(model.model_hash);
|
||||
@@ -35,41 +63,73 @@ export function CommitSettingsCard() {
|
||||
label: model.display_name && model.display_name !== model.model_id
|
||||
? `${model.display_name}(${model.model_id})`
|
||||
: model.display_name || model.model_id,
|
||||
group: modelProviderName(model),
|
||||
icon: model.type === "anthropic" ? claudeIcon : openAiIcon,
|
||||
});
|
||||
}
|
||||
for (const plugin of plugins) {
|
||||
for (const provider of plugin.providers) {
|
||||
if (!provider.configured) continue;
|
||||
const group = pluginText(provider.displayName, locale) || plugin.name;
|
||||
for (const model of provider.models) {
|
||||
seen.add(model.id);
|
||||
options.push({
|
||||
value: model.id,
|
||||
label: model.displayName,
|
||||
group,
|
||||
iconSrc: model.icon || undefined,
|
||||
icon: model.icon ? undefined : flatColorOrganizationIcon,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (view?.model_id && !seen.has(view.model_id)) {
|
||||
options.push({ value: view.model_id, label: view.model_id });
|
||||
options.push({ value: view.model_id, label: view.model_id, group: "Cursor" });
|
||||
}
|
||||
return options;
|
||||
}, [models, view]);
|
||||
|
||||
const selectedModelId = view?.model_id ?? "";
|
||||
}, [locale, models, plugins, view]);
|
||||
|
||||
const persist = useCallback(
|
||||
async (modelId: string, prompt: string) => {
|
||||
if (!view) return null;
|
||||
const normalizedPrompt =
|
||||
prompt.trim() === view.default_prompt.trim() ? "" : prompt.trim();
|
||||
return api.setCommitSettings({ model_id: modelId, prompt: normalizedPrompt });
|
||||
return api.setCommitSettings({
|
||||
model_id: modelId,
|
||||
prompt: normalizedPrompt,
|
||||
prompt_locale: locale,
|
||||
});
|
||||
},
|
||||
[view],
|
||||
[view, locale],
|
||||
);
|
||||
|
||||
const changeModel = useCallback(
|
||||
async (modelId: string) => {
|
||||
if (!view) return;
|
||||
setSavingModel(true);
|
||||
try {
|
||||
const saved = await persist(modelId, view.prompt);
|
||||
if (saved) setView(saved);
|
||||
} catch (cause) {
|
||||
message(errorText(cause));
|
||||
} finally {
|
||||
setSavingModel(false);
|
||||
const editModel = useCallback(() => {
|
||||
if (!view) return;
|
||||
setModelDraft(view.model_id);
|
||||
setEditing(true);
|
||||
}, [view]);
|
||||
|
||||
const cancelModelEdit = useCallback(() => {
|
||||
setModelDraft(view?.model_id ?? "");
|
||||
setEditing(false);
|
||||
}, [view]);
|
||||
|
||||
const saveModel = useCallback(async () => {
|
||||
if (!view) return;
|
||||
setSavingModel(true);
|
||||
try {
|
||||
const saved = await persist(modelDraft, view.prompt);
|
||||
if (saved) {
|
||||
setView(saved);
|
||||
setModelDraft(saved.model_id);
|
||||
setEditing(false);
|
||||
}
|
||||
},
|
||||
[view, persist, message],
|
||||
);
|
||||
} catch (cause) {
|
||||
message(errorText(cause));
|
||||
} finally {
|
||||
setSavingModel(false);
|
||||
}
|
||||
}, [modelDraft, view, persist, message]);
|
||||
|
||||
const openPrompt = useCallback(() => {
|
||||
if (!view) return;
|
||||
@@ -97,29 +157,45 @@ export function CommitSettingsCard() {
|
||||
setPromptDraft(view.default_prompt);
|
||||
}, [view]);
|
||||
|
||||
const selectedModelLabel = modelOptions.find((option) => option.value === view?.model_id)?.label
|
||||
?? view?.model_id
|
||||
?? t("加载中…");
|
||||
const action = editing ? (
|
||||
<div className={styles.actionGroup}>
|
||||
<Button size="small" disabled={savingModel} onClick={cancelModelEdit}>{t("取消")}</Button>
|
||||
<Button variant="primary" size="small" disabled={savingModel} onClick={() => void saveModel()}>
|
||||
{savingModel ? t("保存中…") : t("保存")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.actionGroup}>
|
||||
<button type="button" className={styles.textButton} disabled={!view} onClick={openPrompt}>
|
||||
{t("提示词设置")}
|
||||
</button>
|
||||
<button type="button" className={styles.textButton} disabled={!view} onClick={editModel}>
|
||||
{t("编辑")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TitledCard
|
||||
title={t("Commit 设置")}
|
||||
action={
|
||||
<button type="button" className={styles.textButton} disabled={!view} onClick={openPrompt}>
|
||||
{t("提示词设置")}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className={styles.row}>
|
||||
<div>
|
||||
<strong>{t("生成模型")}</strong>
|
||||
<small>{t("直连走 Cursor 原有通道;选择 Cursor 页已配置的模型后由本地生成。")}</small>
|
||||
</div>
|
||||
<div className={styles.select}>
|
||||
<Select
|
||||
value={selectedModelId}
|
||||
options={modelOptions}
|
||||
disabled={!view || savingModel}
|
||||
ariaLabel={t("生成模型")}
|
||||
onChange={(value) => void changeModel(value)}
|
||||
/>
|
||||
<TitledCard title={t("Commit 提交代码模型设置")} action={action}>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.row}>
|
||||
<div className={styles.details}>
|
||||
<strong>{t("生成模型")}</strong>
|
||||
</div>
|
||||
{editing ? <div className={styles.select}>
|
||||
<ModelSelect
|
||||
mode="single"
|
||||
value={modelDraft}
|
||||
options={modelOptions}
|
||||
disabled={savingModel}
|
||||
label={t("生成模型")}
|
||||
onChange={setModelDraft}
|
||||
/>
|
||||
</div> : <span className={styles.value}>{selectedModelLabel}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</TitledCard>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
}
|
||||
|
||||
.control {
|
||||
width: min(420px, 62%);
|
||||
width: min(320px, 50%);
|
||||
}
|
||||
|
||||
.value {
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
}
|
||||
|
||||
.control {
|
||||
width: min(420px, 62%);
|
||||
width: min(320px, 50%);
|
||||
}
|
||||
|
||||
.value {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -30,7 +30,6 @@
|
||||
"105a9082c346f958": "Testing…",
|
||||
"124be3f86f197802": "Token usage",
|
||||
"12ae77e6202d063e": "Custom Headers",
|
||||
"12b86e89701b0241": "Enter a model name and press Enter to add it",
|
||||
"133340e53175128a": "Test all",
|
||||
"13a9ac7a68c5fd96": "The CA is stored only on this device and is used to securely inspect Cursor HTTPS requests.",
|
||||
"13b61c5f697b6700": "Cache hit rate",
|
||||
@@ -64,11 +63,13 @@
|
||||
"24a0a24864454575": "Existing, skipped",
|
||||
"2555d6c7fbb7e070": "Enter a model ID directly or load models returned by the API.",
|
||||
"29585d7193539200": "Current version {version}",
|
||||
"296da37506328d89": "Disable Cursor takeover",
|
||||
"29fbbef32a6eb58b": "Do not show this ad again",
|
||||
"2a2773134a829016": "Aggregated from historical LLM calls; in-progress calls are excluded.",
|
||||
"2caeaec539e78898": "Thinking budget tokens",
|
||||
"2cbc58108d78b06c": "Waiting for browser authorization…",
|
||||
"2cd0f3be8738a86c": "Cancel",
|
||||
"2cdf7d3275016cc2": "Disable Cursor takeover?",
|
||||
"2d30c2a98ebb5278": "Current: {rate}",
|
||||
"2eb2bf7c6597ab9a": "Detailed records",
|
||||
"2f280a8120c0db12": "Used only for display and does not change the model name sent to the model service.",
|
||||
@@ -77,7 +78,6 @@
|
||||
"2f5f1d6fbfb061ed": "Not set",
|
||||
"2f6416a2c424856b": "Final request URL",
|
||||
"2f7ba5fd1d12f7f9": "Open the tutorial?",
|
||||
"2f7dec3be28d7597": "{count} selected",
|
||||
"2f9daa828907b93f": "Delete",
|
||||
"2fe5a8d0eee9f14c": "Invalid",
|
||||
"303c30f301514250": "Search resources",
|
||||
@@ -91,7 +91,6 @@
|
||||
"382f2e3419a02fef": "Only clear detailed records",
|
||||
"38844b135cf70dfc": "More",
|
||||
"393e1241552b1870": "Request",
|
||||
"398f8e6c6f0a0b97": "Continue selecting or typing",
|
||||
"39f52eee100131d7": "Cached input",
|
||||
"3a0fb74abe2460b6": "Chunks",
|
||||
"3a3f595df70ec8ff": "Clear storage",
|
||||
@@ -134,11 +133,13 @@
|
||||
"51d04bc3d286f018": "Last calendar day",
|
||||
"51de3bcec137ab1b": "Connectivity tests succeeded for all {count} models",
|
||||
"5228358a6db59fe7": "For example: now, 2026-08-23 18:00",
|
||||
"5319e7374f78fb38": "Taken over",
|
||||
"5401344227e49e2f": "TAB settings",
|
||||
"54644705e9c61009": "Port settings",
|
||||
"54c53e5fe791d1f3": "Initialize CA",
|
||||
"54e6745ff43c9c74": "Unable to save model order",
|
||||
"550eddc3c7fefa99": "Sponsored",
|
||||
"552a5d4baf45d878": "Commit Message Model Settings",
|
||||
"56432ba297009bdc": "Initialize the CA first",
|
||||
"56627c94a9decee6": "Maximum output tokens",
|
||||
"576d81bb0631b165": "Import",
|
||||
@@ -225,11 +226,13 @@
|
||||
"91af6e57e7453fbe": "Add account",
|
||||
"92156a483d4ba248": "Only request, response, and trace attachments are deleted; call summaries, metrics, and configuration are kept.",
|
||||
"92e26b27d5ea8f0e": "Failed to check for updates: {error}",
|
||||
"9305c0e13642cab4": "Not taken over",
|
||||
"940a168911ade998": "Items per page",
|
||||
"945fb1c67eca8493": "Installing the plugin runtime",
|
||||
"946b3ffc02f026c0": "Delete this model?",
|
||||
"954ec984cd4f49d1": "Syncing…",
|
||||
"966498853d801a52": "TAB connection",
|
||||
"96f7642963ca0dbf": "Enable Cursor takeover",
|
||||
"9850ed41a5bfbb0c": "{count} selected",
|
||||
"997ec8201c2adeda": "Open terminal to install CA",
|
||||
"9b1b7ed518ee401d": "This will open the tutorial in your system browser. Continue?",
|
||||
@@ -293,7 +296,6 @@
|
||||
"ba2e93e73037c71e": "Restore default",
|
||||
"ba5865fbc734e672": "For example: Primary model",
|
||||
"ba6403d22876d626": "Cooling down",
|
||||
"bac36835ace45f3a": "Commit settings",
|
||||
"baff6c144180b185": "Connectivity tests completed: {successful} succeeded, {failed} failed",
|
||||
"bb2b7736433ae867": "Cursor tracing",
|
||||
"bb7efdcb6af6e805": "Default dark",
|
||||
@@ -331,6 +333,7 @@
|
||||
"d59e47070f7f358e": "Callable",
|
||||
"d60669bb26a22f5d": "Leave blank to use the default",
|
||||
"d6b1f203680f5496": "Leave blank to use adaptive thinking",
|
||||
"d71b0171c44b668a": "Disabling this will remove Cursor's local proxy configuration. If you need to sign in to an official account, you usually do not need to disable it. You can sign in directly because BYOK models and official-account models now work together seamlessly. Do you want to continue disabling and clearing the proxy configuration?",
|
||||
"d766536c18e8e990": "Plugin runtime {version} is installed and ready to use.",
|
||||
"d7e266bdc8064193": "Group name",
|
||||
"d8c47e9776cf1082": "Main menu",
|
||||
@@ -393,13 +396,13 @@
|
||||
"f9615d05d8e18595": "Clear {label} filter",
|
||||
"f9aa11dbb15ce647": "Saturday",
|
||||
"f9b55ca75425161b": "Response content was not recorded. Enable detailed records and try again.",
|
||||
"fa2352729a82e74b": "Direct uses Cursor's original channel; pick a model configured on the Cursor page to generate locally.",
|
||||
"fa5b4b8a751c7d1b": "The local proxy port used by Cursor. Enter 0 to select a random port at startup.",
|
||||
"fad86bf65f72c747": "Download progress",
|
||||
"fb11aa6f29827095": "Checking…",
|
||||
"fbe8778fa8b9bab5": "Initialize the local CA first",
|
||||
"fc22d1ab9ac73c6f": "Checking the plugin runtime",
|
||||
"fc3947ebe6b2177b": "Default {defaultRate} / include creation {reuseRate}",
|
||||
"fc50d0b72bc871db": "Disable takeover",
|
||||
"fcd311fd8ad42462": "Open model list",
|
||||
"fd415f8e0097c832": "Cache reads and writes are included in prompt-side statistics.",
|
||||
"fd77192739703811": "Bulk import",
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
"105a9082c346f958": "测试中…",
|
||||
"124be3f86f197802": "Token 消耗",
|
||||
"12ae77e6202d063e": "自定义 Headers",
|
||||
"12b86e89701b0241": "输入模型名称后按 Enter 添加",
|
||||
"133340e53175128a": "一键测试",
|
||||
"13a9ac7a68c5fd96": "CA 仅保存在本机,用于安全解析 Cursor 的 HTTPS 请求。",
|
||||
"13b61c5f697b6700": "缓存命中率",
|
||||
@@ -64,11 +63,13 @@
|
||||
"24a0a24864454575": "已存在,跳过",
|
||||
"2555d6c7fbb7e070": "可以直接输入模型标识,也可以读取接口返回的模型列表。",
|
||||
"29585d7193539200": "当前版本 {version}",
|
||||
"296da37506328d89": "关闭接管Cursor",
|
||||
"29fbbef32a6eb58b": "不再显示此广告",
|
||||
"2a2773134a829016": "按历史 LLM 调用记录汇总,进行中的调用不计入。",
|
||||
"2caeaec539e78898": "思考预算 Token",
|
||||
"2cbc58108d78b06c": "等待网页端确认授权中…",
|
||||
"2cd0f3be8738a86c": "取消",
|
||||
"2cdf7d3275016cc2": "关闭接管Cursor?",
|
||||
"2d30c2a98ebb5278": "当前:{rate}",
|
||||
"2eb2bf7c6597ab9a": "详细记录",
|
||||
"2f280a8120c0db12": "仅用于界面展示,不会改变发送给模型服务的模型名称。",
|
||||
@@ -77,7 +78,6 @@
|
||||
"2f5f1d6fbfb061ed": "未设置",
|
||||
"2f6416a2c424856b": "最终请求地址",
|
||||
"2f7ba5fd1d12f7f9": "打开使用教程?",
|
||||
"2f7dec3be28d7597": "已选择 {count} 个",
|
||||
"2f9daa828907b93f": "删除",
|
||||
"2fe5a8d0eee9f14c": "已失效",
|
||||
"303c30f301514250": "搜索资源",
|
||||
@@ -91,7 +91,6 @@
|
||||
"382f2e3419a02fef": "仅清理详细记录",
|
||||
"38844b135cf70dfc": "更多",
|
||||
"393e1241552b1870": "请求",
|
||||
"398f8e6c6f0a0b97": "继续选择或输入",
|
||||
"39f52eee100131d7": "缓存输入",
|
||||
"3a0fb74abe2460b6": "分块数",
|
||||
"3a3f595df70ec8ff": "清理存储空间",
|
||||
@@ -134,11 +133,13 @@
|
||||
"51d04bc3d286f018": "近1自然日",
|
||||
"51de3bcec137ab1b": "全部 {count} 个模型连通性测试成功",
|
||||
"5228358a6db59fe7": "如:现在、2026-08-23 18:00",
|
||||
"5319e7374f78fb38": "已接管",
|
||||
"5401344227e49e2f": "TAB 设置",
|
||||
"54644705e9c61009": "端口设置",
|
||||
"54c53e5fe791d1f3": "初始化 CA",
|
||||
"54e6745ff43c9c74": "排序失败",
|
||||
"550eddc3c7fefa99": "推广",
|
||||
"552a5d4baf45d878": "Commit 提交代码模型设置",
|
||||
"56432ba297009bdc": "请先初始化 CA",
|
||||
"56627c94a9decee6": "最大输出 Token",
|
||||
"576d81bb0631b165": "导入",
|
||||
@@ -225,11 +226,13 @@
|
||||
"91af6e57e7453fbe": "添加账号",
|
||||
"92156a483d4ba248": "仅删除请求、响应和追踪附件等详细内容,保留调用汇总、统计指标和配置。",
|
||||
"92e26b27d5ea8f0e": "检查更新失败:{error}",
|
||||
"9305c0e13642cab4": "未接管",
|
||||
"940a168911ade998": "每页条数",
|
||||
"945fb1c67eca8493": "正在安装插件运行时",
|
||||
"946b3ffc02f026c0": "确定删除这个模型吗?",
|
||||
"954ec984cd4f49d1": "正在同步…",
|
||||
"966498853d801a52": "TAB 选择",
|
||||
"96f7642963ca0dbf": "开启接管Cursor",
|
||||
"9850ed41a5bfbb0c": "已选 {count} 项",
|
||||
"997ec8201c2adeda": "打开终端安装 CA",
|
||||
"9b1b7ed518ee401d": "将在系统浏览器中打开使用教程,是否继续?",
|
||||
@@ -293,7 +296,6 @@
|
||||
"ba2e93e73037c71e": "恢复默认",
|
||||
"ba5865fbc734e672": "例如:主力模型",
|
||||
"ba6403d22876d626": "冷却中",
|
||||
"bac36835ace45f3a": "Commit 设置",
|
||||
"baff6c144180b185": "连通性测试完成:成功 {successful},失败 {failed}",
|
||||
"bb2b7736433ae867": "Cursor 追踪",
|
||||
"bb7efdcb6af6e805": "默认暗色",
|
||||
@@ -331,6 +333,7 @@
|
||||
"d59e47070f7f358e": "可调用",
|
||||
"d60669bb26a22f5d": "留空使用默认值",
|
||||
"d6b1f203680f5496": "留空使用 adaptive thinking",
|
||||
"d71b0171c44b668a": "关闭后将移除 Cursor 本地代理配置。如果你需要登陆官方账号,通常不需要关闭操作,推荐直接登陆你的账号即可(byok模型与官方账号的模型已支持无缝衔接),是否继续关闭并清理代理?",
|
||||
"d766536c18e8e990": "插件运行时 {version} 已安装,可以开始使用插件。",
|
||||
"d7e266bdc8064193": "分组名称",
|
||||
"d8c47e9776cf1082": "主菜单",
|
||||
@@ -393,13 +396,13 @@
|
||||
"f9615d05d8e18595": "清除{label}筛选",
|
||||
"f9aa11dbb15ce647": "周六",
|
||||
"f9b55ca75425161b": "未记录响应内容,请开启详细记录后重试。",
|
||||
"fa2352729a82e74b": "直连走 Cursor 原有通道;选择 Cursor 页已配置的模型后由本地生成。",
|
||||
"fa5b4b8a751c7d1b": "Cursor 使用的本地代理端口;填写 0 时启动时随机选择。",
|
||||
"fad86bf65f72c747": "下载进度",
|
||||
"fb11aa6f29827095": "检查中…",
|
||||
"fbe8778fa8b9bab5": "需要先初始化本地 CA",
|
||||
"fc22d1ab9ac73c6f": "正在检查插件运行时",
|
||||
"fc3947ebe6b2177b": "默认 {defaultRate} / 计入创建 {reuseRate}",
|
||||
"fc50d0b72bc871db": "关闭接管",
|
||||
"fcd311fd8ad42462": "打开模型列表",
|
||||
"fd415f8e0097c832": "缓存读写已计入提示词侧统计。",
|
||||
"fd77192739703811": "批量导入",
|
||||
|
||||
@@ -103,6 +103,7 @@ export interface CursorHarnessStatus {
|
||||
configured_models: number;
|
||||
enabled_models: number;
|
||||
integration: IntegrationState;
|
||||
settings_applied: boolean;
|
||||
proxy_url: string | null;
|
||||
ca_install_command: string | null;
|
||||
}
|
||||
@@ -153,6 +154,7 @@ export interface DesktopSettings {
|
||||
export interface CommitSettings {
|
||||
model_id: string;
|
||||
prompt: string;
|
||||
prompt_locale: Locale;
|
||||
}
|
||||
|
||||
export interface CommitSettingsView extends CommitSettings {
|
||||
@@ -297,22 +299,11 @@ export interface PluginImportResult {
|
||||
modelSyncError: string | null;
|
||||
}
|
||||
|
||||
export type ConfiguredModel =
|
||||
| { kind: "builtin"; id: string; name: string; builtin: Model }
|
||||
| { kind: "plugin"; id: string; name: string; plugin: PluginModelDescriptor };
|
||||
|
||||
export function configuredPluginModels(plugins: PluginDescriptor[]): PluginModelDescriptor[] {
|
||||
return plugins.flatMap((plugin) =>
|
||||
plugin.providers.flatMap((provider) => provider.configured ? provider.models : []));
|
||||
}
|
||||
|
||||
export function configuredModels(models: Model[], plugins: PluginDescriptor[]): ConfiguredModel[] {
|
||||
return [
|
||||
...models.map((model): ConfiguredModel => ({ kind: "builtin", id: model.model_hash, name: model.display_name, builtin: model })),
|
||||
...configuredPluginModels(plugins).map((model): ConfiguredModel => ({ kind: "plugin", id: model.id, name: model.displayName, plugin: model })),
|
||||
];
|
||||
}
|
||||
|
||||
export interface OverviewMetrics {
|
||||
llm_calls: number;
|
||||
successful_calls: number;
|
||||
@@ -517,6 +508,6 @@ export const api = {
|
||||
setTabSettings: (settings: TabSettings) => request<TabSettings>("/settings/tab", { method: "PUT", body: JSON.stringify(settings) }),
|
||||
desktopSettings: () => request<DesktopSettings>("/settings/desktop"),
|
||||
setDesktopSettings: (settings: DesktopSettings) => request<DesktopSettings>("/settings/desktop", { method: "PUT", body: JSON.stringify(settings) }),
|
||||
commitSettings: () => request<CommitSettingsView>("/settings/commit"),
|
||||
commitSettings: (locale: Locale) => request<CommitSettingsView>("/settings/commit", { headers: { "accept-language": locale } }),
|
||||
setCommitSettings: (settings: CommitSettings) => request<CommitSettingsView>("/settings/commit", { method: "PUT", body: JSON.stringify(settings) }),
|
||||
};
|
||||
|
||||
+105
-30
@@ -12,6 +12,7 @@
|
||||
|
||||
&:focus-within,
|
||||
&[data-open] { border-color: var(--vscode-focusBorder); }
|
||||
&[data-disabled] { opacity: 0.55; }
|
||||
}
|
||||
|
||||
.trigger {
|
||||
@@ -64,35 +65,6 @@
|
||||
border: 1px solid var(--vscode-dropdown-border);
|
||||
border-radius: var(--oa-overlay-radius);
|
||||
box-shadow: var(--oa-dropdown-shadow);
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 5px 8px;
|
||||
color: var(--vscode-dropdown-foreground);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
font-size: type.$font-size-xs;
|
||||
|
||||
&:hover {
|
||||
color: var(--vscode-list-hoverForeground, var(--vscode-dropdown-foreground));
|
||||
background: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
|
||||
&[aria-selected="true"] {
|
||||
color: var(--vscode-list-activeSelectionForeground);
|
||||
background: var(--vscode-list-activeSelectionBackground);
|
||||
}
|
||||
|
||||
&[aria-selected="true"]:hover {
|
||||
background: var(--vscode-list-inactiveSelectionBackground, var(--vscode-list-activeSelectionBackground));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.listFrame,
|
||||
@@ -106,6 +78,101 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.group,
|
||||
.groupLabel {
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.group {
|
||||
gap: 8px;
|
||||
padding: 7px 8px 3px;
|
||||
cursor: pointer;
|
||||
|
||||
input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:has(input:focus-visible) .checkbox {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.groupLabel {
|
||||
padding: 7px 8px 3px;
|
||||
}
|
||||
|
||||
.optionRow {
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 8px 5px 32px;
|
||||
box-sizing: border-box;
|
||||
color: var(--vscode-dropdown-foreground);
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
font-size: type.$font-size-xs;
|
||||
cursor: pointer;
|
||||
|
||||
input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--vscode-list-hoverForeground, var(--vscode-dropdown-foreground));
|
||||
background: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: 0 0 16px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--vscode-checkbox-foreground, var(--vscode-button-foreground));
|
||||
background: var(--vscode-checkbox-background, var(--vscode-input-background));
|
||||
border: 1px solid var(--vscode-checkbox-border, var(--vscode-input-border));
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.optionRow:has(input:checked) .checkbox,
|
||||
.group:has(input:checked) .checkbox,
|
||||
.checkbox[data-indeterminate] {
|
||||
color: var(--vscode-button-foreground);
|
||||
background: var(--vscode-button-background);
|
||||
border-color: var(--vscode-button-background);
|
||||
}
|
||||
|
||||
.checkbox[data-indeterminate]::after {
|
||||
width: 8px;
|
||||
height: 2px;
|
||||
content: "";
|
||||
background: currentColor;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.option {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
@@ -119,7 +186,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.check { width: 1.1em; flex: 0 0 1.1em; }
|
||||
.empty { padding: 8px; color: var(--vscode-descriptionForeground); font-size: type.$font-size-xs; }
|
||||
|
||||
.footer {
|
||||
@@ -133,7 +199,16 @@
|
||||
button {
|
||||
width: auto;
|
||||
min-height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 5px 8px;
|
||||
color: var(--vscode-textLink-foreground);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
font-size: type.$font-size-xs;
|
||||
|
||||
&:hover { background: var(--vscode-list-hoverBackground); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { autoUpdate, computePosition, flip, offset, shift, size } from "@floating-ui/dom";
|
||||
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Icon, type IconProps } from "./Icon";
|
||||
import { checkIcon, chevronDownIcon, windowCloseIcon } from "./icons";
|
||||
import { VirtualList } from "../virtual/VirtualList";
|
||||
import styles from "./ModelSelect.module.scss";
|
||||
|
||||
export type ModelSelectOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
group: string;
|
||||
icon?: IconProps["icon"];
|
||||
iconSrc?: string;
|
||||
};
|
||||
|
||||
type ModelSelectProps = {
|
||||
label: string;
|
||||
options: ModelSelectOption[];
|
||||
disabled?: boolean;
|
||||
} & (
|
||||
| { mode: "single"; value: string; onChange: (value: string) => void }
|
||||
| { mode: "multiple"; value: string[]; onChange: (value: string[]) => void }
|
||||
);
|
||||
|
||||
type MenuItem =
|
||||
| { kind: "group"; key: string; label: string; options: ModelSelectOption[] }
|
||||
| { kind: "option"; key: string; option: ModelSelectOption };
|
||||
|
||||
function groupedItems(options: ModelSelectOption[]): MenuItem[] {
|
||||
const groups = new Map<string, ModelSelectOption[]>();
|
||||
for (const option of options) {
|
||||
const group = groups.get(option.group);
|
||||
if (group) group.push(option);
|
||||
else groups.set(option.group, [option]);
|
||||
}
|
||||
return [...groups].flatMap(([group, groupOptions]) => [
|
||||
{ kind: "group" as const, key: `group:${group}`, label: group, options: groupOptions },
|
||||
...groupOptions.map((option) => ({ kind: "option" as const, key: `option:${option.value}`, option })),
|
||||
]);
|
||||
}
|
||||
|
||||
function GroupCheckbox({ label, options, selected, onChange }: {
|
||||
label: string;
|
||||
options: ModelSelectOption[];
|
||||
selected: Set<string>;
|
||||
onChange: (checked: boolean) => void;
|
||||
}) {
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const selectedCount = options.filter((option) => selected.has(option.value)).length;
|
||||
const checked = selectedCount === options.length;
|
||||
const indeterminate = selectedCount > 0 && !checked;
|
||||
|
||||
useEffect(() => {
|
||||
if (input.current) input.current.indeterminate = indeterminate;
|
||||
}, [indeterminate]);
|
||||
|
||||
return <label className={styles.group}>
|
||||
<input
|
||||
ref={input}
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
aria-checked={indeterminate ? "mixed" : checked}
|
||||
onChange={(event) => onChange(event.target.checked)}
|
||||
/>
|
||||
<span className={styles.checkbox} data-indeterminate={indeterminate || undefined} aria-hidden="true">
|
||||
{checked && <Icon icon={checkIcon} size="0.875em" />}
|
||||
</span>
|
||||
<span>{label}</span>
|
||||
</label>;
|
||||
}
|
||||
|
||||
export function ModelSelect(props: ModelSelectProps) {
|
||||
const { label, options, disabled, mode } = props;
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
const menu = useRef<HTMLDivElement>(null);
|
||||
const firstOption = useRef<HTMLLabelElement>(null);
|
||||
const menuId = useId();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [position, setPosition] = useState({ left: 0, top: 0, width: 220, maxHeight: 280 });
|
||||
const items = useMemo(() => groupedItems(options), [options]);
|
||||
const selected = useMemo(
|
||||
() => new Set(mode === "multiple" ? props.value : [props.value]),
|
||||
[mode, props.value],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !root.current || !menu.current) return;
|
||||
return autoUpdate(root.current, menu.current, () => void computePosition(root.current!, menu.current!, {
|
||||
placement: "bottom-start",
|
||||
middleware: [offset(5), flip({ padding: 10 }), shift({ padding: 10 }), size({
|
||||
padding: 10,
|
||||
apply: ({ rects, availableHeight }) => setPosition((current) => ({
|
||||
...current,
|
||||
width: rects.reference.width,
|
||||
maxHeight: Math.max(120, availableHeight),
|
||||
})),
|
||||
})],
|
||||
}).then(({ x, y }) => setPosition((current) => ({ ...current, left: x, top: y }))));
|
||||
}, [open]);
|
||||
|
||||
const close = () => {
|
||||
setOpen(false);
|
||||
trigger.current?.focus();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
firstOption.current?.focus();
|
||||
const closeOutside = (event: PointerEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (!root.current?.contains(target) && !menu.current?.contains(target)) close();
|
||||
};
|
||||
document.addEventListener("pointerdown", closeOutside);
|
||||
return () => document.removeEventListener("pointerdown", closeOutside);
|
||||
}, [open]);
|
||||
|
||||
const summary = mode === "multiple"
|
||||
? props.value.length === 0 ? t("全部") : t("已选 {count} 项", { count: props.value.length })
|
||||
: options.find((option) => option.value === props.value)?.label ?? props.value;
|
||||
|
||||
const choose = (option: ModelSelectOption) => {
|
||||
if (mode === "single") {
|
||||
props.onChange(option.value);
|
||||
close();
|
||||
return;
|
||||
}
|
||||
props.onChange(selected.has(option.value)
|
||||
? props.value.filter((item) => item !== option.value)
|
||||
: [...props.value, option.value]);
|
||||
};
|
||||
|
||||
const toggleGroup = (groupOptions: ModelSelectOption[], checked: boolean) => {
|
||||
if (mode !== "multiple") return;
|
||||
const groupValues = new Set(groupOptions.map((option) => option.value));
|
||||
props.onChange(checked
|
||||
? [...props.value, ...groupOptions.filter((option) => !selected.has(option.value)).map((option) => option.value)]
|
||||
: props.value.filter((value) => !groupValues.has(value)));
|
||||
};
|
||||
|
||||
const openMenu = () => {
|
||||
if (disabled) return;
|
||||
const width = root.current?.getBoundingClientRect().width;
|
||||
if (width) setPosition((current) => ({ ...current, width }));
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
return <>
|
||||
<div ref={root} className={styles.target} data-open={open || undefined} data-disabled={disabled || undefined}>
|
||||
<button
|
||||
ref={trigger}
|
||||
type="button"
|
||||
className={styles.trigger}
|
||||
disabled={disabled}
|
||||
aria-label={t("筛选项:{label},{summary}", { label, summary })}
|
||||
aria-haspopup="listbox"
|
||||
aria-controls={open ? menuId : undefined}
|
||||
aria-expanded={open}
|
||||
onClick={() => { if (open) close(); else openMenu(); }}
|
||||
onKeyDown={(event) => { if (event.key === "ArrowDown") { event.preventDefault(); openMenu(); } }}
|
||||
>
|
||||
<span>{label}</span><span className={styles.summary} aria-hidden="true">{summary}</span><Icon icon={chevronDownIcon} size="1.1em" />
|
||||
</button>
|
||||
{mode === "multiple" && props.value.length > 0 && <button
|
||||
type="button"
|
||||
className={styles.clear}
|
||||
aria-label={t("清除{label}筛选", { label })}
|
||||
onClick={() => props.onChange([])}
|
||||
><Icon icon={windowCloseIcon} size="1.1em" /></button>}
|
||||
</div>
|
||||
{open && createPortal(<div
|
||||
id={menuId}
|
||||
ref={menu}
|
||||
className={styles.menu}
|
||||
aria-label={label}
|
||||
style={position}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); close(); } }}
|
||||
>
|
||||
<div
|
||||
className={styles.listFrame}
|
||||
role="listbox"
|
||||
aria-label={label}
|
||||
aria-multiselectable={mode === "multiple" ? "true" : undefined}
|
||||
style={{ height: Math.min(items.length * 32, Math.max(32, position.maxHeight - (mode === "multiple" ? 45 : 0))) }}
|
||||
><VirtualList
|
||||
items={items}
|
||||
itemKey="key"
|
||||
className={styles.list}
|
||||
style={{ height: "100%" }}
|
||||
estimatedItemHeight={32}
|
||||
overscan={4}
|
||||
empty={<div className={styles.empty}>{t("暂无选项")}</div>}
|
||||
>{(item, index) => item.kind === "group"
|
||||
? mode === "multiple"
|
||||
? <GroupCheckbox
|
||||
label={item.label}
|
||||
options={item.options}
|
||||
selected={selected}
|
||||
onChange={(checked) => toggleGroup(item.options, checked)}
|
||||
/>
|
||||
: <div className={styles.groupLabel}>{item.label}</div>
|
||||
: <label
|
||||
ref={index === 1 ? firstOption : undefined}
|
||||
className={styles.optionRow}
|
||||
role="option"
|
||||
aria-selected={selected.has(item.option.value)}
|
||||
tabIndex={index === 1 ? 0 : -1}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === " " || event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
choose(item.option);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(item.option.value)}
|
||||
tabIndex={-1}
|
||||
onChange={() => choose(item.option)}
|
||||
/>
|
||||
<span className={styles.checkbox} aria-hidden="true">
|
||||
{selected.has(item.option.value) && <Icon icon={checkIcon} size="0.875em" />}
|
||||
</span>
|
||||
<span className={styles.option}>
|
||||
{(item.option.icon || item.option.iconSrc) && <Icon icon={item.option.icon} src={item.option.iconSrc} />}
|
||||
<span>{item.option.label}</span>
|
||||
</span>
|
||||
</label>}</VirtualList></div>
|
||||
{mode === "multiple" && options.length > 0 && <div className={styles.footer}>
|
||||
<button type="button" onClick={() => props.onChange(options.map((option) => option.value))}>{t("全选")}</button>
|
||||
<button type="button" onClick={() => props.onChange([])}>{t("全不选")}</button>
|
||||
</div>}
|
||||
</div>, document.body)}
|
||||
</>;
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import { autoUpdate, computePosition, flip, offset, shift, size } from "@floating-ui/dom";
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Icon, type IconProps } from "./Icon";
|
||||
import { checkIcon, chevronDownIcon, windowCloseIcon } from "./icons";
|
||||
import { VirtualList } from "../virtual/VirtualList";
|
||||
import styles from "./MultiSelect.module.scss";
|
||||
|
||||
export type MultiSelectOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
icon?: IconProps["icon"];
|
||||
};
|
||||
|
||||
export function MultiSelect({ label, value, options, onChange }: {
|
||||
label: string;
|
||||
value: string[];
|
||||
options: MultiSelectOption[];
|
||||
onChange: (value: string[]) => void;
|
||||
}) {
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
const menu = useRef<HTMLDivElement>(null);
|
||||
const firstOption = useRef<HTMLButtonElement>(null);
|
||||
const menuId = useId();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [position, setPosition] = useState({ left: 0, top: 0, width: 220, maxHeight: 280 });
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !root.current || !menu.current) return;
|
||||
return autoUpdate(root.current, menu.current, () => void computePosition(root.current!, menu.current!, {
|
||||
placement: "bottom-start",
|
||||
middleware: [offset(5), flip({ padding: 10 }), shift({ padding: 10 }), size({
|
||||
padding: 10,
|
||||
apply: ({ rects, availableHeight }) => setPosition((current) => ({
|
||||
...current,
|
||||
width: rects.reference.width,
|
||||
maxHeight: Math.max(120, availableHeight),
|
||||
})),
|
||||
})],
|
||||
}).then(({ x, y }) => setPosition((current) => ({ ...current, left: x, top: y }))));
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
firstOption.current?.focus();
|
||||
const closeOutside = (event: PointerEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (!root.current?.contains(target) && !menu.current?.contains(target)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("pointerdown", closeOutside);
|
||||
return () => document.removeEventListener("pointerdown", closeOutside);
|
||||
}, [open]);
|
||||
|
||||
const selected = new Set(value);
|
||||
const summary = value.length === 0 ? t("全部") : t("已选 {count} 项", { count: value.length });
|
||||
const toggle = (option: string) => onChange(selected.has(option)
|
||||
? value.filter((item) => item !== option)
|
||||
: [...value, option]);
|
||||
|
||||
const close = () => { setOpen(false); trigger.current?.focus(); };
|
||||
const openMenu = () => {
|
||||
const width = root.current?.getBoundingClientRect().width;
|
||||
if (width) setPosition((current) => ({ ...current, width }));
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
return <>
|
||||
<div ref={root} className={styles.target} data-open={open || undefined}>
|
||||
<button
|
||||
ref={trigger}
|
||||
type="button"
|
||||
className={styles.trigger}
|
||||
aria-label={t("筛选项:{label},{summary}", { label, summary })}
|
||||
aria-haspopup="listbox"
|
||||
aria-controls={open ? menuId : undefined}
|
||||
aria-expanded={open}
|
||||
onClick={() => { if (open) close(); else openMenu(); }}
|
||||
onKeyDown={(event) => { if (event.key === "ArrowDown") { event.preventDefault(); openMenu(); } }}
|
||||
>
|
||||
<span>{label}</span><span className={styles.summary} aria-hidden="true">{summary}</span><Icon icon={chevronDownIcon} size="1.1em" />
|
||||
</button>
|
||||
{value.length > 0 && <button type="button" className={styles.clear} aria-label={t("清除{label}筛选", { label })} onClick={() => onChange([])}>
|
||||
<Icon icon={windowCloseIcon} size="1.1em" />
|
||||
</button>}
|
||||
</div>
|
||||
{open && createPortal(<div
|
||||
id={menuId}
|
||||
ref={menu}
|
||||
className={styles.menu}
|
||||
aria-label={label}
|
||||
style={position}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); close(); } }}
|
||||
>
|
||||
<div
|
||||
className={styles.listFrame}
|
||||
role="listbox"
|
||||
aria-label={label}
|
||||
aria-multiselectable="true"
|
||||
style={{ height: Math.min(options.length * 32, Math.max(32, position.maxHeight - 45)) }}
|
||||
><VirtualList
|
||||
items={options}
|
||||
itemKey="value"
|
||||
className={styles.list}
|
||||
style={{ height: "100%" }}
|
||||
estimatedItemHeight={32}
|
||||
overscan={4}
|
||||
empty={<div className={styles.empty}>{t("暂无选项")}</div>}
|
||||
>{(option, index) => <button
|
||||
ref={index === 0 ? firstOption : undefined}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-label={option.label}
|
||||
aria-selected={selected.has(option.value)}
|
||||
onClick={() => toggle(option.value)}
|
||||
>
|
||||
<span className={styles.option}>{option.icon && <Icon icon={option.icon} />}<span>{option.label}</span></span>
|
||||
<span className={styles.check} aria-hidden="true">{selected.has(option.value) && <Icon icon={checkIcon} size="1.1em" />}</span>
|
||||
</button>}</VirtualList></div>
|
||||
{options.length > 0 && <div className={styles.footer}>
|
||||
<button type="button" onClick={() => onChange(options.map((option) => option.value))}>{t("全选")}</button>
|
||||
<button type="button" onClick={() => onChange([])}>{t("全不选")}</button>
|
||||
</div>}
|
||||
</div>, document.body)}
|
||||
</>;
|
||||
}
|
||||
@@ -62,55 +62,6 @@
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
}
|
||||
|
||||
.multiCombo {
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
overflow: hidden;
|
||||
background: var(--vscode-input-background);
|
||||
border: 1px solid var(--vscode-input-border, transparent);
|
||||
border-radius: 6px;
|
||||
|
||||
&:focus-within,
|
||||
&[data-open] {
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
}
|
||||
|
||||
.multiValues {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 6px;
|
||||
|
||||
> input {
|
||||
min-width: 120px;
|
||||
min-height: 24px;
|
||||
flex: 1;
|
||||
padding: 0 3px;
|
||||
color: var(--vscode-input-foreground);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
}
|
||||
|
||||
.multiCount {
|
||||
height: 24px;
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 3px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.combo input {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
@@ -172,23 +123,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.menu .multiOption {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
|
||||
> span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 8px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createPortal } from "react-dom";
|
||||
import { VirtualList } from "../virtual/VirtualList";
|
||||
import type { VirtualListApi } from "../virtual/virtualTypes";
|
||||
import { Icon, type IconProps } from "./Icon";
|
||||
import { checkIcon, chevronDownIcon } from "./icons";
|
||||
import { chevronDownIcon } from "./icons";
|
||||
import styles from "./Select.module.scss";
|
||||
|
||||
export type SelectOption = { value: string; label: string; icon?: IconProps["icon"]; iconSrc?: string };
|
||||
@@ -117,86 +117,3 @@ export function Combobox({ value, options = [], placeholder, disabled, append, o
|
||||
</div>{append}</div>;
|
||||
}
|
||||
|
||||
export function MultiCombobox({ value, options = [], placeholder, disabled, append, onChange }: {
|
||||
value: string[];
|
||||
options?: string[];
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
append?: ReactNode;
|
||||
onChange: (value: string[]) => void;
|
||||
}) {
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const menu = useRef<HTMLDivElement>(null);
|
||||
const listApi = useRef<VirtualListApi | null>(null);
|
||||
const menuId = useId();
|
||||
const [query, setQuery] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [active, setActive] = useState(0);
|
||||
const [position, setPosition] = useState({ left: 0, top: 0, width: 0, maxHeight: 280 });
|
||||
const selected = useMemo(() => new Set(value), [value]);
|
||||
const filtered = useMemo(() => {
|
||||
const normalized = query.trim().toLocaleLowerCase();
|
||||
return options.filter((option) => !normalized || option.toLocaleLowerCase().includes(normalized));
|
||||
}, [options, query]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !root.current || !menu.current) return;
|
||||
return autoUpdate(root.current, menu.current, () => void computePosition(root.current!, menu.current!, {
|
||||
placement: "bottom-start",
|
||||
middleware: [offset(6), flip({ padding: 12 }), shift({ padding: 12 }), size({ padding: 12, apply: ({ rects, availableHeight }) => setPosition((current) => ({ ...current, width: rects.reference.width, maxHeight: Math.max(160, availableHeight) })) })],
|
||||
}).then(({ x, y }) => setPosition((current) => ({ ...current, left: x, top: y }))));
|
||||
}, [filtered.length, open]);
|
||||
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);
|
||||
};
|
||||
document.addEventListener("pointerdown", outside);
|
||||
return () => document.removeEventListener("pointerdown", outside);
|
||||
}, [open]);
|
||||
useEffect(() => {
|
||||
if (open && active >= 0) listApi.current?.scrollToIndex(active);
|
||||
}, [active, open]);
|
||||
|
||||
const add = (modelId: string) => {
|
||||
const normalized = modelId.trim();
|
||||
if (normalized && !selected.has(normalized)) onChange([...value, normalized]);
|
||||
setQuery("");
|
||||
};
|
||||
const toggle = (modelId: string) => {
|
||||
onChange(selected.has(modelId) ? value.filter((item) => item !== modelId) : [...value, modelId]);
|
||||
setQuery("");
|
||||
input.current?.focus();
|
||||
};
|
||||
const move = (step: number) => {
|
||||
if (!filtered.length) return;
|
||||
setOpen(true);
|
||||
setActive((index) => (index + step + filtered.length) % filtered.length);
|
||||
};
|
||||
|
||||
return <div className={styles.comboRow}><div ref={root} className={styles.multiCombo} data-open={open || undefined}>
|
||||
<div className={styles.multiValues}>
|
||||
{value.length > 0 && <span className={styles.multiCount}>{t("已选择 {count} 个", { count: value.length })}</span>}
|
||||
<input ref={input} value={query} placeholder={value.length ? t("继续选择或输入") : placeholder} disabled={disabled} role="combobox" aria-haspopup="listbox" aria-controls={open ? menuId : undefined} aria-expanded={open} aria-autocomplete="list" onFocus={() => { if (options.length) setOpen(true); }} onBlur={() => add(query)} onChange={(event) => { setQuery(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") {
|
||||
event.preventDefault();
|
||||
const normalized = query.trim();
|
||||
const exact = filtered.find((option) => option.toLocaleLowerCase() === normalized.toLocaleLowerCase());
|
||||
if (normalized && !exact) add(normalized);
|
||||
else if (open && filtered[active]) toggle(filtered[active]);
|
||||
}
|
||||
if (event.key === "Backspace" && !query && value.length) onChange(value.slice(0, -1));
|
||||
if (event.key === "Escape") { setOpen(false); input.current?.focus(); }
|
||||
}} />
|
||||
</div>
|
||||
<button type="button" className={styles.comboToggle} disabled={disabled || !options.length} aria-label={t("打开模型列表")} aria-expanded={open} tabIndex={-1} onMouseDown={(event) => event.preventDefault()} onClick={() => { setOpen(!open); input.current?.focus(); }}><Icon icon={chevronDownIcon} size="1.1em" className={[styles.dropdownIcon, open && styles.dropdownIconOpen].filter(Boolean).join(" ")} /></button>
|
||||
{open && createPortal(<div id={menuId} ref={menu} className={styles.menu} role="listbox" aria-multiselectable="true" style={{ left: position.left, top: position.top, width: position.width }} onPointerDown={(event) => event.stopPropagation()} onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); setOpen(false); input.current?.focus(); } }}>
|
||||
{filtered.length ? <VirtualList items={filtered} itemKey={(option) => option} estimatedItemHeight={30} onReady={(api) => { listApi.current = api; api.scrollToIndex(active); }} style={{ height: Math.min(filtered.length * 30, Math.max(30, position.maxHeight - 8)) }}>
|
||||
{(option, index) => <button type="button" className={styles.multiOption} role="option" aria-selected={selected.has(option)} data-active={index === active || undefined} onMouseEnter={() => setActive(index)} onMouseDown={(event) => event.preventDefault()} onClick={() => toggle(option)}><span>{option}</span>{selected.has(option) && <Icon icon={checkIcon} size="1.1em" />}</button>}
|
||||
</VirtualList> : <div className={styles.empty}>{t("输入模型名称后按 Enter 添加")}</div>}
|
||||
</div>, document.body)}
|
||||
</div>{append}</div>;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
import type { ButtonHTMLAttributes } from "react";
|
||||
import styles from "./Switch.module.scss";
|
||||
|
||||
export function Switch({ checked, disabled, label, onChange }: { checked: boolean; disabled?: boolean; label: string; onChange: (checked: boolean) => void }) {
|
||||
type SwitchProps = {
|
||||
checked: boolean;
|
||||
label: string;
|
||||
onChange: (checked: boolean) => void;
|
||||
} & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "aria-label" | "children" | "onChange" | "role">;
|
||||
|
||||
export function Switch({ checked, disabled, label, onChange, onClick, ...props }: SwitchProps) {
|
||||
return <button
|
||||
{...props}
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
className={styles.root}
|
||||
className={[styles.root, props.className].filter(Boolean).join(" ")}
|
||||
data-checked={checked || undefined}
|
||||
onClick={() => onChange(!checked)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event);
|
||||
if (!event.defaultPrevented) onChange(!checked);
|
||||
}}
|
||||
><span /></button>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Model } from "../api";
|
||||
|
||||
export function modelProviderName(model: Pick<Model, "group_name" | "base_url">): string {
|
||||
const configuredName = model.group_name?.trim();
|
||||
if (configuredName) return configuredName;
|
||||
|
||||
const value = model.base_url.trim();
|
||||
try {
|
||||
return new URL(value).hostname.toLowerCase() || value;
|
||||
} catch {
|
||||
try {
|
||||
return new URL(`https://${value}`).hostname.toLowerCase() || value;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
.menuStatusTag {
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
padding: 1px 5px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
background: color-mix(in srgb, var(--vscode-descriptionForeground) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--vscode-descriptionForeground) 28%, transparent);
|
||||
border-radius: 999px;
|
||||
font-size: type.$font-size-2xs;
|
||||
line-height: 1.4;
|
||||
|
||||
&[data-taken] {
|
||||
color: var(--vscode-testing-iconPassed, #73c991);
|
||||
background: color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 10%, transparent);
|
||||
border-color: color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 28%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.menuIndicatorDot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
|
||||
@@ -43,7 +43,7 @@ function loadStoredAdIds(key: string): Set<string> {
|
||||
}
|
||||
|
||||
export function AppLayout() {
|
||||
const { busy } = useAppStore();
|
||||
const { busy, cursorHarness } = useAppStore();
|
||||
const { availableVersion } = useUpdateStore();
|
||||
const { locale } = useI18n();
|
||||
const message = useMessage();
|
||||
@@ -215,6 +215,12 @@ export function AppLayout() {
|
||||
? <Icon src={item.icon} size="1.3em" />
|
||||
: <Icon icon={item.icon} size="1.3em" />}
|
||||
<span>{item.label}</span>
|
||||
{item.path === "/harness/cursor" && cursorHarness && <span
|
||||
className={styles.menuStatusTag}
|
||||
data-taken={cursorHarness.settings_applied || undefined}
|
||||
>
|
||||
{cursorHarness.settings_applied ? t("已接管") : t("未接管")}
|
||||
</span>}
|
||||
{item.path === "/settings" && availableVersion && <span className={styles.menuIndicatorDot} aria-hidden="true" />}
|
||||
</NavLink>
|
||||
</div>}
|
||||
|
||||
Reference in New Issue
Block a user