diff --git a/.agents/skills/model-selection/SKILL.md b/.agents/skills/model-selection/SKILL.md new file mode 100644 index 0000000..84d1abe --- /dev/null +++ b/.agents/skills/model-selection/SKILL.md @@ -0,0 +1,86 @@ +--- +name: model-selection +description: Implement and review desktop selection of configured models through the unified grouped ModelSelect component. Use when adding or changing model dropdowns, model filters, Commit model settings, single-model selection, multi-model selection, provider grouping, or configured-model option construction under apps/desktop. +--- + +# Model Selection + +Use one presentation component for every UI that selects from already configured models: + +```text +apps/desktop/src/ +├── shared/ui/ +│ ├── ModelSelect.tsx # Single/multiple selection behavior and grouped floating menu +│ └── ModelSelect.module.scss # Trigger, provider headers, checkbox rows, and footer +├── shared/utils/ +│ └── modelProvider.ts # Built-in model provider display name +└── features/ + ├── home/ # Builds overview multi-select options + └── settings/ # Builds Commit single-select options and persists selection + +server/src/ +├── cursor/services/commit_message.rs # Validates built-in/plugin IDs and creates Commit invocation +├── provider/router.rs # Routes stable IDs to built-in or plugin providers +└── plugin/registry.rs # Resolves plugin model IDs and executes plugin streams +``` + +## Component boundary + +- Use `ModelSelect` when choosing one or more existing configured models. +- Do not implement another model dropdown, reuse generic `Select`, or add a compatibility wrapper for configured-model selection. +- Keep `ModelSelect` presentation-only. Pages own model/plugin state, option construction, filtering, and persistence. +- Keep editable model-ID entry in `Combobox`; creating or editing a model identifier is input, not configured-model selection. +- Delete replaced model-selection components, exports, styles, helpers, and compatibility paths once references are gone. + +## Option contract + +Construct every `ModelSelectOption` with: + +- `value`: stable persisted/request identifier. Built-in models use `model_hash`; plugin models use plugin model `id`. +- `label`: user-facing model display name. +- `group`: supplier display name. +- `icon`/`iconSrc`: model/provider icon when available. + +For built-in models, derive `group` with `modelProviderName(model)`. It uses the configured `group_name` first and the API hostname otherwise. For plugin models, use the localized provider display name. + +Preserve source order within each supplier. The first occurrence of a supplier determines group order. + +## Modes + +```text +Single owner value: string + └── ── choose one ── close ── persist + +Multiple owner value: string[] + └── ── toggle many ── remain open ── apply/filter +``` + +- Both modes render classic checkbox controls in option rows. +- Single mode allows exactly one checked option and closes immediately after selection. +- Multiple mode supports toggling, clearing, selecting all, and selecting none. +- In multiple mode, every supplier header has a checkbox: unchecked means none selected, checked means all selected, and indeterminate means some selected. Toggling it selects or clears that supplier. +- Indent child model rows relative to their supplier header so hierarchy remains visible. +- Commit always uses single mode and includes both configured built-in and configured plugin models. Its `直连` option has value `""`, belongs to the `Cursor` group, and is the first option. +- Commit settings follow the settings-card edit-state pattern: read mode shows the persisted model, Edit creates a local draft, selection only changes that draft, Cancel restores the persisted value, and Save persists once before returning to read mode. +- Persist the stable plugin model `id` unchanged. Commit generation validates that identifier through `PluginRegistry`, then lets `ProviderRouter` dispatch it; do not query the built-in model table for plugin IDs. +- Overview filtering uses multiple mode. + +## Floating-menu invariants + +Also apply the project `floating-ui` and `frontend` skills: + +- Render the menu through a body portal and position it with `@floating-ui/dom`. +- Keep supplier headers and checkbox options inside the virtualized list; keep multi-select bulk actions outside it. +- Close on Escape and outside pointer interaction, then restore trigger focus. +- Preserve `aria-expanded`, `aria-controls`, `aria-haspopup`, listbox semantics, and multi-select semantics. +- Keep the trigger's open/focus border visible while the portaled menu owns focus. + +## Review checklist + +- Search the repository for old model-selection components and zero-reference model option helpers; delete them instead of retaining fallbacks. +- Confirm all configured-model selectors import `shared/ui/ModelSelect`. +- Confirm no feature implements checkbox selection, supplier grouping, portal positioning, or bulk actions independently. +- Confirm single/multiple value types cannot be mixed. +- Confirm Commit has `直连` first and cannot select multiple values. +- Confirm supplier labels are based on supplier identity, not request protocol type. +- Follow the user's validation instruction; when automated tests are not requested, report manual checks without running test or build commands. diff --git a/Makefile b/Makefile index 4715ea8..4432435 100644 --- a/Makefile +++ b/Makefile @@ -1,20 +1,16 @@ LOCAL_TAURI_SIGNING_KEY := $(CURDIR)/.tauri/cursor-byok.local.key -.PHONY: check dev-web dev-docs dev-server dev-desktop build-web build-docs build-server build-desktop build-docker +.PHONY: check dev-web dev-server dev-desktop build-web build-server build-desktop build-docker check: cargo fmt --all -- --check cargo clippy --workspace --all-targets -- -D warnings cargo test --workspace --all-targets npm --prefix apps/desktop run check - npm --prefix apps/docs run check dev-web: npm --prefix apps/desktop run dev:web -dev-docs: - npm --prefix apps/docs run dev - dev-server: CURSOR_CONSOLE_DIR=apps/desktop/dist cargo run --package cursor-server --bin cursor-server @@ -24,9 +20,6 @@ dev-desktop: build-web: npm --prefix apps/desktop run build -build-docs: - npm --prefix apps/docs run build - build-server: cargo build --release --package cursor-server --bin cursor-server diff --git a/README-CN.md b/README-CN.md index 892b355..716e04c 100644 --- a/README-CN.md +++ b/README-CN.md @@ -128,7 +128,6 @@ cursor-byok/ │ │ │ ├── i18n/ # 本地化运行时与语言目录 │ │ │ └── styles/ # 全局主题与排版令牌 │ │ └── src-tauri/ # Tauri 桌面生命周期 -│ └── docs/ # Next.js 与 Fumadocs 中文/英文文档站 ├── server/ │ ├── src/ │ │ ├── cursor/ # Cursor 协议适配 @@ -167,7 +166,6 @@ cursor-byok/ ```bash npm --prefix apps/desktop install -npm --prefix apps/docs install ``` ### 启动开发环境 @@ -184,14 +182,6 @@ make dev-web make dev-desktop ``` -启动文档站: - -```bash -make dev-docs -``` - -文档站默认地址为 。 - ### 检查与构建 运行完整检查: @@ -205,13 +195,10 @@ make check ```bash make build-web # 构建桌面前端 make build-server # 构建 Rust 本地服务 -make build-docs # 构建文档站 make build-desktop # 构建 Tauri 桌面安装包 make build-docker # 构建 Docker 镜像 ``` -文档内容位于 `apps/docs/content/docs` 和 `apps/docs/content/blog`。修改文档侧边栏时同步更新 `apps/docs/content/docs/meta.json`。 - ## 路线图 项目将继续改进模型兼容性、Agent 工具、本地运行稳定性和自托管体验,并探索支持更多 IDE、聊天和 Agent 工作流。 diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 7bf987e..a6cd22d 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -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(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 ; diff --git a/apps/desktop/src/demo/api.ts b/apps/desktop/src/demo/api.ts index 270081a..bd7c7ae 100644 --- a/apps/desktop/src/demo/api.ts +++ b/apps/desktop/src/demo/api.ts @@ -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); diff --git a/apps/desktop/src/features/home/HomePage.tsx b/apps/desktop/src/features/home/HomePage.tsx index bceb6d7..6a85f85 100644 --- a/apps/desktop/src/features/home/HomePage.tsx +++ b/apps/desktop/src/features/home/HomePage.tsx @@ -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, now = new D export function HomePage() { const { overview, busy, models, plugins } = useAppStore(); + const { locale } = useI18n(); const [preset, setPreset] = useState("month"); const [customRange, setCustomRange] = useState(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[] = [ { diff --git a/apps/desktop/src/features/home/overview/OverviewTimeRangeFilter.tsx b/apps/desktop/src/features/home/overview/OverviewTimeRangeFilter.tsx index 69eab62..ed45d1f 100644 --- a/apps/desktop/src/features/home/overview/OverviewTimeRangeFilter.tsx +++ b/apps/desktop/src/features/home/overview/OverviewTimeRangeFilter.tsx @@ -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) => void; @@ -105,7 +105,7 @@ export function OverviewTimeRangeFilter({ value, customOpen, customStart, custom > -
+
diff --git a/apps/desktop/src/features/models/CursorModelCards.tsx b/apps/desktop/src/features/models/CursorModelCards.tsx index 3049b77..49a1716 100644 --- a/apps/desktop/src/features/models/CursorModelCards.tsx +++ b/apps/desktop/src/features/models/CursorModelCards.tsx @@ -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) => {builtins} {pluginGroups(props.pluginModels).map((group) => {group.models.map((model) => void; children: ReactNode; }) { - const [open, setOpen] = useState(true); + const [open, setOpen] = useState(defaultOpen); return
- {onSettings && } + {onSettings && } - {canGroupByProvider && } - {canGroupByType && } - + +
+ {cursorTakenOver ? t("已接管") : t("未接管")} + + { + if (enabled) void appStore.setCursorEnabled(true); + else setConfirmDisableTakeover(true); + }} + /> + + {testTargets.length > 0 &&
+ + {canGroupByProvider && } + {canGroupByType && } + +
}
-
} + + setConfirmDisableTakeover(false)} + onConfirm={() => { + setConfirmDisableTakeover(false); + void appStore.setCursorEnabled(false); + }} + > +

{t("关闭后将移除 Cursor 本地代理配置。如果你需要登陆官方账号,通常不需要关闭操作,推荐直接登陆你的账号即可(byok模型与官方账号的模型已支持无缝衔接),是否继续关闭并清理代理?")}

+
: undefined} busy={cursorBusy || savingAndTesting} onClose={() => { if (editing && editorTesting) void cancelModelTest(editing.model_hash); setDraft(null); setEditing(null); }} onSubmit={() => void save()} submitLabel={t("保存")} secondaryAction={}> {draft && <> void discover()} /> diff --git a/apps/desktop/src/features/settings/CommitSettingsCard.module.scss b/apps/desktop/src/features/settings/CommitSettingsCard.module.scss index 17a8905..472ae50 100644 --- a/apps/desktop/src/features/settings/CommitSettingsCard.module.scss +++ b/apps/desktop/src/features/settings/CommitSettingsCard.module.scss @@ -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%; } +} diff --git a/apps/desktop/src/features/settings/CommitSettingsCard.tsx b/apps/desktop/src/features/settings/CommitSettingsCard.tsx index 2702173..4a4bcc9 100644 --- a/apps/desktop/src/features/settings/CommitSettingsCard.tsx +++ b/apps/desktop/src/features/settings/CommitSettingsCard.tsx @@ -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(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(); 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 ? ( +
+ + +
+ ) : ( +
+ + +
+ ); + return ( <> - - {t("提示词设置")} - - } - > -
-
- {t("生成模型")} - {t("直连走 Cursor 原有通道;选择 Cursor 页已配置的模型后由本地生成。")} -
-
- onChange(event.target.checked)} + /> + + {label} + ; +} + +export function ModelSelect(props: ModelSelectProps) { + const { label, options, disabled, mode } = props; + const root = useRef(null); + const trigger = useRef(null); + const menu = useRef(null); + const firstOption = useRef(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 <> +
+ + {mode === "multiple" && props.value.length > 0 && } +
+ {open && createPortal(
event.stopPropagation()} + onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); close(); } }} + > +
{t("暂无选项")}
} + >{(item, index) => item.kind === "group" + ? mode === "multiple" + ? toggleGroup(item.options, checked)} + /> + :
{item.label}
+ : }
+ {mode === "multiple" && options.length > 0 &&
+ + +
} +
, document.body)} + ; +} diff --git a/apps/desktop/src/shared/ui/MultiSelect.tsx b/apps/desktop/src/shared/ui/MultiSelect.tsx deleted file mode 100644 index 88bfc65..0000000 --- a/apps/desktop/src/shared/ui/MultiSelect.tsx +++ /dev/null @@ -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(null); - const trigger = useRef(null); - const menu = useRef(null); - const firstOption = useRef(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 <> -
- - {value.length > 0 && } -
- {open && createPortal(
event.stopPropagation()} - onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); close(); } }} - > -
{t("暂无选项")}
} - >{(option, index) => }
- {options.length > 0 &&
- - -
} -
, document.body)} - ; -} diff --git a/apps/desktop/src/shared/ui/Select.module.scss b/apps/desktop/src/shared/ui/Select.module.scss index 34b951d..c9629b6 100644 --- a/apps/desktop/src/shared/ui/Select.module.scss +++ b/apps/desktop/src/shared/ui/Select.module.scss @@ -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; -} diff --git a/apps/desktop/src/shared/ui/Select.tsx b/apps/desktop/src/shared/ui/Select.tsx index 899d3cb..623b817 100644 --- a/apps/desktop/src/shared/ui/Select.tsx +++ b/apps/desktop/src/shared/ui/Select.tsx @@ -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
{append}
; } -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(null); - const input = useRef(null); - const menu = useRef(null); - const listApi = useRef(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
-
- {value.length > 0 && {t("已选择 {count} 个", { count: value.length })}} - { 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(); } - }} /> -
- - {open && createPortal(
event.stopPropagation()} onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); setOpen(false); input.current?.focus(); } }}> - {filtered.length ? 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) => } - :
{t("输入模型名称后按 Enter 添加")}
} -
, document.body)} -
{append}
; -} diff --git a/apps/desktop/src/shared/ui/Switch.tsx b/apps/desktop/src/shared/ui/Switch.tsx index 440cee1..14e2b55 100644 --- a/apps/desktop/src/shared/ui/Switch.tsx +++ b/apps/desktop/src/shared/ui/Switch.tsx @@ -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, "aria-label" | "children" | "onChange" | "role">; + +export function Switch({ checked, disabled, label, onChange, onClick, ...props }: SwitchProps) { return ; } diff --git a/apps/desktop/src/shared/utils/modelProvider.ts b/apps/desktop/src/shared/utils/modelProvider.ts new file mode 100644 index 0000000..7dd6f5d --- /dev/null +++ b/apps/desktop/src/shared/utils/modelProvider.ts @@ -0,0 +1,17 @@ +import type { Model } from "../api"; + +export function modelProviderName(model: Pick): 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; + } + } +} diff --git a/apps/desktop/src/shell/AppLayout.module.scss b/apps/desktop/src/shell/AppLayout.module.scss index 9c4198b..d42d268 100644 --- a/apps/desktop/src/shell/AppLayout.module.scss +++ b/apps/desktop/src/shell/AppLayout.module.scss @@ -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; diff --git a/apps/desktop/src/shell/AppLayout.tsx b/apps/desktop/src/shell/AppLayout.tsx index 8487e6d..75f78ff 100644 --- a/apps/desktop/src/shell/AppLayout.tsx +++ b/apps/desktop/src/shell/AppLayout.tsx @@ -43,7 +43,7 @@ function loadStoredAdIds(key: string): Set { } 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() { ? : } {item.label} + {item.path === "/harness/cursor" && cursorHarness && + {cursorHarness.settings_applied ? t("已接管") : t("未接管")} + } {item.path === "/settings" && availableVersion &&